diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..865d0e2 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,41 @@ +module.exports = { + root: true, + env: { + browser: true, + es2022: true, + node: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + plugins: ['@typescript-eslint', 'react', 'react-hooks'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + ], + settings: { + react: { version: 'detect' }, + }, + ignorePatterns: ['dist', 'dist-electron', 'node_modules'], + rules: { + // Vite's automatic JSX runtime makes React imports unnecessary + 'react/react-in-jsx-scope': 'off', + // TypeScript types make prop-types redundant + 'react/prop-types': 'off', + // Downgraded: existing code uses `any` in a few places; flag without failing the build + '@typescript-eslint/no-explicit-any': 'warn', + // Downgraded: existing code has unused vars; `_`-prefix is the in-repo convention + // for intentionally unused bindings, so ignore those entirely + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + // Stylistic: literal quotes in JSX prose are fine in this app + 'react/no-unescaped-entities': 'off', + }, +}; diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4f46205..ed2558e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,5 +1,5 @@ name: Check file size -on: # or directly `on: [push]` to run the action on every push on any branch +on: pull_request: branches: [main] @@ -7,10 +7,10 @@ on: # or directly `on: [push]` to run the action on every push on workflow_dispatch: jobs: - sync-to-hub: + check-file-size: runs-on: ubuntu-latest steps: - name: Check large files uses: ActionsDesk/lfs-warning@v2.0 with: - filesizelimit: 10485760 # this is 10MB so we can sync to HF Spaces + filesizelimit: 10485760 # 10 MiB diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 6870680..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Sync to Hugging Face hub -on: - push: - branches: [main] - # to run this workflow manually from the Actions tab - workflow_dispatch: - -jobs: - sync-to-hub: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - lfs: true - - name: Push to hub - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - run: git push --force https://kartikmandar:$HF_TOKEN@huggingface.co/spaces/kartikmandar/StingrayExplorer main diff --git a/.github/workflows/windows-secure-export.yml b/.github/workflows/windows-secure-export.yml new file mode 100644 index 0000000..d40ce0c --- /dev/null +++ b/.github/workflows/windows-secure-export.yml @@ -0,0 +1,73 @@ +name: Windows secure export + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-secure-export-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + windows-secure-export: + name: Windows NTFS grants and publication + runs-on: windows-2025 + timeout-minutes: 30 + env: + STINGRAY_REQUIRE_WINDOWS_SECURE_EXPORT: "1" + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install pinned Pixi + uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 + with: + pixi-version: v0.74.0 + cache: true + environments: dev + + - name: Assert real Windows execution + shell: pwsh + run: pixi run -e dev python -c "import os; assert os.name == 'nt', os.name" + + - name: Run secure Windows filesystem tests + shell: pwsh + run: >- + pixi run -e dev pytest -q + python-backend/tests/test_windows_secure_publication.py + python-backend/tests/test_internal_grant_routes.py + python-backend/tests/test_backend_startup.py + + - name: Check Windows adapter formatting + shell: pwsh + run: >- + pixi run -e dev ruff format --check + python-backend/services/windows_secure_fs.py + python-backend/services/secure_publication.py + python-backend/services/utility_helpers.py + python-backend/routes/internal_grant_routes.py + python-backend/tests/test_windows_secure_publication.py + python-backend/tests/test_secure_publication.py + python-backend/tests/test_internal_grant_routes.py + python-backend/main.py + python-backend/tests/test_backend_startup.py + + - name: Lint Windows adapter + shell: pwsh + run: >- + pixi run -e dev ruff check + python-backend/services/windows_secure_fs.py + python-backend/services/secure_publication.py + python-backend/services/utility_helpers.py + python-backend/routes/internal_grant_routes.py + python-backend/tests/test_windows_secure_publication.py + python-backend/tests/test_secure_publication.py + python-backend/tests/test_internal_grant_routes.py + python-backend/main.py + python-backend/tests/test_backend_startup.py diff --git a/.gitignore b/.gitignore index a9eaad0..dca4d71 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /.DS_Store +.DS_Store +._* .idea/ __pycache__/ files/loaded-data/ @@ -13,3 +15,54 @@ files/data/SE1_*.evt.gz files/data/data_small*.hdf5 files/data/data_smaller*.hdf5 files/data/test.rmf + +# Large NICER event file (2.4 GB) - download from https://zenodo.org/record/6785435 +files/data/ni1200120106_0mpu7_cl_bary.evt.gz + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.pnpm-debug.log* + +# Electron +dist/ +dist-electron/ +out/ +*.asar +*.snap + +# Build output +*.tsbuildinfo +*.log + +# Python virtual environment +.venv/ +venv/ +*.pyc +*.pyo +*.egg-info/ +*.egg +.eggs/ + +# Environment files +.env +.env.local +.env.*.local + +# IDE +*.swp +*.swo +*~ + +# Test coverage +coverage/ +.nyc_output/ + +# Pixi +.pixi/ + +# AI reference documentation (local-only) +AI_DOCS/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 5d861bc..0000000 --- a/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -# Use the full Anaconda base image -FROM continuumio/anaconda3:latest - -WORKDIR /code - -# Create a new Conda environment with Python 3.11.9 -RUN conda create --name stingray_env python=3.11.9 -y - -# Activate the environment and upgrade pip -RUN conda run -n stingray_env pip install --no-cache-dir --upgrade pip - -# Install all dependencies using pip within the Conda environment -RUN conda run -n stingray_env pip install --no-cache-dir astropy \ - scipy \ - matplotlib \ - numpy \ - tqdm \ - numba \ - pint-pulsar \ - emcee \ - corner \ - statsmodels \ - stingray \ - panel \ - watchfiles \ - holoviews \ - hvplot \ - param \ - pandas \ - h5py \ - datashader \ - psutil - -# Set environment variables -ENV NUMBA_CACHE_DIR=/tmp/numba_cache - -# Copy the application code -COPY . . - -# Create the necessary directories with appropriate permissions -RUN mkdir -p /code/files/loaded-data && chmod -R 777 /code/files -RUN mkdir -p /tmp/numba_cache && chmod -R 777 /tmp/numba_cache - - -# Set the shell to activate the Conda environment by default -SHELL ["conda", "run", "-n", "stingray_env", "/bin/bash", "-c"] - -# Command to run the Panel app within the Conda environment -CMD ["conda", "run", "--no-capture-output", "-n", "stingray_env", "panel", "serve", "explorer.py", "--autoreload", "--static-dirs", "assets=./assets", "--address", "0.0.0.0", "--port", "7860", "--allow-websocket-origin", "*"] diff --git a/README.md b/README.md index 7d55fc4..9c22c9d 100644 --- a/README.md +++ b/README.md @@ -1,471 +1,93 @@ ---- -title: Stingray Explorer -emoji: 🚀 -colorFrom: gray -colorTo: green -sdk: docker -pinned: false -license: mit -thumbnail: >- - https://cdn-uploads.huggingface.co/production/uploads/668d17c6e6887d1f6afde2a6/4q5lnlS6-eJ_JBh8tW3_I.png -short_description: Stingray Explorer Dashboard Demo ---- +# Stingray Explorer -# StingrayExplorer +Stingray Explorer is a desktop application for X-ray timing analysis. It combines +an Electron and React interface with a loopback FastAPI service backed by +[Stingray](https://docs.stingray.science/), Astropy, and NumPy. -StingrayExplorer is a comprehensive data analysis and visualization dashboard designed for X-ray astronomy time series data. Built on top of the Stingray library, it provides an intuitive graphical interface for analyzing event lists, generating light curves, computing various types of spectra, and performing advanced timing analysis. +> [!IMPORTANT] +> The original Panel, public Docker, and Hugging Face Spaces runtime has been +> retired. It accepted browser-controlled local paths, arbitrary download URLs, +> and unsafe serialization formats that do not belong at a public web boundary. +> The files still under `modules/`, root `services/`, and `utils/` are migration +> reference code; they are not a supported application entrypoint. -## Overview +## Current application -StingrayExplorer combines the powerful timing analysis capabilities of the Stingray library with a modern, interactive dashboard built using Panel and HoloViz. It enables astronomers to: +The supported runtime is the Electron desktop app: -- Load and analyze event lists from various X-ray telescopes -- Generate and manipulate light curves -- Compute power spectra, cross spectra, and bispectra -- Analyze dynamical power spectra and power colors -- Visualize results through interactive plots -- Export analysis results in multiple formats +- React, TypeScript, Material UI, and Plotly provide the renderer. +- Electron owns native file dialogs and launches the Python service. +- FastAPI exposes the local scientific API on an ephemeral loopback port. +- Native selections are represented by short-lived grants instead of accepting + paths typed by renderer code. +- Stingray performs event-list, light-curve, spectral, timing, correlation, + variable-energy, and dead-time analyses. -The dashboard is designed to be user-friendly while providing access to advanced features for experienced users. +The desktop UI includes data ingestion, HEASARC archive browsing, quick-look +analysis pages, job progress, logs, and utility workflows for General I/O, GTIs, +mission I/O, statistics, and miscellaneous Stingray helpers. -## Key Features +## Development setup -### Data Loading and Management -- Support for multiple file formats (FITS, HDF5, ASCII, etc.) -- Batch loading of multiple event lists -- Automatic GTI (Good Time Interval) handling -- Energy calibration using RMF (Response Matrix File) -- File preview and metadata inspection +Install [Pixi](https://pixi.sh/) and a Node version supported by the locked +frontend toolchain, then run: -### Event List Analysis -- Event list creation and simulation -- Deadtime correction -- Energy filtering and PI channel conversion -- Event list joining and sorting -- Color and intensity evolution analysis - -### Spectral Analysis -- Power spectrum computation -- Cross spectrum analysis -- Averaged power/cross spectra -- Bispectrum calculation -- Dynamical power spectrum visualization -- Power color analysis - -### Interactive Visualization -- Real-time plot updates -- Customizable plot layouts -- Floating plot panels -- Interactive plot manipulation -- Multiple visualization options - -### System Features -- Resource monitoring (CPU, RAM usage) -- Warning and error handling -- Comprehensive help documentation -- Responsive layout design - -## Architecture - -The project follows a modular architecture with clear separation of concerns: - -### Core Components - -1. **explorer.py**: Main entry point and dashboard initialization - - Panel/HoloViz setup - - Layout configuration - - Component integration - -2. **modules/**: Core functionality modules - - **DataLoading/**: Data ingestion and management - - **Home/**: Dashboard home page and navigation - - **QuickLook/**: Analysis tools and visualizations - - EventList handling - - Light curve generation - - Spectral analysis - - Power color computation - -3. **utils/**: Utility classes and functions - - **DashboardClasses.py**: Reusable UI components - - **sidebar.py**: Navigation and control - - **globals.py**: Global state management - - **strings.py**: Text content - -4. **assets/**: Static resources - - Images and icons - - CSS stylesheets - - Documentation assets - -5. **files/**: Data storage - - Sample data files - - User-loaded data - - Analysis outputs - -### Technology Stack - -- **Backend**: Python 3.11+ -- **Frontend**: Panel, HoloViz -- **Data Analysis**: Stingray, NumPy, Astropy -- **Visualization**: Bokeh, Matplotlib -- **Deployment**: Docker, Hugging Face Spaces - -## Installation Guide - -### Prerequisites - -- Python 3.11 or above -- Conda package manager -- Git (for cloning the repository) - -### Dependencies - -Core packages: -- Panel >= 1.3.0 -- HoloViews >= 1.18.0 -- Stingray >= 0.3 -- NumPy >= 1.24.0 -- Astropy >= 5.0 -- Matplotlib >= 3.7.0 -- Bokeh >= 3.3.0 - -### Setup Instructions - -1. Clone the repository: - ```bash - git clone https://github.com/kartikmandar-GSOC24/StingrayExplorer.git - cd StingrayExplorer - ``` - -2. Create and activate the conda environment: - ```bash - conda env create -f environment.yml - conda activate stingray-env - ``` - -3. Verify installation: - ```bash - python -c "import stingray; import panel; import holoviews" - ``` - -### Troubleshooting Dependencies - -If you encounter dependency conflicts: - -1. Check individual package versions: - ```bash - conda list stingray - conda list panel - conda list holoviews - ``` - -2. Try installing missing dependencies: - ```bash - conda install -c conda-forge - # or - pip install - ``` - -3. Common issues: - - Stingray version compatibility - - Panel/HoloViews version mismatch - - Missing system libraries - -4. Support channels: - - Email: kartik4321mandar@gmail.com - - Stingray Slack: @kartikmandar - - GitHub Issues - -## Deployment Options - -### Local Development Server - -Run the application locally: ```bash -panel serve explorer.py --autoreload --static-dirs assets=./assets -``` - -This starts a development server with: -- Auto-reloading on file changes -- Static file serving -- Debug information -- Default port 5006 - -### Docker Deployment - -1. Build the image: - ```bash - docker build -t stingray-explorer . - ``` - -2. Run the container: - ```bash - docker run -p 7860:7860 stingray-explorer - ``` - -3. Access the application at `http://localhost:7860` - -### Hugging Face Spaces - -The dashboard is deployed on Hugging Face Spaces: -- Live demo: [https://kartikmandar-stingrayexplorer.hf.space/explorer](https://kartikmandar-stingrayexplorer.hf.space/explorer) -- Repository: [https://huggingface.co/spaces/kartikmandar/StingrayExplorer](https://huggingface.co/spaces/kartikmandar/StingrayExplorer) -- Website demo: [https://www.kartikmandar.com/gsoc-2024/stingray-explorer](https://www.kartikmandar.com/gsoc-2024/stingray-explorer) - -### Continuous Integration - -GitHub Actions automatically sync changes to Hugging Face Spaces: -- Triggers on pushes to `main` branch -- Builds and deploys Docker image -- Updates Hugging Face Space - -## Usage Guide - -### Quick Start - -1. Launch the application: - ```bash - panel serve explorer.py --autoreload --static-dirs assets=./assets - ``` - -2. Navigate to `http://localhost:5006` in your browser - -3. Basic workflow: - - Use the sidebar navigation - - Load data files - - Generate visualizations - - Export results - -### Data Loading - -1. Click "Read Data" in the sidebar -2. Choose from multiple options: - - Load local files - - Fetch from URL - - Use sample data - -Supported formats: -- FITS event files -- HDF5 files -- ASCII tables -- ECSV files - -#### Sample Data Files - -The repository includes small sample data files (< 1MB total) in `files/data/` for basic testing: -- Small event lists (.evt files) -- Example light curves (.fits files) - -**Note**: Large sample files (HDF5, RMF > 10MB) are not included in the repository to keep the codebase lightweight and deployable on free-tier hosting platforms like Hugging Face Spaces. - -For full-scale analysis: -- Upload your own data files using the "Read Data" feature -- Load data directly from URLs -- Download X-ray astronomy datasets from archives like [HEASARC](https://heasarc.gsfc.nasa.gov/) - -### Analysis Tools - -1. **Event List Operations** - - Create/simulate event lists - - Apply deadtime corrections - - Filter by energy range - - Convert PI to energy - -2. **Light Curve Analysis** - - Generate light curves - - Apply GTI filters - - Compute statistics - - Plot time series - -3. **Spectral Analysis** - - Compute power spectra - - Generate cross spectra - - Calculate bispectra - - Analyze power colors - -4. **Advanced Features** - - Dynamical power spectra - - Color evolution - - Intensity analysis - - Custom plotting - -### Visualization Options - -1. **Plot Types** - - Time series - - Spectral plots - - Contour plots - - Scatter plots - -2. **Interactive Features** - - Zoom/pan - - Hover tooltips - - Plot customization - - Export options - -3. **Layout Options** - - Floating panels - - Grid arrangements - - Multiple views - - Responsive design - -### Data Export - -- Save plots as PNG/SVG -- Export data as CSV/FITS -- Save analysis results -- Generate reports - -## Development Guide - -### Setting Up Development Environment - -1. Fork and clone the repository -2. Create development environment: - ```bash - conda env create -f environment.yml - conda activate stingray-env - ``` -3. Install development dependencies: - ```bash - pip install -r docs/requirements.txt - ``` - -### Project Structure - -``` -stingray-explorer/ -├── explorer.py # Main application entry point -├── modules/ # Core functionality modules -│ ├── DataLoading/ # Data ingestion components -│ ├── Home/ # Dashboard home components -│ └── QuickLook/ # Analysis tools -├── utils/ # Utility functions and classes -├── assets/ # Static resources -├── files/ # Data files -└── tests/ # Test suite +pixi install +npm install +npm run dev ``` -### Development Workflow - -1. Create feature branch: - ```bash - git checkout -b feature/new-feature - ``` +Electron starts and authenticates the Python service automatically. Running the +FastAPI service as an unrelated external process is intentionally unsupported; +it cannot share Electron's per-launch credentials or native file grants. -2. Make changes and test: - ```bash - # Run tests - pytest tests/ - - # Start development server - panel serve explorer.py --autoreload - ``` +Useful commands: -3. Submit pull request: - - Fork repository - - Push changes - - Create PR with description - -### Coding Standards - -- Follow PEP 8 style guide -- Add docstrings (NumPy format) -- Write unit tests -- Update documentation - -### Testing - -Run test suite: -```bash -pytest tests/ -``` - -Test coverage: ```bash -pytest --cov=./ tests/ +# Backend tests in the development environment +pixi run -e dev pytest python-backend/tests + +# Frontend tests, type checking, linting, and production build +npm test -- --run +npm run typecheck +npm run lint +npm run build ``` -## Troubleshooting Guide - -### Common Issues - -1. **Installation Problems** - - Dependency conflicts - - Python version mismatch - - Missing system libraries - - Solution: Check versions, use conda-forge channel - -2. **Import Errors** - - Missing packages - - Version incompatibilities - - Path issues - - Solution: Verify environment, check imports +Additional scripts and packaging targets are listed in `package.json` and +`pixi.toml`. -3. **Runtime Errors** - - Memory issues - - Performance problems - - Display errors +## Repository layout - Solution: Monitor resources, check logs - -4. **Data Loading Issues** - - File format problems - - Permission errors - - Corrupt files - - Solution: Verify file integrity, check formats - -### Performance Optimization - -1. **Memory Management** - - Use chunked loading - - Clear unused data - - Monitor memory usage - -2. **Speed Improvements** - - Enable caching - - Optimize computations - - Use efficient algorithms - -3. **Display Performance** - - Limit plot sizes - - Use appropriate renderers - - Optimize updates - -### Getting Help - -1. **Documentation** - - Read the docs - - Check examples - - Review tutorials - -2. **Support Channels** - - GitHub Issues - - Email support - - Slack channel - -3. **Debugging** - - Check logs - - Use debugger - - Print statements +```text +electron/ Electron main process, preload bridge, and backend lifecycle +python-backend/ Authenticated FastAPI routes, services, models, and tests +src/ React renderer, API clients, state, pages, and component tests +files/ Small sample data used for development +resources/ Desktop application icons and packaging resources +docs/ Implementation plans and engineering notes +``` -## License and Credits +The historical Panel implementation remains in the legacy root Python folders +while migration work is completed. It has no executable `explorer.py`, Docker +image, or deployment workflow. -### License +## Data and exports -This project is licensed under the MIT License. See [LICENSE](LICENSE) file for details. +Use the desktop application's native open and save dialogs for local files. +General I/O is the maintained export path; the duplicate raw-path `/api/export/*` +API has been retired. User-visible outputs are expected to use explicit formats, +refuse unintended overwrite, and pass format-specific verification before +publication. -### Credits +Large scientific files are intentionally not tracked. The small examples under +`files/data/` are suitable for local development; use your own mission data for +full-scale analysis. -- **Stingray Library**: Core timing analysis functionality -- **Panel/HoloViz**: Interactive visualization framework -- **Contributors**: See [GitHub contributors page](https://github.com/kartikmandar-GSOC24/StingrayExplorer/graphs/contributors) +## License -### Acknowledgments +This project is licensed under the MIT License. See [LICENSE](LICENSE). -- The Stingray development team -- HoloViz community -- X-ray astronomy community -- Google Summer of Code program +Stingray Explorer builds on the work of the Stingray, Astropy, HoloViz, and +broader X-ray astronomy communities. diff --git a/docs/superpowers/plans/2026-06-10-quicklook-core-pages.md b/docs/superpowers/plans/2026-06-10-quicklook-core-pages.md new file mode 100644 index 0000000..787e373 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-quicklook-core-pages.md @@ -0,0 +1,4167 @@ +# QuickLook Core Pages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the eleven QuickLook placeholder pages (EventList, LightCurve, PowerSpectrum, AvgPowerSpectrum, CrossSpectrum, AvgCrossSpectrum, DynamicalPowerSpectrum, Bispectrum, Coherence, TimeLags, PowerColors) with working analysis UIs wired to the existing FastAPI endpoints, fixing the four backend correctness bugs that block them. + +**Architecture:** Each page is a self-contained React component: a parameters card (left) driving one POST to the backend, and a Plotly result card (right). Shared machinery is built once — a theme-aware `PlotlyChart`, an `EventListSelector` fed by a TanStack Query hook, and a `useAnalysisRunner` hook that owns the request lifecycle and notifications. Analysis results live in backend memory (StateManager); pages display the create-response payload directly. Backend fixes land first (TDD with pytest) because three endpoints currently return unserializable or scientifically wrong data. + +**Tech Stack:** React 18 + TypeScript + MUI v5 + TanStack Query v5 + Zustand + react-plotly.js (lazy-loaded), FastAPI + Stingray 2.2.10, vitest + @testing-library/react (frontend), pytest via pixi `dev` env (backend). + +**Testing strategy (read before executing):** Backend fixes and all shared frontend logic (hooks, helpers, selector, chart wrapper) are TDD'd. The EventList and LightCurve pages get component tests establishing the display-page and runner-page patterns. The remaining nine pages are declarative wiring of already-tested pieces; their gates are `npm run typecheck`, `npm run lint`, and a manual verification step each — jsdom tests for Plotly-heavy pages would test mocks, not behavior. + +**Git note (user preference):** Commits use conventional format, **no Claude co-authorship**. The user requires confirmation before git commands — at execution start, ask the user for blanket approval of the commit steps in this plan, or pause at each commit step. + +--- + +## Context for an engineer with zero prior knowledge + +Repo root: `/Volumes/Mac Projects/StingrayExplorer`, branch `electron-migration`. + +- Run the app: `npm run dev` (Electron spawns the Python backend itself from `.pixi/envs/default/bin/python`). First cold start can take ~60 s. +- The renderer talks to FastAPI at `http://127.0.0.1:` via `apiClient` ([src/api/client.ts](../../../src/api/client.ts)). Every response has shape `ApiResponse = { success, data, message, error }`. **A failed analysis still returns HTTP 200 with `success: false`** — always branch on `res.success`. +- Frontend API modules already exist and match the backend: `src/api/dataApi.ts`, `lightcurveApi.ts`, `spectrumApi.ts`, `timingApi.ts`. +- Notifications: `useUIStore.getState().addNotification({ type, title, message })` where `type: 'info' | 'success' | 'warning' | 'error'` (`src/store/uiStore.ts:81`). +- Pages live at `src/pages/QuickLook//index.tsx`, currently rendering `PageTemplate` with `status="coming-soon"`. `PageTemplate` (`src/components/common/PageTemplate.tsx`) accepts `status="ready"` and `children`. +- Routes: `src/App.tsx:648-665` (hash router). Sidebar nav: `src/components/layout/Sidebar.tsx:79-98` (submenu items) and `:157-181` (category groupings). **There is no route/page/nav entry yet for Time Lags or Power Colors — Tasks 20-21 add them.** +- Path alias `@/` → `src/` (configured in `electron.vite.config.ts`; vitest config in Task 1 must mirror it). +- Backend services return plain dicts via `BaseService.create_result(success, data, message, error)`. Services are constructed per-request: `LightcurveService(state_manager=request.app.state.state_manager, performance_monitor=...)` — the second arg is optional. +- Python style: 4-space indent. TS style: 2-space indent. + +**Known backend bugs this plan fixes (verified by reading code):** +1. `spectrum_service.py:203,286` — `cs.power.tolist()` on a **complex** array (Stingray cross spectra) → not JSON-serializable; both cross-spectrum endpoints fail. +2. `timing_service.py:305` — coherence computed as `np.abs(cs.unnorm_power)**2` (just |cross power|², values ≫ 1). Must use Stingray's `cs.coherence()`. +3. `timing_service.py:217` — time lags hand-rolled as `np.angle(unnorm_power)/(2πf)`, no uncertainties. Must use `cs.time_lag()`. +4. `lightcurve_routes.py` / `spectrum_routes.py` / `timing_routes.py` — `async def` handlers call synchronous services directly, **blocking the event loop** during computation (freezes SSE job/log streams). `data_routes.py` already shows the fix pattern: `await asyncio.to_thread(...)`. +5. Light curves return full `time`/`counts` arrays — a NICER file at dt=1 ms is tens of millions of bins → renderer death. Add server-side stride decimation for plotting. + +--- + +## File structure + +**Created:** +- `vitest.config.ts`, `src/test/setup.ts`, `src/test/testUtils.tsx` +- `python-backend/tests/__init__.py`, `conftest.py`, `test_spectrum_service.py`, `test_timing_service.py`, `test_lightcurve_service.py`, `test_route_concurrency.py` +- `src/components/plots/PlotlyChart.tsx` (+ test) +- `src/components/analysis/EventListSelector.tsx` (+ test) +- `src/hooks/useEventLists.ts` (+ test), `src/hooks/useAnalysisRunner.ts` (+ test) +- `src/utils/numbers.ts`, `src/utils/powerColors.ts` (+ tests) +- `src/pages/QuickLook/TimeLags/index.tsx`, `src/pages/QuickLook/PowerColors/index.tsx` +- Page tests: `src/pages/QuickLook/EventList/index.test.tsx`, `src/pages/QuickLook/LightCurve/index.test.tsx` + +**Modified:** +- `python-backend/services/spectrum_service.py`, `timing_service.py`, `lightcurve_service.py` +- `python-backend/routes/lightcurve_routes.py`, `spectrum_routes.py`, `timing_routes.py` +- `src/api/lightcurveApi.ts`, `spectrumApi.ts`, `timingApi.ts` +- `src/pages/QuickLook/{EventList,LightCurve,PowerSpectrum,AvgPowerSpectrum,CrossSpectrum,AvgCrossSpectrum,DynamicalPowerSpectrum,Bispectrum,Coherence}/index.tsx` +- `src/App.tsx` (2 imports + 2 routes), `src/components/layout/Sidebar.tsx` (2 nav items + 2 category entries) +- `package.json` (test devDeps) + +--- + +### Task 1: Frontend test infrastructure + +**Files:** +- Create: `vitest.config.ts`, `src/test/setup.ts`, `src/test/testUtils.tsx`, `src/utils/numbers.ts`, `src/utils/numbers.test.ts` +- Modify: `package.json` (devDependencies via npm) + +- [x] **Step 1: Install test dependencies** + +Run: `npm install --save-dev jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event` +Expected: exits 0, package.json devDependencies updated. + +- [x] **Step 2: Create vitest config and setup** + +`vitest.config.ts`: +```ts +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + }, +}); +``` + +`src/test/setup.ts`: +```ts +import '@testing-library/jest-dom/vitest'; + +// MUI useMediaQuery requires matchMedia, absent in jsdom +if (!window.matchMedia) { + window.matchMedia = (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }) as MediaQueryList; +} +``` + +`src/test/testUtils.tsx`: +```tsx +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +export function renderWithProviders(ui: React.ReactElement): RenderResult { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0 } }, + }); + return render( + + {ui} + + ); +} +``` + +- [x] **Step 3: Write a first real test (number parsing helpers used by every page)** + +`src/utils/numbers.test.ts`: +```ts +import { describe, expect, it } from 'vitest'; +import { parseNumber, parsePositiveNumber } from './numbers'; + +describe('parsePositiveNumber', () => { + it('parses valid positive numbers', () => { + expect(parsePositiveNumber('0.0625')).toBe(0.0625); + expect(parsePositiveNumber('32')).toBe(32); + }); + + it('rejects zero, negatives, and junk', () => { + expect(parsePositiveNumber('0')).toBeNull(); + expect(parsePositiveNumber('-1')).toBeNull(); + expect(parsePositiveNumber('abc')).toBeNull(); + expect(parsePositiveNumber('')).toBeNull(); + }); +}); + +describe('parseNumber', () => { + it('parses any finite number', () => { + expect(parseNumber('-2.5')).toBe(-2.5); + expect(parseNumber('0')).toBe(0); + }); + + it('rejects non-numeric input', () => { + expect(parseNumber('1e999')).toBeNull(); + expect(parseNumber('x')).toBeNull(); + expect(parseNumber('')).toBeNull(); + }); +}); +``` + +- [x] **Step 4: Run to verify it fails** + +Run: `npm test -- --run` +Expected: FAIL — `Cannot find module './numbers'` (or equivalent resolve error). + +- [x] **Step 5: Implement the helpers** + +`src/utils/numbers.ts`: +```ts +/** Parse a text-field value into a finite positive number, or null if invalid. */ +export function parsePositiveNumber(value: string): number | null { + if (value.trim() === '') return null; + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : null; +} + +/** Parse a text-field value into any finite number, or null if invalid. */ +export function parseNumber(value: string): number | null { + if (value.trim() === '') return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} +``` + +- [x] **Step 6: Run to verify it passes** + +Run: `npm test -- --run` +Expected: PASS (4 tests). + +- [x] **Step 7: Commit** + +```bash +git add vitest.config.ts src/test/ src/utils/numbers.ts src/utils/numbers.test.ts package.json package-lock.json +git commit -m "chore: add vitest + testing-library infrastructure" +``` + +--- + +### Task 2: Python test infrastructure + +**Files:** +- Create: `python-backend/tests/__init__.py`, `python-backend/tests/conftest.py`, `python-backend/tests/test_smoke.py` + +- [x] **Step 1: Ensure the pixi dev environment exists** + +Run: `pixi install -e dev` +Expected: exits 0 (installs pytest, pytest-asyncio into `.pixi/envs/dev`). May take a few minutes the first time. + +- [x] **Step 2: Create the test package and fixtures** + +`python-backend/tests/__init__.py`: empty file. + +`python-backend/tests/conftest.py`: +```python +"""Shared fixtures for backend service tests. + +python-backend is not an installable package (hyphenated dir name), so tests +add it to sys.path and import the same way main.py does (cwd=python-backend). +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import numpy as np +import pytest +from stingray import EventList + +from services.state_manager import StateManager + + +def make_event_list(seed: int, n_events: int = 20000, length: float = 64.0) -> EventList: + """Deterministic synthetic event list spanning [0, length] seconds.""" + rng = np.random.default_rng(seed) + times = np.sort(rng.uniform(0.0, length, n_events)) + energy = rng.uniform(0.5, 10.0, n_events) + return EventList(time=times, energy=energy, gti=[[0.0, length]]) + + +@pytest.fixture() +def state_manager() -> StateManager: + return StateManager() + + +@pytest.fixture() +def loaded_state(state_manager: StateManager) -> StateManager: + state_manager.add_event_data("ev1", make_event_list(1)) + state_manager.add_event_data("ev2", make_event_list(2)) + return state_manager +``` + +`python-backend/tests/test_smoke.py`: +```python +def test_services_import_and_state_works(loaded_state): + assert loaded_state.has_event_data("ev1") + assert loaded_state.has_event_data("ev2") + assert len(loaded_state.get_event_data("ev1").time) == 20000 +``` + +- [x] **Step 3: Run the smoke test** + +Run: `pixi run -e dev pytest python-backend/tests -v` +Expected: PASS (1 test). If `add_event_data` has a different name, check `python-backend/services/state_manager.py:48` — it is `add_event_data(name, event_list)`. + +- [x] **Step 4: Commit** + +```bash +git add python-backend/tests/ +git commit -m "chore: add pytest scaffolding for python backend" +``` + +--- + +### Task 3: Fix cross-spectrum complex power serialization (backend) + +**Files:** +- Create: `python-backend/tests/test_spectrum_service.py` +- Modify: `python-backend/services/spectrum_service.py` + +- [x] **Step 1: Write the failing tests** + +`python-backend/tests/test_spectrum_service.py`: +```python +import json + +from services.spectrum_service import SpectrumService + + +def test_cross_spectrum_is_strict_json_serializable(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_cross_spectrum("ev1", "ev2", dt=0.0625) + assert result["success"], result + json.dumps(result, allow_nan=False) # complex or NaN values raise here + data = result["data"] + assert all(isinstance(p, float) for p in data["power"][:10]) + assert data["power_phase"] is not None + assert len(data["power_phase"]) == len(data["power"]) + + +def test_averaged_cross_spectrum_is_strict_json_serializable(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_averaged_cross_spectrum( + "ev1", "ev2", dt=0.0625, segment_size=8.0 + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + assert result["data"]["power_phase"] is not None + + +def test_power_spectrum_has_null_phase(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_power_spectrum("ev1", dt=0.0625) + assert result["success"], result + json.dumps(result, allow_nan=False) + assert result["data"]["power_phase"] is None + + +def test_rebin_of_stored_cross_spectrum_serializes(loaded_state): + svc = SpectrumService(loaded_state) + created = svc.create_cross_spectrum("ev1", "ev2", dt=0.0625, output_name="cs1") + assert created["success"], created + rebinned = svc.rebin_spectrum("cs1", rebin_factor=0.1, log=True) + assert rebinned["success"], rebinned + json.dumps(rebinned, allow_nan=False) +``` + +- [x] **Step 2: Run to verify they fail** + +Run: `pixi run -e dev pytest python-backend/tests/test_spectrum_service.py -v` +Expected: FAIL — `TypeError: Object of type complex is not JSON serializable` (cross-spectrum tests) and `KeyError: 'power_phase'` (power-spectrum test). + +- [x] **Step 3: Implement the fix** + +In `python-backend/services/spectrum_service.py`, add after the imports (below line 18): + +```python +def _power_to_lists(power) -> tuple: + """Split a (possibly complex) power array into JSON-safe magnitude and phase lists. + + Returns (power_list, phase_list_or_None). Non-finite values become None so + strict JSON (and JS JSON.parse) never sees NaN/Infinity. + """ + arr = np.asarray(power) + if np.iscomplexobj(arr): + mag = np.abs(arr) + phase = np.angle(arr) + return _finite_list(mag), _finite_list(phase) + return _finite_list(arr.astype(float)), None + + +def _finite_list(arr) -> list: + """Convert a float array to a list, replacing non-finite values with None.""" + values = np.asarray(arr, dtype=float) + return [float(v) if np.isfinite(v) else None for v in values] +``` + +Replace the four response dict constructions: + +1. `create_power_spectrum` (lines 64-72) — replace with: +```python + power_list, phase_list = _power_to_lists(ps.power) + ps_data = { + "name": output_name, + "freq": ps.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "norm": norm, + "n_freq": len(ps.freq), + "df": float(ps.df), + "freq_range": [float(ps.freq[0]), float(ps.freq[-1])], + } +``` + +2. `create_averaged_power_spectrum` (lines 122-131) — replace with: +```python + power_list, phase_list = _power_to_lists(ps.power) + ps_data = { + "name": output_name, + "freq": ps.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "norm": norm, + "n_freq": len(ps.freq), + "df": float(ps.df), + "segment_size": segment_size, + "n_segments": int(ps.m) if hasattr(ps, "m") else None, + } +``` + +3. `create_cross_spectrum` (lines 200-207) — replace with: +```python + power_list, phase_list = _power_to_lists(cs.power) + cs_data = { + "name": output_name, + "freq": cs.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "norm": norm, + "n_freq": len(cs.freq), + "df": float(cs.df), + } +``` + +4. `create_averaged_cross_spectrum` (lines 281-289) — replace with: +```python + power_list, phase_list = _power_to_lists(cs.power) + cs_data = { + "name": output_name, + "freq": cs.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "norm": norm, + "n_freq": len(cs.freq), + "df": float(cs.df), + "segment_size": segment_size, + } +``` + +5. `rebin_spectrum` (lines 409-414) — replace with: +```python + power_list, phase_list = _power_to_lists(rebinned.power) + data = { + "name": output_name, + "freq": rebinned.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "n_freq": len(rebinned.freq), + } +``` + +- [x] **Step 4: Run to verify they pass** + +Run: `pixi run -e dev pytest python-backend/tests/test_spectrum_service.py -v` +Expected: PASS (4 tests). + +- [x] **Step 5: Commit** + +```bash +git add python-backend/services/spectrum_service.py python-backend/tests/test_spectrum_service.py +git commit -m "fix: serialize complex cross-spectrum power as magnitude and phase" +``` + +--- + +### Task 4: Fix coherence and time lags (backend) + +**Files:** +- Create: `python-backend/tests/test_timing_service.py` +- Modify: `python-backend/services/timing_service.py` + +- [x] **Step 1: Write the failing tests** + +`python-backend/tests/test_timing_service.py`: +```python +import json + +import numpy as np + +from services.timing_service import TimingService + + +def test_coherence_of_identical_signals_is_one(loaded_state): + svc = TimingService(loaded_state) + # An event list crossed with itself has coherence == 1 at all frequencies. + result = svc.calculate_coherence("ev1", "ev1", dt=0.0625, segment_size=8.0) + assert result["success"], result + json.dumps(result, allow_nan=False) + coh = np.asarray(result["data"]["coherence"], dtype=float) + assert np.all(coh <= 1.0 + 1e-6) + assert np.median(coh) > 0.9 + + +def test_coherence_includes_uncertainty(loaded_state): + svc = TimingService(loaded_state) + result = svc.calculate_coherence("ev1", "ev2", dt=0.0625, segment_size=8.0) + assert result["success"], result + data = result["data"] + assert "coherence_err" in data + if data["coherence_err"] is not None: + assert len(data["coherence_err"]) == len(data["coherence"]) + + +def test_time_lags_include_errors_and_serialize(loaded_state): + svc = TimingService(loaded_state) + result = svc.calculate_time_lags("ev1", "ev2", dt=0.0625, segment_size=8.0) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert "time_lags_err" in data + assert len(data["freq"]) == len(data["time_lags"]) + if data["time_lags_err"] is not None: + assert len(data["time_lags_err"]) == len(data["time_lags"]) + + +def test_time_lags_freq_range_filters_all_arrays(loaded_state): + svc = TimingService(loaded_state) + full = svc.calculate_time_lags("ev1", "ev2", dt=0.0625, segment_size=8.0) + sub = svc.calculate_time_lags( + "ev1", "ev2", dt=0.0625, segment_size=8.0, freq_range=(0.5, 2.0) + ) + assert sub["success"], sub + freqs = np.asarray(sub["data"]["freq"], dtype=float) + assert freqs.min() >= 0.5 + assert freqs.max() <= 2.0 + assert len(sub["data"]["freq"]) < len(full["data"]["freq"]) + assert len(sub["data"]["time_lags"]) == len(sub["data"]["freq"]) +``` + +- [x] **Step 2: Run to verify they fail** + +Run: `pixi run -e dev pytest python-backend/tests/test_timing_service.py -v` +Expected: FAIL — coherence values ≫ 1 (first test), `KeyError`/missing `coherence_err` and `time_lags_err`. + +- [x] **Step 3: Implement the fix** + +In `python-backend/services/timing_service.py`: + +Add after the imports (below line 12): +```python +def _finite_list(arr) -> list: + """Convert a float array to a list, replacing non-finite values with None.""" + values = np.asarray(arr, dtype=float) + return [float(v) if np.isfinite(v) else None for v in values] +``` + +In `calculate_time_lags`, replace lines 215-230 (from `# Calculate time lags` through the `result_data = {...}` block) with: +```python + # Stingray's time_lag() returns (lag, lag_err) for averaged spectra. + lag_result = cs.time_lag() + if isinstance(lag_result, tuple): + time_lags, time_lags_err = lag_result + else: + time_lags, time_lags_err = lag_result, None + + freq = np.asarray(cs.freq, dtype=float) + time_lags = np.real(np.asarray(time_lags)) + if time_lags_err is not None: + time_lags_err = np.real(np.asarray(time_lags_err)) + + if freq_range: + mask = (freq >= freq_range[0]) & (freq <= freq_range[1]) + freq = freq[mask] + time_lags = time_lags[mask] + if time_lags_err is not None: + time_lags_err = time_lags_err[mask] + + result_data = { + "name": output_name, + "freq": freq.tolist(), + "time_lags": _finite_list(time_lags), + "time_lags_err": _finite_list(time_lags_err) if time_lags_err is not None else None, + "freq_range": freq_range, + } +``` + +In `calculate_coherence`, replace lines 304-311 (from `# Calculate coherence` through the `result_data = {...}` block) with: +```python + # Stingray's coherence() returns (coherence, uncertainty) for + # averaged cross spectra (Vaughan & Nowak 1997). + coh_result = cs.coherence() + if isinstance(coh_result, tuple): + coherence_vals, coherence_err = coh_result + else: + coherence_vals, coherence_err = coh_result, None + + coherence_vals = np.real(np.asarray(coherence_vals)) + result_data = { + "name": output_name, + "freq": cs.freq.tolist(), + "coherence": _finite_list(coherence_vals), + "coherence_err": _finite_list(np.real(np.asarray(coherence_err))) + if coherence_err is not None + else None, + "segment_size": segment_size, + "n_segments": int(cs.m) if hasattr(cs, "m") else None, + } +``` + +- [x] **Step 4: Run to verify they pass** + +Run: `pixi run -e dev pytest python-backend/tests/test_timing_service.py -v` +Expected: PASS (4 tests). Also run the full suite: `pixi run -e dev pytest python-backend/tests -v` — all green. + +- [x] **Step 5: Commit** + +```bash +git add python-backend/services/timing_service.py python-backend/tests/test_timing_service.py +git commit -m "fix: use stingray coherence() and time_lag() with uncertainties" +``` + +--- + +### Task 5: Light-curve plot decimation (backend) + +**Files:** +- Create: `python-backend/tests/test_lightcurve_service.py` +- Modify: `python-backend/services/lightcurve_service.py`, `python-backend/routes/lightcurve_routes.py` + +- [x] **Step 1: Write the failing tests** + +`python-backend/tests/test_lightcurve_service.py`: +```python +import json + +from services.lightcurve_service import LightcurveService + + +def test_decimation_caps_returned_points(loaded_state): + svc = LightcurveService(loaded_state) + # 64 s span at dt=0.001 -> 64000 bins; cap at 5000 plot points. + result = svc.create_lightcurve_from_event_list( + "ev1", dt=0.001, output_name="lc_fine", max_points=5000 + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert data["n_bins"] == 64000 # true resolution is reported + assert len(data["time"]) <= 5000 # transferred arrays are capped + assert data["plot_stride"] == 13 # ceil(64000 / 5000) + assert len(data["time"]) == len(data["counts"]) + + +def test_no_decimation_below_cap(loaded_state): + svc = LightcurveService(loaded_state) + result = svc.create_lightcurve_from_event_list("ev1", dt=1.0, output_name="lc_coarse") + assert result["success"], result + data = result["data"] + assert data["plot_stride"] == 1 + assert len(data["time"]) == data["n_bins"] + + +def test_get_lightcurve_data_decimates(loaded_state): + svc = LightcurveService(loaded_state) + svc.create_lightcurve_from_event_list("ev1", dt=0.001, output_name="lc_fine2") + result = svc.get_lightcurve_data("lc_fine2", max_points=1000) + assert result["success"], result + assert len(result["data"]["time"]) <= 1000 + assert result["data"]["plot_stride"] == 64 +``` + +- [x] **Step 2: Run to verify they fail** + +Run: `pixi run -e dev pytest python-backend/tests/test_lightcurve_service.py -v` +Expected: FAIL — `TypeError: ... unexpected keyword argument 'max_points'`. + +- [x] **Step 3: Implement decimation in the service** + +In `python-backend/services/lightcurve_service.py`: + +Add after the imports (below line 12): +```python +# Cap on points transferred for plotting. The full-resolution Lightcurve stays +# in StateManager; only the JSON payload is strided. +DEFAULT_MAX_PLOT_POINTS = 200_000 + + +def _decimate_for_plot(time, counts, max_points): + """Stride-decimate arrays for display. Returns (time, counts, stride).""" + n = len(time) + if not max_points or n <= max_points: + return time, counts, 1 + stride = int(np.ceil(n / max_points)) + return time[::stride], counts[::stride], stride +``` + +Change `create_lightcurve_from_event_list` signature (line 22-28) to: +```python + def create_lightcurve_from_event_list( + self, + event_list_name: str, + dt: float, + output_name: str, + gti: Optional[List[List[float]]] = None, + max_points: Optional[int] = DEFAULT_MAX_PLOT_POINTS, + ) -> Dict[str, Any]: +``` +and replace its `lc_data = {...}` block (lines 64-72) with: +```python + plot_time, plot_counts, stride = _decimate_for_plot(lc.time, lc.counts, max_points) + lc_data = { + "name": output_name, + "time": plot_time.tolist(), + "counts": plot_counts.tolist(), + "dt": float(lc.dt), + "n_bins": len(lc.time), + "plot_stride": stride, + "time_range": [float(lc.time.min()), float(lc.time.max())], + "count_rate_mean": float(np.mean(lc.counts / lc.dt)), + } +``` + +Change `rebin_lightcurve` signature (lines 130-135) to add `max_points: Optional[int] = DEFAULT_MAX_PLOT_POINTS,` after `output_name: str,` and replace its `lc_data = {...}` block (lines 162-168) with: +```python + plot_time, plot_counts, stride = _decimate_for_plot( + rebinned_lc.time, rebinned_lc.counts, max_points + ) + lc_data = { + "name": output_name, + "time": plot_time.tolist(), + "counts": plot_counts.tolist(), + "dt": float(rebinned_lc.dt), + "n_bins": len(rebinned_lc.time), + "plot_stride": stride, + } +``` + +Change `get_lightcurve_data` signature (line 181) to: +```python + def get_lightcurve_data( + self, name: str, max_points: Optional[int] = DEFAULT_MAX_PLOT_POINTS + ) -> Dict[str, Any]: +``` +and replace its `lc_data = {...}` block (lines 202-215) with: +```python + plot_time, plot_counts, stride = _decimate_for_plot(lc.time, lc.counts, max_points) + lc_data = { + "name": name, + "time": plot_time.tolist(), + "counts": plot_counts.tolist(), + "dt": float(lc.dt), + "n_bins": len(lc.time), + "plot_stride": stride, + "time_range": [float(lc.time.min()), float(lc.time.max())], + "count_stats": { + "mean": float(np.mean(lc.counts)), + "std": float(np.std(lc.counts)), + "min": float(np.min(lc.counts)), + "max": float(np.max(lc.counts)), + }, + } +``` + +- [x] **Step 4: Plumb max_points through the routes** + +In `python-backend/routes/lightcurve_routes.py`: + +`CreateLightcurveFromEventListRequest` (lines 24-28) — add field: +```python +class CreateLightcurveFromEventListRequest(BaseModel): + event_list_name: str + dt: float + output_name: str + gti: Optional[List[List[float]]] = None + max_points: Optional[int] = 200000 +``` + +`RebinLightcurveRequest` (lines 38-41) — add field: +```python +class RebinLightcurveRequest(BaseModel): + name: str + rebin_factor: float + output_name: str + max_points: Optional[int] = 200000 +``` + +Pass them through in the handlers: in `create_lightcurve_from_event_list` add `max_points=request.max_points,` to the service call; in `rebin_lightcurve` add `max_points=request.max_points,`; change `get_lightcurve_data` (lines 86-92) to: +```python +@router.get("/{name}") +async def get_lightcurve_data( + name: str, + max_points: int = 200000, + service: LightcurveService = Depends(get_lightcurve_service), +): + """Get lightcurve data for plotting.""" + return service.get_lightcurve_data(name, max_points=max_points) +``` + +- [x] **Step 5: Run to verify they pass** + +Run: `pixi run -e dev pytest python-backend/tests -v` +Expected: PASS (all tests, including previous tasks'). + +- [x] **Step 6: Commit** + +```bash +git add python-backend/services/lightcurve_service.py python-backend/routes/lightcurve_routes.py python-backend/tests/test_lightcurve_service.py +git commit -m "feat: add server-side plot decimation for large lightcurves" +``` + +--- + +### Task 6: Unblock the event loop in analysis routes + +**Files:** +- Create: `python-backend/tests/test_route_concurrency.py` +- Modify: `python-backend/routes/lightcurve_routes.py`, `python-backend/routes/spectrum_routes.py`, `python-backend/routes/timing_routes.py` + +- [x] **Step 1: Write the failing test** + +`python-backend/tests/test_route_concurrency.py`: +```python +"""Verify analysis routes run blocking work off the event loop. + +A handler that calls the synchronous service directly blocks the loop, so a +concurrent "/" request cannot complete until the slow call finishes. With +asyncio.to_thread, the probe returns immediately. +""" + +import asyncio +import time + +import httpx +import pytest + +from services.state_manager import StateManager +from utils.performance_monitor import PerformanceMonitor + + +@pytest.mark.asyncio +async def test_lightcurve_create_does_not_block_event_loop(monkeypatch): + import services.lightcurve_service as lcs_mod + from main import create_app + + def slow_create(self, **kwargs): + time.sleep(0.6) + return {"success": True, "data": None, "message": "ok", "error": None} + + monkeypatch.setattr( + lcs_mod.LightcurveService, "create_lightcurve_from_event_list", slow_create + ) + + app = create_app() + # ASGITransport does not run the lifespan; provide state manually. + app.state.state_manager = StateManager() + app.state.performance_monitor = PerformanceMonitor() + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + slow_task = asyncio.create_task( + client.post( + "/api/lightcurve/from-event-list", + json={"event_list_name": "x", "dt": 0.1, "output_name": "y"}, + ) + ) + await asyncio.sleep(0.05) # let the slow handler start + + t0 = time.monotonic() + probe = await client.get("/") + elapsed = time.monotonic() - t0 + + slow_response = await slow_task + assert probe.status_code == 200 + assert slow_response.status_code == 200 + # Without to_thread the probe waits ~0.55s for the loop to free up. + assert elapsed < 0.4, f"event loop was blocked for {elapsed:.2f}s" +``` + +- [x] **Step 2: Run to verify it fails** + +Run: `pixi run -e dev pytest python-backend/tests/test_route_concurrency.py -v` +Expected: FAIL — `event loop was blocked for ~0.55s`. (If it fails with an import error on `create_app`, check `python-backend/main.py:86` for the factory name.) + +- [x] **Step 3: Wrap service calls in asyncio.to_thread** + +Pattern (matches `data_routes.py`): add `import asyncio` to the imports of each file, and change every handler that calls a service method doing computation from `return service.method(...)` to `return await asyncio.to_thread(service.method, ...)` with the same keyword arguments. + +`python-backend/routes/lightcurve_routes.py` — wrap all 6 handlers. Example for the first: +```python +import asyncio +``` +```python +@router.post("/from-event-list") +async def create_lightcurve_from_event_list( + request: CreateLightcurveFromEventListRequest, + service: LightcurveService = Depends(get_lightcurve_service), +): + """Create a Lightcurve from an EventList.""" + return await asyncio.to_thread( + service.create_lightcurve_from_event_list, + event_list_name=request.event_list_name, + dt=request.dt, + output_name=request.output_name, + gti=request.gti, + max_points=request.max_points, + ) +``` +Apply the same transformation to `create_lightcurve_from_arrays`, `rebin_lightcurve`, `get_lightcurve_data` (`await asyncio.to_thread(service.get_lightcurve_data, name, max_points=max_points)`), `list_lightcurves`, and `delete_lightcurve`. + +`python-backend/routes/spectrum_routes.py` — same for all 8 handlers (`create_power_spectrum`, `create_averaged_power_spectrum`, `create_cross_spectrum`, `create_averaged_cross_spectrum`, `create_dynamical_power_spectrum`, `rebin_spectrum`, `list_spectra`, `delete_spectrum`), preserving each handler's existing keyword arguments. + +`python-backend/routes/timing_routes.py` — same for all 4 handlers (`create_bispectrum`, `calculate_power_colors`, `calculate_time_lags`, `calculate_coherence`). + +- [x] **Step 4: Run to verify it passes** + +Run: `pixi run -e dev pytest python-backend/tests -v` +Expected: PASS (all backend tests). + +- [x] **Step 5: Commit** + +```bash +git add python-backend/routes/lightcurve_routes.py python-backend/routes/spectrum_routes.py python-backend/routes/timing_routes.py python-backend/tests/test_route_concurrency.py +git commit -m "fix: run analysis routes in worker threads to keep event loop responsive" +``` + +--- + +### Task 7: Frontend API type updates + +**Files:** +- Modify: `src/api/lightcurveApi.ts`, `src/api/spectrumApi.ts`, `src/api/timingApi.ts` + +- [x] **Step 1: Update the types and params** + +`src/api/spectrumApi.ts` — in `PowerSpectrumData` (lines 8-18), add after `power: number[];`: +```ts + power_phase?: Array | null; +``` + +`src/api/timingApi.ts` — replace `TimeLagsData` and `CoherenceData` (lines 27-38) with: +```ts +export interface TimeLagsData { + name: string | null; + freq: number[]; + time_lags: Array; + time_lags_err?: Array | null; + freq_range: [number, number] | null; +} + +export interface CoherenceData { + name: string | null; + freq: number[]; + coherence: Array; + coherence_err?: Array | null; + segment_size?: number; + n_segments?: number | null; +} +``` + +`src/api/lightcurveApi.ts`: +- In `LightcurveData` (lines 8-22), add after `n_bins: number;`: +```ts + plot_stride?: number; +``` +- In `createFromEventList`, add `max_points?: number;` to the params type and `max_points: params.max_points,` to the POST body. +- In `rebin`, add `max_points?: number;` to the params type and `max_points: params.max_points,` to the POST body. +- Replace `getLightcurveData` with: +```ts + /** + * Get lightcurve data for plotting + */ + async getLightcurveData( + name: string, + maxPoints?: number + ): Promise> { + const query = maxPoints ? `?max_points=${maxPoints}` : ''; + return apiClient.get(`/api/lightcurve/${name}${query}`); + }, +``` + +- [x] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: both exit 0. + +- [x] **Step 3: Commit** + +```bash +git add src/api/lightcurveApi.ts src/api/spectrumApi.ts src/api/timingApi.ts +git commit -m "feat: extend api types for phase, uncertainties, and plot decimation" +``` + +--- + +### Task 8: PlotlyChart shared component + +**Files:** +- Create: `src/components/plots/PlotlyChart.tsx`, `src/components/plots/PlotlyChart.test.tsx` + +- [x] **Step 1: Write the failing test** + +`src/components/plots/PlotlyChart.test.tsx`: +```tsx +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +vi.mock('react-plotly.js', () => ({ + default: ({ data, layout }: { data: unknown[]; layout: Record }) => ( +
+ ), +})); + +import PlotlyChart from './PlotlyChart'; + +describe('PlotlyChart', () => { + it('renders traces and merges page layout over theme defaults', async () => { + render( + + ); + await waitFor(() => expect(screen.getByTestId('plotly-mock')).toBeInTheDocument()); + expect(screen.getByTestId('plotly-mock').dataset.traces).toBe('1'); + expect(screen.getByTestId('plotly-mock').dataset.xtype).toBe('log'); + }); +}); +``` + +- [x] **Step 2: Run to verify it fails** + +Run: `npm test -- --run src/components/plots` +Expected: FAIL — cannot resolve `./PlotlyChart`. + +- [x] **Step 3: Implement** + +`src/components/plots/PlotlyChart.tsx`: +```tsx +import React, { Suspense } from 'react'; +import { Box, CircularProgress, useTheme } from '@mui/material'; +import type { Config, Data, Layout } from 'plotly.js'; + +// plotly.js is ~3 MB; load it only when a page actually renders a chart. +const Plot = React.lazy(() => import('react-plotly.js')); + +export interface PlotlyChartProps { + data: Data[]; + layout?: Partial; + height?: number | string; +} + +const PlotlyChart: React.FC = ({ data, layout = {}, height = 440 }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const gridColor = isDark ? 'rgba(148, 163, 184, 0.12)' : 'rgba(100, 116, 139, 0.2)'; + + const mergedLayout: Partial = { + autosize: true, + paper_bgcolor: 'rgba(0,0,0,0)', + plot_bgcolor: 'rgba(0,0,0,0)', + font: { + family: '"IBM Plex Sans", sans-serif', + size: 12, + color: theme.palette.text.primary, + }, + margin: { l: 64, r: 24, t: 24, b: 52 }, + showlegend: false, + ...layout, + xaxis: { gridcolor: gridColor, zeroline: false, ...layout.xaxis }, + yaxis: { gridcolor: gridColor, zeroline: false, ...layout.yaxis }, + }; + + const config: Partial = { + responsive: true, + displaylogo: false, + modeBarButtonsToRemove: ['lasso2d', 'select2d', 'autoScale2d'], + }; + + return ( + + + + } + > + + + ); +}; + +export default PlotlyChart; +``` + +- [x] **Step 4: Run to verify it passes** + +Run: `npm test -- --run src/components/plots && npm run typecheck` +Expected: PASS. + +- [x] **Step 5: Commit** + +```bash +git add src/components/plots/ +git commit -m "feat: add theme-aware lazy-loaded PlotlyChart component" +``` + +--- + +### Task 9: useEventLists hook + EventListSelector + +**Files:** +- Create: `src/hooks/useEventLists.ts`, `src/hooks/useEventLists.test.tsx`, `src/components/analysis/EventListSelector.tsx`, `src/components/analysis/EventListSelector.test.tsx` + +- [x] **Step 1: Write the failing hook test** + +`src/hooks/useEventLists.test.tsx`: +```tsx +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...args: unknown[]) => listEventLists(...args) }, +})); + +import { useEventLists } from './useEventLists'; + +const wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +}; + +describe('useEventLists', () => { + beforeEach(() => listEventLists.mockReset()); + + it('returns event list summaries on success', async () => { + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'ev1', n_events: 10, time_range: [0, 1] }], + message: '', + error: null, + }); + const { result } = renderHook(() => useEventLists(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.[0].name).toBe('ev1'); + }); + + it('surfaces a success:false response as a query error', async () => { + listEventLists.mockResolvedValue({ success: false, data: null, message: 'boom', error: 'boom' }); + const { result } = renderHook(() => useEventLists(), { wrapper }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect((result.current.error as Error).message).toBe('boom'); + }); +}); +``` + +- [x] **Step 2: Run to verify it fails, then implement the hook** + +Run: `npm test -- --run src/hooks/useEventLists` → FAIL (module not found). + +`src/hooks/useEventLists.ts`: +```ts +import { useQuery } from '@tanstack/react-query'; +import { dataApi, EventListSummary } from '@/api/dataApi'; + +export const EVENT_LISTS_QUERY_KEY = ['eventLists'] as const; + +/** + * Loaded event lists from the backend. The ApiClient re-resolves the backend + * port on every request, so this works without gating on backend readiness; + * failures surface as query errors with a retry affordance in the UI. + */ +export function useEventLists() { + return useQuery({ + queryKey: EVENT_LISTS_QUERY_KEY, + queryFn: async (): Promise => { + const res = await dataApi.listEventLists(); + if (!res.success) { + throw new Error(res.error || res.message || 'Failed to list event lists'); + } + return res.data ?? []; + }, + staleTime: 5_000, + }); +} +``` + +Run: `npm test -- --run src/hooks/useEventLists` → PASS. + +- [x] **Step 3: Write the failing selector test** + +`src/components/analysis/EventListSelector.test.tsx`: +```tsx +import React, { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...args: unknown[]) => listEventLists(...args) }, +})); + +import EventListSelector from './EventListSelector'; + +const Harness: React.FC = () => { + const [value, setValue] = useState(''); + return ; +}; + +describe('EventListSelector', () => { + beforeEach(() => listEventLists.mockReset()); + + it('lists loaded event lists and selects one', async () => { + listEventLists.mockResolvedValue({ + success: true, + data: [ + { name: 'obs1', n_events: 1000, time_range: [0, 10] }, + { name: 'obs2', n_events: 2000, time_range: [0, 20] }, + ], + message: '', + error: null, + }); + renderWithProviders(); + const select = await screen.findByLabelText('Event list'); + await userEvent.click(select); + await userEvent.click(await screen.findByText(/obs2/)); + await waitFor(() => expect(screen.getByLabelText('Event list')).toHaveTextContent('obs2')); + }); + + it('shows an empty-state prompt linking to data ingestion', async () => { + listEventLists.mockResolvedValue({ success: true, data: [], message: '', error: null }); + renderWithProviders(); + expect(await screen.findByText(/No event lists loaded/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Load data/ })).toHaveAttribute( + 'href', + '/data-ingestion' + ); + }); +}); +``` + +- [x] **Step 4: Run to verify it fails, then implement the selector** + +Run: `npm test -- --run src/components/analysis` → FAIL. + +`src/components/analysis/EventListSelector.tsx`: +```tsx +import React from 'react'; +import { + Alert, + Box, + CircularProgress, + FormControl, + IconButton, + InputLabel, + Link, + MenuItem, + Select, + Tooltip, +} from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { Link as RouterLink } from 'react-router-dom'; +import { useEventLists } from '@/hooks/useEventLists'; + +interface EventListSelectorProps { + label: string; + value: string; + onChange: (name: string) => void; +} + +/** Dropdown of event lists currently loaded in the backend. */ +const EventListSelector: React.FC = ({ label, value, onChange }) => { + const { data, isLoading, isError, error, refetch, isFetching } = useEventLists(); + + const refreshButton = ( + + + refetch()} disabled={isFetching}> + {isFetching ? : } + + + + ); + + if (isError) { + return ( + + Failed to load event lists: {error instanceof Error ? error.message : 'unknown error'} + + ); + } + + if (!isLoading && (data?.length ?? 0) === 0) { + return ( + + No event lists loaded.{' '} + + Load data + {' '} + first. + + ); + } + + const labelId = `event-list-selector-${label.replace(/\s+/g, '-').toLowerCase()}`; + + return ( + + + {label} + + + {refreshButton} + + ); +}; + +export default EventListSelector; +``` + +Run: `npm test -- --run src/components/analysis src/hooks` → PASS. + +- [x] **Step 5: Commit** + +```bash +git add src/hooks/useEventLists.ts src/hooks/useEventLists.test.tsx src/components/analysis/ +git commit -m "feat: add event list query hook and selector component" +``` + +--- + +### Task 10: useAnalysisRunner hook + +**Files:** +- Create: `src/hooks/useAnalysisRunner.ts`, `src/hooks/useAnalysisRunner.test.tsx` + +- [x] **Step 1: Write the failing test** + +`src/hooks/useAnalysisRunner.test.tsx`: +```tsx +import { beforeEach, describe, expect, it } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useAnalysisRunner } from './useAnalysisRunner'; +import { useUIStore } from '@/store/uiStore'; + +describe('useAnalysisRunner', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + }); + + it('stores the result and pushes a success notification', async () => { + const { result } = renderHook(() => useAnalysisRunner<{ v: number }>('Test Op')); + await act(async () => { + await result.current.run(async () => ({ + success: true, + data: { v: 42 }, + message: 'computed', + error: null, + })); + }); + expect(result.current.result?.v).toBe(42); + expect(result.current.running).toBe(false); + expect(result.current.error).toBeNull(); + const notes = useUIStore.getState().notifications; + expect(notes[0].type).toBe('success'); + expect(notes[0].title).toBe('Test Op'); + }); + + it('captures success:false as an error and keeps the previous result', async () => { + const { result } = renderHook(() => useAnalysisRunner<{ v: number }>('Test Op')); + await act(async () => { + await result.current.run(async () => ({ + success: true, + data: { v: 1 }, + message: '', + error: null, + })); + }); + await act(async () => { + await result.current.run(async () => ({ + success: false, + data: null, + message: 'bad dt', + error: 'bad dt', + })); + }); + expect(result.current.error).toBe('bad dt'); + expect(result.current.result?.v).toBe(1); + expect(useUIStore.getState().notifications[0].type).toBe('error'); + }); + + it('captures thrown errors (network failures)', async () => { + const { result } = renderHook(() => useAnalysisRunner('Test Op')); + await act(async () => { + await result.current.run(async () => { + throw new Error('connection refused'); + }); + }); + expect(result.current.error).toBe('connection refused'); + }); +}); +``` + +- [x] **Step 2: Run to verify it fails, then implement** + +Run: `npm test -- --run src/hooks/useAnalysisRunner` → FAIL. + +`src/hooks/useAnalysisRunner.ts`: +```ts +import { useCallback, useState } from 'react'; +import { ApiResponse } from '@/api/client'; +import { useUIStore } from '@/store/uiStore'; + +interface AnalysisRunnerState { + result: T | null; + running: boolean; + error: string | null; +} + +/** + * Owns the lifecycle of a single analysis request: running flag, last + * successful result, last error, and success/error notifications. + * On failure the previous result is kept so the plot doesn't vanish. + */ +export function useAnalysisRunner(label: string) { + const addNotification = useUIStore((s) => s.addNotification); + const [state, setState] = useState>({ + result: null, + running: false, + error: null, + }); + + const run = useCallback( + async (call: () => Promise>): Promise => { + setState((s) => ({ ...s, running: true, error: null })); + try { + const res = await call(); + if (res.success && res.data !== null) { + setState({ result: res.data, running: false, error: null }); + addNotification({ type: 'success', title: label, message: res.message || 'Done' }); + } else { + const msg = res.error || res.message || 'Operation failed'; + setState((s) => ({ ...s, running: false, error: msg })); + addNotification({ type: 'error', title: label, message: msg }); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setState((s) => ({ ...s, running: false, error: msg })); + addNotification({ type: 'error', title: label, message: msg }); + } + }, + [label, addNotification] + ); + + const reset = useCallback((): void => { + setState({ result: null, running: false, error: null }); + }, []); + + return { ...state, run, reset }; +} +``` + +Run: `npm test -- --run src/hooks/useAnalysisRunner` → PASS. + +- [x] **Step 3: Commit** + +```bash +git add src/hooks/useAnalysisRunner.ts src/hooks/useAnalysisRunner.test.tsx +git commit -m "feat: add useAnalysisRunner request-lifecycle hook" +``` + +--- + +### Task 11: EventList page + +**Files:** +- Modify: `src/pages/QuickLook/EventList/index.tsx` +- Create: `src/pages/QuickLook/EventList/index.test.tsx` + +- [x] **Step 1: Write the failing test** + +`src/pages/QuickLook/EventList/index.test.tsx`: +```tsx +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; + +const listEventLists = vi.fn(); +const getEventListInfo = vi.fn(); +const getEventListFullPreview = vi.fn(); +const deleteEventList = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { + listEventLists: (...a: unknown[]) => listEventLists(...a), + getEventListInfo: (...a: unknown[]) => getEventListInfo(...a), + getEventListFullPreview: (...a: unknown[]) => getEventListFullPreview(...a), + deleteEventList: (...a: unknown[]) => deleteEventList(...a), + }, +})); +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import EventListPage from './index'; + +describe('EventListPage', () => { + beforeEach(() => { + listEventLists.mockReset(); + getEventListInfo.mockReset(); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + getEventListInfo.mockResolvedValue({ + success: true, + data: { + name: 'obs1', + n_events: 5000, + time_range: [0, 100], + duration: 100, + mjdref: 56000, + gti_count: 2, + gti_list: [ + [0, 40], + [60, 100], + ], + mean_count_rate: 50, + }, + message: '', + error: null, + }); + }); + + it('lists event lists and shows details when one is selected', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByText(/obs1/)); + expect(await screen.findByText('Duration (s)')).toBeInTheDocument(); + expect(getEventListInfo).toHaveBeenCalledWith('obs1'); + // GTI table rows + expect(await screen.findByText('Good Time Intervals')).toBeInTheDocument(); + }); +}); +``` + +- [x] **Step 2: Run to verify it fails** + +Run: `npm test -- --run src/pages/QuickLook/EventList` +Expected: FAIL — page still renders the coming-soon placeholder, `obs1` never appears. + +- [x] **Step 3: Implement the page** + +Replace `src/pages/QuickLook/EventList/index.tsx` entirely with: +```tsx +import React, { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Grid, + IconButton, + List, + ListItemButton, + ListItemText, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + Tooltip, + Typography, +} from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import DeleteIcon from '@mui/icons-material/Delete'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import { EVENT_LISTS_QUERY_KEY, useEventLists } from '@/hooks/useEventLists'; +import { dataApi, EventListFullPreview, EventListInfo } from '@/api/dataApi'; +import { useUIStore } from '@/store/uiStore'; + +const mono = { fontFamily: '"JetBrains Mono", monospace' }; + +const formatNum = (v: number | null | undefined, digits = 3): string => + v === null || v === undefined + ? '—' + : Number(v).toLocaleString(undefined, { maximumFractionDigits: digits }); + +const InfoRow: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( + + + {label} + + + {value} + + +); + +const EventListPage: React.FC = () => { + const [selected, setSelected] = useState(null); + const [tab, setTab] = useState(0); + const [deleteTarget, setDeleteTarget] = useState(null); + const addNotification = useUIStore((s) => s.addNotification); + const queryClient = useQueryClient(); + const { data: eventLists, isLoading, isError, error, refetch, isFetching } = useEventLists(); + + const infoQuery = useQuery({ + queryKey: ['eventListInfo', selected], + enabled: selected !== null, + queryFn: async (): Promise => { + const res = await dataApi.getEventListInfo(selected as string); + if (!res.success || !res.data) throw new Error(res.error || res.message); + return res.data; + }, + }); + + const previewQuery = useQuery({ + queryKey: ['eventListPreview', selected], + enabled: selected !== null && tab === 1, + queryFn: async (): Promise => { + const res = await dataApi.getEventListFullPreview(selected as string); + if (!res.success || !res.data) throw new Error(res.error || res.message); + return res.data; + }, + }); + + const handleDelete = async (): Promise => { + if (!deleteTarget) return; + const res = await dataApi.deleteEventList(deleteTarget); + if (res.success) { + addNotification({ type: 'success', title: 'Event List', message: `Deleted '${deleteTarget}'` }); + if (selected === deleteTarget) setSelected(null); + await queryClient.invalidateQueries({ queryKey: EVENT_LISTS_QUERY_KEY }); + } else { + addNotification({ + type: 'error', + title: 'Event List', + message: res.error || res.message || 'Delete failed', + }); + } + setDeleteTarget(null); + }; + + const info = infoQuery.data; + const preview = previewQuery.data; + + return ( + + + + + + + Loaded event lists + + + refetch()} disabled={isFetching}> + {isFetching ? : } + + + + + {isError && ( + + {error instanceof Error ? error.message : 'Failed to load'} + + )} + {isLoading && } + {!isLoading && (eventLists?.length ?? 0) === 0 && ( + + Nothing loaded yet — use Data Ingestion first. + + )} + + {(eventLists ?? []).map((ev) => ( + setSelected(ev.name)} + > + + { + e.stopPropagation(); + setDeleteTarget(ev.name); + }} + > + + + + ))} + + + + + + + + + {!selected && ( + + + Select an event list to inspect it. + + + )} + {selected && ( + <> + + + {selected} + + {info?.mission && } + {info?.instrument && } + + setTab(v)} sx={{ mb: 2 }}> + + + + + {infoQuery.isError && ( + + {infoQuery.error instanceof Error ? infoQuery.error.message : 'Failed to load info'} + + )} + {tab === 0 && infoQuery.isLoading && } + + {tab === 0 && info && ( + + + + + + + + + + + + + {(info.validation_issues ?? []) + .filter((v) => v.severity === 'error' || v.severity === 'warning') + .map((v, i) => ( + + {v.message} + + ))} + {info.notes && ( + + {info.notes} + + )} + + {(info.gti_list?.length ?? 0) > 0 && ( + + + + Good Time Intervals + + + + + + # + Start + Stop + Duration (s) + Rate (cts/s) + + + + {(info.gti_list ?? []).map((g, i) => { + const rate = info.per_gti_rates?.[i]; + return ( + + {i + 1} + {formatNum(g[0])} + {formatNum(g[1])} + {formatNum(g[1] - g[0])} + {formatNum(rate?.rate)} + + ); + })} + +
+
+
+ )} +
+ )} + + {tab === 1 && previewQuery.isLoading && } + {tab === 1 && previewQuery.isError && ( + + {previewQuery.error instanceof Error + ? previewQuery.error.message + : 'Failed to load preview'} + + )} + {tab === 1 && preview && ( + + + + Arrival time distribution (preview sample) + + + + {preview.has_energy && preview.energy_preview && ( + + + Energy distribution (preview sample) + + + + )} + + )} + + )} +
+
+
+
+ + setDeleteTarget(null)}> + Delete event list? + + + Remove '{deleteTarget}' from backend memory? This cannot be undone. + + + + + + + +
+ ); +}; + +export default EventListPage; +``` + +- [x] **Step 4: Run to verify it passes** + +Run: `npm test -- --run src/pages/QuickLook/EventList && npm run typecheck && npm run lint` +Expected: PASS, no type or lint errors. + +- [x] **Step 5: Manual verification** + +Run `npm run dev`, load a sample file from `files/data/` via Data Ingestion, open QuickLook → Event List. Confirm: list shows the file, Overview shows real numbers and GTI table, Distributions tab renders two histograms, Delete removes it. + +- [x] **Step 6: Commit** + +```bash +git add src/pages/QuickLook/EventList/ +git commit -m "feat: implement EventList QuickLook page" +``` + +--- + +### Task 12: Light Curve page + +**Files:** +- Modify: `src/pages/QuickLook/LightCurve/index.tsx` +- Create: `src/pages/QuickLook/LightCurve/index.test.tsx` + +- [x] **Step 1: Write the failing test** + +`src/pages/QuickLook/LightCurve/index.test.tsx`: +```tsx +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const createFromEventList = vi.fn(); +const listLightcurves = vi.fn(); +const getLightcurveData = vi.fn(); +const rebin = vi.fn(); +const deleteLightcurve = vi.fn(); +vi.mock('@/api/lightcurveApi', () => ({ + lightcurveApi: { + createFromEventList: (...a: unknown[]) => createFromEventList(...a), + listLightcurves: (...a: unknown[]) => listLightcurves(...a), + getLightcurveData: (...a: unknown[]) => getLightcurveData(...a), + rebin: (...a: unknown[]) => rebin(...a), + deleteLightcurve: (...a: unknown[]) => deleteLightcurve(...a), + }, +})); +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import LightCurvePage from './index'; + +describe('LightCurvePage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + listLightcurves.mockResolvedValue({ success: true, data: [], message: '', error: null }); + createFromEventList.mockResolvedValue({ + success: true, + data: { + name: 'obs1_lc', + time: [0.5, 1.5, 2.5], + counts: [10, 12, 9], + dt: 1, + n_bins: 3, + plot_stride: 1, + count_rate_mean: 10.3, + }, + message: 'created', + error: null, + }); + }); + + it('creates a light curve with parsed parameters and plots it', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + const dtField = screen.getByLabelText(/Time bin/); + await userEvent.clear(dtField); + await userEvent.type(dtField, '1.0'); + await userEvent.click(screen.getByRole('button', { name: /Generate/ })); + await waitFor(() => + expect(createFromEventList).toHaveBeenCalledWith( + expect.objectContaining({ event_list_name: 'obs1', dt: 1, output_name: 'obs1_lc' }) + ) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + }); + + it('disables Generate until inputs are valid', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Generate/ }); + expect(button).toBeDisabled(); + }); +}); +``` + +- [x] **Step 2: Run to verify it fails** + +Run: `npm test -- --run src/pages/QuickLook/LightCurve` +Expected: FAIL (placeholder page). + +- [x] **Step 3: Implement the page** + +Replace `src/pages/QuickLook/LightCurve/index.tsx` entirely with: +```tsx +import React, { useEffect, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + FormControl, + Grid, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import DeleteIcon from '@mui/icons-material/Delete'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { lightcurveApi, LightcurveData, LightcurveSummary } from '@/api/lightcurveApi'; +import { parsePositiveNumber } from '@/utils/numbers'; +import { useUIStore } from '@/store/uiStore'; + +const LIGHTCURVES_QUERY_KEY = ['lightcurves'] as const; + +const LightCurvePage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('1.0'); + const [outputName, setOutputName] = useState(''); + const [existingSelection, setExistingSelection] = useState(''); + const [rebinFactor, setRebinFactor] = useState('2'); + const addNotification = useUIStore((s) => s.addNotification); + const queryClient = useQueryClient(); + const { result, running, error, run } = useAnalysisRunner('Light Curve'); + + const existingQuery = useQuery({ + queryKey: LIGHTCURVES_QUERY_KEY, + queryFn: async (): Promise => { + const res = await lightcurveApi.listLightcurves(); + if (!res.success) throw new Error(res.error || res.message); + return res.data ?? []; + }, + }); + + // Any successful create/rebin changes the stored set — refresh the list. + useEffect(() => { + if (result) void queryClient.invalidateQueries({ queryKey: LIGHTCURVES_QUERY_KEY }); + }, [result, queryClient]); + + const dtNum = parsePositiveNumber(dt); + const canRun = eventList !== '' && dtNum !== null && !running; + const rebinNum = parsePositiveNumber(rebinFactor); + + const handleGenerate = (): void => { + if (!dtNum || !eventList) return; + const name = outputName.trim() || `${eventList}_lc`; + void run(() => + lightcurveApi.createFromEventList({ event_list_name: eventList, dt: dtNum, output_name: name }) + ); + }; + + const handleView = (): void => { + if (!existingSelection) return; + void run(() => lightcurveApi.getLightcurveData(existingSelection)); + }; + + const handleRebin = (): void => { + if (!result?.name || !rebinNum) return; + void run(() => + lightcurveApi.rebin({ + name: result.name, + rebin_factor: rebinNum, + output_name: `${result.name}_r${rebinNum}`, + }) + ); + }; + + const handleDelete = async (name: string): Promise => { + const res = await lightcurveApi.deleteLightcurve(name); + if (res.success) { + addNotification({ type: 'success', title: 'Light Curve', message: `Deleted '${name}'` }); + await queryClient.invalidateQueries({ queryKey: LIGHTCURVES_QUERY_KEY }); + } else { + addNotification({ + type: 'error', + title: 'Light Curve', + message: res.error || res.message || 'Delete failed', + }); + } + }; + + const plotData: Data[] = result + ? [ + { + x: result.time, + y: result.counts, + type: 'scattergl', + mode: 'lines', + line: { color: '#00d4aa', width: 1 }, + }, + ] + : []; + + return ( + + + + + + + Generate from event list + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setOutputName(e.target.value)} + placeholder={eventList ? `${eventList}_lc` : 'name'} + /> + + + + {result?.name && ( + <> + + + Rebin '{result.name}' + setRebinFactor(e.target.value)} + error={rebinFactor !== '' && rebinNum === null} + helperText={ + rebinFactor !== '' && rebinNum === null ? 'Must be a positive number' : ' ' + } + /> + + + + )} + + + + Stored light curves + + Light curve + + + + + + + { + void handleDelete(existingSelection); + setExistingSelection(''); + }} + > + + + + + + + + + + + + + + + + {result?.name ? `Light curve: ${result.name}` : 'Result'} + + {result && } + {result && } + {result?.count_rate_mean !== undefined && ( + + )} + + {result?.plot_stride !== undefined && result.plot_stride > 1 && ( + + Showing every {result.plot_stride}th bin for display performance (full resolution + is stored in the backend). + + )} + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Generate a light curve or view a stored one. + + + )} + + + + + + ); +}; + +export default LightCurvePage; +``` + +- [x] **Step 4: Run to verify it passes** + +Run: `npm test -- --run src/pages/QuickLook/LightCurve && npm run typecheck && npm run lint` +Expected: PASS. + +- [x] **Step 5: Manual verification** + +In the running app: generate a light curve from a loaded event list (dt=1), confirm plot + chips; rebin ×2, confirm new plot and that the stored list now contains both; view and delete stored curves. + +- [x] **Step 6: Commit** + +```bash +git add src/pages/QuickLook/LightCurve/ +git commit -m "feat: implement Light Curve page with rebin and stored-curve viewer" +``` + +--- + +### Task 13: Power Spectrum page + +**Files:** +- Modify: `src/pages/QuickLook/PowerSpectrum/index.tsx` + +The pattern for all single-input spectrum pages. `lastStoredName` tracks the most recent result that was stored under a name, so Rebin always re-derives from the stored original (rebin responses themselves have `name: null` and are display-only). + +- [x] **Step 1: Implement the page** + +Replace `src/pages/QuickLook/PowerSpectrum/index.tsx` entirely with: +```tsx +import React, { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { spectrumApi, PowerSpectrumData } from '@/api/spectrumApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const NORM_OPTIONS = ['leahy', 'frac', 'abs', 'none']; + +const PowerSpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [norm, setNorm] = useState('leahy'); + const [outputName, setOutputName] = useState(''); + const [logX, setLogX] = useState(true); + const [logY, setLogY] = useState(true); + const [rebinFactor, setRebinFactor] = useState('0.02'); + const [logRebin, setLogRebin] = useState(true); + const [lastStoredName, setLastStoredName] = useState(null); + const { result, running, error, run } = useAnalysisRunner('Power Spectrum'); + + useEffect(() => { + if (result?.name) setLastStoredName(result.name); + }, [result]); + + const dtNum = parsePositiveNumber(dt); + const rebinNum = parsePositiveNumber(rebinFactor); + const canRun = eventList !== '' && dtNum !== null && !running; + + const handleRun = (): void => { + if (!dtNum) return; + void run(() => + spectrumApi.createPowerSpectrum({ + event_list_name: eventList, + dt: dtNum, + norm, + output_name: outputName.trim() || undefined, + }) + ); + }; + + const handleRebin = (): void => { + if (!lastStoredName || !rebinNum) return; + void run(() => + spectrumApi.rebinSpectrum({ name: lastStoredName, rebin_factor: rebinNum, log: logRebin }) + ); + }; + + const plotData: Data[] = result + ? [ + { + x: result.freq, + y: result.power, + type: 'scattergl', + mode: 'lines', + line: { color: '#00d4aa', width: 1 }, + }, + ] + : []; + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + + Normalization + + + setOutputName(e.target.value)} + placeholder={eventList ? `${eventList}_ps` : ''} + helperText="Required to enable rebinning" + /> + + + + {lastStoredName && ( + <> + + + Rebin '{lastStoredName}' + setRebinFactor(e.target.value)} + error={rebinFactor !== '' && rebinNum === null} + helperText={logRebin ? 'Each bin grows by (1 + f)' : ' '} + /> + setLogRebin(e.target.checked)} />} + label="Logarithmic" + /> + + + + )} + + + + + + + + + + Result + + {result?.norm && } + {result && } + {result?.df !== undefined && ( + + )} + setLogX(e.target.checked)} />} + label="log f" + /> + setLogY(e.target.checked)} />} + label="log P" + /> + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Choose an event list and compute a power spectrum. + + + )} + + + + + + ); +}; + +export default PowerSpectrumPage; +``` + +- [x] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint && npm test -- --run` +Expected: all pass (existing tests unaffected). + +- [x] **Step 3: Manual verification** + +In the app: compute leahy PS at dt=0.0625 on a loaded event list — expect mean power ≈ 2 at high frequency for Poisson data. Store + rebin (log, f=0.02) and confirm the curve smooths. + +- [x] **Step 4: Commit** + +```bash +git add src/pages/QuickLook/PowerSpectrum/ +git commit -m "feat: implement Power Spectrum page" +``` + +--- + +### Task 14: Averaged Power Spectrum page + +**Files:** +- Modify: `src/pages/QuickLook/AvgPowerSpectrum/index.tsx` + +- [x] **Step 1: Implement the page** + +Replace `src/pages/QuickLook/AvgPowerSpectrum/index.tsx` entirely. It is the PowerSpectrum page (Task 13) with five deltas — apply them to a fresh copy of that component source: + +1. Component name and export: `AvgPowerSpectrumPage`. +2. Add state below `dt`: `const [segmentSize, setSegmentSize] = useState('16');` and parse it: `const segNum = parsePositiveNumber(segmentSize);` and extend `canRun`: `const canRun = eventList !== '' && dtNum !== null && segNum !== null && !running;` +3. `handleRun` calls the averaged endpoint: +```tsx + const handleRun = (): void => { + if (!dtNum || !segNum) return; + void run(() => + spectrumApi.createAveragedPowerSpectrum({ + event_list_name: eventList, + dt: dtNum, + segment_size: segNum, + norm, + output_name: outputName.trim() || undefined, + }) + ); + }; +``` +4. Add a segment-size field after the dt TextField: +```tsx + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> +``` +5. Add a segments chip in the result header (after the `n_freq` chip): +```tsx + {result?.n_segments != null && ( + + )} +``` +Also update `PageTemplate` props: `title="Averaged Power Spectrum"`, `description="Welch-style averaged power spectrum over fixed-length segments"`, label `'Averaged Power Spectrum'` in `useAnalysisRunner`, and placeholder `_aps` instead of `_ps`. + +- [x] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: clean. + +- [x] **Step 3: Manual verification** + +Compute with dt=0.0625, segment=16 s: scatter should be visibly smaller than the single PS; segments chip shows a sensible count (~duration/16). + +- [x] **Step 4: Commit** + +```bash +git add src/pages/QuickLook/AvgPowerSpectrum/ +git commit -m "feat: implement Averaged Power Spectrum page" +``` + +--- + +### Task 15: Cross Spectrum page + +**Files:** +- Modify: `src/pages/QuickLook/CrossSpectrum/index.tsx` + +- [x] **Step 1: Implement the page** + +Replace `src/pages/QuickLook/CrossSpectrum/index.tsx` entirely with: +```tsx +import React, { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { spectrumApi, PowerSpectrumData } from '@/api/spectrumApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const NORM_OPTIONS = ['leahy', 'frac', 'abs', 'none']; + +const CrossSpectrumPage: React.FC = () => { + const [eventList1, setEventList1] = useState(''); + const [eventList2, setEventList2] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [norm, setNorm] = useState('leahy'); + const [outputName, setOutputName] = useState(''); + const [logX, setLogX] = useState(true); + const [logY, setLogY] = useState(true); + const [rebinFactor, setRebinFactor] = useState('0.02'); + const [logRebin, setLogRebin] = useState(true); + const [lastStoredName, setLastStoredName] = useState(null); + const { result, running, error, run } = useAnalysisRunner('Cross Spectrum'); + + useEffect(() => { + if (result?.name) setLastStoredName(result.name); + }, [result]); + + const dtNum = parsePositiveNumber(dt); + const rebinNum = parsePositiveNumber(rebinFactor); + const canRun = eventList1 !== '' && eventList2 !== '' && dtNum !== null && !running; + + const handleRun = (): void => { + if (!dtNum) return; + void run(() => + spectrumApi.createCrossSpectrum({ + event_list_1_name: eventList1, + event_list_2_name: eventList2, + dt: dtNum, + norm, + output_name: outputName.trim() || undefined, + }) + ); + }; + + const handleRebin = (): void => { + if (!lastStoredName || !rebinNum) return; + void run(() => + spectrumApi.rebinSpectrum({ name: lastStoredName, rebin_factor: rebinNum, log: logRebin }) + ); + }; + + const magnitudeTrace: Data[] = result + ? [ + { + x: result.freq, + y: result.power, + type: 'scattergl', + mode: 'lines', + line: { color: '#00d4aa', width: 1 }, + }, + ] + : []; + + const phaseTrace: Data[] = + result && result.power_phase + ? [ + { + x: result.freq, + y: result.power_phase, + type: 'scattergl', + mode: 'markers', + marker: { color: '#3b82f6', size: 3 }, + }, + ] + : []; + + return ( + + + + + + + Parameters + + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + + Normalization + + + setOutputName(e.target.value)} + helperText="Required to enable rebinning" + /> + + + + {lastStoredName && ( + <> + + + Rebin '{lastStoredName}' + setRebinFactor(e.target.value)} + /> + setLogRebin(e.target.checked)} />} + label="Logarithmic" + /> + + + + )} + + + + + + + + + + Result + + {result?.norm && } + {result && } + setLogX(e.target.checked)} />} + label="log f" + /> + setLogY(e.target.checked)} />} + label="log |C|" + /> + + {error && ( + + {error} + + )} + {result ? ( + + + + Cross-power magnitude + + + + {phaseTrace.length > 0 && ( + + + Cross-spectrum phase + + + + )} + + ) : ( + + + Choose two event lists and compute their cross spectrum. + + + )} + + + + + + ); +}; + +export default CrossSpectrumPage; +``` + +- [x] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: clean. + +- [x] **Step 3: Manual verification** + +Load the same file twice under two names (or two different files), compute: magnitude plot renders, phase panel renders with values in [-π, π]. This exercises the Task 3 backend fix end-to-end. + +- [x] **Step 4: Commit** + +```bash +git add src/pages/QuickLook/CrossSpectrum/ +git commit -m "feat: implement Cross Spectrum page with magnitude and phase" +``` + +--- + +### Task 16: Averaged Cross Spectrum page + +**Files:** +- Modify: `src/pages/QuickLook/AvgCrossSpectrum/index.tsx` + +- [x] **Step 1: Implement the page** + +Replace `src/pages/QuickLook/AvgCrossSpectrum/index.tsx` entirely. It is the CrossSpectrum page (Task 15) with five deltas — apply them to a fresh copy of that component source: + +1. Component name and export: `AvgCrossSpectrumPage`; runner label `'Averaged Cross Spectrum'`. +2. Add state below `dt`: `const [segmentSize, setSegmentSize] = useState('16');` plus `const segNum = parsePositiveNumber(segmentSize);` and extend `canRun` with `&& segNum !== null`. +3. `handleRun` calls: +```tsx + spectrumApi.createAveragedCrossSpectrum({ + event_list_1_name: eventList1, + event_list_2_name: eventList2, + dt: dtNum, + segment_size: segNum, + norm, + output_name: outputName.trim() || undefined, + }) +``` +(guard becomes `if (!dtNum || !segNum) return;`) +4. Add the same segment-size TextField as Task 14 delta 4, after the dt field. +5. `PageTemplate` props: `title="Averaged Cross Spectrum"`, `description="Segment-averaged cross spectrum between two event lists"`; add a `segment_size` chip in the result header: +```tsx + {result?.segment_size !== undefined && ( + + )} +``` + +- [x] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: clean. + +- [x] **Step 3: Manual verification** + +Compute with segment=16 s on two loaded lists; phase scatter should be visibly less noisy than the single cross spectrum. + +- [x] **Step 4: Commit** + +```bash +git add src/pages/QuickLook/AvgCrossSpectrum/ +git commit -m "feat: implement Averaged Cross Spectrum page" +``` + +--- + +### Task 17: Dynamical Power Spectrum page + +**Files:** +- Modify: `src/pages/QuickLook/DynamicalPowerSpectrum/index.tsx` + +- [x] **Step 1: Implement the page** + +Replace `src/pages/QuickLook/DynamicalPowerSpectrum/index.tsx` entirely with: +```tsx +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { spectrumApi, DynamicalPowerSpectrumData } from '@/api/spectrumApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const NORM_OPTIONS = ['leahy', 'frac', 'abs', 'none']; + +const DynamicalPowerSpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [segmentSize, setSegmentSize] = useState('8'); + const [norm, setNorm] = useState('leahy'); + const [outputName, setOutputName] = useState(''); + const [logZ, setLogZ] = useState(true); + const { result, running, error, run } = useAnalysisRunner( + 'Dynamical Power Spectrum' + ); + + const dtNum = parsePositiveNumber(dt); + const segNum = parsePositiveNumber(segmentSize); + const canRun = eventList !== '' && dtNum !== null && segNum !== null && !running; + + const handleRun = (): void => { + if (!dtNum || !segNum) return; + void run(() => + spectrumApi.createDynamicalPowerSpectrum({ + event_list_name: eventList, + dt: dtNum, + segment_size: segNum, + norm, + output_name: outputName.trim() || undefined, + }) + ); + }; + + // dyn_ps rows correspond to frequencies (n_freq x n_times) — matches + // Plotly's convention that z[i] pairs with y[i]. + const zValues: Array> | undefined = result + ? logZ + ? result.dyn_ps.map((row) => row.map((v) => (v > 0 ? Math.log10(v) : null))) + : result.dyn_ps + : undefined; + + const heatmap: Data[] = + result && zValues + ? [ + { + z: zValues, + x: result.time, + y: result.freq, + type: 'heatmap', + colorscale: 'Viridis', + colorbar: { title: { text: logZ ? 'log10 P' : 'Power' } }, + } as Data, + ] + : []; + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + Normalization + + + setOutputName(e.target.value)} + /> + + + + + + + + + + + + Result + + {result && ( + + )} + setLogZ(e.target.checked)} />} + label="log color" + /> + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Compute a dynamical power spectrum to see the time-frequency map. + + + )} + + + + + + ); +}; + +export default DynamicalPowerSpectrumPage; +``` + +- [x] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: clean. + +- [x] **Step 3: Manual verification** + +Compute with dt=0.0625, segment=8 s. Heatmap renders with time on x, frequency on y; toggling "log color" rescales. + +- [x] **Step 4: Commit** + +```bash +git add src/pages/QuickLook/DynamicalPowerSpectrum/ +git commit -m "feat: implement Dynamical Power Spectrum page" +``` + +--- + +### Task 18: Bispectrum page + +**Files:** +- Modify: `src/pages/QuickLook/Bispectrum/index.tsx` + +- [x] **Step 1: Implement the page** + +Replace `src/pages/QuickLook/Bispectrum/index.tsx` entirely with: +```tsx +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + Tab, + Tabs, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { timingApi, BispectrumData } from '@/api/timingApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const SCALE_OPTIONS = ['biased', 'unbiased']; +const WINDOW_OPTIONS = ['uniform', 'parzen', 'hamming', 'hanning', 'triangular', 'welch', 'blackmann', 'flat-top']; + +const BispectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.1'); + const [maxlag, setMaxlag] = useState('25'); + const [scale, setScale] = useState('unbiased'); + const [windowFn, setWindowFn] = useState('uniform'); + const [outputName, setOutputName] = useState(''); + const [tab, setTab] = useState(0); + const [logZ, setLogZ] = useState(true); + const { result, running, error, run } = useAnalysisRunner('Bispectrum'); + + const dtNum = parsePositiveNumber(dt); + const maxlagNum = parsePositiveNumber(maxlag); + const canRun = eventList !== '' && dtNum !== null && maxlagNum !== null && !running; + + const handleRun = (): void => { + if (!dtNum || !maxlagNum) return; + void run(() => + timingApi.createBispectrum({ + event_list_name: eventList, + dt: dtNum, + maxlag: Math.round(maxlagNum), + scale, + window: windowFn, + output_name: outputName.trim() || undefined, + }) + ); + }; + + const buildHeatmap = (): Data[] => { + if (!result) return []; + if (tab === 0) { + const z = logZ + ? result.bispec_mag.map((row) => row.map((v) => (v > 0 ? Math.log10(v) : null))) + : result.bispec_mag; + return [ + { + z, + x: result.freq, + y: result.freq, + type: 'heatmap', + colorscale: 'Viridis', + colorbar: { title: { text: logZ ? 'log10 |B|' : '|B|' } }, + } as Data, + ]; + } + return [ + { + z: result.bispec_phase, + x: result.freq, + y: result.freq, + type: 'heatmap', + colorscale: 'RdBu', + zmid: 0, + colorbar: { title: { text: 'Phase (rad)' } }, + } as Data, + ]; + }; + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setMaxlag(e.target.value)} + error={maxlag !== '' && maxlagNum === null} + helperText="Bispectrum size is (2·maxlag+1)²; keep ≤ 100" + /> + + Scale + + + + Window + + + setOutputName(e.target.value)} + /> + + + + + + + + + + + setTab(v)} sx={{ flexGrow: 1 }}> + + + + {result && } + {result && } + {tab === 0 && ( + setLogZ(e.target.checked)} />} + label="log color" + /> + )} + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Compute a bispectrum to see magnitude and phase maps. + + + )} + + + + + + ); +}; + +export default BispectrumPage; +``` + +- [x] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: clean. + +- [x] **Step 3: Manual verification** + +Compute with dt=0.1, maxlag=25: Magnitude tab shows a (51×51) heatmap, Phase tab a diverging map. Computation may take a while — confirm the log panel stays live (Task 6 fix). + +- [x] **Step 4: Commit** + +```bash +git add src/pages/QuickLook/Bispectrum/ +git commit -m "feat: implement Bispectrum page with magnitude/phase heatmaps" +``` + +--- + +### Task 19: Coherence page + +**Files:** +- Modify: `src/pages/QuickLook/Coherence/index.tsx` + +- [x] **Step 1: Implement the page** + +Replace `src/pages/QuickLook/Coherence/index.tsx` entirely with: +```tsx +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControlLabel, + Grid, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { timingApi, CoherenceData } from '@/api/timingApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const CoherencePage: React.FC = () => { + const [eventList1, setEventList1] = useState(''); + const [eventList2, setEventList2] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [segmentSize, setSegmentSize] = useState('16'); + const [logX, setLogX] = useState(true); + const { result, running, error, run } = useAnalysisRunner('Coherence'); + + const dtNum = parsePositiveNumber(dt); + const segNum = parsePositiveNumber(segmentSize); + const canRun = eventList1 !== '' && eventList2 !== '' && dtNum !== null && segNum !== null && !running; + + const handleRun = (): void => { + if (!dtNum || !segNum) return; + void run(() => + timingApi.calculateCoherence({ + event_list_1_name: eventList1, + event_list_2_name: eventList2, + dt: dtNum, + segment_size: segNum, + }) + ); + }; + + const traces: Data[] = result + ? [ + { + x: result.freq, + y: result.coherence, + type: 'scattergl', + mode: 'lines+markers', + marker: { size: 4, color: '#00d4aa' }, + line: { color: '#00d4aa', width: 1 }, + error_y: result.coherence_err + ? { type: 'data', array: result.coherence_err, visible: true, color: 'rgba(0, 212, 170, 0.35)' } + : undefined, + } as Data, + ] + : []; + + return ( + + + + + + + Parameters + + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + + + + + + + + + + + Result + + {result?.n_segments != null && ( + + )} + setLogX(e.target.checked)} />} + label="log f" + /> + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Choose two event lists and compute their coherence. + + + )} + + + + + + ); +}; + +export default CoherencePage; +``` + +- [x] **Step 2: Verify** + +Run: `npm run typecheck && npm run lint` +Expected: clean. + +- [x] **Step 3: Manual verification** + +Compute coherence of an event list with itself (load the same file under two names): values cluster at 1.0 (this directly validates the Task 4 fix). With two unrelated lists, values are low. + +- [x] **Step 4: Commit** + +```bash +git add src/pages/QuickLook/Coherence/ +git commit -m "feat: implement Coherence page with uncertainties" +``` + +--- + +### Task 20: Time Lags page (new route + nav) + +**Files:** +- Create: `src/pages/QuickLook/TimeLags/index.tsx` +- Modify: `src/App.tsx`, `src/components/layout/Sidebar.tsx` + +- [x] **Step 1: Implement the page** + +Create `src/pages/QuickLook/TimeLags/index.tsx`. It is the Coherence page (Task 19) with these deltas applied to a fresh copy of that component source: + +1. Component name/export `TimeLagsPage`; runner type `TimeLagsData`; label `'Time Lags'`; import `{ timingApi, TimeLagsData }` and additionally `{ parseNumber }` from `@/utils/numbers`. +2. Add optional frequency-range state after `segmentSize`: +```tsx + const [freqMin, setFreqMin] = useState(''); + const [freqMax, setFreqMax] = useState(''); +``` +and parse: `const fMin = parseNumber(freqMin); const fMax = parseNumber(freqMax);` +3. `handleRun` becomes: +```tsx + const handleRun = (): void => { + if (!dtNum || !segNum) return; + const freq_range: [number, number] | undefined = + fMin !== null && fMax !== null && fMax > fMin ? [fMin, fMax] : undefined; + void run(() => + timingApi.calculateTimeLags({ + event_list_1_name: eventList1, + event_list_2_name: eventList2, + dt: dtNum, + segment_size: segNum, + freq_range, + }) + ); + }; +``` +4. Add two small fields after the segment-size TextField: +```tsx + + setFreqMin(e.target.value)} + /> + setFreqMax(e.target.value)} + /> + +``` +5. Replace the `traces` constant with: +```tsx + const traces: Data[] = result + ? [ + { + x: result.freq, + y: result.time_lags, + type: 'scattergl', + mode: 'lines+markers', + marker: { size: 4, color: '#00d4aa' }, + line: { color: '#00d4aa', width: 1 }, + error_y: result.time_lags_err + ? { type: 'data', array: result.time_lags_err, visible: true, color: 'rgba(0, 212, 170, 0.35)' } + : undefined, + } as Data, + ] + : []; +``` +and in the chart layout use `yaxis: { title: { text: 'Time lag (s)' } }` (no fixed range), with the dashed reference shape at `y0: 0, y1: 0` (zero lag) instead of 1. +6. `PageTemplate` props: `title="Time Lags"`, `description="Frequency-dependent time lags between two energy bands (positive = band 1 lags band 2)"`, `category="Frequency Domain"`. +7. Result header chip: `TimeLagsData` has no `n_segments` — replace that chip with one showing `result.freq_range` when set: +```tsx + {result?.freq_range && ( + + )} +``` + +- [x] **Step 2: Register route and navigation** + +`src/App.tsx`: +- After the `CoherencePage` import (line 33) add: +```tsx +import TimeLagsPage from '@/pages/QuickLook/TimeLags'; +``` +- After the coherence route (`{ path: 'quicklook/coherence', element: },`) add: +```tsx + { path: 'quicklook/time-lags', element: }, +``` + +`src/components/layout/Sidebar.tsx`: +- In `submenuItems` after the Coherence entry (line 87) add: +```tsx + { text: 'Time Lags', path: '/quicklook/time-lags' }, +``` +- In `quicklookCategories`, append `'Time Lags'` to the `'Frequency Domain'` array (after `'Coherence'`). + +- [x] **Step 3: Verify** + +Run: `npm run typecheck && npm run lint && npm test -- --run` +Expected: clean. + +- [x] **Step 4: Manual verification** + +Sidebar → QuickLook → Frequency Domain shows "Time Lags"; page computes and plots lags with error bars and a zero reference line; the f-range fields restrict the plotted band. + +- [x] **Step 5: Commit** + +```bash +git add src/pages/QuickLook/TimeLags/ src/App.tsx src/components/layout/Sidebar.tsx +git commit -m "feat: add Time Lags page with route and navigation" +``` + +--- + +### Task 21: Power Colors page (new route + nav) + +**Files:** +- Create: `src/utils/powerColors.ts`, `src/utils/powerColors.test.ts`, `src/pages/QuickLook/PowerColors/index.tsx` +- Modify: `src/App.tsx`, `src/components/layout/Sidebar.tsx` + +- [x] **Step 1: Write the failing helper test** + +`src/utils/powerColors.test.ts`: +```ts +import { describe, expect, it } from 'vitest'; +import { computePowerColorRatios } from './powerColors'; + +describe('computePowerColorRatios', () => { + const powerColors = { + A: [1, 2], + B: [10, 20], + C: [100, 200], + D: [5, 10], + }; + + it('computes PC1 = C/A and PC2 = B/D per segment', () => { + const result = computePowerColorRatios(powerColors, ['A', 'B', 'C', 'D']); + expect(result).not.toBeNull(); + expect(result?.pc1).toEqual([100, 100]); + expect(result?.pc2).toEqual([2, 2]); + }); + + it('returns null when a band is missing or count is not 4', () => { + expect(computePowerColorRatios(powerColors, ['A', 'B', 'C'])).toBeNull(); + expect(computePowerColorRatios({ A: [1] }, ['A', 'B', 'C', 'D'])).toBeNull(); + }); + + it('skips segments with non-positive denominators', () => { + const result = computePowerColorRatios( + { A: [0, 1], B: [1, 1], C: [1, 1], D: [1, 1] }, + ['A', 'B', 'C', 'D'] + ); + expect(result?.pc1).toEqual([1]); + expect(result?.pc2).toEqual([1]); + }); +}); +``` + +- [x] **Step 2: Run to verify it fails, then implement the helper** + +Run: `npm test -- --run src/utils/powerColors` → FAIL. + +`src/utils/powerColors.ts`: +```ts +/** + * Power-color ratios following the Heil et al. (2015) convention: with four + * frequency bands A < B < C < D (ascending f_min), + * PC1 = P(C) / P(A) and PC2 = P(B) / P(D) + * computed per dynamical-spectrum segment. + */ +export interface PowerColorRatios { + pc1: number[]; + pc2: number[]; +} + +export function computePowerColorRatios( + powerColors: Record, + bandOrder: string[] +): PowerColorRatios | null { + if (bandOrder.length !== 4) return null; + const [a, b, c, d] = bandOrder.map((key) => powerColors[key]); + if (!a || !b || !c || !d) return null; + + const n = Math.min(a.length, b.length, c.length, d.length); + const pc1: number[] = []; + const pc2: number[] = []; + for (let i = 0; i < n; i++) { + if (a[i] > 0 && d[i] > 0 && c[i] > 0 && b[i] > 0) { + pc1.push(c[i] / a[i]); + pc2.push(b[i] / d[i]); + } + } + return { pc1, pc2 }; +} +``` + +Run: `npm test -- --run src/utils/powerColors` → PASS. + +- [x] **Step 3: Implement the page** + +Create `src/pages/QuickLook/PowerColors/index.tsx`: +```tsx +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + CircularProgress, + Grid, + Stack, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { timingApi, PowerColorsData } from '@/api/timingApi'; +import { parsePositiveNumber } from '@/utils/numbers'; +import { computePowerColorRatios } from '@/utils/powerColors'; + +interface BandInput { + label: string; + fmin: string; + fmax: string; +} + +// Heil et al. (2015) bands; require dt <= 1/(2*16) s for the top band. +const DEFAULT_BANDS: BandInput[] = [ + { label: 'A', fmin: '0.0039', fmax: '0.031' }, + { label: 'B', fmin: '0.031', fmax: '0.25' }, + { label: 'C', fmin: '0.25', fmax: '2.0' }, + { label: 'D', fmin: '2.0', fmax: '16.0' }, +]; + +const BAND_COLORS = ['#00d4aa', '#3b82f6', '#f59e0b', '#ef4444']; + +const PowerColorsPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.03125'); + const [segmentSize, setSegmentSize] = useState('64'); + const [bands, setBands] = useState(DEFAULT_BANDS); + const { result, running, error, run } = useAnalysisRunner('Power Colors'); + + const dtNum = parsePositiveNumber(dt); + const segNum = parsePositiveNumber(segmentSize); + + const parsedBands = bands.map((b) => ({ + label: b.label, + fmin: parsePositiveNumber(b.fmin), + fmax: parsePositiveNumber(b.fmax), + })); + const bandsValid = parsedBands.every( + (b) => b.fmin !== null && b.fmax !== null && b.fmax > b.fmin + ); + const canRun = eventList !== '' && dtNum !== null && segNum !== null && bandsValid && !running; + + const updateBand = (index: number, field: 'fmin' | 'fmax', value: string): void => { + setBands((prev) => prev.map((b, i) => (i === index ? { ...b, [field]: value } : b))); + }; + + const handleRun = (): void => { + if (!dtNum || !segNum || !bandsValid) return; + const freq_ranges: Record = {}; + for (const b of parsedBands) { + freq_ranges[b.label] = [b.fmin as number, b.fmax as number]; + } + void run(() => + timingApi.calculatePowerColors({ + event_list_name: eventList, + dt: dtNum, + segment_size: segNum, + freq_ranges, + }) + ); + }; + + const bandTraces: Data[] = result + ? Object.entries(result.power_colors).map(([label, values], i) => ({ + x: result.time, + y: values, + type: 'scattergl', + mode: 'lines+markers', + marker: { size: 4 }, + line: { width: 1, color: BAND_COLORS[i % BAND_COLORS.length] }, + name: label, + })) + : []; + + const ratios = result + ? computePowerColorRatios(result.power_colors, bands.map((b) => b.label)) + : null; + + const scatterTrace: Data[] = ratios + ? [ + { + x: ratios.pc1, + y: ratios.pc2, + type: 'scattergl', + mode: 'markers', + marker: { size: 6, color: '#00d4aa' }, + }, + ] + : []; + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText="Nyquist must cover the highest band" + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText="Must exceed 1/f_min of the lowest band" + /> + Frequency bands (Hz) + {bands.map((b, i) => ( + + + {b.label} + + updateBand(i, 'fmin', e.target.value)} + /> + updateBand(i, 'fmax', e.target.value)} + /> + + ))} + {!bandsValid && ( + Each band needs 0 < f min < f max. + )} + + + + + + + + + + {error && ( + + {error} + + )} + {result ? ( + + + + Band power vs time + + + + {ratios && ratios.pc1.length > 0 && ( + + + Power-color diagram (PC1 = C/A, PC2 = B/D) + + + + )} + + ) : ( + + + Compute band powers to populate the power-color diagram. + + + )} + + + + + + ); +}; + +export default PowerColorsPage; +``` + +- [x] **Step 4: Register route and navigation** + +`src/App.tsx`: +- After the `TimeLagsPage` import add: +```tsx +import PowerColorsPage from '@/pages/QuickLook/PowerColors'; +``` +- After the bispectrum route (`{ path: 'quicklook/bispectrum', element: },`) add: +```tsx + { path: 'quicklook/power-colors', element: }, +``` + +`src/components/layout/Sidebar.tsx`: +- In `submenuItems` after the Bispectrum entry add: +```tsx + { text: 'Power Colors', path: '/quicklook/power-colors' }, +``` +- In `quicklookCategories`, append `'Power Colors'` to the `'Advanced Analysis'` array (after `'Bispectrum'`). + +- [x] **Step 5: Verify** + +Run: `npm test -- --run && npm run typecheck && npm run lint` +Expected: all pass. + +- [x] **Step 6: Manual verification** + +Compute with the defaults on a loaded list (needs ≥ 64 s of data and dt=0.03125): four band traces render; PC diagram shows one point per segment. + +- [x] **Step 7: Commit** + +```bash +git add src/utils/powerColors.ts src/utils/powerColors.test.ts src/pages/QuickLook/PowerColors/ src/App.tsx src/components/layout/Sidebar.tsx +git commit -m "feat: add Power Colors page with band powers and PC diagram" +``` + +--- + +### Task 22: Full-suite verification and end-to-end pass + +- [x] **Step 1: Run every automated gate** + +```bash +npm test -- --run +npm run typecheck +npm run lint +pixi run -e dev pytest python-backend/tests -v +``` +Expected: all green. Fix anything that isn't before proceeding. + +- [x] **Step 2: End-to-end manual checklist** + +Start `npm run dev`. Load one sample event file from `files/data/` twice under two names (`evA`, `evB`). Then walk every page: + +| Page | Action | Expect | +|---|---|---| +| Event List | select evA, both tabs | info + GTI table + 2 histograms | +| Light Curve | generate dt=1, rebin ×2 | plot, chips, stored list grows | +| Power Spectrum | dt=0.0625, leahy, store, rebin log | Poisson level ≈ 2 at high f | +| Avg Power Spectrum | segment=16 | smoother spectrum, segments chip | +| Cross Spectrum | evA × evB | magnitude + phase panels | +| Avg Cross Spectrum | segment=16 | less scatter | +| Dynamical PS | dt=0.0625, segment=8 | heatmap, log-color toggle | +| Bispectrum | dt=0.1, maxlag=25 | magnitude + phase tabs; UI stays responsive | +| Coherence | evA × evA | γ² ≈ 1 flat line at the reference | +| Time Lags | evA × evB, then f-range 0.5–2 | error bars, zero line, filtered range | +| Power Colors | defaults | 4 band traces + PC scatter | + +Also confirm: notifications fire on each success/failure; the log panel keeps streaming during a Bispectrum run; no `coming soon` banner remains on any of the eleven pages. + +- [x] **Step 3: Check off this plan** + +Mark all checkboxes in this document, note any deviations at the bottom, and commit the plan updates: +```bash +git add docs/superpowers/plans/2026-06-10-quicklook-core-pages.md +git commit -m "docs: record quicklook core implementation plan completion" +``` + +--- + +## Execution notes (2026-06-11) + +- **Rebin factor semantics fixed on both paths**: lightcurve rebin uses `f=` (fractional factor) not the positional `dt` arg; spectrum `rebin()` similarly uses `f=rebin_factor` to avoid passing df-in-Hz. +- **Power-colors axis bug fixed**: `dps.dyn_ps` has shape `(n_freq, n_time)` — mask and mean were applied along the wrong axis in earlier draft; corrected to `dps.dyn_ps[mask, :].mean(axis=0)`. +- **longdouble serialization**: `dps.time`, `lc.time`, and similar stingray arrays use numpy `longdouble`; serialization now coerces via `.astype(float).tolist()` or `float(v)` casts to avoid JSON failures on macOS/Linux where `longdouble` is not JSON-safe. +- **dyn_ps NaN sanitization**: `_finite_list` applied row-by-row on `dps.dyn_ps` to replace any NaN/Inf cells with `null` before JSON serialization. +- **Segment-size + overlap + empty-list guards added**: `_overlap_error` in both `spectrum_service.py` and `timing_service.py` now checks empty event lists first, then disjoint ranges, then optionally rejects if overlap duration < `segment_size`; the optional `segment_size` parameter is passed at all three averaged call sites (`create_averaged_cross_spectrum`, `calculate_time_lags`, `calculate_coherence`), NOT at plain `create_cross_spectrum`. +- **ESLint config created**: project had no `.eslintrc` / `eslint.config.js`; created `eslint.config.js` with `@typescript-eslint` and `react-hooks` rules to make `npm run lint` operational. +- **6 pre-existing typecheck errors cleared**: stray `any` types, missing return-type annotations, and an unused import in route handler stubs were fixed before the first typecheck gate. +- **maxlag capped at 500 + cum3 dropped from payload**: bispectrum `cum3` field is large and unused by the UI; dropped from the JSON response. `maxlag` is validated ≤ 500 server-side to prevent OOM on large lags. +- **Lag sign convention pinned**: positive lag = second list leads first list (stingray convention); documented in the Time Lags page UI and pinned by `test_time_lag_sign_convention_for_shifted_signal`. +- **Time Lags + Power Colors pages added with routes/nav**: Tasks 20–21 added `src/pages/QuickLook/TimeLags/index.tsx` and `PowerColors/index.tsx`, wired routes in `src/App.tsx`, and added sidebar entries in `src/components/layout/Sidebar.tsx`. +- **Decimation with longdouble coercion on lightcurve payloads**: stride-decimation helper also coerces `lc.time` (longdouble on some platforms) to `float64` before `tolist()`. +- **Band-mean (not integrated) power-colors convention documented**: power colors use `dps.dyn_ps[mask, :].mean(axis=0)` (mean over frequencies in band per segment) following the Heil et al. 2015 convention; "integrated" wording removed from UI descriptions to avoid confusion with flux integrals. + +## E2E execution notes (2026-07-29) + +Task 22 Step 2 manual checklist executed against `npm run dev` (agent-driven via CDP: `--remoteDebuggingPort` + playwright-core `connectOverCDP`). All 11 page rows passed: Event List (both tabs, GTI table, 2 histograms), Light Curve (dt=1, rebin ×2 → 512 bins, stored list grows), Power Spectrum (Leahy Poisson level ≈ 2 at high f, log rebin 8,199 → 258 freqs), Avg Power Spectrum (64-segments chip, smoother), Cross Spectrum (magnitude + phase; phase ≡ 0 for identical files), Avg Cross Spectrum (less scatter), Dynamical PS (heatmap + log-color toggle), Bispectrum (magnitude/phase tabs, console kept streaming during run), Coherence (γ² ≡ 1 for evA × evA), Time Lags (error bars, zero line, f-range 0.5–2 Hz filter verified in plot data), Power Colors (4 band traces + PC scatter). Success and failure notifications both fire; no coming-soon banner on any of the eleven pages. Deviations: event files were loaded via `POST /api/data/load` (the Browse button opens a native macOS dialog, which is not automatable; the rest of the flow used the real UI). Three Electron-shell issues were found outside the plan's scope during startup — hard 180 s backend-ready timeout leaves the app stuck on "Error" even after the backend becomes healthy; `PythonManager.stop()`'s 5 s force-kill timer is never cancelled and SIGKILLs the replacement backend on restart; `python:restart` IPC never re-sends `python:ready`/`python:error` to the renderer — tracked separately. diff --git a/docs/superpowers/plans/2026-07-29-quicklook-remaining-pages.md b/docs/superpowers/plans/2026-07-29-quicklook-remaining-pages.md new file mode 100644 index 0000000..f18e1f5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-quicklook-remaining-pages.md @@ -0,0 +1,134 @@ +# QuickLook Remaining Pages Implementation Plan (2026-07-29) + +**Goal:** Replace the nine remaining QuickLook placeholder pages — AutoCorrelation, CrossCorrelation, CovarianceSpectrum, AvgCovarianceSpectrum, RmsEnergySpectrum, LagEnergySpectrum, ExcessVarianceSpectrum, VariableEnergySpectrum, DeadTimeCorrections — with working analysis UIs. Unlike the 2026-06-10 plan, **no backend endpoints exist for any of these**: each needs a service method + FastAPI route + frontend API module + page. + +**Grounding:** All stingray behavior cited below was verified by introspecting the installed stingray 2.2.10 in `.pixi/envs/default` (not the AI_DOCS guides, several of which contain verified inaccuracies). Routes and sidebar nav entries for all 9 pages already exist; page stubs are 15-line `PageTemplate status="coming-soon"` components. + +--- + +## Verified stingray facts the implementation MUST honor + +### Correlation (`stingray.crosscorrelation`) +- `CrossCorrelation(lc1=None, lc2=None, cross=None, mode='same', norm='none')`; `AutoCorrelation(lc=None, mode='same')` — AutoCorrelation does **not** forward `norm` (always `'none'`). For a normalized ACF use `CrossCorrelation(lc, lc, norm='variance')`. +- Inputs must be `Lightcurve` (EventList NOT accepted). Attributes: `.corr`, `.time_lags` (seconds), `.time_shift` (argmax lag), `.dt`, `.n`, `.mode`, `.norm`. +- **Sign convention (verified with Gaussian-pulse alignment): `time_shift > 0` ⟺ lc1 (first arg) lags lc2; `time_shift < 0` ⟺ lc1 leads.** +- **stingray performs NO time-grid alignment**: it correlates `counts` arrays by position, ignoring `.time` entirely. The service MUST bin both event lists onto an identical grid over their common time range (shared bin edges) before constructing light curves. +- `norm='variance'` can silently produce **all-NaN `.corr`** (negative noise-subtracted variance for flat/low-count curves) with a bogus `time_shift`; the service must detect NaN and null out `time_shift` + attach a warning. +- `mode='full'|'valid'` are only reliable on the two-Lightcurve path; with equal-length inputs `'valid'` degenerates to one point. Expose `same` (default) and `full` only. + +### Var-energy spectra (`stingray.varenergyspectrum`) +- `RmsSpectrum` (= alias `RmsEnergySpectrum`), `LagSpectrum` (= `LagEnergySpectrum`), `CovarianceSpectrum`, `ComplexCovarianceSpectrum`, `ExcessVarianceSpectrum`, `CountSpectrum`. All take `EventList` with `.energy` (or `.pi` with `use_pi=True`). +- `energy_spec` = tuple `(emin, emax, n_bins, 'lin'|'log')`; `freq_interval=[fmin,fmax)`; outputs `.energy`, `.spectrum`, `.spectrum_error` (float64, NaN for skipped bins). +- **`segment_size=None` CRASHES `RmsSpectrum` and `CovarianceSpectrum`** (TypeError). Always require an explicit numeric segment_size. `LagSpectrum` tolerates None (m=1) but require it anyway. +- **`ref_band` is silently inert for `RmsSpectrum`** (single-EventList case) — do not expose it on the RMS page. It IS used by `LagSpectrum`/`CovarianceSpectrum`. +- **`ExcessVarianceSpectrum` is broken in 2.2.10**: `_spectrum_function()` returns `(spec, spec_err)` but `__init__` discards it → `.spectrum` always all-NaN. Workaround (verified): call `spec, spec_err = xvs._spectrum_function()` manually and use the returned arrays. `ExcessVarianceSpectrum` positional order is `(events, freq_interval, energy_spec, ...)` — different from every sibling; pass all kwargs by keyword. It ignores `segment_size` entirely. +- Covariance with pure Poisson noise is legitimately all-NaN (no excess variance in ref band → sqrt of negative). Tests need **correlated variability** (shared sinusoidally-modulated rate across energies); low-count bins emit `UserWarning ... Skipping.` and stay NaN — capture and surface these warnings. +- `min_phot_per_segment` is only a constructor arg on Covariance/ComplexCovariance (default 10); Rms/Lag do not accept it. +- **Legacy module `stingray.covariancespectrum`** (`Covariancespectrum`, `AveragedCovariancespectrum`) exists in 2.2.10, distinct API (takes raw `[time, energy]` event data, `band_interest`/`ref_band_interest`, `std`). Not yet introspected — the covariance implementer must introspect it before writing tests. + +### Dead time (`stingray.deadtime`) +- `r_det(td, r_i)`, `r_in(td, r_0)` — trivial rate conversions (implement client-side). +- `.deadtime_correct(dead_time, rate, background_rate=0, limit_k=200, n_approx=None, paralyzable=False)` → corrected copy. **Assumes Leahy norm without checking** (correction is `2/model`); `paralyzable=True` raises `NotImplementedError`; `(rate+background_rate)*dead_time >= 1` raises ValueError — pre-validate with a readable message. `rate` is the DETECTED rate (`n_events / total GTI exposure`). +- `stingray.deadtime.fad.FAD(data1, data2, segment_size, dt=None, norm='frac', ...)` → astropy Table with columns `freq, pds1, pds2, cs, ptot, *_unnorm, fad`. Needs two independent simultaneous EventLists (different detectors). Verified working, fast (0.16 s for 300 s of data). Recommend ≥30 segments — warn (don't block) below that. +- First call to numba-JIT'd deadtime functions pays ~1 s compile cost per process — acceptable, no warmup needed. +- `check_A`/`check_B` have matplotlib global-state side effects — do NOT call them in the backend. + +--- + +## Design decisions + +1. **Three new backend service/route pairs** (keeps files disjoint for parallel work): + - `services/correlation_service.py` + `routes/correlation_routes.py` → `/api/correlation/*` + - `services/varenergy_service.py` + `routes/varenergy_routes.py` → `/api/varenergy/*` + - `services/deadtime_service.py` + `routes/deadtime_routes.py` → `/api/deadtime/*` +2. **Shared helpers hoisted**: new `services/analysis_helpers.py` with `finite_list`, `segment_size_error`, `overlap_error` (same code as the copies in timing/spectrum services). New services import from it; the two existing services are left untouched (separate cleanup, not this plan). +3. **Warnings surfaced**: every service method runs its stingray call inside `warnings.catch_warnings(record=True)` and returns `"warnings": [str, ...]` (deduplicated) in `data`, so the UI can show low-count/NaN advisories. +4. **Covariance page pairing** mirrors PowerSpectrum/AvgPowerSpectrum using the LEGACY module: CovarianceSpectrum page → `stingray.Covariancespectrum` (whole-lightcurve), AvgCovarianceSpectrum page → `stingray.AveragedCovariancespectrum` (segmented). If introspection shows the legacy classes are broken/unusable in 2.2.10, fall back to `varenergyspectrum.CovarianceSpectrum` for BOTH (unsegmented page uses one segment spanning the GTI) and record the deviation at the bottom of this doc. +5. **VariableEnergySpectrum page = variability-vs-energy overview**: one endpoint computing `CountSpectrum` + `RmsSpectrum` + `LagSpectrum` with shared parameters; the page renders three stacked panels. (The abstract `VarEnergySpectrum` base has no direct product; this is the useful composite.) +6. **DeadTimeCorrections page = three panels**: (a) rate calculator, pure client-side TS (`r_det`/`r_in` formulas + `rate*td ≥ 1` guard); (b) model-based PDS correction endpoint; (c) FAD two-detector endpoint. +7. **ExcessVariance workaround** encapsulated in the service with a comment naming the upstream bug; test pins finite output on modulated data. +8. **Response envelope**: bare `create_result` dicts (timing/spectrum precedent), soft-fail `success:false` for domain errors, `asyncio.to_thread` in every route. +9. **Frontend**: three new API modules (`correlationApi.ts`, `varenergyApi.ts`, `deadtimeApi.ts`), interfaces defined below; each page follows the TimeLags runner-page skeleton, `scattergl` traces, chips, warning `Alert` when `warnings` non-empty. Every page gets a vitest test following the LightCurve/EventList mock pattern (api module mocked, PlotlyChart stubbed, assert request fields + disabled-until-valid). + +## Endpoint contracts + +All request fields snake_case; all array outputs pass through `finite_list` (NaN→null). Every `data` payload includes `"warnings": string[]`. + +### POST /api/correlation/auto-correlation +Req `{ event_list_name, dt, mode='same', norm='none' }` (norm: `none|variance`; variance path uses `CrossCorrelation(lc, lc, ...)`). +Data `{ time_lags[], corr[], time_shift|null, dt, n, mode, norm, warnings }` — `time_shift` nulled when corr contains NaN. + +### POST /api/correlation/cross-correlation +Req `{ event_list_1_name, event_list_2_name, dt, mode='same', norm='none' }`. +Service: `overlap_error` check → crop both to common `[max(start), min(stop)]` → bin with SHARED edges → `CrossCorrelation(lc1, lc2)`. +Data: same shape as auto. Test pins sign convention: ev2 = ev1 shifted +0.5 s ⇒ lc2 lags lc1 ⇒ `time_shift == -0.5 ± dt`. + +### POST /api/varenergy/rms-spectrum +Req `{ event_list_name, bin_time, segment_size, freq_min, freq_max, energy_min, energy_max, n_bands, log_bands=false, norm='frac' }`. +Data `{ energy[], spectrum[], spectrum_error[], freq_range:[f,f], norm, n_segments_hint?, warnings }`. No ref_band (inert in stingray). + +### POST /api/varenergy/lag-spectrum +Req: rms fields minus `norm`, plus optional `ref_min`, `ref_max` (both-or-neither; None → full band). +Data: same shape; spectrum in seconds. UI draws a zero line. + +### POST /api/varenergy/excess-variance +Req `{ event_list_name, bin_time, energy_min, energy_max, n_bands, log_bands=false, normalization='fvar' }` (+ freq_min/freq_max if introspection shows they matter; implementer verifies). +Service constructs `ExcessVarianceSpectrum` then applies the `_spectrum_function()` workaround. +Data `{ energy[], spectrum[], spectrum_error[], normalization, warnings }`. + +### POST /api/varenergy/variable-energy-spectrum +Req: shared `{ event_list_name, bin_time, segment_size, freq_min, freq_max, energy_min, energy_max, n_bands, log_bands, ref_min?, ref_max? }`. +Data `{ energy[], counts:{spectrum[],error[]}, rms:{spectrum[],error[]}, lag:{spectrum[],error[]}, warnings }`. + +### POST /api/varenergy/covariance-spectrum and /avg-covariance-spectrum +Contracts finalized by the implementer after introspecting the legacy module; must include `{ energy[], spectrum[], spectrum_error[], warnings }` plus whatever band/segment params the API needs, following the field-naming conventions above. Document the final contract in this file's execution notes. + +### POST /api/deadtime/pds-correction +Req `{ event_list_name, dt, segment_size, dead_time, background_rate=0, limit_k=200 }`. +Service: `(rate+background_rate)*dead_time >= 1` → readable soft-fail; norm hard-locked to leahy. +Data `{ freq[], power_uncorrected[], power_corrected[], rate, n_segments, warnings }`. + +### POST /api/deadtime/fad-correction +Req `{ event_list_1_name, event_list_2_name, dt, segment_size, norm='frac', smoothing_length? }`. +Service: overlap check; `< 30` segments → warning (still computes). +Data `{ freq[], pds1[], pds2[], ptot[], cs[], n_segments, warnings }`. + +## Frontend API module interfaces + +`correlationApi.ts`: `CorrelationData { time_lags: number[]; corr: (number|null)[]; time_shift: number|null; dt: number; n: number; mode: string; norm: string; warnings: string[] }`; methods `autoCorrelation(params)`, `crossCorrelation(params)` mirroring the request fields 1:1. + +`varenergyApi.ts`: `VarEnergyData { energy: number[]; spectrum: (number|null)[]; spectrum_error: (number|null)[]; warnings: string[]; [k: string]: unknown }`; `VariableEnergyData { energy: number[]; counts: Band; rms: Band; lag: Band; warnings: string[] }` with `Band { spectrum: (number|null)[]; error: (number|null)[] }`; methods `rmsSpectrum`, `lagSpectrum`, `excessVariance`, `variableEnergySpectrum`, `covarianceSpectrum`, `avgCovarianceSpectrum`. + +`deadtimeApi.ts`: `PdsCorrectionData { freq: number[]; power_uncorrected: (number|null)[]; power_corrected: (number|null)[]; rate: number; n_segments: number; warnings: string[] }`; `FadData { freq: number[]; pds1: (number|null)[]; pds2: (number|null)[]; ptot: (number|null)[]; cs: (number|null)[]; n_segments: number; warnings: string[] }`; methods `pdsCorrection`, `fadCorrection`. + +## Page specs (all: params card left md=4/lg=3, result card right, `useAnalysisRunner`, warning Alert when `result.warnings.length > 0`, vitest test per page) + +1. **AutoCorrelation** — selector, dt, mode (same/full), norm (none/variance); plot corr vs time_lags with zero-lag reference line; chips: n, dt, mode. +2. **CrossCorrelation** — two selectors, dt, mode, norm; plot + dashed vertical line at `time_shift` (when non-null) + chip `time shift: X s`; caption: "Positive shift ⇒ first list lags the second." +3. **RmsEnergySpectrum** — selector, bin_time (default 0.01), segment_size (default 8), f-range (0.1–1), energy range (0.5–10, 5 bands, lin/log toggle), norm (frac/abs); error-bar scatter energy vs rms. +4. **LagEnergySpectrum** — as RMS plus optional ref-band pair; zero line; y-axis seconds. +5. **ExcessVarianceSpectrum** — selector, bin_time, energy bands, normalization (fvar/none per introspection); error bars. +6. **VariableEnergySpectrum** — shared params; three stacked PlotlyCharts (counts / fractional rms / lag vs energy). +7. **CovarianceSpectrum / AvgCovarianceSpectrum** — per final backend contract; error-bar scatter vs energy; Avg adds segment_size + n_segments chip. +8. **DeadTimeCorrections** — panel (a) client-side calculator (incident⇄detected radio, dead_time; outputs both rates + loss fraction, error state when `rate*td ≥ 1`); panel (b) model correction: selector + dt + segment_size + dead_time + background_rate + limit_k, overlay uncorrected/corrected traces (log-log) + note "norm fixed to Leahy"; panel (c) FAD: two selectors + dt + segment_size + norm, multi-trace (pds1, pds2, ptot) + cs; warning chip when n_segments < 30. + +## Execution phases + +- **Phase 0 (orchestrator, inline):** this doc; `services/analysis_helpers.py`; empty skeleton service/route files; `main.py` router registration; commit. +- **Phase 1 (workflow, 3 parallel TDD agents):** correlation / varenergy / deadtime — each introspects what it needs, writes failing pytest cases in its own `tests/test__service.py`, implements service + routes, runs `pixi run -e dev pytest` to green. Files per agent are disjoint. +- **Phase 2 (workflow):** one agent writes the three API modules to the interfaces above; then 9 parallel page agents (one per page dir) write `index.tsx` + `index.test.tsx`; gates `npm run typecheck && npm run lint && npm test -- --run`. +- **Phase 3 (verification workflow + orchestrator):** full-suite gates; adversarial science review of the three services against this doc's verified-facts section; live E2E of all 9 pages via CDP with a dense synthetic event file (modulated correlated variability so covariance/rms produce finite results); fix findings; update this doc's execution notes; commit. + +## Execution notes (2026-07-29) + +All phases executed same-day; every gate green at completion (backend pytest **145 passed**, vitest **72 passed**, typecheck clean, eslint 0 errors). Live E2E of all nine pages performed via CDP against `npm run dev` with dense synthetic data (120k-event 0.5 Hz-modulated list + 0.5 s-shifted copy + two independent dead-time-filtered streams, loaded as HDF5 via `/api/data/load`). + +**Key deviations/decisions (full details in service docstrings and tests):** +- Legacy `stingray.covariancespectrum` module rejected after introspection proved it histograms the energy column as arrival times and its "averaged" variant always computes exactly one segment; both covariance endpoints use `varenergyspectrum.CovarianceSpectrum` (unsegmented page = one segment spanning the longest GTI, with GTI-usage accounting fields). +- `ExcessVarianceSpectrum` 2.2.10 discard-bug worked around via a subclass whose `_spectrum_function` stores its results (single compute); excess variance additionally masks inter-GTI gap bins (`create_gti_mask`), which the upstream class does not — gaps otherwise fabricate variability. +- `stingray.deadtime.fad.FAD` mutates its inputs' GTIs; the service passes detached EventList copies. `fad_delta` can be NaN (identical inputs) — serialized as null + warning. +- Cross/auto correlation bin on a shared relative-time grid built with `np.histogram` (stingray ignores absolute time and `to_lc` snaps dt); 500k-bin cap; unsorted-time-safe bounds (`np.min`/`np.max`); verified sign convention pinned: positive `time_shift` ⇒ first list lags second. +- `collect_warnings` is context-aware on Python 3.14 (`PYTHON_CONTEXT_AWARE_WARNINGS=1` set at spawn; `-X` flag in `python:dev`) with a serializing RLock fallback — `warnings.catch_warnings` is otherwise process-global and concurrent captures cross-contaminate and can permanently orphan the global warning hook. +- Post-implementation adversarial review (5 reviewers + per-finding verification) confirmed 22 findings (1 critical, 10 major, 11 minor); all fixed and pinned with discriminating tests (each verified to fail against the pre-fix code where practical). One rejected finding (hardcoded trace colors) left as-is. +- Live E2E numeric cross-checks: recovered `time_shift = -0.5000 s` for the +0.5 s-shifted list; Leahy dead-time correction restored mean power ≈ 2 with the calculator's predicted detected rate (171.43 c/s) matching the measured data rate (171.41 c/s); FAD Δ = 0.007 (compliant) at 37 segments; counts/band = 24k = 120k/5. +- Plotly quirk (verified live): shapes on log axes use RAW data coordinates in the bundled plotly version, not log10 — the Leahy reference line is passed untransformed. diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 0000000..d977103 --- /dev/null +++ b/electron.vite.config.ts @@ -0,0 +1,59 @@ +import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + outDir: 'dist-electron', + emptyOutDir: false, + rollupOptions: { + input: resolve(__dirname, 'electron/main.ts'), + output: { + format: 'es', + entryFileNames: '[name].js', + }, + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + outDir: 'dist-electron', + emptyOutDir: false, + rollupOptions: { + input: resolve(__dirname, 'electron/preload.ts'), + output: { + format: 'cjs', + // Must be .cjs: the root package.json sets "type": "module", and + // Electron >= ~29 resolves unsandboxed preload module type Node-style, + // so a CommonJS preload named .js is parsed as ESM and crashes before + // contextBridge.exposeInMainWorld runs (electronAPI never appears). + entryFileNames: '[name].cjs', + }, + }, + }, + }, + renderer: { + root: '.', + build: { + outDir: 'dist', + rollupOptions: { + input: resolve(__dirname, 'index.html'), + }, + }, + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + watch: { + // Ignore large directories to prevent ENOSPC errors on Linux + ignored: ['**/.pixi/**', '**/node_modules/**', '**/.git/**'], + }, + }, + }, +}); diff --git a/electron/backendSessionPolicy.ts b/electron/backendSessionPolicy.ts new file mode 100644 index 0000000..5807548 --- /dev/null +++ b/electron/backendSessionPolicy.ts @@ -0,0 +1,66 @@ +export const BACKEND_SESSION_HEADER = 'X-Stingray-Session'; + +export interface BackendSessionRequestContext { + requestUrl: string; + method: string; + backendPort: number; + requestWebContentsId?: number; + trustedWebContentsId: number; + isMainFrame: boolean; + frameUrl: string; + rendererEntryUrl: string; +} + +/** Return true only for the trusted app document, never a child or navigated page. */ +export function isTrustedRendererLocation(currentUrl: string, entryUrl: string): boolean { + try { + const current = new URL(currentUrl); + const entry = new URL(entryUrl); + return ( + current.protocol === entry.protocol && + current.username === entry.username && + current.password === entry.password && + current.hostname === entry.hostname && + current.port === entry.port && + current.pathname === entry.pathname && + current.search === entry.search + ); + } catch { + return false; + } +} + +/** Decide whether Electron main may authenticate one renderer request. */ +export function shouldAuthenticateBackendRequest( + context: BackendSessionRequestContext +): boolean { + if (context.method.toUpperCase() === 'OPTIONS') return false; + if (context.requestWebContentsId !== context.trustedWebContentsId) return false; + if (!context.isMainFrame) return false; + if (!isTrustedRendererLocation(context.frameUrl, context.rendererEntryUrl)) return false; + + try { + const target = new URL(context.requestUrl); + return ( + target.protocol === 'http:' && + target.hostname === '127.0.0.1' && + target.port === String(context.backendPort) + ); + } catch { + return false; + } +} + +/** Strip renderer-supplied copies and optionally install the main-process secret. */ +export function withBackendSessionHeader( + requestHeaders: Record, + sessionSecret?: string +): Record { + const headers = Object.fromEntries( + Object.entries(requestHeaders).filter( + ([name]) => name.toLowerCase() !== BACKEND_SESSION_HEADER.toLowerCase() + ) + ); + if (sessionSecret) headers[BACKEND_SESSION_HEADER] = sessionSecret; + return headers; +} diff --git a/electron/backendStatus.ts b/electron/backendStatus.ts new file mode 100644 index 0000000..dfd96cd --- /dev/null +++ b/electron/backendStatus.ts @@ -0,0 +1,61 @@ +import type { BackendStatus } from '../src/types/backendStatus'; + +export type BackendStatusTransition = + | { phase: 'starting' } + | { phase: 'ready'; port: number } + | { phase: 'error'; error: string } + | { phase: 'stopped' }; + +type BackendStatusListener = (status: BackendStatus) => void; + +/** Main-process owner for the renderer-visible backend lifecycle snapshot. */ +export class BackendStatusStore { + private status: BackendStatus = { + revision: 0, + phase: 'stopped', + port: null, + error: null, + }; + + constructor(private readonly notify: BackendStatusListener) {} + + getSnapshot(): BackendStatus { + return { ...this.status }; + } + + publish(transition: BackendStatusTransition): BackendStatus { + const revision = this.status.revision + 1; + + switch (transition.phase) { + case 'ready': + this.status = { + revision, + phase: 'ready', + port: transition.port, + error: null, + }; + break; + case 'error': + this.status = { + revision, + phase: 'error', + port: null, + error: transition.error, + }; + break; + case 'starting': + case 'stopped': + this.status = { + revision, + phase: transition.phase, + port: null, + error: null, + }; + break; + } + + const snapshot = this.getSnapshot(); + this.notify(snapshot); + return snapshot; + } +} diff --git a/electron/fileGrantResponse.ts b/electron/fileGrantResponse.ts new file mode 100644 index 0000000..cd2d3a0 --- /dev/null +++ b/electron/fileGrantResponse.ts @@ -0,0 +1,52 @@ +export interface NativeFileGrant { + path: string; + grant: string; +} + +export const MAX_FILE_GRANT_RESPONSE_BYTES = 32 * 1024; + +const MAX_GRANTED_PATH_LENGTH = 4096; +const MAX_GRANT_LENGTH = 4096; +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/; + +/** + * Parse the private grant issuer's response without trusting its shape or size. + * Keeping this independent from Electron and Node HTTP makes the fail-closed + * response policy easy to exercise under Vitest. + */ +export function parseFileGrantResponse(body: string): NativeFileGrant { + if (Buffer.byteLength(body, 'utf8') > MAX_FILE_GRANT_RESPONSE_BYTES) { + throw new Error('The native file authorization response was too large'); + } + + let value: unknown; + try { + value = JSON.parse(body); + } catch { + throw new Error('The native file authorization response was not valid JSON'); + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The native file authorization response had an invalid shape'); + } + + const candidate = value as Record; + if ( + typeof candidate.path !== 'string' || + candidate.path.length === 0 || + candidate.path.length > MAX_GRANTED_PATH_LENGTH || + CONTROL_CHARACTER.test(candidate.path) + ) { + throw new Error('The native file authorization response contained an invalid path'); + } + if ( + typeof candidate.grant !== 'string' || + candidate.grant.length === 0 || + candidate.grant.length > MAX_GRANT_LENGTH || + CONTROL_CHARACTER.test(candidate.grant) + ) { + throw new Error('The native file authorization response contained an invalid grant'); + } + + return { path: candidate.path, grant: candidate.grant }; +} diff --git a/electron/ipcHandlers.ts b/electron/ipcHandlers.ts new file mode 100644 index 0000000..832c43e --- /dev/null +++ b/electron/ipcHandlers.ts @@ -0,0 +1,262 @@ +import { + ipcMain, + dialog, + app, + shell, + clipboard, + BrowserWindow, + type OpenDialogOptions, +} from 'electron'; +import { PythonManager, type NativeFileGrant } from './pythonManager'; +import type { BackendStatus } from '../src/types/backendStatus'; + +type PythonManagerGetter = () => PythonManager | null; +type PythonRestarter = () => Promise; +type BackendStatusGetter = () => BackendStatus; + +function requirePythonManager(pythonManager: PythonManager | null): PythonManager { + if (!pythonManager) { + throw new Error( + 'Native file authorization is unavailable because the Python backend is not initialized. Wait for startup to finish and select the file again.' + ); + } + return pythonManager; +} + +/** + * Set up all IPC handlers for communication between main and renderer processes + */ +export function setupIpcHandlers( + getPythonManager: PythonManagerGetter, + restartPython: PythonRestarter, + getBackendStatus: BackendStatusGetter +): void { + // ============================================ + // File Dialog Handlers + // ============================================ + + // Utility pages use signed variants of the native dialogs. The signature is + // checked by FastAPI and binds each request to this exact path and access + // mode, rather than trusting an arbitrary renderer-provided path string. + ipcMain.handle( + 'dialog:openGrantedFile', + async ( + event, + options?: { + title?: string; + filters?: { name: string; extensions: string[] }[]; + multiple?: boolean; + } + ): Promise => { + const parentWindow = BrowserWindow.fromWebContents(event.sender); + const dialogOptions: OpenDialogOptions = { + title: options?.title || 'Open Scientific File', + properties: options?.multiple + ? (['openFile', 'multiSelections'] as ('openFile' | 'multiSelections')[]) + : (['openFile'] as ('openFile')[]), + }; + // On macOS, even valid custom scientific extensions can be disabled when + // NSOpenPanel receives an extension filter. Omit the property entirely + // for unrestricted pickers; the backend validates the selected format. + if (options?.filters?.length) dialogOptions.filters = options.filters; + const selected = parentWindow + ? dialog.showOpenDialogSync(parentWindow, dialogOptions) + : dialog.showOpenDialogSync(dialogOptions); + if (!selected?.length) return null; + const pythonManager = requirePythonManager(getPythonManager()); + return Promise.all( + selected.map((selectedPath) => pythonManager.issueFileGrant(selectedPath, 'read')) + ); + } + ); + + ipcMain.handle( + 'dialog:saveGrantedFile', + async ( + event, + options?: { + title?: string; + defaultPath?: string; + filters?: { name: string; extensions: string[] }[]; + } + ): Promise => { + const parentWindow = BrowserWindow.fromWebContents(event.sender); + const dialogOptions = { + title: options?.title || 'Export Scientific Data', + defaultPath: options?.defaultPath, + filters: options?.filters || [ + { name: 'FITS', extensions: ['fits'] }, + { name: 'CSV', extensions: ['csv'] }, + { name: 'ECSV', extensions: ['ecsv'] }, + { name: 'JSON', extensions: ['json'] }, + ], + }; + const selected = parentWindow + ? dialog.showSaveDialogSync(parentWindow, dialogOptions) + : dialog.showSaveDialogSync(dialogOptions); + if (!selected) return null; + return requirePythonManager(getPythonManager()).issueFileGrant(selected, 'write'); + } + ); + + // ============================================ + // Python Backend Handlers + // ============================================ + + ipcMain.handle('python:getPort', () => { + const pythonManager = getPythonManager(); + return pythonManager?.getPort() || 8765; + }); + + ipcMain.handle('python:isRunning', () => { + const pythonManager = getPythonManager(); + return pythonManager?.getIsRunning() || false; + }); + + ipcMain.handle('python:getStatus', () => getBackendStatus()); + + // Restart goes through main.ts so the renderer receives the same + // python:starting/python:ready/python:error events as initial startup — + // calling pythonManager.restart() directly would leave the renderer's + // backend status stale until a window reload. + ipcMain.handle('python:restart', () => restartPython()); + + // ============================================ + // Application Info Handlers + // ============================================ + + ipcMain.handle('app:getVersion', () => { + return app.getVersion(); + }); + + ipcMain.handle('app:getName', () => { + return app.getName(); + }); + + ipcMain.handle('app:getPlatform', () => { + return process.platform; + }); + + ipcMain.handle('app:isDev', () => { + return process.env.NODE_ENV === 'development' || !app.isPackaged; + }); + + // ============================================ + // Window Control Handlers + // ============================================ + + ipcMain.on('window:minimize', (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + window?.minimize(); + }); + + ipcMain.on('window:maximize', (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window?.isMaximized()) { + window.unmaximize(); + } else { + window?.maximize(); + } + }); + + ipcMain.on('window:close', (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + window?.close(); + }); + + ipcMain.on('window:toggleFullscreen', (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window) { + window.setFullScreen(!window.isFullScreen()); + } + }); + + ipcMain.on('window:openDevTools', (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window) { + window.webContents.openDevTools(); + } + }); + + // ============================================ + // Shell Handlers + // ============================================ + + ipcMain.handle('shell:openExternal', async (_event, url: string) => { + await shell.openExternal(url); + }); + + // ============================================ + // Clipboard Handlers + // ============================================ + + ipcMain.on('clipboard:copy', (_event, text: string) => { + clipboard.writeText(text); + }); + + ipcMain.handle('clipboard:read', () => { + return clipboard.readText(); + }); + + // ============================================ + // Resource Monitoring Handlers + // ============================================ + + ipcMain.handle('resources:getElectronUsage', (event) => { + // Get main process metrics + const mainProcessMemory = process.memoryUsage(); + const mainCpuUsage = process.cpuUsage(); + + // Get renderer process metrics + const window = BrowserWindow.fromWebContents(event.sender); + let rendererMetrics = null; + + if (window) { + // Get all app metrics which includes renderer processes + const appMetrics = app.getAppMetrics(); + + // Find the renderer process for this window + const rendererPid = window.webContents.getOSProcessId(); + const rendererProcess = appMetrics.find(m => m.pid === rendererPid); + + if (rendererProcess) { + rendererMetrics = { + memory_mb: rendererProcess.memory.workingSetSize / (1024 * 1024), + cpu_percent: rendererProcess.cpu.percentCPUUsage, + }; + } + } + + // Main process metrics + const mainMetrics: { + memory_mb: number; + heap_used_mb: number; + heap_total_mb: number; + cpu_user_ms: number; + cpu_system_ms: number; + cpu_percent?: number; + } = { + memory_mb: mainProcessMemory.rss / (1024 * 1024), + heap_used_mb: mainProcessMemory.heapUsed / (1024 * 1024), + heap_total_mb: mainProcessMemory.heapTotal / (1024 * 1024), + // CPU usage is cumulative, convert to approximate percent + // Note: This is microseconds since process start, not a percentage + cpu_user_ms: mainCpuUsage.user / 1000, + cpu_system_ms: mainCpuUsage.system / 1000, + }; + + // Get main process CPU percentage from app metrics + const appMetrics = app.getAppMetrics(); + const mainPid = process.pid; + const mainAppMetric = appMetrics.find(m => m.pid === mainPid); + if (mainAppMetric) { + mainMetrics['cpu_percent'] = mainAppMetric.cpu.percentCPUUsage; + } + + return { + main: mainMetrics, + renderer: rendererMetrics, + timestamp: Date.now(), + }; + }); +} diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 0000000..9b15499 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,358 @@ +import { app, BrowserWindow, dialog, shell, ipcMain } from 'electron'; +import path from 'path'; +import { pathToFileURL } from 'url'; +import { PythonManager, LogLevel, LogSource, LogMessage } from './pythonManager'; +import { setupIpcHandlers } from './ipcHandlers'; +import { createAppMenu } from './menu'; +import { + BackendStatusStore, + type BackendStatusTransition, +} from './backendStatus'; +import { + isTrustedRendererLocation, + shouldAuthenticateBackendRequest, + withBackendSessionHeader, +} from './backendSessionPolicy'; + +let mainWindow: BrowserWindow | null = null; +let pythonManager: PythonManager | null = null; +let rendererReady = false; +const logHistory: LogMessage[] = []; // Persistent history for replay +const MAX_LOG_HISTORY = 100; + +const backendStatusStore = new BackendStatusStore((status) => { + if (!mainWindow || mainWindow.isDestroyed()) return; + + mainWindow.webContents.send('python:status', status); + + // Retain the existing public bridge events for compatibility. The renderer + // application uses only the atomic python:status contract below. + switch (status.phase) { + case 'starting': + mainWindow.webContents.send('python:starting'); + break; + case 'ready': + mainWindow.webContents.send('python:ready', status.port); + break; + case 'error': + mainWindow.webContents.send('python:error', status.error); + break; + case 'stopped': + break; + } +}); + +function publishBackendStatus(transition: BackendStatusTransition): void { + backendStatusStore.publish(transition); +} + +function getSafeBackendError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getUnexpectedExitReason(code: number | null, signal: NodeJS.Signals | null): string { + if (signal) return `signal ${signal}`; + return `exit code ${code ?? 'unknown'}`; +} + +const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; + +/** + * Send a log message to the renderer process + * Always stores in history for replay on new connections + */ +function sendLog(level: LogLevel, message: string, source: LogSource = 'electron'): void { + console.log(`[${source}] ${message}`); + const logMessage: LogMessage = { level, source, message }; + + // Always store in history for replay + logHistory.push(logMessage); + if (logHistory.length > MAX_LOG_HISTORY) { + logHistory.shift(); + } + + // Send immediately if renderer ready + if (rendererReady && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('log:message', logMessage); + } +} + +async function createWindow(): Promise { + const rendererEntryUrl = isDev + ? 'http://localhost:5173' + : pathToFileURL(path.join(__dirname, '../dist/index.html')).toString(); + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 1024, + minHeight: 768, + show: false, // Don't show until ready + webPreferences: { + // .cjs extension is load-bearing: see the preload output comment in + // electron.vite.config.ts ("type": "module" + Electron >= ~29). + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + // Pin the traffic lights (y:24 centers them in the 64px app header) and + // enable the Window Controls Overlay API so the renderer can size its + // titlebar inset from env(titlebar-area-x) — which tracks page zoom and + // fullscreen, unlike any fixed pixel offset. + trafficLightPosition: { x: 16, y: 24 }, + titleBarOverlay: true, + icon: isDev + ? path.join(__dirname, '../resources/icon.png') + : path.join(process.resourcesPath, 'icon.png'), + backgroundColor: '#ffffff', + }); + + // Set up the application menu + createAppMenu(mainWindow); + + // Show window when ready + mainWindow.once('ready-to-show', () => { + mainWindow?.show(); + // DevTools can be opened manually with Ctrl+Shift+I or View menu + }); + + // Handle external links + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: 'deny' }; + }); + mainWindow.webContents.on('will-navigate', (event, url) => { + if (!isTrustedRendererLocation(url, rendererEntryUrl)) event.preventDefault(); + }); + + // Authenticate only requests issued by this trusted top-level application + // document to the exact Electron-managed backend. Renderer JavaScript never + // receives the per-launch credential, and child/navigated pages are excluded. + const trustedWindow = mainWindow; + trustedWindow.webContents.session.webRequest.onBeforeSendHeaders( + { urls: ['http://127.0.0.1/*'] }, + (details, callback) => { + const manager = pythonManager; + const authenticate = + manager !== null && + shouldAuthenticateBackendRequest({ + requestUrl: details.url, + method: details.method, + backendPort: manager.getPort(), + requestWebContentsId: details.webContentsId, + trustedWebContentsId: trustedWindow.webContents.id, + isMainFrame: + details.frame?.frameTreeNodeId === + trustedWindow.webContents.mainFrame.frameTreeNodeId, + frameUrl: details.frame?.url ?? '', + rendererEntryUrl, + }); + let secret: string | undefined; + if (authenticate) { + try { + secret = manager.getBackendSessionSecret(); + } catch { + secret = undefined; + } + } + callback({ + requestHeaders: withBackendSessionHeader(details.requestHeaders, secret), + }); + } + ); + + // Add right-click context menu for copy/paste + mainWindow.webContents.on('context-menu', (_event, params) => { + const { Menu, MenuItem } = require('electron'); + const menu = new Menu(); + + // Add "Copy" if text is selected + if (params.selectionText) { + menu.append(new MenuItem({ + label: 'Copy', + role: 'copy', + })); + } + + // Add "Paste" if in an editable field + if (params.isEditable) { + menu.append(new MenuItem({ + label: 'Paste', + role: 'paste', + })); + } + + // Add "Cut" if text is selected and in editable field + if (params.selectionText && params.isEditable) { + menu.insert(0, new MenuItem({ + label: 'Cut', + role: 'cut', + })); + } + + // Add "Select All" for editable fields + if (params.isEditable) { + menu.append(new MenuItem({ + label: 'Select All', + role: 'selectAll', + })); + } + + // Only show menu if it has items + if (menu.items.length > 0) { + menu.popup(); + } + }); + + // Load the app + if (isDev) { + await mainWindow.loadURL(rendererEntryUrl); + } else { + await mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); + } + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +async function initializeApp(): Promise { + try { + // Start Python backend + const manager = new PythonManager(); + pythonManager = manager; + + // Set the log callback so pythonManager uses our buffered logging + manager.setLogCallback((level, message, source) => { + sendLog(level, message, source); + }); + manager.setUnexpectedExitCallback(({ code, signal }) => { + // Ignore a delayed notification from a manager that has since been + // replaced by a fresh initialization attempt. + if (pythonManager !== manager) return; + const reason = getUnexpectedExitReason(code, signal); + sendLog('error', `Python backend exited unexpectedly with ${reason}`); + publishBackendStatus({ + phase: 'error', + error: `The Python backend exited unexpectedly (${reason}). Restart the backend to continue.`, + }); + }); + + publishBackendStatus({ phase: 'starting' }); + sendLog('info', 'Starting Python backend...'); + + await manager.start(); + + publishBackendStatus({ phase: 'ready', port: manager.getPort() }); + + sendLog('info', `Python backend started successfully on port ${manager.getPort()}`); + } catch (error) { + sendLog('error', `Failed to start Python backend: ${error}`); + publishBackendStatus({ phase: 'error', error: getSafeBackendError(error) }); + + // Show error dialog + dialog.showErrorBox( + 'Python Backend Error', + `Failed to start the Python backend. Please ensure Python and required dependencies are installed.\n\nError: ${error}` + ); + } +} + +/** + * Restart the Python backend, emitting the same renderer events as initial + * startup so the backend status in the UI never goes stale. Errors are + * reported via python:error rather than rethrown — the renderer's restart + * button awaits the IPC call without a catch. + */ +async function restartBackend(): Promise { + if (!pythonManager) { + await initializeApp(); + return; + } + + publishBackendStatus({ phase: 'starting' }); + sendLog('info', 'Restarting Python backend...'); + + try { + await pythonManager.restart(); + publishBackendStatus({ phase: 'ready', port: pythonManager.getPort() }); + sendLog('info', `Python backend restarted successfully on port ${pythonManager.getPort()}`); + } catch (error) { + sendLog('error', `Failed to restart Python backend: ${error}`); + publishBackendStatus({ phase: 'error', error: getSafeBackendError(error) }); + } +} + +// Handle renderer ready signal +ipcMain.on('log:rendererReady', () => { + rendererReady = true; + // Note: We no longer replay log history here to avoid duplicate logs. + // Early startup logs are visible in the terminal. + // Real-time logs come through the Python SSE stream once the backend is ready. +}); + +// App lifecycle +app.whenReady().then(async () => { + // Set the application name (important for Linux desktop integration) + app.setName('Stingray Explorer'); + + // Set up IPC handlers before creating window + setupIpcHandlers( + () => pythonManager, + restartBackend, + () => backendStatusStore.getSnapshot() + ); + + await createWindow(); + await initializeApp(); + + app.on('activate', async () => { + // On macOS, re-create the window when the dock icon is clicked. + if (BrowserWindow.getAllWindows().length === 0) { + await createWindow(); + + // The freshly-loaded renderer subscribes and reads the authoritative + // snapshot, so a ready notification does not need to be timed around its + // mount. Reinitialize only if the process is no longer running. + if (!pythonManager || !pythonManager.getIsRunning()) { + await initializeApp(); + } + } + }); +}); + +app.on('window-all-closed', async () => { + // On macOS the app (and its Python backend) stays alive when all windows are + // closed — the user reopens via the dock and we want the backend still there + // to reconnect to. Tearing it down here meant a reopened window had no backend + // and got stuck on "Starting...". Only fully shut down on platforms where + // closing the last window means quitting; final cleanup lives in 'before-quit'. + if (process.platform !== 'darwin') { + if (pythonManager) { + await pythonManager.stop(); + pythonManager = null; + publishBackendStatus({ phase: 'stopped' }); + } + app.quit(); + } +}); + +app.on('before-quit', async () => { + // Ensure Python backend is stopped + if (pythonManager) { + await pythonManager.stop(); + pythonManager = null; + publishBackendStatus({ phase: 'stopped' }); + } +}); + +// Handle uncaught exceptions +process.on('uncaughtException', (error) => { + sendLog('error', `Uncaught exception: ${error.message}`); + dialog.showErrorBox('Unexpected Error', `An unexpected error occurred:\n\n${error.message}`); +}); + +process.on('unhandledRejection', (reason) => { + sendLog('error', `Unhandled rejection: ${reason}`); +}); diff --git a/electron/menu.ts b/electron/menu.ts new file mode 100644 index 0000000..a6d9986 --- /dev/null +++ b/electron/menu.ts @@ -0,0 +1,249 @@ +import { app, Menu, shell, BrowserWindow, MenuItemConstructorOptions } from 'electron'; + +const isMac = process.platform === 'darwin'; + +/** + * Create the application menu + */ +export function createAppMenu(mainWindow: BrowserWindow): void { + const template: MenuItemConstructorOptions[] = [ + // App menu (macOS only) + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: 'about' as const }, + { type: 'separator' as const }, + { + label: 'Preferences...', + accelerator: 'CmdOrCtrl+,', + click: (): void => { + mainWindow.webContents.send('menu:preferences'); + }, + }, + { type: 'separator' as const }, + { role: 'services' as const }, + { type: 'separator' as const }, + { role: 'hide' as const }, + { role: 'hideOthers' as const }, + { role: 'unhide' as const }, + { type: 'separator' as const }, + { role: 'quit' as const }, + ], + }, + ] + : []), + + // File menu + { + label: 'File', + submenu: [ + { + label: 'Open File...', + accelerator: 'CmdOrCtrl+O', + click: (): void => { + mainWindow.webContents.send('menu:openFile'); + }, + }, + { + label: 'Open Recent', + role: 'recentDocuments' as const, + submenu: [ + { + label: 'Clear Recent', + role: 'clearRecentDocuments' as const, + }, + ], + }, + { type: 'separator' }, + { + label: 'Save', + accelerator: 'CmdOrCtrl+S', + click: (): void => { + mainWindow.webContents.send('menu:save'); + }, + }, + { + label: 'Save As...', + accelerator: 'CmdOrCtrl+Shift+S', + click: (): void => { + mainWindow.webContents.send('menu:saveAs'); + }, + }, + { + label: 'Export...', + accelerator: 'CmdOrCtrl+E', + click: (): void => { + mainWindow.webContents.send('menu:export'); + }, + }, + { type: 'separator' }, + isMac ? { role: 'close' as const } : { role: 'quit' as const }, + ], + }, + + // Edit menu + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + ...(isMac + ? [ + { role: 'pasteAndMatchStyle' as const }, + { role: 'delete' as const }, + { role: 'selectAll' as const }, + ] + : [{ role: 'delete' as const }, { type: 'separator' as const }, { role: 'selectAll' as const }]), + ], + }, + + // View menu + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + { type: 'separator' }, + { + label: 'Toggle Sidebar', + accelerator: 'CmdOrCtrl+B', + click: (): void => { + mainWindow.webContents.send('menu:toggleSidebar'); + }, + }, + ], + }, + + // Analysis menu + { + label: 'Analysis', + submenu: [ + { + label: 'QuickLook', + submenu: [ + { + label: 'Power Spectrum', + click: (): void => { + mainWindow.webContents.send('menu:navigate', '/quicklook/power-spectrum'); + }, + }, + { + label: 'Light Curve', + click: (): void => { + mainWindow.webContents.send('menu:navigate', '/quicklook/light-curve'); + }, + }, + { + label: 'Cross Spectrum', + click: (): void => { + mainWindow.webContents.send('menu:navigate', '/quicklook/cross-spectrum'); + }, + }, + ], + }, + { + label: 'Pulsar', + submenu: [ + { + label: 'Period Search', + click: (): void => { + mainWindow.webContents.send('menu:navigate', '/pulsar/search'); + }, + }, + { + label: 'Phase Folding', + click: (): void => { + mainWindow.webContents.send('menu:navigate', '/pulsar/folding'); + }, + }, + ], + }, + { + label: 'Modeling', + submenu: [ + { + label: 'Model Builder', + click: (): void => { + mainWindow.webContents.send('menu:navigate', '/modeling/builder'); + }, + }, + { + label: 'MCMC Fitting', + click: (): void => { + mainWindow.webContents.send('menu:navigate', '/modeling/mcmc'); + }, + }, + ], + }, + { type: 'separator' }, + { + label: 'Simulator', + click: (): void => { + mainWindow.webContents.send('menu:navigate', '/simulator'); + }, + }, + ], + }, + + // Window menu + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + ...(isMac + ? [{ type: 'separator' as const }, { role: 'front' as const }, { type: 'separator' as const }, { role: 'window' as const }] + : [{ role: 'close' as const }]), + ], + }, + + // Help menu + { + role: 'help', + submenu: [ + { + label: 'Stingray Documentation', + click: async (): Promise => { + await shell.openExternal('https://docs.stingray.science/'); + }, + }, + { + label: 'Stingray Explorer Wiki', + click: async (): Promise => { + await shell.openExternal('https://github.com/kartikmandar-GSOC24/StingrayExplorer/wiki'); + }, + }, + { type: 'separator' }, + { + label: 'Report Issue', + click: async (): Promise => { + await shell.openExternal('https://github.com/kartikmandar-GSOC24/StingrayExplorer/issues'); + }, + }, + { type: 'separator' }, + { + label: 'About Stingray', + click: async (): Promise => { + await shell.openExternal('https://stingray.science/'); + }, + }, + ], + }, + ]; + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 0000000..88edd7d --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,214 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import type { BackendStatus } from '../src/types/backendStatus'; + +/** + * Electron API exposed to the renderer process via context bridge + * All communication between renderer and main process goes through here + */ +const electronAPI = { + // ============================================ + // File System Operations + // ============================================ + + /** Open native-selected files with short-lived backend-verifiable grants. */ + openGrantedFile: (options?: { + title?: string; + filters?: { name: string; extensions: string[] }[]; + multiple?: boolean; + }): Promise<{ path: string; grant: string }[] | null> => + ipcRenderer.invoke('dialog:openGrantedFile', options), + + /** Select an export destination and bind a write grant to that exact path. */ + saveGrantedFile: (options?: { + title?: string; + defaultPath?: string; + filters?: { name: string; extensions: string[] }[]; + }): Promise<{ path: string; grant: string } | null> => + ipcRenderer.invoke('dialog:saveGrantedFile', options), + + // ============================================ + // Python Backend Communication + // ============================================ + + /** + * Get the port the Python backend is running on + */ + getBackendPort: (): Promise => ipcRenderer.invoke('python:getPort'), + + /** + * Check if Python backend is running + */ + isPythonRunning: (): Promise => ipcRenderer.invoke('python:isRunning'), + + /** Get the atomic main-process backend lifecycle snapshot. */ + getBackendStatus: (): Promise => ipcRenderer.invoke('python:getStatus'), + + /** Subscribe to revisioned backend lifecycle snapshots. */ + onBackendStatus: (callback: (status: BackendStatus) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, status: BackendStatus): void => + callback(status); + ipcRenderer.on('python:status', handler); + return () => ipcRenderer.removeListener('python:status', handler); + }, + + /** + * Restart the Python backend + */ + restartPython: (): Promise => ipcRenderer.invoke('python:restart'), + + /** @deprecated Use onBackendStatus for revision-safe lifecycle updates. */ + onPythonReady: (callback: (port: number) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, port: number): void => callback(port); + ipcRenderer.on('python:ready', handler); + return () => ipcRenderer.removeListener('python:ready', handler); + }, + + /** @deprecated Use onBackendStatus for revision-safe lifecycle updates. */ + onPythonStarting: (callback: () => void): (() => void) => { + const handler = (): void => callback(); + ipcRenderer.on('python:starting', handler); + return () => ipcRenderer.removeListener('python:starting', handler); + }, + + /** @deprecated Use onBackendStatus for revision-safe lifecycle updates. */ + onPythonError: (callback: (error: string) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, error: string): void => callback(error); + ipcRenderer.on('python:error', handler); + return () => ipcRenderer.removeListener('python:error', handler); + }, + + // ============================================ + // Application Info + // ============================================ + + /** + * Get the application version + */ + getAppVersion: (): Promise => ipcRenderer.invoke('app:getVersion'), + + /** + * Get the application name + */ + getAppName: (): Promise => ipcRenderer.invoke('app:getName'), + + /** + * Get platform information + */ + getPlatform: (): Promise => ipcRenderer.invoke('app:getPlatform'), + + /** + * Check if running in development mode + */ + isDev: (): Promise => ipcRenderer.invoke('app:isDev'), + + // ============================================ + // Window Controls + // ============================================ + + /** + * Minimize the window + */ + minimizeWindow: (): void => ipcRenderer.send('window:minimize'), + + /** + * Maximize/restore the window + */ + maximizeWindow: (): void => ipcRenderer.send('window:maximize'), + + /** + * Close the window + */ + closeWindow: (): void => ipcRenderer.send('window:close'), + + /** + * Toggle fullscreen mode + */ + toggleFullscreen: (): void => ipcRenderer.send('window:toggleFullscreen'), + + /** + * Open Chrome DevTools + */ + openDevTools: (): void => ipcRenderer.send('window:openDevTools'), + + // ============================================ + // Shell Operations + // ============================================ + + /** + * Open a URL in the default browser + */ + openExternal: (url: string): Promise => ipcRenderer.invoke('shell:openExternal', url), + + // ============================================ + // Clipboard Operations + // ============================================ + + /** + * Copy text to clipboard + */ + copyToClipboard: (text: string): void => ipcRenderer.send('clipboard:copy', text), + + /** + * Read text from clipboard + */ + readFromClipboard: (): Promise => ipcRenderer.invoke('clipboard:read'), + + // ============================================ + // Log Events + // ============================================ + + /** + * Subscribe to log messages from main process + */ + onLog: ( + callback: (log: { level: 'info' | 'warn' | 'error' | 'debug'; source: 'python' | 'electron'; message: string }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + log: { level: 'info' | 'warn' | 'error' | 'debug'; source: 'python' | 'electron'; message: string } + ): void => callback(log); + ipcRenderer.on('log:message', handler); + return () => ipcRenderer.removeListener('log:message', handler); + }, + + /** + * Send a log message from renderer to main (for aggregation) + */ + sendLog: (log: { level: 'info' | 'warn' | 'error' | 'debug'; message: string }): void => { + ipcRenderer.send('log:fromRenderer', log); + }, + + /** + * Signal that the renderer is ready to receive logs + */ + signalLogReady: (): void => { + ipcRenderer.send('log:rendererReady'); + }, + + // ============================================ + // Resource Monitoring + // ============================================ + + /** + * Get Electron process resource usage (main + renderer) + */ + getElectronResources: (): Promise<{ + main: { + memory_mb: number; + heap_used_mb: number; + heap_total_mb: number; + cpu_percent?: number; + }; + renderer: { + memory_mb: number; + cpu_percent: number; + } | null; + timestamp: number; + }> => ipcRenderer.invoke('resources:getElectronUsage'), +}; + +// Expose the API to the renderer process +contextBridge.exposeInMainWorld('electronAPI', electronAPI); + +// Type declaration for the exposed API +export type ElectronAPI = typeof electronAPI; diff --git a/electron/pythonManager.ts b/electron/pythonManager.ts new file mode 100644 index 0000000..c45d83f --- /dev/null +++ b/electron/pythonManager.ts @@ -0,0 +1,639 @@ +import { spawn, type ChildProcess } from 'child_process'; +import { randomBytes } from 'crypto'; +import path from 'path'; +import { app } from 'electron'; +import http from 'http'; +import { + MAX_FILE_GRANT_RESPONSE_BYTES, + parseFileGrantResponse, + type NativeFileGrant, +} from './fileGrantResponse'; +export type { NativeFileGrant } from './fileGrantResponse'; + +const FILE_GRANT_ENDPOINT = '/internal/file-grants/issue'; +const BACKEND_SESSION_HEADER = 'X-Stingray-Session'; +const FILE_GRANT_ISSUER_HEADER = 'X-Stingray-Grant-Issuer'; +const FILE_GRANT_REQUEST_TIMEOUT_MS = 5000; +const MAX_SELECTED_PATH_LENGTH = 4096; +const MAX_FILE_GRANT_REQUEST_BYTES = 16 * 1024; +export const DEFAULT_BACKEND_PORT = 8765; + +/** Parse the backend's stdout protocol without accepting ambiguous port text. */ +export function parseBackendPortAnnouncement(line: string): number | null { + const match = /^BACKEND_PORT:([1-9][0-9]{0,4})$/.exec(line); + if (!match) return null; + const port = Number(match[1]); + return port >= 1 && port <= 65535 ? port : null; +} + +export type LogLevel = 'info' | 'warn' | 'error' | 'debug'; +export type LogSource = 'python' | 'electron'; + +export interface LogMessage { + level: LogLevel; + source: LogSource; + message: string; +} + +export type LogCallback = (level: LogLevel, message: string, source: LogSource) => void; + +export interface PythonExitInfo { + code: number | null; + signal: NodeJS.Signals | null; +} + +export type UnexpectedExitCallback = (info: PythonExitInfo) => void; + +export class PythonManager { + private process: ChildProcess | null = null; + private port: number = DEFAULT_BACKEND_PORT; + private announcedPort: number | null = null; + private retryInterval: number = 500; // ms between health checks + // Soft threshold after which the wait for /health is logged as a warning. + // A cold first launch imports the full scientific stack (stingray, numba, + // astropy, scipy) and warms numba's compile cache — ~60s normally, but a + // loaded machine can push it well past any fixed cap, so we never give up + // while the child process is still alive (see waitForReady). A genuine + // startup crash still surfaces quickly because the process exits. + private slowStartWarnMs: number = 180000; // 3 minutes + private progressLogIntervalMs: number = 15000; // emit a "still waiting" log every 15s + private isRunning: boolean = false; + private startupError: Error | null = null; + private logCallback: LogCallback | null = null; + private unexpectedExitCallback: UnexpectedExitCallback | null = null; + private readonly expectedExits = new WeakSet(); + // Shared only with Electron main and the child backend. The renderer never + // receives this credential; main adds it to trusted loopback requests. + private readonly backendSessionSecret: string = randomBytes(32).toString('hex'); + // Shared only with the spawned loopback backend. Renderer code receives + // short-lived HMAC grants, never this secret, so it cannot substitute a + // manually typed path for one selected in an owned native dialog. + private readonly fileGrantSecret: string = randomBytes(32).toString('hex'); + + /** + * Set the log callback for sending logs to the renderer + */ + setLogCallback(callback: LogCallback): void { + this.logCallback = callback; + } + + /** Report only child exits that occur after authenticated readiness. */ + setUnexpectedExitCallback(callback: UnexpectedExitCallback): void { + this.unexpectedExitCallback = callback; + } + + /** + * Send a log message via the callback + */ + private sendLog(level: LogLevel, message: string): void { + if (this.logCallback) { + this.logCallback(level, message, 'python'); + } else { + console.log(`[Python] ${message}`); + } + } + + /** + * Start the Python backend process + */ + async start(): Promise { + if (this.isRunning) { + this.sendLog('info', 'Python backend is already running'); + return; + } + + this.announcedPort = null; + this.port = DEFAULT_BACKEND_PORT; + this.isRunning = false; + this.startupError = null; + + // An external backend cannot prove that it shares this launch credential. + // Refuse it instead of silently attaching to an unauthenticated process. + const alreadyRunning = await this.checkHealth(DEFAULT_BACKEND_PORT); + if (alreadyRunning) { + throw new Error( + `A backend is already responding on 127.0.0.1:${this.port}, but Electron did not launch it and cannot authenticate it. ` + + 'Stop the external backend, then restart Stingray Explorer.' + ); + } + + // Not running, start it ourselves + const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; + const { pythonPath, args } = this.getPythonCommand(); + + this.sendLog('info', `Starting Python backend: ${pythonPath} ${args.join(' ')}`); + + const child = spawn(pythonPath, args, { + cwd: isDev ? path.join(app.getAppPath(), 'python-backend') : undefined, + env: { + ...process.env, + PYTHONUNBUFFERED: '1', + PYTHONDONTWRITEBYTECODE: '1', + // Python 3.14+: make warnings.catch_warnings state context-local instead + // of process-global, so the concurrent warning capture in + // services/analysis_helpers.py (collect_warnings) can run lock-free. + // Unknown to older interpreters, which simply ignore it; analysis_helpers + // falls back to a serializing lock whenever the flag is not active. + // Requires that any global warnings.showwarning replacement chain to the + // handler it displaced - utils/log_stream.py does, and + // tests/test_analysis_helpers.py keeps it that way. + PYTHON_CONTEXT_AWARE_WARNINGS: '1', + STINGRAY_BACKEND_SESSION_SECRET: this.backendSessionSecret, + STINGRAY_FILE_GRANT_SECRET: this.fileGrantSecret, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.process = child; + + let stdoutBuffer = ''; + let stderrBuffer = ''; + const handleOutput = ( + chunk: Buffer | string, + defaultLevel: LogLevel, + stream: 'stdout' | 'stderr' + ) => { + const buffer = stream === 'stdout' ? stdoutBuffer : stderrBuffer; + const lines = (buffer + chunk.toString()).split('\n'); + const partial = lines.pop() ?? ''; + if (stream === 'stdout') stdoutBuffer = partial; + else stderrBuffer = partial; + for (const rawLine of lines) { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + if (!line) continue; + if (defaultLevel === 'info' && line.startsWith('BACKEND_PORT:')) { + const parsedPort = parseBackendPortAnnouncement(line); + if (parsedPort === null || this.announcedPort !== null) { + this.startupError = new Error('Python backend announced an invalid or duplicate port'); + if (this.process === child) child.kill('SIGTERM'); + } else { + this.announcedPort = parsedPort; + this.port = parsedPort; + this.sendLog('info', `Backend will use port ${parsedPort}`); + } + } + const level = this.detectLogLevel(line, defaultLevel); + this.sendLog(level, line); + } + }; + + // Handle stdout + child.stdout?.on('data', (data: Buffer) => handleOutput(data, 'info', 'stdout')); + + // Handle stderr + child.stderr?.on('data', (data: Buffer) => handleOutput(data, 'warn', 'stderr')); + + // Handle process exit + child.on('exit', (code, signal) => { + const wasRunning = this.process === child && this.isRunning; + const wasExpected = this.expectedExits.delete(child); + const message = `Python backend exited with code ${code}, signal ${signal}`; + this.sendLog(code === 0 ? 'info' : 'error', message); + if (this.process === child) { + this.isRunning = false; + this.announcedPort = null; + this.port = DEFAULT_BACKEND_PORT; + this.process = null; + } + if (wasRunning && !wasExpected) { + this.unexpectedExitCallback?.({ code, signal }); + } + }); + + // Handle process error + child.on('error', (error) => { + this.sendLog('error', `Failed to start Python backend: ${error.message}`); + if (this.process === child) { + this.isRunning = false; + this.announcedPort = null; + this.port = DEFAULT_BACKEND_PORT; + this.startupError = error; + } + }); + + // Wait for backend to be ready + try { + await this.waitForReady(); + if (this.process === child && this.announcedPort !== null) { + this.isRunning = true; + } + } catch (error) { + if (this.process === child) { + await this.stop(); + } + throw error; + } + } + + /** + * Detect log level from message content + */ + private detectLogLevel(message: string, defaultLevel: LogLevel = 'info'): LogLevel { + const lowerMessage = message.toLowerCase(); + + // Check for explicit level prefixes (uvicorn style: "INFO:", "WARNING:", etc.) + if (lowerMessage.startsWith('info:') || lowerMessage.includes('info: ')) { + return 'info'; + } + if (lowerMessage.startsWith('debug:')) { + return 'debug'; + } + + // Check for error indicators + if (lowerMessage.startsWith('error:') || lowerMessage.includes('error') || + lowerMessage.includes('exception') || lowerMessage.includes('traceback')) { + return 'error'; + } + + // Check for warning indicators + if (lowerMessage.startsWith('warning:') || lowerMessage.startsWith('warn:') || + lowerMessage.includes('warning') || lowerMessage.includes('warn')) { + return 'warn'; + } + + return defaultLevel; + } + + /** + * Stop the Python backend process + */ + async stop(): Promise { + this.sendLog('info', 'Stopping Python backend...'); + + // If we spawned it, kill the process + this.announcedPort = null; + this.port = DEFAULT_BACKEND_PORT; + this.isRunning = false; + if (!this.process) { + return; + } + + return new Promise((resolve) => { + // Capture the process we are stopping: this.process gets reassigned by a + // subsequent start(), and an uncancelled timer reading this.process would + // SIGKILL the freshly started replacement backend (seen during restart()). + const proc = this.process; + if (!proc) { + resolve(); + return; + } + this.expectedExits.add(proc); + + // Force kill after 5 seconds if this same process is still running + const forceKillTimer = setTimeout(() => { + if (this.process === proc) { + this.sendLog('warn', 'Force killing Python backend...'); + proc.kill('SIGKILL'); + } + }, 5000); + + // Try graceful shutdown first + proc.once('exit', () => { + clearTimeout(forceKillTimer); + if (this.process === proc) { + this.process = null; + this.announcedPort = null; + this.port = DEFAULT_BACKEND_PORT; + this.isRunning = false; + } + this.sendLog('info', 'Python backend stopped'); + resolve(); + }); + + // Send SIGTERM for graceful shutdown + proc.kill('SIGTERM'); + }); + } + + /** + * Get the Python command and arguments based on environment + */ + private getPythonCommand(): { pythonPath: string; args: string[] } { + const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; + + if (isDev) { + // Development: run main.py from python-backend directory + // The cwd is set to python-backend in the spawn call + // Use pixi environment Python if available, otherwise fall back to system python + const pixiPython = path.join(app.getAppPath(), '.pixi', 'envs', 'default', 'bin', 'python'); + return { + pythonPath: pixiPython, + args: ['main.py'], + }; + } else { + // Production: use bundled executable + const platform = process.platform; + let executableName = 'stingray-backend'; + + if (platform === 'win32') { + executableName = 'stingray-backend.exe'; + } + + const executablePath = path.join(process.resourcesPath, 'python-backend', executableName); + + return { + pythonPath: executablePath, + args: [], + }; + } + } + + /** + * Wait for the Python backend to be ready + */ + private async waitForReady(): Promise { + this.sendLog( + 'info', + 'Waiting for Python backend to be ready (first launch can take ~60s while the scientific stack and numba caches warm up)...' + ); + + const startTime = Date.now(); + let lastProgressLog = startTime; + let slowStartWarned = false; + + // Poll until the backend is healthy or the process dies. There is no hard + // deadline: a fixed cap (previously 180s) was observed expiring while the + // child was alive and still importing, leaving the app stuck in an error + // state even though the backend became healthy seconds later. A live + // process is either booting or serving — only a dead one is a failure. + for (;;) { + // If we spawned the process and it has already exited, fail fast so the + // real error (crash, missing dependency, etc.) surfaces immediately. + // The exit handler in start() sets this.process to null on child exit. + if (this.startupError) { + throw this.startupError; + } + if (!this.process) { + throw new Error('Python backend process exited before becoming ready'); + } + + try { + const announcedPort = this.announcedPort; + if (announcedPort !== null && await this.checkAuthenticatedReady(announcedPort)) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + this.sendLog('info', `Python backend is ready! (took ${elapsed}s)`); + return; + } + } catch { + // Ignore errors, keep trying + } + + // Periodic progress so a slow cold start doesn't look like a hang, with + // a one-time escalation to warn once the start is unusually slow. + if (Date.now() - lastProgressLog >= this.progressLogIntervalMs) { + const elapsed = Math.round((Date.now() - startTime) / 1000); + if (!slowStartWarned && Date.now() - startTime >= this.slowStartWarnMs) { + slowStartWarned = true; + this.sendLog( + 'warn', + `Python backend is taking unusually long to start (${elapsed}s); continuing to wait while the process is alive. Use the restart button if it never comes up.` + ); + } else { + this.sendLog('info', `Still waiting for Python backend... (${elapsed}s elapsed)`); + } + lastProgressLog = Date.now(); + } + + await this.sleep(this.retryInterval); + } + } + + /** + * Check if the backend is healthy + */ + private checkHealth(port: number = this.getPort()): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/health', + method: 'GET', + timeout: 1000, + }, + (res) => { + resolve(res.statusCode === 200); + } + ); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); + } + + /** Confirm that the child received this launch's private session credential. */ + private checkAuthenticatedReady(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/api/status', + method: 'GET', + headers: { [BACKEND_SESSION_HEADER]: this.backendSessionSecret }, + timeout: 1000, + }, + (res) => { + res.resume(); + resolve(res.statusCode === 200); + } + ); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); + } + + /** + * Sleep for a specified number of milliseconds + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Get the port the Python backend is running on + */ + getPort(): number { + return this.announcedPort ?? DEFAULT_BACKEND_PORT; + } + + /** + * Check if the Python backend is running + */ + getIsRunning(): boolean { + return this.isRunning; + } + + /** Credential available to Electron main for trusted backend requests only. */ + getBackendSessionSecret(): string { + if (!this.process || this.announcedPort === null || !this.isRunning) { + throw new Error('Backend session authentication is unavailable before startup'); + } + return this.backendSessionSecret; + } + + /** + * Exchange an exact native-dialog selection for a short-lived backend grant. + * Both credentials remain in Electron main; renderer requests never receive + * either header and cannot ask the backend to authorize an arbitrary path. + */ + issueFileGrant(selectedPath: string, access: 'read' | 'write'): Promise { + if (!this.process || !this.isRunning) { + return Promise.reject( + new Error( + 'Native file authorization is unavailable until the Electron-managed backend is ready. Wait for startup to finish, then select the file again.' + ) + ); + } + if ( + typeof selectedPath !== 'string' || + selectedPath.length === 0 || + selectedPath.length > MAX_SELECTED_PATH_LENGTH || + selectedPath.includes('\0') + ) { + return Promise.reject(new Error('The native file dialog returned an invalid path')); + } + + const requestBody = Buffer.from(JSON.stringify({ path: selectedPath, access }), 'utf8'); + if (requestBody.byteLength > MAX_FILE_GRANT_REQUEST_BYTES) { + return Promise.reject(new Error('The selected path is too long to authorize safely')); + } + + return new Promise((resolve, reject) => { + let settled = false; + let deadlineTimer: ReturnType | undefined; + const fail = (message: string) => { + if (settled) return; + settled = true; + if (deadlineTimer) clearTimeout(deadlineTimer); + reject(new Error(message)); + }; + const succeed = (grant: NativeFileGrant) => { + if (settled) return; + settled = true; + if (deadlineTimer) clearTimeout(deadlineTimer); + resolve(grant); + }; + + const req = http.request( + { + hostname: '127.0.0.1', + port: this.getPort(), + path: FILE_GRANT_ENDPOINT, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Content-Length': requestBody.byteLength, + [BACKEND_SESSION_HEADER]: this.backendSessionSecret, + [FILE_GRANT_ISSUER_HEADER]: this.fileGrantSecret, + }, + timeout: FILE_GRANT_REQUEST_TIMEOUT_MS, + }, + (res) => { + const advertisedLength = Number(res.headers['content-length']); + if ( + Number.isFinite(advertisedLength) && + advertisedLength > MAX_FILE_GRANT_RESPONSE_BYTES + ) { + res.destroy(); + fail('The backend returned an oversized native file authorization response'); + return; + } + + let responseBytes = 0; + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer | string) => { + if (settled) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + responseBytes += bytes.byteLength; + if (responseBytes > MAX_FILE_GRANT_RESPONSE_BYTES) { + res.destroy(); + fail('The backend returned an oversized native file authorization response'); + return; + } + chunks.push(bytes); + }); + res.on('error', () => { + fail( + 'Native file authorization was interrupted. Restart Stingray Explorer and select the file again.' + ); + }); + res.on('end', () => { + if (settled) return; + const statusCode = res.statusCode ?? 0; + if (statusCode !== 200) { + res.resume(); + if (statusCode === 401 || statusCode === 403) { + fail( + 'The backend refused native file authorization. Restart Stingray Explorer and select the file again.' + ); + } else if (statusCode === 400 || statusCode === 422) { + fail( + 'The backend could not authorize that selection. Choose an existing input file or a writable destination.' + ); + } else if (statusCode === 503) { + fail( + 'The backend is not ready to authorize native files. Wait for startup to finish and try again.' + ); + } else { + fail( + 'The backend could not authorize the native file selection. Restart Stingray Explorer and try again.' + ); + } + return; + } + + try { + const grant = parseFileGrantResponse(Buffer.concat(chunks).toString('utf8')); + succeed(grant); + } catch { + fail( + 'The Electron-managed backend returned an invalid native file authorization response. Restart Stingray Explorer and select the file again.' + ); + } + }); + } + ); + + req.on('timeout', () => { + req.destroy(); + fail( + 'Native file authorization timed out. Check that the backend is running, then select the file again.' + ); + }); + req.on('error', () => { + fail( + 'Native file authorization could not reach the Electron-managed backend. Restart Stingray Explorer and try again.' + ); + }); + deadlineTimer = setTimeout(() => { + req.destroy(); + fail( + 'Native file authorization timed out. Check that the backend is running, then select the file again.' + ); + }, FILE_GRANT_REQUEST_TIMEOUT_MS); + req.end(requestBody); + }); + } + + /** + * Restart the Python backend + */ + async restart(): Promise { + await this.stop(); + await this.start(); + } +} diff --git a/explorer.py b/explorer.py deleted file mode 100644 index 5029d79..0000000 --- a/explorer.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -Stingray Explorer Main Application File - -This module serves as the entry point for the Stingray Explorer dashboard application. -It handles the initialization of the Panel/Holoviews environment and sets up the -dashboard layout and components. - -The dashboard provides an interactive interface for analyzing X-ray astronomy data -using the Stingray library, with various visualization and analysis tools. - -Key Components: -- Header: Main navigation and branding -- Sidebar: Control panel for data loading and analysis -- Main Area: Primary workspace for data visualization -- Plots Area: Container for generated plots and charts -- Resource Monitor: System resource usage display -- Help/Info Sections: Documentation and support resources -""" - -import panel as pn -import holoviews as hv -from modules.Home.HomeContent import ( - create_home_header, - create_home_main_area, - create_home_output_box, - create_home_warning_box, - create_home_help_area, - create_home_footer, - create_home_plots_area, - create_home_resource_monitor, -) - -from utils.sidebar import create_sidebar -from utils.app_context import AppContext - - -# Initialize Panel and Holoviews extensions with required features -# Note: 'filedropper' is required for FileDropper widgets used in data loading -pn.extension('floatpanel', 'mathjax', 'echarts', 'filedropper', nthreads=0) -hv.extension('bokeh') - -# Create a boolean status indicator to show system activity -# Note: In Panel 1.8+, we don't pass custom busy indicators to avoid param warnings -# The template will use its default busy indicator -# busy_status = pn.indicators.BooleanStatus( -# value=True, color="warning", width=30, height=30 -# ) - -# ============================================================================= -# Create Application Context -# ============================================================================= - -# Initialize the application context that will hold all containers and state -context = AppContext() - -# Create the main dashboard header with branding and navigation -header = create_home_header() - -# Create resource monitor to display system usage statistics -resource_monitor = create_home_resource_monitor() - -# Create the main workspace area for data visualization and analysis -main_area = create_home_main_area() - -# Create the output console for displaying analysis results and messages -output_box = create_home_output_box() - -# Create the warning box for displaying important alerts and notifications -warning_box = create_home_warning_box() - -# Create the help box containing documentation and support resources -help_box = create_home_help_area() - -# Create the footer with copyright and additional information -footer = create_home_footer() - -# Create the plots area container for visualization outputs -plots_area = create_home_plots_area() - -# ============================================================================= -# Register All Containers with AppContext -# ============================================================================= - -# Create containers for each section and register them with the context -context.register_container('header', pn.Column(header), {'purpose': 'Main navigation and branding'}) -context.register_container('resource_monitor', pn.Column(resource_monitor), {'purpose': 'System resource usage display'}) -context.register_container('main_area', pn.Column(main_area), {'purpose': 'Primary workspace for data visualization'}) -context.register_container('output_box', pn.Column(output_box), {'purpose': 'Analysis results and messages'}) -context.register_container('warning_box', pn.Column(warning_box), {'purpose': 'Important alerts and notifications'}) -context.register_container('plots', pn.FlexBox(plots_area, flex_direction='row', align_content='space-evenly', align_items="center", justify_content="center", flex_wrap="wrap"), {'purpose': 'Visualization outputs'}) -context.register_container('help_box', pn.Column(help_box), {'purpose': 'Documentation and support resources'}) -context.register_container('footer', pn.Column(footer), {'purpose': 'Copyright and additional information'}) -context.register_container('float_panel', pn.Column(pn.pane.Markdown("This is not a bug that this container is scrolling, it's a container to hold Floating Plots. You can ignore it completely.")), {'purpose': 'Floating plot container'}) - -# Create the sidebar with navigation and control elements -# Now passing a single AppContext instead of 9 individual parameters! -sidebar = create_sidebar(context) - - -""" -Create the main dashboard layout using Panel's FastGridTemplate - -The layout organizes all components into a responsive grid system with: -- Header at the top -- Sidebar on the left -- Main content area in the center -- Output and warning box on the right of main layout -- Plots and help section below the main content -- Footer at the bottom - -The grid is fully responsive and adapts to different screen sizes. -""" -layout = pn.template.FastGridTemplate( - # Basic Panel layout components - main=[], - header="Next-Generation Spectral Timing Made Easy", - sidebar=[sidebar], - modal=True, - # Parameters for the FastGridTemplate - site="", # Not shown as already doing in title - site_url="StingrayExplorer", - logo="assets/images/stingray_explorer.png", - title="Stingray Explorer", - favicon="assets/images/stingray_explorer.png", - # sidebar_footer="Sidebar Footer", - # config= (TemplateConfig): Contains configuration options similar to pn.config but applied to the current Template only. (Currently only css_files is supported) But css_files are now deprecated. - # busy_indicator removed to avoid param warnings in Panel 1.8+ (uses default) - # For configuring the grid - cols={"lg": 12, "md": 12, "sm": 12, "xs": 4, "xxs": 2}, - breakpoints={"lg": 1200, "md": 996, "sm": 768, "xs": 480, "xxs": 0}, - row_height=10, - dimensions={"minW": 0, "maxW": float("inf"), "minH": 0, "maxH": float("inf")}, - prevent_collision=False, - save_layout=True, - # Styling parameter - theme="default", - theme_toggle=False, - background_color="#FFFFFF", - neutral_color="#D3D3D3", - accent_base_color="#5ead61", - header_background="#000000", - header_color="#c4e1c5", - header_neutral_color="#D3D3D3", - header_accent_base_color="#c4e1c5", - corner_radius=7, - # font="", - # font_url="", - shadow=True, - main_layout="card", - # Layout parameters - collapsed_sidebar=False, - sidebar_width=250, - main_max_width="100%", - # Meta data - meta_description="Stingray Explorer Dashboard", - meta_keywords="Stingray, Explorer, Dashboard, Astronomy, Stingray Explorer, X-ray Astronomy, X-ray Data Analysis", - meta_author="Kartik Mandar", - meta_refresh="", - meta_viewport="width=device-width, initial-scale=1", - base_url="/", - base_target="_self", -) - -layout.main[0:10, 0:6] = context.get_container('header') -layout.main[0:10, 6:12] = context.get_container('resource_monitor') -layout.main[10:55, 0:8] = context.get_container('main_area') -layout.main[10:33, 8:12] = context.get_container('output_box') -layout.main[33:55, 8:12] = context.get_container('warning_box') -layout.main[55:100, 0:12] = context.get_container('plots') -layout.main[100:140, 0:12] = context.get_container('help_box') -layout.main[140:170, 0:12] = context.get_container('footer') -layout.main[170:170, 0:12] = context.get_container('float_panel') - - -# Make the layout available for serving -layout.servable() diff --git a/files/data/DATA_README.txt b/files/data/DATA_README.txt new file mode 100644 index 0000000..4f32287 --- /dev/null +++ b/files/data/DATA_README.txt @@ -0,0 +1,143 @@ +================================================================================ + StingrayExplorer Sample Data Files +================================================================================ + +This folder contains sample X-ray astronomy event lists and light curves for +testing and demonstration purposes with StingrayExplorer. + +================================================================================ + EVENT LIST FILES +================================================================================ + +ni1200120106_0mpu7_cl_bary.evt.gz (2.4 GB compressed) +------------------------------------------------------------------------------ + Source: MAXI J1820+070 (accreting stellar-mass black hole) + Mission: NASA NICER (Neutron star Interior Composition Explorer) + ObsID: 1200120106 + Observation: 2018 outburst - famous for strong quasi-periodic oscillations + Processing: Barycentered using JPL DE 430 ephemeris + Authors: Matteo Bachetti, Daniela Huppenkothen + Download: https://zenodo.org/record/6785435 + Usage: Official Stingray tutorial dataset + Load with: EventList.read("ni1200120106_0mpu7_cl_bary.evt.gz", fmt="hea") + +monol_testA.evt (28 KB) +------------------------------------------------------------------------------ + Source: Simulated/test data + Mission: Generic test event list + Description: Basic event list for unit testing and quick demos + Load with: EventList.read("monol_testA.evt", fmt="hea") + +monol_testA_calib.evt (25 KB) +------------------------------------------------------------------------------ + Source: Simulated/test data + Mission: Generic test event list + Description: Calibrated version of monol_testA with energy information + Load with: EventList.read("monol_testA_calib.evt", fmt="hea") + +monol_testA_calib_unsrt.evt (25 KB) +------------------------------------------------------------------------------ + Source: Simulated/test data + Mission: Generic test event list + Description: Unsorted calibrated event list (for testing sorting functions) + Load with: EventList.read("monol_testA_calib_unsrt.evt", fmt="hea") + +nomission.evt (28 KB) +------------------------------------------------------------------------------ + Source: Simulated/test data + Mission: None (generic format) + Description: Event list without mission-specific metadata + Usage: Testing generic event list handling + Load with: EventList.read("nomission.evt", fmt="hea") + +xte_test.evt.gz (11 KB compressed) +------------------------------------------------------------------------------ + Source: Test data based on RXTE format + Mission: RXTE (Rossi X-ray Timing Explorer) + Description: Small RXTE-format event list for testing + Usage: Testing RXTE data loading and processing + Load with: EventList.read("xte_test.evt.gz", fmt="hea") + +xte_gx_test.evt.gz (34 KB compressed) +------------------------------------------------------------------------------ + Source: Test data based on RXTE format + Mission: RXTE (Rossi X-ray Timing Explorer) + Description: RXTE event list, likely from a GX source observation + Usage: Testing RXTE data with slightly more events + Load with: EventList.read("xte_gx_test.evt.gz", fmt="hea") + +================================================================================ + LIGHT CURVE FILES +================================================================================ + +lcurveA.fits (37 KB) +------------------------------------------------------------------------------ + Type: Pre-computed light curve + Format: FITS (OGIP standard) + Description: Sample light curve for testing light curve operations + Load with: Lightcurve.read("lcurveA.fits", fmt="ogip") + +lcurve_new.fits (54 KB) +------------------------------------------------------------------------------ + Type: Pre-computed light curve + Format: FITS (OGIP standard) + Description: Another sample light curve with different parameters + Load with: Lightcurve.read("lcurve_new.fits", fmt="ogip") + +LightCurve_bexvar.fits (416 KB) +------------------------------------------------------------------------------ + Type: Pre-computed light curve + Format: FITS + Description: Light curve for testing excess variance (bexvar) calculations + Usage: Testing variability and excess variance spectrum analysis + Load with: Lightcurve.read("LightCurve_bexvar.fits", fmt="ogip") + +================================================================================ + LOADING DATA IN PYTHON +================================================================================ + +Using Stingray directly: + + from stingray import EventList, Lightcurve + + # Load event list + evt = EventList.read("files/data/monol_testA.evt", fmt="hea") + print(f"Events: {len(evt.time)}, Time range: {evt.time.min()}-{evt.time.max()}") + + # Load light curve + lc = Lightcurve.read("files/data/lcurveA.fits", fmt="ogip") + print(f"Bins: {len(lc.time)}, Count rate: {lc.countrate.mean():.2f} cts/s") + +Using StingrayExplorer UI: + + 1. Go to "Data Ingestion" page + 2. Click "Browse Files" and select the file + 3. Choose format: "OGIP/FITS (recommended)" for .evt/.fits files + 4. Enter a name for the dataset + 5. Click "Load Event List" + +================================================================================ + DATA SOURCES +================================================================================ + +HEASARC (NASA): https://heasarc.gsfc.nasa.gov +NICER Archive: https://heasarc.gsfc.nasa.gov/docs/nicer/ +Stingray Docs: https://docs.stingray.science/ +Zenodo Dataset: https://zenodo.org/record/6785435 + +================================================================================ + FILE FORMATS +================================================================================ + +.evt / .evt.gz - Event list files (photon arrival times + metadata) +.fits - FITS format (Flexible Image Transport System) +.gz - Gzip compressed files (auto-detected by Stingray) + +Format parameter for EventList.read(): + - "hea" or "ogip" : HEASARC/OGIP standard FITS event files + - "hdf5" : HDF5 format + - "ascii.ecsv" : ASCII Enhanced CSV + +================================================================================ +Last updated: February 2025 +================================================================================ diff --git a/index.html b/index.html new file mode 100644 index 0000000..00d4f7f --- /dev/null +++ b/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + Stingray Explorer + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..73bd8ec --- /dev/null +++ b/package-lock.json @@ -0,0 +1,15093 @@ +{ + "name": "stingray-explorer", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stingray-explorer", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "@mui/icons-material": "^5.15.6", + "@mui/material": "^5.15.6", + "@tanstack/react-query": "^5.17.19", + "axios": "^1.6.7", + "plotly.js": "^2.29.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-plotly.js": "^2.6.0", + "react-router-dom": "^6.22.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^20.11.16", + "@types/plotly.js": "^2.35.14", + "@types/react": "^18.2.52", + "@types/react-dom": "^18.2.18", + "@types/react-plotly.js": "^2.6.3", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "@vitejs/plugin-react": "^5.2.0", + "electron": "^41.0.4", + "electron-builder": "^26.8.1", + "electron-vite": "^5.0.0", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "jsdom": "^29.1.1", + "typescript": "^5.3.3", + "vite": "^7.3.1", + "vitest": "^4.1.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@choojs/findup": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", + "integrity": "sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==", + "license": "MIT", + "dependencies": { + "commander": "^2.15.1" + }, + "bin": { + "findup": "bin/findup.js" + } + }, + "node_modules/@choojs/findup/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/fuses/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/fuses/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", + "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "got": "^11.7.0", + "graceful-fs": "^4.2.11", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^11.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^7.5.6", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/windows-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", + "integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz", + "integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-rewind/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC" + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@plotly/d3": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.2.tgz", + "integrity": "sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.7.2.tgz", + "integrity": "sha512-2jdVos1N3mMp3QW0k2q1ph7Gd6j5PY1YihBrwpkFnKqO+cqtZq3AdEYUeSGXMeLsBDQYiqTVcihYfk8vr5tqhw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1", + "d3-collection": "1", + "d3-shape": "^1.2.0" + } + }, + "node_modules/@plotly/d3-sankey-circular": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey-circular/-/d3-sankey-circular-0.33.1.tgz", + "integrity": "sha512-FgBV1HEvCr3DV7RHhDsPXyryknucxtfnLwPtCKKxdolKyTFYoLX/ibEfX39iFYIL7DYbVeRtP43dbFcrHNE+KQ==", + "license": "MIT", + "dependencies": { + "d3-array": "^1.2.1", + "d3-collection": "^1.0.4", + "d3-shape": "^1.2.0", + "elementary-circuits-directed-graph": "^1.0.4" + } + }, + "node_modules/@plotly/mapbox-gl": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", + "integrity": "sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/@plotly/point-cluster": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@plotly/point-cluster/-/point-cluster-3.1.9.tgz", + "integrity": "sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "binary-search-bounds": "^2.0.4", + "clamp": "^1.0.1", + "defined": "^1.0.0", + "dtype": "^2.0.0", + "flatten-vertex-data": "^1.0.2", + "is-obj": "^1.0.1", + "math-log2": "^1.0.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz", + "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", + "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.95.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@turf/area": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.4.tgz", + "integrity": "sha512-UEQQFw2XwHpozSBAMEtZI3jDsAad4NnHL/poF7/S6zeDCjEBCkt3MYd6DSGH/cvgcOozxH/ky3/rIVSMZdx4vA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.4.tgz", + "integrity": "sha512-D5ErVWtfQbEPh11yzI69uxqrcJmbPU/9Y59f1uTapgwAwQHQztDWgsYpnL3ns8r1GmPWLP8sGJLVTIk2TZSiYA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/centroid": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.3.4.tgz", + "integrity": "sha512-6c3kyTSKBrmiPMe75UkHw6MgedroZ6eR5usEvdlDhXgA3MudFPXIZkMFmMd1h9XeJ9xFfkmq+HPCdF0cOzvztA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz", + "integrity": "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.4.tgz", + "integrity": "sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.4", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/plotly.js": { + "version": "2.35.14", + "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-2.35.14.tgz", + "integrity": "sha512-CcD/32JcK19+xWH4FFpmYez/5X9kOjUcBr8Hxh7gQ/3Z32gIoLLy/L9xvC7DG5YikPvJjq6QN05B9+MCRu/Ncw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-plotly.js": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.4.tgz", + "integrity": "sha512-AU6w1u3qEGM0NmBA69PaOgNc0KPFA/+qkH6Uu9EBTJ45/WYOUoXi9AF5O15PRM2klpHSiHAAs4WnlI+OZAFmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/plotly.js": "*", + "@types/react": "*" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/almost-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/almost-equal/-/almost-equal-1.1.0.tgz", + "integrity": "sha512-0V/PkoculFl5+0Lp47JoxUcO0xSxhIBvm+BxHdD/OgXNmdRpRHCFnKVuUoWyS9EzQP+otSGv0m9Lb4yVkQBn2A==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", + "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", + "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.8.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.8.1", + "electron-builder-squirrel-windows": "26.8.1" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-bounds": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-bounds/-/array-bounds-1.0.1.tgz", + "integrity": "sha512-8wdW3ZGk6UjMPJx/glyEt0sLzzwAE1bhToPsO1W2pbpR2gULyxe3BjSiuJFheP50T/GgODVPz2fuMUmIywt8cQ==", + "license": "MIT" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-normalize": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array-normalize/-/array-normalize-1.1.4.tgz", + "integrity": "sha512-fCp0wKFLjvSPmCn4F5Tiw4M3lpMZoHlCjfcs7nNzuj3vqQQ1/a8cgB9DXcpDSn18c+coLnaW7rqfcYCvKbyJXg==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.0" + } + }, + "node_modules/array-range": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-range/-/array-range-1.0.1.tgz", + "integrity": "sha512-shdaI1zT3CVNL2hnx9c0JMc0ZogGaxDs5e85akgHWKYa0yVbIyp06Ind3dVkTj/uuFrzaHBOyqFzo+VV6aXgtA==", + "license": "MIT" + }, + "node_modules/array-rearrange": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array-rearrange/-/array-rearrange-2.2.2.tgz", + "integrity": "sha512-UfobP5N12Qm4Qu4fwLDIi2v6+wZsSf6snYSxAMeKhrh37YGnNWZPRmVEKc/2wfms53TLQnzfpG8wCx2Y/6NG1w==", + "license": "MIT" + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", + "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", + "license": "MIT" + }, + "node_modules/bit-twiddle": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", + "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==", + "license": "MIT" + }, + "node_modules/bitmap-sdf": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", + "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-fit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/canvas-fit/-/canvas-fit-1.5.0.tgz", + "integrity": "sha512-onIcjRpz69/Hx5bB5HGbYKUF2uC6QT6Gp+pfpGm3A7mPfcluSLV5v4Zu+oflDUwLdUw0rLIBhUbi0v8hM4FJQQ==", + "license": "MIT", + "dependencies": { + "element-size": "^1.1.1" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clamp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-alpha": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/color-alpha/-/color-alpha-1.0.4.tgz", + "integrity": "sha512-lr8/t5NPozTSqli+duAN+x+no/2WaKTeWvxhHGN+aXT6AJ8vPlzLa7UriyjWak0pSC2jHol9JgjBYnnHsGha9A==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.3.8" + } + }, + "node_modules/color-alpha/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/color-id/-/color-id-1.1.0.tgz", + "integrity": "sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-normalize": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/color-normalize/-/color-normalize-1.5.0.tgz", + "integrity": "sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-rgba": "^2.1.1", + "dtype": "^2.0.0" + } + }, + "node_modules/color-parse": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-2.0.0.tgz", + "integrity": "sha512-g2Z+QnWsdHLppAbrpcFWo629kLOnOPtpxYV69GCqm92gqSgyXbzlfyN3MXs0412fPBkFmiuS+rXposgBgBa6Kg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-rgba": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.1.1.tgz", + "integrity": "sha512-VaX97wsqrMwLSOR6H7rU1Doa2zyVdmShabKrPEIFywLlHoibgD3QW9Dw6fSqM4+H/LfjprDNAUUW31qEQcGzNw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-parse": "^1.3.8", + "color-space": "^1.14.6" + } + }, + "node_modules/color-rgba/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-space": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/color-space/-/color-space-1.16.0.tgz", + "integrity": "sha512-A6WMiFzunQ8KEPFmj02OnnoUnqhmSaHaZ/0LVFcPTdlvm8+3aMJ5x1HRHy3bDHPkovkf4sS0f4wsVvwk71fKkg==", + "license": "MIT", + "dependencies": { + "hsluv": "^0.0.3", + "mumath": "^3.3.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/country-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/country-regex/-/country-regex-1.1.0.tgz", + "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-font": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-font/-/css-font-1.2.0.tgz", + "integrity": "sha512-V4U4Wps4dPDACJ4WpgofJ2RT5Yqwe1lEH6wlOOaIxMi0gTjdIijsc5FmxQlZ7ZZyKQkkutqqvULOp07l9c7ssA==", + "license": "MIT", + "dependencies": { + "css-font-size-keywords": "^1.0.0", + "css-font-stretch-keywords": "^1.0.1", + "css-font-style-keywords": "^1.0.1", + "css-font-weight-keywords": "^1.0.0", + "css-global-keywords": "^1.0.1", + "css-system-font-keywords": "^1.0.0", + "pick-by-alias": "^1.2.0", + "string-split-by": "^1.0.0", + "unquote": "^1.1.0" + } + }, + "node_modules/css-font-size-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz", + "integrity": "sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==", + "license": "MIT" + }, + "node_modules/css-font-stretch-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz", + "integrity": "sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==", + "license": "MIT" + }, + "node_modules/css-font-style-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz", + "integrity": "sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==", + "license": "MIT" + }, + "node_modules/css-font-weight-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz", + "integrity": "sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==", + "license": "MIT" + }, + "node_modules/css-global-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-global-keywords/-/css-global-keywords-1.0.1.tgz", + "integrity": "sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==", + "license": "MIT" + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-system-font-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", + "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1" + } + }, + "node_modules/d3-geo-projection": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-2.9.0.tgz", + "integrity": "sha512-ZULvK/zBn87of5rWAfFMc9mJOipeSo57O+BBitsKIXmU4rTVAnX1kSsJkE0R+TxY8pGNoM1nbyRRE7GYHhdOEQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "2", + "d3-array": "1", + "d3-geo": "^1.12.0", + "resolve": "^1.1.10" + }, + "bin": { + "geo2svg": "bin/geo2svg", + "geograticule": "bin/geograticule", + "geoproject": "bin/geoproject", + "geoquantize": "bin/geoquantize", + "geostitch": "bin/geostitch" + } + }, + "node_modules/d3-geo-projection/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1" + } + }, + "node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", + "license": "BSD-3-Clause" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-kerning": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", + "integrity": "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", + "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/draw-svg-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", + "integrity": "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "~0.1.1", + "normalize-svg-path": "~0.1.0" + } + }, + "node_modules/dtype": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz", + "integrity": "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dup/-/dup-1.0.0.tgz", + "integrity": "sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==", + "license": "MIT" + }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/duplexify/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexify/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexify/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "41.0.4", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.0.4.tgz", + "integrity": "sha512-rO08CxnAsAkKPFj3OZnxFkKrlnpSL3OCOewMDj5kaohVo++7e8hIT5Sl+tNl9WkNKiLvfZSW180ueA9s5zh9dg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", + "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.8.1", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", + "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", + "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "license": "ISC" + }, + "node_modules/electron-vite": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", + "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "cac": "^6.7.14", + "esbuild": "^0.25.11", + "magic-string": "^0.30.19", + "picocolors": "^1.1.1" + }, + "bin": { + "electron-vite": "bin/electron-vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/element-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/element-size/-/element-size-1.1.1.tgz", + "integrity": "sha512-eaN+GMOq/Q+BIWy0ybsgpcYImjGIdNLyjLFJU4XsLHXYQao5jCNb36GyN6C2qwmDDYSfIBmKpPpr4VnBdLCsPQ==", + "license": "MIT" + }, + "node_modules/elementary-circuits-directed-graph": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/elementary-circuits-directed-graph/-/elementary-circuits-directed-graph-1.3.1.tgz", + "integrity": "sha512-ZEiB5qkn2adYmpXGnJKkxT8uJHlW/mxmBpmeqawEHzPxh9HkLD4/1mFYX5l0On+f6rcPIt8/EWlRU2Vo3fX6dQ==", + "license": "MIT", + "dependencies": { + "strongly-connected-components": "^1.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/falafel": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz", + "integrity": "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==", + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "isarray": "^2.0.1" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/falafel/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-isnumeric": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", + "integrity": "sha512-1mM8qOr2LYz8zGaUdmiqRDiuue00Dxjgcb1NQR7TnhLVh6sQyngP9xvLo7Sl7LZpP/sk5eb+bcyWXw530NTBZw==", + "license": "MIT", + "dependencies": { + "is-string-blank": "^1.0.1" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/flatten-vertex-data": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flatten-vertex-data/-/flatten-vertex-data-1.0.2.tgz", + "integrity": "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==", + "license": "MIT", + "dependencies": { + "dtype": "^2.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/font-atlas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", + "integrity": "sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==", + "license": "MIT", + "dependencies": { + "css-font": "^1.0.0" + } + }, + "node_modules/font-measure": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/font-measure/-/font-measure-1.2.2.tgz", + "integrity": "sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA==", + "license": "MIT", + "dependencies": { + "css-font": "^1.2.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/from2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/from2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/from2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/from2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-canvas-context": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-canvas-context/-/get-canvas-context-1.0.2.tgz", + "integrity": "sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==", + "license": "MIT" + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gl-mat4": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", + "integrity": "sha512-sT5C0pwB1/e9G9AvAoLsoaJtbMGjfd/jfxo8jMCKqYYEnjZuFvqV5rehqar0538EmssjdDeiEWnKyBSTw7quoA==", + "license": "Zlib" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/gl-text": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gl-text/-/gl-text-1.4.0.tgz", + "integrity": "sha512-o47+XBqLCj1efmuNyCHt7/UEJmB9l66ql7pnobD6p+sgmBUdzfMZXIF0zD2+KRfpd99DJN+QXdvTFAGCKCVSmQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.2", + "color-normalize": "^1.5.0", + "css-font": "^1.2.0", + "detect-kerning": "^2.1.2", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "font-atlas": "^2.1.0", + "font-measure": "^1.2.2", + "gl-util": "^3.1.2", + "is-plain-obj": "^1.1.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "parse-unit": "^1.0.1", + "pick-by-alias": "^1.2.0", + "regl": "^2.0.0", + "to-px": "^1.0.1", + "typedarray-pool": "^1.1.0" + } + }, + "node_modules/gl-util": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/gl-util/-/gl-util-3.1.3.tgz", + "integrity": "sha512-dvRTggw5MSkJnCbh74jZzSoTOGnVYK+Bt+Ckqm39CVcl6+zSsxqWk4lr5NKhkqXHL6qvZAU9h17ZF8mIskY9mA==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1", + "is-firefox": "^1.0.3", + "is-plain-obj": "^1.1.0", + "number-is-integer": "^1.0.1", + "object-assign": "^4.1.0", + "pick-by-alias": "^1.2.0", + "weak-map": "^1.0.5" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glsl-inject-defines": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/glsl-inject-defines/-/glsl-inject-defines-1.0.3.tgz", + "integrity": "sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==", + "license": "MIT", + "dependencies": { + "glsl-token-inject-block": "^1.0.0", + "glsl-token-string": "^1.0.1", + "glsl-tokenizer": "^2.0.2" + } + }, + "node_modules/glsl-resolve": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/glsl-resolve/-/glsl-resolve-0.0.1.tgz", + "integrity": "sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==", + "license": "MIT", + "dependencies": { + "resolve": "^0.6.1", + "xtend": "^2.1.2" + } + }, + "node_modules/glsl-resolve/node_modules/resolve": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", + "integrity": "sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==", + "license": "MIT" + }, + "node_modules/glsl-resolve/node_modules/xtend": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", + "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/glsl-token-assignments": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-assignments/-/glsl-token-assignments-2.0.2.tgz", + "integrity": "sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==", + "license": "MIT" + }, + "node_modules/glsl-token-defines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-defines/-/glsl-token-defines-1.0.0.tgz", + "integrity": "sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==", + "license": "MIT", + "dependencies": { + "glsl-tokenizer": "^2.0.0" + } + }, + "node_modules/glsl-token-depth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-depth/-/glsl-token-depth-1.1.2.tgz", + "integrity": "sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==", + "license": "MIT" + }, + "node_modules/glsl-token-descope": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-descope/-/glsl-token-descope-1.0.2.tgz", + "integrity": "sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==", + "license": "MIT", + "dependencies": { + "glsl-token-assignments": "^2.0.0", + "glsl-token-depth": "^1.1.0", + "glsl-token-properties": "^1.0.0", + "glsl-token-scope": "^1.1.0" + } + }, + "node_modules/glsl-token-inject-block": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/glsl-token-inject-block/-/glsl-token-inject-block-1.1.0.tgz", + "integrity": "sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==", + "license": "MIT" + }, + "node_modules/glsl-token-properties": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-properties/-/glsl-token-properties-1.0.1.tgz", + "integrity": "sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==", + "license": "MIT" + }, + "node_modules/glsl-token-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-scope/-/glsl-token-scope-1.1.2.tgz", + "integrity": "sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==", + "license": "MIT" + }, + "node_modules/glsl-token-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-string/-/glsl-token-string-1.0.1.tgz", + "integrity": "sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==", + "license": "MIT" + }, + "node_modules/glsl-token-whitespace-trim": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-whitespace-trim/-/glsl-token-whitespace-trim-1.0.0.tgz", + "integrity": "sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", + "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", + "license": "MIT", + "dependencies": { + "through2": "^0.6.3" + } + }, + "node_modules/glsl-tokenizer/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/glsl-tokenizer/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", + "license": "MIT", + "dependencies": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/glslify": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glslify/-/glslify-7.1.1.tgz", + "integrity": "sha512-bud98CJ6kGZcP9Yxcsi7Iz647wuDz3oN+IZsjCRi5X1PI7t/xPKeL0mOwXJjo+CRZMqvq0CkSJiywCcY7kVYog==", + "license": "MIT", + "dependencies": { + "bl": "^2.2.1", + "concat-stream": "^1.5.2", + "duplexify": "^3.4.5", + "falafel": "^2.1.0", + "from2": "^2.3.0", + "glsl-resolve": "0.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glslify-bundle": "^5.0.0", + "glslify-deps": "^1.2.5", + "minimist": "^1.2.5", + "resolve": "^1.1.5", + "stack-trace": "0.0.9", + "static-eval": "^2.0.5", + "through2": "^2.0.1", + "xtend": "^4.0.0" + }, + "bin": { + "glslify": "bin.js" + } + }, + "node_modules/glslify-bundle": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glslify-bundle/-/glslify-bundle-5.1.1.tgz", + "integrity": "sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==", + "license": "MIT", + "dependencies": { + "glsl-inject-defines": "^1.0.1", + "glsl-token-defines": "^1.0.0", + "glsl-token-depth": "^1.1.1", + "glsl-token-descope": "^1.0.2", + "glsl-token-scope": "^1.1.1", + "glsl-token-string": "^1.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glsl-tokenizer": "^2.0.2", + "murmurhash-js": "^1.0.0", + "shallow-copy": "0.0.1" + } + }, + "node_modules/glslify-deps": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/glslify-deps/-/glslify-deps-1.3.2.tgz", + "integrity": "sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==", + "license": "ISC", + "dependencies": { + "@choojs/findup": "^0.2.0", + "events": "^3.2.0", + "glsl-resolve": "0.0.1", + "glsl-tokenizer": "^2.0.0", + "graceful-fs": "^4.1.2", + "inherits": "^2.0.1", + "map-limit": "0.0.1", + "resolve": "^1.0.0" + } + }, + "node_modules/glslify/node_modules/bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/glslify/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/glslify/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/glslify/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/glslify/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-hover": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-hover/-/has-hover-1.0.1.tgz", + "integrity": "sha512-0G6w7LnlcpyDzpeGUTuT0CEw05+QlMuGVk1IHNAlHrGJITGodjZu3x8BNDUMfKJSZXNB2ZAclqc1bvrd+uUpfg==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-passive-events": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-passive-events/-/has-passive-events-1.0.0.tgz", + "integrity": "sha512-2vSj6IeIsgvsRMyeQ0JaCX5Q3lX4zMn5HpoVc7MEhQ6pv8Iq9rsXjsp+E5ZwaT7T0xhMT0KmU8gtt1EFVdbJiw==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/hsluv": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/hsluv/-/hsluv-0.0.3.tgz", + "integrity": "sha512-08iL2VyCRbkQKBySkSh6m8zMUa3sADAxGVWs3Z1aPcUkTJeK0ETG4Fc27tEmQBGUAXZjIsXOZqBvacuVNSC/fQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-browser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", + "integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-firefox": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-firefox/-/is-firefox-1.0.3.tgz", + "integrity": "sha512-6Q9ITjvWIm0Xdqv+5U12wgOKEM2KoBw4Y926m0OFkvlCxnbG94HKAsVz8w3fWcfAS5YA2fJORXX1dLrkprCCxA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-iexplorer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-iexplorer/-/is-iexplorer-1.0.0.tgz", + "integrity": "sha512-YeLzceuwg3K6O0MLM3UyUUjKAlyULetwryFp1mHy1I5PfArK0AEqlfa+MR4gkJjcbuJXoDJCvXbyqZVf5CR2Sg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-mobile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-4.0.0.tgz", + "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==", + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string-blank": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", + "integrity": "sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==", + "license": "MIT" + }, + "node_modules/is-svg-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-svg-path/-/is-svg-path-1.0.2.tgz", + "integrity": "sha512-Lj4vePmqpPR1ZnRctHv8ltSh1OrSxHkhUkd7wi+VQdcdP15/KvQFyk7LhNuM7ZW0EVbJz8kZLVmL9quLrfq4Kg==", + "license": "MIT" + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/map-limit": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", + "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", + "license": "MIT", + "dependencies": { + "once": "~1.3.0" + } + }, + "node_modules/map-limit/node_modules/once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/maplibre-gl/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-log2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz", + "integrity": "sha512-9W0yGtkaMAkf74XGYVy4Dqw3YUMnTNB2eeiw9aQbUl4A3KmuCEHTt2DgAB07ENzOYAjsYSAYufkAq0Zd+jU7zA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mouse-change": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/mouse-change/-/mouse-change-1.4.0.tgz", + "integrity": "sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==", + "license": "MIT", + "dependencies": { + "mouse-event": "^1.0.0" + } + }, + "node_modules/mouse-event": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mouse-event/-/mouse-event-1.0.5.tgz", + "integrity": "sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==", + "license": "MIT" + }, + "node_modules/mouse-event-offset": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mouse-event-offset/-/mouse-event-offset-3.0.2.tgz", + "integrity": "sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==", + "license": "MIT" + }, + "node_modules/mouse-wheel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mouse-wheel/-/mouse-wheel-1.2.0.tgz", + "integrity": "sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==", + "license": "MIT", + "dependencies": { + "right-now": "^1.0.0", + "signum": "^1.0.0", + "to-px": "^1.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mumath": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/mumath/-/mumath-3.3.4.tgz", + "integrity": "sha512-VAFIOG6rsxoc7q/IaY3jdjmrsuX9f15KlRLYTHmixASBZkZEKC1IFqE2BC5CdhXmK6WLM1Re33z//AGmeRI6FA==", + "deprecated": "Redundant dependency in your project.", + "license": "Unlicense", + "dependencies": { + "almost-equal": "^1.1.0" + } + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/native-promise-only": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/node-abi": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", + "integrity": "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-gyp": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", + "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-svg-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz", + "integrity": "sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==", + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/number-is-integer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-integer/-/number-is-integer-1.0.1.tgz", + "integrity": "sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==", + "license": "MIT", + "dependencies": { + "is-finite": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parenthesis": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-rect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parse-rect/-/parse-rect-1.2.0.tgz", + "integrity": "sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA==", + "license": "MIT", + "dependencies": { + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, + "node_modules/parse-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz", + "integrity": "sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/pick-by-alias": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pick-by-alias/-/pick-by-alias-1.2.0.tgz", + "integrity": "sha512-ESj2+eBxhGrcA1azgHs7lARG5+5iLakc/6nlfbpjcLl00HuuUOIuORhYXN4D1HfvMSKuVtFQjAlnwi1JHEeDIw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plotly.js": { + "version": "2.35.3", + "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-2.35.3.tgz", + "integrity": "sha512-7RaC6FxmCUhpD6H4MpD+QLUu3hCn76I11rotRefrh3m1iDvWqGnVqVk9dSaKmRAhFD3vsNsYea0OxnR1rc2IzQ==", + "license": "MIT", + "dependencies": { + "@plotly/d3": "3.8.2", + "@plotly/d3-sankey": "0.7.2", + "@plotly/d3-sankey-circular": "0.33.1", + "@plotly/mapbox-gl": "1.13.4", + "@turf/area": "^7.1.0", + "@turf/bbox": "^7.1.0", + "@turf/centroid": "^7.1.0", + "base64-arraybuffer": "^1.0.2", + "canvas-fit": "^1.5.0", + "color-alpha": "1.0.4", + "color-normalize": "1.5.0", + "color-parse": "2.0.0", + "color-rgba": "2.1.1", + "country-regex": "^1.1.0", + "css-loader": "^7.1.2", + "d3-force": "^1.2.1", + "d3-format": "^1.4.5", + "d3-geo": "^1.12.1", + "d3-geo-projection": "^2.9.0", + "d3-hierarchy": "^1.1.9", + "d3-interpolate": "^3.0.1", + "d3-time": "^1.1.0", + "d3-time-format": "^2.2.3", + "fast-isnumeric": "^1.1.4", + "gl-mat4": "^1.2.0", + "gl-text": "^1.4.0", + "has-hover": "^1.0.1", + "has-passive-events": "^1.0.0", + "is-mobile": "^4.0.0", + "maplibre-gl": "^4.5.2", + "mouse-change": "^1.4.0", + "mouse-event-offset": "^3.0.2", + "mouse-wheel": "^1.2.0", + "native-promise-only": "^0.8.1", + "parse-svg-path": "^0.1.2", + "point-in-polygon": "^1.1.0", + "polybooljs": "^1.2.2", + "probe-image-size": "^7.2.3", + "regl": "npm:@plotly/regl@^2.1.2", + "regl-error2d": "^2.0.12", + "regl-line2d": "^3.1.3", + "regl-scatter2d": "^3.3.1", + "regl-splom": "^1.0.14", + "strongly-connected-components": "^1.0.1", + "style-loader": "^4.0.0", + "superscript-text": "^1.0.0", + "svg-path-sdf": "^1.1.3", + "tinycolor2": "^1.4.2", + "to-px": "1.0.1", + "topojson-client": "^3.1.0", + "webgl-context": "^2.2.0", + "world-calendars": "^1.0.3" + } + }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/polybooljs": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/polybooljs/-/polybooljs-1.2.2.tgz", + "integrity": "sha512-ziHW/02J0XuNuUtmidBc6GXE8YohYydp3DWPWXYsd7O721TjcmN+k6ezjdwkDqep+gnWnFY+yqZHvzElra2oCg==", + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/probe-image-size": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", + "integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==", + "license": "MIT", + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" + }, + "node_modules/react-plotly.js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-plotly.js/-/react-plotly.js-2.6.0.tgz", + "integrity": "sha512-g93xcyhAVCSt9kV1svqG1clAEdL6k3U+jjuSzfTV7owaSU9Go6Ph8bl25J+jKfKvIGAEYpe4qj++WHJuc9IaeA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "plotly.js": ">1.34.0", + "react": ">0.13.0" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regl": { + "name": "@plotly/regl", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@plotly/regl/-/regl-2.1.2.tgz", + "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", + "license": "MIT" + }, + "node_modules/regl-error2d": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/regl-error2d/-/regl-error2d-2.0.12.tgz", + "integrity": "sha512-r7BUprZoPO9AbyqM5qlJesrSRkl+hZnVKWKsVp7YhOl/3RIpi4UDGASGJY0puQ96u5fBYw/OlqV24IGcgJ0McA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "color-normalize": "^1.5.0", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-line2d": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/regl-line2d/-/regl-line2d-3.1.3.tgz", + "integrity": "sha512-fkgzW+tTn4QUQLpFKsUIE0sgWdCmXAM3ctXcCgoGBZTSX5FE2A0M7aynz7nrZT5baaftLrk9te54B+MEq4QcSA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-find-index": "^1.0.2", + "array-normalize": "^1.1.4", + "color-normalize": "^1.5.0", + "earcut": "^2.1.5", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0" + } + }, + "node_modules/regl-scatter2d": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/regl-scatter2d/-/regl-scatter2d-3.3.1.tgz", + "integrity": "sha512-seOmMIVwaCwemSYz/y4WE0dbSO9svNFSqtTh5RE57I7PjGo3tcUYKtH0MTSoshcAsreoqN8HoCtnn8wfHXXfKQ==", + "license": "MIT", + "dependencies": { + "@plotly/point-cluster": "^3.1.9", + "array-range": "^1.0.1", + "array-rearrange": "^2.2.2", + "clamp": "^1.0.1", + "color-id": "^1.1.0", + "color-normalize": "^1.5.0", + "color-rgba": "^2.1.1", + "flatten-vertex-data": "^1.0.2", + "glslify": "^7.0.0", + "is-iexplorer": "^1.0.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-splom": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/regl-splom/-/regl-splom-1.0.14.tgz", + "integrity": "sha512-OiLqjmPRYbd7kDlHC6/zDf6L8lxgDC65BhC8JirhP4ykrK4x22ZyS+BnY8EUinXKDeMgmpRwCvUmk7BK4Nweuw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-range": "^1.0.1", + "color-alpha": "^1.0.4", + "flatten-vertex-data": "^1.0.2", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "raf": "^3.4.1", + "regl-scatter2d": "^3.2.3" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/right-now": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", + "integrity": "sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shallow-copy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/signum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/signum/-/signum-1.0.0.tgz", + "integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/stack-trace": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz", + "integrity": "sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==", + "engines": { + "node": "*" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "license": "MIT", + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-split-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", + "license": "MIT", + "dependencies": { + "parenthesis": "^3.1.5" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strongly-connected-components": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", + "integrity": "sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==", + "license": "MIT" + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, + "node_modules/supercluster/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, + "node_modules/superscript-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/superscript-text/-/superscript-text-1.0.0.tgz", + "integrity": "sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, + "node_modules/svg-path-bounds": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/svg-path-bounds/-/svg-path-bounds-1.0.2.tgz", + "integrity": "sha512-H4/uAgLWrppIC0kHsb2/dWUYSmb4GE5UqH06uqWBcg6LBjX2fu0A8+JrO2/FJPZiSsNOKZAhyFFgsLTdYUvSqQ==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "^0.1.1", + "is-svg-path": "^1.0.1", + "normalize-svg-path": "^1.0.0", + "parse-svg-path": "^0.1.2" + } + }, + "node_modules/svg-path-bounds/node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, + "node_modules/svg-path-sdf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/svg-path-sdf/-/svg-path-sdf-1.1.3.tgz", + "integrity": "sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==", + "license": "MIT", + "dependencies": { + "bitmap-sdf": "^1.0.0", + "draw-svg-path": "^1.0.0", + "is-svg-path": "^1.0.1", + "parse-svg-path": "^0.1.2", + "svg-path-bounds": "^1.0.1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "peer": true + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/through2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/to-float32": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", + "integrity": "sha512-keDnAusn/vc+R3iEiSDw8TOF7gPiTLdK1ArvWtYbJQiVfmRg6i/CAvbKq3uIS0vWroAC7ZecN3DjQKw3aSklUg==", + "license": "MIT" + }, + "node_modules/to-px": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-px/-/to-px-1.0.1.tgz", + "integrity": "sha512-2y3LjBeIZYL19e5gczp14/uRWFDtDUErJPVN3VU9a7SJO+RjGRtYR47aMN2bZgGlxvW4ZcEz2ddUPVHXcMfuXw==", + "license": "MIT", + "dependencies": { + "parse-unit": "^1.0.1" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typedarray-pool": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", + "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.0", + "dup": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", + "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-diff/-/update-diff-1.1.0.tgz", + "integrity": "sha512-rCiBPiHxZwT4+sBhEbChzpO5hYHjm91kScWgdHf4Qeafs6Ba7MBl+d9GlGv72bcTZQO0sLmtQS1pHSWoCLtN/A==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/weak-map": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", + "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", + "license": "Apache-2.0" + }, + "node_modules/webgl-context": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", + "integrity": "sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==", + "license": "MIT", + "dependencies": { + "get-canvas-context": "^1.0.1" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack": { + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/world-calendars": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", + "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ed1cdb1 --- /dev/null +++ b/package.json @@ -0,0 +1,183 @@ +{ + "name": "stingray-explorer", + "version": "2.0.0", + "description": "X-ray Timing Analysis Desktop Application - Next Generation Spectral Timing Made Easy", + "type": "module", + "main": "dist-electron/main.js", + "author": "Kartik Mandar ", + "license": "MIT", + "homepage": "https://github.com/kartikmandar-GSOC24/StingrayExplorer", + "repository": { + "type": "git", + "url": "https://github.com/kartikmandar-GSOC24/StingrayExplorer.git" + }, + "scripts": { + "dev": "electron-vite dev", + "dev:linux": "ELECTRON_DISABLE_SANDBOX=1 electron-vite dev", + "build": "electron-vite build", + "preview": "electron-vite preview", + "package": "npm run build && electron-builder", + "package:mac": "npm run build && electron-builder --mac", + "package:win": "npm run build && electron-builder --win", + "package:linux": "npm run build && electron-builder --linux", + "package:all": "npm run build && electron-builder -mwl", + "python:install": "cd python-backend && pip install -r requirements.txt", + "python:dev": "cd python-backend && python -X context_aware_warnings=1 main.py 8765", + "python:build": "cd python-backend && pyinstaller --onefile --name stingray-backend main.py", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:coverage": "vitest --coverage" + }, + "build": { + "appId": "com.stingray.explorer", + "productName": "Stingray Explorer", + "copyright": "Copyright (c) 2024 Kartik Mandar", + "directories": { + "output": "release", + "buildResources": "resources" + }, + "files": [ + "dist/**/*", + "dist-electron/**/*" + ], + "extraResources": [ + { + "from": "python-backend/dist/", + "to": "python-backend", + "filter": [ + "**/*" + ] + }, + { + "from": "files/", + "to": "files", + "filter": [ + "**/*" + ] + }, + { + "from": "resources/icon.png", + "to": "icon.png" + } + ], + "mac": { + "target": [ + { + "target": "dmg", + "arch": [ + "x64", + "arm64" + ] + }, + { + "target": "zip", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "resources/icon.png", + "category": "public.app-category.developer-tools", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "resources/entitlements.mac.plist", + "entitlementsInherit": "resources/entitlements.mac.plist" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + }, + { + "target": "portable", + "arch": [ + "x64" + ] + } + ], + "icon": "resources/icon.ico", + "publisherName": "Kartik Mandar" + }, + "linux": { + "target": [ + { + "target": "AppImage", + "arch": [ + "x64" + ] + }, + { + "target": "deb", + "arch": [ + "x64" + ] + }, + { + "target": "rpm", + "arch": [ + "x64" + ] + } + ], + "icon": "resources/icon.png", + "category": "Science", + "maintainer": "kartik4321mandar@gmail.com" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "installerIcon": "resources/icon.ico", + "uninstallerIcon": "resources/icon.ico", + "installerHeaderIcon": "resources/icon.ico" + } + }, + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "@mui/icons-material": "^5.15.6", + "@mui/material": "^5.15.6", + "@tanstack/react-query": "^5.17.19", + "axios": "^1.6.7", + "plotly.js": "^2.29.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-plotly.js": "^2.6.0", + "react-router-dom": "^6.22.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^20.11.16", + "@types/plotly.js": "^2.35.14", + "@types/react": "^18.2.52", + "@types/react-dom": "^18.2.18", + "@types/react-plotly.js": "^2.6.3", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "@vitejs/plugin-react": "^5.2.0", + "electron": "^41.0.4", + "electron-builder": "^26.8.1", + "electron-vite": "^5.0.0", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "jsdom": "^29.1.1", + "typescript": "^5.3.3", + "vite": "^7.3.1", + "vitest": "^4.1.2" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/pixi.lock b/pixi.lock new file mode 100644 index 0000000..44aedcb --- /dev/null +++ b/pixi.lock @@ -0,0 +1,13080 @@ +version: 6 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-2.25.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiofiles-25.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohttp-3.13.2-pyh4ca1811_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aioitertools-0.12.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.14-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/astropy-base-7.2.0-py314hc02f841_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-iers-data-0.2025.12.8.0.38.44-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astroquery-0.4.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.9.3-hef928c7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.13-h2c9d079_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.12.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.1-h8b1a151_9.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.5.7-h28f887f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.10.7-ha8fc4e3_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.23.3-hdaf4b65_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.13.3-hc63082f_11.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.11.3-h06ab39a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.4-h8b1a151_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.7-h8b1a151_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.35.4-h8824e59_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.11.606-h20b40b1_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-core-cpp-1.16.1-h3a458e0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-identity-cpp-1.13.2-h3a5f585_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-blobs-cpp-12.15.0-h2a74896_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-common-cpp-12.11.0-h3d7a050_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-files-datalake-cpp-12.13.0-hf38f1be_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/botocore-1.40.70-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bottleneck-1.6.0-np2py314h56abb78_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.45-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-blosc2-2.22.0-hc31b594_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py314h9891dd4_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.2-py314hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-46.0.4-py314h7fe84b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hd9c7081_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2025.12.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.18-py314h42812f9_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.3.1-h5888daf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.124.4-hd122799_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.124.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.61.1-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/frozenlist-1.7.0-pyhf298e5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/gflags-2.2.2-h5888daf_1005.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glog-0.7.1-hbabe93e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/h5py-3.15.1-nompi_py314hc32fe06_101.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h1b119a7_104.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/httptools-0.7.1-py314h5bd0f2a_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyha191276_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jeepney-0.9.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jplephem-2.23-pyha4b2019_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.7.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyha804496_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.4.9-py314h97ea11e_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.17-h717163a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_104.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.4-h3f801dc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-22.0.0-hb6ed5f4_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-acero-22.0.0-h635bf11_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-compute-22.0.0-h8c2c5c3_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-dataset-22.0.0-h635bf11_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-substrait-22.0.0-h3f74fd7_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-4_h4a7cf45_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-4_h0358290_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.7-default_h99862b1_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.7-default_h746c552_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcrc32c-1.1.2-h9c3ff4c_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.17.0-h4e3cde8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libevent-2.1.12-hf998b51_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.2-h32235b2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-2.39.0-hdb79228_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-storage-2.39.0-hdbdcf42_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.73.1-h3288cfb_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-4_h47877c9_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm21-21.1.7-hf7376ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.30-pthreads_h94d23a6_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-1.21.0-hb9b0907_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-headers-1.21.0-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libparquet-22.0.0-h7376487_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.53-h421ea60_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpq-18.1-h5c52fec_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-6.31.1-h49aed37_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libre2-11-2025.11.05-h7b12aa8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.20-h4ab18f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.1-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.22.0-h454ac66_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libutf8proc-2.11.2-hfe17d71_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-h5347b49_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.328.1-h5279c79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.43-h711ed8c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/llvmlite-0.46.0-py314h946fb2a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/lxml-6.0.2-py314hae3bed6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.10.8-py314hdafbbf9_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.8-py314h1194b4b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/multidict-6.7.0-pyh62beb40_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.13.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.2.1-he2c55a7_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nomkl-1.0-h5ca1d4c_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/numba-0.63.1-py314h8169c2f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.14.1-py314heb044ea_101.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.5-py314h2b28147_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.10-he970967_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/orc-2.2.1-hd747db4_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-2.3.3-py314ha0b5721_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.0.0-py314h8ec4b1a_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/prometheus-cpp-1.3.0-ha5d0236_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.3.1-pyhe1237c8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py314h0f05182_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py-cpuinfo-9.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyarrow-22.0.0-py314hdafbbf9_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyarrow-core-22.0.0-py314h52d6ec5_0_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.12.5-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.41.5-py314h2e6c369_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyerfa-2.0.1.5-py310h32771cd_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.3-py314hf36963e_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pytables-3.10.2-py314h5611b9a_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.2-h32b2ec7_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.2-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.20-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyvo-1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hfb55c3c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.3-h5c1c036_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/re2-2025.11.05-h5301d42_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.17.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/s2n-1.6.2-he8a4886_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/s3fs-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.3-py314he7377e1_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.4.1-py314hdafbbf9_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-0.50.0-pyhfdc7a7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stingray-2.2.10-pyhc455866_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.3-py314h5bd0f2a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.20.0-pyhefaf540_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-0.20.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-standard-0.20.0-h4daf872_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.0-py314h5bd0f2a_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/uvloop-0.22.1-py314h5bd0f2a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/watchfiles-1.1.1-py314ha5689aa_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-hd6090a7_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/websockets-15.0.1-py314h31f8a6b_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wrapt-1.17.3-py314h5bd0f2a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.22.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h387f397_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.1-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.2-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + osx-64: + - conda: https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-2.25.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiofiles-25.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohttp-3.13.2-pyh4ca1811_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aioitertools-0.12.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/astropy-base-7.2.0-py314hd1ec8a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-iers-data-0.2025.12.8.0.38.44-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astroquery-0.4.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-auth-0.9.3-hdff831d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-cal-0.9.13-hea39f9f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-common-0.12.6-h8616949_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-compression-0.3.1-h901532c_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-event-stream-0.5.7-ha05da6a_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-http-0.10.7-h924c446_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-io-0.23.3-hf559bb5_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-mqtt-0.13.3-ha72ff4e_11.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-s3-0.11.3-he30762a_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-sdkutils-0.2.4-h901532c_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-checksums-0.2.7-h901532c_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-crt-cpp-0.35.2-h7484968_6.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-sdk-cpp-1.11.606-hffd60a0_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-core-cpp-1.16.1-he2a98a9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-identity-cpp-1.13.2-h0e8e1c8_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-storage-blobs-cpp-12.15.0-h388f2e7_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-storage-common-cpp-12.11.0-h56a711b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-storage-files-datalake-cpp-12.13.0-h1984e67_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/blosc-1.21.6-hd145fbb_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/botocore-1.40.70-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/bottleneck-1.6.0-np2py314hfeef9c2_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.45-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-1.2.0-hf139dec_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-bin-1.2.0-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-python-1.2.0-py314h3262eb8_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/c-ares-1.34.6-hb5e19a0_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/c-blosc2-2.22.0-hedb7e5f_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/contourpy-1.3.3-py314h00ed6fe_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.2-py314hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2025.12.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/debugpy-1.8.18-py314h3658963_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.124.4-hd122799_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.124.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.61.1-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/freetype-2.14.1-h694c41f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/frozenlist-1.7.0-pyhf298e5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-64/gflags-2.2.2-hac325c4_1005.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/glog-0.7.1-h2790a97_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/h5py-3.15.1-nompi_py314hf613b1f_101.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.6-nompi_hc1508a4_104.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/httptools-0.7.1-py314h6482030_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyh5552912_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jplephem-2.23-pyha4b2019_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.7.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh534df25_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/kiwisolver-1.4.9-py314hf3ac25a_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/krb5-1.21.3-h37d8d59_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/lcms2-2.17-h72f5680_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/lerc-4.0.0-hcca01a6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libabseil-20250512.1-cxx17_hfc00f1c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libaec-1.1.4-ha6bc127_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-22.0.0-hd1700fa_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-acero-22.0.0-h2db2d7d_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-compute-22.0.0-h7751554_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-dataset-22.0.0-h2db2d7d_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-substrait-22.0.0-h4653b8a_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libblas-3.11.0-4_he492b99_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlicommon-1.2.0-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlidec-1.2.0-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlienc-1.2.0-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcblas-3.11.0-4_h9b27e0a_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcrc32c-1.1.2-he49afe7_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcurl-8.17.0-h7dd4100_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.7-h3d58e20_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libdeflate-1.25-h517ebb2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libedit-3.1.20250104-pl5321ha958ccf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libev-4.33-h10d778d_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libevent-2.1.12-ha90c15b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.7.3-heffb93a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-h750e83c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libfreetype-2.14.1-h694c41f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libfreetype6-2.14.1-h6912278_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgcc-15.2.0-h08519bb_15.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran-15.2.0-h7e5c614_15.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-hd16e46c_15.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgoogle-cloud-2.39.0-hed66dea_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgoogle-cloud-storage-2.39.0-h8ac052b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgrpc-1.73.1-h451496d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libjpeg-turbo-3.1.2-h8616949_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblapack-3.11.0-4_h859234e_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.1-hd471939_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-h6e16a3a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libnghttp2-1.67.0-h3338091_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libopenblas-0.3.30-openmp_h6006d49_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libopentelemetry-cpp-1.21.0-h7d3f41d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libopentelemetry-cpp-headers-1.21.0-h694c41f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libparquet-22.0.0-habb56ca_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libpng-1.6.53-h380d223_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libprotobuf-6.31.1-h03562ea_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libre2-11-2025.11.05-h554ac88_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsodium-1.0.20-hfdf4475_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.51.1-h6cc646a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libssh2-1.11.1-hed3591d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libthrift-0.22.0-h687e942_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libtiff-4.7.1-ha0a348c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libutf8proc-2.11.2-h7983711_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libuv-1.51.0-h58003a5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libwebp-base-1.6.0-hb807250_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxcb-1.17.0-hf1f96e2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.1-ha1d9b0f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.1-h7b7ecba_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxslt-1.1.43-h486b42e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-21.1.7-h472b3d1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvmlite-0.46.0-py314h85c3bf0_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-64/lxml-6.0.2-py314h787f955_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/lz4-c-1.10.0-h240833e_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/matplotlib-3.10.8-py314hee6578b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/matplotlib-base-3.10.8-py314hd47142c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/multidict-6.7.0-pyh62beb40_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.13.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/nlohmann_json-3.12.0-h53ec75d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/nodejs-25.2.1-h5523da6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/numba-0.63.0-py314h385e359_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/numexpr-2.14.1-py314h205861b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/numpy-2.3.5-py314hf08249b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openjpeg-2.5.4-h87e8dc5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.0-h230baf5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/orc-2.2.1-hd1b02dc_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pandas-2.3.3-py314hc4308db_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pillow-12.0.0-py314hedf0282_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/prometheus-cpp-1.3.0-h7802330_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.3.1-pyhe1237c8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.1.3-py314hd1e8ddb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pthread-stubs-0.4-h00291cd_1002.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py-cpuinfo-9.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-64/pyarrow-22.0.0-py314hee6578b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pyarrow-core-22.0.0-py314h35e0213_0_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.12.5-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pydantic-core-2.41.5-py314ha7b6dee_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pyerfa-2.0.1.5-py310hcbffc5d_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pytables-3.10.2-py314hb51f073_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.2-hf88997e_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.2-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.20-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyvo-1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pyzmq-27.1.0-py312hb7d603e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/qhull-2020.2-h3c5361c_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/re2-2025.11.05-h7df6414_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h7cca4af_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.17.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/s3fs-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/scipy-1.16.3-py314h9d854bd_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/snappy-1.2.2-h01f5ddf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-0.50.0-pyhfdc7a7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stingray-2.2.10-pyhc455866_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-hf689a15_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tornado-6.5.3-py314h6482030_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.20.0-pyhefaf540_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-0.20.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-standard-0.20.0-h4daf872_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/unicodedata2-17.0.0-py314h6482030_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/uvloop-0.22.1-py314h6482030_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/watchfiles-1.1.1-py314hc9c287a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/websockets-15.0.1-py314hcfd16f8_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/wrapt-1.17.3-py314h03d016b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/xorg-libxau-1.0.12-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/xorg-libxdmcp-1.1.5-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.22.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zeromq-4.3.5-h6c33b1e_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-1.3.1-hd23fc13_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.2-h53ec75d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-2.25.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiofiles-25.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohttp-3.13.2-pyh4ca1811_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aioitertools-0.12.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/astropy-base-7.2.0-py314hdcf55e8_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-iers-data-0.2025.12.8.0.38.44-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astroquery-0.4.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-auth-0.9.3-h1ddaa69_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-cal-0.9.13-h6ee9776_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-common-0.12.6-hc919400_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-compression-0.3.1-h16f91aa_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-event-stream-0.5.7-h9ae9c55_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-http-0.10.7-h5928ca5_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-io-0.23.3-hbe03c90_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-mqtt-0.13.3-haf5c5c8_11.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-s3-0.11.3-h8da9771_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-sdkutils-0.2.4-h16f91aa_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-checksums-0.2.7-h16f91aa_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-crt-cpp-0.35.4-h74951b9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-sdk-cpp-1.11.606-h4e1b0f7_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-core-cpp-1.16.1-h88fedcc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-identity-cpp-1.13.2-h853621b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-storage-blobs-cpp-12.15.0-h10d327b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-storage-common-cpp-12.11.0-h7e4aa5d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-storage-files-datalake-cpp-12.13.0-hb288d13_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/blosc-1.21.6-h7dd00d9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/botocore-1.40.70-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bottleneck-1.6.0-np2py314hfa18b03_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.45-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-1.2.0-h7d5ae5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-bin-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-blosc2-2.22.0-hb83781b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/contourpy-1.3.3-py314h784bc60_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.2-py314hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2025.12.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.18-py314hf820bb6_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.124.4-hd122799_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.124.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.61.1-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.1-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/frozenlist-1.7.0-pyhf298e5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gflags-2.2.2-hf9b8971_1005.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glog-0.7.1-heb240a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/h5py-3.15.1-nompi_py314h1c8d760_101.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdf5-1.14.6-nompi_hd3baa01_104.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/httptools-0.7.1-py314h0612a62_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyh5552912_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jplephem-2.23-pyha4b2019_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.7.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh534df25_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/kiwisolver-1.4.9-py314h42813c9_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.21.3-h237132a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.17-h7eeda09_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.0.0-hd64df32_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20250512.1-cxx17_hd41c47c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libaec-1.1.4-h51d1e36_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-22.0.0-he6e817a_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-acero-22.0.0-hc317990_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-compute-22.0.0-h75845d1_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-dataset-22.0.0-hc317990_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-substrait-22.0.0-h144af7f_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-4_h51639a9_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-4_hb0561ab_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcrc32c-1.1.2-hbdafb3b_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.17.0-hdece5d2_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.7-hf598326_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libevent-2.1.12-h2757513_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.1-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.1-h6da58f4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_16.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_16.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_16.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgoogle-cloud-2.39.0-head0a95_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgoogle-cloud-storage-2.39.0-hfa3a374_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgrpc-1.73.1-h3063b79_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.2-hc919400_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-4_hd9741b5_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.67.0-hc438710_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.30-openmp_ha158390_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopentelemetry-cpp-1.21.0-he15edb5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopentelemetry-cpp-headers-1.21.0-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libparquet-22.0.0-h0ac143b_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.53-hfab5511_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-6.31.1-h658db43_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libre2-11-2025.11.05-h91c62da_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.20-h99b78c6_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.1-h9a5124b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libthrift-0.22.0-h14a376c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h4030677_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libutf8proc-2.11.2-hd2415e0_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxcb-1.17.0-hdb1d25a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.1-h0ff4647_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.1-h9329255_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxslt-1.1.43-hb2570ba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.7-h4a912ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvmlite-0.46.0-py314ha398f32_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lxml-6.0.2-py314he05ef12_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-3.10.8-py314he55896b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-base-3.10.8-py314hd63e3f0_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/multidict-6.7.0-pyh62beb40_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.13.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nlohmann_json-3.12.0-h248ca61_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-25.2.1-h5230ea7_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numba-0.63.1-py314h945de62_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.14.1-py314hc5bb990_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.3.5-py314h5b5928d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hbfb3c88_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/orc-2.2.1-h4fd0076_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandas-2.3.3-py314ha3d490a_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pillow-12.0.0-py314h57fbdfe_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/prometheus-cpp-1.3.0-h0967b3e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.3.1-pyhe1237c8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.1.3-py314h9d33bd4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pthread-stubs-0.4-hd74edd7_1002.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py-cpuinfo-9.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyarrow-22.0.0-py314he55896b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyarrow-core-22.0.0-py314hf20a12a_0_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.12.5-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pydantic-core-2.41.5-py314haad56a0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyerfa-2.0.1.5-py310hbb12772_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pytables-3.10.2-py314h8eb144a_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.2-h40d2674_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.2-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.20-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyvo-1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312hd65ceae_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/qhull-2020.2-h420ef59_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/re2-2025.11.05-h64b956e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.17.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/s3fs-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.16.3-py314h624bdf2_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-hada39a4_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-0.50.0-pyhfdc7a7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stingray-2.2.10-pyhc455866_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.3-py314h0612a62_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.20.0-pyhefaf540_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-0.20.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-standard-0.20.0-h4daf872_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/unicodedata2-17.0.0-py314h0612a62_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uvloop-0.22.1-py314h0612a62_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/watchfiles-1.1.1-py314h8d4a433_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/websockets-15.0.1-py314hf17b0b1_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/wrapt-1.17.3-py314hb84d1df_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxau-1.0.12-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxdmcp-1.1.5-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.22.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h888dc83_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.1-h8359307_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.2-h248ca61_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + win-64: + - conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-2_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-2.25.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiofiles-25.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohttp-3.13.2-pyh4ca1811_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aioitertools-0.12.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/astropy-base-7.2.0-py314h2dcd201_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-iers-data-0.2025.12.8.0.38.44-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astroquery-0.4.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-auth-0.9.3-h2970c50_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-cal-0.9.13-h46f3b43_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-common-0.12.6-hfd05255_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-compression-0.3.1-hcb3a2da_9.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-event-stream-0.5.7-ha388e84_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-http-0.10.7-hc678f4a_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-io-0.23.3-h0d5b9f9_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-mqtt-0.13.3-hfa314fa_11.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-s3-0.11.3-ha659bf3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-sdkutils-0.2.4-hcb3a2da_4.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-checksums-0.2.7-hcb3a2da_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-crt-cpp-0.35.4-hca034e6_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-sdk-cpp-1.11.606-hac16450_10.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/blosc-1.21.6-hfd34d9b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/botocore-1.40.70-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/bottleneck-1.6.0-np2py314hea88fa1_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.45-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-1.2.0-h2d644bc_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-bin-1.2.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/c-ares-1.34.6-hfd05255_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/c-blosc2-2.22.0-h2af8807_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/cairo-1.18.4-h5782bbf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyha7b4d00_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/contourpy-1.3.3-py314h909e829_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.2-py314hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2025.12.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.18-py314hb98de8c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/double-conversion-3.3.1-he0c23c2_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.124.4-hd122799_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.124.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/fontconfig-2.15.0-h765892d_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.61.1-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/freetype-2.14.1-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/frozenlist-1.7.0-pyhf298e5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/graphite2-1.3.14-hac47afa_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/h5py-3.15.1-nompi_py314hc249e69_101.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/harfbuzz-12.2.0-h5f2951f_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.6-nompi_h89f0904_104.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/httptools-0.7.1-py314h5a2d7ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/icu-75.1-he0c23c2_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyhe2676ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jplephem-2.23-pyha4b2019_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.7.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh7428d3b_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/kiwisolver-1.4.9-py314hf309875_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.21.3-hdf4eb48_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lcms2-2.17-hbcf6048_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lerc-4.0.0-h6470a55_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libabseil-20250512.1-cxx17_habfad5f_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libaec-1.1.4-h20038f6_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-22.0.0-h89d7da9_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-acero-22.0.0-h7d8d6a5_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-compute-22.0.0-h2db994a_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-dataset-22.0.0-h7d8d6a5_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-substrait-22.0.0-hf865cc0_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-4_hf2e6a31_mkl.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libbrotlicommon-1.2.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libbrotlidec-1.2.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libbrotlienc-1.2.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-4_h2a3cdd5_mkl.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libclang13-21.1.7-default_ha2db4b5_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libcrc32c-1.1.2-h0e60522_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/libcurl-8.17.0-h43ecb02_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libdeflate-1.25-h51727cc_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libevent-2.1.12-h3671451_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.3-hac47afa_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h52bdfb6_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libfreetype-2.14.1-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libfreetype6-2.14.1-hdbac1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_16.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libglib-2.86.2-hd9c3897_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_16.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgoogle-cloud-2.39.0-h19ee442_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgoogle-cloud-storage-2.39.0-he04ea4c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgrpc-1.73.1-h317e13b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.12.1-default_h4379cf1_1003.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libintl-0.22.5-h5728263_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libjpeg-turbo-3.1.2-hfd05255_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-4_hf9ab0e9_mkl.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-h2466b09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libparquet-22.0.0-h7051d1f_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libpng-1.6.53-h7351971_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libprotobuf-6.31.1-hdcda5b4_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libre2-11-2025.11.05-h0eb2380_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.20-hc70643c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.51.1-hf5d6505_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libssh2-1.11.1-h9aa295b_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libthrift-0.22.0-h23985f6_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libtiff-4.7.1-h8f73337_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libutf8proc-2.11.2-hb980946_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libvulkan-loader-1.4.328.1-h477610d_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libwebp-base-1.6.0-h4d5522a_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxcb-1.17.0-h0e4246c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.1-h06f855e_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.1-ha29bfb0_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-21.1.7-h4fa8253_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/llvmlite-0.46.0-py314hb492ee6_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.0.2-py314hcdb55d9_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lz4-c-1.10.0-h2466b09_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/matplotlib-3.10.8-py314h86ab7b2_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/matplotlib-base-3.10.8-py314hfa45d96_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2025.3.0-hac47afa_454.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/multidict-6.7.0-pyh62beb40_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.13.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-25.2.1-he453025_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/numba-0.63.1-py314h36f8cf2_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/numexpr-2.14.1-mkl_py314h220b711_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.3.5-py314h06c3c77_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openjpeg-2.5.4-h24db6dd_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.0-h725018a_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/orc-2.2.1-h7414dfc_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-2.3.3-py314hd8fd7ce_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pcre2-10.46-h3402e2f_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pillow-12.0.0-py314h61b30b5_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pixman-0.46.4-h5112557_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.3.1-pyhe1237c8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.1.3-py314hc5dbbe4_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pthread-stubs-0.4-h0e40799_1002.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py-cpuinfo-9.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/pyarrow-22.0.0-py314h86ab7b2_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyarrow-core-22.0.0-py314hb5be3fa_0_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.12.5-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.41.5-py314h9f07db2_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyerfa-2.0.1.5-py310h1f63838_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyside6-6.9.3-py314h2c9462b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pytables-3.10.2-py314h2bd12ea_10.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.2-h4b44e0e_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.2-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.20-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyvo-1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314h8f8f202_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-ctypes-0.2.3-py314h86ab7b2_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312hbb5da91_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/qhull-2020.2-hc790b64_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/qt6-main-6.9.3-ha0de62e_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/re2-2025.11.05-ha104f34_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.17.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/s3fs-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/scipy-1.16.3-py314h5798d8a_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/snappy-1.2.2-h7fa0ca8_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-0.50.0-pyhfdc7a7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stingray-2.2.10-pyhc455866_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2022.3.0-hd094cb3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.3-py314h5a2d7ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.20.0-pyhefaf540_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-0.20.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-standard-0.20.0-h4daf872_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/unicodedata2-17.0.0-py314h5a2d7ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh5737063_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h5737063_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h2b53caa_33.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_33.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_33.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vs2015_runtime-14.44.35208-h38c0c73_33.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/watchfiles-1.1.1-py314h170c82c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/websockets-15.0.1-py314h4667ab5_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-1.17.3-py314h5a2d7ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/xorg-libxau-1.0.12-hba3369d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/xorg-libxdmcp-1.1.5-hba3369d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.22.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h5bddc39_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.2-h5112557_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + dev: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-2.25.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiofiles-25.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohttp-3.13.2-pyh4ca1811_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aioitertools-0.12.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.14-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/astropy-base-7.2.0-py314hc02f841_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-iers-data-0.2025.12.8.0.38.44-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astroquery-0.4.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.9.3-hef928c7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.13-h2c9d079_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.12.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.1-h8b1a151_9.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.5.7-h28f887f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.10.7-ha8fc4e3_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.23.3-hdaf4b65_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.13.3-hc63082f_11.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.11.3-h06ab39a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.4-h8b1a151_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.7-h8b1a151_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.35.4-h8824e59_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.11.606-h20b40b1_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-core-cpp-1.16.1-h3a458e0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-identity-cpp-1.13.2-h3a5f585_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-blobs-cpp-12.15.0-h2a74896_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-common-cpp-12.11.0-h3d7a050_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-files-datalake-cpp-12.13.0-hf38f1be_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.asyncio.runner-1.2.0-pyh5ded981_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/black-25.12.0-pyh866005b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/botocore-1.40.70-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bottleneck-1.6.0-np2py314h56abb78_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.45-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-blosc2-2.22.0-hc31b594_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py314h9891dd4_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.2-py314hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-46.0.4-py314h7fe84b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hd9c7081_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2025.12.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.18-py314h42812f9_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.3.1-h5888daf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.124.4-hd122799_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.124.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.61.1-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/frozenlist-1.7.0-pyhf298e5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/gflags-2.2.2-h5888daf_1005.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glog-0.7.1-hbabe93e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/h5py-3.15.1-nompi_py314hc32fe06_101.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h1b119a7_104.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/httptools-0.7.1-py314h5bd0f2a_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyha191276_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jeepney-0.9.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jplephem-2.23-pyha4b2019_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.7.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyha804496_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.4.9-py314h97ea11e_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.17-h717163a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_104.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.4-h3f801dc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-22.0.0-hb6ed5f4_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-acero-22.0.0-h635bf11_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-compute-22.0.0-h8c2c5c3_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-dataset-22.0.0-h635bf11_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-substrait-22.0.0-h3f74fd7_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-4_h4a7cf45_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-4_h0358290_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.7-default_h99862b1_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.7-default_h746c552_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcrc32c-1.1.2-h9c3ff4c_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.17.0-h4e3cde8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libevent-2.1.12-hf998b51_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.2-h32235b2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-2.39.0-hdb79228_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-storage-2.39.0-hdbdcf42_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.73.1-h3288cfb_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-4_h47877c9_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm21-21.1.7-hf7376ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.30-pthreads_h94d23a6_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-1.21.0-hb9b0907_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-headers-1.21.0-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libparquet-22.0.0-h7376487_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.53-h421ea60_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpq-18.1-h5c52fec_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-6.31.1-h49aed37_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libre2-11-2025.11.05-h7b12aa8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.20-h4ab18f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.1-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_16.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.22.0-h454ac66_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libutf8proc-2.11.2-hfe17d71_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-h5347b49_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.328.1-h5279c79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.43-h711ed8c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/llvmlite-0.46.0-py314h946fb2a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/lxml-6.0.2-py314hae3bed6_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.10.8-py314hdafbbf9_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.8-py314h1194b4b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/multidict-6.7.0-pyh62beb40_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.13.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.2.1-he2c55a7_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nomkl-1.0-h5ca1d4c_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/numba-0.63.1-py314h8169c2f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.14.1-py314heb044ea_101.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.5-py314h2b28147_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.10-he970967_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/orc-2.2.1-hd747db4_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-2.3.3-py314ha0b5721_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.0.0-py314h8ec4b1a_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/prometheus-cpp-1.3.0-ha5d0236_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.3.1-pyhe1237c8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py314h0f05182_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py-cpuinfo-9.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyarrow-22.0.0-py314hdafbbf9_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyarrow-core-22.0.0-py314h52d6ec5_0_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.12.5-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.41.5-py314h2e6c369_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyerfa-2.0.1.5-py310h32771cd_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.3-py314hf36963e_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pytables-3.10.2-py314h5611b9a_10.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-asyncio-1.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.2-h32b2ec7_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.2-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.20-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytokens-0.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyvo-1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hfb55c3c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.3-h5c1c036_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/re2-2025.11.05-h5301d42_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.17.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.14.8-h813ae00_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/s2n-1.6.2-he8a4886_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/s3fs-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.3-py314he7377e1_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.4.1-py314hdafbbf9_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-0.50.0-pyhfdc7a7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stingray-2.2.10-pyhc455866_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.3-py314h5bd0f2a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.20.0-pyhefaf540_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-0.20.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-standard-0.20.0-h4daf872_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.0-py314h5bd0f2a_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/uvloop-0.22.1-py314h5bd0f2a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/watchfiles-1.1.1-py314ha5689aa_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-hd6090a7_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/websockets-15.0.1-py314h31f8a6b_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wrapt-1.17.3-py314h5bd0f2a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.22.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h387f397_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.1-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.2-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + osx-64: + - conda: https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-2.25.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiofiles-25.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohttp-3.13.2-pyh4ca1811_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aioitertools-0.12.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/astropy-base-7.2.0-py314hd1ec8a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-iers-data-0.2025.12.8.0.38.44-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astroquery-0.4.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-auth-0.9.3-hdff831d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-cal-0.9.13-hea39f9f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-common-0.12.6-h8616949_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-compression-0.3.1-h901532c_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-event-stream-0.5.7-ha05da6a_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-http-0.10.7-h924c446_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-io-0.23.3-hf559bb5_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-mqtt-0.13.3-ha72ff4e_11.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-s3-0.11.3-he30762a_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-sdkutils-0.2.4-h901532c_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-checksums-0.2.7-h901532c_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-crt-cpp-0.35.2-h7484968_6.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/aws-sdk-cpp-1.11.606-hffd60a0_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-core-cpp-1.16.1-he2a98a9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-identity-cpp-1.13.2-h0e8e1c8_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-storage-blobs-cpp-12.15.0-h388f2e7_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-storage-common-cpp-12.11.0-h56a711b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/azure-storage-files-datalake-cpp-12.13.0-h1984e67_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.asyncio.runner-1.2.0-pyh5ded981_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/black-25.12.0-pyh866005b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/blosc-1.21.6-hd145fbb_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/botocore-1.40.70-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/bottleneck-1.6.0-np2py314hfeef9c2_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.45-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-1.2.0-hf139dec_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-bin-1.2.0-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-python-1.2.0-py314h3262eb8_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/c-ares-1.34.6-hb5e19a0_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/c-blosc2-2.22.0-hedb7e5f_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/contourpy-1.3.3-py314h00ed6fe_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.2-py314hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2025.12.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/debugpy-1.8.18-py314h3658963_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.124.4-hd122799_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.124.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.61.1-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/freetype-2.14.1-h694c41f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/frozenlist-1.7.0-pyhf298e5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-64/gflags-2.2.2-hac325c4_1005.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/glog-0.7.1-h2790a97_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/h5py-3.15.1-nompi_py314hf613b1f_101.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.6-nompi_hc1508a4_104.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/httptools-0.7.1-py314h6482030_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyh5552912_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jplephem-2.23-pyha4b2019_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.7.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh534df25_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/kiwisolver-1.4.9-py314hf3ac25a_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/krb5-1.21.3-h37d8d59_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/lcms2-2.17-h72f5680_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/lerc-4.0.0-hcca01a6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libabseil-20250512.1-cxx17_hfc00f1c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libaec-1.1.4-ha6bc127_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-22.0.0-hd1700fa_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-acero-22.0.0-h2db2d7d_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-compute-22.0.0-h7751554_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-dataset-22.0.0-h2db2d7d_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-substrait-22.0.0-h4653b8a_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libblas-3.11.0-4_he492b99_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlicommon-1.2.0-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlidec-1.2.0-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlienc-1.2.0-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcblas-3.11.0-4_h9b27e0a_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcrc32c-1.1.2-he49afe7_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcurl-8.17.0-h7dd4100_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.7-h3d58e20_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libdeflate-1.25-h517ebb2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libedit-3.1.20250104-pl5321ha958ccf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libev-4.33-h10d778d_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libevent-2.1.12-ha90c15b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.7.3-heffb93a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-h750e83c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libfreetype-2.14.1-h694c41f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libfreetype6-2.14.1-h6912278_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgcc-15.2.0-h08519bb_15.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran-15.2.0-h7e5c614_15.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-hd16e46c_15.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgoogle-cloud-2.39.0-hed66dea_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgoogle-cloud-storage-2.39.0-h8ac052b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libgrpc-1.73.1-h451496d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libjpeg-turbo-3.1.2-h8616949_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblapack-3.11.0-4_h859234e_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.1-hd471939_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-h6e16a3a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libnghttp2-1.67.0-h3338091_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libopenblas-0.3.30-openmp_h6006d49_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libopentelemetry-cpp-1.21.0-h7d3f41d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libopentelemetry-cpp-headers-1.21.0-h694c41f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libparquet-22.0.0-habb56ca_4_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libpng-1.6.53-h380d223_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libprotobuf-6.31.1-h03562ea_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libre2-11-2025.11.05-h554ac88_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsodium-1.0.20-hfdf4475_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.51.1-h6cc646a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libssh2-1.11.1-hed3591d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libthrift-0.22.0-h687e942_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libtiff-4.7.1-ha0a348c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libutf8proc-2.11.2-h7983711_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libuv-1.51.0-h58003a5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libwebp-base-1.6.0-hb807250_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxcb-1.17.0-hf1f96e2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.1-ha1d9b0f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.1-h7b7ecba_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxslt-1.1.43-h486b42e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-21.1.7-h472b3d1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvmlite-0.46.0-py314h85c3bf0_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-64/lxml-6.0.2-py314h787f955_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/lz4-c-1.10.0-h240833e_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/matplotlib-3.10.8-py314hee6578b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/matplotlib-base-3.10.8-py314hd47142c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/multidict-6.7.0-pyh62beb40_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.13.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/nlohmann_json-3.12.0-h53ec75d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/nodejs-25.2.1-h5523da6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/numba-0.63.0-py314h385e359_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/numexpr-2.14.1-py314h205861b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/numpy-2.3.5-py314hf08249b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openjpeg-2.5.4-h87e8dc5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.0-h230baf5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/orc-2.2.1-hd1b02dc_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pandas-2.3.3-py314hc4308db_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pillow-12.0.0-py314hedf0282_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/prometheus-cpp-1.3.0-h7802330_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.3.1-pyhe1237c8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.1.3-py314hd1e8ddb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pthread-stubs-0.4-h00291cd_1002.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py-cpuinfo-9.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-64/pyarrow-22.0.0-py314hee6578b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pyarrow-core-22.0.0-py314h35e0213_0_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.12.5-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pydantic-core-2.41.5-py314ha7b6dee_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pyerfa-2.0.1.5-py310hcbffc5d_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pytables-3.10.2-py314hb51f073_10.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-asyncio-1.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.2-hf88997e_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.2-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.20-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytokens-0.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyvo-1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pyzmq-27.1.0-py312hb7d603e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/qhull-2020.2-h3c5361c_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/re2-2025.11.05-h7df6414_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h7cca4af_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.17.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ruff-0.14.8-hd9f4cfa_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/s3fs-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/scipy-1.16.3-py314h9d854bd_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/snappy-1.2.2-h01f5ddf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-0.50.0-pyhfdc7a7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stingray-2.2.10-pyhc455866_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-hf689a15_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tornado-6.5.3-py314h6482030_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.20.0-pyhefaf540_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-0.20.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-standard-0.20.0-h4daf872_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/unicodedata2-17.0.0-py314h6482030_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/uvloop-0.22.1-py314h6482030_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/watchfiles-1.1.1-py314hc9c287a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/websockets-15.0.1-py314hcfd16f8_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/wrapt-1.17.3-py314h03d016b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/xorg-libxau-1.0.12-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/xorg-libxdmcp-1.1.5-h8616949_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.22.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zeromq-4.3.5-h6c33b1e_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-1.3.1-hd23fc13_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.2-h53ec75d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-2.25.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiofiles-25.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohttp-3.13.2-pyh4ca1811_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aioitertools-0.12.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/astropy-base-7.2.0-py314hdcf55e8_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-iers-data-0.2025.12.8.0.38.44-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astroquery-0.4.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-auth-0.9.3-h1ddaa69_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-cal-0.9.13-h6ee9776_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-common-0.12.6-hc919400_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-compression-0.3.1-h16f91aa_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-event-stream-0.5.7-h9ae9c55_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-http-0.10.7-h5928ca5_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-io-0.23.3-hbe03c90_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-mqtt-0.13.3-haf5c5c8_11.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-s3-0.11.3-h8da9771_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-sdkutils-0.2.4-h16f91aa_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-checksums-0.2.7-h16f91aa_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-crt-cpp-0.35.4-h74951b9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-sdk-cpp-1.11.606-h4e1b0f7_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-core-cpp-1.16.1-h88fedcc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-identity-cpp-1.13.2-h853621b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-storage-blobs-cpp-12.15.0-h10d327b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-storage-common-cpp-12.11.0-h7e4aa5d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-storage-files-datalake-cpp-12.13.0-hb288d13_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.asyncio.runner-1.2.0-pyh5ded981_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/black-25.12.0-pyh866005b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/blosc-1.21.6-h7dd00d9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/botocore-1.40.70-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bottleneck-1.6.0-np2py314hfa18b03_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.45-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-1.2.0-h7d5ae5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-bin-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-blosc2-2.22.0-hb83781b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/contourpy-1.3.3-py314h784bc60_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.2-py314hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2025.12.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.18-py314hf820bb6_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.124.4-hd122799_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.124.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.61.1-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.1-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/frozenlist-1.7.0-pyhf298e5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gflags-2.2.2-hf9b8971_1005.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glog-0.7.1-heb240a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/h5py-3.15.1-nompi_py314h1c8d760_101.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdf5-1.14.6-nompi_hd3baa01_104.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/httptools-0.7.1-py314h0612a62_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyh5552912_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jplephem-2.23-pyha4b2019_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.7.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh534df25_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/kiwisolver-1.4.9-py314h42813c9_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.21.3-h237132a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.17-h7eeda09_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.0.0-hd64df32_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20250512.1-cxx17_hd41c47c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libaec-1.1.4-h51d1e36_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-22.0.0-he6e817a_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-acero-22.0.0-hc317990_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-compute-22.0.0-h75845d1_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-dataset-22.0.0-hc317990_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-substrait-22.0.0-h144af7f_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-4_h51639a9_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-4_hb0561ab_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcrc32c-1.1.2-hbdafb3b_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.17.0-hdece5d2_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.7-hf598326_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libevent-2.1.12-h2757513_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.1-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.1-h6da58f4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_16.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_16.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_16.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgoogle-cloud-2.39.0-head0a95_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgoogle-cloud-storage-2.39.0-hfa3a374_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgrpc-1.73.1-h3063b79_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.2-hc919400_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-4_hd9741b5_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.67.0-hc438710_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.30-openmp_ha158390_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopentelemetry-cpp-1.21.0-he15edb5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopentelemetry-cpp-headers-1.21.0-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libparquet-22.0.0-h0ac143b_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.53-hfab5511_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-6.31.1-h658db43_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libre2-11-2025.11.05-h91c62da_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.20-h99b78c6_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.1-h9a5124b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libthrift-0.22.0-h14a376c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h4030677_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libutf8proc-2.11.2-hd2415e0_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxcb-1.17.0-hdb1d25a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.1-h0ff4647_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.1-h9329255_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxslt-1.1.43-hb2570ba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.7-h4a912ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvmlite-0.46.0-py314ha398f32_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lxml-6.0.2-py314he05ef12_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-3.10.8-py314he55896b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-base-3.10.8-py314hd63e3f0_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/multidict-6.7.0-pyh62beb40_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.13.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nlohmann_json-3.12.0-h248ca61_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-25.2.1-h5230ea7_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numba-0.63.1-py314h945de62_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.14.1-py314hc5bb990_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.3.5-py314h5b5928d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hbfb3c88_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/orc-2.2.1-h4fd0076_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandas-2.3.3-py314ha3d490a_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pillow-12.0.0-py314h57fbdfe_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/prometheus-cpp-1.3.0-h0967b3e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.3.1-pyhe1237c8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.1.3-py314h9d33bd4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pthread-stubs-0.4-hd74edd7_1002.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py-cpuinfo-9.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyarrow-22.0.0-py314he55896b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyarrow-core-22.0.0-py314hf20a12a_0_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.12.5-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pydantic-core-2.41.5-py314haad56a0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyerfa-2.0.1.5-py310hbb12772_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pytables-3.10.2-py314h8eb144a_10.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-asyncio-1.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.2-h40d2674_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.2-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.20-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytokens-0.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyvo-1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312hd65ceae_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/qhull-2020.2-h420ef59_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/re2-2025.11.05-h64b956e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.17.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.14.8-h382de68_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/s3fs-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.16.3-py314h624bdf2_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-hada39a4_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-0.50.0-pyhfdc7a7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stingray-2.2.10-pyhc455866_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.3-py314h0612a62_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.20.0-pyhefaf540_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-0.20.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-standard-0.20.0-h4daf872_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/unicodedata2-17.0.0-py314h0612a62_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h31011fe_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uvloop-0.22.1-py314h0612a62_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/watchfiles-1.1.1-py314h8d4a433_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/websockets-15.0.1-py314hf17b0b1_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/wrapt-1.17.3-py314hb84d1df_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxau-1.0.12-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxdmcp-1.1.5-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.22.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h888dc83_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.1-h8359307_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.2-h248ca61_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + win-64: + - conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-2_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-2.25.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiofiles-25.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiohttp-3.13.2-pyh4ca1811_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aioitertools-0.12.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/astropy-base-7.2.0-py314h2dcd201_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astropy-iers-data-0.2025.12.8.0.38.44-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/astroquery-0.4.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-auth-0.9.3-h2970c50_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-cal-0.9.13-h46f3b43_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-common-0.12.6-hfd05255_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-compression-0.3.1-hcb3a2da_9.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-event-stream-0.5.7-ha388e84_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-http-0.10.7-hc678f4a_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-io-0.23.3-h0d5b9f9_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-mqtt-0.13.3-hfa314fa_11.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-s3-0.11.3-ha659bf3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-sdkutils-0.2.4-hcb3a2da_4.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-checksums-0.2.7-hcb3a2da_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-crt-cpp-0.35.4-hca034e6_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/aws-sdk-cpp-1.11.606-hac16450_10.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.asyncio.runner-1.2.0-pyh5ded981_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/black-25.12.0-pyh866005b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/blosc-1.21.6-hfd34d9b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/botocore-1.40.70-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/bottleneck-1.6.0-np2py314hea88fa1_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.45-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-1.2.0-h2d644bc_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-bin-1.2.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/c-ares-1.34.6-hfd05255_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/c-blosc2-2.22.0-h2af8807_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/cairo-1.18.4-h5782bbf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyha7b4d00_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/contourpy-1.3.3-py314h909e829_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.2-py314hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2025.12.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.18-py314hb98de8c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/double-conversion-3.3.1-he0c23c2_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.124.4-hd122799_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.124.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/fontconfig-2.15.0-h765892d_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.61.1-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/freetype-2.14.1-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/frozenlist-1.7.0-pyhf298e5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/graphite2-1.3.14-hac47afa_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/h5py-3.15.1-nompi_py314hc249e69_101.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/harfbuzz-12.2.0-h5f2951f_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.6-nompi_h89f0904_104.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/httptools-0.7.1-py314h5a2d7ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/icu-75.1-he0c23c2_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyhe2676ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.4.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.0.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jplephem-2.23-pyha4b2019_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.7.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh7428d3b_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/kiwisolver-1.4.9-py314hf309875_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.21.3-hdf4eb48_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lcms2-2.17-hbcf6048_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lerc-4.0.0-h6470a55_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libabseil-20250512.1-cxx17_habfad5f_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libaec-1.1.4-h20038f6_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-22.0.0-h89d7da9_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-acero-22.0.0-h7d8d6a5_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-compute-22.0.0-h2db994a_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-dataset-22.0.0-h7d8d6a5_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-substrait-22.0.0-hf865cc0_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-4_hf2e6a31_mkl.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libbrotlicommon-1.2.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libbrotlidec-1.2.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libbrotlienc-1.2.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-4_h2a3cdd5_mkl.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libclang13-21.1.7-default_ha2db4b5_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libcrc32c-1.1.2-h0e60522_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/libcurl-8.17.0-h43ecb02_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libdeflate-1.25-h51727cc_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libevent-2.1.12-h3671451_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.3-hac47afa_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h52bdfb6_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libfreetype-2.14.1-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libfreetype6-2.14.1-hdbac1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_16.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libglib-2.86.2-hd9c3897_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_16.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgoogle-cloud-2.39.0-h19ee442_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgoogle-cloud-storage-2.39.0-he04ea4c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgrpc-1.73.1-h317e13b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.12.1-default_h4379cf1_1003.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libintl-0.22.5-h5728263_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libjpeg-turbo-3.1.2-hfd05255_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-4_hf9ab0e9_mkl.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-h2466b09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libparquet-22.0.0-h7051d1f_6_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libpng-1.6.53-h7351971_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libprotobuf-6.31.1-hdcda5b4_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libre2-11-2025.11.05-h0eb2380_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.20-hc70643c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.51.1-hf5d6505_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libssh2-1.11.1-h9aa295b_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libthrift-0.22.0-h23985f6_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libtiff-4.7.1-h8f73337_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libutf8proc-2.11.2-hb980946_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libvulkan-loader-1.4.328.1-h477610d_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libwebp-base-1.6.0-h4d5522a_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxcb-1.17.0-h0e4246c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.1-h06f855e_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.1-ha29bfb0_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-21.1.7-h4fa8253_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/llvmlite-0.46.0-py314hb492ee6_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.0.2-py314hcdb55d9_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/lz4-c-1.10.0-h2466b09_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/matplotlib-3.10.8-py314h86ab7b2_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/matplotlib-base-3.10.8-py314hfa45d96_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2025.3.0-hac47afa_454.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/multidict-6.7.0-pyh62beb40_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.13.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-25.2.1-he453025_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/numba-0.63.1-py314h36f8cf2_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/numexpr-2.14.1-mkl_py314h220b711_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.3.5-py314h06c3c77_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openjpeg-2.5.4-h24db6dd_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.0-h725018a_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/orc-2.2.1-h7414dfc_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-2.3.3-py314hd8fd7ce_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pcre2-10.46-h3402e2f_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pillow-12.0.0-py314h61b30b5_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pixman-0.46.4-h5112557_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.3.1-pyhe1237c8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.1.3-py314hc5dbbe4_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pthread-stubs-0.4-h0e40799_1002.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py-cpuinfo-9.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/pyarrow-22.0.0-py314h86ab7b2_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyarrow-core-22.0.0-py314hb5be3fa_0_cpu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.12.5-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.41.5-py314h9f07db2_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyerfa-2.0.1.5-py310h1f63838_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyside6-6.9.3-py314h2c9462b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pytables-3.10.2-py314h2bd12ea_10.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-asyncio-1.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.2-h4b44e0e_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.2-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.20-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytokens-0.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyvo-1.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314h8f8f202_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-ctypes-0.2.3-py314h86ab7b2_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312hbb5da91_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/qhull-2020.2-hc790b64_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/qt6-main-6.9.3-ha0de62e_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/re2-2025.11.05-ha104f34_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.17.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ruff-0.14.8-h15e3a1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/s3fs-2025.12.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/scipy-1.16.3-py314h5798d8a_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/snappy-1.2.2-h7fa0ca8_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-0.50.0-pyhfdc7a7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/stingray-2.2.10-pyhc455866_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2022.3.0-hd094cb3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.3-py314h5a2d7ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.20.0-pyhefaf540_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-0.20.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-standard-0.20.0-h4daf872_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/unicodedata2-17.0.0-py314h5a2d7ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh5737063_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h5737063_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h2b53caa_33.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_33.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_33.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vs2015_runtime-14.44.35208-h38c0c73_33.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/watchfiles-1.1.1-py314h170c82c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/websockets-15.0.1-py314h4667ab5_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-1.17.3-py314h5a2d7ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/xorg-libxau-1.0.12-hba3369d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/xorg-libxdmcp-1.1.5-hba3369d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.22.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h5bddc39_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.2-h5112557_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 + md5: d7c89558ba9fa0495403155b64376d81 + license: None + size: 2562 + timestamp: 1578324546067 +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + build_number: 16 + sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22 + md5: 73aaf86a425cc6e73fcf236a5a46396d + depends: + - _libgcc_mutex 0.1 conda_forge + - libgomp >=7.5.0 + constrains: + - openmp_impl 9999 + license: BSD-3-Clause + license_family: BSD + size: 23621 + timestamp: 1650670423406 +- conda: https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda + build_number: 7 + sha256: 30006902a9274de8abdad5a9f02ef7c8bb3d69a503486af0c1faee30b023e5b7 + md5: eaac87c21aff3ed21ad9656697bb8326 + depends: + - llvm-openmp >=9.0.1 + license: BSD-3-Clause + license_family: BSD + size: 8328 + timestamp: 1764092562779 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda + build_number: 7 + sha256: 7acaa2e0782cad032bdaf756b536874346ac1375745fb250e9bdd6a48a7ab3cd + md5: a44032f282e7d2acdeb1c240308052dd + depends: + - llvm-openmp >=9.0.1 + license: BSD-3-Clause + license_family: BSD + size: 8325 + timestamp: 1764092507920 +- conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-2_gnu.conda + build_number: 8 + sha256: 1a62cd1f215fe0902e7004089693a78347a30ad687781dfda2289cab000e652d + md5: 37e16618af5c4851a3f3d66dd0e11141 + depends: + - libgomp >=7.5.0 + - libwinpthread >=12.0.0.r2.ggc561118da + constrains: + - openmp_impl 9999 + - msys2-conda-epoch <0.0a0 + license: BSD-3-Clause + license_family: BSD + size: 49468 + timestamp: 1718213032772 +- conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + sha256: a3967b937b9abf0f2a99f3173fa4630293979bd1644709d89580e7c62a544661 + md5: aaa2a381ccc56eac91d63b6c1240312f + depends: + - cpython + - python-gil + license: MIT + license_family: MIT + size: 8191 + timestamp: 1744137672556 +- conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-2.25.2-pyhcf101f3_0.conda + sha256: 922fb146148449c6bc374a37fa9edb89b3af1c385392391339206d3ab3d571a3 + md5: 6e90a60dbb939d24a6295e19377cf0e6 + depends: + - python >=3.10 + - aiohttp >=3.9.2,<4.0.0 + - aioitertools >=0.5.1,<1.0.0 + - botocore >=1.40.46,<1.40.71 + - python-dateutil >=2.1,<3.0.0 + - jmespath >=0.7.1,<2.0.0 + - multidict >=6.0.0,<7.0.0 + - wrapt >=1.10.10,<2.0.0 + - python + license: Apache-2.0 + license_family: APACHE + size: 80900 + timestamp: 1762893423043 +- conda: https://conda.anaconda.org/conda-forge/noarch/aiofiles-25.1.0-pyhd8ed1ab_0.conda + sha256: 1d0dcbeaab76d87aa9f9fb07ec9ba07d30f0386019328aaa11a578266f324aaf + md5: 9b7781a926808f424434003f728ea7ab + depends: + - python >=3.10 + license: Apache-2.0 + license_family: Apache + size: 19145 + timestamp: 1760127109813 +- conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.1-pyhd8ed1ab_0.conda + sha256: 7842ddc678e77868ba7b92a726b437575b23aaec293bca0d40826f1026d90e27 + md5: 18fd895e0e775622906cdabfc3cf0fb4 + depends: + - python >=3.9 + license: PSF-2.0 + license_family: PSF + size: 19750 + timestamp: 1741775303303 +- conda: https://conda.anaconda.org/conda-forge/noarch/aiohttp-3.13.2-pyh4ca1811_0.conda + sha256: 8af88a6daa5e30f347da7faee1ee17d920a1090c0e921431bf43adff02429b50 + md5: 9b7efc1b9351892fc1b0af3fb7e44280 + depends: + - aiohappyeyeballs >=2.5.0 + - aiosignal >=1.4.0 + - async-timeout >=4.0,<6.0 + - attrs >=17.3.0 + - frozenlist >=1.1.1 + - multidict >=4.5,<7.0 + - propcache >=0.2.0 + - python >=3.10 + - yarl >=1.17.0,<2.0 + track_features: + - aiohttp_no_compile + license: MIT AND Apache-2.0 + license_family: Apache + size: 474272 + timestamp: 1761726660058 +- conda: https://conda.anaconda.org/conda-forge/noarch/aioitertools-0.12.0-pyhd8ed1ab_1.conda + sha256: 7d56e547a819a03c058dd8793ca9df6ff9825812da52c214192edb61a7de1c95 + md5: 3eb47adbffac44483f59e580f8600a1e + depends: + - python >=3.9 + - typing_extensions >=4.0 + license: MIT + license_family: MIT + size: 25063 + timestamp: 1735329177103 +- conda: https://conda.anaconda.org/conda-forge/noarch/aiosignal-1.4.0-pyhd8ed1ab_0.conda + sha256: 8dc149a6828d19bf104ea96382a9d04dae185d4a03cc6beb1bc7b84c428e3ca2 + md5: 421a865222cd0c9d83ff08bc78bf3a61 + depends: + - frozenlist >=1.1.0 + - python >=3.9 + - typing_extensions >=4.2 + license: Apache-2.0 + license_family: APACHE + size: 13688 + timestamp: 1751626573984 +- conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.14-hb9d3cd8_0.conda + sha256: b9214bc17e89bf2b691fad50d952b7f029f6148f4ac4fe7c60c08f093efdf745 + md5: 76df83c2a9035c54df5d04ff81bcc02d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-or-later + license_family: GPL + size: 566531 + timestamp: 1744668655747 +- conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda + sha256: cc9fbc50d4ee7ee04e49ee119243e6f1765750f0fd0b4d270d5ef35461b643b1 + md5: 52be5139047efadaeeb19c6a5103f92a + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 14222 + timestamp: 1762868213144 +- conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + sha256: e0ea1ba78fbb64f17062601edda82097fcf815012cf52bb704150a2668110d48 + md5: 2934f256a8acfe48f6ebb4fce6cde29c + depends: + - python >=3.9 + - typing-extensions >=4.0.0 + license: MIT + license_family: MIT + size: 18074 + timestamp: 1733247158254 +- conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda + sha256: 830fc81970cd9d19869909b9b16d241f4d557e4f201a1030aa6ed87c6aa8b930 + md5: 9958d4a1ee7e9c768fe8f4fb51bd07ea + depends: + - exceptiongroup >=1.0.2 + - idna >=2.8 + - python >=3.10 + - typing_extensions >=4.5 + - python + constrains: + - trio >=0.32.0 + - uvloop >=0.21 + license: MIT + license_family: MIT + size: 144702 + timestamp: 1764375386926 +- conda: https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.4-pyhd8ed1ab_1.conda + sha256: 8f032b140ea4159806e4969a68b4a3c0a7cab1ad936eb958a2b5ffe5335e19bf + md5: 54898d0f524c9dee622d44bbb081a8ab + depends: + - python >=3.9 + license: BSD-2-Clause + license_family: BSD + size: 10076 + timestamp: 1733332433806 +- conda: https://conda.anaconda.org/conda-forge/noarch/astropy-7.2.0-pyhd8ed1ab_0.conda + sha256: 41ca79f6c5c6e5dd2ebbc96dae8420c914161b261d437d3de7dce0071de55318 + md5: 8f873669e83f0d7fcbaf0b221f6963d5 + depends: + - aiohttp + - astropy-base >=7.2.0,<7.2.1.0a0 + - beautifulsoup4 >=4.9.3 + - bleach >=3.2.1 + - bottleneck >=1.3.3 + - certifi >=2022.6.15.1 + - dask-core >=2024.8.0 + - fsspec >=2023.4.0 + - h5py >=3.9.0 + - html5lib >=1.1 + - ipydatagrid >=1.1.13 + - ipykernel >=6.16.0 + - ipython >=8.0.0 + - ipywidgets >=7.7.3 + - jplephem >=2.17.0 + - matplotlib-base >=3.8.0 + - mpmath >=1.2.1 + - narwhals >=1.42.0 + - pandas >=2.0 + - pyarrow >=14.0.2 + - python >=3.11 + - pytz >=2016.10 + - s3fs >=2023.4.0 + - scipy >=1.9.2 + - sortedcontainers >=2.1.0 + - uncompresspy >=0.4.0 + license: BSD-3-Clause + license_family: BSD + size: 9060 + timestamp: 1764120734938 +- conda: https://conda.anaconda.org/conda-forge/linux-64/astropy-base-7.2.0-py314hc02f841_0.conda + sha256: c430dec69f18dd013fb8d7a930cb1f4b3781ba0d150bb18dc3a221eaeca57ca8 + md5: 8c7d00a0b82d5399c242be5c80a5a0d4 + depends: + - __glibc >=2.17,<3.0.a0 + - astropy-iers-data >=0.2025.10.27.0.39.10 + - libgcc >=14 + - numpy >=1.23,<3 + - numpy >=1.24 + - packaging >=22.0.0 + - pyerfa >=2.0.1.1 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - pyyaml >=6.0.0 + constrains: + - astropy >=7.0.0 + license: BSD-3-Clause + license_family: BSD + size: 9880758 + timestamp: 1764120697264 +- conda: https://conda.anaconda.org/conda-forge/osx-64/astropy-base-7.2.0-py314hd1ec8a2_0.conda + sha256: b8baffed419ab2cd755d1ec84ba770fae31bb48d978e409847c57d4b84cd122d + md5: f227079dec8a611007e0ab0e6776600b + depends: + - __osx >=10.13 + - astropy-iers-data >=0.2025.10.27.0.39.10 + - numpy >=1.23,<3 + - numpy >=1.24 + - packaging >=22.0.0 + - pyerfa >=2.0.1.1 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - pyyaml >=6.0.0 + constrains: + - astropy >=7.0.0 + license: BSD-3-Clause + license_family: BSD + size: 9632932 + timestamp: 1764121177780 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/astropy-base-7.2.0-py314hdcf55e8_0.conda + sha256: bbc0210239ef80bd80e5981b192f03c5a237a9a066945025585234648db19d11 + md5: 59d305b4aac5f4b79d5c8a5c9383f6fe + depends: + - __osx >=11.0 + - astropy-iers-data >=0.2025.10.27.0.39.10 + - numpy >=1.23,<3 + - numpy >=1.24 + - packaging >=22.0.0 + - pyerfa >=2.0.1.1 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + - pyyaml >=6.0.0 + constrains: + - astropy >=7.0.0 + license: BSD-3-Clause + license_family: BSD + size: 9696751 + timestamp: 1764121149314 +- conda: https://conda.anaconda.org/conda-forge/win-64/astropy-base-7.2.0-py314h2dcd201_0.conda + sha256: 9b5726688adef7b2fe970a33ccb3d60ae7826ca3cb7ebcb04c051f7249ba5757 + md5: 535a85525e40a1cc8e5d2fe7f7b85c53 + depends: + - astropy-iers-data >=0.2025.10.27.0.39.10 + - numpy >=1.23,<3 + - numpy >=1.24 + - packaging >=22.0.0 + - pyerfa >=2.0.1.1 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - pyyaml >=6.0.0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - astropy >=7.0.0 + license: BSD-3-Clause + license_family: BSD + size: 9647766 + timestamp: 1764120824948 +- conda: https://conda.anaconda.org/conda-forge/noarch/astropy-iers-data-0.2025.12.8.0.38.44-pyhd8ed1ab_0.conda + sha256: 621bc2dcbb165587067c68d4ff87ce6ddbb92cbe86161f35a44c81f20f4d08aa + md5: 62be984ecacca909a0f8c4009347fdd8 + depends: + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + size: 1237257 + timestamp: 1765160444930 +- conda: https://conda.anaconda.org/conda-forge/noarch/astroquery-0.4.11-pyhd8ed1ab_0.conda + sha256: 47b686df04b1dc8bf2a6dd6c4ebfa7afa80eaf00974a00c2976804e31369692d + md5: f6d7a43c5fb5cbeadaa0e2815f96fd36 + depends: + - astropy-base >=5.0 + - beautifulsoup4 >=4.8 + - html5lib >=0.999 + - keyring >=15.0 + - numpy >=1.20.0 + - python >=3.9 + - pyvo >=1.5 + - requests >=2.19 + license: BSD-3-Clause + license_family: BSD + size: 9855118 + timestamp: 1758344073086 +- conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + sha256: ee4da0f3fe9d59439798ee399ef3e482791e48784873d546e706d0935f9ff010 + md5: 9673a61a297b00016442e022d689faa6 + depends: + - python >=3.10 + constrains: + - astroid >=2,<5 + license: Apache-2.0 + license_family: Apache + size: 28797 + timestamp: 1763410017955 +- conda: https://conda.anaconda.org/conda-forge/noarch/async-timeout-5.0.1-pyhd8ed1ab_1.conda + sha256: 33d12250c870e06c9a313c6663cfbf1c50380b73dfbbb6006688c3134b29b45a + md5: 5d842988b11a8c3ab57fb70840c83d24 + depends: + - python >=3.9 + license: Apache-2.0 + license_family: Apache + size: 11763 + timestamp: 1733235428203 +- conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyhcf101f3_1.conda + sha256: c13d5e42d187b1d0255f591b7ce91201d4ed8a5370f0d986707a802c20c9d32f + md5: 537296d57ea995666c68c821b00e360b + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 64759 + timestamp: 1764875182184 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.9.3-hef928c7_0.conda + sha256: d9c5babed03371448bb0dc91a1573c80d278d1222a3b0accef079ed112e584f9 + md5: bdd464b33f6540ed70845b946c11a7b8 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-sdkutils >=0.2.4,<0.2.5.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 133443 + timestamp: 1764765235190 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-auth-0.9.3-hdff831d_0.conda + sha256: aaadae39675911059bf0caa072c9d0cab622278365f6c3ceb6a63a2e9e57df03 + md5: a04fb222805ce5697065036ae1676436 + depends: + - __osx >=10.13 + - aws-c-sdkutils >=0.2.4,<0.2.5.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + license: Apache-2.0 + license_family: APACHE + size: 119662 + timestamp: 1764765258455 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-auth-0.9.3-h1ddaa69_0.conda + sha256: 491576e1ef8640e0cc345705c2028aebb98e015d51471395fe595f60a3b33884 + md5: f0cc47ecd2058f2dd65fde1a5f6528ec + depends: + - __osx >=11.0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-sdkutils >=0.2.4,<0.2.5.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 114473 + timestamp: 1764765266429 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-auth-0.9.3-h2970c50_0.conda + sha256: 1ca3be8873335aff46da2d613c0e9e0c27b9878e402548e3cf31cd378a2f9342 + md5: 6f42aac88a3b880dd3a4e0fe61f418bc + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-sdkutils >=0.2.4,<0.2.5.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 125616 + timestamp: 1764765271198 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.13-h2c9d079_1.conda + sha256: f21d648349a318f4ae457ea5403d542ba6c0e0343b8642038523dd612b2a5064 + md5: 3c3d02681058c3d206b562b2e3bc337f + depends: + - __glibc >=2.17,<3.0.a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - libgcc >=14 + - openssl >=3.5.4,<4.0a0 + license: Apache-2.0 + license_family: Apache + size: 56230 + timestamp: 1764593147526 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-cal-0.9.13-hea39f9f_1.conda + sha256: c085b749572ca7c137dfbf8a2a4fd505657f8f7f8a7b374d5f41bf4eb2dd9214 + md5: cbf7be9e03e8b5e38ec60b6dbdf3a649 + depends: + - __osx >=10.13 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: Apache + size: 45262 + timestamp: 1764593359925 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-cal-0.9.13-h6ee9776_1.conda + sha256: 13c42cb54619df0a1c3e5e5b0f7c8e575460b689084024fd23abeb443aac391b + md5: 8baab664c541d6f059e83423d9fc5e30 + depends: + - __osx >=11.0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: Apache + size: 45233 + timestamp: 1764593742187 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-cal-0.9.13-h46f3b43_1.conda + sha256: 5f61082caea9fbdd6ba02702935e9dea9997459a7e6c06fd47f21b81aac882fb + md5: 7cc4953d504d4e8f3d6f4facb8549465 + depends: + - aws-c-common >=0.12.6,<0.12.7.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + size: 53613 + timestamp: 1764593604081 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.12.6-hb03c661_0.conda + sha256: 926a5b9de0a586e88669d81de717c8dd3218c51ce55658e8a16af7e7fe87c833 + md5: e36ad70a7e0b48f091ed6902f04c23b8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + size: 239605 + timestamp: 1763585595898 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-common-0.12.6-h8616949_0.conda + sha256: 66fb2710898bb3e25cb4af52ee88a0559dcde5e56e6bd09b31b98a346a89b2e3 + md5: c7f2d588a6d50d170b343f3ae0b72e62 + depends: + - __osx >=10.13 + license: Apache-2.0 + license_family: Apache + size: 230785 + timestamp: 1763585852531 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-common-0.12.6-hc919400_0.conda + sha256: cd3817c82470826167b1d8008485676862640cff65750c34062e6c20aeac419b + md5: b759f02a7fa946ea9fd9fb035422c848 + depends: + - __osx >=11.0 + license: Apache-2.0 + license_family: Apache + size: 224116 + timestamp: 1763585987935 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-common-0.12.6-hfd05255_0.conda + sha256: 0627691c34eb3d9fcd18c71346d9f16f83e8e58f9983e792138a2cccf387d18a + md5: b1465f33b05b9af02ad0887c01837831 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + size: 236441 + timestamp: 1763586152571 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.1-h8b1a151_9.conda + sha256: 96edccb326b8c653c8eb95a356e01d4aba159da1a97999577b7dd74461b040b4 + md5: f7ec84186dfe7a9e3a9f9e5a4d023e75 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 22272 + timestamp: 1764593718823 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-compression-0.3.1-h901532c_9.conda + sha256: b99ddb6654ca12b9f530ca4cbe4d2063335d4ac43f9d97092c4076ccaf9b89e7 + md5: abb79371a321d47da8f7ddca128533de + depends: + - __osx >=10.13 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 21423 + timestamp: 1764593738902 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-compression-0.3.1-h16f91aa_9.conda + sha256: 988f2251c5ddb91a93a3893e52eccb4fdd8b755af80bbc2bf739aabc25c5cfdf + md5: 8dc111381c4c73deb8b9a529b3abee4a + depends: + - __osx >=11.0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 21372 + timestamp: 1764593773975 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-compression-0.3.1-hcb3a2da_9.conda + sha256: ff1046d67709960859adfa5793391a2d233bb432ec7429069fcfab5b643827df + md5: 0888dbe9e883582d138ec6221f5482d6 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 23136 + timestamp: 1764593733263 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.5.7-h28f887f_1.conda + sha256: a5b151db1c8373b6ca2dacea65bc8bda02791a43685eebfa4ea987bb1a758ca9 + md5: 7b8e3f846353b75db163ad93248e5f9d + depends: + - libgcc >=14 + - libstdcxx >=14 + - __glibc >=2.17,<3.0.a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-checksums >=0.2.7,<0.2.8.0a0 + license: Apache-2.0 + license_family: APACHE + size: 58806 + timestamp: 1764675439822 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-event-stream-0.5.7-ha05da6a_1.conda + sha256: 56f7aebd59d5527830ef7cf6e91f63ee4c5cf510af56529276affe8e2dc9eb24 + md5: e0d71662f35b21fb993484238b4861d9 + depends: + - __osx >=10.13 + - libcxx >=19 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-checksums >=0.2.7,<0.2.8.0a0 + license: Apache-2.0 + license_family: APACHE + size: 52911 + timestamp: 1764675471218 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-event-stream-0.5.7-h9ae9c55_1.conda + sha256: c336b71a356d9b39fa6e9769d475dea6fd0cfe25ad81dcecac3102ef30f8b753 + md5: 53c59e7f68bbd3754de6c8dcd4c27f86 + depends: + - libcxx >=19 + - __osx >=11.0 + - aws-checksums >=0.2.7,<0.2.8.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 52221 + timestamp: 1764675514267 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-event-stream-0.5.7-ha388e84_1.conda + sha256: 5fbbfd835831dace087064d08c38eb279b7db3231fbd0db32fad86fe9273c10c + md5: 34e3b065b76c8a144c92e224cc3f5672 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-checksums >=0.2.7,<0.2.8.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 57054 + timestamp: 1764675494741 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.10.7-ha8fc4e3_5.conda + sha256: 5527224d6e0813e37426557d38cb04fed3753d6b1e544026cfbe2654f5e556be + md5: 3028f20dacafc00b22b88b324c8956cc + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-compression >=0.3.1,<0.3.2.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 224580 + timestamp: 1764675497060 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-http-0.10.7-h924c446_5.conda + sha256: 53ee041db79f6cbff62179b2f693e50e484d163b9a843a3dbbb80dbc36220c7e + md5: acff093ebb711857fb78fae3b656631c + depends: + - __osx >=10.13 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-compression >=0.3.1,<0.3.2.0a0 + license: Apache-2.0 + license_family: APACHE + size: 192149 + timestamp: 1764675489248 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-http-0.10.7-h5928ca5_5.conda + sha256: 29e180b61155279a2e64011b95957fbe38385113c60467b8d34fce47bc29c728 + md5: f12bd6066c693efba2e5886e2c70d7ba + depends: + - __osx >=11.0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-compression >=0.3.1,<0.3.2.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 171020 + timestamp: 1764675515369 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-http-0.10.7-hc678f4a_5.conda + sha256: 4f41b922ce01c983f98898208d49af5f3d6b0d8f3e8dcb44bd13d8183287b19a + md5: 3427460b0654d317e72a0ba959bb3a23 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-compression >=0.3.1,<0.3.2.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + license: Apache-2.0 + license_family: APACHE + size: 206709 + timestamp: 1764675527860 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.23.3-hdaf4b65_5.conda + sha256: 07d7f2a4493ada676084c3f4313da1fab586cf0a7302572c5d8dde6606113bf4 + md5: 132e8f8f40f0ffc0bbde12bb4e8dd1a1 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - s2n >=1.6.2,<1.6.3.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + license: Apache-2.0 + license_family: APACHE + size: 181361 + timestamp: 1765168239856 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-io-0.23.3-hf559bb5_5.conda + sha256: 734496fb5a33a4d13ff0a27c5bc4a0f4e7fe9ed15ec099722d5be82b456b9502 + md5: d9cc056da3a1ee0a2da750d10a5496f3 + depends: + - __osx >=10.15 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 182572 + timestamp: 1765168277462 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-io-0.23.3-hbe03c90_5.conda + sha256: bf1c7cf7997d28922283e6612e5ea6a9409fcfc2749cd4acfafd1bf6e0c57c08 + md5: c249aa1a151e319d7acd05a2e1f165d2 + depends: + - __osx >=11.0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + license: Apache-2.0 + license_family: APACHE + size: 176451 + timestamp: 1765168273313 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-io-0.23.3-h0d5b9f9_5.conda + sha256: 2d726ffd67fb387dbebf63c9b9965b476b9d670f683e71c3dca1feb6365ddc7c + md5: 400792109e426730ac9047fd6c9537ef + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 182053 + timestamp: 1765168273517 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.13.3-hc63082f_11.conda + sha256: fb102b0346a1f5c4f3bb680ec863c529b0333fa4119d78768c3e8a5d1cc2c812 + md5: 6a653aefdc5d83a4f959869d1759e6e3 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 216454 + timestamp: 1764681745427 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-mqtt-0.13.3-ha72ff4e_11.conda + sha256: c05215c85f90a0caba1202f4c852d6e3a2ad93b4a25f286435a8e855db4237ae + md5: 96f22c912f1cf3493d9113b9fd04c912 + depends: + - __osx >=10.13 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 188230 + timestamp: 1764681760102 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-mqtt-0.13.3-haf5c5c8_11.conda + sha256: 880996ae8c792eb15fcbca0a452d8b3508dba16ed7384bdb73fb7ed6c075c125 + md5: 3fcd02361ce1427ae5968fcd532a85b4 + depends: + - __osx >=11.0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + license: Apache-2.0 + license_family: APACHE + size: 150454 + timestamp: 1764681796127 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-mqtt-0.13.3-hfa314fa_11.conda + sha256: 9b241397ef436dcf67e8e6cde15ff9c0d03ea942ad11e27c77caecce0d51b5be + md5: 6c043365f1d3f89c0b68238c6f5b8cce + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + license: Apache-2.0 + license_family: APACHE + size: 206357 + timestamp: 1764681793150 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.11.3-h06ab39a_1.conda + sha256: 8de2292329dce2fd512413d83988584d616582442a07990f67670f9bc793a98b + md5: 3689a4290319587e3b54a4f9e68f70c8 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - openssl >=3.5.4,<4.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-auth >=0.9.3,<0.9.4.0a0 + - aws-checksums >=0.2.7,<0.2.8.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + license: Apache-2.0 + license_family: APACHE + size: 151382 + timestamp: 1765174166541 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-s3-0.11.3-he30762a_1.conda + sha256: 9c989a5f0b35ff5cee91b74bcba0d540ce5684450dc072ba0bb5299783cdf9cd + md5: 33c653401dc7b016b0011cb4d16de458 + depends: + - __osx >=10.13 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-auth >=0.9.3,<0.9.4.0a0 + - aws-checksums >=0.2.7,<0.2.8.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + license: Apache-2.0 + license_family: APACHE + size: 133827 + timestamp: 1765174162875 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-s3-0.11.3-h8da9771_1.conda + sha256: 31f432d1a0f7dacbe80b476c3236c22a71f4018e840ae6974e843d38d5763335 + md5: 06417cb45f131cf503d3483446cedbc3 + depends: + - __osx >=11.0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-checksums >=0.2.7,<0.2.8.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-auth >=0.9.3,<0.9.4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 129384 + timestamp: 1765174183548 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-s3-0.11.3-ha659bf3_1.conda + sha256: cda138c03683e85f29eafc680b043a40f304ac8759138dc141a42878eb17a90f + md5: dcfc08ccd8e332411c454e38110ea915 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-auth >=0.9.3,<0.9.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-checksums >=0.2.7,<0.2.8.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + license: Apache-2.0 + license_family: APACHE + size: 141805 + timestamp: 1765174184168 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.4-h8b1a151_4.conda + sha256: 9d62c5029f6f8219368a8665f0a549da572dc777f52413b7d75609cacdbc02cc + md5: c7e3e08b7b1b285524ab9d74162ce40b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 59383 + timestamp: 1764610113765 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-c-sdkutils-0.2.4-h901532c_4.conda + sha256: 468629dbf52fee6dcabda1fcb0c0f2f29941b9001dcc75a57ebfbe38d0bde713 + md5: b384fb05730f549a55cdb13c484861eb + depends: + - __osx >=10.13 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 55664 + timestamp: 1764610141049 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-sdkutils-0.2.4-h16f91aa_4.conda + sha256: 8a4ee03ea6e14d5a498657e5fe96875a133b4263b910c5b60176db1a1a0aaa27 + md5: 658a8236f3f1ebecaaa937b5ccd5d730 + depends: + - __osx >=11.0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 53430 + timestamp: 1764755714246 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-c-sdkutils-0.2.4-hcb3a2da_4.conda + sha256: c86c30edba7457e04d905c959328142603b62d7d1888aed893b2e21cca9c302c + md5: 3c97faee5be6fd0069410cf2bca71c85 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 56509 + timestamp: 1764610148907 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.7-h8b1a151_5.conda + sha256: a8693d2e06903a09e98fe724ed5ec32e7cd1b25c405d754f0ab7efb299046f19 + md5: 68da5b56dde41e172b7b24f071c4b392 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 76915 + timestamp: 1764593731486 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-checksums-0.2.7-h901532c_5.conda + sha256: 0f67c453829592277f90d520f7855e260cf0565a3dc59fe90c55293996b7fbe9 + md5: cccf553ce36da9ae739206b69c1a4d28 + depends: + - __osx >=10.13 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 75646 + timestamp: 1764593751665 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-checksums-0.2.7-h16f91aa_5.conda + sha256: c630ece8c0fe99cdf03774bb0b048cfd72daec0458dbc825be5de0106431087e + md5: ee9ebfd7b6fdf61dd632e4fea6287c47 + depends: + - __osx >=11.0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 74377 + timestamp: 1764593734393 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-checksums-0.2.7-hcb3a2da_5.conda + sha256: ca5e0719b7ca257462a4aa7d3b99fde756afaf579ee1472cac91c04c7bf3a725 + md5: 38f1501fc55f833a4567c83581a2d2ed + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 93142 + timestamp: 1764593765744 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.35.4-h8824e59_0.conda + sha256: 524fc8aa2645e5701308b865bf5c523257feabc6dfa7000cb8207ccfbb1452a1 + md5: 113b9d9913280474c0868b0e290c0326 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - aws-c-event-stream >=0.5.7,<0.5.8.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-sdkutils >=0.2.4,<0.2.5.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-auth >=0.9.3,<0.9.4.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-mqtt >=0.13.3,<0.13.4.0a0 + - aws-c-s3 >=0.11.3,<0.11.4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 408804 + timestamp: 1765200263609 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-crt-cpp-0.35.2-h7484968_6.conda + sha256: 199db73ed3d3c7503b4cdfaef2e18bd7b2e67c2464d64c37f250833897a65d84 + md5: 1c3916576404e725bb46c8393e90dab5 + depends: + - libcxx >=19 + - __osx >=10.13 + - aws-c-event-stream >=0.5.7,<0.5.8.0a0 + - aws-c-mqtt >=0.13.3,<0.13.4.0a0 + - aws-c-auth >=0.9.3,<0.9.4.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-s3 >=0.11.3,<0.11.4.0a0 + - aws-c-sdkutils >=0.2.4,<0.2.5.0a0 + license: Apache-2.0 + license_family: APACHE + size: 344127 + timestamp: 1765193382465 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-crt-cpp-0.35.4-h74951b9_0.conda + sha256: 465527f414c2399ab70503d9d4e891658e7698439ba7f22d723f2ca8c03bb3e8 + md5: 87351fb3a08425237b701c582773be1a + depends: + - __osx >=11.0 + - libcxx >=19 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + - aws-c-s3 >=0.11.3,<0.11.4.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-auth >=0.9.3,<0.9.4.0a0 + - aws-c-mqtt >=0.13.3,<0.13.4.0a0 + - aws-c-event-stream >=0.5.7,<0.5.8.0a0 + - aws-c-sdkutils >=0.2.4,<0.2.5.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + license: Apache-2.0 + license_family: APACHE + size: 266862 + timestamp: 1765200345049 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-crt-cpp-0.35.4-hca034e6_0.conda + sha256: 7b4aef9e1823207a5f91e8b5b95853bdfafcfea306cd62b99fd53c38aa5c3da0 + md5: ce1a20b5c406727e32222ac91e5848c4 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-c-mqtt >=0.13.3,<0.13.4.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-sdkutils >=0.2.4,<0.2.5.0a0 + - aws-c-event-stream >=0.5.7,<0.5.8.0a0 + - aws-c-http >=0.10.7,<0.10.8.0a0 + - aws-c-cal >=0.9.13,<0.9.14.0a0 + - aws-c-auth >=0.9.3,<0.9.4.0a0 + - aws-c-s3 >=0.11.3,<0.11.4.0a0 + - aws-c-io >=0.23.3,<0.23.4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 302247 + timestamp: 1765200336894 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.11.606-h20b40b1_10.conda + sha256: e0d81b7dd6d054d457a1c54d17733d430d96dc5ca9b2ca69a72eb41c3fc8c9bf + md5: 937d1d4c233adc6eeb2ac3d6e9a73e53 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libcurl >=8.17.0,<9.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-crt-cpp >=0.35.4,<0.35.5.0a0 + - libzlib >=1.3.1,<2.0a0 + - aws-c-event-stream >=0.5.7,<0.5.8.0a0 + license: Apache-2.0 + license_family: APACHE + size: 3472674 + timestamp: 1765257107074 +- conda: https://conda.anaconda.org/conda-forge/osx-64/aws-sdk-cpp-1.11.606-hffd60a0_9.conda + sha256: a58e471c09ffc63bafa4a2833a1d8f175693852763d840c446092898fa635b31 + md5: a76d9ef0a4417a6f418207b62ca3c796 + depends: + - libcxx >=19 + - __osx >=10.13 + - libcurl >=8.17.0,<9.0a0 + - aws-crt-cpp >=0.35.2,<0.35.3.0a0 + - libzlib >=1.3.1,<2.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-c-event-stream >=0.5.7,<0.5.8.0a0 + license: Apache-2.0 + license_family: APACHE + size: 3313038 + timestamp: 1765199752667 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-sdk-cpp-1.11.606-h4e1b0f7_10.conda + sha256: 87660413df6c49984a897544c8ace8461cd4ed69301ede5a793d00530985f702 + md5: a392fe9e9a3c6e0b65161533aca39be9 + depends: + - __osx >=11.0 + - libcxx >=19 + - aws-c-event-stream >=0.5.7,<0.5.8.0a0 + - libzlib >=1.3.1,<2.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - aws-crt-cpp >=0.35.4,<0.35.5.0a0 + - libcurl >=8.17.0,<9.0a0 + license: Apache-2.0 + license_family: APACHE + size: 3121951 + timestamp: 1765257130593 +- conda: https://conda.anaconda.org/conda-forge/win-64/aws-sdk-cpp-1.11.606-hac16450_10.conda + sha256: 8a12c4f6774ecb3641048b74133ff5e6c2b560469fe5ac1d7515631b84e63059 + md5: d9b942bede589d0ad1e8e360e970efd0 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - aws-crt-cpp >=0.35.4,<0.35.5.0a0 + - aws-c-common >=0.12.6,<0.12.7.0a0 + - libzlib >=1.3.1,<2.0a0 + - aws-c-event-stream >=0.5.7,<0.5.8.0a0 + license: Apache-2.0 + license_family: APACHE + size: 3438133 + timestamp: 1765257127502 +- conda: https://conda.anaconda.org/conda-forge/linux-64/azure-core-cpp-1.16.1-h3a458e0_0.conda + sha256: cba633571e7368953520a4f66dc74c3942cc12f735e0afa8d3d5fc3edf35c866 + md5: 1d4e0d37da5f3c22ecd44033f673feba + depends: + - __glibc >=2.17,<3.0.a0 + - libcurl >=8.14.1,<9.0a0 + - libgcc >=14 + - libstdcxx >=14 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + size: 348231 + timestamp: 1760926677260 +- conda: https://conda.anaconda.org/conda-forge/osx-64/azure-core-cpp-1.16.1-he2a98a9_0.conda + sha256: 923a0f9fab0c922e17f8bb27c8210d8978111390ff4e0cf6c1adff3c1a4d13bc + md5: 9f39c22aad61e76bfb73bb7d4114efac + depends: + - __osx >=10.13 + - libcurl >=8.14.1,<9.0a0 + - libcxx >=19 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + size: 297681 + timestamp: 1760927174036 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-core-cpp-1.16.1-h88fedcc_0.conda + sha256: d995413e4daf19ee3120f3ab9f0c9e330771787f33cbd4a33d8e5445f52022e3 + md5: fbe485a39b05090c0b5f8bb4febcd343 + depends: + - __osx >=11.0 + - libcurl >=8.14.1,<9.0a0 + - libcxx >=19 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + size: 289984 + timestamp: 1760927117177 +- conda: https://conda.anaconda.org/conda-forge/linux-64/azure-identity-cpp-1.13.2-h3a5f585_1.conda + sha256: fc1df5ea2595f4f16d0da9f7713ce5fed20cb1bfc7fb098eda7925c7d23f0c45 + md5: 4e921d9c85e6559c60215497978b3cdb + depends: + - __glibc >=2.17,<3.0.a0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - libgcc >=14 + - libstdcxx >=14 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + size: 249684 + timestamp: 1761066654684 +- conda: https://conda.anaconda.org/conda-forge/osx-64/azure-identity-cpp-1.13.2-h0e8e1c8_1.conda + sha256: 555e9c9262b996f8c688598760b4cddf4d16ae1cb2f0fd0a31cb76c2fdc7d628 + md5: 32eb613f88ae1530ca78481bdce41cdd + depends: + - __osx >=10.13 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - libcxx >=19 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + size: 174582 + timestamp: 1761067038720 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-identity-cpp-1.13.2-h853621b_1.conda + sha256: a4ed52062025035d9c1b3d8c70af39496fc5153cc741420139a770bc1312cfd6 + md5: fac63edc393d7035ab23fbccdeda34f4 + depends: + - __osx >=11.0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - libcxx >=19 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + size: 167268 + timestamp: 1761066827371 +- conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-blobs-cpp-12.15.0-h2a74896_1.conda + sha256: 58879f33cd62c30a4d6a19fd5ebc59bd0c4560f575bd02645d93d342b6f881d2 + md5: ffd553ff98ce5d74d3d89ac269153149 + depends: + - __glibc >=2.17,<3.0.a0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - azure-storage-common-cpp >=12.11.0,<12.11.1.0a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 576406 + timestamp: 1761080005291 +- conda: https://conda.anaconda.org/conda-forge/osx-64/azure-storage-blobs-cpp-12.15.0-h388f2e7_1.conda + sha256: 0a736f04c9778b87884422ebb6b549495430652204d964ff161efb719362baee + md5: 6b5f36e610295f4f859dd9cf680bbf7d + depends: + - __osx >=10.13 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - azure-storage-common-cpp >=12.11.0,<12.11.1.0a0 + - libcxx >=19 + license: MIT + license_family: MIT + size: 432811 + timestamp: 1761080273088 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-storage-blobs-cpp-12.15.0-h10d327b_1.conda + sha256: 274267b458ed51f4b71113fe615121fabd6f1d7b62ebfefdad946f8436a5db8e + md5: 443b74cf38c6b0f4b675c0517879ce69 + depends: + - __osx >=11.0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - azure-storage-common-cpp >=12.11.0,<12.11.1.0a0 + - libcxx >=19 + license: MIT + license_family: MIT + size: 425175 + timestamp: 1761080947110 +- conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-common-cpp-12.11.0-h3d7a050_1.conda + sha256: eb590e5c47ee8e6f8cc77e9c759da860ae243eed56aceb67ce51db75f45c9a50 + md5: 89985ba2a3742f34be6aafd6a8f3af8c + depends: + - __glibc >=2.17,<3.0.a0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + size: 149620 + timestamp: 1761066643066 +- conda: https://conda.anaconda.org/conda-forge/osx-64/azure-storage-common-cpp-12.11.0-h56a711b_1.conda + sha256: 322919e9842ddf5c9d0286667420a76774e1e42ae0520445d65726f8a2565823 + md5: 278ccb9a3616d4342731130287c3ba79 + depends: + - __osx >=10.13 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.6 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + size: 126230 + timestamp: 1761066840950 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-storage-common-cpp-12.11.0-h7e4aa5d_1.conda + sha256: 74803bd26983b599ea54ff1267a0c857ff37ccf6f849604a72eb63d8d30e4425 + md5: ac9113ea0b7ed5ecf452503f82bf2956 + depends: + - __osx >=11.0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.6 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + size: 121744 + timestamp: 1761066874537 +- conda: https://conda.anaconda.org/conda-forge/linux-64/azure-storage-files-datalake-cpp-12.13.0-hf38f1be_1.conda + sha256: 9f3d0f484e97cef5f019b7faef0c07fb7ee6c584e3a6e2954980f440978a365e + md5: f10b9303c7239fbce3580a60a92bcf97 + depends: + - __glibc >=2.17,<3.0.a0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - azure-storage-blobs-cpp >=12.15.0,<12.15.1.0a0 + - azure-storage-common-cpp >=12.11.0,<12.11.1.0a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 299198 + timestamp: 1761094654852 +- conda: https://conda.anaconda.org/conda-forge/osx-64/azure-storage-files-datalake-cpp-12.13.0-h1984e67_1.conda + sha256: 268175ab07f1917eff35e4c38a17a2b71c5f9b86e38e5c0b313da477600a82df + md5: ef5701f2da108d432e7872d58e8ac64e + depends: + - __osx >=10.13 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - azure-storage-blobs-cpp >=12.15.0,<12.15.1.0a0 + - azure-storage-common-cpp >=12.11.0,<12.11.1.0a0 + - libcxx >=19 + license: MIT + license_family: MIT + size: 203298 + timestamp: 1761095036240 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/azure-storage-files-datalake-cpp-12.13.0-hb288d13_1.conda + sha256: 2205e24d587453a04b075f86c59e3e72ad524c447fc5be61d7d1beb3cf2d7661 + md5: 595091ae43974e5059d6eabf0a6a7aa5 + depends: + - __osx >=11.0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - azure-storage-blobs-cpp >=12.15.0,<12.15.1.0a0 + - azure-storage-common-cpp >=12.11.0,<12.11.1.0a0 + - libcxx >=19 + license: MIT + license_family: MIT + size: 197152 + timestamp: 1761094913245 +- conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda + sha256: e1c3dc8b5aa6e12145423fed262b4754d70fec601339896b9ccf483178f690a6 + md5: 767d508c1a67e02ae8f50e44cacfadb2 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 7069 + timestamp: 1733218168786 +- conda: https://conda.anaconda.org/conda-forge/noarch/backports.asyncio.runner-1.2.0-pyh5ded981_2.conda + sha256: 2ade43752e8494f110a2cfb9e4d5b1ea29e3dcb037fba63395442d00371e8bf9 + md5: 0fd7e45c862b3305226a992f9f7b204a + depends: + - python >=3.11 + - python + constrains: + - python >=3.11 + license: PSF-2.0 + license_family: PSF + size: 10186 + timestamp: 1753456386827 +- conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda + sha256: 25abdb37e186f0d6ac3b774a63c81c5bc4bf554b5096b51343fa5e7c381193b1 + md5: bea46844deb274b2cc2a3a941745fa73 + depends: + - python >=3.10 + - backports + - python + license: MIT + license_family: MIT + size: 35739 + timestamp: 1767290467820 +- conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + noarch: generic + sha256: de90f762aecfa4b8680ae7299398bd4a1634870a01db8351e5e22affc6bbf313 + md5: 25e227ee028a17c2f2ef6eaf97e86734 + depends: + - python >=3.14 + license: BSD-3-Clause AND MIT AND EPL-2.0 + size: 7512 + timestamp: 1765057691766 +- conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda + sha256: bf1e71c3c0a5b024e44ff928225a0874fc3c3356ec1a0b6fe719108e6d1288f6 + md5: 5267bef8efea4127aacd1f4e1f149b6e + depends: + - python >=3.10 + - soupsieve >=1.2 + - typing-extensions + license: MIT + license_family: MIT + size: 90399 + timestamp: 1764520638652 +- conda: https://conda.anaconda.org/conda-forge/noarch/black-25.12.0-pyh866005b_0.conda + sha256: b7d00a8b682f650ac547d8d70c6cd65f303011313b3d3608d3704f20b1dad5b6 + md5: 7b658ed81f14384c83f4c4f01959fdc2 + depends: + - click >=8.0.0 + - mypy_extensions >=0.4.3 + - packaging >=22.0 + - pathspec >=0.9 + - platformdirs >=2 + - python >=3.11 + - pytokens >=0.3 + license: MIT + license_family: MIT + size: 169740 + timestamp: 1765222747417 +- conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda + sha256: e03ba1a2b93fe0383c57920a9dc6b4e0c2c7972a3f214d531ed3c21dc8f8c717 + md5: b1a27250d70881943cca0dd6b4ba0956 + depends: + - python >=3.10 + - webencodings + - python + constrains: + - tinycss >=1.1.0,<1.5 + license: Apache-2.0 AND MIT + size: 141952 + timestamp: 1763589981635 +- conda: https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda + sha256: e7af5d1183b06a206192ff440e08db1c4e8b2ca1f8376ee45fb2f3a85d4ee45d + md5: 2c2fae981fd2afd00812c92ac47d023d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.1,<1.3.0a0 + - zstd >=1.5.6,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 48427 + timestamp: 1733513201413 +- conda: https://conda.anaconda.org/conda-forge/osx-64/blosc-1.21.6-hd145fbb_1.conda + sha256: 876bdb1947644b4408f498ac91c61f1f4987d2c57eb47c0aba0d5ee822cd7da9 + md5: 717852102c68a082992ce13a53403f9d + depends: + - __osx >=10.13 + - libcxx >=18 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.1,<1.3.0a0 + - zstd >=1.5.6,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 46990 + timestamp: 1733513422834 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/blosc-1.21.6-h7dd00d9_1.conda + sha256: c3fe902114b9a3ac837e1a32408cc2142c147ec054c1038d37aec6814343f48a + md5: 925acfb50a750aa178f7a0aced77f351 + depends: + - __osx >=11.0 + - libcxx >=18 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.1,<1.3.0a0 + - zstd >=1.5.6,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 33602 + timestamp: 1733513285902 +- conda: https://conda.anaconda.org/conda-forge/win-64/blosc-1.21.6-hfd34d9b_1.conda + sha256: 9303a7a0e03cf118eab3691013f6d6cbd1cbac66efbc70d89b20f5d0145257c0 + md5: 357d7be4146d5fec543bfaa96a8a40de + depends: + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.1,<1.3.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + - zstd >=1.5.6,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 49840 + timestamp: 1733513605730 +- conda: https://conda.anaconda.org/conda-forge/noarch/botocore-1.40.70-pyhd8ed1ab_0.conda + sha256: 92e3b65d162600eec4c858a870e2b7593886d837c965ca51bf8bd1ed0e6f1e27 + md5: 280a8a31bface0a6b1cf49ea85004128 + depends: + - jmespath >=0.7.1,<2.0.0 + - python >=3.10 + - python-dateutil >=2.1,<3.0.0 + - urllib3 >=1.25.4,!=2.2.0,<3 + license: Apache-2.0 + license_family: Apache + size: 8150945 + timestamp: 1762813779810 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bottleneck-1.6.0-np2py314h56abb78_3.conda + sha256: 58cc4ecb796ec8093863d13264aca2746fa833461b30fd24b620d1acee0efd08 + md5: 48b137fb9317635b90c335348518d0a6 + depends: + - numpy + - python + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - numpy >=1.23,<3 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + size: 158983 + timestamp: 1762775788892 +- conda: https://conda.anaconda.org/conda-forge/osx-64/bottleneck-1.6.0-np2py314hfeef9c2_3.conda + sha256: b75b8e766102cac6fa01ae63f94f81841a041f8f2dba554be8095bd2e3f02d19 + md5: 5088e82d7776efb203ff2ef560d0dc52 + depends: + - numpy + - python + - __osx >=10.13 + - python_abi 3.14.* *_cp314 + - numpy >=1.23,<3 + license: BSD-2-Clause + license_family: BSD + size: 158336 + timestamp: 1762775903695 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bottleneck-1.6.0-np2py314hfa18b03_3.conda + sha256: 377dd23a6ebc813a6f3e9f54ef6152bd0dc447527aad6b37638822916b4fd484 + md5: f48af87bb77ab96c244e5105c4a9434b + depends: + - numpy + - python + - python 3.14.* *_cp314 + - __osx >=11.0 + - numpy >=1.23,<3 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + size: 140095 + timestamp: 1762775905428 +- conda: https://conda.anaconda.org/conda-forge/win-64/bottleneck-1.6.0-np2py314hea88fa1_3.conda + sha256: 480b5a3f635e6cbefceb29adb448b83e580fede49022894d0939bce0ebd1cfe7 + md5: 9f8dae835389010da7ad59bc673dd06b + depends: + - numpy + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - numpy >=1.23,<3 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + size: 141911 + timestamp: 1762775771443 +- conda: https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.45-pyhe01879c_0.conda + sha256: 2248c46491d6cc11692d7fbc5bb61c1b6177fd50654a296c13e31434e30b8994 + md5: 3cedf673ae6d0e272807bcb9929df40e + depends: + - ipywidgets >=7.6.0,<9 + - numpy >=1.10.4 + - pandas >=1.0.0,<3.0.0 + - python >=3.9 + - traitlets >=4.3.0,<6.0 + - traittypes >=0.0.6 + - python + license: Apache-2.0 + license_family: APACHE + size: 966021 + timestamp: 1756830785696 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda + sha256: e511644d691f05eb12ebe1e971fd6dc3ae55a4df5c253b4e1788b789bdf2dfa6 + md5: 8ccf913aaba749a5496c17629d859ed1 + depends: + - __glibc >=2.17,<3.0.a0 + - brotli-bin 1.2.0 hb03c661_1 + - libbrotlidec 1.2.0 hb03c661_1 + - libbrotlienc 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + size: 20103 + timestamp: 1764017231353 +- conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-1.2.0-hf139dec_1.conda + sha256: c838c71ded28ada251589f6462fc0f7c09132396799eea2701277566a1a863bf + md5: 149d8ee7d6541a02a6117d8814fd9413 + depends: + - __osx >=10.13 + - brotli-bin 1.2.0 h8616949_1 + - libbrotlidec 1.2.0 h8616949_1 + - libbrotlienc 1.2.0 h8616949_1 + license: MIT + license_family: MIT + size: 20194 + timestamp: 1764017661405 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-1.2.0-h7d5ae5b_1.conda + sha256: 422ac5c91f8ef07017c594d9135b7ae068157393d2a119b1908c7e350938579d + md5: 48ece20aa479be6ac9a284772827d00c + depends: + - __osx >=11.0 + - brotli-bin 1.2.0 hc919400_1 + - libbrotlidec 1.2.0 hc919400_1 + - libbrotlienc 1.2.0 hc919400_1 + license: MIT + license_family: MIT + size: 20237 + timestamp: 1764018058424 +- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-1.2.0-h2d644bc_1.conda + sha256: a4fffdf1c9b9d3d0d787e20c724cff3a284dfa3773f9ce609c93b1cfd0ce8933 + md5: bc58fdbced45bb096364de0fba1637af + depends: + - brotli-bin 1.2.0 hfd05255_1 + - libbrotlidec 1.2.0 hfd05255_1 + - libbrotlienc 1.2.0 hfd05255_1 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 20342 + timestamp: 1764017988883 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda + sha256: 64b137f30b83b1dd61db6c946ae7511657eead59fdf74e84ef0ded219605aa94 + md5: af39b9a8711d4a8d437b52c1d78eb6a1 + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlidec 1.2.0 hb03c661_1 + - libbrotlienc 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + size: 21021 + timestamp: 1764017221344 +- conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-bin-1.2.0-h8616949_1.conda + sha256: dcb5a2b29244b82af2545efad13dfdf8dddb86f88ce64ff415be9e7a10cc0383 + md5: 34803b20dfec7af32ba675c5ccdbedbf + depends: + - __osx >=10.13 + - libbrotlidec 1.2.0 h8616949_1 + - libbrotlienc 1.2.0 h8616949_1 + license: MIT + license_family: MIT + size: 18589 + timestamp: 1764017635544 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-bin-1.2.0-hc919400_1.conda + sha256: e2d142052a83ff2e8eab3fe68b9079cad80d109696dc063a3f92275802341640 + md5: 377d015c103ad7f3371be1777f8b584c + depends: + - __osx >=11.0 + - libbrotlidec 1.2.0 hc919400_1 + - libbrotlienc 1.2.0 hc919400_1 + license: MIT + license_family: MIT + size: 18628 + timestamp: 1764018033635 +- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-bin-1.2.0-hfd05255_1.conda + sha256: e76966232ef9612de33c2087e3c92c2dc42ea5f300050735a3c646f33bce0429 + md5: 6abd7089eb3f0c790235fe469558d190 + depends: + - libbrotlidec 1.2.0 hfd05255_1 + - libbrotlienc 1.2.0 hfd05255_1 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 22714 + timestamp: 1764017952449 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda + sha256: 3ad3500bff54a781c29f16ce1b288b36606e2189d0b0ef2f67036554f47f12b0 + md5: 8910d2c46f7e7b519129f486e0fe927a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - libbrotlicommon 1.2.0 hb03c661_1 + license: MIT + license_family: MIT + size: 367376 + timestamp: 1764017265553 +- conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-python-1.2.0-py314h3262eb8_1.conda + sha256: 2e34922abda4ac5726c547887161327b97c3bbd39f1204a5db162526b8b04300 + md5: 389d75a294091e0d7fa5a6fc683c4d50 + depends: + - __osx >=10.13 + - libcxx >=19 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - libbrotlicommon 1.2.0 h8616949_1 + license: MIT + license_family: MIT + size: 390153 + timestamp: 1764017784596 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda + sha256: 5c2e471fd262fcc3c5a9d5ea4dae5917b885e0e9b02763dbd0f0d9635ed4cb99 + md5: f9501812fe7c66b6548c7fcaa1c1f252 + depends: + - __osx >=11.0 + - libcxx >=19 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + size: 359854 + timestamp: 1764018178608 +- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda + sha256: 6854ee7675135c57c73a04849c29cbebc2fb6a3a3bfee1f308e64bf23074719b + md5: 1302b74b93c44791403cbeee6a0f62a3 + depends: + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - libbrotlicommon 1.2.0 hfd05255_1 + license: MIT + license_family: MIT + size: 335782 + timestamp: 1764018443683 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda + sha256: c30daba32ddebbb7ded490f0e371eae90f51e72db620554089103b4a6934b0d5 + md5: 51a19bba1b8ebfb60df25cde030b7ebc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + size: 260341 + timestamp: 1757437258798 +- conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda + sha256: 8f50b58efb29c710f3cecf2027a8d7325ba769ab10c746eff75cea3ac050b10c + md5: 97c4b3bd8a90722104798175a1bdddbf + depends: + - __osx >=10.13 + license: bzip2-1.0.6 + license_family: BSD + size: 132607 + timestamp: 1757437730085 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda + sha256: b456200636bd5fecb2bec63f7e0985ad2097cf1b83d60ce0b6968dffa6d02aa1 + md5: 58fd217444c2a5701a44244faf518206 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + size: 125061 + timestamp: 1757437486465 +- conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda + sha256: d882712855624641f48aa9dc3f5feea2ed6b4e6004585d3616386a18186fe692 + md5: 1077e9333c41ff0be8edd1a5ec0ddace + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: bzip2-1.0.6 + license_family: BSD + size: 55977 + timestamp: 1757437738856 +- conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda + sha256: cc9accf72fa028d31c2a038460787751127317dcfa991f8d1f1babf216bb454e + md5: 920bb03579f15389b9e512095ad995b7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 207882 + timestamp: 1765214722852 +- conda: https://conda.anaconda.org/conda-forge/osx-64/c-ares-1.34.6-hb5e19a0_0.conda + sha256: 2f5bc0292d595399df0d168355b4e9820affc8036792d6984bd751fdda2bcaea + md5: fc9a153c57c9f070bebaa7eef30a8f17 + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 186122 + timestamp: 1765215100384 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda + sha256: 2995f2aed4e53725e5efbc28199b46bf311c3cab2648fc4f10c2227d6d5fa196 + md5: bcb3cba70cf1eec964a03b4ba7775f01 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 180327 + timestamp: 1765215064054 +- conda: https://conda.anaconda.org/conda-forge/win-64/c-ares-1.34.6-hfd05255_0.conda + sha256: 5e1e2e24ce279f77e421fcc0e5846c944a8a75f7cf6158427c7302b02984291a + md5: 7c6da34e5b6e60b414592c74582e28bf + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 193550 + timestamp: 1765215100218 +- conda: https://conda.anaconda.org/conda-forge/linux-64/c-blosc2-2.22.0-hc31b594_1.conda + sha256: efe06a982fe7f4e483a2043c4b43fc3598a538a66ed11364ee5b25d3400ef415 + md5: 52019609422a72ec80c32bbc16a889d8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - lz4-c >=1.10.0,<1.11.0a0 + - zlib-ng >=2.3.1,<2.4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 352332 + timestamp: 1764291444176 +- conda: https://conda.anaconda.org/conda-forge/osx-64/c-blosc2-2.22.0-hedb7e5f_1.conda + sha256: f529640f28822172017b8159c5d1f149ceda2c44707bcf8732b812e806cff669 + md5: 13038523111830630683530ea54eb503 + depends: + - __osx >=10.13 + - libcxx >=19 + - lz4-c >=1.10.0,<1.11.0a0 + - zlib-ng >=2.3.1,<2.4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 287057 + timestamp: 1764291903510 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-blosc2-2.22.0-hb83781b_1.conda + sha256: 4c1afcc78418a5d171f94238bae8b798c288deb8ba454113cf11f10d72b09ff6 + md5: 5e4bdded23f6d61d8351223db98bc8f3 + depends: + - __osx >=11.0 + - libcxx >=19 + - lz4-c >=1.10.0,<1.11.0a0 + - zlib-ng >=2.3.1,<2.4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 253671 + timestamp: 1764291734763 +- conda: https://conda.anaconda.org/conda-forge/win-64/c-blosc2-2.22.0-h2af8807_1.conda + sha256: fb27b61b4c969e1761c2d02c12854a3e809c9db2b4097bdef77e0aaa3f7ee33a + md5: eb7c33dcf2ff0cea48cd13f0ebba44f5 + depends: + - lz4-c >=1.10.0,<1.11.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - zlib-ng >=2.3.1,<2.4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 225534 + timestamp: 1764291826235 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-h4c7d964_0.conda + sha256: 686a13bd2d4024fc99a22c1e0e68a7356af3ed3304a8d3ff6bb56249ad4e82f0 + md5: f98fb7db808b94bc1ec5b0e62f9f1069 + depends: + - __win + license: ISC + size: 152827 + timestamp: 1762967310929 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + sha256: b986ba796d42c9d3265602bc038f6f5264095702dd546c14bc684e60c385e773 + md5: f0991f0f84902f6b6009b4d2350a83aa + depends: + - __unix + license: ISC + size: 152432 + timestamp: 1762967197890 +- conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + noarch: python + sha256: 561e6660f26c35d137ee150187d89767c988413c978e1b712d53f27ddf70ea17 + md5: 9b347a7ec10940d3f7941ff6c460b551 + depends: + - cached_property >=1.5.2,<1.5.3.0a0 + license: BSD-3-Clause + license_family: BSD + size: 4134 + timestamp: 1615209571450 +- conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 + sha256: 6dbf7a5070cc43d90a1e4c2ec0c541c69d8e30a0e25f50ce9f6e4a432e42c5d7 + md5: 576d629e47797577ab0f1b351297ef4a + depends: + - python >=3.6 + license: BSD-3-Clause + license_family: BSD + size: 11065 + timestamp: 1615209567874 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda + sha256: 3bd6a391ad60e471de76c0e9db34986c4b5058587fbf2efa5a7f54645e28c2c7 + md5: 09262e66b19567aff4f592fb53b28760 + depends: + - __glibc >=2.17,<3.0.a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - freetype >=2.12.1,<3.0a0 + - icu >=75.1,<76.0a0 + - libexpat >=2.6.4,<3.0a0 + - libgcc >=13 + - libglib >=2.82.2,<3.0a0 + - libpng >=1.6.47,<1.7.0a0 + - libstdcxx >=13 + - libxcb >=1.17.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pixman >=0.44.2,<1.0a0 + - xorg-libice >=1.1.2,<2.0a0 + - xorg-libsm >=1.2.5,<2.0a0 + - xorg-libx11 >=1.8.11,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + license: LGPL-2.1-only or MPL-1.1 + size: 978114 + timestamp: 1741554591855 +- conda: https://conda.anaconda.org/conda-forge/win-64/cairo-1.18.4-h5782bbf_0.conda + sha256: b9f577bddb033dba4533e851853924bfe7b7c1623d0697df382eef177308a917 + md5: 20e32ced54300292aff690a69c5e7b97 + depends: + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - freetype >=2.12.1,<3.0a0 + - icu >=75.1,<76.0a0 + - libexpat >=2.6.4,<3.0a0 + - libglib >=2.82.2,<3.0a0 + - libpng >=1.6.47,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + - pixman >=0.44.2,<1.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: LGPL-2.1-only or MPL-1.1 + size: 1524254 + timestamp: 1741555212198 +- conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + sha256: 083a2bdad892ccf02b352ecab38ee86c3e610ba9a4b11b073ea769d55a115d32 + md5: 96a02a5c1a65470a7e4eedb644c872fd + depends: + - python >=3.10 + license: ISC + size: 157131 + timestamp: 1762976260320 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda + sha256: c6339858a0aaf5d939e00d345c98b99e4558f285942b27232ac098ad17ac7f8e + md5: cf45f4278afd6f4e6d03eda0f435d527 + depends: + - __glibc >=2.17,<3.0.a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - pycparser + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 300271 + timestamp: 1761203085220 +- conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + sha256: b32f8362e885f1b8417bac2b3da4db7323faa12d5db62b7fd6691c02d60d6f59 + md5: a22d1fd9bf98827e280a02875d9a007a + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 50965 + timestamp: 1760437331772 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + sha256: 38cfe1ee75b21a8361c8824f5544c3866f303af1762693a178266d7f198e8715 + md5: ea8a6c3256897cc31263de9f455e25d9 + depends: + - python >=3.10 + - __unix + - python + license: BSD-3-Clause + license_family: BSD + size: 97676 + timestamp: 1764518652276 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyha7b4d00_1.conda + sha256: c3bc9a49930fa1c3383a1485948b914823290efac859a2587ca57a270a652e08 + md5: 6cd3ccc98bacfcc92b2bd7f236f01a7e + depends: + - python >=3.10 + - colorama + - __win + - python + license: BSD-3-Clause + license_family: BSD + size: 96620 + timestamp: 1764518654675 +- conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda + sha256: 4c287c2721d8a34c94928be8fe0e9a85754e90189dd4384a31b1806856b50a67 + md5: 61b8078a0905b12529abc622406cb62c + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 27353 + timestamp: 1765303462831 +- conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 + md5: 962b9857ee8e7018c22f2776ffa0b2d7 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 27011 + timestamp: 1733218222191 +- conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda + sha256: 576a44729314ad9e4e5ebe055fbf48beb8116b60e58f9070278985b2b634f212 + md5: 2da13f2b299d8e1995bafbbe9689a2f7 + depends: + - python >=3.9 + - python + license: BSD-3-Clause + license_family: BSD + size: 14690 + timestamp: 1753453984907 +- conda: https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py314h9891dd4_3.conda + sha256: 54c79736927c787e535db184bb7f3bce13217cb7d755c50666cfc0da7c6c86f3 + md5: 72d57382d0f63c20a16b1d514fcde6ff + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - numpy >=1.25 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 299226 + timestamp: 1762525516589 +- conda: https://conda.anaconda.org/conda-forge/osx-64/contourpy-1.3.3-py314h00ed6fe_3.conda + sha256: 1ffeead3cedb5990d17c077b0943d6ded6b5d8c148becb01caaaa7920be122a4 + md5: 761aa19f97a0dd5dedb9a0a6003707c1 + depends: + - __osx >=10.13 + - libcxx >=19 + - numpy >=1.25 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 272746 + timestamp: 1762525900749 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/contourpy-1.3.3-py314h784bc60_3.conda + sha256: e5ca7f079f9bd49a9fce837dfe9014d96603600a29e5575cce19895d3639182c + md5: d75fae59fe0c8863de391e95959b2c65 + depends: + - __osx >=11.0 + - libcxx >=19 + - numpy >=1.25 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 262199 + timestamp: 1762525837746 +- conda: https://conda.anaconda.org/conda-forge/win-64/contourpy-1.3.3-py314h909e829_3.conda + sha256: f014eb687eb8dd25cec124594f4e48cf85803ff1db85a2a1f95719f9ec6434d2 + md5: 3647d90eea49efc6076729ef0ae81075 + depends: + - numpy >=1.25 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 227536 + timestamp: 1762525688384 +- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.2-py314hd8ed1ab_100.conda + noarch: generic + sha256: 9e345f306446500956ffb1414b773f5476f497d7a2b5335a59edd2c335209dbb + md5: 30f999d06f347b0116f0434624b6e559 + depends: + - python >=3.14,<3.15.0a0 + - python_abi * *_cp314 + license: Python-2.0 + size: 49298 + timestamp: 1765020324943 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-46.0.4-py314h7fe84b3_0.conda + sha256: 90738c26981732357d71b97df1994a1a74f87701468d61e19755af7d9e35edf8 + md5: afabda22fe5163200fc59f31b58d9e6a + depends: + - __glibc >=2.17,<3.0.a0 + - cffi >=1.14 + - libgcc >=14 + - openssl >=3.5.5,<4.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - __glibc >=2.17 + license: Apache-2.0 AND BSD-3-Clause AND PSF-2.0 AND MIT + license_family: BSD + size: 1719239 + timestamp: 1769650654007 +- conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda + sha256: bb47aec5338695ff8efbddbc669064a3b10fe34ad881fb8ad5d64fbfa6910ed1 + md5: 4c2a8fef270f6c69591889b93f9f55c1 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 14778 + timestamp: 1764466758386 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hd9c7081_0.conda + sha256: ee09ad7610c12c7008262d713416d0b58bf365bc38584dce48950025850bdf3f + md5: cae723309a49399d2949362f4ab5c9e4 + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.21.3,<1.22.0a0 + - libgcc >=13 + - libntlm >=1.8,<2.0a0 + - libstdcxx >=13 + - libxcrypt >=4.4.36 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause-Attribution + license_family: BSD + size: 209774 + timestamp: 1750239039316 +- conda: https://conda.anaconda.org/conda-forge/noarch/dask-core-2025.12.0-pyhcf101f3_1.conda + sha256: f02b63259e8f927a7e38e818a8dd251a06bce3f3f853235b8886a3cb89e0dded + md5: cc7b371edd70319942c802c7d828a428 + depends: + - python >=3.10 + - click >=8.1 + - cloudpickle >=3.0.0 + - fsspec >=2021.9.0 + - packaging >=20.0 + - partd >=1.4.0 + - pyyaml >=5.3.1 + - toolz >=0.12.0 + - importlib-metadata >=4.13.0 + - python + license: BSD-3-Clause + size: 1062442 + timestamp: 1765558272352 +- conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + sha256: 8bb557af1b2b7983cf56292336a1a1853f26555d9c6cecf1e5b2b96838c9da87 + md5: ce96f2f470d39bd96ce03945af92e280 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - libglib >=2.86.2,<3.0a0 + - libexpat >=2.7.3,<3.0a0 + license: AFL-2.1 OR GPL-2.0-or-later + size: 447649 + timestamp: 1764536047944 +- conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.18-py314h42812f9_0.conda + sha256: 2803e9285da433a5d704a63ac9c64c87b5df9aaa1e2d48cc333e65d5a945912e + md5: 69635aa34b45d84c2599ff8b48094978 + depends: + - python + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - python_abi 3.14.* *_cp314 + license: MIT + size: 2888322 + timestamp: 1765704065377 +- conda: https://conda.anaconda.org/conda-forge/osx-64/debugpy-1.8.18-py314h3658963_0.conda + sha256: 7f9ace95f4a1ba6c6e212ee0d7d05aa6bf0f44adaf1388ca35348308962958d1 + md5: 42fe9cfc2ab30e60ad4641b42e93615a + depends: + - python + - libcxx >=19 + - __osx >=10.13 + - python_abi 3.14.* *_cp314 + license: MIT + size: 2784382 + timestamp: 1765704065500 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.18-py314hf820bb6_0.conda + sha256: 9cbd840be5ac5304b28dd422552ac6a42b45606b94ba140579c0799f3802998f + md5: e12de4b9087624d63dba226c297a8d7f + depends: + - python + - python 3.14.* *_cp314 + - libcxx >=19 + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + license: MIT + size: 2776113 + timestamp: 1765704076173 +- conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.18-py314hb98de8c_0.conda + sha256: d60cf14462bc4d4f0c851a24e268b688b6ae7918d89e8648028e8a141257e28d + md5: c0647965c420ce856b7d7a6d077afe55 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: MIT + size: 4021632 + timestamp: 1765704089964 +- conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda + sha256: c17c6b9937c08ad63cb20a26f403a3234088e57d4455600974a0ce865cb14017 + md5: 9ce473d1d1be1cc3810856a48b3fab32 + depends: + - python >=3.9 + license: BSD-2-Clause + license_family: BSD + size: 14129 + timestamp: 1740385067843 +- conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda + sha256: ef1e7b8405997ed3d6e2b6722bd7088d4a8adf215e7c88335582e65651fb4e05 + md5: d73fdc05f10693b518f52c994d748c19 + depends: + - python >=3.10,<4.0.0 + - sniffio + - python + constrains: + - aioquic >=1.2.0 + - cryptography >=45 + - httpcore >=1.0.0 + - httpx >=0.28.0 + - h2 >=4.2.0 + - idna >=3.10 + - trio >=0.30 + - wmi >=1.5.1 + license: ISC + size: 196500 + timestamp: 1757292856922 +- conda: https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.3.1-h5888daf_0.conda + sha256: 1bcc132fbcc13f9ad69da7aa87f60ea41de7ed4d09f3a00ff6e0e70e1c690bc2 + md5: bfd56492d8346d669010eccafe0ba058 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: BSD-3-Clause + license_family: BSD + size: 69544 + timestamp: 1739569648873 +- conda: https://conda.anaconda.org/conda-forge/win-64/double-conversion-3.3.1-he0c23c2_0.conda + sha256: b1fee32ef36a98159f0a2a96c4e734dfc9adff73acd444940831b22c1fb6d5c0 + md5: e9a1402439c18a4e3c7a52e4246e9e1c + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: BSD-3-Clause + license_family: BSD + size: 71355 + timestamp: 1739570178995 +- conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda + sha256: c37320864c35ef996b0e02e289df6ee89582d6c8e233e18dc9983375803c46bb + md5: 3bc0ac31178387e8ed34094d9481bfe8 + depends: + - dnspython >=2.0.0 + - idna >=2.0.0 + - python >=3.10 + license: Unlicense + size: 46767 + timestamp: 1756221480106 +- conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda + sha256: 6a518e00d040fcad016fb2dde29672aa3476cd9ae33ea5b7b257222e66037d89 + md5: 2452e434747a6b742adc5045f2182a8e + depends: + - email-validator >=2.3.0,<2.3.1.0a0 + license: Unlicense + size: 7077 + timestamp: 1756221480651 +- conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + sha256: ee6cf346d017d954255bbcbdb424cddea4d14e4ed7e9813e429db1d795d01144 + md5: 8e662bd460bda79b1ea39194e3c4c9ab + depends: + - python >=3.10 + - typing_extensions >=4.6.0 + license: MIT and PSF-2.0 + size: 21333 + timestamp: 1763918099466 +- conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + sha256: 210c8165a58fdbf16e626aac93cc4c14dbd551a01d1516be5ecad795d2422cad + md5: ff9efb7f7469aed3c4a8106ffa29593c + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 30753 + timestamp: 1756729456476 +- conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.124.4-hd122799_0.conda + sha256: 750d5c9a2f3b5887d3d4ae390544295ece610b75f9276eafc926f99afe7ee2d8 + md5: de74823e1b48db18c446d8b123a5391b + depends: + - fastapi-core ==0.124.4 pyhcf101f3_0 + - email_validator + - fastapi-cli + - httpx + - jinja2 + - python-multipart + - uvicorn-standard + license: MIT + size: 4785 + timestamp: 1765682147035 +- conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.16-pyhcf101f3_1.conda + sha256: 4136b0c277188b205332983278c7b278ea946dc1c78a381e0f5bc79204b8ac97 + md5: 4f82a266e2d5b199db16cdb42341d785 + depends: + - python >=3.10 + - rich-toolkit >=0.14.8 + - tomli >=2.0.0 + - typer >=0.15.1 + - uvicorn-standard >=0.15.0 + - python + license: MIT + license_family: MIT + size: 19029 + timestamp: 1763068963965 +- conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.124.4-pyhcf101f3_0.conda + sha256: f38b786d4b2012d629ecacbb6c090205245590dc464ed5500fe31e9e58f0c4d8 + md5: a3d7236ab2f52f893e667ab551cac180 + depends: + - python >=3.10 + - annotated-doc >=0.0.2 + - starlette >=0.40.0,<0.51.0 + - typing_extensions >=4.8.0 + - pydantic >=1.7.4,!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0 + - python + constrains: + - email_validator >=2.0.0 + - fastapi-cli >=0.0.8 + - httpx >=0.23.0,<1.0.0 + - jinja2 >=3.1.5 + - python-multipart >=0.0.18 + - uvicorn-standard >=0.12.0 + license: MIT + size: 89596 + timestamp: 1765682147034 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + sha256: 58d7f40d2940dd0a8aa28651239adbf5613254df0f75789919c4e6762054403b + md5: 0c96522c6bdaed4b1566d11387caaf45 + license: BSD-3-Clause + license_family: BSD + size: 397370 + timestamp: 1566932522327 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + sha256: c52a29fdac682c20d252facc50f01e7c2e7ceac52aa9817aaf0bb83f7559ec5c + md5: 34893075a5c9e55cdafac56607368fc6 + license: OFL-1.1 + license_family: Other + size: 96530 + timestamp: 1620479909603 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + sha256: 00925c8c055a2275614b4d983e1df637245e19058d79fc7dd1a93b8d9fb4b139 + md5: 4d59c254e01d9cde7957100457e2d5fb + license: OFL-1.1 + license_family: Other + size: 700814 + timestamp: 1620479612257 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + sha256: 2821ec1dc454bd8b9a31d0ed22a7ce22422c0aef163c59f49dfdf915d0f0ca14 + md5: 49023d73832ef61042f6a237cb2687e7 + license: LicenseRef-Ubuntu-Font-Licence-Version-1.0 + license_family: Other + size: 1620504 + timestamp: 1727511233259 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda + sha256: 7093aa19d6df5ccb6ca50329ef8510c6acb6b0d8001191909397368b65b02113 + md5: 8f5b0b297b59e1ac160ad4beec99dbee + depends: + - __glibc >=2.17,<3.0.a0 + - freetype >=2.12.1,<3.0a0 + - libexpat >=2.6.3,<3.0a0 + - libgcc >=13 + - libuuid >=2.38.1,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 265599 + timestamp: 1730283881107 +- conda: https://conda.anaconda.org/conda-forge/win-64/fontconfig-2.15.0-h765892d_1.conda + sha256: ed122fc858fb95768ca9ca77e73c8d9ddc21d4b2e13aaab5281e27593e840691 + md5: 9bb0026a2131b09404c59c4290c697cd + depends: + - freetype >=2.12.1,<3.0a0 + - libexpat >=2.6.3,<3.0a0 + - libiconv >=1.17,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: MIT + license_family: MIT + size: 192355 + timestamp: 1730284147944 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + sha256: a997f2f1921bb9c9d76e6fa2f6b408b7fa549edd349a77639c9fe7a23ea93e61 + md5: fee5683a3f04bd15cbd8318b096a27ab + depends: + - fonts-conda-forge + license: BSD-3-Clause + license_family: BSD + size: 3667 + timestamp: 1566974674465 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + sha256: 54eea8469786bc2291cc40bca5f46438d3e062a399e8f53f013b6a9f50e98333 + md5: a7970cd949a077b7cb9696379d338681 + depends: + - font-ttf-ubuntu + - font-ttf-inconsolata + - font-ttf-dejavu-sans-mono + - font-ttf-source-code-pro + license: BSD-3-Clause + license_family: BSD + size: 4059 + timestamp: 1762351264405 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.61.1-pyh7db6752_0.conda + sha256: bb74f1732065eb95c3ea4ae7f7ab29d6ddaafe6da32f009106bf9a335147cb77 + md5: d5da976e963e70364b9e3ff270842b9f + depends: + - brotli + - munkres + - python >=3.10 + - unicodedata2 >=15.1.0 + track_features: + - fonttools_no_compile + license: MIT + license_family: MIT + size: 834764 + timestamp: 1765632669874 +- conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda + sha256: bf8e4dffe46f7d25dc06f31038cacb01672c47b9f45201f065b0f4d00ab0a83e + md5: 4afc585cd97ba8a23809406cd8a9eda8 + depends: + - libfreetype 2.14.1 ha770c72_0 + - libfreetype6 2.14.1 h73754d4_0 + license: GPL-2.0-only OR FTL + size: 173114 + timestamp: 1757945422243 +- conda: https://conda.anaconda.org/conda-forge/osx-64/freetype-2.14.1-h694c41f_0.conda + sha256: 9f8282510db291496e89618fc66a58a1124fe7a6276fbd57ed18c602ce2576e9 + md5: ca641fdf8b7803f4b7212b6d66375930 + depends: + - libfreetype 2.14.1 h694c41f_0 + - libfreetype6 2.14.1 h6912278_0 + license: GPL-2.0-only OR FTL + size: 173969 + timestamp: 1757945973505 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.1-hce30654_0.conda + sha256: 14427aecd72e973a73d5f9dfd0e40b6bc3791d253de09b7bf233f6a9a190fd17 + md5: 1ec9a1ee7a2c9339774ad9bb6fe6caec + depends: + - libfreetype 2.14.1 hce30654_0 + - libfreetype6 2.14.1 h6da58f4_0 + license: GPL-2.0-only OR FTL + size: 173399 + timestamp: 1757947175403 +- conda: https://conda.anaconda.org/conda-forge/win-64/freetype-2.14.1-h57928b3_0.conda + sha256: a9b3313edea0bf14ea6147ea43a1059d0bf78771a1336d2c8282891efc57709a + md5: d69c21967f35eb2ce7f1f85d6b6022d3 + depends: + - libfreetype 2.14.1 h57928b3_0 + - libfreetype6 2.14.1 hdbac1cb_0 + license: GPL-2.0-only OR FTL + size: 184553 + timestamp: 1757946164012 +- conda: https://conda.anaconda.org/conda-forge/noarch/frozenlist-1.7.0-pyhf298e5d_0.conda + sha256: d065c6c76ba07c148b07102f89fd14e39e4f0b2c022ad671bbef8fda9431ba1b + md5: 3998c9592e3db2f6809e4585280415f4 + depends: + - python >=3.9 + track_features: + - frozenlist_no_compile + license: Apache-2.0 + license_family: APACHE + size: 18952 + timestamp: 1752167260183 +- conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.12.0-pyhd8ed1ab_0.conda + sha256: 64a4ed910e39d96cd590d297982b229c57a08e70450d489faa34fd2bec36dbcc + md5: a3b9510e2491c20c7fc0f5e730227fbb + depends: + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + size: 147391 + timestamp: 1764784920938 +- conda: https://conda.anaconda.org/conda-forge/noarch/gast-0.4.0-pyh9f0ad1d_0.tar.bz2 + sha256: 0f7eff1aab91ec3ac2eb3bbace1297fd71c16d235503222c3da89428ac562a63 + md5: 42323c77b73462199fca93bc8ac9279d + depends: + - python + license: BSD-3-Clause + license_family: BSD + size: 12325 + timestamp: 1596839771978 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gflags-2.2.2-h5888daf_1005.conda + sha256: 6c33bf0c4d8f418546ba9c250db4e4221040936aef8956353bc764d4877bc39a + md5: d411fc29e338efb48c5fd4576d71d881 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: BSD-3-Clause + license_family: BSD + size: 119654 + timestamp: 1726600001928 +- conda: https://conda.anaconda.org/conda-forge/osx-64/gflags-2.2.2-hac325c4_1005.conda + sha256: c0bea66f71a6f4baa8d4f0248e17f65033d558d9e882c0af571b38bcca3e4b46 + md5: a26de8814083a6971f14f9c8c3cb36c2 + depends: + - __osx >=10.13 + - libcxx >=17 + license: BSD-3-Clause + license_family: BSD + size: 84946 + timestamp: 1726600054963 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gflags-2.2.2-hf9b8971_1005.conda + sha256: fd56ed8a1dab72ab90d8a8929b6f916a6d9220ca297ff077f8f04c5ed3408e20 + md5: 57a511a5905caa37540eb914dfcbf1fb + depends: + - __osx >=11.0 + - libcxx >=17 + license: BSD-3-Clause + license_family: BSD + size: 82090 + timestamp: 1726600145480 +- conda: https://conda.anaconda.org/conda-forge/linux-64/glog-0.7.1-hbabe93e_0.conda + sha256: dc824dc1d0aa358e28da2ecbbb9f03d932d976c8dca11214aa1dcdfcbd054ba2 + md5: ff862eebdfeb2fd048ae9dc92510baca + depends: + - gflags >=2.2.2,<2.3.0a0 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: BSD-3-Clause + license_family: BSD + size: 143452 + timestamp: 1718284177264 +- conda: https://conda.anaconda.org/conda-forge/osx-64/glog-0.7.1-h2790a97_0.conda + sha256: dd56547db8625eb5c91bb0a9fbe8bd6f5c7fbf5b6059d46365e94472c46b24f9 + md5: 06cf91665775b0da395229cd4331b27d + depends: + - __osx >=10.13 + - gflags >=2.2.2,<2.3.0a0 + - libcxx >=16 + license: BSD-3-Clause + license_family: BSD + size: 117017 + timestamp: 1718284325443 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/glog-0.7.1-heb240a5_0.conda + sha256: 9fc77de416953aa959039db72bc41bfa4600ae3ff84acad04a7d0c1ab9552602 + md5: fef68d0a95aa5b84b5c1a4f6f3bf40e1 + depends: + - __osx >=11.0 + - gflags >=2.2.2,<2.3.0a0 + - libcxx >=16 + license: BSD-3-Clause + license_family: BSD + size: 112215 + timestamp: 1718284365403 +- conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda + sha256: 25ba37da5c39697a77fce2c9a15e48cf0a84f1464ad2aafbe53d8357a9f6cc8c + md5: 2cd94587f3a401ae05e03a6caf09539d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: LGPL-2.0-or-later + license_family: LGPL + size: 99596 + timestamp: 1755102025473 +- conda: https://conda.anaconda.org/conda-forge/win-64/graphite2-1.3.14-hac47afa_2.conda + sha256: 5f1714b07252f885a62521b625898326ade6ca25fbc20727cfe9a88f68a54bfd + md5: b785694dd3ec77a011ccf0c24725382b + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: LGPL-2.0-or-later + license_family: LGPL + size: 96336 + timestamp: 1755102441729 +- conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda + sha256: f64b68148c478c3bfc8f8d519541de7d2616bf59d44485a5271041d40c061887 + md5: 4b69232755285701bc86a5afe4d9933a + depends: + - python >=3.9 + - typing_extensions + license: MIT + license_family: MIT + size: 37697 + timestamp: 1745526482242 +- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + sha256: 84c64443368f84b600bfecc529a1194a3b14c3656ee2e832d15a20e0329b6da3 + md5: 164fc43f0b53b6e3a7bc7dce5e4f1dc9 + depends: + - python >=3.10 + - hyperframe >=6.1,<7 + - hpack >=4.1,<5 + - python + license: MIT + license_family: MIT + size: 95967 + timestamp: 1756364871835 +- conda: https://conda.anaconda.org/conda-forge/linux-64/h5py-3.15.1-nompi_py314hc32fe06_101.conda + sha256: 36f836d9212fda38e09e3d7c1e694996112456c1b1da1b1bb6c0072321559082 + md5: d5f709371311de1343675757978a50d5 + depends: + - __glibc >=2.17,<3.0.a0 + - cached-property + - hdf5 >=1.14.6,<1.14.7.0a0 + - libgcc >=14 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 1291384 + timestamp: 1764016672412 +- conda: https://conda.anaconda.org/conda-forge/osx-64/h5py-3.15.1-nompi_py314hf613b1f_101.conda + sha256: 7df694dadfe5dae733617d27f31b392148b42f0068766c4d4c3dc6d8dd1d709d + md5: 60a46376d9f6bc9f84b7327a200d6753 + depends: + - __osx >=10.13 + - cached-property + - hdf5 >=1.14.6,<1.14.7.0a0 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 1162048 + timestamp: 1764016999757 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/h5py-3.15.1-nompi_py314h1c8d760_101.conda + sha256: 1add46ebafbab228bbb2db615740b5763f139f65aa110a2996f08695b5fed7d3 + md5: 81e42cd3fcea0984435a3c21857e0d50 + depends: + - __osx >=11.0 + - cached-property + - hdf5 >=1.14.6,<1.14.7.0a0 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 1157833 + timestamp: 1764017977683 +- conda: https://conda.anaconda.org/conda-forge/win-64/h5py-3.15.1-nompi_py314hc249e69_101.conda + sha256: 7a05562f2cf290b50de67eefef6ea704ec2356551a2683b767c511680562eeaa + md5: 4019722f94eac6540faf77d20cc4190d + depends: + - cached-property + - hdf5 >=1.14.6,<1.14.7.0a0 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 1059478 + timestamp: 1764017347777 +- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda + sha256: 6bd8b22beb7d40562b2889dc68232c589ff0d11a5ad3addd41a8570d11f039d9 + md5: b8690f53007e9b5ee2c2178dd4ac778c + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - graphite2 >=1.3.14,<2.0a0 + - icu >=75.1,<76.0a0 + - libexpat >=2.7.1,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libgcc >=14 + - libglib >=2.86.1,<3.0a0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 2411408 + timestamp: 1762372726141 +- conda: https://conda.anaconda.org/conda-forge/win-64/harfbuzz-12.2.0-h5f2951f_0.conda + sha256: db73714c7f7e0c47b3b9db9302a83f2deb6f8d6081716d35710ef3c6756af6c3 + md5: e798ef748fc564e42f381d3d276850f0 + depends: + - cairo >=1.18.4,<2.0a0 + - graphite2 >=1.3.14,<2.0a0 + - icu >=75.1,<76.0a0 + - libexpat >=2.7.1,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libglib >=2.86.1,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: MIT + license_family: MIT + size: 1138900 + timestamp: 1762373626704 +- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h1b119a7_104.conda + sha256: 454e9724b322cee277abd7acf4f8d688e9c4ded006b6d5bc9fcc2a1ff907d27a + md5: 0857f4d157820dcd5625f61fdfefb780 + depends: + - __glibc >=2.17,<3.0.a0 + - libaec >=1.1.4,<2.0a0 + - libcurl >=8.17.0,<9.0a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 3720961 + timestamp: 1764771748126 +- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.6-nompi_hc1508a4_104.conda + sha256: aed322f0e8936960332305fbc213831a3cd301db5ea22c06e1293d953ddec563 + md5: 9425a5c53febdf71696aed291586d038 + depends: + - __osx >=10.13 + - libaec >=1.1.4,<2.0a0 + - libcurl >=8.17.0,<9.0a0 + - libcxx >=19 + - libgfortran + - libgfortran5 >=14.3.0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 3528765 + timestamp: 1764773824647 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdf5-1.14.6-nompi_hd3baa01_104.conda + sha256: 3cd591334a838b127dfe8a626f38241892063eac8873abb93255962c71155533 + md5: 5a1cbaf2349dd2e6dd6cfaab378de51b + depends: + - __osx >=11.0 + - libaec >=1.1.4,<2.0a0 + - libcurl >=8.17.0,<9.0a0 + - libcxx >=19 + - libgfortran + - libgfortran5 >=14.3.0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 3292042 + timestamp: 1764771887501 +- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.6-nompi_h89f0904_104.conda + sha256: cc948149f700033ff85ce4a1854edf6adcb5881391a3df5c40cbe2a793dd9f81 + md5: 9cc4a5567d46c7fcde99563e86522882 + depends: + - libaec >=1.1.4,<2.0a0 + - libcurl >=8.17.0,<9.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 2028777 + timestamp: 1764771527382 +- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + sha256: 6ad78a180576c706aabeb5b4c8ceb97c0cb25f1e112d76495bff23e3779948ba + md5: 0a802cb9888dd14eeefc611f05c40b6e + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 30731 + timestamp: 1737618390337 +- conda: https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyhd8ed1ab_2.conda + sha256: 8027e436ad59e2a7392f6036392ef9d6c223798d8a1f4f12d5926362def02367 + md5: cf25bfddbd3bc275f3d3f9936cee1dd3 + depends: + - python >=3.9 + - six >=1.9 + - webencodings + license: MIT + license_family: MIT + size: 94853 + timestamp: 1734075276288 +- conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda + sha256: 04d49cb3c42714ce533a8553986e1642d0549a05dc5cc48e0d43ff5be6679a5b + md5: 4f14640d58e2cc0aa0819d9d8ba125bb + depends: + - python >=3.9 + - h11 >=0.16 + - h2 >=3,<5 + - sniffio 1.* + - anyio >=4.0,<5.0 + - certifi + - python + license: BSD-3-Clause + license_family: BSD + size: 49483 + timestamp: 1745602916758 +- conda: https://conda.anaconda.org/conda-forge/linux-64/httptools-0.7.1-py314h5bd0f2a_1.conda + sha256: 91bfdf1dad0fa57efc2404ca00f5fee8745ad9b56ec1d0df298fd2882ad39806 + md5: 067a52c66f453b97771650bbb131e2b5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 99037 + timestamp: 1762504051423 +- conda: https://conda.anaconda.org/conda-forge/osx-64/httptools-0.7.1-py314h6482030_1.conda + sha256: 28f63c3a15b60d11f81b8b291776a804ff0d5b7cb2d56a3e8cd9c2c6f21258f3 + md5: defa8d782c5ae29fedab67e33a10df68 + depends: + - __osx >=10.13 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 90328 + timestamp: 1762504303446 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/httptools-0.7.1-py314h0612a62_1.conda + sha256: 042343211aafabab79120d0deda73358ddd3cb61b9ad55307108a275976fccfa + md5: 0ca03669a236fee8ce414e166d0bbf23 + depends: + - __osx >=11.0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 90384 + timestamp: 1762504632522 +- conda: https://conda.anaconda.org/conda-forge/win-64/httptools-0.7.1-py314h5a2d7ad_1.conda + sha256: 8377e165207fcd24844b7e62ed68b9da3573c0a7b1c9998736d50cf1d6324afc + md5: edde0f16d9733e829901f0c9755e5d22 + depends: + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 75701 + timestamp: 1762504456801 +- conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda + sha256: cd0f1de3697b252df95f98383e9edb1d00386bfdd03fdf607fa42fe5fcb09950 + md5: d6989ead454181f4f9bc987d3dc4e285 + depends: + - anyio + - certifi + - httpcore 1.* + - idna + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 63082 + timestamp: 1733663449209 +- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + sha256: 77af6f5fe8b62ca07d09ac60127a30d9069fdc3c68d6b256754d0ffb1f7779f8 + md5: 8e6923fc12f1fe8f8c4e5c9f343256ac + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 17397 + timestamp: 1737618427549 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda + sha256: 71e750d509f5fa3421087ba88ef9a7b9be11c53174af3aa4d06aff4c18b38e8e + md5: 8b189310083baabfb622af68fd9d3ae3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: MIT + license_family: MIT + size: 12129203 + timestamp: 1720853576813 +- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda + sha256: 2e64307532f482a0929412976c8450c719d558ba20c0962832132fd0d07ba7a7 + md5: d68d48a3060eb5abdc1cdc8e2a3a5966 + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 11761697 + timestamp: 1720853679409 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda + sha256: 9ba12c93406f3df5ab0a43db8a4b4ef67a5871dfd401010fbe29b218b2cbe620 + md5: 5eb22c1d7b3fc4abb50d92d621583137 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 11857802 + timestamp: 1720853997952 +- conda: https://conda.anaconda.org/conda-forge/win-64/icu-75.1-he0c23c2_0.conda + sha256: 1d04369a1860a1e9e371b9fc82dd0092b616adcf057d6c88371856669280e920 + md5: 8579b6bb8d18be7c0b27fb08adeeeb40 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: MIT + license_family: MIT + size: 14544252 + timestamp: 1720853966338 +- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + sha256: ae89d0299ada2a3162c2614a9d26557a92aa6a77120ce142f8e0109bbf0342b0 + md5: 53abe63df7e10a6ba605dc5f9f961d36 + depends: + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + size: 50721 + timestamp: 1760286526795 +- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + sha256: c18ab120a0613ada4391b15981d86ff777b5690ca461ea7e9e49531e8f374745 + md5: 63ccfdc3a3ce25b027b8767eb722fca8 + depends: + - python >=3.9 + - zipp >=3.20 + - python + license: Apache-2.0 + license_family: APACHE + size: 34641 + timestamp: 1747934053147 +- conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda + sha256: acc1d991837c0afb67c75b77fdc72b4bf022aac71fedd8b9ea45918ac9b08a80 + md5: c85c76dc67d75619a92f51dfbce06992 + depends: + - python >=3.9 + - zipp >=3.1.0 + constrains: + - importlib-resources >=6.5.2,<6.5.3.0a0 + license: Apache-2.0 + license_family: APACHE + size: 33781 + timestamp: 1736252433366 +- conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + sha256: e1a9e3b1c8fe62dc3932a616c284b5d8cbe3124bbfbedcf4ce5c828cb166ee19 + md5: 9614359868482abba1bd15ce465e3c42 + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 13387 + timestamp: 1760831448842 +- conda: https://conda.anaconda.org/conda-forge/noarch/ipydatagrid-1.4.0-pyhcf101f3_2.conda + sha256: 05d121a997a7911e2644f5a58a62d24c8ae87d0e715f00ac537895fbc5c895d4 + md5: 12234484af2c95fca5911cd4b90ba30a + depends: + - bqplot >=0.11.6 + - ipywidgets >=7.6,<9 + - pandas >=1.3.5 + - py2vega >=0.5 + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 681089 + timestamp: 1755954998991 +- conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyh5552912_0.conda + sha256: b5f7eaba3bb109be49d00a0a8bda267ddf8fa66cc1b54fc5944529ed6f3e8503 + md5: 1849eec35b60082d2bd66b4e36dec2b6 + depends: + - appnope + - __osx + - comm >=0.1.1 + - debugpy >=1.6.5 + - ipython >=7.23.1 + - jupyter_client >=8.0.0 + - jupyter_core >=4.12,!=5.0.* + - matplotlib-inline >=0.1 + - nest-asyncio >=1.4 + - packaging >=22 + - psutil >=5.7 + - python >=3.10 + - pyzmq >=25 + - tornado >=6.2 + - traitlets >=5.4.0 + - python + constrains: + - appnope >=0.1.2 + license: BSD-3-Clause + license_family: BSD + size: 132289 + timestamp: 1761567969884 +- conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyh6dadd2b_0.conda + sha256: 75e42103bc3350422896f727041e24767795b214a20f50bf39c371626b8aae8b + md5: f22cb16c5ad68fd33d0f65c8739b6a06 + depends: + - python + - __win + - comm >=0.1.1 + - debugpy >=1.6.5 + - ipython >=7.23.1 + - jupyter_client >=8.0.0 + - jupyter_core >=4.12,!=5.0.* + - matplotlib-inline >=0.1 + - nest-asyncio >=1.4 + - packaging >=22 + - psutil >=5.7 + - python >=3.10 + - pyzmq >=25 + - tornado >=6.2 + - traitlets >=5.4.0 + - python + constrains: + - appnope >=0.1.2 + license: BSD-3-Clause + license_family: BSD + size: 132418 + timestamp: 1761567966860 +- conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.1.0-pyha191276_0.conda + sha256: a9d6b74115dbd62e19017ff8fa4885b07b5164427f262cc15b5307e5aaf3ee73 + md5: c6f63cfe66adaa5650788e3106b6683a + depends: + - python + - __linux + - comm >=0.1.1 + - debugpy >=1.6.5 + - ipython >=7.23.1 + - jupyter_client >=8.0.0 + - jupyter_core >=4.12,!=5.0.* + - matplotlib-inline >=0.1 + - nest-asyncio >=1.4 + - packaging >=22 + - psutil >=5.7 + - python >=3.10 + - pyzmq >=25 + - tornado >=6.2 + - traitlets >=5.4.0 + - python + constrains: + - appnope >=0.1.2 + license: BSD-3-Clause + license_family: BSD + size: 133820 + timestamp: 1761567932044 +- conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyh53cf698_0.conda + sha256: 8a72c9945dc4726ee639a9652b622ae6b03f3eba0e16a21d1c6e5bfb562f5a3f + md5: fd77b1039118a3e8ce1070ac8ed45bae + depends: + - __unix + - pexpect >4.3 + - decorator >=4.3.2 + - ipython_pygments_lexers >=1.0.0 + - jedi >=0.18.1 + - matplotlib-inline >=0.1.5 + - prompt-toolkit >=3.0.41,<3.1.0 + - pygments >=2.11.0 + - python >=3.11 + - stack_data >=0.6.0 + - traitlets >=5.13.0 + - typing_extensions >=4.6 + - python + license: BSD-3-Clause + license_family: BSD + size: 645145 + timestamp: 1764766793792 +- conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.8.0-pyhe2676ad_0.conda + sha256: 7c6974866caaccb7eb827bb70523205601c10b8e89d724b193cb4e818f4db2bd + md5: 1bc380b3fd0ea85afdfe0aba5b6b7398 + depends: + - __win + - colorama >=0.4.4 + - decorator >=4.3.2 + - ipython_pygments_lexers >=1.0.0 + - jedi >=0.18.1 + - matplotlib-inline >=0.1.5 + - prompt-toolkit >=3.0.41,<3.1.0 + - pygments >=2.11.0 + - python >=3.11 + - stack_data >=0.6.0 + - traitlets >=5.13.0 + - typing_extensions >=4.6 + - python + license: BSD-3-Clause + license_family: BSD + size: 644388 + timestamp: 1764766840112 +- conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda + sha256: 894682a42a7d659ae12878dbcb274516a7031bbea9104e92f8e88c1f2765a104 + md5: bd80ba060603cc228d9d81c257093119 + depends: + - pygments + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 13993 + timestamp: 1737123723464 +- conda: https://conda.anaconda.org/conda-forge/noarch/ipywidgets-8.1.8-pyhd8ed1ab_0.conda + sha256: 6bb58afb7eabc8b4ac0c7e92707fb498313cc0164cf04e7ba1090dbf49af514b + md5: d68e3f70d1f068f1b66d94822fdc644e + depends: + - comm >=0.1.3 + - ipython >=6.1.0 + - jupyterlab_widgets >=3.0.15,<3.1.0 + - python >=3.10 + - traitlets >=4.3.1 + - widgetsnbextension >=4.0.14,<4.1.0 + license: BSD-3-Clause + license_family: BSD + size: 114376 + timestamp: 1762040524661 +- conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda + sha256: 3cc991f0f09dfd00d2626e745ba68da03e4f1dcbb7b36dd20f7a7373643cd5d5 + md5: d59568bad316413c89831456e691de29 + depends: + - python >=3.10 + - more-itertools + - python + license: MIT + license_family: MIT + size: 14831 + timestamp: 1767294269456 +- conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.0-pyhcf101f3_0.conda + sha256: 04c9f919dcc9edd18f748c47d809479812429af27c43c5562a861df22d5bda6a + md5: f34ec3aa0ea911a038d973d97603faf3 + depends: + - python >=3.10 + - backports.tarfile + - python + license: MIT + license_family: MIT + size: 15566 + timestamp: 1768299702258 +- conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.4.0-pyhcf101f3_1.conda + sha256: 6a91447b3bb4d7ae94cc0d77ed12617796629aee11111efe7ea43cbd0e113bda + md5: aa83cc08626bf6b613a3103942be8951 + depends: + - python >=3.10 + - more-itertools + - python + license: MIT + license_family: MIT + size: 18744 + timestamp: 1767294193246 +- conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda + sha256: 92c4d217e2dc68983f724aa983cca5464dcb929c566627b26a2511159667dba8 + md5: a4f4c5dc9b80bc50e0d3dc4e6e8f1bd9 + depends: + - parso >=0.8.3,<0.9.0 + - python >=3.9 + license: Apache-2.0 AND MIT + size: 843646 + timestamp: 1733300981994 +- conda: https://conda.anaconda.org/conda-forge/noarch/jeepney-0.9.0-pyhd8ed1ab_0.conda + sha256: 00d37d85ca856431c67c8f6e890251e7cc9e5ef3724a0302b8d4a101f22aa27f + md5: b4b91eb14fbe2f850dd2c5fc20676c0d + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 40015 + timestamp: 1740828380668 +- conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + sha256: fc9ca7348a4f25fed2079f2153ecdcf5f9cf2a0bc36c4172420ca09e1849df7b + md5: 04558c96691bed63104678757beb4f8d + depends: + - markupsafe >=2.0 + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 120685 + timestamp: 1764517220861 +- conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.0.1-pyhd8ed1ab_1.conda + sha256: 3d2f20ee7fd731e3ff55c189db9c43231bc8bde957875817a609c227bcb295c6 + md5: 972bdca8f30147135f951847b30399ea + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 23708 + timestamp: 1733229244590 +- conda: https://conda.anaconda.org/conda-forge/noarch/jplephem-2.23-pyha4b2019_0.conda + sha256: 396678bcf99f925380e90b6ec4f0a8b3c6dc4c06a8e89ce777375ae44016f38e + md5: c778493b6112f330d4aa9569954119d3 + depends: + - numpy + - python >=3.9 + license: MIT + license_family: MIT + size: 40807 + timestamp: 1750675277409 +- conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.7.0-pyhcf101f3_0.conda + sha256: 6aa61417547b925de64905b7a4da7c98e0b355f48a7b21bdbef438f8950ee74e + md5: 1b0397a7b1fbffa031feb690b5fd0277 + depends: + - jupyter_core >=5.1 + - python >=3.10 + - python-dateutil >=2.8.2 + - pyzmq >=25.0 + - tornado >=6.4.1 + - traitlets >=5.3 + - python + license: BSD-3-Clause + license_family: BSD + size: 111367 + timestamp: 1765375773813 +- conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda + sha256: ed709a6c25b731e01563521ef338b93986cd14b5bc17f35e9382000864872ccc + md5: a8db462b01221e9f5135be466faeb3e0 + depends: + - __win + - pywin32 + - platformdirs >=2.5 + - python >=3.10 + - traitlets >=5.3 + - python + constrains: + - pywin32 >=300 + license: BSD-3-Clause + license_family: BSD + size: 64679 + timestamp: 1760643889625 +- conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda + sha256: 1d34b80e5bfcd5323f104dbf99a2aafc0e5d823019d626d0dce5d3d356a2a52a + md5: b38fe4e78ee75def7e599843ef4c1ab0 + depends: + - __unix + - python + - platformdirs >=2.5 + - python >=3.10 + - traitlets >=5.3 + - python + constrains: + - pywin32 >=300 + license: BSD-3-Clause + license_family: BSD + size: 65503 + timestamp: 1760643864586 +- conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_widgets-3.0.16-pyhcf101f3_1.conda + sha256: 5c03de243d7ae6247f39a402f4785d95e61c3be79ef18738e8f17155585d31a8 + md5: dbf8b81974504fa51d34e436ca7ef389 + depends: + - python >=3.10 + - python + constrains: + - jupyterlab >=3,<5 + license: BSD-3-Clause + license_family: BSD + size: 216779 + timestamp: 1762267481404 +- conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh534df25_0.conda + sha256: 9def5c6fb3b3b4952a4f6b55a019b5c7065b592682b84710229de5a0b73f6364 + md5: c88f9579d08eb4031159f03640714ce3 + depends: + - __osx + - importlib-metadata >=4.11.4 + - importlib_resources + - jaraco.classes + - jaraco.context + - jaraco.functools + - python >=3.10 + license: MIT + license_family: MIT + size: 37924 + timestamp: 1763320995459 +- conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh7428d3b_0.conda + sha256: ed76a29fd1dbaf1bb24058191386618315ab9e35da9ef9a76da232cd6885165b + md5: e91b0f2040c580527ccc54665aa7cdba + depends: + - __win + - importlib-metadata >=4.11.4 + - importlib_resources + - jaraco.classes + - jaraco.context + - jaraco.functools + - python >=3.10 + - pywin32-ctypes >=0.2.0 + license: MIT + license_family: MIT + size: 38153 + timestamp: 1763320939579 +- conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyha804496_0.conda + sha256: 010718b1b1a35ce72782d38e6d6b9495d8d7d0dbea9a3e42901d030ff2189545 + md5: 9eeb0eaf04fa934808d3e070eebbe630 + depends: + - __linux + - importlib-metadata >=4.11.4 + - importlib_resources + - jaraco.classes + - jaraco.context + - jaraco.functools + - jeepney >=0.4.2 + - python >=3.10 + - secretstorage >=3.2 + license: MIT + license_family: MIT + size: 37717 + timestamp: 1763320674488 +- conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda + sha256: 0960d06048a7185d3542d850986d807c6e37ca2e644342dd0c72feefcf26c2a4 + md5: b38117a3c920364aff79f870c984b4a3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-or-later + size: 134088 + timestamp: 1754905959823 +- conda: https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.4.9-py314h97ea11e_2.conda + sha256: a707d08c095d02148201f2da9fba465054fb750e33117e215892a4fefcc1b54a + md5: 57f1ce4f7ba6bcd460be8f83c8f04c69 + depends: + - python + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 78071 + timestamp: 1762488742381 +- conda: https://conda.anaconda.org/conda-forge/osx-64/kiwisolver-1.4.9-py314hf3ac25a_2.conda + sha256: a9d220022002611515de26be256a08abcf046bf8e66a7d95d22cdef0842b0f84 + md5: 28a77c52c425fa9c6d914c609c626b1a + depends: + - python + - libcxx >=19 + - __osx >=10.13 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 69742 + timestamp: 1762488879086 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/kiwisolver-1.4.9-py314h42813c9_2.conda + sha256: c4d7e6653d343e768110ec77ac1c6c89f313f77a19a1f2cd60b7c7b8b0758bdf + md5: 9aa431bf603c231e8c77a1b0842a85ed + depends: + - python + - python 3.14.* *_cp314 + - __osx >=11.0 + - libcxx >=19 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 68534 + timestamp: 1762489024029 +- conda: https://conda.anaconda.org/conda-forge/win-64/kiwisolver-1.4.9-py314hf309875_2.conda + sha256: ded907ab1ce24abcff20bc239e770ae7ef4cff6fdcfb8cc24ca59ebe736a1d3f + md5: e9d93271b021332f5492ff5478601614 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 73670 + timestamp: 1762488752873 +- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda + sha256: 99df692f7a8a5c27cd14b5fb1374ee55e756631b9c3d659ed3ee60830249b238 + md5: 3f43953b7d3fb3aaa1d0d0723d91e368 + depends: + - keyutils >=1.6.1,<2.0a0 + - libedit >=3.1.20191231,<3.2.0a0 + - libedit >=3.1.20191231,<4.0a0 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + - openssl >=3.3.1,<4.0a0 + license: MIT + license_family: MIT + size: 1370023 + timestamp: 1719463201255 +- conda: https://conda.anaconda.org/conda-forge/osx-64/krb5-1.21.3-h37d8d59_0.conda + sha256: 83b52685a4ce542772f0892a0f05764ac69d57187975579a0835ff255ae3ef9c + md5: d4765c524b1d91567886bde656fb514b + depends: + - __osx >=10.13 + - libcxx >=16 + - libedit >=3.1.20191231,<3.2.0a0 + - libedit >=3.1.20191231,<4.0a0 + - openssl >=3.3.1,<4.0a0 + license: MIT + license_family: MIT + size: 1185323 + timestamp: 1719463492984 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.21.3-h237132a_0.conda + sha256: 4442f957c3c77d69d9da3521268cad5d54c9033f1a73f99cde0a3658937b159b + md5: c6dc8a0fdec13a0565936655c33069a1 + depends: + - __osx >=11.0 + - libcxx >=16 + - libedit >=3.1.20191231,<3.2.0a0 + - libedit >=3.1.20191231,<4.0a0 + - openssl >=3.3.1,<4.0a0 + license: MIT + license_family: MIT + size: 1155530 + timestamp: 1719463474401 +- conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.21.3-hdf4eb48_0.conda + sha256: 18e8b3430d7d232dad132f574268f56b3eb1a19431d6d5de8c53c29e6c18fa81 + md5: 31aec030344e962fbd7dbbbbd68e60a9 + depends: + - openssl >=3.3.1,<4.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: MIT + license_family: MIT + size: 712034 + timestamp: 1719463874284 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.17-h717163a_0.conda + sha256: d6a61830a354da022eae93fa896d0991385a875c6bba53c82263a289deda9db8 + md5: 000e85703f0fd9594c81710dd5066471 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libjpeg-turbo >=3.0.0,<4.0a0 + - libtiff >=4.7.0,<4.8.0a0 + license: MIT + license_family: MIT + size: 248046 + timestamp: 1739160907615 +- conda: https://conda.anaconda.org/conda-forge/osx-64/lcms2-2.17-h72f5680_0.conda + sha256: bcb81543e49ff23e18dea79ef322ab44b8189fb11141b1af99d058503233a5fc + md5: bf210d0c63f2afb9e414a858b79f0eaa + depends: + - __osx >=10.13 + - libjpeg-turbo >=3.0.0,<4.0a0 + - libtiff >=4.7.0,<4.8.0a0 + license: MIT + license_family: MIT + size: 226001 + timestamp: 1739161050843 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.17-h7eeda09_0.conda + sha256: 310a62c2f074ebd5aa43b3cd4b00d46385ce680fa2132ecee255a200e2d2f15f + md5: 92a61fd30b19ebd5c1621a5bfe6d8b5f + depends: + - __osx >=11.0 + - libjpeg-turbo >=3.0.0,<4.0a0 + - libtiff >=4.7.0,<4.8.0a0 + license: MIT + license_family: MIT + size: 212125 + timestamp: 1739161108467 +- conda: https://conda.anaconda.org/conda-forge/win-64/lcms2-2.17-hbcf6048_0.conda + sha256: 7712eab5f1a35ca3ea6db48ead49e0d6ac7f96f8560da8023e61b3dbe4f3b25d + md5: 3538827f77b82a837fa681a4579e37a1 + depends: + - libjpeg-turbo >=3.0.0,<4.0a0 + - libtiff >=4.7.0,<4.8.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: MIT + license_family: MIT + size: 510641 + timestamp: 1739161381270 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_104.conda + sha256: 9e191baf2426a19507f1d0a17be0fdb7aa155cdf0f61d5a09c808e0a69464312 + md5: a6abd2796fc332536735f68ba23f7901 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45 + license: GPL-3.0-only + license_family: GPL + size: 725545 + timestamp: 1764007826689 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda + sha256: 412381a43d5ff9bbed82cd52a0bbca5b90623f62e41007c9c42d3870c60945ff + md5: 9344155d33912347b37f0ae6c410a835 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: Apache-2.0 + license_family: Apache + size: 264243 + timestamp: 1745264221534 +- conda: https://conda.anaconda.org/conda-forge/osx-64/lerc-4.0.0-hcca01a6_1.conda + sha256: cc1f1d7c30aa29da4474ec84026ec1032a8df1d7ec93f4af3b98bb793d01184e + md5: 21f765ced1a0ef4070df53cb425e1967 + depends: + - __osx >=10.13 + - libcxx >=18 + license: Apache-2.0 + license_family: Apache + size: 248882 + timestamp: 1745264331196 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.0.0-hd64df32_1.conda + sha256: 12361697f8ffc9968907d1a7b5830e34c670e4a59b638117a2cdfed8f63a38f8 + md5: a74332d9b60b62905e3d30709df08bf1 + depends: + - __osx >=11.0 + - libcxx >=18 + license: Apache-2.0 + license_family: Apache + size: 188306 + timestamp: 1745264362794 +- conda: https://conda.anaconda.org/conda-forge/win-64/lerc-4.0.0-h6470a55_1.conda + sha256: 868a3dff758cc676fa1286d3f36c3e0101cca56730f7be531ab84dc91ec58e9d + md5: c1b81da6d29a14b542da14a36c9fbf3f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: Apache-2.0 + license_family: Apache + size: 164701 + timestamp: 1745264384716 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda + sha256: dcd1429a1782864c452057a6c5bc1860f2b637dc20a2b7e6eacd57395bbceff8 + md5: 83b160d4da3e1e847bf044997621ed63 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + constrains: + - libabseil-static =20250512.1=cxx17* + - abseil-cpp =20250512.1 + license: Apache-2.0 + license_family: Apache + size: 1310612 + timestamp: 1750194198254 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libabseil-20250512.1-cxx17_hfc00f1c_0.conda + sha256: a878efebf62f039a1f1733c1e150a75a99c7029ece24e34efdf23d56256585b1 + md5: ddf1acaed2276c7eb9d3c76b49699a11 + depends: + - __osx >=10.13 + - libcxx >=18 + constrains: + - abseil-cpp =20250512.1 + - libabseil-static =20250512.1=cxx17* + license: Apache-2.0 + license_family: Apache + size: 1162435 + timestamp: 1750194293086 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20250512.1-cxx17_hd41c47c_0.conda + sha256: 7f0ee9ae7fa2cf7ac92b0acf8047c8bac965389e48be61bf1d463e057af2ea6a + md5: 360dbb413ee2c170a0a684a33c4fc6b8 + depends: + - __osx >=11.0 + - libcxx >=18 + constrains: + - libabseil-static =20250512.1=cxx17* + - abseil-cpp =20250512.1 + license: Apache-2.0 + license_family: Apache + size: 1174081 + timestamp: 1750194620012 +- conda: https://conda.anaconda.org/conda-forge/win-64/libabseil-20250512.1-cxx17_habfad5f_0.conda + sha256: 78790771f44e146396d9ae92efbe1022168295afd8d174f653a1fa16f0f0fa32 + md5: d6a4cd236fc1c69a1cfc9698fb5e391f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.42.34438 + constrains: + - libabseil-static =20250512.1=cxx17* + - abseil-cpp =20250512.1 + license: Apache-2.0 + license_family: Apache + size: 1615210 + timestamp: 1750194549591 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.4-h3f801dc_0.conda + sha256: 410ab78fe89bc869d435de04c9ffa189598ac15bb0fe1ea8ace8fb1b860a2aa3 + md5: 01ba04e414e47f95c03d6ddd81fd37be + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: BSD-2-Clause + license_family: BSD + size: 36825 + timestamp: 1749993532943 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libaec-1.1.4-ha6bc127_0.conda + sha256: f4fe00ef0df58b670696c62f2ec3f6484431acbf366ecfbcb71141c81439e331 + md5: 1a768b826dfc68e07786788d98babfc3 + depends: + - __osx >=10.13 + - libcxx >=18 + license: BSD-2-Clause + license_family: BSD + size: 30034 + timestamp: 1749993664561 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libaec-1.1.4-h51d1e36_0.conda + sha256: 0ea6b73b3fb1511615d9648186a7409e73b7a8d9b3d890d39df797730e3d1dbb + md5: 8ed0f86b7a5529b98ec73b43a53ce800 + depends: + - __osx >=11.0 + - libcxx >=18 + license: BSD-2-Clause + license_family: BSD + size: 30173 + timestamp: 1749993648288 +- conda: https://conda.anaconda.org/conda-forge/win-64/libaec-1.1.4-h20038f6_0.conda + sha256: 0be89085effce9fdcbb6aea7acdb157b18793162f68266ee0a75acf615d4929b + md5: 85a2bed45827d77d5b308cb2b165404f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: BSD-2-Clause + license_family: BSD + size: 33847 + timestamp: 1749993666162 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-22.0.0-hb6ed5f4_6_cpu.conda + build_number: 6 + sha256: bab5fcb86cf28a3de65127fbe61ed9194affc1cf2d9b60a9e09af8a8b96b93e3 + md5: fbaa3742ccca0f7096216c0832137b72 + depends: + - __glibc >=2.17,<3.0.a0 + - aws-crt-cpp >=0.35.4,<0.35.5.0a0 + - aws-sdk-cpp >=1.11.606,<1.11.607.0a0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - azure-identity-cpp >=1.13.2,<1.13.3.0a0 + - azure-storage-blobs-cpp >=12.15.0,<12.15.1.0a0 + - azure-storage-files-datalake-cpp >=12.13.0,<12.13.1.0a0 + - bzip2 >=1.0.8,<2.0a0 + - glog >=0.7.1,<0.8.0a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libgcc >=14 + - libgoogle-cloud >=2.39.0,<2.40.0a0 + - libgoogle-cloud-storage >=2.39.0,<2.40.0a0 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - orc >=2.2.1,<2.2.2.0a0 + - snappy >=1.2.2,<1.3.0a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - arrow-cpp <0.0a0 + - apache-arrow-proc =*=cpu + - parquet-cpp <0.0a0 + license: Apache-2.0 + license_family: APACHE + size: 6324546 + timestamp: 1765381265473 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-22.0.0-hd1700fa_4_cpu.conda + build_number: 4 + sha256: 82d764b803ed198123c77ec954770deec0e477e003d3d906eb8eda5260f88e24 + md5: 9c95de09ac58d37d8cfbaa54b7174ee5 + depends: + - __osx >=11.0 + - aws-crt-cpp >=0.35.2,<0.35.3.0a0 + - aws-sdk-cpp >=1.11.606,<1.11.607.0a0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - azure-identity-cpp >=1.13.2,<1.13.3.0a0 + - azure-storage-blobs-cpp >=12.15.0,<12.15.1.0a0 + - azure-storage-files-datalake-cpp >=12.13.0,<12.13.1.0a0 + - bzip2 >=1.0.8,<2.0a0 + - glog >=0.7.1,<0.8.0a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libcxx >=19 + - libgoogle-cloud >=2.39.0,<2.40.0a0 + - libgoogle-cloud-storage >=2.39.0,<2.40.0a0 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - orc >=2.2.1,<2.2.2.0a0 + - snappy >=1.2.2,<1.3.0a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - apache-arrow-proc =*=cpu + - parquet-cpp <0.0a0 + - arrow-cpp <0.0a0 + license: Apache-2.0 + license_family: APACHE + size: 4266919 + timestamp: 1763229988804 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-22.0.0-he6e817a_6_cpu.conda + build_number: 6 + sha256: 77d82f2d6787ec0300da0ad683d30eccc71723665c5dc4e7c6e4ca9b7955f599 + md5: b972d880c503c30ee178489ec76bbd6d + depends: + - __osx >=11.0 + - aws-crt-cpp >=0.35.4,<0.35.5.0a0 + - aws-sdk-cpp >=1.11.606,<1.11.607.0a0 + - azure-core-cpp >=1.16.1,<1.16.2.0a0 + - azure-identity-cpp >=1.13.2,<1.13.3.0a0 + - azure-storage-blobs-cpp >=12.15.0,<12.15.1.0a0 + - azure-storage-files-datalake-cpp >=12.13.0,<12.13.1.0a0 + - bzip2 >=1.0.8,<2.0a0 + - glog >=0.7.1,<0.8.0a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libcxx >=19 + - libgoogle-cloud >=2.39.0,<2.40.0a0 + - libgoogle-cloud-storage >=2.39.0,<2.40.0a0 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - orc >=2.2.1,<2.2.2.0a0 + - snappy >=1.2.2,<1.3.0a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - parquet-cpp <0.0a0 + - arrow-cpp <0.0a0 + - apache-arrow-proc =*=cpu + license: Apache-2.0 + license_family: APACHE + size: 4160249 + timestamp: 1765382560379 +- conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-22.0.0-h89d7da9_6_cpu.conda + build_number: 6 + sha256: 5469cd02381c6760893fc2bcfda9cfb7a2c248527132964d36740e5789648133 + md5: e9fe1ee5e997417347e1ee312af94092 + depends: + - aws-crt-cpp >=0.35.4,<0.35.5.0a0 + - aws-sdk-cpp >=1.11.606,<1.11.607.0a0 + - bzip2 >=1.0.8,<2.0a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libcrc32c >=1.1.2,<1.2.0a0 + - libcurl >=8.17.0,<9.0a0 + - libgoogle-cloud >=2.39.0,<2.40.0a0 + - libgoogle-cloud-storage >=2.39.0,<2.40.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - orc >=2.2.1,<2.2.2.0a0 + - snappy >=1.2.2,<1.3.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - parquet-cpp <0.0a0 + - apache-arrow-proc =*=cpu + - arrow-cpp <0.0a0 + license: Apache-2.0 + license_family: APACHE + size: 3965279 + timestamp: 1765381971425 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-acero-22.0.0-h635bf11_6_cpu.conda + build_number: 6 + sha256: b7e013502eb6dbb59bf58c34b83ed4e7bbcc32ee37600016d862f0bb21a6dc5a + md5: 5a8f878ca313083960ab819a009848b3 + depends: + - __glibc >=2.17,<3.0.a0 + - libarrow 22.0.0 hb6ed5f4_6_cpu + - libarrow-compute 22.0.0 h8c2c5c3_6_cpu + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + size: 585860 + timestamp: 1765381484672 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-acero-22.0.0-h2db2d7d_4_cpu.conda + build_number: 4 + sha256: ebc47c938c7e3af8af35cb6ad92a2ff4fcaba3776bf3f0dc8690e3c168035b0b + md5: f9e754e716ed279c88f25d17fc6b5764 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 hd1700fa_4_cpu + - libarrow-compute 22.0.0 h7751554_4_cpu + - libcxx >=19 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + license: Apache-2.0 + license_family: APACHE + size: 551790 + timestamp: 1763230587607 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-acero-22.0.0-hc317990_6_cpu.conda + build_number: 6 + sha256: 3250653194b95fc30785f7fc394381318ecc3afb500884967b6d736349b135fe + md5: f17f28aba732a290919eecdec17677d9 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 he6e817a_6_cpu + - libarrow-compute 22.0.0 h75845d1_6_cpu + - libcxx >=19 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + license: Apache-2.0 + license_family: APACHE + size: 523683 + timestamp: 1765383066107 +- conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-acero-22.0.0-h7d8d6a5_6_cpu.conda + build_number: 6 + sha256: bea322b50e5db84ba1de28a70e0da9ebb44a8d525a0ffb5facc2fa0b8332c3e5 + md5: bbef682dd3d8f686faad9f1a94b3d9ae + depends: + - libarrow 22.0.0 h89d7da9_6_cpu + - libarrow-compute 22.0.0 h2db994a_6_cpu + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: APACHE + size: 451321 + timestamp: 1765382291986 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-compute-22.0.0-h8c2c5c3_6_cpu.conda + build_number: 6 + sha256: 0cd08dd11263105e2bf45514e08f8e4a59fac41a80a82f17540e047242835872 + md5: d2cd924b5f451a7c258001cb1c14155d + depends: + - __glibc >=2.17,<3.0.a0 + - libarrow 22.0.0 hb6ed5f4_6_cpu + - libgcc >=14 + - libre2-11 >=2025.8.12 + - libstdcxx >=14 + - libutf8proc >=2.11.2,<2.12.0a0 + - re2 + license: Apache-2.0 + license_family: APACHE + size: 2973397 + timestamp: 1765381343806 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-compute-22.0.0-h7751554_4_cpu.conda + build_number: 4 + sha256: 0a27101d20f8e47dea60fb489d8904472e92da913660605d61f318352ac79163 + md5: 673d1c37cbbf9a99235337cbb6969dff + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 hd1700fa_4_cpu + - libcxx >=19 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libre2-11 >=2025.8.12 + - libutf8proc >=2.11.0,<2.12.0a0 + - re2 + license: Apache-2.0 + license_family: APACHE + size: 2394943 + timestamp: 1763230184019 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-compute-22.0.0-h75845d1_6_cpu.conda + build_number: 6 + sha256: 053d096e77464ea8da7c35ab167864bacac3590af304aa3368d09aba8cdf8af8 + md5: 51b139c330f194379c4271c91c9cd1c7 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 he6e817a_6_cpu + - libcxx >=19 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libre2-11 >=2025.8.12 + - libutf8proc >=2.11.2,<2.12.0a0 + - re2 + license: Apache-2.0 + license_family: APACHE + size: 2155806 + timestamp: 1765382724366 +- conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-compute-22.0.0-h2db994a_6_cpu.conda + build_number: 6 + sha256: f26d1d4752f847c11ed3202b1314b1729a52f1468b17dfd3174885db7e3e2dfe + md5: 922c36699625c3f49940337feeba8291 + depends: + - libarrow 22.0.0 h89d7da9_6_cpu + - libre2-11 >=2025.8.12 + - libutf8proc >=2.11.2,<2.12.0a0 + - re2 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: APACHE + size: 1685242 + timestamp: 1765382093115 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-dataset-22.0.0-h635bf11_6_cpu.conda + build_number: 6 + sha256: d0321d8d82ccc55557ccb3119174179de3f282df68a6efe60f9c523bbf242a1f + md5: 579bdb829ab093d048e49a289d3c9883 + depends: + - __glibc >=2.17,<3.0.a0 + - libarrow 22.0.0 hb6ed5f4_6_cpu + - libarrow-acero 22.0.0 h635bf11_6_cpu + - libarrow-compute 22.0.0 h8c2c5c3_6_cpu + - libgcc >=14 + - libparquet 22.0.0 h7376487_6_cpu + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + size: 584952 + timestamp: 1765381575560 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-dataset-22.0.0-h2db2d7d_4_cpu.conda + build_number: 4 + sha256: 31c410ca02fb477d8c19792ebb6b5f50144e510ed50a41be268fba6cca6a3417 + md5: 64d5722c982b89dabf848feb7edf97f9 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 hd1700fa_4_cpu + - libarrow-acero 22.0.0 h2db2d7d_4_cpu + - libarrow-compute 22.0.0 h7751554_4_cpu + - libcxx >=19 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libparquet 22.0.0 habb56ca_4_cpu + - libprotobuf >=6.31.1,<6.31.2.0a0 + license: Apache-2.0 + license_family: APACHE + size: 533092 + timestamp: 1763230993273 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-dataset-22.0.0-hc317990_6_cpu.conda + build_number: 6 + sha256: ab07545a7f99cb8026b3bfe0f7f2c33d3204972fe1d5eb011adf2eb002277989 + md5: cf0d62de81a3a2b7afb723b4b629879a + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 he6e817a_6_cpu + - libarrow-acero 22.0.0 hc317990_6_cpu + - libarrow-compute 22.0.0 h75845d1_6_cpu + - libcxx >=19 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libparquet 22.0.0 h0ac143b_6_cpu + - libprotobuf >=6.31.1,<6.31.2.0a0 + license: Apache-2.0 + license_family: APACHE + size: 520397 + timestamp: 1765383321028 +- conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-dataset-22.0.0-h7d8d6a5_6_cpu.conda + build_number: 6 + sha256: 147e9f2092443bf4facda44323097d8a494b4930c2865996aa54e2d19a454d93 + md5: 974630001cbf61d4d94a7c7c142eade4 + depends: + - libarrow 22.0.0 h89d7da9_6_cpu + - libarrow-acero 22.0.0 h7d8d6a5_6_cpu + - libarrow-compute 22.0.0 h2db994a_6_cpu + - libparquet 22.0.0 h7051d1f_6_cpu + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: APACHE + size: 435881 + timestamp: 1765382430115 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libarrow-substrait-22.0.0-h3f74fd7_6_cpu.conda + build_number: 6 + sha256: a343378e20aaa27e955c1f84394f00668458b69f6eaf7efcf4b21a3f8f10e02a + md5: cfc7d2c5a81eb6de3100661a69de5f3d + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 hb6ed5f4_6_cpu + - libarrow-acero 22.0.0 h635bf11_6_cpu + - libarrow-dataset 22.0.0 h635bf11_6_cpu + - libgcc >=14 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + size: 487167 + timestamp: 1765381605708 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libarrow-substrait-22.0.0-h4653b8a_4_cpu.conda + build_number: 4 + sha256: ecc37e1e0faa308f7bed2e346a1b3aa2bae7155f6df2312f11ad25fe30731bd4 + md5: c2f7a8fec7db2ce12d5aaabeed2cedea + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 hd1700fa_4_cpu + - libarrow-acero 22.0.0 h2db2d7d_4_cpu + - libarrow-dataset 22.0.0 h2db2d7d_4_cpu + - libcxx >=19 + - libprotobuf >=6.31.1,<6.31.2.0a0 + license: Apache-2.0 + license_family: APACHE + size: 447573 + timestamp: 1763231087749 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarrow-substrait-22.0.0-h144af7f_6_cpu.conda + build_number: 6 + sha256: f2181c286af7d0d4cf381976f100daf1ac84b9661975130adce4ce7a03025696 + md5: 58a5b39bc7d23fa938affe1bfc43c241 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 he6e817a_6_cpu + - libarrow-acero 22.0.0 hc317990_6_cpu + - libarrow-dataset 22.0.0 hc317990_6_cpu + - libcxx >=19 + - libprotobuf >=6.31.1,<6.31.2.0a0 + license: Apache-2.0 + license_family: APACHE + size: 458819 + timestamp: 1765383438751 +- conda: https://conda.anaconda.org/conda-forge/win-64/libarrow-substrait-22.0.0-hf865cc0_6_cpu.conda + build_number: 6 + sha256: 393a9bedc2424ea2335364de0be0de69f6dbcc456c893b70a9776975acd749d0 + md5: 01d0606bf4202d358a71545759223202 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 h89d7da9_6_cpu + - libarrow-acero 22.0.0 h7d8d6a5_6_cpu + - libarrow-dataset 22.0.0 h7d8d6a5_6_cpu + - libprotobuf >=6.31.1,<6.31.2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: APACHE + size: 364040 + timestamp: 1765382475732 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-4_h4a7cf45_openblas.conda + build_number: 4 + sha256: f35fee1eb3fe1a80b2c8473f145a830cf6f98c3b15b232b256b93d44bd9c93b3 + md5: 14ff9fdfbd8bd590fca383b995470711 + depends: + - libopenblas >=0.3.30,<0.3.31.0a0 + - libopenblas >=0.3.30,<1.0a0 + constrains: + - liblapack 3.11.0 4*_openblas + - blas 2.304 openblas + - mkl <2026 + - libcblas 3.11.0 4*_openblas + - liblapacke 3.11.0 4*_openblas + license: BSD-3-Clause + license_family: BSD + size: 18529 + timestamp: 1764823833499 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libblas-3.11.0-4_he492b99_openblas.conda + build_number: 4 + sha256: 293e5290eee6d9be5a817ba4e1830ba18b04be9d619c2bdffeacf8ba3b0bef8d + md5: fa78d175db3b07d8eb963558e1bd9228 + depends: + - libopenblas >=0.3.30,<0.3.31.0a0 + - libopenblas >=0.3.30,<1.0a0 + constrains: + - mkl <2026 + - liblapack 3.11.0 4*_openblas + - libcblas 3.11.0 4*_openblas + - liblapacke 3.11.0 4*_openblas + - blas 2.304 openblas + license: BSD-3-Clause + license_family: BSD + size: 18702 + timestamp: 1764824607451 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-4_h51639a9_openblas.conda + build_number: 4 + sha256: db31cdcd24b9f4be562c37a780d6a665f5eddc88a97d59997e293d91c522ffc1 + md5: f5c7d8c3256cd95d5ec31afc24c9dd30 + depends: + - libopenblas >=0.3.30,<0.3.31.0a0 + - libopenblas >=0.3.30,<1.0a0 + constrains: + - libcblas 3.11.0 4*_openblas + - blas 2.304 openblas + - liblapack 3.11.0 4*_openblas + - liblapacke 3.11.0 4*_openblas + - mkl <2026 + license: BSD-3-Clause + license_family: BSD + size: 18767 + timestamp: 1764824430403 +- conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-4_hf2e6a31_mkl.conda + build_number: 4 + sha256: 0c6ecdabcd3c5b92c7be68a65c30c29983040dd81f502d2e9ad3763fdbbabdef + md5: 97ec87aab53fb310e6c19cde2eec1de2 + depends: + - mkl >=2025.3.0,<2026.0a0 + constrains: + - liblapacke 3.11.0 4*_mkl + - libcblas 3.11.0 4*_mkl + - liblapack 3.11.0 4*_mkl + - blas 2.304 mkl + license: BSD-3-Clause + license_family: BSD + size: 67784 + timestamp: 1764824188313 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + sha256: 318f36bd49ca8ad85e6478bd8506c88d82454cc008c1ac1c6bf00a3c42fa610e + md5: 72c8fd1af66bd67bf580645b426513ed + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 79965 + timestamp: 1764017188531 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlicommon-1.2.0-h8616949_1.conda + sha256: 4c19b211b3095f541426d5a9abac63e96a5045e509b3d11d4f9482de53efe43b + md5: f157c098841474579569c85a60ece586 + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 78854 + timestamp: 1764017554982 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + sha256: a7cb9e660531cf6fbd4148cff608c85738d0b76f0975c5fc3e7d5e92840b7229 + md5: 006e7ddd8a110771134fcc4e1e3a6ffa + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 79443 + timestamp: 1764017945924 +- conda: https://conda.anaconda.org/conda-forge/win-64/libbrotlicommon-1.2.0-hfd05255_1.conda + sha256: 5097303c2fc8ebf9f9ea9731520aa5ce4847d0be41764edd7f6dee2100b82986 + md5: 444b0a45bbd1cb24f82eedb56721b9c4 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 82042 + timestamp: 1764017799966 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + sha256: 12fff21d38f98bc446d82baa890e01fd82e3b750378fedc720ff93522ffb752b + md5: 366b40a69f0ad6072561c1d09301c886 + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + size: 34632 + timestamp: 1764017199083 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlidec-1.2.0-h8616949_1.conda + sha256: 729158be90ae655a4e0427fe4079767734af1f9b69ff58cf94ca6e8d4b3eb4b7 + md5: 63186ac7a8a24b3528b4b14f21c03f54 + depends: + - __osx >=10.13 + - libbrotlicommon 1.2.0 h8616949_1 + license: MIT + license_family: MIT + size: 30835 + timestamp: 1764017584474 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + sha256: 2eae444039826db0454b19b52a3390f63bfe24f6b3e63089778dd5a5bf48b6bf + md5: 079e88933963f3f149054eec2c487bc2 + depends: + - __osx >=11.0 + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + size: 29452 + timestamp: 1764017979099 +- conda: https://conda.anaconda.org/conda-forge/win-64/libbrotlidec-1.2.0-hfd05255_1.conda + sha256: 3239ce545cf1c32af6fffb7fc7c75cb1ef5b6ea8221c66c85416bb2d46f5cccb + md5: 450e3ae947fc46b60f1d8f8f318b40d4 + depends: + - libbrotlicommon 1.2.0 hfd05255_1 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 34449 + timestamp: 1764017851337 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + sha256: a0c15c79997820bbd3fbc8ecf146f4fe0eca36cc60b62b63ac6cf78857f1dd0d + md5: 4ffbb341c8b616aa2494b6afb26a0c5f + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + size: 298378 + timestamp: 1764017210931 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libbrotlienc-1.2.0-h8616949_1.conda + sha256: 8ece7b41b6548d6601ac2c2cd605cf2261268fc4443227cc284477ed23fbd401 + md5: 12a58fd3fc285ce20cf20edf21a0ff8f + depends: + - __osx >=10.13 + - libbrotlicommon 1.2.0 h8616949_1 + license: MIT + license_family: MIT + size: 310355 + timestamp: 1764017609985 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + sha256: 01436c32bb41f9cb4bcf07dda647ce4e5deb8307abfc3abdc8da5317db8189d1 + md5: b2b7c8288ca1a2d71ff97a8e6a1e8883 + depends: + - __osx >=11.0 + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + size: 290754 + timestamp: 1764018009077 +- conda: https://conda.anaconda.org/conda-forge/win-64/libbrotlienc-1.2.0-hfd05255_1.conda + sha256: 3226df6b7df98734440739f75527d585d42ca2bfe912fbe8d1954c512f75341a + md5: ccd93cfa8e54fd9df4e83dbe55ff6e8c + depends: + - libbrotlicommon 1.2.0 hfd05255_1 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 252903 + timestamp: 1764017901735 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-4_h0358290_openblas.conda + build_number: 4 + sha256: 7abc88e2fdccddab27d2a889b9c9063df84a05766cc24828c9b5ca879f25c92c + md5: 25f5e5af61cee1ffedd9b4c9947d3af8 + depends: + - libblas 3.11.0 4_h4a7cf45_openblas + constrains: + - liblapack 3.11.0 4*_openblas + - blas 2.304 openblas + - liblapacke 3.11.0 4*_openblas + license: BSD-3-Clause + license_family: BSD + size: 18521 + timestamp: 1764823852735 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libcblas-3.11.0-4_h9b27e0a_openblas.conda + build_number: 4 + sha256: 2412cc96eda9455cdddc6221b023df738f4daef269007379d06cfe79cfd065be + md5: 4ebb29d020eb3c2c8ac9674d8cfa4a31 + depends: + - libblas 3.11.0 4_he492b99_openblas + constrains: + - liblapacke 3.11.0 4*_openblas + - liblapack 3.11.0 4*_openblas + - blas 2.304 openblas + license: BSD-3-Clause + license_family: BSD + size: 18690 + timestamp: 1764824633990 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-4_hb0561ab_openblas.conda + build_number: 4 + sha256: fd57f4c8863ac78f42c55ee68351c963fe14fb3d46575c6f236082076690dd0f + md5: be77be52a6f01b46b1eb9aa5270023cc + depends: + - libblas 3.11.0 4_h51639a9_openblas + constrains: + - liblapack 3.11.0 4*_openblas + - blas 2.304 openblas + - liblapacke 3.11.0 4*_openblas + license: BSD-3-Clause + license_family: BSD + size: 18722 + timestamp: 1764824449333 +- conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-4_h2a3cdd5_mkl.conda + build_number: 4 + sha256: 4cd0f2ec9823995a74b73c0119201dcf9a28444bdc2f0a824dfa938b5bdd5601 + md5: 64410b46ecf6fdfd19eb1d124d9eb450 + depends: + - libblas 3.11.0 4_hf2e6a31_mkl + constrains: + - liblapacke 3.11.0 4*_mkl + - liblapack 3.11.0 4*_mkl + - blas 2.304 mkl + license: BSD-3-Clause + license_family: BSD + size: 68001 + timestamp: 1764824219221 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.7-default_h99862b1_1.conda + sha256: ce8b8464b1230dd93d2b5a2646d2c80639774c9e781097f041581c07b83d4795 + md5: d3042ebdaacc689fd1daa701885fc96c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libllvm21 >=21.1.7,<21.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 21055642 + timestamp: 1764816319608 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.7-default_h746c552_1.conda + sha256: a9bcd5fc463ddf088077eceaf314d560af347d10c4d92ca3177fa313a79a6e46 + md5: 66508e5f84c3dc9af1a0a62694325ef2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libllvm21 >=21.1.7,<21.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 12347100 + timestamp: 1764816644936 +- conda: https://conda.anaconda.org/conda-forge/win-64/libclang13-21.1.7-default_ha2db4b5_1.conda + sha256: 9153b722591aac572b2384daac7f5071d59b746239e6d5b74b06844e49339ec7 + md5: 065bcc5d1a29de06d4566b7b9ac89882 + depends: + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 28995533 + timestamp: 1764820055107 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcrc32c-1.1.2-h9c3ff4c_0.tar.bz2 + sha256: fd1d153962764433fe6233f34a72cdeed5dcf8a883a85769e8295ce940b5b0c5 + md5: c965a5aa0d5c1c37ffc62dff36e28400 + depends: + - libgcc-ng >=9.4.0 + - libstdcxx-ng >=9.4.0 + license: BSD-3-Clause + license_family: BSD + size: 20440 + timestamp: 1633683576494 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libcrc32c-1.1.2-he49afe7_0.tar.bz2 + sha256: 3043869ac1ee84554f177695e92f2f3c2c507b260edad38a0bf3981fce1632ff + md5: 23d6d5a69918a438355d7cbc4c3d54c9 + depends: + - libcxx >=11.1.0 + license: BSD-3-Clause + license_family: BSD + size: 20128 + timestamp: 1633683906221 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcrc32c-1.1.2-hbdafb3b_0.tar.bz2 + sha256: 58477b67cc719060b5b069ba57161e20ba69b8695d154a719cb4b60caf577929 + md5: 32bd82a6a625ea6ce090a81c3d34edeb + depends: + - libcxx >=11.1.0 + license: BSD-3-Clause + license_family: BSD + size: 18765 + timestamp: 1633683992603 +- conda: https://conda.anaconda.org/conda-forge/win-64/libcrc32c-1.1.2-h0e60522_0.tar.bz2 + sha256: 75e60fbe436ba8a11c170c89af5213e8bec0418f88b7771ab7e3d9710b70c54e + md5: cd4cc2d0c610c8cb5419ccc979f2d6ce + depends: + - vc >=14.1,<15.0a0 + - vs2015_runtime >=14.16.27012 + license: BSD-3-Clause + license_family: BSD + size: 25694 + timestamp: 1633684287072 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda + sha256: cb83980c57e311783ee831832eb2c20ecb41e7dee6e86e8b70b8cef0e43eab55 + md5: d4a250da4737ee127fb1fa6452a9002e + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.21.3,<1.22.0a0 + - libgcc >=13 + - libstdcxx >=13 + - libzlib >=1.3.1,<2.0a0 + license: Apache-2.0 + license_family: Apache + size: 4523621 + timestamp: 1749905341688 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.17.0-h4e3cde8_1.conda + sha256: 2d7be2fe0f58a0945692abee7bb909f8b19284b518d958747e5ff51d0655c303 + md5: 117499f93e892ea1e57fdca16c2e8351 + depends: + - __glibc >=2.17,<3.0.a0 + - krb5 >=1.21.3,<1.22.0a0 + - libgcc >=14 + - libnghttp2 >=1.67.0,<2.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + size: 459417 + timestamp: 1765379027010 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libcurl-8.17.0-h7dd4100_1.conda + sha256: 80c7c8ff76eb699ec8d096dce80642b527fd8fc9dd72779bccec8d140c5b997a + md5: 9ddfaeed0eafce233ae8f4a430816aa5 + depends: + - __osx >=10.13 + - krb5 >=1.21.3,<1.22.0a0 + - libnghttp2 >=1.67.0,<2.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + size: 413119 + timestamp: 1765379670120 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.17.0-hdece5d2_1.conda + sha256: 1a8a958448610ca3f8facddfe261fdbb010e7029a1571b84052ec9770fc0a36e + md5: 1d6e791c6e264ae139d469ce011aab51 + depends: + - __osx >=11.0 + - krb5 >=1.21.3,<1.22.0a0 + - libnghttp2 >=1.67.0,<2.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + size: 394471 + timestamp: 1765379821294 +- conda: https://conda.anaconda.org/conda-forge/win-64/libcurl-8.17.0-h43ecb02_1.conda + sha256: 5ebab5c980c09d31b35a25095b295124d89fd8bdffdb3487604218ad56512885 + md5: c02248f96a0073904bb085a437143895 + depends: + - krb5 >=1.21.3,<1.22.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: curl + license_family: MIT + size: 379189 + timestamp: 1765379273605 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.7-h3d58e20_0.conda + sha256: 0ac1b1d1072a14fe8fd3a871c8ca0b411f0fdf30de70e5c95365a149bd923ac8 + md5: 67c086bf0efc67b54a235dd9184bd7a2 + depends: + - __osx >=10.13 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 571564 + timestamp: 1764676139160 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.7-hf598326_0.conda + sha256: 4bdbef0241b52e7a8552e8af7425f0b56d5621dd69df46c816546fefa17d77ab + md5: 0de94f39727c31c0447e408c5a210a56 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 568715 + timestamp: 1764676451068 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda + sha256: aa8e8c4be9a2e81610ddf574e05b64ee131fab5e0e3693210c9d6d2fba32c680 + md5: 6c77a605a7a689d17d4819c0f8ac9a00 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 73490 + timestamp: 1761979956660 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libdeflate-1.25-h517ebb2_0.conda + sha256: 025f8b1e85dd8254e0ca65f011919fb1753070eb507f03bca317871a884d24de + md5: 31aa65919a729dc48180893f62c25221 + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 70840 + timestamp: 1761980008502 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda + sha256: 5e0b6961be3304a5f027a8c00bd0967fc46ae162cffb7553ff45c70f51b8314c + md5: a6130c709305cd9828b4e1bd9ba0000c + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 55420 + timestamp: 1761980066242 +- conda: https://conda.anaconda.org/conda-forge/win-64/libdeflate-1.25-h51727cc_0.conda + sha256: 834e4881a18b690d5ec36f44852facd38e13afe599e369be62d29bd675f107ee + md5: e77030e67343e28b084fabd7db0ce43e + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 156818 + timestamp: 1761979842440 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda + sha256: c076a213bd3676cc1ef22eeff91588826273513ccc6040d9bea68bccdc849501 + md5: 9314bc5a1fe7d1044dc9dfd3ef400535 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpciaccess >=0.18,<0.19.0a0 + license: MIT + license_family: MIT + size: 310785 + timestamp: 1757212153962 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + sha256: d789471216e7aba3c184cd054ed61ce3f6dac6f87a50ec69291b9297f8c18724 + md5: c277e0a4d549b03ac1e9d6cbbe3d017b + depends: + - ncurses + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + size: 134676 + timestamp: 1738479519902 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libedit-3.1.20250104-pl5321ha958ccf_0.conda + sha256: 6cc49785940a99e6a6b8c6edbb15f44c2dd6c789d9c283e5ee7bdfedd50b4cd6 + md5: 1f4ed31220402fcddc083b4bff406868 + depends: + - ncurses + - __osx >=10.13 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + size: 115563 + timestamp: 1738479554273 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + sha256: 66aa216a403de0bb0c1340a88d1a06adaff66bae2cfd196731aa24db9859d631 + md5: 44083d2d2c2025afca315c7a172eab2b + depends: + - ncurses + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + size: 107691 + timestamp: 1738479560845 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda + sha256: 7fd5408d359d05a969133e47af580183fbf38e2235b562193d427bb9dad79723 + md5: c151d5eb730e9b7480e6d48c0fc44048 + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_2 + license: LicenseRef-libglvnd + size: 44840 + timestamp: 1731330973553 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda + sha256: 1cd6048169fa0395af74ed5d8f1716e22c19a81a8a36f934c110ca3ad4dd27b4 + md5: 172bf1cd1ff8629f2b1179945ed45055 + depends: + - libgcc-ng >=12 + license: BSD-2-Clause + license_family: BSD + size: 112766 + timestamp: 1702146165126 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libev-4.33-h10d778d_2.conda + sha256: 0d238488564a7992942aa165ff994eca540f687753b4f0998b29b4e4d030ff43 + md5: 899db79329439820b7e8f8de41bca902 + license: BSD-2-Clause + license_family: BSD + size: 106663 + timestamp: 1702146352558 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda + sha256: 95cecb3902fbe0399c3a7e67a5bed1db813e5ab0e22f4023a5e0f722f2cc214f + md5: 36d33e440c31857372a72137f78bacf5 + license: BSD-2-Clause + license_family: BSD + size: 107458 + timestamp: 1702146414478 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libevent-2.1.12-hf998b51_1.conda + sha256: 2e14399d81fb348e9d231a82ca4d816bf855206923759b69ad006ba482764131 + md5: a1cfcc585f0c42bf8d5546bb1dfb668d + depends: + - libgcc-ng >=12 + - openssl >=3.1.1,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 427426 + timestamp: 1685725977222 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libevent-2.1.12-ha90c15b_1.conda + sha256: e0bd9af2a29f8dd74309c0ae4f17a7c2b8c4b89f875ff1d6540c941eefbd07fb + md5: e38e467e577bd193a7d5de7c2c540b04 + depends: + - openssl >=3.1.1,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 372661 + timestamp: 1685726378869 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libevent-2.1.12-h2757513_1.conda + sha256: 8c136d7586259bb5c0d2b913aaadc5b9737787ae4f40e3ad1beaf96c80b919b7 + md5: 1a109764bff3bdc7bdd84088347d71dc + depends: + - openssl >=3.1.1,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 368167 + timestamp: 1685726248899 +- conda: https://conda.anaconda.org/conda-forge/win-64/libevent-2.1.12-h3671451_1.conda + sha256: af03882afb7a7135288becf340c2f0cf8aa8221138a9a7b108aaeb308a486da1 + md5: 25efbd786caceef438be46da78a7b5ef + depends: + - openssl >=3.1.1,<4.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: BSD-3-Clause + license_family: BSD + size: 410555 + timestamp: 1685726568668 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda + sha256: 1e1b08f6211629cbc2efe7a5bca5953f8f6b3cae0eeb04ca4dacee1bd4e2db2f + md5: 8b09ae86839581147ef2e5c5e229d164 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.7.3.* + license: MIT + license_family: MIT + size: 76643 + timestamp: 1763549731408 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.7.3-heffb93a_0.conda + sha256: d11b3a6ce5b2e832f430fd112084533a01220597221bee16d6c7dc3947dffba6 + md5: 222e0732a1d0780a622926265bee14ef + depends: + - __osx >=10.13 + constrains: + - expat 2.7.3.* + license: MIT + license_family: MIT + size: 74058 + timestamp: 1763549886493 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda + sha256: fce22610ecc95e6d149e42a42fbc3cc9d9179bd4eb6232639a60f06e080eec98 + md5: b79875dbb5b1db9a4a22a4520f918e1a + depends: + - __osx >=11.0 + constrains: + - expat 2.7.3.* + license: MIT + license_family: MIT + size: 67800 + timestamp: 1763549994166 +- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.3-hac47afa_0.conda + sha256: 844ab708594bdfbd7b35e1a67c379861bcd180d6efe57b654f482ae2f7f5c21e + md5: 8c9e4f1a0e688eef2e95711178061a0f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - expat 2.7.3.* + license: MIT + license_family: MIT + size: 70137 + timestamp: 1763550049107 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda + sha256: 25cbdfa65580cfab1b8d15ee90b4c9f1e0d72128f1661449c9a999d341377d54 + md5: 35f29eec58405aaf55e01cb470d8c26a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 57821 + timestamp: 1760295480630 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-h750e83c_0.conda + sha256: 277dc89950f5d97f1683f26e362d6dca3c2efa16cb2f6fdb73d109effa1cd3d0 + md5: d214916b24c625bcc459b245d509f22e + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 52573 + timestamp: 1760295626449 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda + sha256: 9b8acdf42df61b7bfe8bdc545c016c29e61985e79748c64ad66df47dbc2e295f + md5: 411ff7cd5d1472bba0f55c0faf04453b + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 40251 + timestamp: 1760295839166 +- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h52bdfb6_0.conda + sha256: ddff25aaa4f0aa535413f5d831b04073789522890a4d8626366e43ecde1534a3 + md5: ba4ad812d2afc22b9a34ce8327a0930f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 44866 + timestamp: 1760295760649 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda + sha256: 4641d37faeb97cf8a121efafd6afd040904d4bca8c46798122f417c31d5dfbec + md5: f4084e4e6577797150f9b04a4560ceb0 + depends: + - libfreetype6 >=2.14.1 + license: GPL-2.0-only OR FTL + size: 7664 + timestamp: 1757945417134 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libfreetype-2.14.1-h694c41f_0.conda + sha256: 035e23ef87759a245d51890aedba0b494a26636784910c3730d76f3dc4482b1d + md5: e0e2edaf5e0c71b843e25a7ecc451cc9 + depends: + - libfreetype6 >=2.14.1 + license: GPL-2.0-only OR FTL + size: 7780 + timestamp: 1757945952392 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.1-hce30654_0.conda + sha256: 9de25a86066f078822d8dd95a83048d7dc2897d5d655c0e04a8a54fca13ef1ef + md5: f35fb38e89e2776994131fbf961fa44b + depends: + - libfreetype6 >=2.14.1 + license: GPL-2.0-only OR FTL + size: 7810 + timestamp: 1757947168537 +- conda: https://conda.anaconda.org/conda-forge/win-64/libfreetype-2.14.1-h57928b3_0.conda + sha256: 2029702ec55e968ce18ec38cc8cf29f4c8c4989a0d51797164dab4f794349a64 + md5: 3235024fe48d4087721797ebd6c9d28c + depends: + - libfreetype6 >=2.14.1 + license: GPL-2.0-only OR FTL + size: 8109 + timestamp: 1757946135015 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda + sha256: 4a7af818a3179fafb6c91111752954e29d3a2a950259c14a2fc7ba40a8b03652 + md5: 8e7251989bca326a28f4a5ffbd74557a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpng >=1.6.50,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + constrains: + - freetype >=2.14.1 + license: GPL-2.0-only OR FTL + size: 386739 + timestamp: 1757945416744 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libfreetype6-2.14.1-h6912278_0.conda + sha256: f5f28092e368efc773bcd1c381d123f8b211528385a9353e36f8808d00d11655 + md5: dfbdc8fd781dc3111541e4234c19fdbd + depends: + - __osx >=10.13 + - libpng >=1.6.50,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + constrains: + - freetype >=2.14.1 + license: GPL-2.0-only OR FTL + size: 374993 + timestamp: 1757945949585 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.1-h6da58f4_0.conda + sha256: cc4aec4c490123c0f248c1acd1aeab592afb6a44b1536734e20937cda748f7cd + md5: 6d4ede03e2a8e20eb51f7f681d2a2550 + depends: + - __osx >=11.0 + - libpng >=1.6.50,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + constrains: + - freetype >=2.14.1 + license: GPL-2.0-only OR FTL + size: 346703 + timestamp: 1757947166116 +- conda: https://conda.anaconda.org/conda-forge/win-64/libfreetype6-2.14.1-hdbac1cb_0.conda + sha256: 223710600b1a5567163f7d66545817f2f144e4ef8f84e99e90f6b8a4e19cb7ad + md5: 6e7c5c5ab485057b5d07fd8188ba5c28 + depends: + - libpng >=1.6.50,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - freetype >=2.14.1 + license: GPL-2.0-only OR FTL + size: 340264 + timestamp: 1757946133889 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda + sha256: 6eed58051c2e12b804d53ceff5994a350c61baf117ec83f5f10c953a3f311451 + md5: 6d0363467e6ed84f11435eb309f2ff06 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_16 + - libgomp 15.2.0 he0feb66_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 1042798 + timestamp: 1765256792743 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libgcc-15.2.0-h08519bb_15.conda + sha256: e04b115ae32f8cbf95905971856ff557b296511735f4e1587b88abf519ff6fb8 + md5: c816665789d1e47cdfd6da8a81e1af64 + depends: + - _openmp_mutex + constrains: + - libgomp 15.2.0 15 + - libgcc-ng ==15.2.0=*_15 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 422960 + timestamp: 1764839601296 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_16.conda + sha256: 646c91dbc422fe92a5f8a3a5409c9aac66549f4ce8f8d1cab7c2aa5db789bb69 + md5: 8b216bac0de7a9d60f3ddeba2515545c + depends: + - _openmp_mutex + constrains: + - libgcc-ng ==15.2.0=*_16 + - libgomp 15.2.0 16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 402197 + timestamp: 1765258985740 +- conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_16.conda + sha256: 24984e1e768440ba73021f08a1da0c1ec957b30d7071b9a89b877a273d17cae8 + md5: 1edb8bd8e093ebd31558008e9cb23b47 + depends: + - _openmp_mutex >=4.5 + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + constrains: + - libgomp 15.2.0 h8ee18e1_16 + - libgcc-ng ==15.2.0=*_16 + - msys2-conda-epoch <0.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 819696 + timestamp: 1765260437409 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_16.conda + sha256: 5f07f9317f596a201cc6e095e5fc92621afca64829785e483738d935f8cab361 + md5: 5a68259fac2da8f2ee6f7bfe49c9eb8b + depends: + - libgcc 15.2.0 he0feb66_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 27256 + timestamp: 1765256804124 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_16.conda + sha256: 8a7b01e1ee1c462ad243524d76099e7174ebdd94ff045fe3e9b1e58db196463b + md5: 40d9b534410403c821ff64f00d0adc22 + depends: + - libgfortran5 15.2.0 h68bc16d_16 + constrains: + - libgfortran-ng ==15.2.0=*_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 27215 + timestamp: 1765256845586 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran-15.2.0-h7e5c614_15.conda + sha256: 7bb4d51348e8f7c1a565df95f4fc2a2021229d42300aab8366eda0ea1af90587 + md5: a089323fefeeaba2ae60e1ccebf86ddc + depends: + - libgfortran5 15.2.0 hd16e46c_15 + constrains: + - libgfortran-ng ==15.2.0=*_15 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 139002 + timestamp: 1764839892631 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_16.conda + sha256: 68a6c1384d209f8654112c4c57c68c540540dd8e09e17dd1facf6cf3467798b5 + md5: 11e09edf0dde4c288508501fe621bab4 + depends: + - libgfortran5 15.2.0 hdae7583_16 + constrains: + - libgfortran-ng ==15.2.0=*_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 138630 + timestamp: 1765259217400 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_16.conda + sha256: d0e974ebc937c67ae37f07a28edace978e01dc0f44ee02f29ab8a16004b8148b + md5: 39183d4e0c05609fd65f130633194e37 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15.2.0 + constrains: + - libgfortran 15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 2480559 + timestamp: 1765256819588 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-hd16e46c_15.conda + sha256: 456385a7d3357d5fdfc8e11bf18dcdf71753c4016c440f92a2486057524dd59a + md5: c2a6149bf7f82774a0118b9efef966dd + depends: + - libgcc >=15.2.0 + constrains: + - libgfortran 15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 1061950 + timestamp: 1764839609607 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_16.conda + sha256: 9fb7f4ff219e3fb5decbd0ee90a950f4078c90a86f5d8d61ca608c913062f9b0 + md5: 265a9d03461da24884ecc8eb58396d57 + depends: + - libgcc >=15.2.0 + constrains: + - libgfortran 15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 598291 + timestamp: 1765258993165 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda + sha256: dc2752241fa3d9e40ce552c1942d0a4b5eeb93740c9723873f6fcf8d39ef8d2d + md5: 928b8be80851f5d8ffb016f9c81dae7a + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_2 + - libglx 1.7.0 ha4b6fd6_2 + license: LicenseRef-libglvnd + size: 134712 + timestamp: 1731330998354 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.2-h32235b2_0.conda + sha256: 918306d6ed211ab483e4e19368e5748b265d24e75c88a1c66a61f72b9fa30b29 + md5: 0cb0612bc9cb30c62baf41f9d600611b + depends: + - __glibc >=2.17,<3.0.a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pcre2 >=10.46,<10.47.0a0 + constrains: + - glib 2.86.2 *_0 + license: LGPL-2.1-or-later + size: 3974801 + timestamp: 1763672326986 +- conda: https://conda.anaconda.org/conda-forge/win-64/libglib-2.86.2-hd9c3897_0.conda + sha256: 60fa317d11a6f5d4bc76be5ff89b9ac608171a00b206c688e3cc4f65c73b1bc4 + md5: fbd144e60009d93f129f0014a76512d3 + depends: + - libffi >=3.5.2,<3.6.0a0 + - libiconv >=1.18,<2.0a0 + - libintl >=0.22.5,<1.0a0 + - libzlib >=1.3.1,<2.0a0 + - pcre2 >=10.46,<10.47.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - glib 2.86.2 *_0 + license: LGPL-2.1-or-later + size: 3793396 + timestamp: 1763672587079 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda + sha256: 1175f8a7a0c68b7f81962699751bb6574e6f07db4c9f72825f978e3016f46850 + md5: 434ca7e50e40f4918ab701e3facd59a0 + depends: + - __glibc >=2.17,<3.0.a0 + license: LicenseRef-libglvnd + size: 132463 + timestamp: 1731330968309 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda + sha256: 2d35a679624a93ce5b3e9dd301fff92343db609b79f0363e6d0ceb3a6478bfa7 + md5: c8013e438185f33b13814c5c488acd5c + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_2 + - xorg-libx11 >=1.8.10,<2.0a0 + license: LicenseRef-libglvnd + size: 75504 + timestamp: 1731330988898 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda + sha256: 5b3e5e4e9270ecfcd48f47e3a68f037f5ab0f529ccb223e8e5d5ac75a58fc687 + md5: 26c46f90d0e727e95c6c9498a33a09f3 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 603284 + timestamp: 1765256703881 +- conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_16.conda + sha256: 9c86aadc1bd9740f2aca291da8052152c32dd1c617d5d4fd0f334214960649bb + md5: ab8189163748f95d4cb18ea1952943c3 + depends: + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + constrains: + - msys2-conda-epoch <0.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 663567 + timestamp: 1765260367147 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-2.39.0-hdb79228_0.conda + sha256: d3341cf69cb02c07bbd1837968f993da01b7bd467e816b1559a3ca26c1ff14c5 + md5: a2e30ccd49f753fd30de0d30b1569789 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcurl >=8.14.1,<9.0a0 + - libgcc >=14 + - libgrpc >=1.73.1,<1.74.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libstdcxx >=14 + - openssl >=3.5.1,<4.0a0 + constrains: + - libgoogle-cloud 2.39.0 *_0 + license: Apache-2.0 + license_family: Apache + size: 1307909 + timestamp: 1752048413383 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libgoogle-cloud-2.39.0-hed66dea_0.conda + sha256: 9b50362bafd60c4a3eb6c37e6dbf7e200562dab7ae1b282b1ebd633d4d77d4bd + md5: 06564befaabd2760dfa742e47074bad2 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcurl >=8.14.1,<9.0a0 + - libcxx >=19 + - libgrpc >=1.73.1,<1.74.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - openssl >=3.5.1,<4.0a0 + constrains: + - libgoogle-cloud 2.39.0 *_0 + license: Apache-2.0 + license_family: Apache + size: 899629 + timestamp: 1752048034356 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgoogle-cloud-2.39.0-head0a95_0.conda + sha256: 209facdb8ea5b68163f146525720768fa3191cef86c82b2538e8c3cafa1e9dd4 + md5: ad7272a081abe0966d0297691154eda5 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcurl >=8.14.1,<9.0a0 + - libcxx >=19 + - libgrpc >=1.73.1,<1.74.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - openssl >=3.5.1,<4.0a0 + constrains: + - libgoogle-cloud 2.39.0 *_0 + license: Apache-2.0 + license_family: Apache + size: 876283 + timestamp: 1752047598741 +- conda: https://conda.anaconda.org/conda-forge/win-64/libgoogle-cloud-2.39.0-h19ee442_0.conda + sha256: 8f5b26e9ea985c819a67e41664da82219534f9b9c8ba190f7d3c440361e5accb + md5: c2c512f98c5c666782779439356a1713 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcurl >=8.14.1,<9.0a0 + - libgrpc >=1.73.1,<1.74.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - libgoogle-cloud 2.39.0 *_0 + license: Apache-2.0 + license_family: Apache + size: 14952 + timestamp: 1752049549178 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-storage-2.39.0-hdbdcf42_0.conda + sha256: 59eb8365f0aee384f2f3b2a64dcd454f1a43093311aa5f21a8bb4bd3c79a6db8 + md5: bd21962ff8a9d1ce4720d42a35a4af40 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil + - libcrc32c >=1.1.2,<1.2.0a0 + - libcurl + - libgcc >=14 + - libgoogle-cloud 2.39.0 hdb79228_0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl + license: Apache-2.0 + license_family: Apache + size: 804189 + timestamp: 1752048589800 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libgoogle-cloud-storage-2.39.0-h8ac052b_0.conda + sha256: fe790fc9ed8ffa468d27e886735fe11844369caee406d98f1da2c0d8aed0401e + md5: 7600fb1377c8eb5a161e4a2520933daa + depends: + - __osx >=11.0 + - libabseil + - libcrc32c >=1.1.2,<1.2.0a0 + - libcurl + - libcxx >=19 + - libgoogle-cloud 2.39.0 hed66dea_0 + - libzlib >=1.3.1,<2.0a0 + - openssl + license: Apache-2.0 + license_family: Apache + size: 543323 + timestamp: 1752048443047 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgoogle-cloud-storage-2.39.0-hfa3a374_0.conda + sha256: a5160c23b8b231b88d0ff738c7f52b0ee703c4c0517b044b18f4d176e729dfd8 + md5: 147a468b9b6c3ced1fccd69b864ae289 + depends: + - __osx >=11.0 + - libabseil + - libcrc32c >=1.1.2,<1.2.0a0 + - libcurl + - libcxx >=19 + - libgoogle-cloud 2.39.0 head0a95_0 + - libzlib >=1.3.1,<2.0a0 + - openssl + license: Apache-2.0 + license_family: Apache + size: 525153 + timestamp: 1752047915306 +- conda: https://conda.anaconda.org/conda-forge/win-64/libgoogle-cloud-storage-2.39.0-he04ea4c_0.conda + sha256: 51c29942d9bb856081605352ac74c45cad4fedbaac89de07c74efb69a3be9ab3 + md5: 26198e3dc20bbcbea8dd6fa5ab7ea1e0 + depends: + - libabseil + - libcrc32c >=1.1.2,<1.2.0a0 + - libcurl + - libgoogle-cloud 2.39.0 h19ee442_0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + size: 14904 + timestamp: 1752049852815 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.73.1-h3288cfb_1.conda + sha256: bc9d32af6167b1f5bcda216dc44eddcb27f3492440571ab12f6e577472a05e34 + md5: ff63bb12ac31c176ff257e3289f20770 + depends: + - __glibc >=2.17,<3.0.a0 + - c-ares >=1.34.5,<2.0a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libgcc >=14 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libre2-11 >=2025.8.12 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - re2 + constrains: + - grpc-cpp =1.73.1 + license: Apache-2.0 + license_family: APACHE + size: 8349777 + timestamp: 1761058442526 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libgrpc-1.73.1-h451496d_1.conda + sha256: 30378f4c9055224fecd1da8b9a65e2c0293cde68edca0f8a306fd9e92fd6ee1f + md5: d6ea2acfae86b523b54938c6bc30e378 + depends: + - __osx >=11.0 + - c-ares >=1.34.5,<2.0a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcxx >=19 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libre2-11 >=2025.8.12 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - re2 + constrains: + - grpc-cpp =1.73.1 + license: Apache-2.0 + license_family: APACHE + size: 5468625 + timestamp: 1761060387315 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgrpc-1.73.1-h3063b79_1.conda + sha256: c2099872b1aa06bf8153e35e5b706d2000c1fc16f4dde2735ccd77a0643a4683 + md5: f5856b3b9dae4463348a7ec23c1301f2 + depends: + - __osx >=11.0 + - c-ares >=1.34.5,<2.0a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcxx >=19 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libre2-11 >=2025.8.12 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - re2 + constrains: + - grpc-cpp =1.73.1 + license: Apache-2.0 + license_family: APACHE + size: 5377798 + timestamp: 1761053602943 +- conda: https://conda.anaconda.org/conda-forge/win-64/libgrpc-1.73.1-h317e13b_1.conda + sha256: 95a83e98c35b8ec03d84f0714eefb2630078d9224360a93dbef6f2403414f76f + md5: 855b10d858d6c078a28d670cf32baa67 + depends: + - c-ares >=1.34.5,<2.0a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libre2-11 >=2025.8.12 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - re2 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - grpc-cpp =1.73.1 + license: Apache-2.0 + license_family: APACHE + size: 14433486 + timestamp: 1761053760632 +- conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.12.1-default_h4379cf1_1003.conda + sha256: 2d534c09f92966b885acb3f4a838f7055cea043165a03079a539b06c54e20a49 + md5: d1699ce4fe195a9f61264a1c29b87035 + depends: + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + - libxml2 + - libxml2-16 >=2.14.6 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 2412642 + timestamp: 1765090345611 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f + md5: 915f5995e94f60e9a4826e0b0920ee88 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-only + size: 790176 + timestamp: 1754908768807 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda + sha256: a1c8cecdf9966921e13f0ae921309a1f415dfbd2b791f2117cf7e8f5e61a48b6 + md5: 210a85a1119f97ea7887188d176db135 + depends: + - __osx >=10.13 + license: LGPL-2.1-only + size: 737846 + timestamp: 1754908900138 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + sha256: de0336e800b2af9a40bdd694b03870ac4a848161b35c8a2325704f123f185f03 + md5: 4d5a7445f0b25b6a3ddbb56e790f5251 + depends: + - __osx >=11.0 + license: LGPL-2.1-only + size: 750379 + timestamp: 1754909073836 +- conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda + sha256: 0dcdb1a5f01863ac4e8ba006a8b0dc1a02d2221ec3319b5915a1863254d7efa7 + md5: 64571d1dd6cdcfa25d0664a5950fdaa2 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: LGPL-2.1-only + size: 696926 + timestamp: 1754909290005 +- conda: https://conda.anaconda.org/conda-forge/win-64/libintl-0.22.5-h5728263_3.conda + sha256: c7e4600f28bcada8ea81456a6530c2329312519efcf0c886030ada38976b0511 + md5: 2cf0cf76cc15d360dfa2f17fd6cf9772 + depends: + - libiconv >=1.17,<2.0a0 + license: LGPL-2.1-or-later + size: 95568 + timestamp: 1723629479451 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda + sha256: cc9aba923eea0af8e30e0f94f2ad7156e2984d80d1e8e7fe6be5a1f257f0eb32 + md5: 8397539e3a0bbd1695584fb4f927485a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + size: 633710 + timestamp: 1762094827865 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libjpeg-turbo-3.1.2-h8616949_0.conda + sha256: ebe2877abc046688d6ea299e80d8322d10c69763f13a102010f90f7168cc5f54 + md5: 48dda187f169f5a8f1e5e07701d5cdd9 + depends: + - __osx >=10.13 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + size: 586189 + timestamp: 1762095332781 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.2-hc919400_0.conda + sha256: 6c061c56058bb10374daaef50e81b39cf43e8aee21f0037022c0c39c4f31872f + md5: f0695fbecf1006f27f4395d64bd0c4b8 + depends: + - __osx >=11.0 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + size: 551197 + timestamp: 1762095054358 +- conda: https://conda.anaconda.org/conda-forge/win-64/libjpeg-turbo-3.1.2-hfd05255_0.conda + sha256: 795e2d4feb2f7fc4a2c6e921871575feb32b8082b5760726791f080d1e2c2597 + md5: 56a686f92ac0273c0f6af58858a3f013 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + size: 841783 + timestamp: 1762094814336 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-4_h47877c9_openblas.conda + build_number: 4 + sha256: 5a6ed95bf093d709c8ba8373890773b912767eafdd2e8e4ad0fa6413d13ae3c9 + md5: 8ba8431802764597f400ee3e99026367 + depends: + - libblas 3.11.0 4_h4a7cf45_openblas + constrains: + - blas 2.304 openblas + - libcblas 3.11.0 4*_openblas + - liblapacke 3.11.0 4*_openblas + license: BSD-3-Clause + license_family: BSD + size: 18533 + timestamp: 1764823871307 +- conda: https://conda.anaconda.org/conda-forge/osx-64/liblapack-3.11.0-4_h859234e_openblas.conda + build_number: 4 + sha256: cd490682199bd61c8db56cb72e71c154d91e8bf652cb28327690fa38246085d5 + md5: ebce74f166fc65413f751b8a125d4be3 + depends: + - libblas 3.11.0 4_he492b99_openblas + constrains: + - liblapacke 3.11.0 4*_openblas + - libcblas 3.11.0 4*_openblas + - blas 2.304 openblas + license: BSD-3-Clause + license_family: BSD + size: 18692 + timestamp: 1764824659093 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-4_hd9741b5_openblas.conda + build_number: 4 + sha256: 63c9ac0c44c99fdf8de038b66f549d29a7b71e51223ad3fac1b4ba79080581c1 + md5: 3b949d8c584bc30932e41c755507bdc1 + depends: + - libblas 3.11.0 4_h51639a9_openblas + constrains: + - libcblas 3.11.0 4*_openblas + - blas 2.304 openblas + - liblapacke 3.11.0 4*_openblas + license: BSD-3-Clause + license_family: BSD + size: 18764 + timestamp: 1764824468301 +- conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-4_hf9ab0e9_mkl.conda + build_number: 4 + sha256: d820333e9bac8381fb69e857d673c12d034bb45d0fe4818a1d12e1ec7a39e7df + md5: 67298727e96b60068a316d2f627e1e35 + depends: + - libblas 3.11.0 4_hf2e6a31_mkl + constrains: + - liblapacke 3.11.0 4*_mkl + - libcblas 3.11.0 4*_mkl + - blas 2.304 mkl + license: BSD-3-Clause + license_family: BSD + size: 80387 + timestamp: 1764824249543 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm21-21.1.7-hf7376ad_0.conda + sha256: afe5c5cfc90dc8b5b394e21cf02188394e36766119ad5d78a1d8619d011bbfb1 + md5: 27dc1a582b442f24979f2a28641fe478 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 44320825 + timestamp: 1764711528746 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda + sha256: f2591c0069447bbe28d4d696b7fcb0c5bd0b4ac582769b89addbcf26fb3430d8 + md5: 1a580f7796c7bf6393fddb8bbbde58dc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + constrains: + - xz 5.8.1.* + license: 0BSD + size: 112894 + timestamp: 1749230047870 +- conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.1-hd471939_2.conda + sha256: 7e22fd1bdb8bf4c2be93de2d4e718db5c548aa082af47a7430eb23192de6bb36 + md5: 8468beea04b9065b9807fc8b9cdc5894 + depends: + - __osx >=10.13 + constrains: + - xz 5.8.1.* + license: 0BSD + size: 104826 + timestamp: 1749230155443 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda + sha256: 0cb92a9e026e7bd4842f410a5c5c665c89b2eb97794ffddba519a626b8ce7285 + md5: d6df911d4564d77c4374b02552cb17d1 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.1.* + license: 0BSD + size: 92286 + timestamp: 1749230283517 +- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda + sha256: 55764956eb9179b98de7cc0e55696f2eff8f7b83fc3ebff5e696ca358bca28cc + md5: c15148b2e18da456f5108ccb5e411446 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + constrains: + - xz 5.8.1.* + license: 0BSD + size: 104935 + timestamp: 1749230611612 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda + sha256: 3aa92d4074d4063f2a162cd8ecb45dccac93e543e565c01a787e16a43501f7ee + md5: c7e925f37e3b40d893459e625f6a53f1 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: BSD-2-Clause + license_family: BSD + size: 91183 + timestamp: 1748393666725 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-h6e16a3a_0.conda + sha256: 98299c73c7a93cd4f5ff8bb7f43cd80389f08b5a27a296d806bdef7841cc9b9e + md5: 18b81186a6adb43f000ad19ed7b70381 + depends: + - __osx >=10.13 + license: BSD-2-Clause + license_family: BSD + size: 77667 + timestamp: 1748393757154 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda + sha256: 0a1875fc1642324ebd6c4ac864604f3f18f57fbcf558a8264f6ced028a3c75b2 + md5: 85ccccb47823dd9f7a99d2c7f530342f + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + size: 71829 + timestamp: 1748393749336 +- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-h2466b09_0.conda + sha256: fc529fc82c7caf51202cc5cec5bb1c2e8d90edbac6d0a4602c966366efe3c7bf + md5: 74860100b2029e2523cf480804c76b9b + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: BSD-2-Clause + license_family: BSD + size: 88657 + timestamp: 1723861474602 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda + sha256: a4a7dab8db4dc81c736e9a9b42bdfd97b087816e029e221380511960ac46c690 + md5: b499ce4b026493a13774bcf0f4c33849 + depends: + - __glibc >=2.17,<3.0.a0 + - c-ares >=1.34.5,<2.0a0 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.2,<4.0a0 + license: MIT + license_family: MIT + size: 666600 + timestamp: 1756834976695 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libnghttp2-1.67.0-h3338091_0.conda + sha256: c48d7e1cc927aef83ff9c48ae34dd1d7495c6ccc1edc4a3a6ba6aff1624be9ac + md5: e7630cef881b1174d40f3e69a883e55f + depends: + - __osx >=10.13 + - c-ares >=1.34.5,<2.0a0 + - libcxx >=19 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.2,<4.0a0 + license: MIT + license_family: MIT + size: 605680 + timestamp: 1756835898134 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.67.0-hc438710_0.conda + sha256: a07cb53b5ffa2d5a18afc6fd5a526a5a53dd9523fbc022148bd2f9395697c46d + md5: a4b4dd73c67df470d091312ab87bf6ae + depends: + - __osx >=11.0 + - c-ares >=1.34.5,<2.0a0 + - libcxx >=19 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.2,<4.0a0 + license: MIT + license_family: MIT + size: 575454 + timestamp: 1756835746393 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda + sha256: 3b3f19ced060013c2dd99d9d46403be6d319d4601814c772a3472fe2955612b0 + md5: 7c7927b404672409d9917d49bff5f2d6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: LGPL-2.1-or-later + size: 33418 + timestamp: 1734670021371 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.30-pthreads_h94d23a6_4.conda + sha256: 199d79c237afb0d4780ccd2fbf829cea80743df60df4705202558675e07dd2c5 + md5: be43915efc66345cccb3c310b6ed0374 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + constrains: + - openblas >=0.3.30,<0.3.31.0a0 + license: BSD-3-Clause + license_family: BSD + size: 5927939 + timestamp: 1763114673331 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libopenblas-0.3.30-openmp_h6006d49_4.conda + sha256: ba642353f7f41ab2d2eb6410fbe522238f0f4483bcd07df30b3222b4454ee7cd + md5: 9241a65e6e9605e4581a2a8005d7f789 + depends: + - __osx >=10.13 + - libgfortran + - libgfortran5 >=14.3.0 + - llvm-openmp >=19.1.7 + constrains: + - openblas >=0.3.30,<0.3.31.0a0 + license: BSD-3-Clause + license_family: BSD + size: 6268795 + timestamp: 1763117623665 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.30-openmp_ha158390_3.conda + sha256: dcc626c7103503d1dfc0371687ad553cb948b8ed0249c2a721147bdeb8db4a73 + md5: a18a7f471c517062ee71b843ef95eb8a + depends: + - __osx >=11.0 + - libgfortran + - libgfortran5 >=14.3.0 + - llvm-openmp >=19.1.7 + constrains: + - openblas >=0.3.30,<0.3.31.0a0 + license: BSD-3-Clause + license_family: BSD + size: 4285762 + timestamp: 1761749506256 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda + sha256: 215086c108d80349e96051ad14131b751d17af3ed2cb5a34edd62fa89bfe8ead + md5: 7df50d44d4a14d6c31a2c54f2cd92157 + depends: + - __glibc >=2.17,<3.0.a0 + - libglvnd 1.7.0 ha4b6fd6_2 + license: LicenseRef-libglvnd + size: 50757 + timestamp: 1731330993524 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-1.21.0-hb9b0907_1.conda + sha256: ba9b09066f9abae9b4c98ffedef444bbbf4c068a094f6c77d70ef6f006574563 + md5: 1c0320794855f457dea27d35c4c71e23 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcurl >=8.14.1,<9.0a0 + - libgrpc >=1.73.1,<1.74.0a0 + - libopentelemetry-cpp-headers 1.21.0 ha770c72_1 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libzlib >=1.3.1,<2.0a0 + - nlohmann_json + - prometheus-cpp >=1.3.0,<1.4.0a0 + constrains: + - cpp-opentelemetry-sdk =1.21.0 + license: Apache-2.0 + license_family: APACHE + size: 885397 + timestamp: 1751782709380 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libopentelemetry-cpp-1.21.0-h7d3f41d_1.conda + sha256: 94df4129f94dbb17998a60bff0b53c700e6124a6cb67f3047fe7059ebaa7d357 + md5: 952dd64cff4a72cadf5e81572a7a81c8 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcurl >=8.14.1,<9.0a0 + - libgrpc >=1.73.1,<1.74.0a0 + - libopentelemetry-cpp-headers 1.21.0 h694c41f_1 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libzlib >=1.3.1,<2.0a0 + - nlohmann_json + - prometheus-cpp >=1.3.0,<1.4.0a0 + constrains: + - cpp-opentelemetry-sdk =1.21.0 + license: Apache-2.0 + license_family: APACHE + size: 585875 + timestamp: 1751782877386 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopentelemetry-cpp-1.21.0-he15edb5_1.conda + sha256: 4bf8f703ddd140fe54d4c8464ac96b28520fbc1083cce52c136a85a854745d5c + md5: cbcea547d6d831863ab0a4e164099062 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcurl >=8.14.1,<9.0a0 + - libgrpc >=1.73.1,<1.74.0a0 + - libopentelemetry-cpp-headers 1.21.0 hce30654_1 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libzlib >=1.3.1,<2.0a0 + - nlohmann_json + - prometheus-cpp >=1.3.0,<1.4.0a0 + constrains: + - cpp-opentelemetry-sdk =1.21.0 + license: Apache-2.0 + license_family: APACHE + size: 564609 + timestamp: 1751782939921 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-headers-1.21.0-ha770c72_1.conda + sha256: b3a1b36d5f92fbbfd7b6426982a99561bdbd7e4adbafca1b7f127c9a5ab0a60f + md5: 9e298d76f543deb06eb0f3413675e13a + license: Apache-2.0 + license_family: APACHE + size: 363444 + timestamp: 1751782679053 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libopentelemetry-cpp-headers-1.21.0-h694c41f_1.conda + sha256: 5b43ec55305a6fabd8eb37cee06bc3260d3641f260435194837d0b64faa0b355 + md5: 62636543478d53b28c1fc5efce346622 + license: Apache-2.0 + license_family: APACHE + size: 362175 + timestamp: 1751782820895 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopentelemetry-cpp-headers-1.21.0-hce30654_1.conda + sha256: ce74278453dec1e3c11158ec368c8f1b03862e279b63f79ed01f38567a1174e6 + md5: c7df4b2d612208f3a27486c113b6aefc + license: Apache-2.0 + license_family: APACHE + size: 363213 + timestamp: 1751782889359 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libparquet-22.0.0-h7376487_6_cpu.conda + build_number: 6 + sha256: c6cc2a73091e5c460c3cbd606927d5ed85d3706e19459073e1ea023d1e754d13 + md5: 83fd8f55f38ac972947c9eca12dc4657 + depends: + - __glibc >=2.17,<3.0.a0 + - libarrow 22.0.0 hb6ed5f4_6_cpu + - libgcc >=14 + - libstdcxx >=14 + - libthrift >=0.22.0,<0.22.1.0a0 + - openssl >=3.5.4,<4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 1350396 + timestamp: 1765381452093 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libparquet-22.0.0-habb56ca_4_cpu.conda + build_number: 4 + sha256: f195841bde46a049fe449cf59b8e42db7f83e2459ffd1de4dad2bd192db86b84 + md5: 67ff6ca0e1fdec92bdc20fa593390ba1 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 hd1700fa_4_cpu + - libcxx >=19 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libthrift >=0.22.0,<0.22.1.0a0 + - openssl >=3.5.4,<4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 1073343 + timestamp: 1763230480681 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libparquet-22.0.0-h0ac143b_6_cpu.conda + build_number: 6 + sha256: 329c6cd1fbeef6e91f8bc7a2e8bd28c50b72bc42e0a028d990e2281966f57ef5 + md5: 4939c8e3ca5f98f229be9f318df740e2 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libarrow 22.0.0 he6e817a_6_cpu + - libcxx >=19 + - libopentelemetry-cpp >=1.21.0,<1.22.0a0 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libthrift >=0.22.0,<0.22.1.0a0 + - openssl >=3.5.4,<4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 1048992 + timestamp: 1765382997871 +- conda: https://conda.anaconda.org/conda-forge/win-64/libparquet-22.0.0-h7051d1f_6_cpu.conda + build_number: 6 + sha256: c30839adc47e3ccd6f717c33632d9b482e83f7e087a24211416246f8f05e9a54 + md5: d840a2b45e737bb768ec4e0d5bf36c90 + depends: + - libarrow 22.0.0 h89d7da9_6_cpu + - libthrift >=0.22.0,<0.22.1.0a0 + - openssl >=3.5.4,<4.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: APACHE + size: 927228 + timestamp: 1765382245972 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda + sha256: 0bd91de9b447a2991e666f284ae8c722ffb1d84acb594dbd0c031bd656fa32b2 + md5: 70e3400cbbfa03e96dcde7fc13e38c7b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + size: 28424 + timestamp: 1749901812541 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.53-h421ea60_0.conda + sha256: 8acdeb9a7e3d2630176ba8e947caf6bf4985a5148dec69b801e5eb797856688b + md5: 00d4e66b1f746cb14944cad23fffb405 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: zlib-acknowledgement + size: 317748 + timestamp: 1764981060755 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libpng-1.6.53-h380d223_0.conda + sha256: 62a861e407bf0d0a2a983d0b0167ed263ae035cae7061976e9994f9963e6c68d + md5: 0cdbbd56f660997cfe5d33e516afac2f + depends: + - __osx >=10.13 + - libzlib >=1.3.1,<2.0a0 + license: zlib-acknowledgement + size: 298397 + timestamp: 1764981064303 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.53-hfab5511_0.conda + sha256: 6793e7284e175c515fc6453be45c7c0febdea853657d246d8136fbda791dd0ad + md5: 62b6111feeffe607c3ecc8ca5bd1514b + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: zlib-acknowledgement + size: 288210 + timestamp: 1764981075326 +- conda: https://conda.anaconda.org/conda-forge/win-64/libpng-1.6.53-h7351971_0.conda + sha256: e5d061e7bdb2b97227b6955d1aa700a58a5703b5150ab0467cc37de609f277b6 + md5: fb6f43f6f08ca100cb24cff125ab0d9e + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - libzlib >=1.3.1,<2.0a0 + license: zlib-acknowledgement + size: 383702 + timestamp: 1764981078732 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libpq-18.1-h5c52fec_2.conda + sha256: bbab2c3e6f650f2bd1bc84d88e6a20fefa6a401fa445bb4b97c509c1b3a89fa8 + md5: a8ac9a6342569d1714ae1b53ae2fcadb + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=75.1,<76.0a0 + - krb5 >=1.21.3,<1.22.0a0 + - libgcc >=14 + - openldap >=2.6.10,<2.7.0a0 + - openssl >=3.5.4,<4.0a0 + license: PostgreSQL + size: 2711480 + timestamp: 1764345810429 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-6.31.1-h49aed37_2.conda + sha256: 1679f16c593d769f3dab219adb1117cbaaddb019080c5a59f79393dc9f45b84f + md5: 94cb88daa0892171457d9fdc69f43eca + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 4645876 + timestamp: 1760550892361 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libprotobuf-6.31.1-h03562ea_2.conda + sha256: 40a32a77cdb7f7b49187a4c9faf5c7812d95233288ab96b06e0dd9978ecd8e6d + md5: 39b7711c03a0d0533e832e734641e56e + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcxx >=19 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 3550823 + timestamp: 1760550860606 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-6.31.1-h658db43_2.conda + sha256: a01c3829eb0e3c1354ee7d61c5cde9a79dcebe6ccc7114c2feadf30aecbc7425 + md5: 155d3d17eaaf49ddddfe6c73842bc671 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcxx >=19 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 2982875 + timestamp: 1760550241203 +- conda: https://conda.anaconda.org/conda-forge/win-64/libprotobuf-6.31.1-hdcda5b4_2.conda + sha256: bb28909aef3777c5e950b769b30fe4bf02e0a7fb5322e583042a5cdc76bb15d0 + md5: 0e44c704760bbe4b696d981c3313f665 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 7787239 + timestamp: 1760550955606 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libre2-11-2025.11.05-h7b12aa8_0.conda + sha256: eb5d5ef4d12cdf744e0f728b35bca910843c8cf1249f758cf15488ca04a21dbb + md5: a30848ebf39327ea078cf26d114cff53 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - re2 2025.11.05.* + license: BSD-3-Clause + license_family: BSD + size: 211099 + timestamp: 1762397758105 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libre2-11-2025.11.05-h554ac88_0.conda + sha256: 901fb4cfdabf1495e7f080f8e8e218d1ad182c9bcd3cea2862481fef0e9d534f + md5: a0237623ed85308cb816c3dcced23db2 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcxx >=19 + constrains: + - re2 2025.11.05.* + license: BSD-3-Clause + license_family: BSD + size: 180107 + timestamp: 1762398117273 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libre2-11-2025.11.05-h91c62da_0.conda + sha256: 7b525313ab16415c4a3191ccf59157c3a4520ed762c8ec61fcfb81d27daa4723 + md5: 060f099756e6baf2ed51b9065e44eda8 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libcxx >=19 + constrains: + - re2 2025.11.05.* + license: BSD-3-Clause + license_family: BSD + size: 165593 + timestamp: 1762398300610 +- conda: https://conda.anaconda.org/conda-forge/win-64/libre2-11-2025.11.05-h0eb2380_0.conda + sha256: 8eb2c205588e6d751fe387e90f1321ac8bbaef0a12d125a1dd898e925327f8ae + md5: 960713477ad3d7f82e5199fa1b940495 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - re2 2025.11.05.* + license: BSD-3-Clause + license_family: BSD + size: 263996 + timestamp: 1762397947932 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.20-h4ab18f5_0.conda + sha256: 0105bd108f19ea8e6a78d2d994a6d4a8db16d19a41212070d2d1d48a63c34161 + md5: a587892d3c13b6621a6091be690dbca2 + depends: + - libgcc-ng >=12 + license: ISC + size: 205978 + timestamp: 1716828628198 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libsodium-1.0.20-hfdf4475_0.conda + sha256: d3975cfe60e81072666da8c76b993af018cf2e73fe55acba2b5ba0928efaccf5 + md5: 6af4b059e26492da6013e79cbcb4d069 + depends: + - __osx >=10.13 + license: ISC + size: 210249 + timestamp: 1716828641383 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.20-h99b78c6_0.conda + sha256: fade8223e1e1004367d7101dd17261003b60aa576df6d7802191f8972f7470b1 + md5: a7ce36e284c5faaf93c220dfc39e3abd + depends: + - __osx >=11.0 + license: ISC + size: 164972 + timestamp: 1716828607917 +- conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.20-hc70643c_0.conda + sha256: 7bcb3edccea30f711b6be9601e083ecf4f435b9407d70fc48fbcf9e5d69a0fc6 + md5: 198bb594f202b205c7d18b936fa4524f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: ISC + size: 202344 + timestamp: 1716828757533 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.1-h0c1763c_0.conda + sha256: 6f0e8a812e8e33a4d8b7a0e595efe28373080d27b78ee4828aa4f6649a088454 + md5: 2e1b84d273b01835256e53fd938de355 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: blessing + size: 938979 + timestamp: 1764359444435 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.51.1-h6cc646a_0.conda + sha256: 8460901daff15749354f0de143e766febf0682fe9201bf307ea84837707644d1 + md5: f71213ed0c51030cb17a77fc60a757f1 + depends: + - __osx >=10.13 + - icu >=75.1,<76.0a0 + - libzlib >=1.3.1,<2.0a0 + license: blessing + size: 991350 + timestamp: 1764359781222 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.1-h9a5124b_0.conda + sha256: a46b167447e2a9e38586320c30b29e3b68b6f7e6b873c18d6b1aa2efd2626917 + md5: 67e50e5bd4e5e2310d66b88c4da50096 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: blessing + size: 906292 + timestamp: 1764359907797 +- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.51.1-hf5d6505_0.conda + sha256: a976c8b455d9023b83878609bd68c3b035b9839d592bd6c7be7552c523773b62 + md5: f92bef2f8e523bb0eabe60099683617a + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: blessing + size: 1291059 + timestamp: 1764359545703 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda + sha256: fa39bfd69228a13e553bd24601332b7cfeb30ca11a3ca50bb028108fe90a7661 + md5: eecce068c7e4eddeb169591baac20ac4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 304790 + timestamp: 1745608545575 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libssh2-1.11.1-hed3591d_0.conda + sha256: 00654ba9e5f73aa1f75c1f69db34a19029e970a4aeb0fa8615934d8e9c369c3c + md5: a6cb15db1c2dc4d3a5f6cf3772e09e81 + depends: + - __osx >=10.13 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 284216 + timestamp: 1745608575796 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + sha256: 8bfe837221390ffc6f111ecca24fa12d4a6325da0c8d131333d63d6c37f27e0a + md5: b68e8f66b94b44aaa8de4583d3d4cc40 + depends: + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 279193 + timestamp: 1745608793272 +- conda: https://conda.anaconda.org/conda-forge/win-64/libssh2-1.11.1-h9aa295b_0.conda + sha256: cbdf93898f2e27cefca5f3fe46519335d1fab25c4ea2a11b11502ff63e602c09 + md5: 9dce2f112bfd3400f4f432b3d0ac07b2 + depends: + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: BSD-3-Clause + license_family: BSD + size: 292785 + timestamp: 1745608759342 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda + sha256: 813427918316a00c904723f1dfc3da1bbc1974c5cfe1ed1e704c6f4e0798cbc6 + md5: 68f68355000ec3f1d6f26ea13e8f525f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_16 + constrains: + - libstdcxx-ng ==15.2.0=*_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 5856456 + timestamp: 1765256838573 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_16.conda + sha256: 81f2f246c7533b41c5e0c274172d607829019621c4a0823b5c0b4a8c7028ee84 + md5: 1b3152694d236cf233b76b8c56bf0eae + depends: + - libstdcxx 15.2.0 h934c35e_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 27300 + timestamp: 1765256885128 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.22.0-h454ac66_1.conda + sha256: 4888b9ea2593c36ca587a5ebe38d0a56a0e6d6a9e4bb7da7d9a326aaaca7c336 + md5: 8ed82d90e6b1686f5e98f8b7825a15ef + depends: + - __glibc >=2.17,<3.0.a0 + - libevent >=2.1.12,<2.1.13.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.1,<4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 424208 + timestamp: 1753277183984 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libthrift-0.22.0-h687e942_1.conda + sha256: a0f9fdc663db089fde4136a0bd6c819d7f8daf869fc3ca8582201412e47f298c + md5: 69251ed374b31a5664bf5ba58626f3b7 + depends: + - __osx >=10.13 + - libcxx >=19 + - libevent >=2.1.12,<2.1.13.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.1,<4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 331822 + timestamp: 1753277335578 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libthrift-0.22.0-h14a376c_1.conda + sha256: 8b703f2c6e47ed5886d7298601b9416b59e823fc8d1a8fa867192c94c5911aac + md5: 3161023bb2f8c152e4c9aa59bdd40975 + depends: + - __osx >=11.0 + - libcxx >=19 + - libevent >=2.1.12,<2.1.13.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.1,<4.0a0 + license: Apache-2.0 + license_family: APACHE + size: 323360 + timestamp: 1753277264380 +- conda: https://conda.anaconda.org/conda-forge/win-64/libthrift-0.22.0-h23985f6_1.conda + sha256: 87516b128ffa497fc607d5da0cc0366dbee1dbcc14c962bf9ea951d480c7698b + md5: 556d49ad5c2ad553c2844cc570bb71c7 + depends: + - libevent >=2.1.12,<2.1.13.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.1,<4.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: APACHE + size: 636513 + timestamp: 1753277481158 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda + sha256: e5f8c38625aa6d567809733ae04bb71c161a42e44a9fa8227abe61fa5c60ebe0 + md5: cd5a90476766d53e901500df9215e927 + depends: + - __glibc >=2.17,<3.0.a0 + - lerc >=4.0.0,<5.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libgcc >=14 + - libjpeg-turbo >=3.1.0,<4.0a0 + - liblzma >=5.8.1,<6.0a0 + - libstdcxx >=14 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + size: 435273 + timestamp: 1762022005702 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libtiff-4.7.1-ha0a348c_1.conda + sha256: e53424c34147301beae2cd9223ebf593720d94c038b3f03cacd0535e12c9668e + md5: 9d4344f94de4ab1330cdc41c40152ea6 + depends: + - __osx >=10.13 + - lerc >=4.0.0,<5.0a0 + - libcxx >=19 + - libdeflate >=1.25,<1.26.0a0 + - libjpeg-turbo >=3.1.0,<4.0a0 + - liblzma >=5.8.1,<6.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + size: 404591 + timestamp: 1762022511178 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h4030677_1.conda + sha256: e9248077b3fa63db94caca42c8dbc6949c6f32f94d1cafad127f9005d9b1507f + md5: e2a72ab2fa54ecb6abab2b26cde93500 + depends: + - __osx >=11.0 + - lerc >=4.0.0,<5.0a0 + - libcxx >=19 + - libdeflate >=1.25,<1.26.0a0 + - libjpeg-turbo >=3.1.0,<4.0a0 + - liblzma >=5.8.1,<6.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + size: 373892 + timestamp: 1762022345545 +- conda: https://conda.anaconda.org/conda-forge/win-64/libtiff-4.7.1-h8f73337_1.conda + sha256: f1b8cccaaeea38a28b9cd496694b2e3d372bb5be0e9377c9e3d14b330d1cba8a + md5: 549845d5133100142452812feb9ba2e8 + depends: + - lerc >=4.0.0,<5.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libjpeg-turbo >=3.1.0,<4.0a0 + - liblzma >=5.8.1,<6.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + size: 993166 + timestamp: 1762022118895 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libutf8proc-2.11.2-hfe17d71_0.conda + sha256: 98812901f52df746f89e1fda2a65494dd30de9e826f89b49ebad5d53e5fc424d + md5: 5641725dfad698909ec71dac80d16736 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 85985 + timestamp: 1764062044259 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libutf8proc-2.11.2-h7983711_0.conda + sha256: 83f2799e28643c7793730aa32e007832ffb520c5d77714d2097c227424f33ef1 + md5: e630b1baa02a5eeb0ef351c6125865c4 + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 84943 + timestamp: 1764062312835 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libutf8proc-2.11.2-hd2415e0_0.conda + sha256: 5c7d4268a1bd02f3cbba6d8a8f9bd47829a46dbc81690a39b1c05e698c180570 + md5: 1ae98806b064c48f184d7c6e0ac506b6 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 88014 + timestamp: 1764062565080 +- conda: https://conda.anaconda.org/conda-forge/win-64/libutf8proc-2.11.2-hb980946_0.conda + sha256: ff63a5e402fb5007174ea9796a210617da898a43d00b4e8a3192537cad0bd403 + md5: 405c392813b74f3df06276e99c0e2841 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 89116 + timestamp: 1764062179403 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-h5347b49_1.conda + sha256: 030447cf827c471abd37092ab9714fde82b8222106f22fde94bc7a64e2704c40 + md5: 41f5c09a211985c3ce642d60721e7c3e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + size: 40235 + timestamp: 1764790744114 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda + sha256: c180f4124a889ac343fc59d15558e93667d894a966ec6fdb61da1604481be26b + md5: 0f03292cc56bf91a077a134ea8747118 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 895108 + timestamp: 1753948278280 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libuv-1.51.0-h58003a5_1.conda + sha256: d90dd0eee6f195a5bd14edab4c5b33be3635b674b0b6c010fb942b956aa2254c + md5: fbfc6cf607ae1e1e498734e256561dc3 + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 422612 + timestamp: 1753948458902 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.conda + sha256: 042c7488ad97a5629ec0a991a8b2a3345599401ecc75ad6a5af73b60e6db9689 + md5: c0d87c3c8e075daf1daf6c31b53e8083 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 421195 + timestamp: 1753948426421 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.328.1-h5279c79_0.conda + sha256: bbabc5c48b63ff03f440940a11d4648296f5af81bb7630d98485405cd32ac1ce + md5: 372a62464d47d9e966b630ffae3abe73 + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxrandr >=1.5.4,<2.0a0 + constrains: + - libvulkan-headers 1.4.328.1.* + license: Apache-2.0 + license_family: APACHE + size: 197672 + timestamp: 1759972155030 +- conda: https://conda.anaconda.org/conda-forge/win-64/libvulkan-loader-1.4.328.1-h477610d_0.conda + sha256: 934d676c445c1ea010753dfa98680b36a72f28bec87d15652f013c91a1d8d171 + md5: 4403eae6c81f448d63a7f66c0b330536 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + constrains: + - libvulkan-headers 1.4.328.1.* + license: Apache-2.0 + license_family: APACHE + size: 280488 + timestamp: 1759972163692 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda + sha256: 3aed21ab28eddffdaf7f804f49be7a7d701e8f0e46c856d801270b470820a37b + md5: aea31d2e5b1091feca96fcfe945c3cf9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + size: 429011 + timestamp: 1752159441324 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libwebp-base-1.6.0-hb807250_0.conda + sha256: 00dbfe574b5d9b9b2b519acb07545380a6bc98d1f76a02695be4995d4ec91391 + md5: 7bb6608cf1f83578587297a158a6630b + depends: + - __osx >=10.13 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + size: 365086 + timestamp: 1752159528504 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda + sha256: a4de3f371bb7ada325e1f27a4ef7bcc81b2b6a330e46fac9c2f78ac0755ea3dd + md5: e5e7d467f80da752be17796b87fe6385 + depends: + - __osx >=11.0 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + size: 294974 + timestamp: 1752159906788 +- conda: https://conda.anaconda.org/conda-forge/win-64/libwebp-base-1.6.0-h4d5522a_0.conda + sha256: 7b6316abfea1007e100922760e9b8c820d6fc19df3f42fb5aca684cfacb31843 + md5: f9bbae5e2537e3b06e0f7310ba76c893 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + size: 279176 + timestamp: 1752159543911 +- conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda + sha256: 0fccf2d17026255b6e10ace1f191d0a2a18f2d65088fd02430be17c701f8ffe0 + md5: 8a86073cf3b343b87d03f41790d8b4e5 + depends: + - ucrt + constrains: + - pthreads-win32 <0.0a0 + - msys2-conda-epoch <0.0a0 + license: MIT AND BSD-3-Clause-Clear + size: 36621 + timestamp: 1759768399557 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda + sha256: 666c0c431b23c6cec6e492840b176dde533d48b7e6fb8883f5071223433776aa + md5: 92ed62436b625154323d40d5f2f11dd7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - pthread-stubs + - xorg-libxau >=1.0.11,<2.0a0 + - xorg-libxdmcp + license: MIT + license_family: MIT + size: 395888 + timestamp: 1727278577118 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libxcb-1.17.0-hf1f96e2_0.conda + sha256: 8896cd5deff6f57d102734f3e672bc17120613647288f9122bec69098e839af7 + md5: bbeca862892e2898bdb45792a61c4afc + depends: + - __osx >=10.13 + - pthread-stubs + - xorg-libxau >=1.0.11,<2.0a0 + - xorg-libxdmcp + license: MIT + license_family: MIT + size: 323770 + timestamp: 1727278927545 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxcb-1.17.0-hdb1d25a_0.conda + sha256: bd3816218924b1e43b275863e21a3e13a5db4a6da74cca8e60bc3c213eb62f71 + md5: af523aae2eca6dfa1c8eec693f5b9a79 + depends: + - __osx >=11.0 + - pthread-stubs + - xorg-libxau >=1.0.11,<2.0a0 + - xorg-libxdmcp + license: MIT + license_family: MIT + size: 323658 + timestamp: 1727278733917 +- conda: https://conda.anaconda.org/conda-forge/win-64/libxcb-1.17.0-h0e4246c_0.conda + sha256: 08dec73df0e161c96765468847298a420933a36bc4f09b50e062df8793290737 + md5: a69bbf778a462da324489976c84cfc8c + depends: + - libgcc >=13 + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + - pthread-stubs + - ucrt >=10.0.20348.0 + - xorg-libxau >=1.0.11,<2.0a0 + - xorg-libxdmcp + license: MIT + license_family: MIT + size: 1208687 + timestamp: 1727279378819 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c + md5: 5aa797f8787fe7a17d1b0821485b5adc + depends: + - libgcc-ng >=12 + license: LGPL-2.1-or-later + size: 100393 + timestamp: 1702724383534 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda + sha256: d2195b5fbcb0af1ff7b345efdf89290c279b8d1d74f325ae0ac98148c375863c + md5: 2bca1fbb221d9c3c8e3a155784bbc2e9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - xkeyboard-config + - xorg-libxau >=1.0.12,<2.0a0 + license: MIT/X11 Derivative + license_family: MIT + size: 837922 + timestamp: 1764794163823 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda + sha256: ec0735ae56c3549149eebd7dc22c0bed91fd50c02eaa77ff418613ddda190aa8 + md5: e512be7dc1f84966d50959e900ca121f + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=75.1,<76.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libxml2-16 2.15.1 ha9997c6_0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 45283 + timestamp: 1761015644057 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.1-h7b7ecba_0.conda + sha256: ddf87bf05955d7870a41ca6f0e9fbd7b896b5a26ec1a98cd990883ac0b4f99bb + md5: e7ed73b34f9d43d80b7e80eba9bce9f3 + depends: + - __osx >=10.13 + - icu >=75.1,<76.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libxml2-16 2.15.1 ha1d9b0f_0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 39985 + timestamp: 1761015935429 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.1-h9329255_0.conda + sha256: c409e384ddf5976a42959265100d6b2c652017d250171eb10bae47ef8166193f + md5: fb5ce61da27ee937751162f86beba6d1 + depends: + - __osx >=11.0 + - icu >=75.1,<76.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libxml2-16 2.15.1 h0ff4647_0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 40607 + timestamp: 1761016108361 +- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.1-ha29bfb0_0.conda + sha256: fb51b91a01eac9ee5e26c67f4e081f09f970c18a3da5231b8172919a1e1b3b6b + md5: 87116b9de9c1825c3fd4ef92c984877b + depends: + - icu >=75.1,<76.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libxml2-16 2.15.1 h06f855e_0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 43042 + timestamp: 1761016261024 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda + sha256: 71436e72a286ef8b57d6f4287626ff91991eb03c7bdbe835280521791efd1434 + md5: e7733bc6785ec009e47a224a71917e84 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=75.1,<76.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libzlib >=1.3.1,<2.0a0 + constrains: + - libxml2 2.15.1 + license: MIT + license_family: MIT + size: 556302 + timestamp: 1761015637262 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.1-ha1d9b0f_0.conda + sha256: e23c5ac1da7b9b65bd18bf32b68717cd9da0387941178cb4d8cc5513eb69a0a9 + md5: 453807a4b94005e7148f89f9327eb1b7 + depends: + - __osx >=10.13 + - icu >=75.1,<76.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libzlib >=1.3.1,<2.0a0 + constrains: + - libxml2 2.15.1 + license: MIT + license_family: MIT + size: 494318 + timestamp: 1761015899881 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.1-h0ff4647_0.conda + sha256: ebe2dd9da94280ad43da936efa7127d329b559f510670772debc87602b49b06d + md5: 438c97d1e9648dd7342f86049dd44638 + depends: + - __osx >=11.0 + - icu >=75.1,<76.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libzlib >=1.3.1,<2.0a0 + constrains: + - libxml2 2.15.1 + license: MIT + license_family: MIT + size: 464952 + timestamp: 1761016087733 +- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.1-h06f855e_0.conda + sha256: 3f65ea0f04c7738116e74ca87d6e40f8ba55b3df31ef42b8cb4d78dd96645e90 + md5: 4a5ea6ec2055ab0dfd09fd0c498f834a + depends: + - icu >=75.1,<76.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - libxml2 2.15.1 + license: MIT + license_family: MIT + size: 518616 + timestamp: 1761016240185 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.43-h711ed8c_1.conda + sha256: 0694760a3e62bdc659d90a14ae9c6e132b525a7900e59785b18a08bb52a5d7e5 + md5: 87e6096ec6d542d1c1f8b33245fe8300 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libxml2 + - libxml2-16 >=2.14.6 + license: MIT + license_family: MIT + size: 245434 + timestamp: 1757963724977 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libxslt-1.1.43-h486b42e_1.conda + sha256: 00d6b5e92fc1c5d86e095b9b6840f793d9fc4c9b4a7753fa0f8197ab11d5eb90 + md5: 367b8029352f3899fb76cc20f4d144b9 + depends: + - __osx >=10.13 + - libxml2 + - libxml2-16 >=2.14.6 + license: MIT + license_family: MIT + size: 225660 + timestamp: 1757964032926 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxslt-1.1.43-hb2570ba_1.conda + sha256: 7a4d0676ab1407fecb24d4ada7fe31a98c8889f61f04612ea533599c22b8c472 + md5: 90f7ed12bb3c164c758131b3d3c2ab0c + depends: + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.14.6 + license: MIT + license_family: MIT + size: 220345 + timestamp: 1757964000982 +- conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda + sha256: 13da38939c2c20e7112d683ab6c9f304bfaf06230a2c6a7cf00359da1a003ec7 + md5: 46034d9d983edc21e84c0b36f1b4ba61 + depends: + - libxml2 + - libxml2-16 >=2.14.6 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 420223 + timestamp: 1757963935611 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda + sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4 + md5: edb0dca6bc32e4f4789199455a1dbeb8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + constrains: + - zlib 1.3.1 *_2 + license: Zlib + license_family: Other + size: 60963 + timestamp: 1727963148474 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda + sha256: 8412f96504fc5993a63edf1e211d042a1fd5b1d51dedec755d2058948fcced09 + md5: 003a54a4e32b02f7355b50a837e699da + depends: + - __osx >=10.13 + constrains: + - zlib 1.3.1 *_2 + license: Zlib + license_family: Other + size: 57133 + timestamp: 1727963183990 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda + sha256: ce34669eadaba351cd54910743e6a2261b67009624dbc7daeeafdef93616711b + md5: 369964e85dc26bfe78f41399b366c435 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.1 *_2 + license: Zlib + license_family: Other + size: 46438 + timestamp: 1727963202283 +- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda + sha256: ba945c6493449bed0e6e29883c4943817f7c79cbff52b83360f7b341277c6402 + md5: 41fbfac52c601159df6c01f875de31b9 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + constrains: + - zlib 1.3.1 *_2 + license: Zlib + license_family: Other + size: 55476 + timestamp: 1727963768015 +- conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-21.1.7-h472b3d1_0.conda + sha256: 5ae51ca08ac19ce5504b8201820ba6387365662033f20af2150ae7949f3f308a + md5: c9f0fc88c8f46637392b95bef78dc036 + depends: + - __osx >=10.13 + constrains: + - openmp 21.1.7|21.1.7.* + - intel-openmp <0.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + size: 311027 + timestamp: 1764721464764 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.7-h4a912ad_0.conda + sha256: 002695e79b0e4c2d117a8bd190ffd62ef3d74a4cae002afa580bd1f98f9560a3 + md5: 05d475f50ddcc2173a6beece9960c6cb + depends: + - __osx >=11.0 + constrains: + - openmp 21.1.7|21.1.7.* + - intel-openmp <0.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + size: 286129 + timestamp: 1764721670250 +- conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-21.1.7-h4fa8253_0.conda + sha256: 79121242419bf8b485c313fa28697c5c61ec207afa674eac997b3cb2fd1ff892 + md5: 5823741f7af732cd56036ae392396ec6 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - intel-openmp <0.0a0 + - openmp 21.1.7|21.1.7.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + size: 347969 + timestamp: 1764722187332 +- conda: https://conda.anaconda.org/conda-forge/linux-64/llvmlite-0.46.0-py314h946fb2a_0.conda + sha256: 99f15d69f059aa9c7d06cc45a6519a2375cc7a93ca85127964d6325a89a2b519 + md5: 7ee180b967506bbd108ca9d5ff45eace + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-2-Clause + license_family: BSD + size: 34123266 + timestamp: 1765279959565 +- conda: https://conda.anaconda.org/conda-forge/osx-64/llvmlite-0.46.0-py314h85c3bf0_0.conda + sha256: 468f68ddfad77e92de45a9023e92c8cea13df253bd27861de7cd594bc13f5569 + md5: babaf455ce9be7d7001cf048eb80508b + depends: + - __osx >=10.13 + - libcxx >=19 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-2-Clause + license_family: BSD + size: 26019299 + timestamp: 1765280661650 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvmlite-0.46.0-py314ha398f32_0.conda + sha256: 10ee25664d790b117d84701506b60caba147f7bf599215cbd688037aaa42ff81 + md5: b9eefe6197dafc779b784731fa507f60 + depends: + - __osx >=11.0 + - libcxx >=19 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-2-Clause + license_family: BSD + size: 24330524 + timestamp: 1765280789928 +- conda: https://conda.anaconda.org/conda-forge/win-64/llvmlite-0.46.0-py314hb492ee6_0.conda + sha256: 8f8bb4cd5a93aaf576e6861846f09dcff8f37032b02704e830d9afd3e6676d6b + md5: de5f7e2de23118d72f43c99fe7f2a942 + depends: + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-2-Clause + license_family: BSD + size: 22926897 + timestamp: 1765280131964 +- conda: https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2 + sha256: 9afe0b5cfa418e8bdb30d8917c5a6cec10372b037924916f1f85b9f4899a67a6 + md5: 91e27ef3d05cc772ce627e51cff111c4 + depends: + - python >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.* + license: BSD-2-Clause + license_family: BSD + size: 8250 + timestamp: 1650660473123 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lxml-6.0.2-py314hae3bed6_2.conda + sha256: 4871db69d62586fa264373cb1fee0fd2e3bbed2cddeca66bc423ccc9836c2e45 + md5: ddd2ee75713129777aa3e3339f899008 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libxml2 + - libxml2-16 >=2.14.6 + - libxslt >=1.1.43,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause and MIT-CMU + size: 1616783 + timestamp: 1762506575980 +- conda: https://conda.anaconda.org/conda-forge/osx-64/lxml-6.0.2-py314h787f955_2.conda + sha256: b76af19826dfe9fc7101fdeb3d96852d091a430b73fdf084fbd4bacd1e1a3210 + md5: 5bedcfaa028960dc42499bbd8be83c16 + depends: + - __osx >=10.13 + - libxml2 + - libxml2-16 >=2.14.6 + - libxslt >=1.1.43,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause and MIT-CMU + size: 1428805 + timestamp: 1762506862335 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lxml-6.0.2-py314he05ef12_2.conda + sha256: b13cb4b556e1973c81aefef5709059251084f5c80fe9fe94981141d7932ceb74 + md5: b53627cd79341966e489b2f8bc82f486 + depends: + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.14.6 + - libxslt >=1.1.43,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause and MIT-CMU + size: 1388732 + timestamp: 1762506892440 +- conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.0.2-py314hcdb55d9_2.conda + sha256: 6231fe0751c1174ddfba98267b58bbcc0cccc7cfd0c68163f29d0b46f1851085 + md5: d1feee0dad2ed550bbb59969d4d3457f + depends: + - libxml2 + - libxml2-16 >=2.14.6 + - libxslt >=1.1.43,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause and MIT-CMU + size: 1238889 + timestamp: 1762506487891 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + sha256: 47326f811392a5fd3055f0f773036c392d26fdb32e4d8e7a8197eed951489346 + md5: 9de5350a85c4a20c685259b889aa6393 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: BSD-2-Clause + license_family: BSD + size: 167055 + timestamp: 1733741040117 +- conda: https://conda.anaconda.org/conda-forge/osx-64/lz4-c-1.10.0-h240833e_1.conda + sha256: 8da3c9d4b596e481750440c0250a7e18521e7f69a47e1c8415d568c847c08a1c + md5: d6b9bd7e356abd7e3a633d59b753495a + depends: + - __osx >=10.13 + - libcxx >=18 + license: BSD-2-Clause + license_family: BSD + size: 159500 + timestamp: 1733741074747 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda + sha256: 94d3e2a485dab8bdfdd4837880bde3dd0d701e2b97d6134b8806b7c8e69c8652 + md5: 01511afc6cc1909c5303cf31be17b44f + depends: + - __osx >=11.0 + - libcxx >=18 + license: BSD-2-Clause + license_family: BSD + size: 148824 + timestamp: 1733741047892 +- conda: https://conda.anaconda.org/conda-forge/win-64/lz4-c-1.10.0-h2466b09_1.conda + sha256: 632cf3bdaf7a7aeb846de310b6044d90917728c73c77f138f08aa9438fc4d6b5 + md5: 0b69331897a92fac3d8923549d48d092 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: BSD-2-Clause + license_family: BSD + size: 139891 + timestamp: 1733741168264 +- conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda + sha256: 7b1da4b5c40385791dbc3cc85ceea9fad5da680a27d5d3cb8bfaa185e304a89e + md5: 5b5203189eb668f042ac2b0826244964 + depends: + - mdurl >=0.1,<1 + - python >=3.10 + license: MIT + license_family: MIT + size: 64736 + timestamp: 1754951288511 +- conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + sha256: e0cbfea51a19b3055ca19428bd9233a25adca956c208abb9d00b21e7259c7e03 + md5: fab1be106a50e20f10fe5228fd1d1651 + depends: + - python >=3.10 + constrains: + - jinja2 >=3.0.0 + track_features: + - markupsafe_no_compile + license: BSD-3-Clause + license_family: BSD + size: 15499 + timestamp: 1759055275624 +- conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.10.8-py314hdafbbf9_0.conda + sha256: 0c9417291ada8df3415ad13d52db38707adaba42584246264294e0faaaa54f77 + md5: 8286e3966eac286d5ac7c7a4afbac812 + depends: + - matplotlib-base >=3.10.8,<3.10.9.0a0 + - pyside6 >=6.7.2 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - tornado >=5 + license: PSF-2.0 + license_family: PSF + size: 17473 + timestamp: 1763055464987 +- conda: https://conda.anaconda.org/conda-forge/osx-64/matplotlib-3.10.8-py314hee6578b_0.conda + sha256: f32e8313e154db7b41c8147cb11f20c666e16b85abbc06ffebf7920c393aad0f + md5: 7fdf446de012e1750bf465b76412928d + depends: + - matplotlib-base >=3.10.8,<3.10.9.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - tornado >=5 + license: PSF-2.0 + license_family: PSF + size: 17466 + timestamp: 1763055821938 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-3.10.8-py314he55896b_0.conda + sha256: 070b99e48cd6dda06086116626203c100e6f34af771b34384848ce5abeaf683e + md5: ad9a3f773f13989b92b41c0eabed5a38 + depends: + - matplotlib-base >=3.10.8,<3.10.9.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - tornado >=5 + license: PSF-2.0 + license_family: PSF + size: 17538 + timestamp: 1763055987021 +- conda: https://conda.anaconda.org/conda-forge/win-64/matplotlib-3.10.8-py314h86ab7b2_0.conda + sha256: e7b6349b12f7d98ab7b595e01e486d3544083c694e8ee2c45a0b8f17016a7a0a + md5: e786fc5fefad7779cb2d954dd214fa37 + depends: + - matplotlib-base >=3.10.8,<3.10.9.0a0 + - pyside6 >=6.7.2 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - tornado >=5 + license: PSF-2.0 + license_family: PSF + size: 18016 + timestamp: 1763056036732 +- conda: https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.8-py314h1194b4b_0.conda + sha256: ee773261fbd6c76fc8174b0e4e1ce272b0bbaa56610f130e9d3d1f575106f04f + md5: b8683e6068099b69c10dbfcf7204203f + depends: + - __glibc >=2.17,<3.0.a0 + - contourpy >=1.0.1 + - cycler >=0.10 + - fonttools >=4.22.0 + - freetype + - kiwisolver >=1.3.1 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libgcc >=14 + - libstdcxx >=14 + - numpy >=1.23 + - numpy >=1.23,<3 + - packaging >=20.0 + - pillow >=8 + - pyparsing >=2.3.1 + - python >=3.14,<3.15.0a0 + - python-dateutil >=2.7 + - python_abi 3.14.* *_cp314 + - qhull >=2020.2,<2020.3.0a0 + - tk >=8.6.13,<8.7.0a0 + license: PSF-2.0 + license_family: PSF + size: 8473358 + timestamp: 1763055439346 +- conda: https://conda.anaconda.org/conda-forge/osx-64/matplotlib-base-3.10.8-py314hd47142c_0.conda + sha256: 912302723c6be178ccf47386ed2cd70ef7a8604e52e957a2e8d3807abe938da5 + md5: 91d76a5937b47f7f0894857ce88feb9f + depends: + - __osx >=10.13 + - contourpy >=1.0.1 + - cycler >=0.10 + - fonttools >=4.22.0 + - freetype + - kiwisolver >=1.3.1 + - libcxx >=19 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - numpy >=1.23 + - numpy >=1.23,<3 + - packaging >=20.0 + - pillow >=8 + - pyparsing >=2.3.1 + - python >=3.14,<3.15.0a0 + - python-dateutil >=2.7 + - python_abi 3.14.* *_cp314 + - qhull >=2020.2,<2020.3.0a0 + license: PSF-2.0 + license_family: PSF + size: 8224527 + timestamp: 1763055779683 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-base-3.10.8-py314hd63e3f0_0.conda + sha256: 198dcc0ed83e78bc7bf48e6ef8d4ecd220e9cf1f07db98508251b2bc0be067f9 + md5: c84152e510d41378b8758826655b6ed7 + depends: + - __osx >=11.0 + - contourpy >=1.0.1 + - cycler >=0.10 + - fonttools >=4.22.0 + - freetype + - kiwisolver >=1.3.1 + - libcxx >=19 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - numpy >=1.23 + - numpy >=1.23,<3 + - packaging >=20.0 + - pillow >=8 + - pyparsing >=2.3.1 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python-dateutil >=2.7 + - python_abi 3.14.* *_cp314 + - qhull >=2020.2,<2020.3.0a0 + license: PSF-2.0 + license_family: PSF + size: 8286510 + timestamp: 1763055937766 +- conda: https://conda.anaconda.org/conda-forge/win-64/matplotlib-base-3.10.8-py314hfa45d96_0.conda + sha256: 82a50284275e8a1818cd3323846f3032dc89bd23a3f80dcf44e34a62b016256b + md5: 9d491a60700e0e90e92607fcc4e2566c + depends: + - contourpy >=1.0.1 + - cycler >=0.10 + - fonttools >=4.22.0 + - freetype + - kiwisolver >=1.3.1 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - numpy >=1.23 + - numpy >=1.23,<3 + - packaging >=20.0 + - pillow >=8 + - pyparsing >=2.3.1 + - python >=3.14,<3.15.0a0 + - python-dateutil >=2.7 + - python_abi 3.14.* *_cp314 + - qhull >=2020.2,<2020.3.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: PSF-2.0 + license_family: PSF + size: 8185296 + timestamp: 1763055983613 +- conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda + sha256: 9d690334de0cd1d22c51bc28420663f4277cfa60d34fa5cad1ce284a13f1d603 + md5: 00e120ce3e40bad7bfc78861ce3c4a25 + depends: + - python >=3.10 + - traitlets + license: BSD-3-Clause + license_family: BSD + size: 15175 + timestamp: 1761214578417 +- conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + sha256: 78c1bbe1723449c52b7a9df1af2ee5f005209f67e40b6e1d3c7619127c43b1c7 + md5: 592132998493b3ff25fd7479396e8351 + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 14465 + timestamp: 1733255681319 +- conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2025.3.0-hac47afa_454.conda + sha256: 3c432e77720726c6bd83e9ee37ac8d0e3dd7c4cf9b4c5805e1d384025f9e9ab6 + md5: c83ec81713512467dfe1b496a8292544 + depends: + - llvm-openmp >=21.1.4 + - tbb >=2022.2.0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: LicenseRef-IntelSimplifiedSoftwareOct2022 + license_family: Proprietary + size: 99909095 + timestamp: 1761668703167 +- conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda + sha256: 449609f0d250607a300754474350a3b61faf45da183d3071e9720e453c765b8a + md5: 32f78e9d06e8593bc4bbf1338da06f5f + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 69210 + timestamp: 1764487059562 +- conda: https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda + sha256: 7d7aa3fcd6f42b76bd711182f3776a02bef09a68c5f117d66b712a6d81368692 + md5: 3585aa87c43ab15b167b574cd73b057b + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 439705 + timestamp: 1733302781386 +- conda: https://conda.anaconda.org/conda-forge/noarch/multidict-6.7.0-pyh62beb40_0.conda + sha256: 1edb22a6cf563a24fcdd1185e9fd9b98b1571233460de1eefe903edd28ac8321 + md5: cf7c106c72e6fd92fee6ded0bd76d343 + depends: + - python >=3.10 + - typing-extensions >=4.1.0 + track_features: + - multidict_no_compile + license: Apache-2.0 + license_family: APACHE + size: 37469 + timestamp: 1765460459538 +- conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + sha256: d09c47c2cf456de5c09fa66d2c3c5035aa1fa228a1983a433c47b876aa16ce90 + md5: 37293a85a0f4f77bbd9cf7aaefc62609 + depends: + - python >=3.9 + license: Apache-2.0 + license_family: Apache + size: 15851 + timestamp: 1749895533014 +- conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + sha256: 6ed158e4e5dd8f6a10ad9e525631e35cee8557718f83de7a4e3966b1f772c4b1 + md5: e9c622e0d00fa24a6292279af3ab6d06 + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 11766 + timestamp: 1745776666688 +- conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.13.0-pyhcf101f3_0.conda + sha256: 03220ba0560de1d81b8b122e8ff6313238dbb1ed621db39f4b81f767904ed475 + md5: 0129bb97a81c2ca0f57031673424387a + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 268700 + timestamp: 1764604454148 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 + md5: 47e340acb35de30501a76c7c799c41d7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: X11 AND BSD-3-Clause + size: 891641 + timestamp: 1738195959188 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda + sha256: ea4a5d27ded18443749aefa49dc79f6356da8506d508b5296f60b8d51e0c4bd9 + md5: ced34dd9929f491ca6dab6a2927aff25 + depends: + - __osx >=10.13 + license: X11 AND BSD-3-Clause + size: 822259 + timestamp: 1738196181298 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda + sha256: 2827ada40e8d9ca69a153a45f7fd14f32b2ead7045d3bbb5d10964898fe65733 + md5: 068d497125e4bf8a66bf707254fff5ae + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + size: 797030 + timestamp: 1738196177597 +- conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + sha256: bb7b21d7fd0445ddc0631f64e66d91a179de4ba920b8381f29b9d006a42788c0 + md5: 598fd7d4d0de2455fb74f56063969a97 + depends: + - python >=3.9 + license: BSD-2-Clause + license_family: BSD + size: 11543 + timestamp: 1733325673691 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda + sha256: fd2cbd8dfc006c72f45843672664a8e4b99b2f8137654eaae8c3d46dca776f63 + md5: 16c2a0e9c4a166e53632cfca4f68d020 + constrains: + - nlohmann_json-abi ==3.12.0 + license: MIT + license_family: MIT + size: 136216 + timestamp: 1758194284857 +- conda: https://conda.anaconda.org/conda-forge/osx-64/nlohmann_json-3.12.0-h53ec75d_1.conda + sha256: 186edb5fe84bddf12b5593377a527542f6ba42486ca5f49cd9dfeda378fb0fbe + md5: 5e9bee5fa11d91e1621e477c3cb9b9ba + constrains: + - nlohmann_json-abi ==3.12.0 + license: MIT + license_family: MIT + size: 136667 + timestamp: 1758194361656 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nlohmann_json-3.12.0-h248ca61_1.conda + sha256: f6aa432b073778c3970d3115d291267f32ae85adfa99d80ff1abdf0b806aa249 + md5: 3ba9d0c21af2150cb92b2ab8bdad3090 + constrains: + - nlohmann_json-abi ==3.12.0 + license: MIT + license_family: MIT + size: 136912 + timestamp: 1758194464430 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.2.1-he2c55a7_1.conda + sha256: 6516f99fe400181ebe27cba29180ca0c7425c15d7392f74220a028ad0e0064a2 + md5: d8005b3a90515c952b51026f6b7d005d + depends: + - __glibc >=2.28,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - zstd >=1.5.7,<1.6.0a0 + - c-ares >=1.34.6,<2.0a0 + - libuv >=1.51.0,<2.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libnghttp2 >=1.67.0,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - libabseil >=20250512.1,<20250513.0a0 + - libabseil * cxx17* + - libzlib >=1.3.1,<2.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - icu >=75.1,<76.0a0 + license: MIT + license_family: MIT + size: 17246248 + timestamp: 1765444698486 +- conda: https://conda.anaconda.org/conda-forge/osx-64/nodejs-25.2.1-h5523da6_1.conda + sha256: 25ade898cb9e6f26622cc563dab89810f59e898e37ec4ffabd079f9f9a068998 + md5: 18ce8107e5d71b65aaa585c238a9e90d + depends: + - __osx >=11.0 + - libcxx >=19 + - libsqlite >=3.51.1,<4.0a0 + - libabseil >=20250512.1,<20250513.0a0 + - libabseil * cxx17* + - zstd >=1.5.7,<1.6.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libnghttp2 >=1.67.0,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - libuv >=1.51.0,<2.0a0 + - c-ares >=1.34.6,<2.0a0 + - icu >=75.1,<76.0a0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 16923801 + timestamp: 1765444650323 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-25.2.1-h5230ea7_1.conda + sha256: acb4a33a096fa89d0ec0eea5d5f19988594d4e5c8d482ac60d2b0365d16dd984 + md5: 0b6dfe96bcfb469afe82885b3fecbd56 + depends: + - __osx >=11.0 + - libcxx >=19 + - libsqlite >=3.51.1,<4.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - openssl >=3.5.4,<4.0a0 + - c-ares >=1.34.6,<2.0a0 + - icu >=75.1,<76.0a0 + - zstd >=1.5.7,<1.6.0a0 + - libabseil >=20250512.1,<20250513.0a0 + - libabseil * cxx17* + - libnghttp2 >=1.67.0,<2.0a0 + - libuv >=1.51.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + size: 16202237 + timestamp: 1765482731453 +- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-25.2.1-he453025_1.conda + sha256: 9742d28cf4a171dc9898bfb3c8512858f1ed46aa3cbc26d8839003d879564beb + md5: 461d47b472740c68ec0771c8b759868b + license: MIT + license_family: MIT + size: 30449097 + timestamp: 1765444649904 +- conda: https://conda.anaconda.org/conda-forge/noarch/nomkl-1.0-h5ca1d4c_0.tar.bz2 + sha256: d38542a151a90417065c1a234866f97fd1ea82a81de75ecb725955ab78f88b4b + md5: 9a66894dfd07c4510beb6b3f9672ccc0 + constrains: + - mkl <0.a0 + license: BSD-3-Clause + license_family: BSD + size: 3843 + timestamp: 1582593857545 +- conda: https://conda.anaconda.org/conda-forge/linux-64/numba-0.63.1-py314h8169c2f_0.conda + sha256: 6ab91790aeee336cc4526b02b477eb0f261df6bd9645f44a138b1e8a3ccc5e60 + md5: 9dfbe6bd11b1c77f618b347ec654b37b + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + - libgcc >=14 + - libstdcxx >=14 + - llvmlite >=0.46.0,<0.47.0a0 + - numpy >=1.22.3,<2.4 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - tbb >=2021.6.0 + - libopenblas !=0.3.6 + - cuda-version >=11.2 + - cudatoolkit >=11.2 + - cuda-python >=11.6 + - scipy >=1.0 + license: BSD-2-Clause + license_family: BSD + size: 5797268 + timestamp: 1765466862046 +- conda: https://conda.anaconda.org/conda-forge/osx-64/numba-0.63.0-py314h385e359_0.conda + sha256: 3c67a020a87a6cd378159bb9d08f998d808bfad29e6f9e147b0b183312e6baf0 + md5: 165bd22e3dc74ccd73e2f061ffecd870 + depends: + - __osx >=10.13 + - libcxx >=19 + - llvm-openmp >=19.1.7 + - llvm-openmp >=21.1.7 + - llvmlite >=0.46.0,<0.47.0a0 + - numpy >=1.22.3,<2.4 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - cuda-python >=11.6 + - libopenblas !=0.3.6 + - tbb >=2021.6.0 + - cuda-version >=11.2 + - scipy >=1.0 + - cudatoolkit >=11.2 + license: BSD-2-Clause + license_family: BSD + size: 5787861 + timestamp: 1765321991941 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numba-0.63.1-py314h945de62_0.conda + sha256: 4e6acf20fafec2b390e73c54bb348f71ef2fd0092e179e370fdf4ad4c2862baa + md5: 4f9128c2986d86725aa0dd5a5dfff168 + depends: + - __osx >=11.0 + - libcxx >=19 + - llvm-openmp >=19.1.7 + - llvm-openmp >=21.1.7 + - llvmlite >=0.46.0,<0.47.0a0 + - numpy >=1.22.3,<2.4 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - scipy >=1.0 + - libopenblas >=0.3.18,!=0.3.20 + - cudatoolkit >=11.2 + - cuda-version >=11.2 + - cuda-python >=11.6 + - tbb >=2021.6.0 + license: BSD-2-Clause + license_family: BSD + size: 5780959 + timestamp: 1765466926700 +- conda: https://conda.anaconda.org/conda-forge/win-64/numba-0.63.1-py314h36f8cf2_0.conda + sha256: 1bbfc2793e04aaac5d289e6e5bec8b020b4419c4af1e161ab409c6995d1cc89d + md5: a77827229f4dfdbae9d503707d41a277 + depends: + - llvmlite >=0.46.0,<0.47.0a0 + - numpy >=1.22.3,<2.4 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - tbb >=2021.6.0 + - libopenblas !=0.3.6 + - cuda-version >=11.2 + - cudatoolkit >=11.2 + - scipy >=1.0 + - cuda-python >=11.6 + license: BSD-2-Clause + license_family: BSD + size: 5775759 + timestamp: 1765466860567 +- conda: https://conda.anaconda.org/conda-forge/linux-64/numexpr-2.14.1-py314heb044ea_101.conda + sha256: d9911d3d54c8fe25e4506c3171fee107a2222b60b7916ba9e8aa10e0b39153ea + md5: 9b1f7d691ba516ec40fa43fc28fcf5be + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - nomkl + - numpy >=1.23,<3 + - numpy >=1.23.0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 217238 + timestamp: 1762594968114 +- conda: https://conda.anaconda.org/conda-forge/osx-64/numexpr-2.14.1-py314h205861b_1.conda + sha256: 68d602e1fea2626e802ba541aa8620032c9f7a5cab0ef73193429a57f56fc19d + md5: 9bfbdd8222dc1cffa8fda9000e5edd60 + depends: + - __osx >=10.13 + - libcxx >=19 + - numpy >=1.23,<3 + - numpy >=1.23.0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 209762 + timestamp: 1762595270088 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numexpr-2.14.1-py314hc5bb990_1.conda + sha256: 36fec9e03675c08ebcba1a85dd8d1de0962bf433ee8ea65e832805466537741f + md5: 4dcec6227b059dae2fc56a5f58ddda48 + depends: + - __osx >=11.0 + - libcxx >=19 + - numpy >=1.23,<3 + - numpy >=1.23.0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 202180 + timestamp: 1762595578484 +- conda: https://conda.anaconda.org/conda-forge/win-64/numexpr-2.14.1-mkl_py314h220b711_1.conda + sha256: 421d316bd2d3bc3e9ccd16bf4e937481292dcb20aa03f6b11c101c892f5f120b + md5: 3ee35b3d4e12cbb427bde41eb4a2c174 + depends: + - libblas * *mkl + - mkl >=2025.3.0,<2026.0a0 + - numpy >=1.23,<3 + - numpy >=1.23.0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 210520 + timestamp: 1764766861691 +- conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.5-py314h2b28147_0.conda + sha256: 4fa3b8b80dd848a70f679b31d74d6fb28f9c4de9cd81086aa8e10256e9de20d1 + md5: 6d2cff81447b8fe424645d7dd3bde8bf + depends: + - python + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + license_family: BSD + size: 8983459 + timestamp: 1763350996398 +- conda: https://conda.anaconda.org/conda-forge/osx-64/numpy-2.3.5-py314hf08249b_0.conda + sha256: 77e0b2ddb433ac23ca9d587c37a8f6da9baee3888c34d19e530fe8cbaaf49bdc + md5: 5c9e4bc0c170115fd3602d7377c9e8da + depends: + - python + - libcxx >=19 + - __osx >=10.13 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - python_abi 3.14.* *_cp314 + - libblas >=3.9.0,<4.0a0 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + license_family: BSD + size: 8140127 + timestamp: 1763350902772 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.3.5-py314h5b5928d_0.conda + sha256: a8731e3e31013be69cb585dbc57cb225437bb0c945ddce9a550c1cd10b6fad37 + md5: e126981f973ddc2510d7a249c5b69533 + depends: + - python + - python 3.14.* *_cp314 + - __osx >=11.0 + - libcxx >=19 + - libcblas >=3.9.0,<4.0a0 + - libblas >=3.9.0,<4.0a0 + - python_abi 3.14.* *_cp314 + - liblapack >=3.9.0,<4.0a0 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + license_family: BSD + size: 6861174 + timestamp: 1763350930747 +- conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.3.5-py314h06c3c77_0.conda + sha256: e64d4c049c9c69ef02d924ac1750b32e08f57732cbc6a3fe11794f3169b59d14 + md5: ddc6687a8f402695bd22229aaf69fb26 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - liblapack >=3.9.0,<4.0a0 + - python_abi 3.14.* *_cp314 + - libcblas >=3.9.0,<4.0a0 + - libblas >=3.9.0,<4.0a0 + constrains: + - numpy-base <0a0 + license: BSD-3-Clause + license_family: BSD + size: 7588219 + timestamp: 1763350950306 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + sha256: 3900f9f2dbbf4129cf3ad6acf4e4b6f7101390b53843591c53b00f034343bc4d + md5: 11b3379b191f63139e29c0d19dee24cd + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpng >=1.6.50,<1.7.0a0 + - libstdcxx >=14 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-2-Clause + license_family: BSD + size: 355400 + timestamp: 1758489294972 +- conda: https://conda.anaconda.org/conda-forge/osx-64/openjpeg-2.5.4-h87e8dc5_0.conda + sha256: fdf4708a4e45b5fd9868646dd0c0a78429f4c0b8be490196c975e06403a841d0 + md5: a67d3517ebbf615b91ef9fdc99934e0c + depends: + - __osx >=10.13 + - libcxx >=19 + - libpng >=1.6.50,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-2-Clause + license_family: BSD + size: 334875 + timestamp: 1758489493148 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hbfb3c88_0.conda + sha256: dd73e8f1da7dd6a5494c5586b835cbe2ec68bace55610b1c4bf927400fe9c0d7 + md5: 6bf3d24692c157a41c01ce0bd17daeea + depends: + - __osx >=11.0 + - libcxx >=19 + - libpng >=1.6.50,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-2-Clause + license_family: BSD + size: 319967 + timestamp: 1758489514651 +- conda: https://conda.anaconda.org/conda-forge/win-64/openjpeg-2.5.4-h24db6dd_0.conda + sha256: 226c270a7e3644448954c47959c00a9bf7845f6d600c2a643db187118d028eee + md5: 5af852046226bb3cb15c7f61c2ac020a + depends: + - libpng >=1.6.50,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-2-Clause + license_family: BSD + size: 244860 + timestamp: 1758489556249 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.10-he970967_0.conda + sha256: cb0b07db15e303e6f0a19646807715d28f1264c6350309a559702f4f34f37892 + md5: 2e5bf4f1da39c0b32778561c3c4e5878 + depends: + - __glibc >=2.17,<3.0.a0 + - cyrus-sasl >=2.1.27,<3.0a0 + - krb5 >=1.21.3,<1.22.0a0 + - libgcc >=13 + - libstdcxx >=13 + - openssl >=3.5.0,<4.0a0 + license: OLDAP-2.8 + license_family: BSD + size: 780253 + timestamp: 1748010165522 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda + sha256: a47271202f4518a484956968335b2521409c8173e123ab381e775c358c67fe6d + md5: 9ee58d5c534af06558933af3c845a780 + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + size: 3165399 + timestamp: 1762839186699 +- conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.0-h230baf5_0.conda + sha256: 36fe9fb316be22fcfb46d5fa3e2e85eec5ef84f908b7745f68f768917235b2d5 + md5: 3f50cdf9a97d0280655758b735781096 + depends: + - __osx >=10.13 + - ca-certificates + license: Apache-2.0 + license_family: Apache + size: 2778996 + timestamp: 1762840724922 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda + sha256: ebe93dafcc09e099782fe3907485d4e1671296bc14f8c383cb6f3dfebb773988 + md5: b34dc4172653c13dcf453862f251af2b + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + size: 3108371 + timestamp: 1762839712322 +- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.0-h725018a_0.conda + sha256: 6d72d6f766293d4f2aa60c28c244c8efed6946c430814175f959ffe8cab899b3 + md5: 84f8fb4afd1157f59098f618cd2437e4 + depends: + - ca-certificates + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + size: 9440812 + timestamp: 1762841722179 +- conda: https://conda.anaconda.org/conda-forge/linux-64/orc-2.2.1-hd747db4_0.conda + sha256: 8d91d6398fc63a94d238e64e4983d38f6f9555460f11bed00abb2da04dbadf7c + md5: ddab8b2af55b88d63469c040377bd37e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.2,<1.3.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 + license_family: Apache + size: 1316445 + timestamp: 1759424644934 +- conda: https://conda.anaconda.org/conda-forge/osx-64/orc-2.2.1-hd1b02dc_0.conda + sha256: a00d48750d2140ea97d92b32c171480b76b2632dbb9d19d1ae423999efcc825f + md5: b4646b6ddcbcb3b10e9879900c66ed48 + depends: + - __osx >=11.0 + - libcxx >=19 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.2,<1.3.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 + license_family: Apache + size: 521463 + timestamp: 1759424838652 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/orc-2.2.1-h4fd0076_0.conda + sha256: f0a31625a647cb8d55a7016950c11f8fabc394df5054d630e9c9b526bf573210 + md5: b5dea50c77ab3cc18df48bdc9994ac44 + depends: + - __osx >=11.0 + - libcxx >=19 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.2,<1.3.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 + license_family: Apache + size: 487298 + timestamp: 1759424875005 +- conda: https://conda.anaconda.org/conda-forge/win-64/orc-2.2.1-h7414dfc_0.conda + sha256: f28f8f2d743c2091f76161b8d59f82c4ba4970d03cb9900c52fb908fe5e8a7c4 + md5: a9b6ebf475194b0e5ad43168e9b936a7 + depends: + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - snappy >=1.2.2,<1.3.0a0 + - tzdata + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 + license_family: Apache + size: 1064397 + timestamp: 1759424869069 +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + sha256: 289861ed0c13a15d7bbb408796af4de72c2fe67e2bcb0de98f4c3fce259d7991 + md5: 58335b26c38bf4a20f399384c33cbcf9 + depends: + - python >=3.8 + - python + license: Apache-2.0 + license_family: APACHE + size: 62477 + timestamp: 1745345660407 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-2.3.3-py314ha0b5721_2.conda + sha256: 0a86a582b906d9cfd4d2c59180898fe9d714b55eea7ced71630a1fedae206c62 + md5: fe3a5c8be07a7b82058bdeb39d33d93b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - numpy >=1.22.4 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python-dateutil >=2.8.2 + - python-tzdata >=2022.7 + - python_abi 3.14.* *_cp314 + - pytz >=2020.1 + constrains: + - pyarrow >=10.0.1 + - numba >=0.56.4 + - odfpy >=1.4.1 + - xlsxwriter >=3.0.5 + - tabulate >=0.9.0 + - html5lib >=1.1 + - lxml >=4.9.2 + - blosc >=1.21.3 + - s3fs >=2022.11.0 + - fsspec >=2022.11.0 + - psycopg2 >=2.9.6 + - pandas-gbq >=0.19.0 + - openpyxl >=3.1.0 + - qtpy >=2.3.0 + - python-calamine >=0.1.7 + - sqlalchemy >=2.0.0 + - pyqt5 >=5.15.9 + - bottleneck >=1.3.6 + - zstandard >=0.19.0 + - numexpr >=2.8.4 + - tzdata >=2022.7 + - scipy >=1.10.0 + - gcsfs >=2022.11.0 + - pyxlsb >=1.0.10 + - matplotlib >=3.6.3 + - pytables >=3.8.0 + - beautifulsoup4 >=4.11.2 + - pyreadstat >=1.2.0 + - fastparquet >=2022.12.0 + - xlrd >=2.0.1 + - xarray >=2022.12.0 + license: BSD-3-Clause + license_family: BSD + size: 15178918 + timestamp: 1764615084415 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pandas-2.3.3-py314hc4308db_2.conda + sha256: 66df07b283018490ca7e75fd869a4ad8e542e61bf916f17463c8ad022cce7ffd + md5: b082e18eb2696625aa09c80e0fbd1997 + depends: + - __osx >=10.13 + - libcxx >=19 + - numpy >=1.22.4 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python-dateutil >=2.8.2 + - python-tzdata >=2022.7 + - python_abi 3.14.* *_cp314 + - pytz >=2020.1 + constrains: + - openpyxl >=3.1.0 + - lxml >=4.9.2 + - tzdata >=2022.7 + - blosc >=1.21.3 + - pandas-gbq >=0.19.0 + - pyarrow >=10.0.1 + - odfpy >=1.4.1 + - sqlalchemy >=2.0.0 + - bottleneck >=1.3.6 + - gcsfs >=2022.11.0 + - beautifulsoup4 >=4.11.2 + - fsspec >=2022.11.0 + - numba >=0.56.4 + - pyxlsb >=1.0.10 + - scipy >=1.10.0 + - pyqt5 >=5.15.9 + - xarray >=2022.12.0 + - qtpy >=2.3.0 + - numexpr >=2.8.4 + - tabulate >=0.9.0 + - pyreadstat >=1.2.0 + - zstandard >=0.19.0 + - html5lib >=1.1 + - matplotlib >=3.6.3 + - xlsxwriter >=3.0.5 + - fastparquet >=2022.12.0 + - python-calamine >=0.1.7 + - xlrd >=2.0.1 + - pytables >=3.8.0 + - psycopg2 >=2.9.6 + - s3fs >=2022.11.0 + license: BSD-3-Clause + license_family: BSD + size: 14362288 + timestamp: 1764615196689 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandas-2.3.3-py314ha3d490a_2.conda + sha256: f71fc63904d80ef7bf4e882b420426e167e02cf68b9bd71ea6beb0a9d0c37430 + md5: 6e2f31aca92c525a884c509738aca93a + depends: + - __osx >=11.0 + - libcxx >=19 + - numpy >=1.22.4 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python-dateutil >=2.8.2 + - python-tzdata >=2022.7 + - python_abi 3.14.* *_cp314 + - pytz >=2020.1 + constrains: + - odfpy >=1.4.1 + - zstandard >=0.19.0 + - blosc >=1.21.3 + - html5lib >=1.1 + - numexpr >=2.8.4 + - gcsfs >=2022.11.0 + - sqlalchemy >=2.0.0 + - numba >=0.56.4 + - pyqt5 >=5.15.9 + - fastparquet >=2022.12.0 + - pandas-gbq >=0.19.0 + - pytables >=3.8.0 + - qtpy >=2.3.0 + - fsspec >=2022.11.0 + - s3fs >=2022.11.0 + - pyreadstat >=1.2.0 + - pyxlsb >=1.0.10 + - pyarrow >=10.0.1 + - xlrd >=2.0.1 + - xarray >=2022.12.0 + - beautifulsoup4 >=4.11.2 + - tabulate >=0.9.0 + - psycopg2 >=2.9.6 + - bottleneck >=1.3.6 + - matplotlib >=3.6.3 + - python-calamine >=0.1.7 + - lxml >=4.9.2 + - openpyxl >=3.1.0 + - scipy >=1.10.0 + - xlsxwriter >=3.0.5 + - tzdata >=2022.7 + license: BSD-3-Clause + license_family: BSD + size: 14130201 + timestamp: 1764615862386 +- conda: https://conda.anaconda.org/conda-forge/win-64/pandas-2.3.3-py314hd8fd7ce_2.conda + sha256: a1c87d34f72d6ae3f78203c60cf1b1adfb8d5cf55a3fc90f47e9f9ed50eb8b91 + md5: 95cf7fc22f898b6faeb1d62ce2f5b82c + depends: + - numpy >=1.22.4 + - numpy >=1.23,<3 + - python >=3.14,<3.15.0a0 + - python-dateutil >=2.8.2 + - python-tzdata >=2022.7 + - python_abi 3.14.* *_cp314 + - pytz >=2020.1 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - scipy >=1.10.0 + - sqlalchemy >=2.0.0 + - fsspec >=2022.11.0 + - pyreadstat >=1.2.0 + - gcsfs >=2022.11.0 + - tabulate >=0.9.0 + - openpyxl >=3.1.0 + - pytables >=3.8.0 + - qtpy >=2.3.0 + - matplotlib >=3.6.3 + - bottleneck >=1.3.6 + - python-calamine >=0.1.7 + - numba >=0.56.4 + - beautifulsoup4 >=4.11.2 + - tzdata >=2022.7 + - xarray >=2022.12.0 + - pyqt5 >=5.15.9 + - odfpy >=1.4.1 + - xlrd >=2.0.1 + - pyarrow >=10.0.1 + - s3fs >=2022.11.0 + - psycopg2 >=2.9.6 + - pandas-gbq >=0.19.0 + - xlsxwriter >=3.0.5 + - fastparquet >=2022.12.0 + - numexpr >=2.8.4 + - zstandard >=0.19.0 + - lxml >=4.9.2 + - pyxlsb >=1.0.10 + - html5lib >=1.1 + - blosc >=1.21.3 + license: BSD-3-Clause + license_family: BSD + size: 14046781 + timestamp: 1764615388271 +- conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda + sha256: 30de7b4d15fbe53ffe052feccde31223a236dae0495bab54ab2479de30b2990f + md5: a110716cdb11cf51482ff4000dc253d7 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 81562 + timestamp: 1755974222274 +- conda: https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda + sha256: 472fc587c63ec4f6eba0cc0b06008a6371e0a08a5986de3cf4e8024a47b4fe6c + md5: 0badf9c54e24cecfb0ad2f99d680c163 + depends: + - locket + - python >=3.9 + - toolz + license: BSD-3-Clause + license_family: BSD + size: 20884 + timestamp: 1715026639309 +- conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda + sha256: 9f64009cdf5b8e529995f18e03665b03f5d07c0b17445b8badef45bde76249ee + md5: 617f15191456cc6a13db418a275435e5 + depends: + - python >=3.9 + license: MPL-2.0 + license_family: MOZILLA + size: 41075 + timestamp: 1733233471940 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda + sha256: 5c7380c8fd3ad5fc0f8039069a45586aa452cf165264bc5a437ad80397b32934 + md5: 7fa07cb0fb1b625a089ccc01218ee5b1 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 1209177 + timestamp: 1756742976157 +- conda: https://conda.anaconda.org/conda-forge/win-64/pcre2-10.46-h3402e2f_0.conda + sha256: 29c2ed44a8534d27faad96bdce16efe29c2788f556f4c5409d4ae8ae074681ec + md5: 889053e920d15353c2665fa6310d7a7a + depends: + - bzip2 >=1.0.8,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 1034703 + timestamp: 1756743085974 +- conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda + sha256: 202af1de83b585d36445dc1fda94266697341994d1a3328fabde4989e1b3d07a + md5: d0d408b1f18883a944376da5cf8101ea + depends: + - ptyprocess >=0.5 + - python >=3.9 + license: ISC + size: 53561 + timestamp: 1733302019362 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.0.0-py314h8ec4b1a_2.conda + sha256: e08f64a5df6ced2a5a366d82377857d7e71ff7b74a3dd1db5b6ddbca39cbe6e1 + md5: 8cad8a4569a55fe71631eaaea27fe451 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libxcb >=1.17.0,<2.0a0 + - tk >=8.6.13,<8.7.0a0 + - openjpeg >=2.5.4,<3.0a0 + - libjpeg-turbo >=3.1.2,<4.0a0 + - lcms2 >=2.17,<3.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - python_abi 3.14.* *_cp314 + - zlib-ng >=2.3.1,<2.4.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + license: HPND + size: 1071517 + timestamp: 1764330106864 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pillow-12.0.0-py314hedf0282_2.conda + sha256: becb686065e95a5ab8acd1ea7894a01dc7f0a736413bb4a7f9fbaad7c96cb2f1 + md5: 399177697c7225b64edeaeb373a8c98b + depends: + - python + - __osx >=10.13 + - libwebp-base >=1.6.0,<2.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libxcb >=1.17.0,<2.0a0 + - zlib-ng >=2.3.1,<2.4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libjpeg-turbo >=3.1.2,<4.0a0 + - lcms2 >=2.17,<3.0a0 + - python_abi 3.14.* *_cp314 + - openjpeg >=2.5.4,<3.0a0 + - tk >=8.6.13,<8.7.0a0 + license: HPND + size: 1002287 + timestamp: 1764330319004 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pillow-12.0.0-py314h57fbdfe_2.conda + sha256: 2cf1346e3aa8ab9f73d533fb55d753dd3a8d64b50f86d1f0e4f3ff8669c3b0d9 + md5: 8c10435c6b30aaa4c376106d68298f6f + depends: + - python + - python 3.14.* *_cp314 + - __osx >=11.0 + - libjpeg-turbo >=3.1.2,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - tk >=8.6.13,<8.7.0a0 + - openjpeg >=2.5.4,<3.0a0 + - zlib-ng >=2.3.1,<2.4.0a0 + - python_abi 3.14.* *_cp314 + - libxcb >=1.17.0,<2.0a0 + - lcms2 >=2.17,<3.0a0 + - libwebp-base >=1.6.0,<2.0a0 + license: HPND + size: 993019 + timestamp: 1764330196019 +- conda: https://conda.anaconda.org/conda-forge/win-64/pillow-12.0.0-py314h61b30b5_2.conda + sha256: a428b9d5c64d1ab9eac878755e72301003efe618d1430e13a18ebdf5f332dfd8 + md5: 27562522f8d26e6fad29e234f6ae48be + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + - libxcb >=1.17.0,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libjpeg-turbo >=3.1.2,<4.0a0 + - tk >=8.6.13,<8.7.0a0 + - zlib-ng >=2.3.1,<2.4.0a0 + - lcms2 >=2.17,<3.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - openjpeg >=2.5.4,<3.0a0 + license: HPND + size: 971941 + timestamp: 1764330112083 +- conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda + sha256: 4d5e2faca810459724f11f78d19a0feee27a7be2b3fc5f7abbbec4c9fdcae93d + md5: bf47878473e5ab9fdb4115735230e191 + depends: + - python >=3.13.0a0 + license: MIT + license_family: MIT + size: 1177084 + timestamp: 1762776338614 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda + sha256: 43d37bc9ca3b257c5dd7bf76a8426addbdec381f6786ff441dc90b1a49143b6a + md5: c01af13bdc553d1a8fbfff6e8db075f0 + depends: + - libgcc >=14 + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + size: 450960 + timestamp: 1754665235234 +- conda: https://conda.anaconda.org/conda-forge/win-64/pixman-0.46.4-h5112557_1.conda + sha256: 246fce4706b3f8b247a7d6142ba8d732c95263d3c96e212b9d63d6a4ab4aff35 + md5: 08c8fa3b419df480d985e304f7884d35 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + size: 542795 + timestamp: 1754665193489 +- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + sha256: 04c64fb78c520e5c396b6e07bc9082735a5cc28175dbe23138201d0a9441800b + md5: 1bd2e65c8c7ef24f4639ae6e850dacc2 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 23922 + timestamp: 1764950726246 +- conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e + md5: d7585b6550ad04c8c5e21097ada2888e + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + size: 25877 + timestamp: 1764896838868 +- conda: https://conda.anaconda.org/conda-forge/linux-64/prometheus-cpp-1.3.0-ha5d0236_0.conda + sha256: 013669433eb447548f21c3c6b16b2ed64356f726b5f77c1b39d5ba17a8a4b8bc + md5: a83f6a2fdc079e643237887a37460668 + depends: + - __glibc >=2.17,<3.0.a0 + - libcurl >=8.10.1,<9.0a0 + - libgcc >=13 + - libstdcxx >=13 + - libzlib >=1.3.1,<2.0a0 + - zlib + license: MIT + license_family: MIT + size: 199544 + timestamp: 1730769112346 +- conda: https://conda.anaconda.org/conda-forge/osx-64/prometheus-cpp-1.3.0-h7802330_0.conda + sha256: af754a477ee2681cb7d5d77c621bd590d25fe1caf16741841fc2d176815fc7de + md5: f36107fa2557e63421a46676371c4226 + depends: + - __osx >=10.13 + - libcurl >=8.10.1,<9.0a0 + - libcxx >=18 + - libzlib >=1.3.1,<2.0a0 + - zlib + license: MIT + license_family: MIT + size: 179103 + timestamp: 1730769223221 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/prometheus-cpp-1.3.0-h0967b3e_0.conda + sha256: 851a77ae1a8e90db9b9f3c4466abea7afb52713c3d98ceb0d37ba6ff27df2eff + md5: 7172339b49c94275ba42fec3eaeda34f + depends: + - __osx >=11.0 + - libcurl >=8.10.1,<9.0a0 + - libcxx >=18 + - libzlib >=1.3.1,<2.0a0 + - zlib + license: MIT + license_family: MIT + size: 173220 + timestamp: 1730769371051 +- conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda + sha256: 4817651a276016f3838957bfdf963386438c70761e9faec7749d411635979bae + md5: edb16f14d920fb3faf17f5ce582942d6 + depends: + - python >=3.10 + - wcwidth + constrains: + - prompt_toolkit 3.0.52 + license: BSD-3-Clause + license_family: BSD + size: 273927 + timestamp: 1756321848365 +- conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.3.1-pyhe1237c8_0.conda + sha256: d8927d64b35e1fb82285791444673e47d3729853be962c7045e75fc0fd715cec + md5: b1cda654f58d74578ac9786909af84cd + depends: + - python >=3.9 + track_features: + - propcache_no_compile + license: Apache-2.0 + license_family: APACHE + size: 17693 + timestamp: 1744525054494 +- conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py314h0f05182_0.conda + sha256: 7c5d69ad61fe4e0d3657185f51302075ef5b9e34686238c6b3bde102344d4390 + md5: aee1c9aecc66339ea6fd89e6a143a282 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 509226 + timestamp: 1762092897605 +- conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.1.3-py314hd1e8ddb_0.conda + sha256: 444a73838eff6d7d35e22a684c1774dacd191500c3e27a828ec1ed0f96d5f70d + md5: 3156552ec761b34da86aeb273e725a25 + depends: + - python + - __osx >=10.13 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 520432 + timestamp: 1762093042719 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.1.3-py314h9d33bd4_0.conda + sha256: e69d9bdc482596abb10a7d54094e3f6a80ccba5b710353e9bda7d3313158985f + md5: 7259e501bb4288143582312017bb1e44 + depends: + - python + - python 3.14.* *_cp314 + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 523325 + timestamp: 1762093068430 +- conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.1.3-py314hc5dbbe4_0.conda + sha256: 1cdcd27f34682414d2481835ff13797e532f28e518bd451256c34952cf37c34c + md5: c96a29c38696f7dcaf486c4a33cd1063 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 527946 + timestamp: 1762092943903 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda + sha256: 9c88f8c64590e9567c6c80823f0328e58d3b1efb0e1c539c0315ceca764e0973 + md5: b3c17d95b5a10c6e64a21fa17573e70e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + size: 8252 + timestamp: 1726802366959 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pthread-stubs-0.4-h00291cd_1002.conda + sha256: 05944ca3445f31614f8c674c560bca02ff05cb51637a96f665cb2bbe496099e5 + md5: 8bcf980d2c6b17094961198284b8e862 + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 8364 + timestamp: 1726802331537 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pthread-stubs-0.4-hd74edd7_1002.conda + sha256: 8ed65e17fbb0ca944bfb8093b60086e3f9dd678c3448b5de212017394c247ee3 + md5: 415816daf82e0b23a736a069a75e9da7 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 8381 + timestamp: 1726802424786 +- conda: https://conda.anaconda.org/conda-forge/win-64/pthread-stubs-0.4-h0e40799_1002.conda + sha256: 7e446bafb4d692792310ed022fe284e848c6a868c861655a92435af7368bae7b + md5: 3c8f2573569bb816483e5cf57efbbe29 + depends: + - libgcc >=13 + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + size: 9389 + timestamp: 1726802555076 +- conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda + sha256: a7713dfe30faf17508ec359e0bc7e0983f5d94682492469bd462cdaae9c64d83 + md5: 7d9daffbb8d8e0af0f769dbbcd173a54 + depends: + - python >=3.9 + license: ISC + size: 19457 + timestamp: 1733302371990 +- conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda + sha256: 71bd24600d14bb171a6321d523486f6a06f855e75e547fa0cb2a0953b02047f0 + md5: 3bfdfb8dbcdc4af1ae3f9a8eb3948f04 + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 16668 + timestamp: 1733569518868 +- conda: https://conda.anaconda.org/conda-forge/noarch/py-cpuinfo-9.0.0-pyhd8ed1ab_1.conda + sha256: 6d8f03c13d085a569fde931892cded813474acbef2e03381a1a87f420c7da035 + md5: 46830ee16925d5ed250850503b5dc3a8 + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 25766 + timestamp: 1733236452235 +- conda: https://conda.anaconda.org/conda-forge/noarch/py2vega-0.6.1-pyhd8ed1ab_0.tar.bz2 + sha256: 1637e850576b0cc1fda0fb2f4a4396bb30b140888e83787de2c8746af3df675e + md5: 07594783f950301f5943e6d080ffb4eb + depends: + - gast >=0.4,<0.5 + - python >=3.6 + license: BSD-3-Clause + license_family: BSD + size: 16798 + timestamp: 1614765686812 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyarrow-22.0.0-py314hdafbbf9_0.conda + sha256: c10ea8100848236cda04307a00cdeba5a86358fc537132ffcc5cac8cc27f5547 + md5: ecb1085032bfa2bbd310807ca6c0c7f6 + depends: + - libarrow-acero 22.0.0.* + - libarrow-dataset 22.0.0.* + - libarrow-substrait 22.0.0.* + - libparquet 22.0.0.* + - pyarrow-core 22.0.0 *_0_* + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: APACHE + size: 26193 + timestamp: 1761648748916 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pyarrow-22.0.0-py314hee6578b_0.conda + sha256: dd884207ed4c43d566a0fb6d46135669932dafce3f646f287b2c1347b1cb7391 + md5: 13fdbf20848018c21129b27b696c4e90 + depends: + - libarrow-acero 22.0.0.* + - libarrow-dataset 22.0.0.* + - libarrow-substrait 22.0.0.* + - libparquet 22.0.0.* + - pyarrow-core 22.0.0 *_0_* + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: APACHE + size: 26271 + timestamp: 1761648628782 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyarrow-22.0.0-py314he55896b_0.conda + sha256: 1c15052ed5cdd0478964ea0b0f73bbc5db1c49f9b6923a378ba4b8dd2d9b802d + md5: 27b21816e9427b5bb9f5686c122b8730 + depends: + - libarrow-acero 22.0.0.* + - libarrow-dataset 22.0.0.* + - libarrow-substrait 22.0.0.* + - libparquet 22.0.0.* + - pyarrow-core 22.0.0 *_0_* + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: APACHE + size: 26356 + timestamp: 1761649037869 +- conda: https://conda.anaconda.org/conda-forge/win-64/pyarrow-22.0.0-py314h86ab7b2_0.conda + sha256: 78c7195c8f4c853e8ff1948f5908af70d523a8d9e708879b47ee4f9a4808f0d7 + md5: bf483b00a926179e1f4a8122c64f7a10 + depends: + - libarrow-acero 22.0.0.* + - libarrow-dataset 22.0.0.* + - libarrow-substrait 22.0.0.* + - libparquet 22.0.0.* + - pyarrow-core 22.0.0 *_0_* + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: APACHE + size: 26652 + timestamp: 1761648406768 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyarrow-core-22.0.0-py314h52d6ec5_0_cpu.conda + sha256: 89d1fdb21ca6488c2e7a262d84eaf3ab4fbdd555a3ce91915869d9bfe640b92e + md5: 3c690d2816c2fe6e8d02a0f60549a393 + depends: + - __glibc >=2.17,<3.0.a0 + - libarrow 22.0.0.* *cpu + - libarrow-compute 22.0.0.* *cpu + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - apache-arrow-proc * cpu + - numpy >=1.21,<3 + license: Apache-2.0 + license_family: APACHE + size: 4814230 + timestamp: 1761648682122 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pyarrow-core-22.0.0-py314h35e0213_0_cpu.conda + sha256: c502d7118b4b5fd59e38f5e8b5ac702ab2923f4c3f0fbbd71a8310fa47aef00b + md5: d46aeaef96eb344a170c178dc7f40a2d + depends: + - __osx >=10.13 + - libarrow 22.0.0.* *cpu + - libarrow-compute 22.0.0.* *cpu + - libcxx >=18 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - apache-arrow-proc * cpu + - numpy >=1.21,<3 + license: Apache-2.0 + license_family: APACHE + size: 4792989 + timestamp: 1761648579819 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyarrow-core-22.0.0-py314hf20a12a_0_cpu.conda + sha256: d06476026a96d93bc44b0269e8b9abcc2b18adb56d82cd69d2f33e8cc0b47299 + md5: e02b151500dcd291ab7cd8f2bd46fef3 + depends: + - __osx >=11.0 + - libarrow 22.0.0.* *cpu + - libarrow-compute 22.0.0.* *cpu + - libcxx >=18 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - numpy >=1.21,<3 + - apache-arrow-proc * cpu + license: Apache-2.0 + license_family: APACHE + size: 3912295 + timestamp: 1761648977007 +- conda: https://conda.anaconda.org/conda-forge/win-64/pyarrow-core-22.0.0-py314hb5be3fa_0_cpu.conda + sha256: 316711f94c4bc8420479fabef4ab6d9c3a46d00bce2b0e402bd205c7954bff82 + md5: 5158c4f9ae4dc6924c4096f5745626f2 + depends: + - libarrow 22.0.0.* *cpu + - libarrow-compute 22.0.0.* *cpu + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - numpy >=1.21,<3 + - apache-arrow-proc * cpu + license: Apache-2.0 + license_family: APACHE + size: 3526470 + timestamp: 1761648362882 +- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + sha256: 79db7928d13fab2d892592223d7570f5061c192f27b9febd1a418427b719acc6 + md5: 12c566707c80111f9799308d9e265aef + depends: + - python >=3.9 + - python + license: BSD-3-Clause + license_family: BSD + size: 110100 + timestamp: 1733195786147 +- conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.12.5-pyhcf101f3_1.conda + sha256: 868569d9505b7fe246c880c11e2c44924d7613a8cdcc1f6ef85d5375e892f13d + md5: c3946ed24acdb28db1b5d63321dbca7d + depends: + - typing-inspection >=0.4.2 + - typing_extensions >=4.14.1 + - python >=3.10 + - typing-extensions >=4.6.1 + - annotated-types >=0.6.0 + - pydantic-core ==2.41.5 + - python + license: MIT + license_family: MIT + size: 340482 + timestamp: 1764434463101 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.41.5-py314h2e6c369_1.conda + sha256: 7e0ae379796e28a429f8e48f2fe22a0f232979d65ec455e91f8dac689247d39f + md5: 432b0716a1dfac69b86aa38fdd59b7e6 + depends: + - python + - typing-extensions >=4.6.0,!=4.7.0 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.14.* *_cp314 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + size: 1943088 + timestamp: 1762988995556 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pydantic-core-2.41.5-py314ha7b6dee_1.conda + sha256: 7cb259e46ecb9f19eeea4d96035546376ce9370b51ffd18d57eb7170b08bbbf4 + md5: 8a9a08b79d530f482c9439790db774e1 + depends: + - python + - typing-extensions >=4.6.0,!=4.7.0 + - __osx >=10.13 + - python_abi 3.14.* *_cp314 + constrains: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 1949458 + timestamp: 1762989007303 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pydantic-core-2.41.5-py314haad56a0_1.conda + sha256: dded9092d89f1d8c267d5ce8b5e21f935c51acb7a64330f507cdfb3b69a98116 + md5: 420a4b8024e9b22880f1e03b612afa7d + depends: + - python + - typing-extensions >=4.6.0,!=4.7.0 + - __osx >=11.0 + - python 3.14.* *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 1784478 + timestamp: 1762989019956 +- conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.41.5-py314h9f07db2_1.conda + sha256: 51773479d973c0b0b96cf581cb8444061eaac9b6c28f1cc6d33afc39201d5f13 + md5: c1f37669ed289c378f3193b35c9df2a7 + depends: + - python + - typing-extensions >=4.6.0,!=4.7.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 1971476 + timestamp: 1762989023313 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyerfa-2.0.1.5-py310h32771cd_2.conda + noarch: python + sha256: a3f25f921be09e15ed6ff46a1ec99ce9cca6affa4a086f6f39ad630e21e48fb7 + md5: e6efd9593a25d093b4ce9dd8053c4af7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - numpy >=1.21,<3 + - python + license: BSD-3-Clause + license_family: BSD + size: 295617 + timestamp: 1756821497270 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pyerfa-2.0.1.5-py310hcbffc5d_2.conda + noarch: python + sha256: 06beb9ed2f6df706b5bd050e42819e49606d6256fe66dc7255c577a0140a2379 + md5: cd854c208de8cd3e2a6a878500021633 + depends: + - __osx >=10.13 + - numpy >=1.21,<3 + - python + license: BSD-3-Clause + license_family: BSD + size: 270277 + timestamp: 1756821799013 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyerfa-2.0.1.5-py310hbb12772_2.conda + noarch: python + sha256: ec2a947d95ffb46ca3a818272c8594f195b1e74369164c46f4512b2d66f7f4c4 + md5: 51a8f8137ff9e55513e5e722c86fb9f8 + depends: + - __osx >=11.0 + - numpy >=1.21,<3 + - python + license: BSD-3-Clause + license_family: BSD + size: 271352 + timestamp: 1756821964759 +- conda: https://conda.anaconda.org/conda-forge/win-64/pyerfa-2.0.1.5-py310h1f63838_2.conda + noarch: python + sha256: 6d4df9c23096118c062254c5f4a9a7e111db198c3ed834b4180f2df145b513de + md5: 215016438dc9d0808d9409c250f26966 + depends: + - numpy >=1.21,<3 + - python + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 296016 + timestamp: 1756821645023 +- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + sha256: 5577623b9f6685ece2697c6eb7511b4c9ac5fb607c9babc2646c811b428fd46a + md5: 6b6ece66ebcae2d5f326c77ef2c5a066 + depends: + - python >=3.9 + license: BSD-2-Clause + license_family: BSD + size: 889287 + timestamp: 1750615908735 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda + sha256: 6814b61b94e95ffc45ec539a6424d8447895fef75b0fec7e1be31f5beee883fb + md5: 6c8979be6d7a17692793114fa26916e8 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 104044 + timestamp: 1758436411254 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.3-py314hf36963e_1.conda + sha256: 54051f72018c7a980578859e3340ba2e4d529f064e5850db4314995ca0d6fc56 + md5: 8d1ffa0a622e8dda170beeadd1795e88 + depends: + - __glibc >=2.17,<3.0.a0 + - libclang13 >=21.1.2 + - libegl >=1.7.0,<2.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libopengl >=1.7.0,<2.0a0 + - libstdcxx >=14 + - libvulkan-loader >=1.4.313.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libxslt >=1.1.43,<2.0a0 + - python >=3.14.0rc3,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - qt6-main 6.9.3.* + - qt6-main >=6.9.3,<6.10.0a0 + license: LGPL-3.0-only + license_family: LGPL + size: 10141491 + timestamp: 1759403061203 +- conda: https://conda.anaconda.org/conda-forge/win-64/pyside6-6.9.3-py314h2c9462b_1.conda + sha256: dbd0e599d3155472c147e2fe75326f8379d6d6f1ac7905b5dc9d64e49c1242a8 + md5: ad4318d725ce9acbf8714ad3e9a2e0bf + depends: + - libclang13 >=21.1.2 + - libvulkan-loader >=1.4.313.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libxslt >=1.1.43,<2.0a0 + - python >=3.14.0rc3,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - qt6-main 6.9.3.* + - qt6-main >=6.9.3,<6.10.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: LGPL-3.0-only + license_family: LGPL + size: 8901954 + timestamp: 1759403164005 +- conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda + sha256: d016e04b0e12063fbee4a2d5fbb9b39a8d191b5a0042f0b8459188aedeabb0ca + md5: e2fd202833c4a981ce8a65974fe4abd1 + depends: + - __win + - python >=3.9 + - win_inet_pton + license: BSD-3-Clause + license_family: BSD + size: 21784 + timestamp: 1733217448189 +- conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + sha256: ba3b032fa52709ce0d9fd388f63d330a026754587a2f461117cac9ab73d8d0d8 + md5: 461219d1a5bd61342293efa2c0c90eac + depends: + - __unix + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 21085 + timestamp: 1733217331982 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pytables-3.10.2-py314h5611b9a_10.conda + sha256: 94628fe932e7aee3fdf4bdfd4a1832324b5a833b98ba103ac69e42d30514953c + md5: 98f9d542e85ac1ae6fcefa3ba3407e2d + depends: + - __glibc >=2.17,<3.0.a0 + - blosc >=1.21.6,<2.0a0 + - bzip2 >=1.0.8,<2.0a0 + - c-blosc2 >=2.22.0,<2.23.0a0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - numexpr + - numpy >=1.20.0 + - numpy >=1.23,<3 + - packaging + - py-cpuinfo + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - typing-extensions >=4.4.0 + license: BSD-3-Clause + license_family: BSD + size: 1710124 + timestamp: 1761751448658 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pytables-3.10.2-py314hb51f073_10.conda + sha256: bc57d59d261b16f3086895b20d3c2ec2cbee1fae10f760197f304f54fba58d40 + md5: b32db0844a5993c9a7b2e975eae6a28b + depends: + - __osx >=10.13 + - blosc >=1.21.6,<2.0a0 + - bzip2 >=1.0.8,<2.0a0 + - c-blosc2 >=2.22.0,<2.23.0a0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libcxx >=19 + - libzlib >=1.3.1,<2.0a0 + - numexpr + - numpy >=1.20.0 + - numpy >=1.23,<3 + - packaging + - py-cpuinfo + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - typing-extensions >=4.4.0 + license: BSD-3-Clause + license_family: BSD + size: 1592351 + timestamp: 1761751753319 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pytables-3.10.2-py314h8eb144a_10.conda + sha256: 2862fad997d1cfa074be171403dfa5f983080062f56f98875cc5c3fd7462f7fd + md5: 86860ff3ab5e016d5af5a0eca346b31b + depends: + - __osx >=11.0 + - blosc >=1.21.6,<2.0a0 + - bzip2 >=1.0.8,<2.0a0 + - c-blosc2 >=2.22.0,<2.23.0a0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libcxx >=19 + - libzlib >=1.3.1,<2.0a0 + - numexpr + - numpy >=1.20.0 + - numpy >=1.23,<3 + - packaging + - py-cpuinfo + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + - typing-extensions >=4.4.0 + license: BSD-3-Clause + license_family: BSD + size: 1777276 + timestamp: 1761751746264 +- conda: https://conda.anaconda.org/conda-forge/win-64/pytables-3.10.2-py314h2bd12ea_10.conda + sha256: 9cd2e83780fbe86069da001c985a7ff90862b138214b6d9744b87c5bf1e0b083 + md5: 63a28f5789c3e30019c7beda4323c0f0 + depends: + - blosc >=1.21.6,<2.0a0 + - bzip2 >=1.0.8,<2.0a0 + - c-blosc2 >=2.22.0,<2.23.0a0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libzlib >=1.3.1,<2.0a0 + - numexpr + - numpy >=1.20.0 + - numpy >=1.23,<3 + - packaging + - py-cpuinfo + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - typing-extensions >=4.4.0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 1538261 + timestamp: 1761831793226 +- conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda + sha256: 9e749fb465a8bedf0184d8b8996992a38de351f7c64e967031944978de03a520 + md5: 2b694bad8a50dc2f712f5368de866480 + depends: + - pygments >=2.7.2 + - python >=3.10 + - iniconfig >=1.0.1 + - packaging >=22 + - pluggy >=1.5,<2 + - tomli >=1 + - colorama >=0.4 + - exceptiongroup >=1 + - python + constrains: + - pytest-faulthandler >=2 + license: MIT + license_family: MIT + size: 299581 + timestamp: 1765062031645 +- conda: https://conda.anaconda.org/conda-forge/noarch/pytest-asyncio-1.3.0-pyhcf101f3_0.conda + sha256: e782cf0555e4d54102423ad3421c8122f97a7a7c2d55c677a91e32d7c3e2b059 + md5: 80eccce75e6728e9e728370984bdc6fd + depends: + - pytest >=8.2,<10 + - python >=3.10 + - typing_extensions >=4.12 + - backports.asyncio.runner >=1.1,<2 + - python + license: Apache-2.0 + license_family: APACHE + size: 39223 + timestamp: 1762797319837 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.2-h32b2ec7_100_cp314.conda + build_number: 100 + sha256: a120fb2da4e4d51dd32918c149b04a08815fd2bd52099dad1334647984bb07f1 + md5: 1cef1236a05c3a98f68c33ae9425f656 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.1,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libuuid >=2.41.2,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.4,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + size: 36790521 + timestamp: 1765021515427 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.2-hf88997e_100_cp314.conda + build_number: 100 + sha256: cd9d41368cb7c531e82fbfdb01e274efbb176c464b59ec619538dd2580602191 + md5: 48921d5efb314c3e628089fc6e27e54a + depends: + - __osx >=10.13 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.1,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.4,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + size: 14323056 + timestamp: 1765026108189 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.2-h40d2674_100_cp314.conda + build_number: 100 + sha256: 1a93782e90b53e04c2b1a50a0f8bf0887936649d19dba6a05b05c4b44dae96b7 + md5: 14f15ab0d31a2ee5635aa56e77132594 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.1,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.4,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + size: 13575758 + timestamp: 1765021280625 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.2-h4b44e0e_100_cp314.conda + build_number: 100 + sha256: 6857d7c97cc71fe9ba298dcb1d3b66cc7df425132ab801babd655faa3df48f32 + md5: c3c73414d5ae3f543c531c978d9cc8b8 + depends: + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.1,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - python_abi 3.14.* *_cp314 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + size: 16833248 + timestamp: 1765020224759 + python_site_packages_path: Lib/site-packages +- conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + sha256: d6a17ece93bbd5139e02d2bd7dbfa80bee1a4261dced63f65f679121686bf664 + md5: 5b8d21249ff20967101ffa321cab24e8 + depends: + - python >=3.9 + - six >=1.5 + - python + license: Apache-2.0 + license_family: APACHE + size: 233310 + timestamp: 1751104122689 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.1-pyhcf101f3_0.conda + sha256: aa98e0b1f5472161318f93224f1cfec1355ff69d2f79f896c0b9e033e4a6caf9 + md5: 083725d6cd3dc007f06d04bcf1e613a2 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 26922 + timestamp: 1761503229008 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.2-h4df99d1_100.conda + sha256: 8203dc90a5cb6687f5bfcf332eeaf494ec95d24ed13fca3c82ef840f0bb92a5d + md5: 0064ab66736c4814864e808169dc7497 + depends: + - cpython 3.14.2.* + - python_abi * *_cp314 + license: Python-2.0 + size: 49287 + timestamp: 1765020424843 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.20-pyhff2d567_0.conda + sha256: 1b03678d145b1675b757cba165a0d9803885807792f7eb4495e48a38858c3cca + md5: a28c984e0429aff3ab7386f7de56de6f + depends: + - python >=3.9 + license: Apache-2.0 + license_family: Apache + size: 27913 + timestamp: 1734420869885 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda + sha256: e8392a8044d56ad017c08fec2b0eb10ae3d1235ac967d0aab8bd7b41c4a5eaf0 + md5: 88476ae6ebd24f39261e0854ac244f33 + depends: + - python >=3.9 + license: Apache-2.0 + license_family: APACHE + size: 144160 + timestamp: 1742745254292 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + build_number: 8 + sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 + md5: 0539938c55b6b1a59b560e843ad864a4 + constrains: + - python 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 6989 + timestamp: 1752805904792 +- conda: https://conda.anaconda.org/conda-forge/noarch/pytokens-0.3.0-pyhcf101f3_0.conda + sha256: 562d54fa0717b7117ee7f6b5f832c6535bf5e44de2dfa2f7056912e53d346469 + md5: 4b1812cb7a8143ee00aef43831fb0d29 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 18860 + timestamp: 1765201048624 +- conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + sha256: 8d2a8bf110cc1fc3df6904091dead158ba3e614d8402a83e51ed3a8aa93cdeb0 + md5: bc8e3267d44011051f2eb14d22fb0960 + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 189015 + timestamp: 1742920947249 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyvo-1.8-pyhd8ed1ab_0.conda + sha256: c5da475506154c76a869d4101f3b16c712d5244fc5b966d389286aac3e537eb5 + md5: 0334b0c99472b10f7154f164ce574927 + depends: + - astropy-base >=4.2 + - python >=3.9 + - requests + license: BSD-3-Clause + license_family: BSD + size: 901577 + timestamp: 1763053979710 +- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py314h8f8f202_1.conda + sha256: 6918a8067f296f3c65d43e84558170c9e6c3f4dd735cfe041af41a7fdba7b171 + md5: 2d7b7ba21e8a8ced0eca553d4d53f773 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: PSF-2.0 + license_family: PSF + size: 6713155 + timestamp: 1756487145487 +- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-ctypes-0.2.3-py314h86ab7b2_3.conda + sha256: 70b43b8d6ac68a524e4d9dd0caf98f6c052918c1b658ee80af9e0269e2bc3a2a + md5: 2507b24a127696b044f441df16c5571c + depends: + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 58083 + timestamp: 1762489935449 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + sha256: 828af2fd7bb66afc9ab1c564c2046be391aaf66c0215f05afaf6d7a9a270fe2a + md5: b12f41c0d7fb5ab81709fcc86579688f + depends: + - python >=3.10.* + - yaml + track_features: + - pyyaml_no_compile + license: MIT + license_family: MIT + size: 45223 + timestamp: 1758891992558 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hfb55c3c_0.conda + noarch: python + sha256: a00a41b66c12d9c60e66b391e9a4832b7e28743348cf4b48b410b91927cd7819 + md5: 3399d43f564c905250c1aea268ebb935 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - _python_abi3_support 1.* + - cpython >=3.12 + - zeromq >=4.3.5,<4.4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 212218 + timestamp: 1757387023399 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pyzmq-27.1.0-py312hb7d603e_0.conda + noarch: python + sha256: 4e052fa3c4ed319e7bcc441fca09dee4ee4006ac6eb3d036a8d683fceda9304b + md5: 81511d0be03be793c622c408c909d6f9 + depends: + - python + - __osx >=10.13 + - libcxx >=19 + - _python_abi3_support 1.* + - cpython >=3.12 + - zeromq >=4.3.5,<4.4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 191697 + timestamp: 1757387104297 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312hd65ceae_0.conda + noarch: python + sha256: ef33812c71eccf62ea171906c3e7fc1c8921f31e9cc1fbc3f079f3f074702061 + md5: bbd22b0f0454a5972f68a5f200643050 + depends: + - python + - __osx >=11.0 + - libcxx >=19 + - _python_abi3_support 1.* + - cpython >=3.12 + - zeromq >=4.3.5,<4.4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 191115 + timestamp: 1757387128258 +- conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312hbb5da91_0.conda + noarch: python + sha256: fd46b30e6a1e4c129045e3174446de3ca90da917a595037d28595532ab915c5d + md5: 808d263ec97bbd93b41ca01552b5fbd4 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - zeromq >=4.3.5,<4.3.6.0a0 + - _python_abi3_support 1.* + - cpython >=3.12 + license: BSD-3-Clause + license_family: BSD + size: 185711 + timestamp: 1757387025899 +- conda: https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda + sha256: 776363493bad83308ba30bcb88c2552632581b143e8ee25b1982c8c743e73abc + md5: 353823361b1d27eb3960efb076dfcaf6 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: LicenseRef-Qhull + size: 552937 + timestamp: 1720813982144 +- conda: https://conda.anaconda.org/conda-forge/osx-64/qhull-2020.2-h3c5361c_5.conda + sha256: 79d804fa6af9c750e8b09482559814ae18cd8df549ecb80a4873537a5a31e06e + md5: dd1ea9ff27c93db7c01a7b7656bd4ad4 + depends: + - __osx >=10.13 + - libcxx >=16 + license: LicenseRef-Qhull + size: 528122 + timestamp: 1720814002588 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/qhull-2020.2-h420ef59_5.conda + sha256: 873ac689484262a51fd79bc6103c1a1bedbf524924d7f0088fb80703042805e4 + md5: 6483b1f59526e05d7d894e466b5b6924 + depends: + - __osx >=11.0 + - libcxx >=16 + license: LicenseRef-Qhull + size: 516376 + timestamp: 1720814307311 +- conda: https://conda.anaconda.org/conda-forge/win-64/qhull-2020.2-hc790b64_5.conda + sha256: 887d53486a37bd870da62b8fa2ebe3993f912ad04bd755e7ed7c47ced97cbaa8 + md5: 854fbdff64b572b5c0b470f334d34c11 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: LicenseRef-Qhull + size: 1377020 + timestamp: 1720814433486 +- conda: https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.3-h5c1c036_1.conda + sha256: 51537408ce1493d267b375b33ec02a060d77c4e00c7bef5e2e1c6724e08a23e3 + md5: 762af6d08fdfa7a45346b1466740bacd + depends: + - __glibc >=2.17,<3.0.a0 + - alsa-lib >=1.2.14,<1.3.0a0 + - dbus >=1.16.2,<2.0a0 + - double-conversion >=3.3.1,<3.4.0a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - harfbuzz >=12.1.0 + - icu >=75.1,<76.0a0 + - krb5 >=1.21.3,<1.22.0a0 + - libclang-cpp21.1 >=21.1.4,<21.2.0a0 + - libclang13 >=21.1.4 + - libcups >=2.3.3,<2.4.0a0 + - libdrm >=2.4.125,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglib >=2.86.0,<3.0a0 + - libjpeg-turbo >=3.1.0,<4.0a0 + - libllvm21 >=21.1.4,<21.2.0a0 + - libpng >=1.6.50,<1.7.0a0 + - libpq >=18.0,<19.0a0 + - libsqlite >=3.50.4,<4.0a0 + - libstdcxx >=14 + - libtiff >=4.7.1,<4.8.0a0 + - libvulkan-loader >=1.4.328.1,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - libxkbcommon >=1.12.2,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - pcre2 >=10.46,<10.47.0a0 + - wayland >=1.24.0,<2.0a0 + - xcb-util >=0.4.1,<0.5.0a0 + - xcb-util-cursor >=0.1.5,<0.2.0a0 + - xcb-util-image >=0.4.0,<0.5.0a0 + - xcb-util-keysyms >=0.4.1,<0.5.0a0 + - xcb-util-renderutil >=0.3.10,<0.4.0a0 + - xcb-util-wm >=0.4.2,<0.5.0a0 + - xorg-libice >=1.1.2,<2.0a0 + - xorg-libsm >=1.2.6,<2.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxcomposite >=0.4.6,<1.0a0 + - xorg-libxcursor >=1.2.3,<2.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrandr >=1.5.4,<2.0a0 + - xorg-libxtst >=1.2.5,<2.0a0 + - xorg-libxxf86vm >=1.1.6,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - qt 6.9.3 + license: LGPL-3.0-only + license_family: LGPL + size: 54785664 + timestamp: 1761308850008 +- conda: https://conda.anaconda.org/conda-forge/win-64/qt6-main-6.9.3-ha0de62e_1.conda + sha256: 257b999442d4e14e1e061890e7bd0620511f57324df3ad27bb3cf78b2a6cdcb3 + md5: ca2bfad3a24794a0f7cf413b03906ade + depends: + - double-conversion >=3.3.1,<3.4.0a0 + - harfbuzz >=12.1.0 + - icu >=75.1,<76.0a0 + - krb5 >=1.21.3,<1.22.0a0 + - libclang13 >=21.1.4 + - libglib >=2.86.0,<3.0a0 + - libjpeg-turbo >=3.1.0,<4.0a0 + - libpng >=1.6.50,<1.7.0a0 + - libsqlite >=3.50.4,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + - libvulkan-loader >=1.4.328.1,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - pcre2 >=10.46,<10.47.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - qt 6.9.3 + license: LGPL-3.0-only + license_family: LGPL + size: 95659243 + timestamp: 1761312853504 +- conda: https://conda.anaconda.org/conda-forge/linux-64/re2-2025.11.05-h5301d42_0.conda + sha256: 2f225ddf4a274743045aded48053af65c31721e797a45beed6774fdc783febfb + md5: 0227d04521bc3d28c7995c7e1f99a721 + depends: + - libre2-11 2025.11.05 h7b12aa8_0 + license: BSD-3-Clause + license_family: BSD + size: 27316 + timestamp: 1762397780316 +- conda: https://conda.anaconda.org/conda-forge/osx-64/re2-2025.11.05-h7df6414_0.conda + sha256: cd892b6b571fc6aaf9132a859e5ef0fae9e9ff980337ce7284798fa1d24bee5d + md5: 13dc8eedbaa30b753546e3d716f51816 + depends: + - libre2-11 2025.11.05 h554ac88_0 + license: BSD-3-Clause + license_family: BSD + size: 27381 + timestamp: 1762398153069 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/re2-2025.11.05-h64b956e_0.conda + sha256: 29c4bceb6b4530bac6820c30ba5a2f53fd26ed3e7003831ecf394e915b975fbc + md5: 1b35e663ed321840af65e7c5cde419f2 + depends: + - libre2-11 2025.11.05 h91c62da_0 + license: BSD-3-Clause + license_family: BSD + size: 27422 + timestamp: 1762398340843 +- conda: https://conda.anaconda.org/conda-forge/win-64/re2-2025.11.05-ha104f34_0.conda + sha256: 9d1bb3d15cdd3257baee5fc063221514482f91154cd1457af126e1ec460bbeac + md5: 50746f61f199c4c00d42e33f5d6cfd0b + depends: + - libre2-11 2025.11.05 h0eb2380_0 + license: BSD-3-Clause + license_family: BSD + size: 216623 + timestamp: 1762397986736 +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda + sha256: 2d6d0c026902561ed77cd646b5021aef2d4db22e57a5b0178dfc669231e06d2c + md5: 283b96675859b20a825f8fa30f311446 + depends: + - libgcc >=13 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 282480 + timestamp: 1740379431762 +- conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h7cca4af_2.conda + sha256: 53017e80453c4c1d97aaf78369040418dea14cf8f46a2fa999f31bd70b36c877 + md5: 342570f8e02f2f022147a7f841475784 + depends: + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 256712 + timestamp: 1740379577668 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda + sha256: 7db04684d3904f6151eff8673270922d31da1eea7fa73254d01c437f49702e34 + md5: 63ef3f6e6d6d5c589e64f11263dc5676 + depends: + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 252359 + timestamp: 1740379663071 +- conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + sha256: 8dc54e94721e9ab545d7234aa5192b74102263d3e704e6d0c8aa7008f2da2a7b + md5: db0c6b99149880c8ba515cf4abe93ee4 + depends: + - certifi >=2017.4.17 + - charset-normalizer >=2,<4 + - idna >=2.5,<4 + - python >=3.9 + - urllib3 >=1.21.1,<3 + constrains: + - chardet >=3.0.2,<6 + license: Apache-2.0 + license_family: APACHE + size: 59263 + timestamp: 1755614348400 +- conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda + sha256: edfb44d0b6468a8dfced728534c755101f06f1a9870a7ad329ec51389f16b086 + md5: a247579d8a59931091b16a1e932bbed6 + depends: + - markdown-it-py >=2.2.0 + - pygments >=2.13.0,<3.0.0 + - python >=3.10 + - typing_extensions >=4.0.0,<5.0.0 + - python + license: MIT + license_family: MIT + size: 200840 + timestamp: 1760026188268 +- conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.17.0-pyhcf101f3_0.conda + sha256: 1bfd53dfc4877e4613702be69f89a180fcbd31f065aba6b9024ee355fb881b82 + md5: c59bd4c924d9f3001803dc1c7c61da2d + depends: + - python >=3.10 + - rich >=13.7.1 + - click >=8.1.7 + - typing_extensions >=4.12.2 + - python + license: MIT + license_family: MIT + size: 31373 + timestamp: 1764252369301 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.14.8-h813ae00_0.conda + noarch: python + sha256: 4adf379daccb73f03297a6966d1200f6ea65e6a1513d749e7f782e32267fe2bb + md5: 295ce05c06920527a581a5e148a4eec6 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + size: 11340280 + timestamp: 1764866215629 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ruff-0.14.8-hd9f4cfa_0.conda + noarch: python + sha256: 686d612b38fa11566e8ddbdd4e8f5558f0bac76926328158f1fbcc1dae9c01da + md5: 544c6d626cf0b56068f3f4c59e8651ac + depends: + - python + - __osx >=10.13 + constrains: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 11286425 + timestamp: 1764866316890 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.14.8-h382de68_0.conda + noarch: python + sha256: 97135a37ab2c55eac06d75569f08ff388af63ec1a0a2a122528b4951b8536027 + md5: f8c69cb8d0c9ac4ab0593926f21a2a3b + depends: + - python + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 10302078 + timestamp: 1764866315123 +- conda: https://conda.anaconda.org/conda-forge/win-64/ruff-0.14.8-h15e3a1f_0.conda + noarch: python + sha256: fbcaafffd55c7022464219b95658d38980ee04bb001d35c3d97e2e933d7c6bf7 + md5: 35ec53f16d22dc8b17e17865a98c2120 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + size: 11874411 + timestamp: 1764866263950 +- conda: https://conda.anaconda.org/conda-forge/linux-64/s2n-1.6.2-he8a4886_1.conda + sha256: dec76e9faa3173579d34d226dbc91892417a80784911daf8e3f0eb9bad19d7a6 + md5: bade189a194e66b93c03021bd36c337b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - openssl >=3.5.4,<4.0a0 + license: Apache-2.0 + license_family: Apache + size: 394197 + timestamp: 1765160261434 +- conda: https://conda.anaconda.org/conda-forge/noarch/s3fs-2025.12.0-pyhd8ed1ab_0.conda + sha256: e060f0566161064453cf353c3a7618a22fa47959b3c7f7224528ca7ebeb2a4b0 + md5: 35a41338454bdba184c3136fbdc7186a + depends: + - aiobotocore >=2.5.4,<3.0.0 + - aiohttp + - fsspec 2025.12.0 + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + size: 33928 + timestamp: 1764796365839 +- conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.3-py314he7377e1_1.conda + sha256: ac76c6187848e529dd0ada06748c7470417ea3994dae24ce9844ff43adf07901 + md5: 881c9466d204a11f424225793bc3c27a + depends: + - __glibc >=2.17,<3.0.a0 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + - liblapack >=3.9.0,<4.0a0 + - libstdcxx >=14 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 16864022 + timestamp: 1763220800462 +- conda: https://conda.anaconda.org/conda-forge/osx-64/scipy-1.16.3-py314h9d854bd_1.conda + sha256: d0f6c598d73f809d805ef3b2c4be42ca5b999d57bd2bdff05850c091979d05ba + md5: 017b471251f1d7401ed1dd63370bad2f + depends: + - __osx >=10.13 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libcxx >=19 + - libgfortran + - libgfortran5 >=14.3.0 + - libgfortran5 >=15.2.0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 15325764 + timestamp: 1763221416721 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.16.3-py314h624bdf2_1.conda + sha256: 34034cbd27588eb8522c90930da556a272555384d3d35952dc2f1750971c390d + md5: 8ff6098e9df32259abcd8475c46c419a + depends: + - __osx >=11.0 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libcxx >=19 + - libgfortran + - libgfortran5 >=14.3.0 + - libgfortran5 >=15.2.0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 14084720 + timestamp: 1763220862474 +- conda: https://conda.anaconda.org/conda-forge/win-64/scipy-1.16.3-py314h5798d8a_1.conda + sha256: 8552e8afa3dac86c10d794b66b94b2bd31f93c702f34ab9571a7ed167379e3c2 + md5: c394de8d285d7040fa99672a65e0c72d + depends: + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 14937821 + timestamp: 1763221198564 +- conda: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.4.1-py314hdafbbf9_0.conda + sha256: f6883925a130126cdbdc62c2f43513db53c9f889cde4abc3bc66542336a87150 + md5: 54452085855583ccc3cc5dcd17b47ffe + depends: + - cryptography >=2.0 + - dbus + - jeepney >=0.6 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 34098 + timestamp: 1763045408414 +- conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda + sha256: 1d6534df8e7924d9087bd388fbac5bd868c5bf8971c36885f9f016da0657d22b + md5: 83ea3a2ddb7a75c1b09cea582aa4f106 + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 15018 + timestamp: 1762858315311 +- conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d + md5: 3339e3b65d58accf4ca4fb8748ab16b3 + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + size: 18455 + timestamp: 1753199211006 +- conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + sha256: 48f3f6a76c34b2cfe80de9ce7f2283ecb55d5ed47367ba91e8bb8104e12b8f11 + md5: 98b6c9dc80eb87b2519b97bcf7e578dd + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + size: 45829 + timestamp: 1762948049098 +- conda: https://conda.anaconda.org/conda-forge/osx-64/snappy-1.2.2-h01f5ddf_1.conda + sha256: 1525e6d8e2edf32dabfe2a8e2fc8bf2df81c5ef9f0b5374a3d4ccfa672bfd949 + md5: 2e993292ec18af5cd480932d448598cf + depends: + - libcxx >=19 + - __osx >=10.13 + license: BSD-3-Clause + license_family: BSD + size: 40023 + timestamp: 1762948053450 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-hada39a4_1.conda + sha256: cb9305ede19584115f43baecdf09a3866bfcd5bcca0d9e527bd76d9a1dbe2d8d + md5: fca4a2222994acd7f691e57f94b750c5 + depends: + - libcxx >=19 + - __osx >=11.0 + license: BSD-3-Clause + license_family: BSD + size: 38883 + timestamp: 1762948066818 +- conda: https://conda.anaconda.org/conda-forge/win-64/snappy-1.2.2-h7fa0ca8_1.conda + sha256: d2deda1350abf8c05978b73cf7fe9147dd5c7f2f9b312692d1b98e52efad53c3 + md5: 3075846de68f942150069d4289aaad63 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: BSD-3-Clause + license_family: BSD + size: 67417 + timestamp: 1762948090450 +- conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda + sha256: dce518f45e24cd03f401cb0616917773159a210c19d601c5f2d4e0e5879d30ad + md5: 03fe290994c5e4ec17293cfb6bdce520 + depends: + - python >=3.10 + license: Apache-2.0 + license_family: Apache + size: 15698 + timestamp: 1762941572482 +- conda: https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda + sha256: d1e3e06b5cf26093047e63c8cc77b70d970411c5cbc0cb1fad461a8a8df599f7 + md5: 0401a17ae845fa72c7210e206ec5647d + depends: + - python >=3.9 + license: Apache-2.0 + license_family: APACHE + size: 28657 + timestamp: 1738440459037 +- conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda + sha256: c978576cf9366ba576349b93be1cfd9311c00537622a2f9e14ba2b90c97cae9c + md5: 18c019ccf43769d211f2cf78e9ad46c2 + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 37803 + timestamp: 1756330614547 +- conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda + sha256: 570da295d421661af487f1595045760526964f41471021056e993e73089e9c41 + md5: b1b505328da7a6b246787df4b5a49fbc + depends: + - asttokens + - executing + - pure_eval + - python >=3.9 + license: MIT + license_family: MIT + size: 26988 + timestamp: 1733569565672 +- conda: https://conda.anaconda.org/conda-forge/noarch/starlette-0.50.0-pyhfdc7a7d_0.conda + sha256: ab9ab67faa3cf12f45f5ced316e2c50dc72b4046cd275612fae756fe9d4cf82c + md5: 68bcb398c375177cf117cf608c274f9d + depends: + - anyio >=3.6.2,<5 + - python >=3.10 + - typing_extensions >=4.10.0 + - python + license: BSD-3-Clause + license_family: BSD + size: 64760 + timestamp: 1762016292582 +- conda: https://conda.anaconda.org/conda-forge/noarch/stingray-2.2.10-pyhc455866_0.conda + sha256: c4264a43717656cf75095902bb1a90182708183a7164f3ba5e3d7f09991c8cbe + md5: 80293d9a4688265c3cd5733403021cf5 + depends: + - astropy-base >=4.0 + - matplotlib-base >=3.0,!=3.4.0 + - numpy >=1.17.0 + - python >=3.10 + - scipy >=1.1.0 + license: MIT + license_family: MIT + size: 49862321 + timestamp: 1761379023180 +- conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2022.3.0-hd094cb3_1.conda + sha256: c31cac57913a699745d124cdc016a63e31c5749f16f60b3202414d071fc50573 + md5: 17c38aaf14c640b85c4617ccb59c1146 + depends: + - libhwloc >=2.12.1,<2.12.2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: APACHE + size: 155714 + timestamp: 1762510341121 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda + sha256: 1544760538a40bcd8ace2b1d8ebe3eb5807ac268641f8acdc18c69c5ebfeaf64 + md5: 86bc20552bf46075e3d92b67f089172d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + size: 3284905 + timestamp: 1763054914403 +- conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-hf689a15_3.conda + sha256: 0d0b6cef83fec41bc0eb4f3b761c4621b7adfb14378051a8177bd9bb73d26779 + md5: bd9f1de651dbd80b51281c694827f78f + depends: + - __osx >=10.13 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + size: 3262702 + timestamp: 1763055085507 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda + sha256: ad0c67cb03c163a109820dc9ecf77faf6ec7150e942d1e8bb13e5d39dc058ab7 + md5: a73d54a5abba6543cb2f0af1bfbd6851 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + size: 3125484 + timestamp: 1763055028377 +- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_3.conda + sha256: 4581f4ffb432fefa1ac4f85c5682cc27014bcd66e7beaa0ee330e927a7858790 + md5: 7cb36e506a7dba4817970f8adb6396f9 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: TCL + license_family: BSD + size: 3472313 + timestamp: 1763055164278 +- conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda + sha256: cb77c660b646c00a48ef942a9e1721ee46e90230c7c570cdeb5a893b5cce9bff + md5: d2732eb636c264dc9aa4cbee404b1a53 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 20973 + timestamp: 1760014679845 +- conda: https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda + sha256: 4e379e1c18befb134247f56021fdf18e112fb35e64dd1691858b0a0f3bea9a45 + md5: c07a6153f8306e45794774cf9b13bd32 + depends: + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + size: 53978 + timestamp: 1760707830681 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.3-py314h5bd0f2a_0.conda + sha256: b8f9f9ae508d79c9c697eb01b6a8d2ed4bc1899370f44aa6497c8abbd15988ea + md5: e35f08043f54d26a1be93fdbf90d30c3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: Apache + size: 905436 + timestamp: 1765458949518 +- conda: https://conda.anaconda.org/conda-forge/osx-64/tornado-6.5.3-py314h6482030_0.conda + sha256: 783ff5e72fe309dffdadbabc9da39bce61a23eaf4cf1a8fccbea58cbc8852486 + md5: dbc922389daff37d23ac89178f5ad21b + depends: + - __osx >=10.13 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: Apache + size: 903268 + timestamp: 1765459306735 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.3-py314h0612a62_0.conda + sha256: 2d8ed4e017012f16483edf88fd9848ac52dbff25448d96f856a7598fdcf1190d + md5: fd6664676f3a2145d153b3967c6a19ef + depends: + - __osx >=11.0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: Apache + size: 907916 + timestamp: 1765459269336 +- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.3-py314h5a2d7ad_0.conda + sha256: d3029e206dc6f83ab76932994f9b075f5fd71a214b2c64df2e0825d0ec4e0ba8 + md5: acfb3820f4d4858807aeb871d64b7144 + depends: + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + size: 909227 + timestamp: 1765459311895 +- conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda + sha256: f39a5620c6e8e9e98357507262a7869de2ae8cc07da8b7f84e517c9fd6c2b959 + md5: 019a7385be9af33791c989871317e1ed + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 110051 + timestamp: 1733367480074 +- conda: https://conda.anaconda.org/conda-forge/noarch/traittypes-0.2.3-pyh332efcf_0.conda + sha256: 67a77ce374a792fc6d8e4d56c83c21b6cf3a7f43b6e98c1db2cbed2254144d05 + md5: d22a0bf07f57cfb1240185961d182a8d + depends: + - python >=3.9 + - traitlets >=4.2.2,<6.0 + license: BSD-3-Clause + license_family: BSD + size: 13283 + timestamp: 1761131966141 +- conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.20.0-pyhefaf540_1.conda + sha256: 17a1e572939af33d709248170871d4da74f7e32b48f2e9b5abca613e201c6e64 + md5: 23a53fdefc45ba3f4e075cc0997fd13b + depends: + - typer-slim-standard ==0.20.0 h4daf872_1 + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 79829 + timestamp: 1762984042927 +- conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-0.20.0-pyhcf101f3_1.conda + sha256: 4b5ded929080b91367f128e7299619f6116f08bc77d9924a2f8766e2a1b18161 + md5: 4b02a515f3e882dcfe9cfbf0a1f5cd3a + depends: + - python >=3.10 + - click >=8.0.0 + - typing_extensions >=3.7.4.3 + - python + constrains: + - typer 0.20.0.* + - rich >=10.11.0 + - shellingham >=1.3.0 + license: MIT + license_family: MIT + size: 47951 + timestamp: 1762984042920 +- conda: https://conda.anaconda.org/conda-forge/noarch/typer-slim-standard-0.20.0-h4daf872_1.conda + sha256: 5027768bc9a580c8ffbf25872bb2208c058cbb79ae959b1cf2cc54b5d32c0377 + md5: 37b26aafb15a6687b31a3d8d7a1f04e7 + depends: + - typer-slim ==0.20.0 pyhcf101f3_1 + - rich + - shellingham + license: MIT + license_family: MIT + size: 5322 + timestamp: 1762984042927 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + sha256: 7c2df5721c742c2a47b2c8f960e718c930031663ac1174da67c1ed5999f7938c + md5: edd329d7d3a4ab45dcf905899a7a6115 + depends: + - typing_extensions ==4.15.0 pyhcf101f3_0 + license: PSF-2.0 + license_family: PSF + size: 91383 + timestamp: 1756220668932 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhd8ed1ab_1.conda + sha256: 70db27de58a97aeb7ba7448366c9853f91b21137492e0b4430251a1870aa8ff4 + md5: a0a4a3035667fc34f29bfbd5c190baa6 + depends: + - python >=3.10 + - typing_extensions >=4.12.0 + license: MIT + license_family: MIT + size: 18923 + timestamp: 1764158430324 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 + md5: 0caa1af407ecff61170c9437a808404d + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + size: 51692 + timestamp: 1756220668932 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda + sha256: 865716d3e2ccaca1218462645830d2370ab075a9a118c238728e1231a234bc6c + md5: e4e8496b68cf5f25e76fbe67f3856550 + license: LicenseRef-Public-Domain + size: 119010 + timestamp: 1765580300078 +- conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + sha256: 3005729dce6f3d3f5ec91dfc49fc75a0095f9cd23bab49efb899657297ac91a5 + md5: 71b24316859acd00bdb8b38f5e2ce328 + constrains: + - vc14_runtime >=14.29.30037 + - vs2015_runtime >=14.29.30037 + license: LicenseRef-MicrosoftWindowsSDK10 + size: 694692 + timestamp: 1756385147981 +- conda: https://conda.anaconda.org/conda-forge/noarch/uncompresspy-0.4.1-pyhd8ed1ab_0.conda + sha256: 423320baa07b12f611f7d72d6d7136a6feca2ddf3691e3dcf073e84571d05c16 + md5: 06de15bda7ff6019d8e02e6682664b63 + depends: + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + size: 16160 + timestamp: 1760291806337 +- conda: https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.0-py314h5bd0f2a_1.conda + sha256: d1dafc15fc5d2b1dd5b0a525e8a815028de20dd53b2c775a1b56e8e4839fb736 + md5: 58e2ee530005067c5db23f33c6ab43d2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: Apache + size: 409745 + timestamp: 1763055060898 +- conda: https://conda.anaconda.org/conda-forge/osx-64/unicodedata2-17.0.0-py314h6482030_1.conda + sha256: 39e3ff3944c609fc2930ea270e5a9abceaf6b851136cafc7ffee5acf2788a7d8 + md5: d69097de15cbad36f1eaafda0bad598a + depends: + - __osx >=10.13 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: Apache + size: 405564 + timestamp: 1763055016092 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/unicodedata2-17.0.0-py314h0612a62_1.conda + sha256: 48c51dd2ef696f7a1a3635716585a8e383a8c00e719305cfda2b480c36ee1283 + md5: c673decfe1f120b0717d0aa193b10060 + depends: + - __osx >=11.0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: Apache + size: 416770 + timestamp: 1763055099322 +- conda: https://conda.anaconda.org/conda-forge/win-64/unicodedata2-17.0.0-py314h5a2d7ad_1.conda + sha256: 47e061aec1487519c398e1c999ac3680f068f9e1d8574c8b365eac4787773250 + md5: 1f90bb13fa5ced89ca4dcc0af3bbebf3 + depends: + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + size: 405783 + timestamp: 1763054877424 +- conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.1-pyhd8ed1ab_0.conda + sha256: a66fc716c9dc6eb048c40381b0d1c5842a1d74bba7ce3d16d80fc0a7232d8644 + md5: fb84f0f6ee8a0ad67213cd1bea98bf5b + depends: + - backports.zstd >=1.0.0 + - brotli-python >=1.2.0 + - h2 >=4,<5 + - pysocks >=1.5.6,<2.0,!=1.5.7 + - python >=3.10 + license: MIT + license_family: MIT + size: 102817 + timestamp: 1765212810619 +- conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh31011fe_0.conda + sha256: 32e637726fd7cfeb74058e829b116e17514d001846fef56d8c763ec9ec5ac887 + md5: d3aa78bc38d9478e9eed5f128ba35f41 + depends: + - __unix + - click >=7.0 + - h11 >=0.8 + - python >=3.10 + - typing_extensions >=4.0 + license: BSD-3-Clause + license_family: BSD + size: 51717 + timestamp: 1760803935306 +- conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh5737063_0.conda + sha256: ebb120ec1626ced65f3965c08f9ac58d57a18488f991a87dad89f002a2094cb2 + md5: 8fb44dcece55529465f9e6f3e40eef61 + depends: + - __win + - click >=7.0 + - h11 >=0.8 + - python >=3.10 + - typing_extensions >=4.0 + license: BSD-3-Clause + license_family: BSD + size: 51772 + timestamp: 1760804061872 +- conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h31011fe_0.conda + sha256: 3629a349257c0e129cbb84fd593759a31d68ac1219c0af8b8ed89b95b9574c9b + md5: 1ce870d7537376362672f5ff57109529 + depends: + - __unix + - httptools >=0.6.3 + - python-dotenv >=0.13 + - pyyaml >=5.1 + - uvicorn 0.38.0 pyh31011fe_0 + - uvloop >=0.14.0,!=0.15.0,!=0.15.1 + - watchfiles >=0.13 + - websockets >=10.4 + license: BSD-3-Clause + license_family: BSD + size: 7719 + timestamp: 1760803936446 +- conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h5737063_0.conda + sha256: ba4a9d4962a671efd2b911c0be9f576beecff8cc606344a46e0c67720e9f5dbc + md5: 816b80d606a73c2ffaf55e84c3ff2516 + depends: + - __win + - colorama >=0.4 + - httptools >=0.6.3 + - python-dotenv >=0.13 + - pyyaml >=5.1 + - uvicorn 0.38.0 pyh5737063_0 + - watchfiles >=0.13 + - websockets >=10.4 + license: BSD-3-Clause + license_family: BSD + size: 8179 + timestamp: 1760804064891 +- conda: https://conda.anaconda.org/conda-forge/linux-64/uvloop-0.22.1-py314h5bd0f2a_1.conda + sha256: ad3058ed67e1de5f9a73622a44a5c7a51af6a4527cf4881ae22b8bb6bd30bceb + md5: 41f06d5cb2a80011c7da5a835721acdd + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libuv >=1.51.0,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT OR Apache-2.0 + size: 593392 + timestamp: 1762472837997 +- conda: https://conda.anaconda.org/conda-forge/osx-64/uvloop-0.22.1-py314h6482030_1.conda + sha256: 1b5fecf3c24b76fdc23b27baa552c1a6d10f4a73c73a5cca5fa8b188cd8dd7f7 + md5: e71ee20d1db39d10eb07bae8edfd5969 + depends: + - __osx >=10.13 + - libuv >=1.51.0,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT OR Apache-2.0 + size: 509743 + timestamp: 1762473238291 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/uvloop-0.22.1-py314h0612a62_1.conda + sha256: 7850dd9238beb14f9c7db1901229cc5d2ecd10d031cbdb712a95eba57a5d5992 + md5: 74683034f513752be1467c9232480a13 + depends: + - __osx >=11.0 + - libuv >=1.51.0,<2.0a0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: MIT OR Apache-2.0 + size: 492509 + timestamp: 1762473163613 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h2b53caa_33.conda + sha256: 7036945b5fff304064108c22cbc1bb30e7536363782b0456681ee6cf209138bd + md5: 2d1c042360c09498891809a3765261be + depends: + - vc14_runtime >=14.42.34433 + track_features: + - vc14 + license: BSD-3-Clause + license_family: BSD + size: 19070 + timestamp: 1765216452130 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_33.conda + sha256: 7e8f7da25d7ce975bbe7d7e6d6e899bf1f253e524a3427cc135a79f3a79c457c + md5: fb8e4914c5ad1c71b3c519621e1df7b8 + depends: + - ucrt >=10.0.20348.0 + - vcomp14 14.44.35208 h818238b_33 + constrains: + - vs2015_runtime 14.44.35208.* *_33 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + license_family: Proprietary + size: 684323 + timestamp: 1765216366832 +- conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_33.conda + sha256: f79edd878094e86af2b2bc1455b0a81e02839a784fb093d5996ad4cf7b810101 + md5: 4cb6942b4bd846e51b4849f4a93c7e6d + depends: + - ucrt >=10.0.20348.0 + constrains: + - vs2015_runtime 14.44.35208.* *_33 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + license_family: Proprietary + size: 115073 + timestamp: 1765216325898 +- conda: https://conda.anaconda.org/conda-forge/win-64/vs2015_runtime-14.44.35208-h38c0c73_33.conda + sha256: 93fc61d05770f4c6b66214ed3494f632bf6e0e6ee7fcb0fb0a847a4bed131953 + md5: 65e5a2127012cd4dbc9354579661b9fd + depends: + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + size: 19159 + timestamp: 1765216369037 +- conda: https://conda.anaconda.org/conda-forge/linux-64/watchfiles-1.1.1-py314ha5689aa_0.conda + sha256: fcec93ca26320764c55042fc56b772a88533ed01f1c713553c985b379e174d09 + md5: fb190bbf05b3b963bea7ab7c20624d5d + depends: + - __glibc >=2.17,<3.0.a0 + - anyio >=3.0.0 + - libgcc >=14 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + size: 421969 + timestamp: 1760456771978 +- conda: https://conda.anaconda.org/conda-forge/osx-64/watchfiles-1.1.1-py314hc9c287a_0.conda + sha256: 860883a9d79688de244443859ff958f35a2363909310d6cc249e95f8453ad136 + md5: 289c6991af0a4a28091b9522e1bb5ec1 + depends: + - __osx >=10.13 + - anyio >=3.0.0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 380050 + timestamp: 1760457275658 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/watchfiles-1.1.1-py314h8d4a433_0.conda + sha256: b9446970047031e66edf76548fa427fe0ce7e81655208dc2e2a0b0bf94ebf7ba + md5: 33c8e4a66a7cb5d75ba8165a6075cd28 + depends: + - __osx >=11.0 + - anyio >=3.0.0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 367150 + timestamp: 1760457260426 +- conda: https://conda.anaconda.org/conda-forge/win-64/watchfiles-1.1.1-py314h170c82c_0.conda + sha256: b6b3ad95d6c2d92150c8b35367d987beffae083627bb49c996a78fc129ab2e00 + md5: f86852dadc13af0ec70e02b175159481 + depends: + - anyio >=3.0.0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 305573 + timestamp: 1760457150003 +- conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-hd6090a7_1.conda + sha256: 3aa04ae8e9521d9b56b562376d944c3e52b69f9d2a0667f77b8953464822e125 + md5: 035da2e4f5770f036ff704fa17aace24 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.7.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 329779 + timestamp: 1761174273487 +- conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda + sha256: e311b64e46c6739e2a35ab8582c20fa30eb608da130625ed379f4467219d4813 + md5: 7e1e5ff31239f9cd5855714df8a3783d + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 33670 + timestamp: 1758622418893 +- conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda + sha256: 19ff205e138bb056a46f9e3839935a2e60bd1cf01c8241a5e172a422fed4f9c6 + md5: 2841eb5bfc75ce15e9a0054b98dcd64d + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 15496 + timestamp: 1733236131358 +- conda: https://conda.anaconda.org/conda-forge/linux-64/websockets-15.0.1-py314h31f8a6b_2.conda + sha256: 102c0acc2301908bcc0bd0c792e059cf8a6b93fc819f56c8a3b8a6b473afe58a + md5: e05c3cce47cc4f32f886eb17091ba6e2 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 380425 + timestamp: 1756476367704 +- conda: https://conda.anaconda.org/conda-forge/osx-64/websockets-15.0.1-py314hcfd16f8_2.conda + sha256: 9b448d15a7e64dba0b10b16d3baa327b2249a9e0828b129829589a14ff34e9a7 + md5: bb2248bc747080459471cdaf10c7202a + depends: + - python + - __osx >=10.13 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 380757 + timestamp: 1756476422027 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/websockets-15.0.1-py314hf17b0b1_2.conda + sha256: c00677dc11e5f20e115ab7252c60893cd0bac9fc78b12678d62ba6b1b5dcb3f7 + md5: 22ef4a8d9fdd426f7fb9d5b3bf168c2a + depends: + - python + - python 3.14.* *_cp314 + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 383627 + timestamp: 1756476437332 +- conda: https://conda.anaconda.org/conda-forge/win-64/websockets-15.0.1-py314h4667ab5_2.conda + sha256: 678cee096988ceafad1ed5aea904fb662d7208ee0eb4ab68acabd97997e8d814 + md5: ddff54cc7821bfa04ec8fdc34b4d5dba + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 437357 + timestamp: 1756476400401 +- conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda + sha256: 826af5e2c09e5e45361fa19168f46ff524e7a766022615678c3a670c45895d9a + md5: dc257b7e7cad9b79c1dfba194e92297b + depends: + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + size: 889195 + timestamp: 1762040404362 +- conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda + sha256: 93807369ab91f230cf9e6e2a237eaa812492fe00face5b38068735858fba954f + md5: 46e441ba871f524e2b067929da3051c2 + depends: + - __win + - python >=3.9 + license: LicenseRef-Public-Domain + size: 9555 + timestamp: 1733130678956 +- conda: https://conda.anaconda.org/conda-forge/linux-64/wrapt-1.17.3-py314h5bd0f2a_1.conda + sha256: e2b6545651aed5e7dead39b7ba3bf8c2669f194c71e89621343bd0bb321a87f1 + md5: 82da729c870ada2f675689a39b4f697f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.14.0rc2,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + size: 64997 + timestamp: 1756851739706 +- conda: https://conda.anaconda.org/conda-forge/osx-64/wrapt-1.17.3-py314h03d016b_1.conda + sha256: 72e67726778356a45bba26c598bd91f13e95b37d0f931e8217408f9e20527786 + md5: eddd65903cdc82babc86d48aba49acae + depends: + - __osx >=10.13 + - python >=3.14.0rc2,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + size: 61409 + timestamp: 1756851745948 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/wrapt-1.17.3-py314hb84d1df_1.conda + sha256: 0f35a19fd99724e8620dc89a6fb9eb100d300f117292adde2c7e8cf12d566e10 + md5: 104bf69250e32a42ca144d7f7abd5d5c + depends: + - __osx >=11.0 + - python >=3.14.0rc2,<3.15.0a0 + - python >=3.14.0rc2,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + size: 61800 + timestamp: 1756851815321 +- conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-1.17.3-py314h5a2d7ad_1.conda + sha256: ecbee7584bc5dfcabed36240059a156dab0d6dd87a0246c71b32b82640558a78 + md5: 0172693b00f64c34667a5bdda0449eb9 + depends: + - python >=3.14.0rc2,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-2-Clause + license_family: BSD + size: 63873 + timestamp: 1756852097390 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda + sha256: ad8cab7e07e2af268449c2ce855cbb51f43f4664936eff679b1f3862e6e4b01d + md5: fdc27cb255a7a2cc73b7919a968b48f0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libxcb >=1.17.0,<2.0a0 + license: MIT + license_family: MIT + size: 20772 + timestamp: 1750436796633 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda + sha256: c2be9cae786fdb2df7c2387d2db31b285cf90ab3bfabda8fa75a596c3d20fc67 + md5: 4d1fc190b99912ed557a8236e958c559 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libxcb >=1.13 + - libxcb >=1.17.0,<2.0a0 + - xcb-util-image >=0.4.0,<0.5.0a0 + - xcb-util-renderutil >=0.3.10,<0.4.0a0 + license: MIT + license_family: MIT + size: 20829 + timestamp: 1763366954390 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda + sha256: 94b12ff8b30260d9de4fd7a28cca12e028e572cbc504fd42aa2646ec4a5bded7 + md5: a0901183f08b6c7107aab109733a3c91 + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + - xcb-util >=0.4.1,<0.5.0a0 + license: MIT + license_family: MIT + size: 24551 + timestamp: 1718880534789 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda + sha256: 546e3ee01e95a4c884b6401284bb22da449a2f4daf508d038fdfa0712fe4cc69 + md5: ad748ccca349aec3e91743e08b5e2b50 + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + license: MIT + license_family: MIT + size: 14314 + timestamp: 1718846569232 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda + sha256: 2d401dadc43855971ce008344a4b5bd804aca9487d8ebd83328592217daca3df + md5: 0e0cbe0564d03a99afd5fd7b362feecd + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + license: MIT + license_family: MIT + size: 16978 + timestamp: 1718848865819 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda + sha256: 31d44f297ad87a1e6510895740325a635dd204556aa7e079194a0034cdd7e66a + md5: 608e0ef8256b81d04456e8d211eee3e8 + depends: + - libgcc-ng >=12 + - libxcb >=1.16,<2.0.0a0 + license: MIT + license_family: MIT + size: 51689 + timestamp: 1718844051451 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda + sha256: aa03b49f402959751ccc6e21932d69db96a65a67343765672f7862332aa32834 + md5: 71ae752a748962161b4740eaff510258 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + size: 396975 + timestamp: 1759543819846 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + sha256: c12396aabb21244c212e488bbdc4abcdef0b7404b15761d9329f5a4a39113c4b + md5: fb901ff28063514abb6046c9ec2c4a45 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + size: 58628 + timestamp: 1734227592886 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + sha256: 277841c43a39f738927145930ff963c5ce4c4dacf66637a3d95d802a64173250 + md5: 1c74ff8c35dcadf952a16f752ca5aa49 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libuuid >=2.38.1,<3.0a0 + - xorg-libice >=1.1.2,<2.0a0 + license: MIT + license_family: MIT + size: 27590 + timestamp: 1741896361728 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda + sha256: 51909270b1a6c5474ed3978628b341b4d4472cd22610e5f22b506855a5e20f67 + md5: db038ce880f100acc74dba10302b5630 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libxcb >=1.17.0,<2.0a0 + license: MIT + license_family: MIT + size: 835896 + timestamp: 1741901112627 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + sha256: 6bc6ab7a90a5d8ac94c7e300cc10beb0500eeba4b99822768ca2f2ef356f731b + md5: b2895afaf55bf96a8c8282a2e47a5de0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 15321 + timestamp: 1762976464266 +- conda: https://conda.anaconda.org/conda-forge/osx-64/xorg-libxau-1.0.12-h8616949_1.conda + sha256: 928f28bd278c7da674b57d71b2e7f4ac4e7c7ce56b0bf0f60d6a074366a2e76d + md5: 47f1b8b4a76ebd0cd22bd7153e54a4dc + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 13810 + timestamp: 1762977180568 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxau-1.0.12-hc919400_1.conda + sha256: adae11db0f66f86156569415ed79cda75b2dbf4bea48d1577831db701438164f + md5: 78b548eed8227a689f93775d5d23ae09 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 14105 + timestamp: 1762976976084 +- conda: https://conda.anaconda.org/conda-forge/win-64/xorg-libxau-1.0.12-hba3369d_1.conda + sha256: 156a583fa43609507146de1c4926172286d92458c307bb90871579601f6bc568 + md5: 8436cab9a76015dfe7208d3c9f97c156 + depends: + - libgcc >=14 + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + size: 109246 + timestamp: 1762977105140 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda + sha256: 753f73e990c33366a91fd42cc17a3d19bb9444b9ca5ff983605fa9e953baf57f + md5: d3c295b50f092ab525ffe3c2aa4b7413 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + license: MIT + license_family: MIT + size: 13603 + timestamp: 1727884600744 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + sha256: 832f538ade441b1eee863c8c91af9e69b356cd3e9e1350fff4fe36cc573fc91a + md5: 2ccd714aa2242315acaf0a67faea780b + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + - xorg-libxrender >=0.9.11,<0.10.0a0 + license: MIT + license_family: MIT + size: 32533 + timestamp: 1730908305254 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda + sha256: 43b9772fd6582bf401846642c4635c47a9b0e36ca08116b3ec3df36ab96e0ec0 + md5: b5fcc7172d22516e1f965490e65e33a4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + license: MIT + license_family: MIT + size: 13217 + timestamp: 1727891438799 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + sha256: 25d255fb2eef929d21ff660a0c687d38a6d2ccfbcbf0cc6aa738b12af6e9d142 + md5: 1dafce8548e38671bea82e3f5c6ce22f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 20591 + timestamp: 1762976546182 +- conda: https://conda.anaconda.org/conda-forge/osx-64/xorg-libxdmcp-1.1.5-h8616949_1.conda + sha256: b7b291cc5fd4e1223058542fca46f462221027779920dd433d68b98e858a4afc + md5: 435446d9d7db8e094d2c989766cfb146 + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 19067 + timestamp: 1762977101974 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxdmcp-1.1.5-hc919400_1.conda + sha256: f7fa0de519d8da589995a1fe78ef74556bb8bc4172079ae3a8d20c3c81354906 + md5: 9d1299ace1924aa8f4e0bc8e71dd0cf7 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 19156 + timestamp: 1762977035194 +- conda: https://conda.anaconda.org/conda-forge/win-64/xorg-libxdmcp-1.1.5-hba3369d_1.conda + sha256: 366b8ae202c3b48958f0b8784bbfdc37243d3ee1b1cd4b8e76c10abe41fa258b + md5: a7c03e38aa9c0e84d41881b9236eacfb + depends: + - libgcc >=14 + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + size: 70691 + timestamp: 1762977015220 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda + sha256: da5dc921c017c05f38a38bd75245017463104457b63a1ce633ed41f214159c14 + md5: febbab7d15033c913d53c7a2c102309d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + license: MIT + license_family: MIT + size: 50060 + timestamp: 1727752228921 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + sha256: 83c4c99d60b8784a611351220452a0a85b080668188dce5dfa394b723d7b64f4 + md5: ba231da7fccf9ea1e768caf5c7099b84 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + size: 20071 + timestamp: 1759282564045 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda + sha256: 1a724b47d98d7880f26da40e45f01728e7638e6ec69f35a3e11f92acd05f9e7a + md5: 17dcc85db3c7886650b8908b183d6876 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + license: MIT + license_family: MIT + size: 47179 + timestamp: 1727799254088 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda + sha256: ac0f037e0791a620a69980914a77cb6bb40308e26db11698029d6708f5aa8e0d + md5: 2de7f99d6581a4a7adbff607b5c278ca + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.11,<0.10.0a0 + license: MIT + license_family: MIT + size: 29599 + timestamp: 1727794874300 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + sha256: 044c7b3153c224c6cedd4484dd91b389d2d7fd9c776ad0f4a34f099b3389f4a1 + md5: 96d57aba173e878a2089d5638016dc5e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + license: MIT + license_family: MIT + size: 33005 + timestamp: 1734229037766 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + sha256: 752fdaac5d58ed863bbf685bb6f98092fe1a488ea8ebb7ed7b606ccfce08637a + md5: 7bbe9a0cc0df0ac5f5a8ad6d6a11af2f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxi >=1.7.10,<2.0a0 + license: MIT + license_family: MIT + size: 32808 + timestamp: 1727964811275 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda + sha256: 8a4e2ee642f884e6b78c20c0892b85dd9b2a6e64a6044e903297e616be6ca35b + md5: 5efa5fa6243a622445fdfd72aee15efa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + license: MIT + license_family: MIT + size: 17819 + timestamp: 1734214575628 +- conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda + sha256: 6d9ea2f731e284e9316d95fa61869fe7bbba33df7929f82693c121022810f4ad + md5: a77f85f77be52ff59391544bfe73390a + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + size: 85189 + timestamp: 1753484064210 +- conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda + sha256: a335161bfa57b64e6794c3c354e7d49449b28b8d8a7c4ed02bf04c3f009953f9 + md5: a645bb90997d3fc2aea0adf6517059bd + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 79419 + timestamp: 1753484072608 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda + sha256: b03433b13d89f5567e828ea9f1a7d5c5d697bf374c28a4168d71e9464f5dafac + md5: 78a0fe9e9c50d2c381e8ee47e3ea437d + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 83386 + timestamp: 1753484079473 +- conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda + sha256: 80ee68c1e7683a35295232ea79bcc87279d31ffeda04a1665efdb43cbd50a309 + md5: 433699cba6602098ae8957a323da2664 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + size: 63944 + timestamp: 1753484092156 +- conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.22.0-pyh7db6752_0.conda + sha256: b04271f56c68483b411c5465afff73b8eabdea564e942f0e7afed06619272635 + md5: ca3c00c764cee005798a518cba79885c + depends: + - idna >=2.0 + - multidict >=4.0 + - propcache >=0.2.1 + - python >=3.10 + track_features: + - yarl_no_compile + license: Apache-2.0 + license_family: Apache + size: 73066 + timestamp: 1761337117132 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h387f397_9.conda + sha256: 47cfe31255b91b4a6fa0e9dbaf26baa60ac97e033402dbc8b90ba5fee5ffe184 + md5: 8035e5b54c08429354d5d64027041cad + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libsodium >=1.0.20,<1.0.21.0a0 + - krb5 >=1.21.3,<1.22.0a0 + license: MPL-2.0 + license_family: MOZILLA + size: 310648 + timestamp: 1757370847287 +- conda: https://conda.anaconda.org/conda-forge/osx-64/zeromq-4.3.5-h6c33b1e_9.conda + sha256: 30aa5a2e9c7b8dbf6659a2ccd8b74a9994cdf6f87591fcc592970daa6e7d3f3c + md5: d940d809c42fbf85b05814c3290660f5 + depends: + - __osx >=10.13 + - libcxx >=19 + - libsodium >=1.0.20,<1.0.21.0a0 + - krb5 >=1.21.3,<1.22.0a0 + license: MPL-2.0 + license_family: MOZILLA + size: 259628 + timestamp: 1757371000392 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h888dc83_9.conda + sha256: b6f9c130646e5971f6cad708e1eee278f5c7eea3ca97ec2fdd36e7abb764a7b8 + md5: 26f39dfe38a2a65437c29d69906a0f68 + depends: + - __osx >=11.0 + - libcxx >=19 + - libsodium >=1.0.20,<1.0.21.0a0 + - krb5 >=1.21.3,<1.22.0a0 + license: MPL-2.0 + license_family: MOZILLA + size: 244772 + timestamp: 1757371008525 +- conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h5bddc39_9.conda + sha256: 690cf749692c8ea556646d1a47b5824ad41b2f6dfd949e4cdb6c44a352fcb1aa + md5: a6c8f8ee856f7c3c1576e14b86cd8038 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - libsodium >=1.0.20,<1.0.21.0a0 + - krb5 >=1.21.3,<1.22.0a0 + license: MPL-2.0 + license_family: MOZILLA + size: 265212 + timestamp: 1757370864284 +- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + sha256: b4533f7d9efc976511a73ef7d4a2473406d7f4c750884be8e8620b0ce70f4dae + md5: 30cd29cb87d819caead4d55184c1d115 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 24194 + timestamp: 1764460141901 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.1-hb9d3cd8_2.conda + sha256: 5d7c0e5f0005f74112a34a7425179f4eb6e73c92f5d109e6af4ddeca407c92ab + md5: c9f075ab2f33b3bbee9e62d4ad0a6cd8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libzlib 1.3.1 hb9d3cd8_2 + license: Zlib + license_family: Other + size: 92286 + timestamp: 1727963153079 +- conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-1.3.1-hd23fc13_2.conda + sha256: 219edbdfe7f073564375819732cbf7cc0d7c7c18d3f546a09c2dfaf26e4d69f3 + md5: c989e0295dcbdc08106fe5d9e935f0b9 + depends: + - __osx >=10.13 + - libzlib 1.3.1 hd23fc13_2 + license: Zlib + license_family: Other + size: 88544 + timestamp: 1727963189976 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.1-h8359307_2.conda + sha256: 58f8860756680a4831c1bf4f294e2354d187f2e999791d53b1941834c4b37430 + md5: e3170d898ca6cb48f1bb567afb92f775 + depends: + - __osx >=11.0 + - libzlib 1.3.1 h8359307_2 + license: Zlib + license_family: Other + size: 77606 + timestamp: 1727963209370 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.2-h54a6638_0.conda + sha256: 0afb07f3511031c35202036e2cd819c90edaa0c6a39a7a865146d3cb066bec96 + md5: 0faadd01896315ceea58bcc3479b1d21 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + license: Zlib + size: 135032 + timestamp: 1764715875371 +- conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.2-h53ec75d_0.conda + sha256: 9183b2ada178d83ca6f8a66ba2ddcfb5f2476c2e866a4609c1f84dd5f32d796e + md5: 1e979f90e823b82604ab1da7e76c75e5 + depends: + - __osx >=10.13 + - libcxx >=19 + license: Zlib + size: 135199 + timestamp: 1764716055794 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.2-h248ca61_0.conda + sha256: 2fe2befe061a51c24fce7f5f071c47b45b43f8c8781c0c557edf7c733ab13b18 + md5: c2a30a3b30cf86ef97ec880d53a6571a + depends: + - libcxx >=19 + - __osx >=11.0 + license: Zlib + size: 105035 + timestamp: 1764716000870 +- conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.2-h5112557_0.conda + sha256: 331e63a801efc9aa47e0a7f7be5becc81d9c52c1163308182078108e003c12e5 + md5: 2b4f8712b09b5fd3182cda872ce8482c + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: Zlib + size: 134848 + timestamp: 1764715928393 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + sha256: 47101a4055a70a4876ffc87b750ab2287b67eca793f21c8224be5e1ee6394d3f + md5: 727109b184d680772e3122f40136d5ca + depends: + - __osx >=10.13 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 528148 + timestamp: 1764777156963 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 + md5: ab136e4c34e97f34fb621d2592a393d8 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 433413 + timestamp: 1764777166076 +- conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + sha256: 368d8628424966fd8f9c8018326a9c779e06913dd39e646cf331226acc90e5b2 + md5: 053b84beec00b71ea8ff7a4f84b55207 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 388453 + timestamp: 1764777142545 diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 0000000..bc21b6d --- /dev/null +++ b/pixi.toml @@ -0,0 +1,87 @@ +[workspace] +name = "stingray-explorer" +version = "1.0.0" +description = "Desktop application for X-ray timing analysis using the Stingray library" +channels = ["conda-forge"] +platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"] + +[tasks] +# Development - Run these in separate terminals +start-backend = { cmd = "cd python-backend && python main.py", description = "Start Python backend server (auto-finds free port)" } +start-frontend = { cmd = "npm run dev", description = "Start Electron frontend" } + +# Run both together (recommended) +dev = { cmd = "bash scripts/dev.sh", description = "Start full development environment" } + +# Kill any orphaned backend processes +kill-backend = { cmd = "pkill -f 'python main.py' || true", description = "Kill any running backend processes" } + +# Build +build = { cmd = "npm run build", description = "Build the Electron app" } +build-frontend = { cmd = "npm run build", description = "Build frontend only" } + +# Install +install-npm = { cmd = "npm install", description = "Install npm dependencies" } +setup = { depends-on = ["install-npm"], description = "Setup the project" } + +# Testing +test = { depends-on = ["test-python"], description = "Run all tests" } + +# Utilities +lint = { cmd = "npm run lint", description = "Run linter" } +format = { cmd = "npm run format", description = "Format code" } +health-check = { cmd = "curl -s http://127.0.0.1:8765/health", description = "Check backend health" } + +# Package +package = { cmd = "npm run build && npm run package", description = "Package the application" } + +[dependencies] +# Python +python = ">=3.10" +pip = "*" + +# Stingray and scientific stack +stingray = ">=2.0" +numpy = ">=1.24" +scipy = ">=1.11" +astropy = ">=5.3" +pandas = ">=2.0" +matplotlib = ">=3.7" +pytables = ">=3.8" +numba = ">=0.58" # Optional but recommended for faster Stingray computations + +# FastAPI backend +fastapi = ">=0.109" +uvicorn = ">=0.27" +pydantic = ">=2.5" +httpx = ">=0.26" +python-multipart = "*" +aiofiles = "*" + +# Utilities +psutil = ">=5.9" +requests = ">=2.31" + +# HEASARC archive queries +astroquery = ">=0.4.8" + +# HTML parsing for HEASARC directory listings +beautifulsoup4 = ">=4.12" +lxml = ">=5.0" + +# Node.js (npm is included with nodejs) +nodejs = ">=20" + +[feature.dev.tasks] +test-python = { cmd = "pytest python-backend/tests", description = "Run Python backend tests" } + +[feature.dev.dependencies] +# Development dependencies +pytest = ">=7.0" +pytest-asyncio = "*" +black = "*" +ruff = "*" + +[environments] +default = { features = [], solve-group = "default" } +dev = { features = ["dev"], solve-group = "default" } diff --git a/pyproject.toml b/pyproject.toml index cf5ad94..f549119 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,3 +16,4 @@ dependencies = [ ] [tool.pytest.ini_options] pythonpath = ["."] +testpaths = ["python-backend/tests"] diff --git a/python-backend/main.py b/python-backend/main.py new file mode 100644 index 0000000..3ddc3dd --- /dev/null +++ b/python-backend/main.py @@ -0,0 +1,550 @@ +""" +Stingray Explorer Python Backend + +FastAPI server providing REST API endpoints for X-ray timing analysis +using the Stingray library. +""" + +import logging +import os +import secrets +import signal +import socket +import sys +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from routes import ( + archive_routes, + correlation_routes, + data_routes, + deadtime_routes, + gti_routes, + internal_grant_routes, + io_utility_routes, + job_routes, + lightcurve_routes, + log_routes, + misc_routes, + mission_io_routes, + spectrum_routes, + statistics_routes, + timing_routes, + varenergy_routes, +) +from services.job_manager import JobManager +from services.state_manager import StateManager +from starlette.types import ASGIApp, Message, Receive, Scope, Send +from utils.log_stream import log_stream_manager + +from services.data_service import DataService +from utils.performance_monitor import PerformanceMonitor + +MAX_REQUEST_BODY_BYTES = 8 * 1024**2 +MAX_SERIALIZED_VALIDATION_ERRORS = 50 +MAX_VALIDATION_MESSAGE_CHARS = 512 +MAX_VALIDATION_LOCATION_PARTS = 16 +BACKEND_SESSION_ENV = "STINGRAY_BACKEND_SESSION_SECRET" +BACKEND_SESSION_HEADER = "x-stingray-session" +MIN_BACKEND_SESSION_SECRET_BYTES = 32 +ALLOWED_RENDERER_ORIGINS = ("http://localhost:5173", "null") +ALLOWED_CORS_METHODS = ("GET", "POST", "DELETE", "OPTIONS") +ALLOWED_CORS_HEADERS = ("Accept", "Content-Type", "X-Stingray-Session") + + +def _scope_header_values(scope: Scope, header_name: bytes) -> list[bytes]: + """Return every value for one ASGI header without hiding duplicates.""" + + return [ + value for name, value in scope.get("headers", []) if name.lower() == header_name + ] + + +class BackendSessionMiddleware: + """Authenticate every state-bearing loopback request before route execution.""" + + def __init__(self, app: ASGIApp, session_secret: str | None) -> None: + self.app = app + encoded = session_secret.encode("utf-8") if session_secret else b"" + self.session_secret = ( + encoded if len(encoded) >= MIN_BACKEND_SESSION_SECRET_BYTES else None + ) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + origin_values = _scope_header_values(scope, b"origin") + if len(origin_values) > 1: + await self._reject(scope, receive, send, 403, "Origin is not allowed") + return + if origin_values: + try: + origin = origin_values[0].decode("ascii") + except UnicodeDecodeError: + origin = "" + if origin not in ALLOWED_RENDERER_ORIGINS: + await self._reject(scope, receive, send, 403, "Origin is not allowed") + return + + # A health probe is intentionally public, but contains no application state. + if scope.get("path") == "/health": + await self.app(scope, receive, send) + return + + # Browser preflights cannot carry the per-launch credential. The outer + # CORSMiddleware validates the requested origin, method, and headers and + # terminates genuine preflights before they reach this middleware. + if scope.get("method") == "OPTIONS": + await self.app(scope, receive, send) + return + + if self.session_secret is None: + await self._reject( + scope, + receive, + send, + 503, + "Backend session authentication is not configured", + ) + return + + credential_values = _scope_header_values( + scope, BACKEND_SESSION_HEADER.encode("ascii") + ) + authenticated = len(credential_values) == 1 and secrets.compare_digest( + credential_values[0], self.session_secret + ) + if not authenticated: + await self._reject( + scope, receive, send, 401, "Backend session authentication required" + ) + return + + await self.app(scope, receive, send) + + async def _reject( + self, + scope: Scope, + receive: Receive, + send: Send, + status_code: int, + detail: str, + ) -> None: + response = JSONResponse(status_code=status_code, content={"detail": detail}) + await response(scope, receive, send) + + +class RequestBodyLimitMiddleware: + """Reject oversized HTTP bodies even when Content-Length is absent or false.""" + + def __init__(self, app: ASGIApp, max_body_size: int) -> None: + self.app = app + self.max_body_size = max_body_size + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + for name, value in scope.get("headers", []): + if name.lower() != b"content-length": + continue + try: + declared_size = int(value) + except ValueError: + # The server normally rejects malformed Content-Length values. + # Counting the received bytes is still safe if one reaches here. + continue + if declared_size > self.max_body_size: + await self._send_too_large(scope, receive, send) + return + + buffered_messages: list[Message] = [] + received_size = 0 + while True: + message = await receive() + buffered_messages.append(message) + if message["type"] == "http.disconnect": + break + if message["type"] != "http.request": + continue + + received_size += len(message.get("body", b"")) + if received_size > self.max_body_size: + await self._send_too_large(scope, receive, send) + return + if not message.get("more_body", False): + break + + message_index = 0 + + async def replay_receive() -> Message: + nonlocal message_index + if message_index < len(buffered_messages): + message = buffered_messages[message_index] + message_index += 1 + return message + # Streaming responses keep listening for a real client disconnect + # after the request body has been consumed. Fabricating one here + # would cancel their response producer before it can finish. + return await receive() + + await self.app(scope, replay_receive, send) + + async def _send_too_large(self, scope: Scope, receive: Receive, send: Send) -> None: + limit_mib = self.max_body_size // 1024**2 + response = JSONResponse( + status_code=413, + content={"detail": f"Request body exceeds the {limit_mib} MiB limit"}, + ) + await response(scope, receive, send) + + +def _bounded_validation_errors(exception: RequestValidationError) -> list[dict]: + """Return useful validation details without reflecting attacker-sized input.""" + + errors = exception.errors() + result: list[dict] = [] + for error in errors[:MAX_SERIALIZED_VALIDATION_ERRORS]: + location = [] + for part in error.get("loc", ())[:MAX_VALIDATION_LOCATION_PARTS]: + if isinstance(part, int): + location.append(part) + else: + location.append(str(part)[:MAX_VALIDATION_MESSAGE_CHARS]) + result.append( + { + "type": str(error.get("type", "value_error"))[ + :MAX_VALIDATION_MESSAGE_CHARS + ], + "loc": location, + "msg": str(error.get("msg", "Invalid value"))[ + :MAX_VALIDATION_MESSAGE_CHARS + ], + "input": None, + } + ) + + omitted = len(errors) - len(result) + if omitted: + result.append( + { + "type": "validation_errors_omitted", + "loc": ["body"], + "msg": f"{omitted} additional validation error(s) omitted", + "input": None, + } + ) + return result + + +# Filter to suppress /api/status access logs (polled every 2s, would flood logs) +class StatusEndpointFilter(logging.Filter): + """Filter out /api/status requests from uvicorn access logs.""" + + def filter(self, record: logging.LogRecord) -> bool: + return "/api/status" not in record.getMessage() + + +# Global instances +state_manager: StateManager = None +performance_monitor: PerformanceMonitor = None +data_service: DataService = None +job_manager: JobManager = None + + +def parse_requested_port(value: str) -> int: + """Parse an explicit port using one canonical decimal representation.""" + if ( + not isinstance(value, str) + or not value + or not value.isascii() + or not value.isdigit() + or value[0] == "0" + ): + raise ValueError("PORT must be a canonical decimal port between 1 and 65535") + port = int(value) + if port < 1 or port > 65535: + raise ValueError("PORT must be a canonical decimal port between 1 and 65535") + return port + + +def bind_backend_socket( + start_port: int = 8765, + max_attempts: int = 100, + requested_port: str | None = None, +) -> socket.socket: + """Bind and listen on the selected loopback port before announcing it.""" + if requested_port is not None: + candidates = (parse_requested_port(requested_port),) + else: + if not isinstance(start_port, int) or not 1 <= start_port <= 65535: + raise ValueError("start_port must be between 1 and 65535") + if not isinstance(max_attempts, int) or max_attempts <= 0: + raise ValueError("max_attempts must be positive") + candidates = range(start_port, min(65536, start_port + max_attempts)) + + last_error: OSError | None = None + for port in candidates: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + if sys.platform == "win32": + exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None) + if exclusive is not None: + listener.setsockopt(socket.SOL_SOCKET, exclusive, 1) + else: + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", port)) + listener.listen() + return listener + except OSError as error: + last_error = error + listener.close() + if requested_port is not None: + break + + if requested_port is not None and last_error is not None: + raise RuntimeError("The requested backend port is unavailable") from last_error + raise RuntimeError( + f"Could not find a free port in range {start_port}-{start_port + max_attempts}" + ) from last_error + + +def run_backend(requested_port: str | None = None) -> None: + """Own the listener for the complete Uvicorn lifetime.""" + import uvicorn + + listener = bind_backend_socket(8765, 100, requested_port) + actual_port = listener.getsockname()[1] + print(f"BACKEND_PORT:{actual_port}", flush=True) + config = uvicorn.Config( + "main:app", + host="127.0.0.1", + port=actual_port, + reload=False, + log_level="info", + workers=1, + ) + server = uvicorn.Server(config) + try: + server.run(sockets=[listener]) + finally: + listener.close() + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Application lifespan handler for startup/shutdown events.""" + global state_manager, performance_monitor, data_service, job_manager + + # Startup + print("Starting Stingray Explorer Backend...") + state_manager = StateManager() + performance_monitor = PerformanceMonitor() + data_service = DataService(state_manager, performance_monitor) + job_manager = JobManager(state_manager, data_service, max_workers=4) + + # Store in app state for access in routes + app.state.state_manager = state_manager + app.state.performance_monitor = performance_monitor + app.state.data_service = data_service + app.state.job_manager = job_manager + + # Install log streaming to capture Python logs and warnings + log_stream_manager.install(log_level=logging.DEBUG) + + print("Backend initialized successfully") + yield + + # Shutdown + print("Shutting down Stingray Explorer Backend...") + # Shutdown job manager + if job_manager: + job_manager.shutdown() + # Uninstall log streaming + log_stream_manager.uninstall() + + +def create_app( + *, + session_secret: str | None = None, + file_grant_secret: str | None = None, +) -> FastAPI: + """Create and configure the FastAPI application.""" + resolved_session_secret = ( + session_secret + if session_secret is not None + else os.environ.get(BACKEND_SESSION_ENV) + ) + resolved_file_grant_secret = ( + file_grant_secret + if file_grant_secret is not None + else os.environ.get("STINGRAY_FILE_GRANT_SECRET") + ) + app = FastAPI( + title="Stingray Explorer API", + description="REST API for X-ray timing analysis using the Stingray library", + version="1.0.0", + lifespan=lifespan, + ) + # This value is consumed only by the hidden main-process issuance route. + # It is never returned by an endpoint or added to renderer request headers. + app.state._file_grant_secret = resolved_file_grant_secret + + app.add_middleware(RequestBodyLimitMiddleware, max_body_size=MAX_REQUEST_BODY_BYTES) + app.add_middleware(BackendSessionMiddleware, session_secret=resolved_session_secret) + + # The renderer is either the fixed development origin or an authenticated + # packaged file origin (serialized by Chromium as "null"). CORS is only a + # browser response policy; BackendSessionMiddleware remains the auth boundary. + app.add_middleware( + CORSMiddleware, + allow_origins=list(ALLOWED_RENDERER_ORIGINS), + allow_credentials=False, + allow_methods=list(ALLOWED_CORS_METHODS), + allow_headers=list(ALLOWED_CORS_HEADERS), + ) + + @app.exception_handler(RequestValidationError) + async def json_safe_validation_error( + _request: Request, exception: RequestValidationError + ) -> JSONResponse: + """Serialize bounded strict JSON without reflecting the rejected payload.""" + return JSONResponse( + status_code=422, + content={"detail": _bounded_validation_errors(exception)}, + ) + + # Register routes + app.include_router(internal_grant_routes.router, prefix="/internal") + app.include_router(data_routes.router, prefix="/api/data", tags=["Data"]) + app.include_router( + lightcurve_routes.router, prefix="/api/lightcurve", tags=["Lightcurve"] + ) + app.include_router( + spectrum_routes.router, prefix="/api/spectrum", tags=["Spectrum"] + ) + app.include_router(timing_routes.router, prefix="/api/timing", tags=["Timing"]) + app.include_router(log_routes.router, prefix="/api/logs", tags=["Logs"]) + app.include_router(archive_routes.router, prefix="/api/archive", tags=["Archive"]) + app.include_router(job_routes.router, prefix="/api/jobs", tags=["Jobs"]) + app.include_router( + correlation_routes.router, prefix="/api/correlation", tags=["Correlation"] + ) + app.include_router( + varenergy_routes.router, prefix="/api/varenergy", tags=["VarEnergy"] + ) + app.include_router( + deadtime_routes.router, prefix="/api/deadtime", tags=["Deadtime"] + ) + app.include_router( + statistics_routes.router, + prefix="/api/utilities/statistics", + tags=["Utilities - Statistics"], + ) + app.include_router( + gti_routes.router, + prefix="/api/utilities/gti", + tags=["Utilities - GTI"], + ) + app.include_router( + io_utility_routes.router, + prefix="/api/utilities/io", + tags=["Utilities - General I/O"], + ) + app.include_router( + mission_io_routes.router, + prefix="/api/utilities/mission-io", + tags=["Utilities - Mission I/O"], + ) + app.include_router( + misc_routes.router, + prefix="/api/utilities/misc", + tags=["Utilities - Miscellaneous"], + ) + + @app.get("/") + async def root(): + """Root endpoint - health check.""" + return {"status": "ok", "message": "Stingray Explorer API is running"} + + @app.get("/health") + async def health_check(): + """Health check endpoint for Electron to verify backend is ready.""" + return {"status": "healthy", "service": "stingray-explorer-backend"} + + @app.get("/api/status") + async def get_status(): + """Get current application status including process-specific resources.""" + backend_resources = None + + if performance_monitor: + mem_info = performance_monitor.get_memory_usage() + cpu_info = performance_monitor.get_cpu_usage() + + backend_resources = { + # Process-specific metrics (Python backend only) + "memory_mb": mem_info.get("process_mb", 0), + "memory_percent": mem_info.get("process_percent", 0), + "cpu_percent": cpu_info.get("process_percent", 0), + # System totals (for reference and percentage calculations) + "system_memory_total_mb": mem_info.get("system_total_gb", 0) * 1024, + "system_memory_available_mb": mem_info.get("system_available_gb", 0) + * 1024, + "system_cpu_count": cpu_info.get("cpu_count", 1), + } + + return { + "event_lists_loaded": len(state_manager.get_event_data()) + if state_manager + else 0, + "lightcurves_loaded": len(state_manager.get_lightcurve_data()) + if state_manager + else 0, + "backend_resources": backend_resources, + } + + @app.post("/api/shutdown") + async def shutdown(): + """Shutdown the backend server.""" + import asyncio + import os + + async def shutdown_server(): + await asyncio.sleep(0.5) # Give time for response to be sent + os._exit(0) + + asyncio.create_task(shutdown_server()) + return {"status": "shutting_down"} + + return app + + +# Create app instance +app = create_app() + + +if __name__ == "__main__": + # Suppress /api/status access logs to avoid log flooding during polling + logging.getLogger("uvicorn.access").addFilter(StatusEndpointFilter()) + + # Get port from environment or command line, or find a free one + requested_port = os.environ.get("PORT") or ( + sys.argv[1] if len(sys.argv) > 1 else None + ) + + # Handle signals for graceful shutdown + def signal_handler(signum, frame): + print(f"\nReceived signal {signum}, shutting down...") + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + run_backend(requested_port) diff --git a/python-backend/models/__init__.py b/python-backend/models/__init__.py new file mode 100644 index 0000000..17c2e0d --- /dev/null +++ b/python-backend/models/__init__.py @@ -0,0 +1,7 @@ +""" +Data models for Stingray Explorer backend. +""" + +from models.job import Job, JobStatus, JobType + +__all__ = ["Job", "JobStatus", "JobType"] diff --git a/python-backend/models/event_formats.py b/python-backend/models/event_formats.py new file mode 100644 index 0000000..8edc4a3 --- /dev/null +++ b/python-backend/models/event_formats.py @@ -0,0 +1,70 @@ +"""Authoritative format policy for legacy EventList ingestion and saving.""" + +from typing import Any, Final, Literal, cast, get_args + + +# Keep this allowlist intentionally narrow. Stingray also exposes a pickle +# reader, plus a broad Astropy registry, but neither is an acceptable API +# surface for renderer-controlled input. ``hea`` is a historical spelling +# accepted only at the boundary and immediately normalized to ``ogip``. +InputEventFormat = Literal[ + "ogip", + "hea", + "fits", + "hdf5", + "ascii.ecsv", +] +OutputEventFormat = Literal["hdf5", "ascii.ecsv"] + +INPUT_EVENT_FORMATS: Final[frozenset[str]] = frozenset( + cast(tuple[str, ...], get_args(InputEventFormat)) +) +CANONICAL_INPUT_EVENT_FORMATS: Final[frozenset[str]] = frozenset( + INPUT_EVENT_FORMATS - {"hea"} +) +OUTPUT_EVENT_FORMATS: Final[frozenset[str]] = frozenset( + cast(tuple[str, ...], get_args(OutputEventFormat)) +) + + +def require_input_event_format(fmt: Any) -> InputEventFormat: + """Return an exact supported input format or fail before any I/O.""" + if not isinstance(fmt, str) or fmt not in INPUT_EVENT_FORMATS: + supported = ", ".join(sorted(INPUT_EVENT_FORMATS)) + raise ValueError( + f"Unsupported input EventList format {fmt!r}. " + f"Supported formats: {supported}" + ) + if fmt == "hea": + return "ogip" + return cast(InputEventFormat, fmt) + + +def require_output_event_format(fmt: Any) -> OutputEventFormat: + """Return an exact supported output format or fail before any I/O.""" + if not isinstance(fmt, str) or fmt not in OUTPUT_EVENT_FORMATS: + supported = ", ".join(sorted(OUTPUT_EVENT_FORMATS)) + raise ValueError( + f"Unsupported output EventList format {fmt!r}. " + f"Supported formats: {supported}" + ) + return cast(OutputEventFormat, fmt) + + +def require_batch_input_formats( + files: list[dict[str, Any]], shared_fmt: Any +) -> tuple[list[dict[str, Any]], InputEventFormat]: + """Validate every supplied batch format before scheduling or path access.""" + validated_shared = require_input_event_format(shared_fmt) + validated_files: list[dict[str, Any]] = [] + for index, file_config in enumerate(files): + validated_config = dict(file_config) + if "fmt" in validated_config: + try: + validated_config["fmt"] = require_input_event_format( + validated_config["fmt"] + ) + except ValueError as exc: + raise ValueError(f"files[{index}].fmt: {exc}") from exc + validated_files.append(validated_config) + return validated_files, validated_shared diff --git a/python-backend/models/job.py b/python-backend/models/job.py new file mode 100644 index 0000000..b3fc450 --- /dev/null +++ b/python-backend/models/job.py @@ -0,0 +1,279 @@ +""" +Job data model for background task queue. + +This module defines the Job dataclass and related enums for tracking +background tasks like file loading, data analysis, and downloads. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +import math +import re +from typing import Any, Dict, Optional +import uuid + + +_PUBLIC_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$") +_PUBLIC_DISPLAY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 _().-]{0,127}$") + + +def _public_name(value: Any) -> str: + if isinstance(value, str) and _PUBLIC_NAME.fullmatch(value): + return value + return "event-list" + + +def _public_count(value: Any) -> int | None: + if ( + isinstance(value, int) + and not isinstance(value, bool) + and 0 <= value <= 1_000_000_000 + ): + return value + return None + + +def _public_float(value: Any) -> float | None: + if isinstance(value, (int, float)) and not isinstance(value, bool): + converted = float(value) + if math.isfinite(converted) and abs(converted) <= 1.0e15: + return converted + return None + + +def _public_warnings(result: Dict[str, Any]) -> list[str] | None: + warnings: list[str] = [] + if result.get("gti_warnings"): + warnings.append("GTI validation reported warnings") + if result.get("stingray_warnings"): + warnings.append("The scientific reader reported warnings") + issues = result.get("validation_issues") + if isinstance(issues, list) and any( + isinstance(issue, dict) and issue.get("severity") in {"warning", "error"} + for issue in issues[:64] + ): + warnings.append("Data-quality validation reported issues") + return warnings or None + + +def _public_single_result(result: Any) -> Dict[str, Any] | None: + """Project an arbitrary internal result onto the public science summary.""" + if not isinstance(result, dict): + return None + public: Dict[str, Any] = {} + event_count = _public_count(result.get("n_events", result.get("event_count"))) + if event_count is not None: + public["event_count"] = event_count + time_range = result.get("time_range") + if isinstance(time_range, (list, tuple)) and len(time_range) == 2: + time_start = _public_float(time_range[0]) + time_end = _public_float(time_range[1]) + else: + time_start = _public_float(result.get("time_start")) + time_end = _public_float(result.get("time_end")) + if time_start is not None: + public["time_start"] = time_start + if time_end is not None: + public["time_end"] = time_end + warnings = _public_warnings(result) + if warnings is not None: + public["warnings"] = warnings + return public or None + + +def _public_batch_result(result: Any) -> Dict[str, Any] | None: + if not isinstance(result, dict): + return None + public: Dict[str, Any] = {} + for key in ("successful", "failed"): + items = result.get(key) + if isinstance(items, list): + public_items = [] + for item in items[:32]: + if not isinstance(item, dict): + continue + projected = {"name": _public_name(item.get("name"))} + if key == "failed": + projected["error"] = "The selected file could not be loaded" + public_items.append(projected) + public[key] = public_items + for key in ("success_count", "failure_count", "total_files"): + count = _public_count(result.get(key)) + if count is not None: + public[key] = count + return public or None + + +class JobStatus(str, Enum): + """Status of a background job.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class JobType(str, Enum): + """Type of background job.""" + + LOAD_EVENT_LIST = "load_event_list" + LOAD_BATCH = "load_batch" + LOAD_FROM_URL = "load_from_url" + # Future job types: + # GENERATE_LIGHTCURVE = "generate_lightcurve" + # COMPUTE_POWER_SPECTRUM = "compute_power_spectrum" + # EXPORT_DATA = "export_data" + # DOWNLOAD_ARCHIVE = "download_archive" + + +@dataclass +class Job: + """ + Represents a background job in the queue. + + Attributes: + id: Unique identifier for the job (UUID) + type: Type of job (load_event_list, load_batch, etc.) + status: Current status of the job + progress: Progress percentage (0.0 to 1.0) + progress_message: Human-readable progress message + total_items: Total number of items to process (for batch jobs) + completed_items: Number of items completed + created_at: ISO timestamp when job was created + started_at: ISO timestamp when job started running + completed_at: ISO timestamp when job completed/failed/cancelled + params: Private job execution options. Never serialized publicly. + result: Result data on successful completion + error: Error message on failure + display_name: Human-readable name for the job (shown in UI) + """ + + id: str = field(default_factory=lambda: str(uuid.uuid4())) + type: JobType = JobType.LOAD_EVENT_LIST + status: JobStatus = JobStatus.PENDING + progress: float = 0.0 + progress_message: str = "Pending..." + total_items: int = 1 + completed_items: int = 0 + created_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + started_at: Optional[str] = None + completed_at: Optional[str] = None + params: Dict[str, Any] = field(default_factory=dict) + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + display_name: str = "" + + def to_dict(self) -> Dict[str, Any]: + """Convert job to dictionary for JSON serialization.""" + progress_messages = { + JobStatus.PENDING: "Pending...", + JobStatus.RUNNING: "Running...", + JobStatus.COMPLETED: "Completed", + JobStatus.FAILED: "Failed", + JobStatus.CANCELLED: "Cancelled", + } + public_result = ( + _public_batch_result(self.result) + if self.type == JobType.LOAD_BATCH + else _public_single_result(self.result) + ) + public_display_name = ( + self.display_name + if _PUBLIC_DISPLAY.fullmatch(self.display_name) + else "Background job" + ) + return { + "id": self.id, + "type": self.type.value, + "status": self.status.value, + "progress": self.progress, + "progress_message": progress_messages[self.status], + "total_items": self.total_items, + "completed_items": self.completed_items, + "created_at": self.created_at, + "started_at": self.started_at, + "completed_at": self.completed_at, + "result": public_result, + "error": "The background job could not be completed" + if self.error + else None, + "display_name": public_display_name, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Job": + """Create a Job from a dictionary.""" + return cls( + id=data.get("id", str(uuid.uuid4())), + type=JobType(data.get("type", "load_event_list")), + status=JobStatus(data.get("status", "pending")), + progress=data.get("progress", 0.0), + progress_message=data.get("progress_message", "Pending..."), + total_items=data.get("total_items", 1), + completed_items=data.get("completed_items", 0), + created_at=data.get("created_at", datetime.now(timezone.utc).isoformat()), + started_at=data.get("started_at"), + completed_at=data.get("completed_at"), + params=data.get("params", {}), + result=data.get("result"), + error=data.get("error"), + display_name=data.get("display_name", ""), + ) + + def start(self) -> None: + """Mark job as started.""" + self.status = JobStatus.RUNNING + self.started_at = datetime.now(timezone.utc).isoformat() + self.progress_message = "Running..." + + def complete(self, result: Optional[Dict[str, Any]] = None) -> None: + """Mark job as completed successfully.""" + self.status = JobStatus.COMPLETED + self.completed_at = datetime.now(timezone.utc).isoformat() + self.progress = 1.0 + self.progress_message = "Completed" + self.result = result + + def fail(self, error: str) -> None: + """Mark job as failed.""" + self.status = JobStatus.FAILED + self.completed_at = datetime.now(timezone.utc).isoformat() + self.error = error + self.progress_message = f"Failed: {error}" + + def cancel(self) -> None: + """Mark job as cancelled.""" + self.status = JobStatus.CANCELLED + self.completed_at = datetime.now(timezone.utc).isoformat() + self.progress_message = "Cancelled" + + def update_progress( + self, + progress: float, + message: str = "", + completed_items: Optional[int] = None, + ) -> None: + """Update job progress.""" + self.progress = min(max(progress, 0.0), 1.0) # Clamp 0-1 + if message: + self.progress_message = message + if completed_items is not None: + self.completed_items = completed_items + + @property + def is_active(self) -> bool: + """Check if job is still active (pending or running).""" + return self.status in (JobStatus.PENDING, JobStatus.RUNNING) + + @property + def is_finished(self) -> bool: + """Check if job has finished (completed, failed, or cancelled).""" + return self.status in ( + JobStatus.COMPLETED, + JobStatus.FAILED, + JobStatus.CANCELLED, + ) diff --git a/python-backend/requirements.txt b/python-backend/requirements.txt new file mode 100644 index 0000000..97ef5ae --- /dev/null +++ b/python-backend/requirements.txt @@ -0,0 +1,30 @@ +# Stingray Explorer Python Backend Dependencies +# Core +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.5.0 + +# Stingray and scientific stack +stingray>=2.0.0 +numpy>=1.24.0 +scipy>=1.11.0 +astropy>=5.3.0 +pandas>=2.0.0 +matplotlib>=3.7.0 +tables>=3.8.0 + +# HTTP client (for URL loading) +requests>=2.31.0 +httpx>=0.26.0 + +# Performance monitoring +psutil>=5.9.0 + +# CORS support +python-multipart>=0.0.6 + +# Optional: async support +aiofiles>=23.2.1 + +# HEASARC archive queries +astroquery>=0.4.8 diff --git a/python-backend/routes/__init__.py b/python-backend/routes/__init__.py new file mode 100644 index 0000000..47add1c --- /dev/null +++ b/python-backend/routes/__init__.py @@ -0,0 +1,10 @@ +"""API routes for Stingray Explorer.""" + +from . import data_routes, lightcurve_routes, spectrum_routes, timing_routes + +__all__ = [ + "data_routes", + "lightcurve_routes", + "spectrum_routes", + "timing_routes", +] diff --git a/python-backend/routes/archive_routes.py b/python-backend/routes/archive_routes.py new file mode 100644 index 0000000..fed1c2d --- /dev/null +++ b/python-backend/routes/archive_routes.py @@ -0,0 +1,489 @@ +""" +API routes for HEASARC archive operations. + +Provides endpoints for searching NASA's HEASARC archive and downloading +X-ray observation data with progress tracking. +""" + +import asyncio +import json +import threading +from contextlib import aclosing +from datetime import date +from functools import partial +from typing import Annotated, Any, Callable, Literal, Optional, Tuple + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from services.archive_service import ArchiveService + +router = APIRouter() + + +def get_archive_service(request: Request) -> ArchiveService: + """Get ArchiveService instance from app state.""" + return ArchiveService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +ArchiveServiceDependency = Annotated[ + ArchiveService, + Depends(get_archive_service), +] + +ArchiveMission = Literal[ + "NICER", + "NuSTAR", + "XMM-Newton", + "Chandra", + "Swift", + "RXTE", + "IXPE", + "Suzaku", + "ASCA", + "XRISM", + "Hitomi", +] +MAX_CONCURRENT_ARCHIVE_SEARCHES = 2 +ARCHIVE_SEARCH_RESPONSE_TIMEOUT_SECONDS = 35.0 +ARCHIVE_SEARCH_CAPACITY = threading.BoundedSemaphore(MAX_CONCURRENT_ARCHIVE_SEARCHES) + + +def _execute_archive_search( + operation: Callable[[], dict[str, Any]], + capacity: threading.BoundedSemaphore, +) -> dict[str, Any]: + try: + return operation() + finally: + capacity.release() + + +def _consume_background_search(task: asyncio.Task[dict[str, Any]]) -> None: + try: + task.result() + except BaseException: + pass + + +async def _run_bounded_archive_search( + service: ArchiveService, + operation: Callable[[], dict[str, Any]], +) -> dict[str, Any]: + """Run synchronous archive transport and parsing off-loop under capacity.""" + capacity = ARCHIVE_SEARCH_CAPACITY + if not capacity.acquire(blocking=False): + return service.create_result( + success=False, + data=None, + message="Too many archive searches are already active", + error="Archive search capacity is temporarily unavailable", + ) + + worker = asyncio.create_task( + asyncio.to_thread(_execute_archive_search, operation, capacity) + ) + try: + return await asyncio.wait_for( + asyncio.shield(worker), + timeout=ARCHIVE_SEARCH_RESPONSE_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + # The underlying synchronous library cannot be interrupted safely. It + # retains its capacity slot until the bounded-session worker actually + # exits, while the request receives a finite response wait. + worker.add_done_callback(_consume_background_search) + return service.create_result( + success=False, + data=None, + message="The archive search timed out", + error="The bounded archive search deadline expired", + ) + except asyncio.CancelledError: + worker.add_done_callback(_consume_background_search) + raise + + +def _validate_iso_date(value: Optional[str]) -> Optional[str]: + if value is None: + return None + try: + parsed = date.fromisoformat(value) + except ValueError as error: + raise ValueError( + "Date must be a real calendar date in YYYY-MM-DD format" + ) from error + if parsed.isoformat() != value: + raise ValueError("Date must use canonical YYYY-MM-DD format") + return value + + +def _validate_display_text(value: str) -> str: + if value != value.strip() or any( + ord(character) < 0x20 or 0x7F <= ord(character) <= 0x9F for character in value + ): + raise ValueError("Text must be canonical and contain no control characters") + return value + + +def _iso_dates_to_mjd_range( + start_date: Optional[str], + end_date: Optional[str], +) -> Optional[Tuple[float, float]]: + """ + Convert ISO date strings to MJD time range tuple. + + Args: + start_date: Start date in ISO format "YYYY-MM-DD" or None + end_date: End date in ISO format "YYYY-MM-DD" or None + + Returns: + Tuple of (mjd_start, mjd_end) or None if neither date is provided + """ + if not start_date and not end_date: + return None + + from astropy.time import Time + + # Use wide defaults when only one bound is specified + mjd_start = 0.0 # Before any real observation + mjd_end = 99999.0 # Far future + + if start_date: + try: + mjd_start = Time(start_date, format="iso").mjd + except Exception as error: + raise ValueError("Invalid start date") from error + + if end_date: + try: + # Add ~1 day to include the end date fully + mjd_end = Time(end_date, format="iso").mjd + 1.0 + except Exception as error: + raise ValueError("Invalid end date") from error + + return (mjd_start, mjd_end) + + +# Request/Response Models +class SearchByNameRequest(BaseModel): + """Request model for searching by source name.""" + + model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False) + + source_name: str = Field(min_length=1, max_length=256) + mission: ArchiveMission + radius: float = Field(default=0.5, gt=0.0, le=10.0) + max_results: int = Field(default=100, ge=1, le=1_000) + min_exposure: Optional[float] = Field( + default=None, + ge=0.0, + le=1_000_000_000.0, + ) + start_date: Optional[str] = Field(default=None, min_length=10, max_length=10) + end_date: Optional[str] = Field(default=None, min_length=10, max_length=10) + + _canonical_source_name = field_validator("source_name")(_validate_display_text) + _real_dates = field_validator("start_date", "end_date")(_validate_iso_date) + + @model_validator(mode="after") + def _ordered_date_range(self) -> "SearchByNameRequest": + if ( + self.start_date is not None + and self.end_date is not None + and self.start_date > self.end_date + ): + raise ValueError("Start date must not be after end date") + return self + + +class SearchByCoordinatesRequest(BaseModel): + """Request model for searching by coordinates.""" + + model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False) + + ra: float = Field(ge=0.0, le=360.0) + dec: float = Field(ge=-90.0, le=90.0) + mission: ArchiveMission + radius: float = Field(default=0.5, gt=0.0, le=10.0) + max_results: int = Field(default=100, ge=1, le=1_000) + min_exposure: Optional[float] = Field( + default=None, + ge=0.0, + le=1_000_000_000.0, + ) + start_date: Optional[str] = Field(default=None, min_length=10, max_length=10) + end_date: Optional[str] = Field(default=None, min_length=10, max_length=10) + + _real_dates = field_validator("start_date", "end_date")(_validate_iso_date) + + @model_validator(mode="after") + def _ordered_date_range(self) -> "SearchByCoordinatesRequest": + if ( + self.start_date is not None + and self.end_date is not None + and self.start_date > self.end_date + ): + raise ValueError("Start date must not be after end date") + return self + + +class SearchByObsidRequest(BaseModel): + """Request model for searching by Observation ID.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + obsid: str = Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$", + ) + mission: ArchiveMission + + +class ObservationLookupData(BaseModel): + """Bounded optional metadata used only for deterministic archive paths.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + ra: Optional[float] = Field(default=None, ge=0.0, le=360.0) + dec: Optional[float] = Field(default=None, ge=-90.0, le=90.0) + prnb: Optional[str] = Field(default=None, pattern=r"^[0-9]{1,6}$") + + +class ListFilesRequest(BaseModel): + """Strict bounded request for crawling one known HEASARC observation.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + mission: ArchiveMission + obsid: str = Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$", + ) + obs_time: Optional[str] = Field(default=None, min_length=1, max_length=64) + obs_data: Optional[ObservationLookupData] = None + recursive: bool = True + max_depth: int = Field(default=3, ge=0, le=3) + + +class DownloadToDiskRequest(BaseModel): + """One approved remote source and one native-granted destination.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + url: str = Field(min_length=1, max_length=4_096) + destination_path: str = Field(min_length=1, max_length=4_096) + destination_grant: str = Field(min_length=1, max_length=512) + + +# Routes +@router.get("/catalogs") +async def get_catalogs( + service: ArchiveServiceDependency, +): + """ + Get list of supported HEASARC catalogs. + + Returns information about available X-ray mission catalogs + that can be searched. + """ + return service.get_supported_catalogs() + + +@router.post("/search/name") +async def search_by_name( + request: SearchByNameRequest, + service: ArchiveServiceDependency, +): + """ + Search HEASARC for observations by source name. + + Resolves the source name to coordinates using SIMBAD/NED, + then queries the HEASARC catalog for matching observations. + + Args: + source_name: Astronomical source name (e.g., "Crab", "Cyg X-1", "NGC 3783") + mission: Mission to search (e.g., "NICER", "NuSTAR", "Chandra") + radius: Search radius in degrees (default: 0.5) + max_results: Maximum number of results (default: 100) + """ + time_range = _iso_dates_to_mjd_range(request.start_date, request.end_date) + return await _run_bounded_archive_search( + service, + partial( + service.search_by_name, + source_name=request.source_name, + mission=request.mission, + radius=request.radius, + max_results=request.max_results, + min_exposure=request.min_exposure, + time_range=time_range, + ), + ) + + +@router.post("/search/coordinates") +async def search_by_coordinates( + request: SearchByCoordinatesRequest, + service: ArchiveServiceDependency, +): + """ + Search HEASARC for observations by coordinates. + + Args: + ra: Right Ascension in degrees + dec: Declination in degrees + mission: Mission to search (e.g., "NICER", "NuSTAR", "Chandra") + radius: Search radius in degrees (default: 0.5) + max_results: Maximum number of results (default: 100) + """ + time_range = _iso_dates_to_mjd_range(request.start_date, request.end_date) + return await _run_bounded_archive_search( + service, + partial( + service.search_by_coordinates, + ra=request.ra, + dec=request.dec, + mission=request.mission, + radius=request.radius, + max_results=request.max_results, + min_exposure=request.min_exposure, + time_range=time_range, + ), + ) + + +@router.post("/search/obsid") +async def search_by_obsid( + request: SearchByObsidRequest, + service: ArchiveServiceDependency, +): + """ + Search HEASARC for an observation by its Observation ID. + + Uses ADQL TAP query to directly look up the observation — + no coordinates needed. + + Args: + obsid: Observation ID (e.g., "4010080142") + mission: Mission to search (e.g., "NICER", "NuSTAR") + """ + return await _run_bounded_archive_search( + service, + partial( + service.search_by_obsid, + obsid=request.obsid, + mission=request.mission, + ), + ) + + +@router.get("/observation/{mission}/{obsid}") +async def get_observation_urls( + mission: ArchiveMission, + obsid: Annotated[ + str, + Path( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$", + ), + ], + service: ArchiveServiceDependency, +): + """ + Get download URLs for a specific observation. + + Returns available download URLs from HEASARC, SciServer, and AWS + for the specified observation. + """ + return service.get_observation_download_urls( + mission=mission, + obsid=obsid, + ) + + +@router.post("/list-files") +async def list_observation_files( + payload: ListFilesRequest, + request: Request, + service: ArchiveServiceDependency, +): + """ + List all files in an observation directory. + + Returns a tree structure of files with metadata including: + - File names and paths + - File sizes (when available) + - File type classification (event, calibration, auxiliary, log, other) + - Full download URLs + + Args: + mission: Mission key (e.g., "NICER", "NuSTAR", "Chandra") + obsid: Observation ID + obs_time: Observation time (MJD or ISO string) - required for some missions + recursive: Whether to recursively list subdirectories (default: True) + max_depth: Maximum recursion depth (default: 3) + """ + return await service.list_observation_files( + mission=payload.mission, + obsid=payload.obsid, + obs_time=payload.obs_time, + obs_data=(payload.obs_data.model_dump() if payload.obs_data else None), + recursive=payload.recursive, + max_depth=payload.max_depth, + cancellation_check=request.is_disconnected, + ) + + +@router.post("/download-to-disk") +async def download_to_disk( + payload: DownloadToDiskRequest, + request: Request, + service: ArchiveServiceDependency, +): + """ + Download a file from URL to local disk with SSE progress streaming. + + The authenticated backend enforces the HEASARC source policy and streams + progress as Server-Sent Events (SSE). + + SSE Event Format: + - type: "progress" - Download progress with bytes_downloaded, total_bytes, percent + - type: "complete" - Download finished with verified file_name, size, and digest + - type: "error" - An error occurred with error message + + The response never contains the destination path or grant. Disconnecting + the renderer cancels work only before exclusive publication. Publication is + the commit point: a disconnect after it suppresses the terminal SSE event but + does not remove the completed destination. + """ + + async def event_generator(): + events = service.download_file_to_disk( + url=payload.url, + destination_path=payload.destination_path, + destination_grant=payload.destination_grant, + cancellation_check=request.is_disconnected, + ) + async with aclosing(events): + async for event in events: + if await request.is_disconnected(): + break + yield f"data: {json.dumps(event)}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "Connection": "keep-alive", + "X-Content-Type-Options": "nosniff", + "X-Accel-Buffering": "no", + }, + ) diff --git a/python-backend/routes/correlation_routes.py b/python-backend/routes/correlation_routes.py new file mode 100644 index 0000000..178955a --- /dev/null +++ b/python-backend/routes/correlation_routes.py @@ -0,0 +1,70 @@ +""" +API routes for correlation operations. + +Implemented per docs/superpowers/plans/2026-07-29-quicklook-remaining-pages.md. +""" + +import asyncio + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel + +from services.correlation_service import CorrelationService + +router = APIRouter() + + +def get_correlation_service(request: Request) -> CorrelationService: + """Get CorrelationService instance from app state.""" + return CorrelationService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +# Request Models +class AutoCorrelationRequest(BaseModel): + event_list_name: str + dt: float + mode: str = "same" + norm: str = "none" + + +class CrossCorrelationRequest(BaseModel): + event_list_1_name: str + event_list_2_name: str + dt: float + mode: str = "same" + norm: str = "none" + + +# Routes +@router.post("/auto-correlation") +async def auto_correlation( + request: AutoCorrelationRequest, + service: CorrelationService = Depends(get_correlation_service), +): + """Auto-correlate an EventList's light curve with itself.""" + return await asyncio.to_thread( + service.auto_correlation, + event_list_name=request.event_list_name, + dt=request.dt, + mode=request.mode, + norm=request.norm, + ) + + +@router.post("/cross-correlation") +async def cross_correlation( + request: CrossCorrelationRequest, + service: CorrelationService = Depends(get_correlation_service), +): + """Cross-correlate two EventLists binned onto a shared time grid.""" + return await asyncio.to_thread( + service.cross_correlation, + event_list_1_name=request.event_list_1_name, + event_list_2_name=request.event_list_2_name, + dt=request.dt, + mode=request.mode, + norm=request.norm, + ) diff --git a/python-backend/routes/data_routes.py b/python-backend/routes/data_routes.py new file mode 100644 index 0000000..c32281d --- /dev/null +++ b/python-backend/routes/data_routes.py @@ -0,0 +1,577 @@ +""" +API routes for EventList data operations. +""" + +import asyncio +import json +import logging +from typing import Annotated, List, Literal, Optional + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from models.event_formats import InputEventFormat +from services.data_service import DataService + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def get_data_service(request: Request) -> DataService: + """Get DataService instance from app state.""" + return DataService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +async def _run_data_operation(operation: str, target, /, *args, **kwargs): + """Keep admission/native-reader exceptions inside the API envelope.""" + try: + return await asyncio.to_thread(target, *args, **kwargs) + except Exception as error: + logger.error("Data operation %s failed (%s)", operation, type(error).__name__) + return { + "success": False, + "data": None, + "message": "The selected input could not be admitted or read", + "error": "data_input_rejected", + } + + +# Request/Response Models +PathText = Annotated[str, Field(min_length=1, max_length=4096)] +GrantText = Annotated[str, Field(min_length=1, max_length=512)] +NameText = Annotated[ + str, + Field( + min_length=1, + max_length=64, + pattern=r"^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$", + ), +] +NoteText = Annotated[str, Field(max_length=4096)] +ColumnText = Annotated[str, Field(min_length=1, max_length=64)] +TimeValue = Annotated[float, Field(ge=-1.0e15, le=1.0e15)] + + +class StrictRequest(BaseModel): + """Bounded request base that rejects silently ignored fields.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + +class OptionalRmfRequest(StrictRequest): + """Require an RMF path and native read grant as one indivisible pair.""" + + rmf_file: Optional[PathText] = None + rmf_grant: Optional[GrantText] = None + + @model_validator(mode="after") + def require_complete_rmf_pair(self): + if (self.rmf_file is None) != (self.rmf_grant is None): + raise ValueError("rmf_file and rmf_grant must be provided together") + return self + + +class GrantedInputRequest(StrictRequest): + file_path: PathText + file_grant: GrantText + + +class LoadEventListRequest(OptionalRmfRequest): + file_path: PathText + file_grant: GrantText + name: NameText + fmt: InputEventFormat = "ogip" + additional_columns: Optional[List[ColumnText]] = Field(default=None, max_length=64) + high_precision: bool = False + skip_checks: bool = False + notes: Optional[NoteText] = None + + +class LoadEventListFromUrlRequest(OptionalRmfRequest): + url: Annotated[str, Field(min_length=1, max_length=4096)] + name: NameText + fmt: InputEventFormat = "ogip" + additional_columns: Optional[List[ColumnText]] = Field(default=None, max_length=64) + high_precision: bool = False + skip_checks: bool = False + notes: Optional[NoteText] = None + + +class CheckFileSizeRequest(GrantedInputRequest): + pass + + +class LoadByTimeRangeRequest(GrantedInputRequest): + """Request model for true lazy loading by time range.""" + + name: NameText + start_time: TimeValue + end_time: TimeValue + fmt: InputEventFormat = "ogip" + notes: Optional[NoteText] = None + + @model_validator(mode="after") + def require_ordered_time_range(self): + if self.start_time >= self.end_time: + raise ValueError("start_time must be less than end_time") + return self + + +class LoadByEventCountRequest(GrantedInputRequest): + """Request model for true lazy loading by event count.""" + + name: NameText + start_index: int = Field(default=0, ge=0, le=100_000_000) + count: int = Field(default=10000, ge=1, le=10_000_000) + fmt: InputEventFormat = "ogip" + notes: Optional[NoteText] = None + + +class GetFileMetadataRequest(GrantedInputRequest): + """Request model for getting file metadata without loading.""" + + fmt: InputEventFormat = "ogip" + + +class SingleFileConfig(OptionalRmfRequest): + """Configuration for a single file in batch load.""" + + file_path: PathText + file_grant: GrantText + name: NameText + fmt: InputEventFormat = "ogip" + additional_columns: Optional[List[ColumnText]] = Field(default=None, max_length=64) + high_precision: bool = False + skip_checks: bool = False + # Per-file partial loading (only used if use_same_settings=False) + use_partial_loading: bool = False + partial_mode: Literal["time_range", "event_count"] = "time_range" + time_range_start: Optional[TimeValue] = None + time_range_end: Optional[TimeValue] = None + event_start_index: Optional[int] = Field(default=None, ge=0, le=100_000_000) + event_count: Optional[int] = Field(default=None, ge=1, le=10_000_000) + # Per-file notes + notes: Optional[NoteText] = None + + @model_validator(mode="after") + def require_complete_partial_settings(self): + if not self.use_partial_loading: + return self + if self.partial_mode == "time_range": + if self.time_range_start is None or self.time_range_end is None: + raise ValueError("partial time-range loading requires both endpoints") + if self.time_range_start >= self.time_range_end: + raise ValueError("time_range_start must be less than time_range_end") + elif self.event_count is None: + raise ValueError("partial event-count loading requires event_count") + return self + + +class BatchLoadEventListRequest(StrictRequest): + """Request for batch loading multiple files.""" + + files: List[SingleFileConfig] = Field(min_length=1, max_length=32) + + # Toggle: same settings vs per-file + use_same_settings: bool = True + + # Shared settings (used when use_same_settings=True) + shared_fmt: InputEventFormat = "ogip" + shared_rmf_file: Optional[PathText] = None + shared_rmf_grant: Optional[GrantText] = None + shared_additional_columns: Optional[List[ColumnText]] = Field( + default=None, max_length=64 + ) + shared_high_precision: bool = False + shared_skip_checks: bool = False + shared_use_partial_loading: bool = False + shared_partial_mode: Literal["time_range", "event_count"] = "time_range" + shared_time_range_start: Optional[TimeValue] = None + shared_time_range_end: Optional[TimeValue] = None + shared_event_start_index: Optional[int] = Field(default=None, ge=0, le=100_000_000) + shared_event_count: Optional[int] = Field(default=None, ge=1, le=10_000_000) + + @model_validator(mode="after") + def require_complete_shared_rmf_pair(self): + if (self.shared_rmf_file is None) != (self.shared_rmf_grant is None): + raise ValueError( + "shared_rmf_file and shared_rmf_grant must be provided together" + ) + if self.shared_use_partial_loading and self.shared_partial_mode == "time_range": + if ( + self.shared_time_range_start is None + or self.shared_time_range_end is None + ): + raise ValueError( + "shared partial time-range loading requires both endpoints" + ) + if self.shared_time_range_start >= self.shared_time_range_end: + raise ValueError( + "shared_time_range_start must be less than shared_time_range_end" + ) + elif self.shared_use_partial_loading and self.shared_event_count is None: + raise ValueError( + "shared partial event-count loading requires shared_event_count" + ) + return self + + +class BatchFileSizeRequest(StrictRequest): + """Request for checking batch file sizes.""" + + files: List[GrantedInputRequest] = Field(min_length=1, max_length=32) + + +# Routes +@router.post("/load") +async def load_event_list( + request: LoadEventListRequest, + service: DataService = Depends(get_data_service), +): + """Load an EventList from a file. + + Uses asyncio.to_thread() to avoid blocking the event loop, + allowing other async operations (like resource monitoring) to continue. + """ + return await _run_data_operation( + "load", + service.load_event_list, + file_path=request.file_path, + file_grant=request.file_grant, + name=request.name, + fmt=request.fmt, + rmf_file=request.rmf_file, + rmf_grant=request.rmf_grant, + additional_columns=request.additional_columns, + high_precision=request.high_precision, + skip_checks=request.skip_checks, + notes=request.notes, + ) + + +@router.post("/load-url") +async def load_event_list_from_url( + request: LoadEventListFromUrlRequest, + service: DataService = Depends(get_data_service), +): + """Load an EventList from a URL. + + Uses asyncio.to_thread() to avoid blocking the event loop. + """ + return await _run_data_operation( + "remote_load", + service.load_event_list_from_url, + url=request.url, + name=request.name, + fmt=request.fmt, + rmf_file=request.rmf_file, + rmf_grant=request.rmf_grant, + additional_columns=request.additional_columns, + high_precision=request.high_precision, + skip_checks=request.skip_checks, + notes=request.notes, + ) + + +@router.post("/load-url-stream") +async def load_event_list_from_url_stream( + request: LoadEventListFromUrlRequest, + service: DataService = Depends(get_data_service), +): + """ + Load an EventList from a URL with SSE streaming for progress updates. + + Returns Server-Sent Events (SSE) with download and processing progress, + allowing the frontend to show real-time download progress. + + SSE Event Format: + - type: "progress" - Download progress with bytes_downloaded, total_bytes, percent + - type: "processing" - Download complete, now loading event list + - type: "complete" - Successfully loaded, includes data summary + - type: "error" - An error occurred + """ + + async def event_generator(): + async for event in service.load_event_list_from_url_stream( + url=request.url, + name=request.name, + fmt=request.fmt, + rmf_file=request.rmf_file, + rmf_grant=request.rmf_grant, + additional_columns=request.additional_columns, + high_precision=request.high_precision, + skip_checks=request.skip_checks, + notes=request.notes, + ): + yield f"data: {json.dumps(event)}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + ) + + +@router.delete("/{name}") +async def delete_event_list( + name: NameText, + service: DataService = Depends(get_data_service), +): + """Delete an EventList from state.""" + return service.delete_event_list(name) + + +@router.get("/{name}") +async def get_event_list_info( + name: NameText, + service: DataService = Depends(get_data_service), +): + """Get information about an EventList.""" + return service.get_event_list_info(name) + + +@router.get("/") +async def list_event_lists( + service: DataService = Depends(get_data_service), +): + """List all loaded EventLists.""" + return service.list_event_lists() + + +@router.post("/check-size") +async def check_file_size( + request: CheckFileSizeRequest, + service: DataService = Depends(get_data_service), +): + """Check file size and get loading recommendations.""" + return await _run_data_operation( + "size_check", + service.check_file_size, + request.file_path, + request.file_grant, + ) + + +@router.delete("/") +async def clear_all_event_lists( + service: DataService = Depends(get_data_service), +): + """Clear all loaded EventLists from memory.""" + return service.clear_all_event_lists() + + +@router.get("/{name}/full-preview") +async def get_event_list_full_preview( + name: NameText, + # HTTP query values are text; FastAPI must parse them before enforcing bounds. + time_limit: Annotated[int, Query(ge=1, le=10_000)] = 10, + service: DataService = Depends(get_data_service), +): + """Get full preview of an EventList with all attributes.""" + return service.get_event_list_full_preview(name=name, time_limit=time_limit) + + +# ========================================================================= +# PARTIAL LOADING ROUTES +# These endpoints use FITSTimeseriesReader to load only a portion of the file +# ========================================================================= + + +@router.post("/load-by-time-range") +async def load_event_list_by_time_range( + request: LoadByTimeRangeRequest, + service: DataService = Depends(get_data_service), +): + """ + Load events within a specific time range using true lazy loading. + + Uses FITSTimeseriesReader to load only events within the specified + time window without reading the entire file into memory. + + Uses asyncio.to_thread() to avoid blocking the event loop. + """ + return await _run_data_operation( + "time_range_load", + service.load_event_list_by_time_range, + file_path=request.file_path, + file_grant=request.file_grant, + name=request.name, + start_time=request.start_time, + end_time=request.end_time, + fmt=request.fmt, + notes=request.notes, + ) + + +@router.post("/load-by-event-count") +async def load_event_list_by_event_count( + request: LoadByEventCountRequest, + service: DataService = Depends(get_data_service), +): + """ + Load a specific number of events using true lazy loading. + + Uses FITSTimeseriesReader slicing to load only the requested events + without reading the entire file into memory. + + Uses asyncio.to_thread() to avoid blocking the event loop. + """ + return await _run_data_operation( + "event_count_load", + service.load_event_list_by_event_count, + file_path=request.file_path, + file_grant=request.file_grant, + name=request.name, + start_index=request.start_index, + count=request.count, + fmt=request.fmt, + notes=request.notes, + ) + + +@router.post("/metadata") +async def get_file_metadata( + request: GetFileMetadataRequest, + service: DataService = Depends(get_data_service), +): + """ + Get metadata from a FITS file without loading the full data. + + Returns file info, event count, time range, GTI, and loading recommendations + without loading any event data into memory. + + Uses asyncio.to_thread() to avoid blocking the event loop. + """ + return await _run_data_operation( + "metadata", + service.get_file_metadata, + file_path=request.file_path, + file_grant=request.file_grant, + fmt=request.fmt, + ) + + +# ========================================================================= +# BATCH LOADING ROUTES +# Load multiple files in parallel +# ========================================================================= + + +@router.post("/check-batch-size") +async def check_batch_file_size( + request: BatchFileSizeRequest, + service: DataService = Depends(get_data_service), +): + """ + Check sizes of multiple files and estimate total memory usage. + + Returns per-file and total memory estimates with risk levels. + """ + return await _run_data_operation( + "batch_size_check", + service.check_batch_file_size, + files=[item.model_dump() for item in request.files], + ) + + +@router.post("/load-batch") +async def load_batch_event_lists( + request: BatchLoadEventListRequest, + service: DataService = Depends(get_data_service), +): + """ + Load multiple EventLists in parallel using threads. + + Supports two modes: + - use_same_settings=True: Apply shared_* settings to all files + - use_same_settings=False: Use per-file settings from each SingleFileConfig + + Returns aggregated results with successful[], failed[], and summary stats. + + Note: Uses asyncio.to_thread() to avoid blocking the event loop, + allowing other async operations (like resource monitoring) to continue. + """ + # Convert Pydantic models to dicts for the service + files_dict = [f.model_dump() for f in request.files] + + # Run the blocking batch load in a thread to avoid blocking the event loop + return await _run_data_operation( + "batch_load", + service.load_batch_event_lists, + files=files_dict, + use_same_settings=request.use_same_settings, + shared_fmt=request.shared_fmt, + shared_rmf_file=request.shared_rmf_file, + shared_rmf_grant=request.shared_rmf_grant, + shared_additional_columns=request.shared_additional_columns, + shared_high_precision=request.shared_high_precision, + shared_skip_checks=request.shared_skip_checks, + shared_use_partial_loading=request.shared_use_partial_loading, + shared_partial_mode=request.shared_partial_mode, + shared_time_range_start=request.shared_time_range_start, + shared_time_range_end=request.shared_time_range_end, + shared_event_start_index=request.shared_event_start_index, + shared_event_count=request.shared_event_count, + ) + + +@router.post("/load-batch-stream") +async def load_batch_event_lists_stream( + request: BatchLoadEventListRequest, + service: DataService = Depends(get_data_service), +): + """ + Stream batch loading results via Server-Sent Events (SSE). + + Returns individual file completion events as they finish, allowing + the frontend to update progress in real-time rather than waiting + for all files to complete. + + SSE Event Format: + - type: "file_complete" - A single file finished (success or failure) + - type: "complete" - All files finished, includes summary stats + - type: "error" - Pre-validation error (e.g., duplicate names) + + This endpoint is preferred for batch loading multiple files, + especially when some files may be significantly larger than others. + """ + files_dict = [f.model_dump() for f in request.files] + + async def event_generator(): + async for event in service.load_batch_event_lists_stream( + files=files_dict, + use_same_settings=request.use_same_settings, + shared_fmt=request.shared_fmt, + shared_rmf_file=request.shared_rmf_file, + shared_rmf_grant=request.shared_rmf_grant, + shared_additional_columns=request.shared_additional_columns, + shared_high_precision=request.shared_high_precision, + shared_skip_checks=request.shared_skip_checks, + shared_use_partial_loading=request.shared_use_partial_loading, + shared_partial_mode=request.shared_partial_mode, + shared_time_range_start=request.shared_time_range_start, + shared_time_range_end=request.shared_time_range_end, + shared_event_start_index=request.shared_event_start_index, + shared_event_count=request.shared_event_count, + ): + yield f"data: {json.dumps(event)}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Disable nginx buffering if behind proxy + "X-Content-Type-Options": "nosniff", + }, + ) diff --git a/python-backend/routes/deadtime_routes.py b/python-backend/routes/deadtime_routes.py new file mode 100644 index 0000000..1ec5067 --- /dev/null +++ b/python-backend/routes/deadtime_routes.py @@ -0,0 +1,80 @@ +""" +API routes for dead-time correction operations. + +Implemented per docs/superpowers/plans/2026-07-29-quicklook-remaining-pages.md. +""" + +import asyncio +from typing import Optional + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel + +from services.deadtime_service import DeadtimeService + +router = APIRouter() + + +def get_deadtime_service(request: Request) -> DeadtimeService: + """Get DeadtimeService instance from app state.""" + return DeadtimeService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +# Request Models +class PdsCorrectionRequest(BaseModel): + # No `norm` field: deadtime_correct() assumes the Leahy white-noise level + # of 2, so the service always builds the spectrum with norm="leahy". + # No `paralyzable` field: stingray raises NotImplementedError for it. + event_list_name: str + dt: float + segment_size: float + dead_time: float + background_rate: float = 0.0 + limit_k: int = 200 + + +class FadCorrectionRequest(BaseModel): + event_list_1_name: str + event_list_2_name: str + dt: float + segment_size: float + norm: str = "frac" + smoothing_length: Optional[float] = None + + +# Routes +@router.post("/pds-correction") +async def pds_correction( + request: PdsCorrectionRequest, + service: DeadtimeService = Depends(get_deadtime_service), +): + """Apply the model dead-time correction to an averaged power spectrum.""" + return await asyncio.to_thread( + service.calculate_pds_correction, + event_list_name=request.event_list_name, + dt=request.dt, + segment_size=request.segment_size, + dead_time=request.dead_time, + background_rate=request.background_rate, + limit_k=request.limit_k, + ) + + +@router.post("/fad-correction") +async def fad_correction( + request: FadCorrectionRequest, + service: DeadtimeService = Depends(get_deadtime_service), +): + """Apply the FAD dead-time correction using two independent detectors.""" + return await asyncio.to_thread( + service.calculate_fad_correction, + event_list_1_name=request.event_list_1_name, + event_list_2_name=request.event_list_2_name, + dt=request.dt, + segment_size=request.segment_size, + norm=request.norm, + smoothing_length=request.smoothing_length, + ) diff --git a/python-backend/routes/gti_routes.py b/python-backend/routes/gti_routes.py new file mode 100644 index 0000000..cc5536c --- /dev/null +++ b/python-backend/routes/gti_routes.py @@ -0,0 +1,186 @@ +"""FastAPI routes for the Utilities GTI workbench.""" + +from __future__ import annotations + +import asyncio +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, ConfigDict, Field +from services.gti_service import GTIService +from services.utility_helpers import MAX_GTI_ROWS +from routes.utility_models import UtilityResponse + +router = APIRouter() + +TimeReference = Literal["absolute_mission_time", "relative_seconds"] +SetOperation = Literal["intersection", "union", "append"] +GtiRow = Annotated[list[float], Field(min_length=2, max_length=2)] + + +def get_gti_service(request: Request) -> GTIService: + """Build a request-scoped service around the shared, thread-safe state.""" + return GTIService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +GTIServiceDependency = Annotated[GTIService, Depends(get_gti_service)] + + +class _RequestModel(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + +class InspectRequest(_RequestModel): + event_list_name: str = Field(min_length=1) + + +class ValidateRequest(_RequestModel): + gtis: list[GtiRow] = Field(max_length=MAX_GTI_ROWS) + time_reference: TimeReference = "absolute_mission_time" + + +class SetOperationRequest(_RequestModel): + left_gtis: list[GtiRow] = Field(max_length=MAX_GTI_ROWS) + right_gtis: list[GtiRow] = Field(max_length=MAX_GTI_ROWS) + operation: SetOperation + time_reference: TimeReference = "absolute_mission_time" + + +class BadTimeIntervalsRequest(_RequestModel): + gtis: list[GtiRow] = Field(max_length=MAX_GTI_ROWS) + start_time: float + stop_time: float + time_reference: TimeReference = "absolute_mission_time" + + +class MaskPreviewRequest(_RequestModel): + event_list_name: str = Field(min_length=1) + gtis: list[GtiRow] = Field(max_length=MAX_GTI_ROWS) + + +class MaskSaveRequest(MaskPreviewRequest): + destination_name: str = Field(min_length=1, max_length=64) + + +class FixedSegmentsRequest(_RequestModel): + gtis: list[GtiRow] = Field(max_length=MAX_GTI_ROWS) + segment_size: float + time_reference: TimeReference = "absolute_mission_time" + + +class ExposureSegmentsRequest(_RequestModel): + gtis: list[GtiRow] = Field(max_length=MAX_GTI_ROWS) + exposure_per_chunk: float + new_interval_if_gti_sep: float | None = None + time_reference: TimeReference = "absolute_mission_time" + + +@router.post("/inspect", response_model=UtilityResponse) +async def inspect( + request: InspectRequest, + service: GTIServiceDependency, +): + """Inspect the effective GTIs on one loaded EventList.""" + return await asyncio.to_thread(service.inspect, request.event_list_name) + + +@router.post("/validate", response_model=UtilityResponse) +async def validate( + request: ValidateRequest, + service: GTIServiceDependency, +): + """Validate manually entered GTIs without normalizing them.""" + return await asyncio.to_thread( + service.validate, + gtis=request.gtis, + time_reference=request.time_reference, + ) + + +@router.post("/set-operation", response_model=UtilityResponse) +async def set_operation( + request: SetOperationRequest, + service: GTIServiceDependency, +): + """Intersect, union, or append two valid GTI sets.""" + return await asyncio.to_thread( + service.set_operation, + left_gtis=request.left_gtis, + right_gtis=request.right_gtis, + operation=request.operation, + time_reference=request.time_reference, + ) + + +@router.post("/bad-time-intervals", response_model=UtilityResponse) +async def bad_time_intervals( + request: BadTimeIntervalsRequest, + service: GTIServiceDependency, +): + """Generate bad-time intervals inside an explicit observation range.""" + return await asyncio.to_thread( + service.bad_time_intervals, + gtis=request.gtis, + start_time=request.start_time, + stop_time=request.stop_time, + time_reference=request.time_reference, + ) + + +@router.post("/mask/preview", response_model=UtilityResponse) +async def mask_preview( + request: MaskPreviewRequest, + service: GTIServiceDependency, +): + """Preview retained/rejected events without changing state.""" + return await asyncio.to_thread( + service.mask_preview, + event_list_name=request.event_list_name, + gtis=request.gtis, + ) + + +@router.post("/mask/save", response_model=UtilityResponse) +async def mask_save( + request: MaskSaveRequest, + service: GTIServiceDependency, +): + """Save a GTI-filtered copy under a unique destination name.""" + return await asyncio.to_thread( + service.save_masked, + event_list_name=request.event_list_name, + gtis=request.gtis, + destination_name=request.destination_name, + ) + + +@router.post("/segment/fixed", response_model=UtilityResponse) +async def fixed_segments( + request: FixedSegmentsRequest, + service: GTIServiceDependency, +): + """Generate fixed-duration segments fully contained within GTIs.""" + return await asyncio.to_thread( + service.fixed_segments, + gtis=request.gtis, + segment_size=request.segment_size, + time_reference=request.time_reference, + ) + + +@router.post("/segment/exposure", response_model=UtilityResponse) +async def exposure_segments( + request: ExposureSegmentsRequest, + service: GTIServiceDependency, +): + """Split GTIs into approximate-exposure chunk groups.""" + return await asyncio.to_thread( + service.split_by_exposure, + gtis=request.gtis, + exposure_per_chunk=request.exposure_per_chunk, + new_interval_if_gti_sep=request.new_interval_if_gti_sep, + time_reference=request.time_reference, + ) diff --git a/python-backend/routes/internal_grant_routes.py b/python-backend/routes/internal_grant_routes.py new file mode 100644 index 0000000..24f3af1 --- /dev/null +++ b/python-backend/routes/internal_grant_routes.py @@ -0,0 +1,119 @@ +"""Main-process-only native file-grant issuance. + +This route is deliberately absent from OpenAPI and unavailable to renderer +requests. Electron main authenticates with both the backend session secret and +a distinct issuer secret that is never exposed through the renderer bridge. +""" + +from __future__ import annotations + +import asyncio +import secrets +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, ConfigDict, Field +from services.utility_helpers import ( + FileGrantEligibilityError, + issue_file_grant, + validated_file_grant_secret, +) + +GRANT_ISSUER_HEADER = "x-stingray-grant-issuer" + +router = APIRouter() + + +class FileGrantIssueRequest(BaseModel): + """One exact path selected by Electron's native dialog.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + path: str = Field(min_length=1, max_length=4_096) + access: Literal["read", "write"] + + +class FileGrantIssueResponse(BaseModel): + """Canonical selected path and its opaque, short-lived grant.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + path: str = Field(min_length=1, max_length=4_096) + grant: str = Field(min_length=1, max_length=512) + expires_at: int + + +def require_main_grant_issuer(request: Request) -> str: + """Authenticate Electron main independently from the renderer session.""" + if any( + name.lower() == b"origin" for name, _value in request.scope.get("headers", []) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Browser-origin requests cannot issue native file grants", + ) + + configured = getattr(request.app.state, "_file_grant_secret", None) + configured_bytes = validated_file_grant_secret(configured) + if configured_bytes is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Native file-grant issuance is not configured", + ) + + supplied_values = [ + value + for name, value in request.scope.get("headers", []) + if name.lower() == GRANT_ISSUER_HEADER.encode("ascii") + ] + authenticated = len(supplied_values) == 1 and secrets.compare_digest( + supplied_values[0], configured_bytes + ) + if not authenticated: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Native file-grant issuer authentication required", + ) + return configured + + +@router.post( + "/file-grants/issue", + response_model=FileGrantIssueResponse, + include_in_schema=False, +) +async def issue_native_file_grant( + request: FileGrantIssueRequest, + response: Response, + issuer_secret: str = Depends(require_main_grant_issuer), +) -> FileGrantIssueResponse: + """Issue one grant using Python's canonical path and filesystem identity.""" + response.headers["Cache-Control"] = "no-store" + try: + issued = await asyncio.to_thread( + issue_file_grant, + request.path, + access=request.access, + secret=issuer_secret, + ) + except FileGrantEligibilityError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc)[:256], + ) from exc + except PermissionError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Native file-grant issuance is not configured", + ) from exc + except (OSError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The selected path is not eligible for the requested access", + ) from exc + + return FileGrantIssueResponse( + path=str(issued.path), + grant=issued.grant, + expires_at=issued.expires_at, + ) diff --git a/python-backend/routes/io_utility_routes.py b/python-backend/routes/io_utility_routes.py new file mode 100644 index 0000000..67d3c33 --- /dev/null +++ b/python-backend/routes/io_utility_routes.py @@ -0,0 +1,135 @@ +"""FastAPI routes for General I/O Utilities.""" + +import asyncio +from typing import Literal + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, ConfigDict, Field + +from services.io_utility_service import IOUtilityService +from routes.utility_models import UtilityResponse + +router = APIRouter() + + +def get_io_utility_service(request: Request) -> IOUtilityService: + """Create an I/O Utilities service over the application StateManager.""" + return IOUtilityService( + state_manager=request.app.state.state_manager, + performance_monitor=getattr(request.app.state, "performance_monitor", None), + ) + + +class GrantedInputRequest(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + file_path: str = Field(min_length=1, max_length=4_096) + file_grant: str = Field(min_length=1, max_length=512) + + +class RmfInputRequest(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + rmf_path: str = Field(min_length=1, max_length=4_096) + rmf_grant: str = Field(min_length=1, max_length=512) + + +class ConvertPiRequest(RmfInputRequest): + pi_values: list[int] = Field(min_length=1, max_length=100_000) + + +class ConvertEventListRequest(RmfInputRequest): + # Source names originate in the existing ingestion/state subsystem, which + # has no length cap. Do not make a valid selectable object unusable here; + # the global request-body cap still bounds the HTTP payload. + event_list_name: str = Field(min_length=1) + save_as: str | None = Field(default=None, max_length=64) + + +class ExportObjectRequest(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + object_type: Literal["event_list", "lightcurve", "analysis_result"] + object_name: str = Field(min_length=1) + format: Literal["csv", "ecsv", "json", "fits", "hdf5"] + destination_path: str = Field(min_length=1, max_length=4_096) + destination_grant: str = Field(min_length=1, max_length=512) + + +@router.post("/inspect-file", response_model=UtilityResponse) +async def inspect_file( + request: GrantedInputRequest, + service: IOUtilityService = Depends(get_io_utility_service), +): + """Inspect one exact native-selected file without loading FITS data arrays.""" + return await asyncio.to_thread( + service.inspect_file, + file_path=request.file_path, + file_grant=request.file_grant, + ) + + +@router.post("/inspect-rmf", response_model=UtilityResponse) +async def inspect_rmf( + request: RmfInputRequest, + service: IOUtilityService = Depends(get_io_utility_service), +): + """Inspect and validate the EBOUNDS portion of one selected RMF.""" + return await asyncio.to_thread( + service.inspect_rmf, + rmf_path=request.rmf_path, + rmf_grant=request.rmf_grant, + ) + + +@router.post("/convert-pi", response_model=UtilityResponse) +async def convert_pi( + request: ConvertPiRequest, + service: IOUtilityService = Depends(get_io_utility_service), +): + """Convert pasted PI channels with exact RMF EBOUNDS coverage.""" + return await asyncio.to_thread( + service.convert_pi_values, + pi_values=request.pi_values, + rmf_path=request.rmf_path, + rmf_grant=request.rmf_grant, + ) + + +@router.post("/convert-event-list", response_model=UtilityResponse) +async def convert_event_list( + request: ConvertEventListRequest, + service: IOUtilityService = Depends(get_io_utility_service), +): + """Preview or explicitly save a calibrated copy of a loaded EventList.""" + return await asyncio.to_thread( + service.convert_event_list, + event_list_name=request.event_list_name, + rmf_path=request.rmf_path, + rmf_grant=request.rmf_grant, + save_as=request.save_as, + ) + + +@router.get("/exportable-objects", response_model=UtilityResponse) +async def list_exportable_objects( + service: IOUtilityService = Depends(get_io_utility_service), +): + """List loaded objects and their tested safe export combinations.""" + return await asyncio.to_thread(service.list_exportable_objects) + + +@router.post("/export", response_model=UtilityResponse) +async def export_object( + request: ExportObjectRequest, + service: IOUtilityService = Depends(get_io_utility_service), +): + """Exclusively create and reopen-verify one selected export destination.""" + return await asyncio.to_thread( + service.export_object, + object_type=request.object_type, + object_name=request.object_name, + export_format=request.format, + destination_path=request.destination_path, + destination_grant=request.destination_grant, + ) diff --git a/python-backend/routes/job_routes.py b/python-backend/routes/job_routes.py new file mode 100644 index 0000000..a56ac9d --- /dev/null +++ b/python-backend/routes/job_routes.py @@ -0,0 +1,621 @@ +""" +Job API routes for background task queue. + +Provides REST endpoints for submitting, listing, streaming, and cancelling jobs. +""" + +import json +import logging +import asyncio +from typing import Annotated, Any, List, Literal, Optional + +from fastapi import APIRouter, HTTPException, Path, Query, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from models.event_formats import InputEventFormat + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + +PathText = Annotated[str, Field(min_length=1, max_length=4096)] +GrantText = Annotated[str, Field(min_length=1, max_length=512)] +NameText = Annotated[ + str, + Field( + min_length=1, + max_length=64, + pattern=r"^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$", + ), +] +NoteText = Annotated[str, Field(max_length=4096)] +ColumnText = Annotated[str, Field(min_length=1, max_length=64)] +TimeValue = Annotated[float, Field(ge=-1.0e15, le=1.0e15)] +JobIdText = Annotated[ + str, + Path( + min_length=36, + max_length=36, + pattern=( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + ), + ), +] + + +class StrictRequest(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + +class OptionalRmfRequest(StrictRequest): + rmf_file: Optional[PathText] = None + rmf_grant: Optional[GrantText] = None + + @model_validator(mode="after") + def require_complete_rmf_pair(self): + if (self.rmf_file is None) != (self.rmf_grant is None): + raise ValueError("rmf_file and rmf_grant must be provided together") + return self + + +class SubmitLoadJobRequest(OptionalRmfRequest): + """Request body for submitting a single file load job.""" + + file_path: PathText = Field(..., description="Path to the file to load") + file_grant: GrantText + name: NameText = Field(..., description="Name for the loaded event list") + fmt: InputEventFormat = Field(default="ogip", description="File format") + additional_columns: Optional[List[ColumnText]] = Field( + default=None, max_length=64, description="Additional columns to load" + ) + high_precision: bool = Field( + default=False, description="Use high precision loading" + ) + skip_checks: bool = Field(default=False, description="Skip validation checks") + notes: Optional[NoteText] = Field(default=None, description="User notes/comments") + use_partial_loading: bool = Field( + default=False, description="Use partial/lazy loading" + ) + partial_mode: Literal["time_range", "event_count"] = Field( + default="time_range", description="Partial loading mode" + ) + time_range_start: Optional[TimeValue] = Field( + default=None, description="Start time for time range loading" + ) + time_range_end: Optional[TimeValue] = Field( + default=None, description="End time for time range loading" + ) + event_start_index: Optional[int] = Field( + default=None, + ge=0, + le=100_000_000, + description="Start index for event count loading", + ) + event_count: Optional[int] = Field( + default=None, ge=1, le=10_000_000, description="Number of events to load" + ) + + @model_validator(mode="after") + def require_complete_partial_settings(self): + if not self.use_partial_loading: + return self + if self.partial_mode == "time_range": + if self.time_range_start is None or self.time_range_end is None: + raise ValueError("partial time-range loading requires both endpoints") + if self.time_range_start >= self.time_range_end: + raise ValueError("time_range_start must be less than time_range_end") + elif self.event_count is None: + raise ValueError("partial event-count loading requires event_count") + return self + + +class FileConfig(OptionalRmfRequest): + """Configuration for a single file in batch loading.""" + + file_path: PathText + file_grant: GrantText + name: NameText + fmt: InputEventFormat = "ogip" + additional_columns: Optional[List[ColumnText]] = Field(default=None, max_length=64) + high_precision: Optional[bool] = None + skip_checks: Optional[bool] = None + use_partial_loading: Optional[bool] = None + partial_mode: Optional[Literal["time_range", "event_count"]] = None + time_range_start: Optional[TimeValue] = None + time_range_end: Optional[TimeValue] = None + event_start_index: Optional[int] = Field(default=None, ge=0, le=100_000_000) + event_count: Optional[int] = Field(default=None, ge=1, le=10_000_000) + notes: Optional[NoteText] = None + + @model_validator(mode="after") + def require_complete_partial_settings(self): + if not self.use_partial_loading: + return self + mode = self.partial_mode or "time_range" + if mode == "time_range": + if self.time_range_start is None or self.time_range_end is None: + raise ValueError("partial time-range loading requires both endpoints") + if self.time_range_start >= self.time_range_end: + raise ValueError("time_range_start must be less than time_range_end") + elif self.event_count is None: + raise ValueError("partial event-count loading requires event_count") + return self + + +class SubmitBatchJobRequest(StrictRequest): + """Request body for submitting a batch load job.""" + + files: List[FileConfig] = Field( + ..., min_length=1, max_length=32, description="List of files to load" + ) + use_same_settings: bool = Field( + default=True, description="Use shared settings for all files" + ) + shared_fmt: InputEventFormat = Field( + default="ogip", description="Shared file format" + ) + shared_rmf_file: Optional[PathText] = Field( + default=None, description="Shared RMF file path" + ) + shared_rmf_grant: Optional[GrantText] = None + shared_additional_columns: Optional[List[ColumnText]] = Field( + default=None, max_length=64, description="Shared additional columns" + ) + shared_high_precision: bool = Field( + default=False, description="Shared high precision setting" + ) + shared_skip_checks: bool = Field( + default=False, description="Shared skip checks setting" + ) + shared_use_partial_loading: bool = Field( + default=False, description="Shared partial loading setting" + ) + shared_partial_mode: Literal["time_range", "event_count"] = Field( + default="time_range", description="Shared partial loading mode" + ) + shared_time_range_start: Optional[TimeValue] = Field( + default=None, description="Shared time range start" + ) + shared_time_range_end: Optional[TimeValue] = Field( + default=None, description="Shared time range end" + ) + shared_event_start_index: Optional[int] = Field( + default=None, ge=0, le=100_000_000, description="Shared event start index" + ) + shared_event_count: Optional[int] = Field( + default=None, ge=1, le=10_000_000, description="Shared event count" + ) + + @model_validator(mode="after") + def require_complete_shared_rmf_pair(self): + if (self.shared_rmf_file is None) != (self.shared_rmf_grant is None): + raise ValueError( + "shared_rmf_file and shared_rmf_grant must be provided together" + ) + if self.shared_use_partial_loading and self.shared_partial_mode == "time_range": + if ( + self.shared_time_range_start is None + or self.shared_time_range_end is None + ): + raise ValueError( + "shared partial time-range loading requires both endpoints" + ) + if self.shared_time_range_start >= self.shared_time_range_end: + raise ValueError( + "shared_time_range_start must be less than shared_time_range_end" + ) + elif self.shared_use_partial_loading and self.shared_event_count is None: + raise ValueError( + "shared partial event-count loading requires shared_event_count" + ) + return self + + +class SubmitUrlJobRequest(OptionalRmfRequest): + """Request body for submitting a URL download job.""" + + url: Annotated[str, Field(min_length=1, max_length=4096)] = Field( + ..., description="URL to download" + ) + name: NameText = Field(..., description="Name for the loaded event list") + fmt: InputEventFormat = Field(default="ogip", description="File format") + additional_columns: Optional[List[ColumnText]] = Field( + default=None, max_length=64, description="Additional columns to load" + ) + high_precision: bool = Field( + default=False, description="Use high precision loading" + ) + skip_checks: bool = Field(default=False, description="Skip validation checks") + notes: Optional[NoteText] = Field(default=None, description="User notes/comments") + + +class CheckNameRequest(StrictRequest): + """Request body for checking name conflicts.""" + + name: NameText = Field(..., description="Name to check") + + +class ApiResponse(BaseModel): + """Standard API response format.""" + + success: bool + data: Optional[Any] = None + message: str = "" + error: Optional[str] = None + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def get_job_manager(request: Request): + """Get the job manager from app state.""" + job_manager = getattr(request.app.state, "job_manager", None) + if job_manager is None: + raise HTTPException(status_code=500, detail="Job manager not initialized") + return job_manager + + +# ============================================================================= +# API Endpoints +# ============================================================================= + + +@router.post("/submit-load", response_model=ApiResponse) +async def submit_load_job(request: Request, body: SubmitLoadJobRequest) -> ApiResponse: + """ + Submit a single file load job. + + Returns immediately with the job ID. The actual loading happens + asynchronously in a background thread. + """ + job_manager = get_job_manager(request) + + try: + job = await asyncio.to_thread( + job_manager.submit_load_job, + file_path=body.file_path, + file_grant=body.file_grant, + name=body.name, + fmt=body.fmt, + rmf_file=body.rmf_file, + rmf_grant=body.rmf_grant, + additional_columns=body.additional_columns, + high_precision=body.high_precision, + skip_checks=body.skip_checks, + notes=body.notes, + use_partial_loading=body.use_partial_loading, + partial_mode=body.partial_mode, + time_range_start=body.time_range_start, + time_range_end=body.time_range_end, + event_start_index=body.event_start_index, + event_count=body.event_count, + ) + + return ApiResponse( + success=True, + data=job.to_dict(), + message=f"Job submitted: {job.display_name}", + ) + + except Exception as error: + logger.error("Failed to submit load job (%s)", type(error).__name__) + return ApiResponse( + success=False, + error="job_submission_rejected", + message="Failed to submit job", + ) + + +@router.post("/submit-batch", response_model=ApiResponse) +async def submit_batch_job( + request: Request, body: SubmitBatchJobRequest +) -> ApiResponse: + """ + Submit a batch load job for multiple files. + + Returns immediately with the job ID. The actual loading happens + asynchronously in a background thread. + """ + job_manager = get_job_manager(request) + + try: + # Convert FileConfig models to dicts + files = [f.model_dump() for f in body.files] + + job = await asyncio.to_thread( + job_manager.submit_batch_load_job, + files=files, + use_same_settings=body.use_same_settings, + shared_fmt=body.shared_fmt, + shared_rmf_file=body.shared_rmf_file, + shared_rmf_grant=body.shared_rmf_grant, + shared_additional_columns=body.shared_additional_columns, + shared_high_precision=body.shared_high_precision, + shared_skip_checks=body.shared_skip_checks, + shared_use_partial_loading=body.shared_use_partial_loading, + shared_partial_mode=body.shared_partial_mode, + shared_time_range_start=body.shared_time_range_start, + shared_time_range_end=body.shared_time_range_end, + shared_event_start_index=body.shared_event_start_index, + shared_event_count=body.shared_event_count, + ) + + return ApiResponse( + success=True, + data=job.to_dict(), + message=f"Batch job submitted: {len(files)} files", + ) + + except Exception as error: + logger.error("Failed to submit batch job (%s)", type(error).__name__) + return ApiResponse( + success=False, + error="job_submission_rejected", + message="Failed to submit batch job", + ) + + +@router.post("/submit-url", response_model=ApiResponse) +async def submit_url_job(request: Request, body: SubmitUrlJobRequest) -> ApiResponse: + """ + Submit a URL download and load job. + + Returns immediately with the job ID. The actual download and loading + happens asynchronously in a background thread. + """ + job_manager = get_job_manager(request) + + try: + job = await asyncio.to_thread( + job_manager.submit_url_load_job, + url=body.url, + name=body.name, + fmt=body.fmt, + rmf_file=body.rmf_file, + rmf_grant=body.rmf_grant, + additional_columns=body.additional_columns, + high_precision=body.high_precision, + skip_checks=body.skip_checks, + notes=body.notes, + ) + + return ApiResponse( + success=True, + data=job.to_dict(), + message=f"URL job submitted: {job.display_name}", + ) + + except Exception as error: + logger.error("Failed to submit URL job (%s)", type(error).__name__) + return ApiResponse( + success=False, + error="job_submission_rejected", + message="Failed to submit URL job", + ) + + +@router.get("/", response_model=ApiResponse) +async def list_jobs( + request: Request, + include_completed: bool = True, + limit: Annotated[int, Query(ge=1, le=100)] = 50, +) -> ApiResponse: + """ + List all jobs, newest first. + + Args: + include_completed: Include completed/failed/cancelled jobs + limit: Maximum number of jobs to return + """ + job_manager = get_job_manager(request) + + try: + jobs = job_manager.list_jobs( + include_completed=include_completed, + limit=limit, + ) + + return ApiResponse( + success=True, + data=[job.to_dict() for job in jobs], + message=f"Found {len(jobs)} jobs", + ) + + except Exception as error: + logger.error("Failed to list jobs (%s)", type(error).__name__) + return ApiResponse( + success=False, + error="job_list_failed", + message="Failed to list jobs", + ) + + +@router.get("/active", response_model=ApiResponse) +async def get_active_jobs(request: Request) -> ApiResponse: + """Get all active (pending or running) jobs.""" + job_manager = get_job_manager(request) + + try: + jobs = job_manager.get_active_jobs() + + return ApiResponse( + success=True, + data=[job.to_dict() for job in jobs], + message=f"Found {len(jobs)} active jobs", + ) + + except Exception as error: + logger.error("Failed to get active jobs (%s)", type(error).__name__) + return ApiResponse( + success=False, + error="job_list_failed", + message="Failed to get active jobs", + ) + + +@router.get("/stream") +async def stream_job_updates(request: Request): + """ + SSE endpoint for real-time job updates. + + Streams job status updates as they happen, including: + - job_created: New job submitted + - job_started: Job execution started + - job_progress: Job progress updated + - job_completed: Job finished successfully + - job_failed: Job failed with error + - job_cancelled: Job was cancelled + - heartbeat: Keep-alive signal + + First sends initial_state with all currently active jobs. + """ + job_manager = get_job_manager(request) + + async def event_generator(): + """Generate SSE events from job updates.""" + async for update in job_manager.stream_updates(): + data = json.dumps(update) + yield f"data: {data}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Disable nginx buffering + "X-Content-Type-Options": "nosniff", + }, + ) + + +@router.get("/{job_id}", response_model=ApiResponse) +async def get_job(request: Request, job_id: JobIdText) -> ApiResponse: + """Get a specific job by ID.""" + job_manager = get_job_manager(request) + + try: + job = job_manager.get_job(job_id) + + if job is None: + return ApiResponse( + success=False, + error="Job not found", + message=f"No job found with ID: {job_id}", + ) + + return ApiResponse( + success=True, + data=job.to_dict(), + message="Job found", + ) + + except Exception as error: + logger.error("Failed to get job %s (%s)", job_id, type(error).__name__) + return ApiResponse( + success=False, + error="job_lookup_failed", + message="Failed to get job", + ) + + +@router.post("/{job_id}/cancel", response_model=ApiResponse) +async def cancel_job(request: Request, job_id: JobIdText) -> ApiResponse: + """ + Cancel a pending job. + + Only pending jobs can be cancelled. Running jobs cannot be interrupted. + """ + job_manager = get_job_manager(request) + + try: + success = job_manager.cancel_job(job_id) + + if success: + return ApiResponse( + success=True, + message=f"Job {job_id} cancelled", + ) + else: + job = job_manager.get_job(job_id) + if job is None: + return ApiResponse( + success=False, + error="Job not found", + message=f"No job found with ID: {job_id}", + ) + else: + return ApiResponse( + success=False, + error="Cannot cancel job", + message=f"Job is {job.status.value}, only pending jobs can be cancelled", + ) + + except Exception as error: + logger.error("Failed to cancel job %s (%s)", job_id, type(error).__name__) + return ApiResponse( + success=False, + error="job_cancel_failed", + message="Failed to cancel job", + ) + + +@router.post("/check-name", response_model=ApiResponse) +async def check_name_conflict(request: Request, body: CheckNameRequest) -> ApiResponse: + """ + Check if a name conflicts with existing data or pending jobs. + + Returns conflict status and suggests an alternative name if needed. + """ + job_manager = get_job_manager(request) + + try: + result = job_manager.check_name_conflict(body.name) + + return ApiResponse( + success=True, + data=result, + message="Name conflict checked" + if result["has_conflict"] + else "Name available", + ) + + except Exception as error: + logger.error("Failed to check name conflict (%s)", type(error).__name__) + return ApiResponse( + success=False, + error="job_name_check_failed", + message="Failed to check name conflict", + ) + + +@router.delete("/completed", response_model=ApiResponse) +async def clear_completed_jobs(request: Request) -> ApiResponse: + """Clear all completed/failed/cancelled jobs.""" + job_manager = get_job_manager(request) + + try: + count = job_manager.clear_completed_jobs() + + return ApiResponse( + success=True, + data={"cleared_count": count}, + message=f"Cleared {count} completed jobs", + ) + + except Exception as error: + logger.error("Failed to clear completed jobs (%s)", type(error).__name__) + return ApiResponse( + success=False, + error="job_clear_failed", + message="Failed to clear completed jobs", + ) diff --git a/python-backend/routes/lightcurve_routes.py b/python-backend/routes/lightcurve_routes.py new file mode 100644 index 0000000..bb05a4e --- /dev/null +++ b/python-backend/routes/lightcurve_routes.py @@ -0,0 +1,120 @@ +""" +API routes for Lightcurve operations. +""" + +import asyncio +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query, Request +from pydantic import BaseModel, Field + +from services.lightcurve_service import DEFAULT_MAX_PLOT_POINTS, LightcurveService + +router = APIRouter() + + +def get_lightcurve_service(request: Request) -> LightcurveService: + """Get LightcurveService instance from app state.""" + return LightcurveService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +# Request Models +class CreateLightcurveFromEventListRequest(BaseModel): + event_list_name: str + dt: float + output_name: str + gti: Optional[List[List[float]]] = None + max_points: Optional[int] = Field(default=DEFAULT_MAX_PLOT_POINTS, ge=0) + + +class CreateLightcurveFromArraysRequest(BaseModel): + times: List[float] + counts: List[float] + dt: float + output_name: str + + +class RebinLightcurveRequest(BaseModel): + name: str + rebin_factor: float + output_name: str + max_points: Optional[int] = Field(default=DEFAULT_MAX_PLOT_POINTS, ge=0) + + +# Routes +@router.post("/from-event-list") +async def create_lightcurve_from_event_list( + request: CreateLightcurveFromEventListRequest, + service: LightcurveService = Depends(get_lightcurve_service), +): + """Create a Lightcurve from an EventList.""" + return await asyncio.to_thread( + service.create_lightcurve_from_event_list, + event_list_name=request.event_list_name, + dt=request.dt, + output_name=request.output_name, + gti=request.gti, + max_points=request.max_points, + ) + + +@router.post("/from-arrays") +async def create_lightcurve_from_arrays( + request: CreateLightcurveFromArraysRequest, + service: LightcurveService = Depends(get_lightcurve_service), +): + """Create a Lightcurve from time and count arrays.""" + return await asyncio.to_thread( + service.create_lightcurve_from_arrays, + times=request.times, + counts=request.counts, + dt=request.dt, + output_name=request.output_name, + ) + + +@router.post("/rebin") +async def rebin_lightcurve( + request: RebinLightcurveRequest, + service: LightcurveService = Depends(get_lightcurve_service), +): + """Rebin a lightcurve.""" + return await asyncio.to_thread( + service.rebin_lightcurve, + name=request.name, + rebin_factor=request.rebin_factor, + output_name=request.output_name, + max_points=request.max_points, + ) + + +@router.get("/{name}") +async def get_lightcurve_data( + name: str, + max_points: int = Query(default=DEFAULT_MAX_PLOT_POINTS, ge=0), + service: LightcurveService = Depends(get_lightcurve_service), +): + """Get lightcurve data for plotting.""" + return await asyncio.to_thread( + service.get_lightcurve_data, name, max_points=max_points + ) + + +@router.get("/") +async def list_lightcurves( + service: LightcurveService = Depends(get_lightcurve_service), +): + """List all loaded lightcurves.""" + return await asyncio.to_thread(service.list_lightcurves) + + +@router.delete("/{name}") +async def delete_lightcurve( + name: str, + service: LightcurveService = Depends(get_lightcurve_service), +): + """Delete a lightcurve from state.""" + return await asyncio.to_thread(service.delete_lightcurve, name) diff --git a/python-backend/routes/log_routes.py b/python-backend/routes/log_routes.py new file mode 100644 index 0000000..80979db --- /dev/null +++ b/python-backend/routes/log_routes.py @@ -0,0 +1,112 @@ +""" +API routes for real-time log streaming via Server-Sent Events (SSE). +""" + +import json +import logging +import warnings + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from utils.log_stream import log_stream_manager + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/stream") +async def stream_logs(request: Request) -> StreamingResponse: + """ + Stream log messages via Server-Sent Events (SSE). + + This endpoint provides real-time streaming of Python logging output + and warnings to the frontend. Logs are delivered as JSON objects + in the SSE data field. + + Event Format: + Log event: + { + "type": "log", + "timestamp": "2024-01-15T10:30:00.123Z", + "level": "warn", + "source": "python", + "logger": "stingray.events", + "message": "No GTI found, using whole time range" + } + + Heartbeat (every 30s of inactivity): + { + "type": "heartbeat", + "timestamp": "2024-01-15T10:30:30.000Z" + } + + Returns: + StreamingResponse with SSE content type + """ + + async def event_generator(): + """Generate SSE events from the log stream.""" + async for log_entry in log_stream_manager.stream_logs(): + # Check if client disconnected + if await request.is_disconnected(): + break + + # Format as SSE data event + yield f"data: {json.dumps(log_entry)}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Disable nginx buffering if behind proxy + }, + ) + + +@router.get("/status") +async def get_log_status() -> dict: + """ + Get the status of the log streaming system. + + Returns: + Dictionary with status information: + - installed: Whether log streaming is active + - active_connections: Number of connected SSE clients + - queue_size: Number of buffered log entries + """ + return { + "success": True, + "data": { + "installed": log_stream_manager.is_installed, + "active_connections": log_stream_manager.active_connections, + "queue_size": log_stream_manager.queue_size, + }, + "message": "Log stream status retrieved", + "error": None, + } + + +@router.post("/test") +async def test_log_generation() -> dict: + """ + Generate test log messages at various levels for testing the SSE stream. + + This endpoint is for development/testing purposes only. + """ + logger.debug("This is a DEBUG test message") + logger.info("This is an INFO test message") + logger.warning("This is a WARNING test message") + logger.error("This is an ERROR test message") + + # Also test warning capture + warnings.warn("This is a test warning from Python warnings module", UserWarning) + + return { + "success": True, + "data": None, + "message": "Test logs generated (debug, info, warning, error, and a Python warning)", + "error": None, + } diff --git a/python-backend/routes/misc_routes.py b/python-backend/routes/misc_routes.py new file mode 100644 index 0000000..b7b0b9b --- /dev/null +++ b/python-backend/routes/misc_routes.py @@ -0,0 +1,280 @@ +"""FastAPI routes for the curated Miscellaneous Utilities workbench.""" + +from __future__ import annotations + +import asyncio +from typing import Annotated, Literal, Optional + +from fastapi import APIRouter, Depends, Request +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + +from services.misc_service import MiscService +from services.utility_helpers import MAX_ARRAY_INPUT, MAX_EXACT_OUTPUT, MAX_MATRIX_CELLS +from routes.utility_models import UtilityResponse + +router = APIRouter() + +NumericArray = Annotated[list[StrictFloat], Field(max_length=MAX_ARRAY_INPUT)] +MatrixRow = Annotated[list[StrictFloat], Field(max_length=MAX_MATRIX_CELLS)] +SampleMatrix = Annotated[list[MatrixRow], Field(max_length=MAX_MATRIX_CELLS)] +OutputMean = Annotated[list[StrictFloat], Field(max_length=MAX_EXACT_OUTPUT)] + + +def get_misc_service(request: Request) -> MiscService: + """Build a request-scoped service over the application's shared state.""" + + return MiscService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +class UtilityRequest(BaseModel): + """Strict base model: renderer payloads may not smuggle unused fields.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + +class LinearRebinRequest(UtilityRequest): + x: NumericArray + y: NumericArray + dx_new: StrictFloat + y_error: Optional[NumericArray] = None + method: Literal["sum", "mean"] = "sum" + dx: Optional[StrictFloat] = None + + +class LogarithmicRebinRequest(UtilityRequest): + x: NumericArray + y: NumericArray + factor: StrictFloat + y_error: Optional[NumericArray] = None + dx: Optional[StrictFloat] = None + + +class BaselineRequest(UtilityRequest): + x: NumericArray + y: NumericArray + lam: StrictFloat = 1e11 + asymmetry: StrictFloat = 0.001 + iterations: StrictInt = 10 + offset_correction: StrictBool = False + + +class WindowRequest(UtilityRequest): + n_samples: StrictInt + window_type: StrictStr = "uniform" + + +class OptimalBinTimeRequest(UtilityRequest): + fft_length: StrictFloat + proposed_bin_time: StrictFloat + + +class NearestPowerOfTwoRequest(UtilityRequest): + value: StrictInt + + +class SegmentSizeRequest(UtilityRequest): + segment_size: StrictFloat + dt: StrictFloat + tolerance: StrictFloat = 0.01 + + +class PoissonErrorRequest(UtilityRequest): + counts: NumericArray + + +class StandardErrorRequest(UtilityRequest): + samples: SampleMatrix + mean: Optional[OutputMean] = None + + +class EqualCountEnergyRangesRequest(UtilityRequest): + n_ranges: StrictInt + energies: Optional[NumericArray] = None + event_list_name: Optional[StrictStr] = None + energy_min: Optional[StrictFloat] = None + energy_max: Optional[StrictFloat] = None + energy_unit: StrictStr = "keV" + + +@router.get("/capabilities", response_model=UtilityResponse) +async def capabilities(service: MiscService = Depends(get_misc_service)): + """Report exact installed window types, defaults and allocation caps.""" + + return await asyncio.to_thread(service.capabilities) + + +@router.post("/rebin/linear", response_model=UtilityResponse) +async def linear_rebin( + request: LinearRebinRequest, + service: MiscService = Depends(get_misc_service), +): + """Linearly rebin values with optional standard uncertainties.""" + + return await asyncio.to_thread( + service.linear_rebin, + request.x, + request.y, + request.dx_new, + y_error=request.y_error, + method=request.method, + dx=request.dx, + ) + + +@router.post("/rebin/logarithmic", response_model=UtilityResponse) +async def logarithmic_rebin( + request: LogarithmicRebinRequest, + service: MiscService = Depends(get_misc_service), +): + """Logarithmically rebin values using Stingray's mean-only helper.""" + + return await asyncio.to_thread( + service.logarithmic_rebin, + request.x, + request.y, + request.factor, + y_error=request.y_error, + dx=request.dx, + ) + + +@router.post("/baseline", response_model=UtilityResponse) +async def estimate_baseline( + request: BaselineRequest, + service: MiscService = Depends(get_misc_service), +): + """Estimate and remove an asymmetric least-squares baseline.""" + + return await asyncio.to_thread( + service.estimate_baseline, + request.x, + request.y, + lam=request.lam, + asymmetry=request.asymmetry, + iterations=request.iterations, + offset_correction=request.offset_correction, + ) + + +@router.post("/window", response_model=UtilityResponse) +async def generate_window( + request: WindowRequest, + service: MiscService = Depends(get_misc_service), +): + """Generate one installed Stingray analysis window.""" + + return await asyncio.to_thread( + service.generate_window, + request.n_samples, + request.window_type, + ) + + +@router.post("/sampling/optimal-bin-time", response_model=UtilityResponse) +async def calculate_optimal_bin_time( + request: OptimalBinTimeRequest, + service: MiscService = Depends(get_misc_service), +): + """Find a nearby bin time yielding a power-of-two FFT sample count.""" + + return await asyncio.to_thread( + service.calculate_optimal_bin_time, + request.fft_length, + request.proposed_bin_time, + ) + + +@router.post("/sampling/nearest-power-of-two", response_model=UtilityResponse) +async def calculate_nearest_power_of_two( + request: NearestPowerOfTwoRequest, + service: MiscService = Depends(get_misc_service), +): + """Find Stingray's nearest integral power of two.""" + + return await asyncio.to_thread( + service.calculate_nearest_power_of_two, request.value + ) + + +@router.post("/sampling/segment-size", response_model=UtilityResponse) +async def adjust_segment_size( + request: SegmentSizeRequest, + service: MiscService = Depends(get_misc_service), +): + """Adjust a segment size to an integer number of samples.""" + + return await asyncio.to_thread( + service.adjust_segment_size, + request.segment_size, + request.dt, + tolerance=request.tolerance, + ) + + +@router.post("/errors/poisson", response_model=UtilityResponse) +async def poisson_errors( + request: PoissonErrorRequest, + service: MiscService = Depends(get_misc_service), +): + """Calculate one-sigma symmetrized frequentist Poisson errors.""" + + return await asyncio.to_thread(service.poisson_errors, request.counts) + + +@router.post("/errors/standard", response_model=UtilityResponse) +async def standard_error( + request: StandardErrorRequest, + service: MiscService = Depends(get_misc_service), +): + """Calculate column-wise standard errors for a sample matrix.""" + + return await asyncio.to_thread( + service.standard_error, + request.samples, + mean=request.mean, + ) + + +@router.post("/energy-ranges", response_model=UtilityResponse) +async def equal_count_ranges( + request: EqualCountEnergyRangesRequest, + service: MiscService = Depends(get_misc_service), +): + """Create equal-count energy ranges from pasted or loaded EventList data.""" + + return await asyncio.to_thread( + service.equal_count_ranges, + n_ranges=request.n_ranges, + energies=request.energies, + event_list_name=request.event_list_name, + energy_min=request.energy_min, + energy_max=request.energy_max, + energy_unit=request.energy_unit, + ) + + +__all__ = [ + "BaselineRequest", + "EqualCountEnergyRangesRequest", + "LinearRebinRequest", + "LogarithmicRebinRequest", + "NearestPowerOfTwoRequest", + "OptimalBinTimeRequest", + "PoissonErrorRequest", + "SegmentSizeRequest", + "StandardErrorRequest", + "WindowRequest", + "get_misc_service", + "router", +] diff --git a/python-backend/routes/mission_io_routes.py b/python-backend/routes/mission_io_routes.py new file mode 100644 index 0000000..dd30a3f --- /dev/null +++ b/python-backend/routes/mission_io_routes.py @@ -0,0 +1,182 @@ +"""FastAPI routes for Mission-Specific I/O Utilities.""" + +from __future__ import annotations + +import asyncio +from typing import Optional + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from services.mission_io_service import MissionIOService +from services.utility_helpers import MAX_ARRAY_INPUT +from routes.utility_models import UtilityResponse + + +router = APIRouter() + + +def get_mission_io_service(request: Request) -> MissionIOService: + """Build a request-scoped service over the shared application state.""" + return MissionIOService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +class MissionInfoRequest(BaseModel): + """Select a runtime mission mapping, optionally specialized by setup.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + mission: str = Field(min_length=1, max_length=128) + instrument: Optional[str] = Field(default=None, min_length=1, max_length=128) + mode: Optional[str] = Field(default=None, min_length=1, max_length=256) + + +class MissionIdentifyRequest(BaseModel): + """Select exactly one loaded or Electron-granted FITS source.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + event_list_name: Optional[str] = Field(default=None, min_length=1) + file_path: Optional[str] = Field(default=None, min_length=1, max_length=4096) + file_grant: Optional[str] = Field(default=None, min_length=1, max_length=512) + mission_override: Optional[str] = Field(default=None, min_length=1, max_length=128) + instrument_override: Optional[str] = Field( + default=None, min_length=1, max_length=128 + ) + mode_override: Optional[str] = Field(default=None, min_length=1, max_length=256) + + @model_validator(mode="after") + def validate_source(self) -> "MissionIdentifyRequest": + if (self.event_list_name is None) == (self.file_path is None): + raise ValueError( + "Select exactly one source: event_list_name or file_path with file_grant" + ) + if self.file_path is not None and self.file_grant is None: + raise ValueError("file_grant is required with file_path") + if self.event_list_name is not None and self.file_grant is not None: + raise ValueError("file_grant is only valid with file_path") + return self + + +class RoughPiConversionRequest(BaseModel): + """Request an explicitly approximate public mission conversion.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + pi_values: Optional[list[float]] = Field( + default=None, + min_length=1, + max_length=MAX_ARRAY_INPUT, + ) + event_list_name: Optional[str] = Field(default=None, min_length=1) + mission_override: Optional[str] = Field(default=None, min_length=1, max_length=128) + instrument_override: Optional[str] = Field( + default=None, min_length=1, max_length=128 + ) + mode_override: Optional[str] = Field(default=None, min_length=1, max_length=256) + epoch_mjd: Optional[float] = None + detector_ids: Optional[list[int]] = Field( + default=None, + min_length=1, + max_length=MAX_ARRAY_INPUT, + ) + save_as: Optional[str] = Field(default=None, min_length=1, max_length=64) + + @model_validator(mode="after") + def validate_source(self) -> "RoughPiConversionRequest": + if (self.pi_values is None) == (self.event_list_name is None): + raise ValueError("Provide exactly one of pi_values or event_list_name") + if self.save_as is not None and self.event_list_name is None: + raise ValueError("save_as is valid only for a loaded EventList source") + return self + + +class MissionInterpretRequest(BaseModel): + """Select one FITS file for supported read-only interpretation.""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False, strict=True) + + file_path: str = Field(min_length=1, max_length=4096) + file_grant: str = Field(min_length=1, max_length=512) + mission_override: Optional[str] = Field(default=None, min_length=1, max_length=128) + instrument_override: Optional[str] = Field( + default=None, min_length=1, max_length=128 + ) + mode_override: Optional[str] = Field(default=None, min_length=1, max_length=256) + + +@router.get("/capabilities", response_model=UtilityResponse) +async def list_mission_capabilities( + service: MissionIOService = Depends(get_mission_io_service), +): + """List runtime xselect mappings and operation-specific capabilities.""" + return await asyncio.to_thread(service.list_capabilities) + + +@router.post("/info", response_model=UtilityResponse) +async def get_mission_info( + request: MissionInfoRequest, + service: MissionIOService = Depends(get_mission_io_service), +): + """Read a runtime mission mapping for an instrument and observing mode.""" + return await asyncio.to_thread( + service.get_mission_info, + request.mission, + instrument=request.instrument, + mode=request.mode, + ) + + +@router.post("/identify", response_model=UtilityResponse) +async def identify_mission_source( + request: MissionIdentifyRequest, + service: MissionIOService = Depends(get_mission_io_service), +): + """Identify mission metadata from a loaded EventList or granted FITS file.""" + return await asyncio.to_thread( + service.identify_source, + event_list_name=request.event_list_name, + file_path=request.file_path, + file_grant=request.file_grant, + mission_override=request.mission_override, + instrument_override=request.instrument_override, + mode_override=request.mode_override, + ) + + +@router.post("/convert-pi", response_model=UtilityResponse) +async def convert_pi_to_energy( + request: RoughPiConversionRequest, + service: MissionIOService = Depends(get_mission_io_service), +): + """Run a prominently labelled approximate PI-to-energy conversion.""" + return await asyncio.to_thread( + service.convert_pi_to_energy, + pi_values=request.pi_values, + event_list_name=request.event_list_name, + mission_override=request.mission_override, + instrument_override=request.instrument_override, + mode_override=request.mode_override, + epoch_mjd=request.epoch_mjd, + detector_ids=request.detector_ids, + save_as=request.save_as, + ) + + +@router.post("/interpret", response_model=UtilityResponse) +async def interpret_mission_fits( + request: MissionInterpretRequest, + service: MissionIOService = Depends(get_mission_io_service), +): + """Summarize supported interpretation on a bounded in-memory FITS copy.""" + return await asyncio.to_thread( + service.interpret_selected_fits, + file_path=request.file_path, + file_grant=request.file_grant, + mission_override=request.mission_override, + instrument_override=request.instrument_override, + mode_override=request.mode_override, + ) diff --git a/python-backend/routes/spectrum_routes.py b/python-backend/routes/spectrum_routes.py new file mode 100644 index 0000000..c899d4b --- /dev/null +++ b/python-backend/routes/spectrum_routes.py @@ -0,0 +1,182 @@ +""" +API routes for spectrum operations. +""" + +import asyncio +from typing import Optional + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel + +from services.spectrum_service import SpectrumService + +router = APIRouter() + + +def get_spectrum_service(request: Request) -> SpectrumService: + """Get SpectrumService instance from app state.""" + return SpectrumService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +# Request Models +class CreatePowerSpectrumRequest(BaseModel): + event_list_name: str + dt: float + norm: str = "leahy" + output_name: Optional[str] = None + + +class CreateAveragedPowerSpectrumRequest(BaseModel): + event_list_name: str + dt: float + segment_size: float + norm: str = "leahy" + output_name: Optional[str] = None + + +class CreateCrossSpectrumRequest(BaseModel): + event_list_1_name: str + event_list_2_name: str + dt: float + norm: str = "leahy" + output_name: Optional[str] = None + + +class CreateAveragedCrossSpectrumRequest(BaseModel): + event_list_1_name: str + event_list_2_name: str + dt: float + segment_size: float + norm: str = "leahy" + output_name: Optional[str] = None + + +class CreateDynamicalPowerSpectrumRequest(BaseModel): + event_list_name: str + dt: float + segment_size: float + norm: str = "leahy" + output_name: Optional[str] = None + + +class RebinSpectrumRequest(BaseModel): + name: str + rebin_factor: float + log: bool = False + output_name: Optional[str] = None + + +# Routes +@router.post("/power-spectrum") +async def create_power_spectrum( + request: CreatePowerSpectrumRequest, + service: SpectrumService = Depends(get_spectrum_service), +): + """Create a power spectrum from an EventList.""" + return await asyncio.to_thread( + service.create_power_spectrum, + event_list_name=request.event_list_name, + dt=request.dt, + norm=request.norm, + output_name=request.output_name, + ) + + +@router.post("/averaged-power-spectrum") +async def create_averaged_power_spectrum( + request: CreateAveragedPowerSpectrumRequest, + service: SpectrumService = Depends(get_spectrum_service), +): + """Create an averaged power spectrum from an EventList.""" + return await asyncio.to_thread( + service.create_averaged_power_spectrum, + event_list_name=request.event_list_name, + dt=request.dt, + segment_size=request.segment_size, + norm=request.norm, + output_name=request.output_name, + ) + + +@router.post("/cross-spectrum") +async def create_cross_spectrum( + request: CreateCrossSpectrumRequest, + service: SpectrumService = Depends(get_spectrum_service), +): + """Create a cross spectrum from two EventLists.""" + return await asyncio.to_thread( + service.create_cross_spectrum, + event_list_1_name=request.event_list_1_name, + event_list_2_name=request.event_list_2_name, + dt=request.dt, + norm=request.norm, + output_name=request.output_name, + ) + + +@router.post("/averaged-cross-spectrum") +async def create_averaged_cross_spectrum( + request: CreateAveragedCrossSpectrumRequest, + service: SpectrumService = Depends(get_spectrum_service), +): + """Create an averaged cross spectrum from two EventLists.""" + return await asyncio.to_thread( + service.create_averaged_cross_spectrum, + event_list_1_name=request.event_list_1_name, + event_list_2_name=request.event_list_2_name, + dt=request.dt, + segment_size=request.segment_size, + norm=request.norm, + output_name=request.output_name, + ) + + +@router.post("/dynamical-power-spectrum") +async def create_dynamical_power_spectrum( + request: CreateDynamicalPowerSpectrumRequest, + service: SpectrumService = Depends(get_spectrum_service), +): + """Create a dynamical power spectrum from an EventList.""" + return await asyncio.to_thread( + service.create_dynamical_power_spectrum, + event_list_name=request.event_list_name, + dt=request.dt, + segment_size=request.segment_size, + norm=request.norm, + output_name=request.output_name, + ) + + +@router.post("/rebin") +async def rebin_spectrum( + request: RebinSpectrumRequest, + service: SpectrumService = Depends(get_spectrum_service), +): + """Rebin a spectrum.""" + return await asyncio.to_thread( + service.rebin_spectrum, + name=request.name, + rebin_factor=request.rebin_factor, + log=request.log, + output_name=request.output_name, + ) + + +@router.get("/") +async def list_spectra( + service: SpectrumService = Depends(get_spectrum_service), +): + """List all loaded spectra.""" + return await asyncio.to_thread(service.list_spectra) + + +@router.delete("/{name}") +async def delete_spectrum( + name: str, + service: SpectrumService = Depends(get_spectrum_service), +): + """Delete a spectrum from state.""" + return await asyncio.to_thread(service.delete_spectrum, name) diff --git a/python-backend/routes/statistics_routes.py b/python-backend/routes/statistics_routes.py new file mode 100644 index 0000000..973a795 --- /dev/null +++ b/python-backend/routes/statistics_routes.py @@ -0,0 +1,285 @@ +"""Typed API routes for the Statistical Functions utility workbench.""" + +# FastAPI dependencies are intentionally declared by calling Depends in +# endpoint defaults, which is the framework's standard injection pattern. +# ruff: noqa: B008 + +from __future__ import annotations + +import asyncio +from typing import Annotated, Any, Literal + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, ConfigDict, Field, model_validator +from services.statistics_service import MAX_COUNT_PARAMETER, StatisticsService + +router = APIRouter() + +Probability = Annotated[float, Field(strict=True, gt=0.0, lt=1.0)] +ClosedProbability = Annotated[float, Field(strict=True, ge=0.0, le=1.0)] +LogProbability = Annotated[float, Field(strict=True, lt=0.0)] +NonnegativeStatistic = Annotated[float, Field(strict=True, ge=0.0)] +PdmStatistic = Annotated[float, Field(strict=True, ge=0.0, le=1.0)] +PositiveCount = Annotated[int, Field(strict=True, ge=1, le=MAX_COUNT_PARAMETER)] +FoldPhaseBins = Annotated[int, Field(strict=True, ge=3, le=MAX_COUNT_PARAMETER)] +PdmPhaseBins = Annotated[int, Field(strict=True, ge=2, le=MAX_COUNT_PARAMETER)] +PdmSamples = Annotated[int, Field(strict=True, ge=3, le=MAX_COUNT_PARAMETER)] + + +class StrictRequest(BaseModel): + """Reject unknown, non-finite, or coercion-dependent request values.""" + + model_config = ConfigDict( + extra="forbid", + allow_inf_nan=False, + strict=True, + ) + + +class ServiceResponse(BaseModel): + """Standard response envelope shared by backend services.""" + + success: bool + data: dict[str, Any] | None + message: str + error: str | None + + +class GaussianRequest(StrictRequest): + probability: Probability | None = None + log_probability: LogProbability | None = None + sidedness: Literal["one-sided", "two-sided"] = "one-sided" + + @model_validator(mode="after") + def require_exactly_one_probability(self) -> GaussianRequest: + if (self.probability is None) == (self.log_probability is None): + raise ValueError("Provide exactly one of probability or log_probability") + return self + + +class TrialRequest(StrictRequest): + direction: Literal["single-to-multi", "multi-to-single"] + probability: ClosedProbability + n_trials: PositiveCount + + @model_validator(mode="after") + def inverse_probability_must_be_below_one(self) -> "TrialRequest": + if self.direction == "multi-to-single" and self.probability == 1.0: + raise ValueError( + "probability must be less than 1 for multi-to-single conversion" + ) + return self + + +class PdsEvaluateRequest(StrictRequest): + power: NonnegativeStatistic + n_trials: PositiveCount = 1 + n_summed_spectra: PositiveCount = 1 + n_rebin: PositiveCount = 1 + + +class PdsDetectionRequest(StrictRequest): + false_alarm_probability: Probability + n_trials: PositiveCount = 1 + n_summed_spectra: PositiveCount = 1 + n_rebin: PositiveCount = 1 + + +class Z2EvaluateRequest(StrictRequest): + z2: NonnegativeStatistic + harmonics: PositiveCount = 2 + n_trials: PositiveCount = 1 + n_summed_spectra: PositiveCount = 1 + + +class Z2DetectionRequest(StrictRequest): + false_alarm_probability: Probability + harmonics: PositiveCount = 2 + n_trials: PositiveCount = 1 + n_summed_spectra: PositiveCount = 1 + + +class FoldEvaluateRequest(StrictRequest): + statistic: NonnegativeStatistic + n_phase_bins: FoldPhaseBins + n_trials: PositiveCount = 1 + + +class FoldDetectionRequest(StrictRequest): + false_alarm_probability: Probability + n_phase_bins: FoldPhaseBins + n_trials: PositiveCount = 1 + + +class PdmBaseRequest(StrictRequest): + n_samples: PdmSamples + n_phase_bins: PdmPhaseBins + n_trials: PositiveCount = 1 + + @model_validator(mode="after") + def samples_must_exceed_bins(self) -> PdmBaseRequest: + if self.n_samples <= self.n_phase_bins: + raise ValueError("n_samples must be greater than n_phase_bins") + return self + + +class PdmEvaluateRequest(PdmBaseRequest): + statistic: PdmStatistic + + +class PdmDetectionRequest(PdmBaseRequest): + false_alarm_probability: Probability + + +def get_statistics_service(request: Request) -> StatisticsService: + """Build the stateless service with the application's shared dependencies.""" + return StatisticsService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +@router.post("/gaussian", response_model=ServiceResponse) +async def gaussian_significance( + request: GaussianRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Convert a one- or two-sided probability to Gaussian sigma.""" + return await asyncio.to_thread( + service.gaussian_significance, + probability=request.probability, + log_probability=request.log_probability, + sidedness=request.sidedness, + ) + + +@router.post("/trials", response_model=ServiceResponse) +async def convert_trials( + request: TrialRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Convert between single-trial and overall independent-trial probability.""" + return await asyncio.to_thread( + service.convert_trials, + direction=request.direction, + probability=request.probability, + n_trials=request.n_trials, + ) + + +@router.post("/pds/evaluate", response_model=ServiceResponse) +async def evaluate_pds( + request: PdsEvaluateRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Evaluate PDS false-alarm probability and natural-log probability.""" + return await asyncio.to_thread( + service.evaluate_pds, + power=request.power, + n_trials=request.n_trials, + n_summed_spectra=request.n_summed_spectra, + n_rebin=request.n_rebin, + ) + + +@router.post("/pds/detection", response_model=ServiceResponse) +async def detect_pds( + request: PdsDetectionRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Calculate a PDS threshold at an overall false-alarm probability.""" + return await asyncio.to_thread( + service.detect_pds, + false_alarm_probability=request.false_alarm_probability, + n_trials=request.n_trials, + n_summed_spectra=request.n_summed_spectra, + n_rebin=request.n_rebin, + ) + + +@router.post("/z2/evaluate", response_model=ServiceResponse) +async def evaluate_z2( + request: Z2EvaluateRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Evaluate Z-squared-n false-alarm and natural-log probabilities.""" + return await asyncio.to_thread( + service.evaluate_z2, + z2=request.z2, + harmonics=request.harmonics, + n_trials=request.n_trials, + n_summed_spectra=request.n_summed_spectra, + ) + + +@router.post("/z2/detection", response_model=ServiceResponse) +async def detect_z2( + request: Z2DetectionRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Calculate a Z-squared-n threshold at an overall false-alarm rate.""" + return await asyncio.to_thread( + service.detect_z2, + false_alarm_probability=request.false_alarm_probability, + harmonics=request.harmonics, + n_trials=request.n_trials, + n_summed_spectra=request.n_summed_spectra, + ) + + +@router.post("/fold/evaluate", response_model=ServiceResponse) +async def evaluate_fold( + request: FoldEvaluateRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Evaluate epoch-folding false-alarm and natural-log probabilities.""" + return await asyncio.to_thread( + service.evaluate_fold, + statistic=request.statistic, + n_phase_bins=request.n_phase_bins, + n_trials=request.n_trials, + ) + + +@router.post("/fold/detection", response_model=ServiceResponse) +async def detect_fold( + request: FoldDetectionRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Calculate an epoch-folding threshold at an overall false-alarm rate.""" + return await asyncio.to_thread( + service.detect_fold, + false_alarm_probability=request.false_alarm_probability, + n_phase_bins=request.n_phase_bins, + n_trials=request.n_trials, + ) + + +@router.post("/pdm/evaluate", response_model=ServiceResponse) +async def evaluate_pdm( + request: PdmEvaluateRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Evaluate lower-tail phase-dispersion probability and log probability.""" + return await asyncio.to_thread( + service.evaluate_pdm, + statistic=request.statistic, + n_samples=request.n_samples, + n_phase_bins=request.n_phase_bins, + n_trials=request.n_trials, + ) + + +@router.post("/pdm/detection", response_model=ServiceResponse) +async def detect_pdm( + request: PdmDetectionRequest, + service: StatisticsService = Depends(get_statistics_service), +) -> dict[str, Any]: + """Calculate a lower-tail PDM threshold at an overall false-alarm rate.""" + return await asyncio.to_thread( + service.detect_pdm, + false_alarm_probability=request.false_alarm_probability, + n_samples=request.n_samples, + n_phase_bins=request.n_phase_bins, + n_trials=request.n_trials, + ) diff --git a/python-backend/routes/timing_routes.py b/python-backend/routes/timing_routes.py new file mode 100644 index 0000000..12871df --- /dev/null +++ b/python-backend/routes/timing_routes.py @@ -0,0 +1,123 @@ +""" +API routes for timing analysis operations. +""" + +import asyncio +from typing import Dict, Optional, Tuple + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel + +from services.timing_service import TimingService + +router = APIRouter() + + +def get_timing_service(request: Request) -> TimingService: + """Get TimingService instance from app state.""" + return TimingService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +# Request Models +class CreateBispectrumRequest(BaseModel): + event_list_name: str + dt: float + maxlag: int = 25 + scale: str = "unbiased" + window: str = "uniform" + output_name: Optional[str] = None + + +class CalculatePowerColorsRequest(BaseModel): + event_list_name: str + dt: float + segment_size: float + freq_ranges: Dict[str, Tuple[float, float]] + output_name: Optional[str] = None + + +class CalculateTimeLagsRequest(BaseModel): + event_list_1_name: str + event_list_2_name: str + dt: float + segment_size: float + freq_range: Optional[Tuple[float, float]] = None + output_name: Optional[str] = None + + +class CalculateCoherenceRequest(BaseModel): + event_list_1_name: str + event_list_2_name: str + dt: float + segment_size: float + output_name: Optional[str] = None + + +# Routes +@router.post("/bispectrum") +async def create_bispectrum( + request: CreateBispectrumRequest, + service: TimingService = Depends(get_timing_service), +): + """Create a bispectrum from an EventList.""" + return await asyncio.to_thread( + service.create_bispectrum, + event_list_name=request.event_list_name, + dt=request.dt, + maxlag=request.maxlag, + scale=request.scale, + window=request.window, + output_name=request.output_name, + ) + + +@router.post("/power-colors") +async def calculate_power_colors( + request: CalculatePowerColorsRequest, + service: TimingService = Depends(get_timing_service), +): + """Calculate power colors from frequency bands.""" + return await asyncio.to_thread( + service.calculate_power_colors, + event_list_name=request.event_list_name, + dt=request.dt, + segment_size=request.segment_size, + freq_ranges=request.freq_ranges, + output_name=request.output_name, + ) + + +@router.post("/time-lags") +async def calculate_time_lags( + request: CalculateTimeLagsRequest, + service: TimingService = Depends(get_timing_service), +): + """Calculate time lags between two event lists.""" + return await asyncio.to_thread( + service.calculate_time_lags, + event_list_1_name=request.event_list_1_name, + event_list_2_name=request.event_list_2_name, + dt=request.dt, + segment_size=request.segment_size, + freq_range=request.freq_range, + output_name=request.output_name, + ) + + +@router.post("/coherence") +async def calculate_coherence( + request: CalculateCoherenceRequest, + service: TimingService = Depends(get_timing_service), +): + """Calculate coherence between two event lists.""" + return await asyncio.to_thread( + service.calculate_coherence, + event_list_1_name=request.event_list_1_name, + event_list_2_name=request.event_list_2_name, + dt=request.dt, + segment_size=request.segment_size, + output_name=request.output_name, + ) diff --git a/python-backend/routes/utility_models.py b/python-backend/routes/utility_models.py new file mode 100644 index 0000000..5e6eac5 --- /dev/null +++ b/python-backend/routes/utility_models.py @@ -0,0 +1,19 @@ +"""Shared typed response envelope for Utilities routes.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class UtilityResponse(BaseModel): + """Document and enforce the service envelope exposed by every Utility API.""" + + model_config = ConfigDict(extra="allow") + + success: bool + data: Any | None + message: str + error: str | None + warnings: list[str] | None = None diff --git a/python-backend/routes/varenergy_routes.py b/python-backend/routes/varenergy_routes.py new file mode 100644 index 0000000..09baf6f --- /dev/null +++ b/python-backend/routes/varenergy_routes.py @@ -0,0 +1,238 @@ +""" +API routes for var-energy spectrum operations. + +Implemented per docs/superpowers/plans/2026-07-29-quicklook-remaining-pages.md. +""" + +import asyncio +from typing import Optional + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel + +from services.varenergy_service import VarEnergyService + +router = APIRouter() + + +def get_varenergy_service(request: Request) -> VarEnergyService: + """Get VarEnergyService instance from app state.""" + return VarEnergyService( + state_manager=request.app.state.state_manager, + performance_monitor=request.app.state.performance_monitor, + ) + + +class RmsSpectrumRequest(BaseModel): + # No reference band: stingray's RmsSpectrum ignores ref_band for a single + # event list, so exposing one would be a control that does nothing. + event_list_name: str + bin_time: float + segment_size: float + freq_min: float + freq_max: float + energy_min: float + energy_max: float + n_bands: int = 5 + log_bands: bool = False + norm: str = "frac" + + +class LagSpectrumRequest(BaseModel): + event_list_name: str + bin_time: float + segment_size: float + freq_min: float + freq_max: float + energy_min: float + energy_max: float + n_bands: int = 5 + log_bands: bool = False + ref_min: Optional[float] = None + ref_max: Optional[float] = None + + +class ExcessVarianceRequest(BaseModel): + # No frequency range and no segment_size: stingray's + # ExcessVarianceSpectrum never reads freq_interval and ignores + # segment_size entirely. + event_list_name: str + bin_time: float + energy_min: float + energy_max: float + n_bands: int = 5 + log_bands: bool = False + normalization: str = "fvar" + + +class VariableEnergySpectrumRequest(BaseModel): + event_list_name: str + bin_time: float + segment_size: float + freq_min: float + freq_max: float + energy_min: float + energy_max: float + n_bands: int = 5 + log_bands: bool = False + ref_min: Optional[float] = None + ref_max: Optional[float] = None + + +class CovarianceSpectrumRequest(BaseModel): + # Unsegmented page: the single segment spans the longest good-time + # interval, so there is no segment_size field here. + event_list_name: str + bin_time: float + freq_min: float + freq_max: float + energy_min: float + energy_max: float + n_bands: int = 5 + log_bands: bool = False + ref_min: Optional[float] = None + ref_max: Optional[float] = None + norm: str = "abs" + + +class AvgCovarianceSpectrumRequest(BaseModel): + event_list_name: str + bin_time: float + segment_size: float + freq_min: float + freq_max: float + energy_min: float + energy_max: float + n_bands: int = 5 + log_bands: bool = False + ref_min: Optional[float] = None + ref_max: Optional[float] = None + norm: str = "abs" + + +@router.post("/rms-spectrum") +async def rms_spectrum( + request: RmsSpectrumRequest, + service: VarEnergyService = Depends(get_varenergy_service), +): + """Compute the rms spectrum as a function of energy.""" + return await asyncio.to_thread( + service.rms_spectrum, + event_list_name=request.event_list_name, + bin_time=request.bin_time, + segment_size=request.segment_size, + freq_min=request.freq_min, + freq_max=request.freq_max, + energy_min=request.energy_min, + energy_max=request.energy_max, + n_bands=request.n_bands, + log_bands=request.log_bands, + norm=request.norm, + ) + + +@router.post("/lag-spectrum") +async def lag_spectrum( + request: LagSpectrumRequest, + service: VarEnergyService = Depends(get_varenergy_service), +): + """Compute the time-lag spectrum as a function of energy.""" + return await asyncio.to_thread( + service.lag_spectrum, + event_list_name=request.event_list_name, + bin_time=request.bin_time, + segment_size=request.segment_size, + freq_min=request.freq_min, + freq_max=request.freq_max, + energy_min=request.energy_min, + energy_max=request.energy_max, + n_bands=request.n_bands, + log_bands=request.log_bands, + ref_min=request.ref_min, + ref_max=request.ref_max, + ) + + +@router.post("/excess-variance") +async def excess_variance( + request: ExcessVarianceRequest, + service: VarEnergyService = Depends(get_varenergy_service), +): + """Compute the excess-variance spectrum as a function of energy.""" + return await asyncio.to_thread( + service.excess_variance_spectrum, + event_list_name=request.event_list_name, + bin_time=request.bin_time, + energy_min=request.energy_min, + energy_max=request.energy_max, + n_bands=request.n_bands, + log_bands=request.log_bands, + normalization=request.normalization, + ) + + +@router.post("/variable-energy-spectrum") +async def variable_energy_spectrum( + request: VariableEnergySpectrumRequest, + service: VarEnergyService = Depends(get_varenergy_service), +): + """Compute counts, fractional rms and lag spectra in one pass.""" + return await asyncio.to_thread( + service.variable_energy_spectrum, + event_list_name=request.event_list_name, + bin_time=request.bin_time, + segment_size=request.segment_size, + freq_min=request.freq_min, + freq_max=request.freq_max, + energy_min=request.energy_min, + energy_max=request.energy_max, + n_bands=request.n_bands, + log_bands=request.log_bands, + ref_min=request.ref_min, + ref_max=request.ref_max, + ) + + +@router.post("/covariance-spectrum") +async def covariance_spectrum( + request: CovarianceSpectrumRequest, + service: VarEnergyService = Depends(get_varenergy_service), +): + """Compute the covariance spectrum over the whole observation.""" + return await asyncio.to_thread( + service.covariance_spectrum, + event_list_name=request.event_list_name, + bin_time=request.bin_time, + freq_min=request.freq_min, + freq_max=request.freq_max, + energy_min=request.energy_min, + energy_max=request.energy_max, + n_bands=request.n_bands, + log_bands=request.log_bands, + ref_min=request.ref_min, + ref_max=request.ref_max, + norm=request.norm, + ) + + +@router.post("/avg-covariance-spectrum") +async def avg_covariance_spectrum( + request: AvgCovarianceSpectrumRequest, + service: VarEnergyService = Depends(get_varenergy_service), +): + """Compute the segment-averaged covariance spectrum.""" + return await asyncio.to_thread( + service.avg_covariance_spectrum, + event_list_name=request.event_list_name, + bin_time=request.bin_time, + segment_size=request.segment_size, + freq_min=request.freq_min, + freq_max=request.freq_max, + energy_min=request.energy_min, + energy_max=request.energy_max, + n_bands=request.n_bands, + log_bands=request.log_bands, + ref_min=request.ref_min, + ref_max=request.ref_max, + norm=request.norm, + ) diff --git a/python-backend/services/__init__.py b/python-backend/services/__init__.py new file mode 100644 index 0000000..7d6e627 --- /dev/null +++ b/python-backend/services/__init__.py @@ -0,0 +1,15 @@ +"""Services for Stingray Explorer backend.""" + +from .state_manager import StateManager +from .data_service import DataService +from .lightcurve_service import LightcurveService +from .spectrum_service import SpectrumService +from .timing_service import TimingService + +__all__ = [ + "StateManager", + "DataService", + "LightcurveService", + "SpectrumService", + "TimingService", +] diff --git a/python-backend/services/analysis_helpers.py b/python-backend/services/analysis_helpers.py new file mode 100644 index 0000000..b1f8d8b --- /dev/null +++ b/python-backend/services/analysis_helpers.py @@ -0,0 +1,151 @@ +""" +Shared helpers for analysis services. + +These mirror the module-private helpers duplicated in timing_service.py and +spectrum_service.py; new services import from here instead of adding a third +copy. (Migrating the two older services to these is a separate cleanup.) +""" + +import sys as _sys +import threading as _threading +import warnings as _warnings +from contextlib import contextmanager +from typing import Iterator, List, Optional + +import numpy as np + +# CPython 3.14 added "context-aware warnings": when enabled, catch_warnings +# stores its saved state in a contextvars.ContextVar instead of module globals, +# which makes concurrent captures genuinely isolated (asyncio.to_thread copies +# the caller's context per call, and a plain threading.Thread starts with its +# own context, so neither can see another block's saved state). +# +# The flag is off by default. It can be turned on with -X context_aware_warnings=1 +# or PYTHON_CONTEXT_AWARE_WARNINGS=1; electron/pythonManager.ts and the +# `python:dev` npm script set it so real launches get the fast path. +# getattr() keeps this importable on Python < 3.14, where the flag does not exist. +# +# Caveat that mode carries: the legacy catch_warnings(record=True) resets +# warnings.showwarning to the default on entry so recording cannot be bypassed, +# but the context-aware path does not. So in that mode anything which replaces +# the global warnings.showwarning must chain to the handler it displaced, or +# capture here silently yields an empty list. utils/log_stream.py (installed for +# the whole process by main.py's lifespan) does chain - see its _capture_warning +# - and tests/test_analysis_helpers.py pins that down in both modes. +CONTEXT_AWARE_WARNINGS = bool(getattr(_sys.flags, "context_aware_warnings", 0)) + +# Fallback for interpreters without that mode: serialize warning capture. +# +# warnings.catch_warnings(record=True) mutates PROCESS-GLOBAL state +# (warnings.filters, warnings.showwarning, warnings._showwarnmsg_impl) on entry +# and restores whatever it saw on exit. Every analysis service runs its stingray +# call inside collect_warnings, and every route dispatches through +# asyncio.to_thread onto the default multi-worker executor, so two in-flight +# requests really do execute these blocks concurrently. Without serialization +# that produces two verified failure modes: +# +# 1. Misattribution - if block B enters while block A is still open, A's +# warnings are recorded into B's sink and A's sink comes back empty. A +# stingray advisory about one request's data is then rendered in another +# request's warning Alert, and dropped from the response it belongs to. +# 2. Permanent global corruption - with a non-LIFO interleave (A enters, B +# enters, A exits, B exits), B's __exit__ restores the _showwarnmsg_impl it +# captured on entry, which is A's now-orphaned record list. Every warning +# raised anywhere in the process afterwards is silently appended to that +# dead list instead of reaching stderr/the server log, for the lifetime of +# the backend. +# +# An RLock (not a plain Lock) so that a future nested collect_warnings on the +# same thread cannot deadlock; nesting within a single thread is already safe +# because catch_warnings restores in LIFO order there. +_CAPTURE_LOCK = None if CONTEXT_AWARE_WARNINGS else _threading.RLock() + + +def finite_list(arr) -> list: + """Convert a float array to a list, replacing non-finite values with None.""" + values = np.asarray(arr, dtype=float) + if np.isfinite(values).all(): + return values.tolist() + return [float(v) if np.isfinite(v) else None for v in values] + + +def segment_size_error(segment_size: float, dt: float) -> Optional[str]: + """Human-readable rejection for segment sizes that stingray fails on cryptically. + + Needs at least 3 time bins per segment to produce a non-empty spectrum. + """ + if segment_size / dt < 3: + return ( + f"segment_size ({segment_size}s) must be at least 3x dt ({dt}s) " + "to produce a non-empty spectrum" + ) + return None + + +def overlap_error( + events1, events2, segment_size: Optional[float] = None +) -> Optional[str]: + """Readable rejection when two event lists share no time overlap. + + Optional segment_size check: if provided and the overlap is shorter than + one segment, stingray will produce zero segments (cryptic error), so we + reject early with a human-readable message. + """ + if len(events1.time) == 0 or len(events2.time) == 0: + return "one of the event lists contains no events" + # min/max rather than time[0]/time[-1]: event times are not guaranteed to be + # sorted (stingray only sorts when skip_checks is False, and EventList.read + # bypasses that path entirely), and an unsorted list would otherwise report a + # sliver of its true span - falsely rejecting two simultaneous observations. + first1, last1 = float(np.min(events1.time)), float(np.max(events1.time)) + first2, last2 = float(np.min(events2.time)), float(np.max(events2.time)) + start = max(first1, first2) + stop = min(last1, last2) + if stop <= start: + return ( + "the two event lists have no overlapping time range " + f"({first1:.1f}-{last1:.1f}s vs " + f"{first2:.1f}-{last2:.1f}s)" + ) + if segment_size is not None and (stop - start) < segment_size: + return ( + f"the overlapping time range ({stop - start:.1f}s) is shorter than " + f"the segment size ({segment_size}s)" + ) + return None + + +@contextmanager +def collect_warnings(sink: List[str]) -> Iterator[None]: + """Capture warnings raised inside the block into `sink` (deduplicated). + + Lets services surface stingray's low-count / NaN advisories to the UI + instead of losing them to the server log. + + Thread safety: catch_warnings is only safe to run concurrently when the + interpreter has context-aware warnings enabled (see CONTEXT_AWARE_WARNINGS + above). When it does not, the capture is serialized on _CAPTURE_LOCK, which + is held for the whole block so that entry and exit are strictly LIFO across + threads. That costs concurrency on the analysis call inside, but it is the + only way to stop concurrent requests from stealing each other's warnings and + from permanently corrupting the process-global warning hooks. + """ + if _CAPTURE_LOCK is None: + with _capture_warnings(sink): + yield + else: + with _CAPTURE_LOCK: + with _capture_warnings(sink): + yield + + +@contextmanager +def _capture_warnings(sink: List[str]) -> Iterator[None]: + """The raw catch_warnings capture, without any locking.""" + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + yield + for w in caught: + text = str(w.message) + if text not in sink: + sink.append(text) diff --git a/python-backend/services/archive_service.py b/python-backend/services/archive_service.py new file mode 100644 index 0000000..2a08cee --- /dev/null +++ b/python-backend/services/archive_service.py @@ -0,0 +1,2138 @@ +""" +Archive service for HEASARC catalog queries and data downloads. + +Provides functionality to search NASA's HEASARC archive for X-ray observations +and download data with progress tracking. +""" + +import asyncio +import hashlib +import inspect +import io +import math +import os +import queue +import re +import threading +import time +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from contextlib import ExitStack, contextmanager +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote, unquote_to_bytes, urlsplit + +from astropy import units as u +from astropy.coordinates import SkyCoord + +from .base_service import BaseService +from .remote_source import ( + CDS_SESAME_POLICY, + HEASARC_TAP_POLICY, + HEASARC_ARCHIVE_POLICY, + RemoteSourceCancelled, + RemoteSourceClient, + RemoteSourceError, + RemoteSourceHTTPError, + RemoteSourcePolicyError, + RemoteSourceTimeout, + RemoteTimeouts, +) +from .secure_publication import SecurePublication, open_secure_publication + +MAX_ARCHIVE_DOWNLOAD_BYTES = 20 * 1024**3 +MAX_CONCURRENT_ARCHIVE_DOWNLOADS = 2 +MAX_AGGREGATE_ARCHIVE_DOWNLOAD_BYTES = ( + MAX_CONCURRENT_ARCHIVE_DOWNLOADS * MAX_ARCHIVE_DOWNLOAD_BYTES +) +ARCHIVE_DOWNLOAD_CHUNK_BYTES = 256 * 1024 +ARCHIVE_DOWNLOAD_TIMEOUTS = RemoteTimeouts( + connect=10.0, + read=60.0, + write=10.0, + pool=5.0, + total=3600.0, +) +ARCHIVE_STAGING_WARNING = ( + "Download completed, but private staging cleanup could not be confirmed." +) +ARCHIVE_VERIFICATION_CANCEL_POLL_SECONDS = 0.05 +ARCHIVE_WRITER_QUEUE_CHUNKS = 2 +MAX_ARCHIVE_DIRECTORY_HTML_BYTES = 2 * 1024**2 +MAX_ARCHIVE_DIRECTORY_ENTRIES = 1_000 +MAX_ARCHIVE_CRAWL_ENTRIES = 5_000 +MAX_ARCHIVE_CRAWL_DIRECTORIES = 64 +MAX_ARCHIVE_CRAWL_DEPTH = 3 +MAX_ARCHIVE_ENTRY_NAME_CHARS = 255 +MAX_ARCHIVE_ENTRY_HREF_CHARS = 1_024 +ARCHIVE_CRAWL_TOTAL_SECONDS = 120.0 +ARCHIVE_CRAWL_HOP_SECONDS = 30.0 +MAX_ARCHIVE_SEARCH_REMOTE_ROWS = 5_000 +MAX_ARCHIVE_OBSID_REMOTE_ROWS = 10 +ARCHIVE_SEARCH_CONNECT_TIMEOUT_SECONDS = 10.0 +ARCHIVE_SEARCH_READ_TIMEOUT_SECONDS = 30.0 +ARCHIVE_SEARCH_TOTAL_SECONDS = 30.0 +MAX_ARCHIVE_SEARCH_REQUEST_BYTES = 64 * 1024 +MAX_ARCHIVE_SEARCH_RESPONSE_BYTES = 32 * 1024**2 +CDS_SESAME_URL = "https://cds.unistra.fr/cgi-bin/nph-sesame/SNV" +HEASARC_TAP_URL = "https://heasarc.gsfc.nasa.gov/xamin/vo/tap/sync" +_ALLOWED_TAP_MEDIA_TYPES = frozenset( + {"text/xml", "application/xml", "application/x-votable+xml"} +) +_ALLOWED_SESAME_MEDIA_TYPES = frozenset({"text/plain"}) +ARCHIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z") +ARCHIVE_PROPOSAL = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z") +CancellationCheck = Callable[[], bool | None | Awaitable[bool | None]] + + +@dataclass +class ArchiveSearchBudget: + """One monotonic deadline shared by name resolution and TAP.""" + + deadline: float + + @classmethod + def start(cls) -> "ArchiveSearchBudget": + return cls(time.monotonic() + ARCHIVE_SEARCH_TOTAL_SECONDS) + + def remaining(self) -> float: + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise RemoteSourceTimeout("The archive search timed out") + return remaining + + +def _search_timeouts(budget: ArchiveSearchBudget) -> RemoteTimeouts: + remaining = budget.remaining() + return RemoteTimeouts( + connect=min(ARCHIVE_SEARCH_CONNECT_TIMEOUT_SECONDS, remaining), + read=min(ARCHIVE_SEARCH_READ_TIMEOUT_SECONDS, remaining), + write=min(ARCHIVE_SEARCH_CONNECT_TIMEOUT_SECONDS, remaining), + pool=min(5.0, remaining), + total=remaining, + ) + + +class ArchiveDownloadBusyError(RuntimeError): + """The exact destination is already owned by an active download.""" + + +class ArchiveDownloadCapacityError(RuntimeError): + """The process-wide bounded archive-download capacity is occupied.""" + + +class ArchiveArtifactWriter: + """Own the staging writer and its flush/fsync lifecycle off the event loop.""" + + _SENTINEL = object() + + def __init__(self, publication: SecurePublication) -> None: + self._publication = publication + self._queue: queue.Queue[bytes | object] = queue.Queue( + maxsize=ARCHIVE_WRITER_QUEUE_CHUNKS + ) + self._cancel = threading.Event() + self._done = threading.Event() + self._exception: BaseException | None = None + self._thread = threading.Thread( + target=self._run, + name="archive-artifact-writer", + daemon=False, + ) + + def start(self) -> None: + self._thread.start() + + def _run(self) -> None: + try: + with self._publication.open_writer("wb", encoding=None) as writer: + while True: + if self._cancel.is_set(): + raise RemoteSourceCancelled("Archive write was cancelled") + try: + item = self._queue.get(timeout=0.05) + except queue.Empty: + continue + if item is self._SENTINEL: + break + writer.write(item) + if self._cancel.is_set(): + raise RemoteSourceCancelled("Archive write was cancelled") + except BaseException as error: + self._exception = error + finally: + self._done.set() + + def _raise_worker_failure(self) -> None: + error = self._exception + if error is None: + return + if isinstance(error, Exception): + raise error + raise RuntimeError("The archive writer terminated unexpectedly") + + async def _put( + self, + item: bytes | object, + cancellation_check: CancellationCheck | None, + ) -> None: + while True: + if self._done.is_set(): + self._thread.join() + self._raise_worker_failure() + raise RuntimeError("The archive writer stopped unexpectedly") + await ArchiveService._raise_if_download_cancelled(cancellation_check) + try: + self._queue.put_nowait(item) + return + except queue.Full: + await asyncio.sleep(0.01) + + async def write( + self, + chunk: bytes, + cancellation_check: CancellationCheck | None, + ) -> None: + await self._put(chunk, cancellation_check) + + async def finish( + self, + cancellation_check: CancellationCheck | None, + ) -> None: + await self._put(self._SENTINEL, cancellation_check) + while not self._done.is_set(): + await ArchiveService._raise_if_download_cancelled(cancellation_check) + await asyncio.sleep(0.01) + self._thread.join() + self._raise_worker_failure() + + async def abort_and_join(self) -> None: + """Signal cancellation and defer teardown until writer ownership ends.""" + self._cancel.set() + while not self._done.is_set(): + try: + await asyncio.sleep(0.01) + except asyncio.CancelledError: + continue + self._thread.join() + + +class ArchiveDownloadCoordinator: + """Fail-fast process-wide claims for bounded archive download resources.""" + + def __init__(self, maximum_active: int) -> None: + self._maximum_active = maximum_active + self._lock = threading.Lock() + self._active_destinations: set[bytes] = set() + + @contextmanager + def claim(self, destination_key: bytes) -> Generator[None, None, None]: + with self._lock: + if destination_key in self._active_destinations: + raise ArchiveDownloadBusyError + if len(self._active_destinations) >= self._maximum_active: + raise ArchiveDownloadCapacityError + self._active_destinations.add(destination_key) + try: + yield + finally: + with self._lock: + self._active_destinations.discard(destination_key) + + +ARCHIVE_DOWNLOAD_COORDINATOR = ArchiveDownloadCoordinator( + MAX_CONCURRENT_ARCHIVE_DOWNLOADS +) + + +@dataclass +class ArchiveCrawlBudget: + """Shared total-time, directory, and entry budget for one crawl.""" + + deadline: float + directories: int = 0 + entries: int = 0 + + def remaining(self) -> float: + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise RemoteSourceTimeout("The archive directory crawl timed out") + return remaining + + def begin_directory(self) -> None: + self.remaining() + self.directories += 1 + if self.directories > MAX_ARCHIVE_CRAWL_DIRECTORIES: + raise RemoteSourceError("The archive directory crawl is too large") + + def add_entries(self, count: int) -> None: + self.remaining() + if count > MAX_ARCHIVE_DIRECTORY_ENTRIES: + raise RemoteSourceError("An archive directory contains too many entries") + self.entries += count + if self.entries > MAX_ARCHIVE_CRAWL_ENTRIES: + raise RemoteSourceError( + "The archive directory crawl contains too many entries" + ) + + +# Supported HEASARC catalogs for X-ray missions +SUPPORTED_CATALOGS = { + "NICER": { + "catalog": "nicermastr", + "display_name": "NICER", + "description": "Neutron star Interior Composition Explorer", + }, + "NuSTAR": { + "catalog": "numaster", + "display_name": "NuSTAR", + "description": "Nuclear Spectroscopic Telescope Array", + }, + "XMM-Newton": { + "catalog": "xmmmaster", + "display_name": "XMM-Newton", + "description": "X-ray Multi-Mirror Mission", + }, + "Chandra": { + "catalog": "chanmaster", + "display_name": "Chandra", + "description": "Chandra X-ray Observatory", + }, + "Swift": { + "catalog": "swiftmastr", + "display_name": "Swift", + "description": "Neil Gehrels Swift Observatory", + }, + "RXTE": { + "catalog": "xtemaster", + "display_name": "RXTE", + "description": "Rossi X-ray Timing Explorer", + }, + "IXPE": { + "catalog": "ixmaster", + "display_name": "IXPE", + "description": "Imaging X-ray Polarimetry Explorer", + }, + "Suzaku": { + "catalog": "suzamaster", + "display_name": "Suzaku", + "description": "Suzaku X-ray Satellite", + }, + "ASCA": { + "catalog": "ascamaster", + "display_name": "ASCA", + "description": "Advanced Satellite for Cosmology and Astrophysics", + }, + "XRISM": { + "catalog": "xrismmastr", + "display_name": "XRISM", + "description": "X-Ray Imaging and Spectroscopy Mission", + }, + "Hitomi": { + "catalog": "hitomaster", + "display_name": "Hitomi", + "description": "Hitomi (ASTRO-H) X-ray Satellite", + }, +} + + +def _to_python_float(val: Any) -> Optional[float]: + """Convert numpy numeric types to Python float for JSON serialization.""" + if val is None: + return None + try: + return float(val) + except (TypeError, ValueError): + return None + + +def _to_python_int(val: Any) -> Optional[int]: + """Convert numpy numeric types to Python int for JSON serialization.""" + if val is None: + return None + try: + return int(val) + except (TypeError, ValueError): + return None + + +class ArchiveService(BaseService): + """ + Service for HEASARC archive operations. + + Handles catalog queries and data downloads without any UI dependencies. + """ + + def get_supported_catalogs(self) -> Dict[str, Any]: + """ + Get list of supported HEASARC catalogs. + + Returns: + Result dictionary with catalog information + """ + catalogs = [ + { + "id": key, + "catalog": info["catalog"], + "display_name": info["display_name"], + "description": info["description"], + } + for key, info in SUPPORTED_CATALOGS.items() + ] + + return self.create_result( + success=True, + data={"catalogs": catalogs}, + message=f"Found {len(catalogs)} supported catalogs", + ) + + def _remote_client(self, policy, budget: ArchiveSearchBudget) -> RemoteSourceClient: + return RemoteSourceClient( + policy, + timeouts=_search_timeouts(budget), + max_redirects=0, + ) + + @staticmethod + def _response_media_type(info: Any, allowed: frozenset[str]) -> bool: + media_type = getattr(info, "content_type", None) + if not isinstance(media_type, str): + return False + return media_type.split(";", 1)[0].strip().lower() in allowed + + def _resolve_source_name( + self, source_name: str, budget: ArchiveSearchBudget | None = None + ) -> Optional[SkyCoord]: + """Resolve a source name through the pinned HTTPS CDS Sesame endpoint.""" + search_budget = budget or ArchiveSearchBudget.start() + try: + quoted_name = quote(source_name, safe="") + body, info = asyncio.run( + self._remote_client(CDS_SESAME_POLICY, search_budget).fetch_text( + f"{CDS_SESAME_URL}?{quoted_name}", + max_bytes=64 * 1024, + ) + ) + search_budget.remaining() + if not self._response_media_type(info, _ALLOWED_SESAME_MEDIA_TYPES): + return None + match = re.search(r"%J\s*([0-9.]+)\s*([+\-.0-9]+)", body) + if match is None: + return None + return SkyCoord( + float(match.group(1)), + float(match.group(2)), + unit=u.deg, + frame="icrs", + ) + except (RemoteSourceError, ValueError, TypeError): + return None + + @staticmethod + def _region_query(catalog: str, coords: SkyCoord, radius: float) -> str: + ra = float(coords.icrs.ra.deg) + dec = float(coords.icrs.dec.deg) + if not math.isfinite(radius) or radius <= 0 or radius > 10: + raise ValueError("Search radius is outside the supported range") + return ( + f"SELECT * FROM {catalog} WHERE CONTAINS(" + f"POINT('ICRS',{ra:.12g},{dec:.12g})," + f"CIRCLE('ICRS',{ra:.12g},{dec:.12g},{radius:.12g}))=1" + ) + + def _query_tap( + self, + adql: str, + maxrec: int, + budget: ArchiveSearchBudget, + ) -> Any | None: + """Fetch and parse one bounded VOTable without network-capable DAL APIs.""" + if not isinstance(maxrec, int) or maxrec <= 0: + raise ValueError("maxrec must be positive") + try: + body, info = asyncio.run( + self._remote_client(HEASARC_TAP_POLICY, budget).post_form_bytes( + HEASARC_TAP_URL, + { + "REQUEST": "doQuery", + "LANG": "ADQL", + "MAXREC": maxrec, + "QUERY": adql, + }, + max_bytes=MAX_ARCHIVE_SEARCH_RESPONSE_BYTES, + max_request_bytes=MAX_ARCHIVE_SEARCH_REQUEST_BYTES, + ) + ) + if not self._response_media_type(info, _ALLOWED_TAP_MEDIA_TYPES): + return None + budget.remaining() + from astropy.io.votable import parse + from pyvo.dal import TAPResults + + votable = parse(io.BytesIO(body)) + budget.remaining() + results = TAPResults(votable, url=HEASARC_TAP_URL) + results.check_overflow_warning(maxrec) + table = results.to_table() + budget.remaining() + if table is None or len(table) > maxrec: + return None + return table + except Exception: + return None + + def _table_to_observations( + self, table: Any, catalog_name: str + ) -> List[Dict[str, Any]]: + """ + Convert astropy Table from HEASARC query to list of observation dicts. + + Args: + table: Astropy Table from Heasarc.query_region() + catalog_name: Name of the HEASARC catalog + + Returns: + List of observation dictionaries + """ + observations = [] + + if table is None or len(table) == 0: + return observations + + # Column mapping varies by catalog - try common column names + # HEASARC returns lowercase column names from astroquery + obsid_cols = [ + "obsid", + "obs_id", + "observation_id", + "seq_num", + "sequence_number", + "OBSID", + "OBS_ID", + "OBSERVATION_ID", + "SEQ_NUM", + "SEQUENCE_NUMBER", + ] + name_cols = [ + "name", + "target_name", + "object", + "src_name", + "NAME", + "TARGET_NAME", + "OBJECT", + "SRC_NAME", + ] + ra_cols = ["ra", "ra_obj", "ra_pnt", "RA", "RA_OBJ", "RA_PNT"] + dec_cols = ["dec", "dec_obj", "dec_pnt", "DEC", "DEC_OBJ", "DEC_PNT"] + # Mission-specific exposure columns: + # - NICER, Chandra, RXTE: "exposure" + # - NuSTAR: "exposure_a" (FPMA), also has exposure_b (FPMB) + # - XMM-Newton: "duration" + # - Swift: "xrt_exposure", "uvot_exposure", "bat_exposure" + # Swift catalog has NO generic "exposure" column, so we must + # prioritize instrument-specific columns for Swift. + if catalog_name == "Swift": + # For Swift, prefer xrt_exposure first (X-ray timing), then + # fall back to bat_exposure (BAT-only triggers have 0 XRT exposure) + exposure_cols = [ + "xrt_exposure", + "XRT_EXPOSURE", + "bat_exposure", + "BAT_EXPOSURE", + "uvot_exposure", + "UVOT_EXPOSURE", + "exposure", + "duration", + "ontime", + "livetime", + "EXPOSURE", + "DURATION", + "ONTIME", + "LIVETIME", + ] + elif catalog_name == "IXPE": + # IXPE has per-detector-unit exposures: exposure_1, exposure_2, exposure_3 + # The main "exposure" column also exists + exposure_cols = [ + "exposure", + "exposure_1", + "exposure_2", + "exposure_3", + "ontime_1", + "ontime_2", + "ontime_3", + "EXPOSURE", + "EXPOSURE_1", + "EXPOSURE_2", + "EXPOSURE_3", + ] + else: + exposure_cols = [ + "exposure", + "exposure_a", + "duration", + "ontime", + "livetime", + "good_time", + "xrt_exposure", + "EXPOSURE", + "EXPOSURE_A", + "DURATION", + "ONTIME", + "LIVETIME", + "GOOD_TIME", + "XRT_EXPOSURE", + ] + time_cols = [ + "time", + "start_time", + "date_obs", + "tstart", + "TIME", + "START_TIME", + "DATE_OBS", + "TSTART", + ] + + # Get table column names once + table_cols = set(table.colnames) + + def get_column_value( + row: Any, col_names: List[str], default: Any = None + ) -> Any: + """Get value from first matching column.""" + for col in col_names: + if col in table_cols: + val = row[col] + # Handle masked arrays + if hasattr(val, "mask") and val.mask: + continue + return val + return default + + # RXTE-specific columns + prnb_cols = ["prnb", "PRNB"] + + # NICER-specific columns + nicer_status_cols = ["processing_status", "PROCESSING_STATUS"] + nicer_fpm_cols = ["num_fpm", "NUM_FPM"] + + for row in table: + try: + obs = { + "obsid": str(get_column_value(row, obsid_cols, "")), + "name": str(get_column_value(row, name_cols, "Unknown")), + "ra": _to_python_float(get_column_value(row, ra_cols)), + "dec": _to_python_float(get_column_value(row, dec_cols)), + "exposure": _to_python_float( + get_column_value(row, exposure_cols, 0) + ), + "time": str(get_column_value(row, time_cols, "")), + "catalog": catalog_name, + } + + # Add mission-specific fields + # Swift: Include per-instrument exposures and pick the best + # non-zero exposure for the main "exposure" field + if catalog_name == "Swift": + swift_xrt_cols = ["xrt_exposure", "XRT_EXPOSURE"] + swift_bat_cols = ["bat_exposure", "BAT_EXPOSURE"] + swift_uvot_cols = ["uvot_exposure", "UVOT_EXPOSURE"] + xrt_exp = _to_python_float(get_column_value(row, swift_xrt_cols)) + bat_exp = _to_python_float(get_column_value(row, swift_bat_cols)) + uvot_exp = _to_python_float(get_column_value(row, swift_uvot_cols)) + obs["xrt_exposure"] = xrt_exp + obs["bat_exposure"] = bat_exp + obs["uvot_exposure"] = uvot_exp + # Use the best non-zero instrument exposure as the main + # exposure value (prefer XRT > BAT > UVOT) + if not obs["exposure"] or obs["exposure"] == 0: + for inst_exp in [xrt_exp, bat_exp, uvot_exp]: + if inst_exp and inst_exp > 0: + obs["exposure"] = inst_exp + break + + # IXPE: Include per-detector-unit exposures + if catalog_name == "IXPE": + ixpe_du1_cols = ["exposure_1", "EXPOSURE_1"] + ixpe_du2_cols = ["exposure_2", "EXPOSURE_2"] + ixpe_du3_cols = ["exposure_3", "EXPOSURE_3"] + obs["exposure_du1"] = _to_python_float( + get_column_value(row, ixpe_du1_cols) + ) + obs["exposure_du2"] = _to_python_float( + get_column_value(row, ixpe_du2_cols) + ) + obs["exposure_du3"] = _to_python_float( + get_column_value(row, ixpe_du3_cols) + ) + + # NICER: Include processing status and number of FPMs + if catalog_name == "NICER": + obs["processing_status"] = str( + get_column_value(row, nicer_status_cols, "") + ) + obs["num_fpm"] = _to_python_int( + get_column_value(row, nicer_fpm_cols) + ) + + # NuSTAR: Include FPMB exposure, observation mode, issue flag + if catalog_name == "NuSTAR": + exp_b_cols = ["exposure_b", "EXPOSURE_B"] + obs_mode_cols = ["observation_mode", "OBSERVATION_MODE"] + issue_cols = ["issue_flag", "ISSUE_FLAG"] + obs["exposure_b"] = _to_python_float( + get_column_value(row, exp_b_cols) + ) + obs["observation_mode"] = str( + get_column_value(row, obs_mode_cols, "") + ) + obs["issue_flag"] = _to_python_int( + get_column_value(row, issue_cols) + ) + + # XMM-Newton: Include per-instrument exposures, modes, and status + # query_region() returns: status, data_in_heasarc (always available) + # ADQL/TAP returns: pn_time, pn_mode, mos1_time, mos1_mode, + # mos2_time, mos2_mode (only via ObsID search) + if catalog_name == "XMM-Newton": + pn_time_cols = ["pn_time", "PN_TIME"] + pn_mode_cols = ["pn_mode", "PN_MODE"] + mos1_time_cols = ["mos1_time", "MOS1_TIME"] + mos1_mode_cols = ["mos1_mode", "MOS1_MODE"] + mos2_time_cols = ["mos2_time", "MOS2_TIME"] + mos2_mode_cols = ["mos2_mode", "MOS2_MODE"] + status_cols = ["status", "STATUS"] + data_avail_cols = ["data_in_heasarc", "DATA_IN_HEASARC"] + obs["pn_time"] = _to_python_float( + get_column_value(row, pn_time_cols) + ) + obs["pn_mode"] = str(get_column_value(row, pn_mode_cols, "")) + obs["mos1_time"] = _to_python_float( + get_column_value(row, mos1_time_cols) + ) + obs["mos1_mode"] = str(get_column_value(row, mos1_mode_cols, "")) + obs["mos2_time"] = _to_python_float( + get_column_value(row, mos2_time_cols) + ) + obs["mos2_mode"] = str(get_column_value(row, mos2_mode_cols, "")) + obs["xmm_status"] = str(get_column_value(row, status_cols, "")) + obs["data_in_heasarc"] = str( + get_column_value(row, data_avail_cols, "") + ) + + # Chandra: Include detector, grating, status + if catalog_name == "Chandra": + detector_cols = ["detector", "DETECTOR"] + grating_cols = ["grating", "GRATING"] + chandra_status_cols = ["status", "STATUS"] + obs["detector"] = str(get_column_value(row, detector_cols, "")) + obs["grating"] = str(get_column_value(row, grating_cols, "")) + obs["chandra_status"] = str( + get_column_value(row, chandra_status_cols, "") + ) + + # RXTE: Include proposal number for directory lookup + prnb = get_column_value(row, prnb_cols) + if prnb is not None: + obs["prnb"] = str(prnb) + + # Only include observations with valid obsid + if obs["obsid"]: + observations.append(obs) + except Exception: + # Skip malformed rows + continue + + return observations + + def _apply_table_filters( + self, + table: Any, + min_exposure: Optional[float] = None, + time_range: Optional[Tuple[float, float]] = None, + ) -> Any: + """ + Apply post-query filters to an astropy Table. + + Args: + table: Astropy Table from HEASARC query + min_exposure: Minimum exposure time in seconds + time_range: Tuple of (mjd_start, mjd_end) for date filtering + + Returns: + Filtered astropy Table + """ + if table is None or len(table) == 0: + return table + + if min_exposure is not None: + exposure_col = None + for col in [ + "exposure", + "exposure_a", + "duration", + "ontime", + "xrt_exposure", + "bat_exposure", + ]: + if col in table.colnames: + exposure_col = col + break + if exposure_col is not None: + try: + table = table[table[exposure_col] >= min_exposure] + except Exception: + pass + + if time_range is not None: + time_col = None + for col in ["time", "start_time", "date_obs", "tstart"]: + if col in table.colnames: + time_col = col + break + if time_col is not None: + try: + mjd_start, mjd_end = time_range + table = table[ + (table[time_col] >= mjd_start) & (table[time_col] <= mjd_end) + ] + except Exception: + pass + + return table + + def search_by_name( + self, + source_name: str, + mission: str, + radius: float = 0.5, + max_results: int = 100, + min_exposure: Optional[float] = None, + time_range: Optional[Tuple[float, float]] = None, + ) -> Dict[str, Any]: + """ + Search HEASARC for observations by source name. + + Uses SIMBAD/NED to resolve the source name to coordinates, + then queries the appropriate HEASARC catalog. + + Args: + source_name: Astronomical source name (e.g., "Crab", "Cyg X-1") + mission: Mission key (e.g., "NICER", "NuSTAR") + radius: Search radius in degrees + max_results: Maximum number of results to return + min_exposure: Minimum exposure time in seconds (post-query filter) + time_range: Tuple of (mjd_start, mjd_end) for date filtering + + Returns: + Result dictionary with observations + """ + try: + # Validate mission + if mission not in SUPPORTED_CATALOGS: + return self.create_result( + success=False, + data=None, + message=f"Unsupported mission: {mission}", + error=f"Supported missions: {list(SUPPORTED_CATALOGS.keys())}", + ) + + catalog_info = SUPPORTED_CATALOGS[mission] + catalog_name = catalog_info["catalog"] + + # Resolve source name to coordinates + budget = ArchiveSearchBudget.start() + coords = self._resolve_source_name(source_name, budget) + if coords is None: + return self.create_result( + success=False, + data=None, + message=f"Could not resolve source name: '{source_name}'", + error="Name resolution failed via SIMBAD/NED", + ) + + table = self._query_tap( + self._region_query(catalog_name, coords, radius), + MAX_ARCHIVE_SEARCH_REMOTE_ROWS, + budget, + ) + if table is None: + return self.create_result( + success=False, + data=None, + message="The archive search could not be completed safely", + error="Archive search response was invalid or unavailable", + ) + + # Apply post-query filters + table = self._apply_table_filters(table, min_exposure, time_range) + + # Convert to observations + observations = self._table_to_observations(table, mission) + + # Limit results + if len(observations) > max_results: + observations = observations[:max_results] + + return self.create_result( + success=True, + data={ + "observations": observations, + "count": len(observations), + "source_name": source_name, + "resolved_ra": _to_python_float(coords.ra.deg), + "resolved_dec": _to_python_float(coords.dec.deg), + "mission": mission, + "radius": radius, + }, + message=f"Found {len(observations)} observations for '{source_name}' in {mission}", + ) + + except Exception as e: + return self.handle_error( + e, + "Searching HEASARC by name", + source_name=source_name, + mission=mission, + ) + + def search_by_coordinates( + self, + ra: float, + dec: float, + mission: str, + radius: float = 0.5, + max_results: int = 100, + min_exposure: Optional[float] = None, + time_range: Optional[Tuple[float, float]] = None, + ) -> Dict[str, Any]: + """ + Search HEASARC for observations by coordinates. + + Args: + ra: Right Ascension in degrees + dec: Declination in degrees + mission: Mission key (e.g., "NICER", "NuSTAR") + radius: Search radius in degrees + max_results: Maximum number of results to return + min_exposure: Minimum exposure time in seconds (post-query filter) + time_range: Tuple of (mjd_start, mjd_end) for date filtering + + Returns: + Result dictionary with observations + """ + try: + # Validate mission + if mission not in SUPPORTED_CATALOGS: + return self.create_result( + success=False, + data=None, + message=f"Unsupported mission: {mission}", + error=f"Supported missions: {list(SUPPORTED_CATALOGS.keys())}", + ) + + catalog_info = SUPPORTED_CATALOGS[mission] + catalog_name = catalog_info["catalog"] + + # Create coordinates + coords = SkyCoord(ra=ra * u.deg, dec=dec * u.deg) + + budget = ArchiveSearchBudget.start() + table = self._query_tap( + self._region_query(catalog_name, coords, radius), + MAX_ARCHIVE_SEARCH_REMOTE_ROWS, + budget, + ) + if table is None: + return self.create_result( + success=False, + data=None, + message="The archive search could not be completed safely", + error="Archive search response was invalid or unavailable", + ) + + # Apply post-query filters + table = self._apply_table_filters(table, min_exposure, time_range) + + # Convert to observations + observations = self._table_to_observations(table, mission) + + # Limit results + if len(observations) > max_results: + observations = observations[:max_results] + + return self.create_result( + success=True, + data={ + "observations": observations, + "count": len(observations), + "ra": ra, + "dec": dec, + "mission": mission, + "radius": radius, + }, + message=f"Found {len(observations)} observations at RA={ra:.4f}, Dec={dec:.4f} in {mission}", + ) + + except Exception as e: + return self.handle_error( + e, + "Searching HEASARC by coordinates", + ra=ra, + dec=dec, + mission=mission, + ) + + def search_by_obsid( + self, + obsid: str, + mission: str, + ) -> Dict[str, Any]: + """ + Search HEASARC for an observation by its ObsID using ADQL TAP query. + + This does not require coordinates — it directly queries the catalog + by observation ID. + + Args: + obsid: Observation ID to search for + mission: Mission key (e.g., "NICER", "NuSTAR") + + Returns: + Result dictionary with observations + """ + try: + # Validate mission + if mission not in SUPPORTED_CATALOGS: + return self.create_result( + success=False, + data=None, + message=f"Unsupported mission: {mission}", + error=f"Supported missions: {list(SUPPORTED_CATALOGS.keys())}", + ) + + if not obsid or not obsid.strip(): + return self.create_result( + success=False, + data=None, + message="Please enter an Observation ID", + error="Empty obsid", + ) + + catalog_info = SUPPORTED_CATALOGS[mission] + catalog_name = catalog_info["catalog"] + + # Sanitize obsid for ADQL + safe_obsid = obsid.strip().replace("'", "''") + adql = f"SELECT * FROM {catalog_name} WHERE obsid = '{safe_obsid}'" + + budget = ArchiveSearchBudget.start() + table = self._query_tap(adql, MAX_ARCHIVE_OBSID_REMOTE_ROWS, budget) + if table is None: + return self.create_result( + success=False, + data=None, + message="The archive search could not be completed safely", + error="Archive search response was invalid or unavailable", + ) + + if table is None or len(table) == 0: + return self.create_result( + success=True, + data={ + "observations": [], + "count": 0, + "obsid": obsid, + "mission": mission, + }, + message=f"No observations found for ObsID '{obsid}' in {mission}", + ) + + # Convert to observations + observations = self._table_to_observations(table, mission) + + return self.create_result( + success=True, + data={ + "observations": observations, + "count": len(observations), + "obsid": obsid, + "mission": mission, + }, + message=f"Found {len(observations)} observation(s) for ObsID '{obsid}' in {mission}", + ) + + except Exception as e: + return self.handle_error( + e, + "Searching HEASARC by ObsID", + obsid=obsid, + mission=mission, + ) + + def get_observation_download_urls( + self, + mission: str, + obsid: str, + ) -> Dict[str, Any]: + """ + Get download URLs for an observation. + + Constructs browse URLs based on known HEASARC patterns. + + Args: + mission: Mission key (e.g., "NICER", "NuSTAR") + obsid: Observation ID + + Returns: + Result dictionary with download URLs + """ + try: + # Validate mission + if mission not in SUPPORTED_CATALOGS: + return self.create_result( + success=False, + data=None, + message=f"Unsupported mission: {mission}", + error=f"Supported missions: {list(SUPPORTED_CATALOGS.keys())}", + ) + + # Construct URLs based on known HEASARC patterns + urls = self._construct_download_urls(mission, obsid) + + return self.create_result( + success=True, + data={ + "urls": urls, + "mission": mission, + "obsid": obsid, + }, + message=f"Found download URLs for {mission} observation {obsid}", + ) + + except Exception as e: + return self.handle_error( + e, + "Getting download URLs", + mission=mission, + obsid=obsid, + ) + + def _construct_download_urls(self, mission: str, obsid: str) -> Dict[str, str]: + """ + Construct download URLs based on mission-specific patterns. + + HEASARC has standard URL patterns for each mission's data archive. + + Args: + mission: Mission key + obsid: Observation ID + + Returns: + Dict with url types as keys and URLs as values + """ + urls = {} + + if mission == "NICER": + # NICER data path: /nicer/data/obs/YYYY_MM/OBSID/ + # We can't know the exact date folder without more info + # But we can construct a search URL + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dnicermastr&obsid={obsid}" + ) + + elif mission == "NuSTAR": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dnumaster&obsid={obsid}" + ) + + elif mission == "XMM-Newton": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dxmmmaster&obsid={obsid}" + ) + + elif mission == "Chandra": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dchanmaster&obsid={obsid}" + ) + + elif mission == "Swift": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dswiftmastr&obsid={obsid}" + ) + + elif mission == "RXTE": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dxtemaster&obsid={obsid}" + ) + + elif mission == "IXPE": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dixmaster&obsid={obsid}" + ) + + elif mission == "Suzaku": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dsuzamaster&obsid={obsid}" + ) + + elif mission == "ASCA": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dascamaster&obsid={obsid}" + ) + + elif mission == "XRISM": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dxrismmastr&obsid={obsid}" + ) + + elif mission == "Hitomi": + urls["browse"] = ( + f"https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3browse.pl?tablehead=name%3Dhitomaster&obsid={obsid}" + ) + + return urls + + async def _list_directory_with_metadata( + self, + directory_url: str, + budget: ArchiveCrawlBudget, + cancellation_check: CancellationCheck | None, + ) -> List[Dict[str, Any]]: + """Fetch and parse one bounded HEASARC directory listing.""" + budget.begin_directory() + hop_seconds = min(ARCHIVE_CRAWL_HOP_SECONDS, budget.remaining()) + client = RemoteSourceClient( + HEASARC_ARCHIVE_POLICY, + timeouts=RemoteTimeouts( + connect=min(10.0, hop_seconds), + read=min(10.0, hop_seconds), + write=min(10.0, hop_seconds), + pool=min(5.0, hop_seconds), + total=hop_seconds, + ), + max_redirects=3, + chunk_size=64 * 1024, + ) + body, info = await client.fetch_bytes( + directory_url, + max_bytes=MAX_ARCHIVE_DIRECTORY_HTML_BYTES, + cancellation_check=cancellation_check, + ) + if info.status_code != 200: + raise RemoteSourceError( + "The archive server did not return a complete directory listing" + ) + if info.content_type is not None: + media_type = info.content_type.split(";", 1)[0].strip().lower() + if media_type not in { + "text/html", + "text/plain", + "application/xhtml+xml", + }: + raise RemoteSourceError( + "The archive server returned an unexpected directory format" + ) + try: + html = body.decode("utf-8", errors="strict") + except UnicodeDecodeError as error: + raise RemoteSourceError( + "The archive directory listing is not valid UTF-8" + ) from error + + await self._raise_if_download_cancelled(cancellation_check) + entries = await asyncio.to_thread(self._parse_archive_directory_html, html) + await self._raise_if_download_cancelled(cancellation_check) + budget.add_entries(len(entries)) + return entries + + @staticmethod + def _decode_archive_entry_href(href: Any) -> tuple[str, bool] | None: + """Accept one canonical relative UTF-8 path segment from listing HTML.""" + if not isinstance(href, str) or not href: + return None + if len(href) > MAX_ARCHIVE_ENTRY_HREF_CHARS: + raise RemoteSourceError("An archive directory entry is too long") + if href.startswith("?") or href.startswith("#") or href in {".", "./", "../"}: + return None + + try: + parts = urlsplit(href) + except ValueError as error: + raise RemoteSourceError("An archive directory entry is invalid") from error + if parts.scheme or parts.netloc: + raise RemoteSourceError("An archive directory entry is not relative") + if parts.query or parts.fragment: + raise RemoteSourceError("An archive directory entry has metadata") + if parts.path.startswith("/"): + # Apache listings can contain root navigation or icon links. They are + # not children of the observation and must never become crawl hops. + return None + + is_directory = parts.path.endswith("/") + encoded_name = parts.path[:-1] if is_directory else parts.path + if not encoded_name: + return None + percent_index = 0 + while True: + percent_index = encoded_name.find("%", percent_index) + if percent_index < 0: + break + if percent_index + 2 >= len(encoded_name) or not all( + character in "0123456789abcdefABCDEF" + for character in encoded_name[percent_index + 1 : percent_index + 3] + ): + raise RemoteSourceError( + "An archive directory entry has invalid percent encoding" + ) + percent_index += 3 + try: + name = unquote_to_bytes(encoded_name).decode("utf-8", errors="strict") + except UnicodeDecodeError as error: + raise RemoteSourceError( + "An archive directory entry is not valid UTF-8" + ) from error + if ( + not name + or name != name.strip() + or len(name) > MAX_ARCHIVE_ENTRY_NAME_CHARS + or name in {".", ".."} + or "%" in name + or "/" in name + or "\\" in name + or any( + ord(character) < 0x20 or 0x7F <= ord(character) <= 0x9F + for character in name + ) + ): + raise RemoteSourceError("An archive directory entry is unsafe") + return name, is_directory + + def _parse_archive_directory_html(self, html: str) -> List[Dict[str, Any]]: + """Parse a byte-bounded listing without accepting arbitrary URL targets.""" + from bs4 import BeautifulSoup, NavigableString + + soup = BeautifulSoup(html, "lxml") + links = soup.find_all("a", limit=MAX_ARCHIVE_DIRECTORY_ENTRIES + 1) + if len(links) > MAX_ARCHIVE_DIRECTORY_ENTRIES: + raise RemoteSourceError("An archive directory contains too many entries") + + entries: List[Dict[str, Any]] = [] + seen: set[str] = set() + for link in links: + decoded = self._decode_archive_entry_href(link.get("href")) + if decoded is None: + continue + name, is_directory = decoded + if name in seen: + continue + seen.add(name) + + size_bytes = None + sibling = link.next_sibling + if not is_directory and isinstance(sibling, NavigableString): + line_tail = str(sibling).splitlines()[0][:256] + parts = line_tail.split() + if parts: + size_bytes = self._parse_size(parts[-1]) + entries.append( + { + "name": name, + "is_directory": is_directory, + "size_bytes": size_bytes, + } + ) + return entries + + def _parse_size(self, size_str: str) -> Optional[int]: + """ + Parse size string like '125M', '45K', '1.2G', '12345' to bytes. + + Args: + size_str: Size string to parse + + Returns: + Size in bytes or None if not a valid size + """ + size_str = size_str.strip() + if not size_str: + return None + + suffixes = { + "K": 1024, + "M": 1024 * 1024, + "G": 1024 * 1024 * 1024, + "T": 1024 * 1024 * 1024 * 1024, + } + match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)([KMGT]?)", size_str.upper()) + if match is None: + return None + try: + value = float(match.group(1)) * suffixes.get(match.group(2), 1) + except (OverflowError, ValueError): + return None + if not math.isfinite(value) or value < 0 or value > (2**63 - 1): + return None + return int(value) + + def _classify_file_type(self, filename: str, mission: str) -> str: + """ + Classify a file into a type category based on its name. + + Args: + filename: File name to classify + mission: Mission name for mission-specific patterns + + Returns: + File type: 'event', 'calibration', 'auxiliary', 'log', 'other' + """ + filename_lower = filename.lower() + + # Strip compression suffixes for pattern matching + for ext in (".gz", ".bz2", ".z", ".zip"): + if filename_lower.endswith(ext): + filename_lower = filename_lower[: -len(ext)] + break + + # Event file patterns + event_patterns = [ + "_cl.evt", + "_ufa.evt", + "evt.fits", + "_evt2.fits", + "evli", # XMM event list + ] + if any(p in filename_lower for p in event_patterns): + return "event" + + # Calibration patterns + cal_patterns = [ + "_cal", + "response", + ".rmf", + ".arf", + "caldb", + "matrix", + ] + if any(p in filename_lower for p in cal_patterns): + return "calibration" + + # Auxiliary patterns + aux_patterns = [ + ".att", + ".orb", + "mkf", + ".gti", + "_uf.evt", # Unfiltered (not cleaned) + "attitude", + "orbit", + "housekeeping", + "hk", + "_asol", # Chandra aspect solution + "_dtf", # Chandra dead time factor (HRC) + "_bpix", # Chandra bad pixel list + "_fov", # Chandra field of view + ] + if any(p in filename_lower for p in aux_patterns): + return "auxiliary" + + # Log patterns + log_patterns = [".log", "readme", "index.html"] + if any(p in filename_lower for p in log_patterns): + return "log" + + return "other" + + def _format_size(self, size_bytes: Optional[int]) -> str: + """Format size in bytes to human-readable string.""" + if size_bytes is None: + return "Unknown" + + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + else: + return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB" + + async def list_observation_files( + self, + mission: str, + obsid: str, + obs_time: Optional[str] = None, + obs_data: Optional[Dict[str, Any]] = None, + recursive: bool = True, + max_depth: int = 3, + cancellation_check: CancellationCheck | None = None, + ) -> Dict[str, Any]: + """ + List all files in an observation directory. + + Uses _get_observation_directory_url() to get the base URL, then + recursively parses HTML directory listings to build a file tree. + + Args: + mission: Mission key (e.g., "NICER", "NuSTAR") + obsid: Observation ID + obs_time: Observation time (MJD or ISO string) for directory lookup + obs_data: Additional observation data (e.g., prnb for RXTE, ra/dec for coordinate queries) + recursive: Whether to recursively list subdirectories + max_depth: Maximum recursion depth + + Returns: + Result dictionary with file tree structure + """ + try: + clean_obs_data = self._validate_archive_crawl_request( + mission, + obsid, + obs_time, + obs_data, + recursive, + max_depth, + ) + base_url = self._get_observation_directory_url( + mission, + obsid, + obs_time, + clean_obs_data, + ) + if base_url is None: + return self.create_result( + success=False, + data=None, + message="The observation directory cannot be derived safely", + error="Required bounded observation metadata is unavailable", + ) + + budget = ArchiveCrawlBudget( + deadline=time.monotonic() + ARCHIVE_CRAWL_TOTAL_SECONDS + ) + files = await self._list_files_recursive( + base_url, + mission, + recursive, + max_depth, + 0, + budget, + cancellation_check, + ) + + return self.create_result( + success=True, + data={ + "base_url": base_url, + "files": files, + "mission": mission, + "obsid": obsid, + "total_files": self._count_files(files), + }, + message=f"Found {self._count_files(files)} files in {mission} observation {obsid}", + ) + except RemoteSourceCancelled: + return self.create_result( + success=False, + data=None, + message="Archive directory listing cancelled", + error="The request was cancelled before the listing completed", + ) + except RemoteSourceTimeout: + return self.create_result( + success=False, + data=None, + message="The archive directory listing timed out", + error="The bounded archive crawl deadline expired", + ) + except (RemoteSourceError, TypeError, ValueError): + return self.create_result( + success=False, + data=None, + message="The archive directory listing failed validation", + error="The archive crawl was rejected safely", + ) + + @staticmethod + def _validate_archive_crawl_request( + mission: Any, + obsid: Any, + obs_time: Any, + obs_data: Any, + recursive: Any, + max_depth: Any, + ) -> Dict[str, Any]: + """Defend the service boundary even when called without the API model.""" + if mission not in SUPPORTED_CATALOGS: + raise ValueError("Unsupported archive mission") + if not isinstance(obsid, str) or ARCHIVE_IDENTIFIER.fullmatch(obsid) is None: + raise ValueError("Invalid archive observation identifier") + if obs_time is not None and ( + not isinstance(obs_time, str) + or not obs_time + or len(obs_time) > 64 + or any( + ord(character) < 0x20 or ord(character) == 0x7F + for character in obs_time + ) + ): + raise ValueError("Invalid archive observation time") + if not isinstance(recursive, bool): + raise TypeError("Archive recursion must be a boolean") + if ( + not isinstance(max_depth, int) + or isinstance(max_depth, bool) + or not 0 <= max_depth <= MAX_ARCHIVE_CRAWL_DEPTH + ): + raise ValueError("Archive recursion depth is out of range") + if obs_data is None: + return {} + if not isinstance(obs_data, dict) or not set(obs_data) <= {"ra", "dec", "prnb"}: + raise ValueError("Invalid archive observation metadata") + + clean_data: Dict[str, Any] = {} + for key, lower, upper in (("ra", 0.0, 360.0), ("dec", -90.0, 90.0)): + value = obs_data.get(key) + if value is None: + continue + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(float(value)) + or not lower <= float(value) <= upper + ): + raise ValueError("Invalid archive observation coordinates") + clean_data[key] = float(value) + proposal = obs_data.get("prnb") + if proposal is not None: + if ( + not isinstance(proposal, str) + or ARCHIVE_PROPOSAL.fullmatch(proposal) is None + ): + raise ValueError("Invalid archive proposal identifier") + if not proposal.isdigit() or len(proposal) > 6: + raise ValueError("Invalid archive proposal identifier") + clean_data["prnb"] = proposal + return clean_data + + async def _list_files_recursive( + self, + directory_url: str, + mission: str, + recursive: bool, + max_depth: int, + current_depth: int, + budget: ArchiveCrawlBudget, + cancellation_check: CancellationCheck | None, + ) -> List[Dict[str, Any]]: + """ + Recursively list files in a directory. + + Args: + directory_url: URL of the directory to list + mission: Mission name for file classification + recursive: Whether to recurse into subdirectories + max_depth: Maximum recursion depth + current_depth: Current recursion depth + + Returns: + List of file/directory entries + """ + if current_depth > max_depth or current_depth > MAX_ARCHIVE_CRAWL_DEPTH: + raise RemoteSourceError("The archive crawl exceeded its recursion depth") + await self._raise_if_download_cancelled(cancellation_check) + budget.remaining() + entries = await self._list_directory_with_metadata( + directory_url, + budget, + cancellation_check, + ) + result = [] + + for entry in entries: + await self._raise_if_download_cancelled(cancellation_check) + budget.remaining() + name = entry["name"] + is_directory = entry["is_directory"] + size_bytes = entry["size_bytes"] + + encoded_name = quote(name, safe="-._~") + full_url = directory_url.rstrip("/") + "/" + encoded_name + + if is_directory: + children = [] + if recursive and current_depth < max_depth: + children = await self._list_files_recursive( + full_url + "/", + mission, + recursive, + max_depth, + current_depth + 1, + budget, + cancellation_check, + ) + + result.append( + { + "path": name, + "name": name, + "is_directory": True, + "file_type": "directory", + "size_bytes": None, + "size_display": "", + "full_url": full_url + "/", + "children": children, + } + ) + else: + file_type = self._classify_file_type(name, mission) + result.append( + { + "path": name, + "name": name, + "is_directory": False, + "file_type": file_type, + "size_bytes": size_bytes, + "size_display": self._format_size(size_bytes), + "full_url": full_url, + } + ) + + return result + + def _count_files(self, entries: List[Dict[str, Any]]) -> int: + """Count total number of files (not directories) in a tree.""" + count = 0 + for entry in entries: + if entry["is_directory"]: + count += self._count_files(entry.get("children", [])) + else: + count += 1 + return count + + def _get_observation_directory_url( + self, + mission: str, + obsid: str, + obs_time: Optional[str] = None, + obs_data: Optional[Dict[str, Any]] = None, + ) -> Optional[str]: + """ + Construct the likely directory URL for an observation. + + HEASARC organizes data in mission-specific directory structures. + This method constructs the most likely directory URL based on + known patterns. + + Args: + mission: Mission key + obsid: Observation ID + obs_time: Observation time (MJD float as string, or ISO date string) + Required for NICER and Swift to determine the date-based subdirectory + obs_data: Additional observation data (e.g., prnb for RXTE) + + Returns: + Directory URL or None if pattern unknown + """ + from astropy.time import Time + + base_url = "https://heasarc.gsfc.nasa.gov/FTP" + obs_data = obs_data or {} + + # Helper to parse obs_time to YYYY_MM format + def get_year_month(time_str: Optional[str]) -> Optional[str]: + if not time_str: + return None + try: + # Try parsing as MJD first (numeric string) + try: + mjd = float(time_str) + t = Time(mjd, format="mjd") + except ValueError: + # Try as ISO format + t = Time(time_str, format="isot") + return t.datetime.strftime("%Y_%m") + except Exception: + return None + + if mission == "NICER": + # NICER: /nicer/data/obs/YYYY_MM/OBSID/ + year_month = get_year_month(obs_time) + if year_month: + return f"{base_url}/nicer/data/obs/{year_month}/{obsid}/" + return None + + elif mission == "NuSTAR": + # NuSTAR ObsID format: CPPttxxxvvv (11 digits) + # C = source category (1 digit): 1=calibration, 3=ToO, 6=AGN, 8=galactic + # PP = proposal/cycle (2 digits): 00=primary, 01+=extended missions + # HEASARC archive path: /nustar/data/obs/PP/C/OBSID/ + # PP = obsid[1:3] (proposal cycle) + # C = obsid[0] (source category) + # Example: 60002023006 -> /nustar/data/obs/00/6/60002023006/ + if len(obsid) >= 4: + return f"{base_url}/nustar/data/obs/{obsid[1:3]}/{obsid[0]}/{obsid}/" + return None + + elif mission == "Chandra": + # Chandra: /chandra/data/byobsid/X/OBSID/ + # X = LAST digit of obsid + # Example: 758 -> /chandra/data/byobsid/8/758/ + if obsid and obsid[-1].isdigit(): + return f"{base_url}/chandra/data/byobsid/{obsid[-1]}/{obsid}/" + return None + + elif mission == "Swift": + # Swift: /swift/data/obs/YYYY_MM/OBSID/ + year_month = get_year_month(obs_time) + if year_month: + return f"{base_url}/swift/data/obs/{year_month}/{obsid}/" + return None + + elif mission == "XMM-Newton": + # XMM: /xmm/data/rev0/OBSID/ + return f"{base_url}/xmm/data/rev0/{obsid}/" + + elif mission == "RXTE": + # RXTE: /xte/data/archive/AO{cycle}/P{prnb}/{obsid}/ + # prnb is the proposal number from the observation data + # The AO cycle can be estimated from the observation date or prnb + prnb = obs_data.get("prnb") + if prnb: + # Estimate AO cycle from prnb + # RXTE had AO cycles 1-16 (1996-2012) + # prnb format is typically 5 digits, early proposals are lower numbers + try: + prnb_num = int(prnb) + # Rough mapping based on proposal number ranges + # This is an approximation - locate_data is more reliable + if prnb_num < 10000: + ao_cycle = "AO1" + elif prnb_num < 20000: + ao_cycle = "AO2" + elif prnb_num < 30000: + ao_cycle = "AO3" + elif prnb_num < 40000: + ao_cycle = "AO4" + elif prnb_num < 50000: + ao_cycle = "AO5" + elif prnb_num < 60000: + ao_cycle = "AO6" + elif prnb_num < 70000: + ao_cycle = "AO7" + elif prnb_num < 80000: + ao_cycle = "AO8" + elif prnb_num < 90000: + ao_cycle = "AO9" + elif prnb_num < 93000: + ao_cycle = "AO10" + elif prnb_num < 94000: + ao_cycle = "AO11" + elif prnb_num < 95000: + ao_cycle = "AO12" + elif prnb_num < 96000: + ao_cycle = "AO13" + elif prnb_num < 97000: + ao_cycle = "AO14" + elif prnb_num < 98000: + ao_cycle = "AO15" + else: + ao_cycle = "AO16" + # Note: RXTE archive uses /xte/ not /rxte/ in the path + return f"{base_url}/xte/data/archive/{ao_cycle}/P{prnb}/{obsid}/" + except (ValueError, TypeError): + pass + # Cannot construct URL without prnb - fallback to locate_data + return None + + elif mission == "IXPE": + # IXPE: /ixpe/data/obs/NN/OBSID/ + # NN = first 2 digits of obsid (e.g., 02001099 -> /obs/02/02001099/) + if len(obsid) >= 2: + return f"{base_url}/ixpe/data/obs/{obsid[:2]}/{obsid}/" + return None + + elif mission == "Suzaku": + # Suzaku: /suzaku/data/obs/N/OBSID/ + # N = first digit of obsid + if obsid and obsid[0].isdigit(): + return f"{base_url}/suzaku/data/obs/{obsid[0]}/{obsid}/" + return None + + elif mission == "ASCA": + # ASCA: /asca/data/rev2/OBSID/ + # Flat structure, direct obsid directory + return f"{base_url}/asca/data/rev2/{obsid}/" + + elif mission == "XRISM": + # XRISM: /xrism/data/obs/N/OBSID/ + # N = first digit of obsid + if obsid and obsid[0].isdigit(): + return f"{base_url}/xrism/data/obs/{obsid[0]}/{obsid}/" + return None + + elif mission == "Hitomi": + # Hitomi: /hitomi/data/obs/N/OBSID/ + # N = first digit of obsid + if obsid and obsid[0].isdigit(): + return f"{base_url}/hitomi/data/obs/{obsid[0]}/{obsid}/" + return None + + return None + + async def download_file_to_disk( + self, + url: str, + destination_path: str, + destination_grant: str, + cancellation_check: CancellationCheck | None = None, + ) -> AsyncGenerator[dict[str, Any], None]: + """Download one approved HEASARC object to one granted destination. + + Bytes remain private until the complete response has been size-checked, + hashed, reopened, and hashed again. The secure-publication adapter is + solely responsible for exclusive publication and owned cleanup. + """ + try: + completion_event: dict[str, Any] | None = None + with ExitStack() as context_stack: + publication = context_stack.enter_context( + open_secure_publication( + destination_path, + destination_grant, + ) + ) + publication.revalidate("The selected destination path changed") + self._validate_download_filename(publication.filename) + destination_key = hashlib.sha256( + os.fsencode(os.path.normcase(str(publication.path))) + ).digest() + context_stack.enter_context( + ARCHIVE_DOWNLOAD_COORDINATOR.claim(destination_key) + ) + publication.assert_destination_available() + publication.reserve_staging(".download") + + client = RemoteSourceClient( + HEASARC_ARCHIVE_POLICY, + timeouts=ARCHIVE_DOWNLOAD_TIMEOUTS, + max_redirects=5, + chunk_size=ARCHIVE_DOWNLOAD_CHUNK_BYTES, + ) + content_digest = hashlib.sha256() + bytes_downloaded = 0 + content_length: int | None = None + + artifact_writer = ArchiveArtifactWriter(publication) + artifact_writer.start() + try: + async with client.stream( + url, + max_bytes=MAX_ARCHIVE_DOWNLOAD_BYTES, + cancellation_check=cancellation_check, + ) as remote_stream: + if remote_stream.info.status_code != 200: + raise RemoteSourceError( + "The archive server did not return a complete object" + ) + content_length = remote_stream.info.content_length + async for chunk in remote_stream.aiter_bytes(): + await artifact_writer.write( + chunk, + cancellation_check, + ) + content_digest.update(chunk) + bytes_downloaded += len(chunk) + total_bytes = content_length or 0 + percent = ( + min(100.0, bytes_downloaded / total_bytes * 100.0) + if total_bytes + else 0.0 + ) + yield { + "type": "progress", + "bytes_downloaded": bytes_downloaded, + "total_bytes": total_bytes, + "percent": round(percent, 1), + } + await artifact_writer.finish(cancellation_check) + except BaseException: + await artifact_writer.abort_and_join() + raise + + await self._raise_if_download_cancelled(cancellation_check) + if content_length is not None and bytes_downloaded != content_length: + raise RemoteSourceError( + "The remote response did not match its declared size" + ) + if bytes_downloaded < 1: + raise RemoteSourceError("The remote response was empty") + + expected_digest = content_digest.digest() + await self._run_download_verification( + publication, + bytes_downloaded, + expected_digest, + cancellation_check, + ) + await self._raise_if_download_cancelled(cancellation_check) + # Publication is a short, descriptor-relative metadata operation. + # Keep it in this task so cancellation cannot detach it and expose + # a final file after the request has already unwound. + warnings = publication.publish() + + completion_event = { + "type": "complete", + "file_name": publication.filename, + "size_bytes": bytes_downloaded, + "sha256": content_digest.hexdigest(), + "warnings": ([ARCHIVE_STAGING_WARNING] if warnings else []), + } + + # Release the global claim and every pinned publication handle before + # signaling terminal success to a potentially stalled SSE consumer. + if completion_event is None: + raise RuntimeError("Download completion was not constructed") + yield completion_event + + except asyncio.CancelledError: + # StreamingResponse cancellation closes the generator; ExitStack + # removes only the adapter-owned private artifact before propagation. + raise + except RemoteSourceCancelled: + yield {"type": "error", "error": "Download cancelled"} + except ArchiveDownloadBusyError: + yield { + "type": "error", + "error": "A download is already using the selected destination", + } + except ArchiveDownloadCapacityError: + yield { + "type": "error", + "error": "Too many archive downloads are already active", + } + except RemoteSourcePolicyError: + yield { + "type": "error", + "error": "The selected URL is not an approved HEASARC archive download", + } + except RemoteSourceHTTPError as error: + yield { + "type": "error", + "error": f"The HEASARC server returned HTTP {error.status_code}", + } + except RemoteSourceTimeout: + yield {"type": "error", "error": "The HEASARC download timed out"} + except RemoteSourceError: + yield {"type": "error", "error": "The HEASARC download failed validation"} + except PermissionError: + yield { + "type": "error", + "error": ( + "The save authorization is invalid or expired; choose the " + "destination again" + ), + } + except FileExistsError: + yield { + "type": "error", + "error": "A file already exists at the selected destination", + } + except (OSError, ValueError, RuntimeError): + yield { + "type": "error", + "error": "The download could not be published safely", + } + + @staticmethod + async def _raise_if_download_cancelled( + cancellation_check: CancellationCheck | None, + ) -> None: + if cancellation_check is None: + return + cancelled = cancellation_check() + if inspect.isawaitable(cancelled): + cancelled = await cancelled + if cancelled: + raise RemoteSourceCancelled("Remote transfer was cancelled") + + @staticmethod + def _validate_download_filename(filename: str) -> None: + if ( + filename in {"", ".", ".."} + or len(filename) > 512 + or "/" in filename + or "\\" in filename + or any( + ord(character) < 0x20 or ord(character) == 0x7F + for character in filename + ) + ): + raise ValueError("The selected destination filename is invalid") + + @staticmethod + async def _run_download_verification( + publication: SecurePublication, + expected_size: int, + expected_digest: bytes, + cancellation_check: CancellationCheck | None, + ) -> None: + """Poll cancellation and join the descriptor-owning verifier on exit.""" + cancellation_event = threading.Event() + task = asyncio.create_task( + asyncio.to_thread( + ArchiveService._verify_download_artifact, + publication, + expected_size, + expected_digest, + cancellation_event, + ) + ) + try: + while True: + completed, _pending = await asyncio.wait( + {task}, + timeout=ARCHIVE_VERIFICATION_CANCEL_POLL_SECONDS, + ) + if completed: + task.result() + return + await ArchiveService._raise_if_download_cancelled(cancellation_check) + except BaseException: + cancellation_event.set() + while not task.done(): + try: + await asyncio.wait({task}) + except asyncio.CancelledError: + continue + try: + task.result() + except Exception: + pass + raise + + @staticmethod + def _verify_download_artifact( + publication: SecurePublication, + expected_size: int, + expected_digest: bytes, + cancellation_event: threading.Event, + ) -> None: + digest = hashlib.sha256() + reopened_size = 0 + with publication.open_reader("rb", encoding=None) as reader: + while True: + if cancellation_event.is_set(): + raise RemoteSourceCancelled("Download verification was cancelled") + chunk = reader.read(ARCHIVE_DOWNLOAD_CHUNK_BYTES) + if cancellation_event.is_set(): + raise RemoteSourceCancelled("Download verification was cancelled") + if not chunk: + break + reopened_size += len(chunk) + digest.update(chunk) + if reopened_size != expected_size or digest.digest() != expected_digest: + raise ValueError("The staged download changed during verification") + if publication.verified_size() != expected_size: + raise ValueError("The staged download size changed during verification") diff --git a/python-backend/services/base_service.py b/python-backend/services/base_service.py new file mode 100644 index 0000000..b8bca70 --- /dev/null +++ b/python-backend/services/base_service.py @@ -0,0 +1,98 @@ +""" +Base service class for Stingray Explorer backend. + +Provides common functionality for all services. +""" + +from typing import Any, Dict, Optional + +from .state_manager import StateManager +from utils.error_handler import ErrorHandler +from utils.performance_monitor import PerformanceMonitor + + +class BaseService: + """ + Base class for all services in the Stingray Explorer backend. + + Provides: + - Access to StateManager for data persistence + - Access to ErrorHandler for consistent error handling + - Standard result format for all service methods + """ + + def __init__( + self, + state_manager: StateManager, + performance_monitor: Optional[PerformanceMonitor] = None, + ): + """ + Initialize the base service. + + Args: + state_manager: The application state manager instance + performance_monitor: Optional performance monitor instance + """ + self.state = state_manager + self.error_handler = ErrorHandler + self.performance_monitor = performance_monitor + + def create_result( + self, + success: bool, + data: Any = None, + message: str = "", + error: Optional[str] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Create a standardized result dictionary. + + All service methods should return results in this format. + + Args: + success: Whether the operation succeeded + data: The result data + message: User-friendly message + error: Technical error message + **kwargs: Additional fields to include + + Returns: + Standardized result dictionary + """ + result: Dict[str, Any] = { + "success": success, + "data": data, + "message": message, + "error": error, + } + result.update(kwargs) + return result + + def handle_error( + self, + exception: Exception, + context: str, + **context_data: Any, + ) -> Dict[str, Any]: + """ + Handle an exception and return a standardized error result. + + Args: + exception: The exception that occurred + context: Description of the operation that failed + **context_data: Additional context data + + Returns: + Error result dictionary + """ + user_msg, tech_msg = self.error_handler.handle_error( + exception, context=context, **context_data + ) + + return self.create_result( + success=False, + data=None, + message=user_msg, + error=tech_msg, + ) diff --git a/python-backend/services/correlation_service.py b/python-backend/services/correlation_service.py new file mode 100644 index 0000000..68ed07f --- /dev/null +++ b/python-backend/services/correlation_service.py @@ -0,0 +1,493 @@ +""" +Correlation service for auto/cross-correlation analysis. + +Implemented per docs/superpowers/plans/2026-07-29-quicklook-remaining-pages.md. + +Sign convention (verified against stingray 2.2.10 by aligning a Gaussian pulse): +**a positive ``time_shift`` means the first event list lags behind the second**; +a negative ``time_shift`` means the first list leads. Surface this verbatim in +the UI -- it is easy to get backwards. + +stingray's ``CrossCorrelation`` correlates the two ``counts`` arrays purely by +position and never looks at ``Lightcurve.time``, so two lists covering different +absolute time ranges would be silently mis-aligned. This service therefore bins +both event lists onto ONE shared bin-edge grid spanning their common time range +before handing them to stingray. + +Three properties of that binning are load-bearing and easy to break: + +* Bounds come from ``np.min``/``np.max``, never ``time[0]``/``time[-1]``: + ``EventList.read`` does not forward ``skip_checks`` in stingray 2.2.10, so an + unsorted event file reaches this service with its stored endpoints pointing at + arbitrary interior photons. ``np.histogram`` does not need sorted input. +* The grid is built in time RELATIVE to the start of the range. ``np.arange`` + over absolute mission times quantises to the local float64 spacing (1.5e-8 s + at MET 8e7), which makes the real bin width differ from the ``dt`` stamped on + the ``Lightcurve`` -- and stingray derives ``time_lags`` from that stamped + value, so the whole lag axis would be silently stretched. +* Both endpoints bin with the SAME helper at exactly the requested ``dt``. + ``EventList.to_lc`` would instead snap ``dt`` to a multiple of the event + list's instrument time resolution, so the auto- and cross-correlation pages + would disagree on the lag axis for identical input. The snapping exists to + avoid beat artefacts, so ``_resolution_warning`` surfaces the mismatch + instead of silently changing the user's ``dt``. +""" + +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +from stingray import Lightcurve +from stingray.crosscorrelation import AutoCorrelation, CrossCorrelation + +from .analysis_helpers import collect_warnings, finite_list, overlap_error +from .base_service import BaseService + +# 'valid' is deliberately excluded: with equal-length inputs (which the shared +# grid always produces) it degenerates to a single point. +VALID_MODES = ("same", "full") +VALID_NORMS = ("none", "variance") + +# Minimum number of shared bins before a correlation is worth computing. +MIN_BINS = 3 + +# Maximum number of bins one request may allocate. Nothing else bounds this: +# two 3 ks lists at dt=1e-3 already produce ~3e6 lags, a ~93 MB JSON body and +# several hundred MB of RSS, and a finer dt OOMs the backend outright. Mirrors +# the DEFAULT_MAX_PLOT_POINTS cap lightcurve_service applies to plot payloads. +MAX_BINS = 500_000 + + +def _mode_error(mode: str) -> Optional[str]: + """Readable rejection for correlation modes this endpoint does not expose.""" + if mode not in VALID_MODES: + return ( + f"mode '{mode}' is not supported; use one of " + f"{', '.join(repr(m) for m in VALID_MODES)}" + ) + return None + + +def _norm_error(norm: str) -> Optional[str]: + """Readable rejection for normalisations stingray's correlation cannot do.""" + if norm not in VALID_NORMS: + return ( + f"norm '{norm}' is not supported; use one of " + f"{', '.join(repr(n) for n in VALID_NORMS)}" + ) + return None + + +def _dt_error(dt: float) -> Optional[str]: + """Readable rejection for bin sizes that cannot produce a light curve.""" + if not np.isfinite(dt) or dt <= 0: + return f"dt ({dt}) must be a positive number of seconds" + return None + + +def _span_error(span: float, dt: float, what: str) -> Optional[str]: + """Readable rejection when the time span cannot hold MIN_BINS bins.""" + if span / dt < MIN_BINS: + return ( + f"dt ({dt}s) is too coarse for {what} ({span:.3f}s): " + f"at least {MIN_BINS} bins are needed to correlate" + ) + return None + + +def _bin_cap_error(span: float, dt: float, what: str) -> Optional[str]: + """Readable rejection when a request would allocate an unusable grid. + + Formatted as a float so a pathologically small dt (span/dt = inf) still + produces a sentence rather than an OverflowError from ``int()``. + """ + n_bins = span / dt + if n_bins <= MAX_BINS: + return None + return ( + f"dt ({dt}s) over {what} ({span:.3f}s) gives {n_bins:,.0f} bins; " + f"increase dt or shorten the range (the cap is {MAX_BINS:,} bins)" + ) + + +def _time_bounds(event_list) -> Tuple[float, float]: + """First and last event time of a list that may not be sorted. + + ``time[0]``/``time[-1]`` are NOT the bounds: stingray only sorts when + ``skip_checks`` is False, and ``EventList.read`` never forwards it, so an + unsorted file yields interior endpoints. Using them shrinks the shared grid + to a sliver of the data (or inverts it, faking a no-overlap rejection). + """ + times = np.asarray(event_list.time, dtype=float) + return float(np.min(times)), float(np.max(times)) + + +def _resolution_warning(event_list, dt: float, label: str) -> Optional[str]: + """Advisory when dt is not a multiple of the instrument time resolution. + + ``EventList.to_lc`` would snap dt to a multiple of ``EventList.dt`` (set + from TIMEDEL for real HEASARC files) to avoid beat artefacts. This service + bins on its own grid so the requested dt is always the dt used -- surface + the mismatch rather than silently changing it behind the user's back. + """ + resolution = float(getattr(event_list, "dt", 0.0) or 0.0) + if resolution <= 0: + return None + ratio = dt / resolution + if abs(ratio - round(ratio)) < 1e-6: + return None + return ( + f"dt ({dt}s) is not a multiple of {label}'s time resolution " + f"({resolution}s), so bins hold unequal numbers of instrument ticks " + f"and the correlation can show beat artefacts. Use a multiple of " + f"{resolution}s." + ) + + +def _empty_error(event_list, name: str) -> Optional[str]: + """Readable rejection for event lists with no photons.""" + times = getattr(event_list, "time", None) + if times is None or len(times) == 0: + return f"EventList '{name}' contains no events" + return None + + +def _noise_subtracted_variance(lc: Lightcurve) -> float: + """Reproduce stingray's norm='variance' denominator term for one curve. + + stingray computes ``var(counts) - mean(counts_err)**2`` and then takes + ``sqrt(var1 * var2)``. A negative term is what makes the normalisation + either meaningless (both negative) or NaN (exactly one negative). + """ + counts = np.asarray(lc.counts, dtype=float) + counts_err = np.asarray(lc.counts_err, dtype=float) + return float(np.var(counts) - np.mean(counts_err) ** 2) + + +def _variance_warning(lc: Lightcurve, label: str) -> Optional[str]: + """Advisory when a light curve is too flat/faint for norm='variance'.""" + variance = _noise_subtracted_variance(lc) + if variance > 0: + return None + return ( + f"{label} has a negative noise-subtracted variance " + f"(var - mean(err)^2 = {variance:.3g}), which happens for flat or " + "low-count data; the 'variance' normalisation is not physically " + "meaningful here. Try a larger dt or norm='none'." + ) + + +def _nan_corr_warning(corr) -> Optional[str]: + """Advisory when stingray silently produced an all/partly-NaN correlation. + + Must be evaluated on the RAW stingray array, before finite_list() turns the + NaNs into JSON nulls. stingray still reports an argmax-derived time_shift + for an all-NaN corr, which is meaningless, so the caller nulls it out. + """ + values = np.asarray(corr, dtype=float) + if not np.isnan(values).any(): + return None + return ( + "The correlation contains NaN values (stingray's 'variance' " + "normalisation takes the square root of a negative variance product " + "for flat or low-count light curves). The time shift is unreliable " + "and has been omitted." + ) + + +def _grid_edges(start: float, stop: float, dt: float) -> np.ndarray: + """Bin edges RELATIVE to ``start``, so ``edges[0]`` is exactly 0.0. + + ``np.arange`` derives its step from ``(start + dt) - start``, which at + absolute mission times rounds to the local float64 spacing: at MET 8e7 a + requested dt of 0.01 s really steps 0.010000005 s. Every edge inherits that + error, so the histogram's true bin width no longer matches the dt stamped + on the Lightcurve (from which stingray derives ``time_lags``) and the tail + of the range is dropped. Starting from 0.0 keeps the step exact. + """ + span = stop - start + edges = np.arange(0.0, span + dt, dt) + # arange's exclusive stop can leave one edge past the range; that final bin + # would only be partially covered, so drop it. + return edges[edges <= span + 1e-9] + + +def _grid_lightcurve(times, start: float, edges: np.ndarray, dt: float) -> Lightcurve: + """Histogram one event list onto ``edges`` (relative to ``start``). + + Times are shifted to relative seconds BEFORE histogramming so the binning + inherits the exact grid. ``np.histogram`` does not require sorted input, so + the raw (possibly unsorted) times are safe to pass straight through. + """ + relative = np.asarray(times, dtype=float) - start + counts, _ = np.histogram(relative, bins=edges) + centers = start + (edges[:-1] + dt / 2.0) + gti = [[start, start + float(edges[-1])]] + return Lightcurve(time=centers, counts=counts, dt=dt, gti=gti, skip_checks=True) + + +def _shared_grid_lightcurves( + events1, events2, dt: float +) -> Tuple[Lightcurve, Lightcurve, float, float]: + """Bin two event lists onto one identical grid over their common range. + + stingray performs no time alignment whatsoever, so this is the only thing + that makes a cross-correlation of two independently-loaded event lists + physically meaningful. + """ + start1, stop1 = _time_bounds(events1) + start2, stop2 = _time_bounds(events2) + start = max(start1, start2) + stop = min(stop1, stop2) + + edges = _grid_edges(start, stop, dt) + lc1 = _grid_lightcurve(events1.time, start, edges, dt) + lc2 = _grid_lightcurve(events2.time, start, edges, dt) + return lc1, lc2, start, start + float(edges[-1]) + + +class CorrelationService(BaseService): + """Service for auto/cross-correlation operations.""" + + def auto_correlation( + self, + event_list_name: str, + dt: float, + mode: str = "same", + norm: str = "none", + ) -> Dict[str, Any]: + """Auto-correlate one event list's light curve with itself. + + ``norm='none'`` uses ``AutoCorrelation``; ``norm='variance'`` must go + through ``CrossCorrelation(lc, lc, norm='variance')`` because + ``AutoCorrelation.__init__`` does not forward ``norm`` (it is hardcoded + to ``'none'`` upstream). ``time_shift`` is always 0 for an + auto-correlation. + + The light curve is built with the same helper the cross path uses, NOT + ``EventList.to_lc``: ``to_lc`` snaps dt to a multiple of the event + list's instrument time resolution and sizes the curve from the GTI, so + the two pages would report different lag axes -- and a different dt -- + for identical input. + """ + try: + for message in (_mode_error(mode), _norm_error(norm), _dt_error(dt)): + if message: + return self.create_result( + success=False, data=None, message=message, error=None + ) + + if not self.state.has_event_data(event_list_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_name}' not found", + error=None, + ) + + event_list = self.state.get_event_data(event_list_name) + empty = _empty_error(event_list, event_list_name) + if empty: + return self.create_result( + success=False, data=None, message=empty, error=None + ) + + start, stop = _time_bounds(event_list) + span = stop - start + what = f"the span of '{event_list_name}'" + for message in ( + _span_error(span, dt, what), + _bin_cap_error(span, dt, what), + ): + if message: + return self.create_result( + success=False, data=None, message=message, error=None + ) + + warnings_out: List[str] = [] + resolution = _resolution_warning(event_list, dt, f"'{event_list_name}'") + if resolution: + warnings_out.append(resolution) + + with collect_warnings(warnings_out): + lc = _grid_lightcurve( + event_list.time, start, _grid_edges(start, stop, dt), dt + ) + if norm == "variance": + variance_warning = _variance_warning(lc, "The light curve") + if variance_warning: + warnings_out.append(variance_warning) + correlation = CrossCorrelation(lc, lc, mode=mode, norm="variance") + else: + correlation = AutoCorrelation(lc, mode=mode) + + data = self._build_payload(correlation, mode, norm, warnings_out) + return self.create_result( + success=True, + data=data, + message=( + f"Auto-correlation computed for '{event_list_name}' " + # dt comes from the payload, i.e. the dt actually binned + # with, so the sentence can never contradict the result. + f"({data['n']} lags, dt={data['dt']}s)" + ), + ) + + except Exception as exception: # pragma: no cover - defensive + return self.handle_error( + exception, + "Auto-correlation", + event_list=event_list_name, + dt=dt, + mode=mode, + norm=norm, + ) + + def cross_correlation( + self, + event_list_1_name: str, + event_list_2_name: str, + dt: float, + mode: str = "same", + norm: str = "none", + ) -> Dict[str, Any]: + """Cross-correlate two event lists binned onto one shared grid. + + A positive ``time_shift`` means the FIRST list lags behind the second. + """ + try: + for message in (_mode_error(mode), _norm_error(norm), _dt_error(dt)): + if message: + return self.create_result( + success=False, data=None, message=message, error=None + ) + + for name in (event_list_1_name, event_list_2_name): + if not self.state.has_event_data(name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{name}' not found", + error=None, + ) + + events1 = self.state.get_event_data(event_list_1_name) + events2 = self.state.get_event_data(event_list_2_name) + for event_list, name in ( + (events1, event_list_1_name), + (events2, event_list_2_name), + ): + empty = _empty_error(event_list, name) + if empty: + return self.create_result( + success=False, data=None, message=empty, error=None + ) + + # Runs on the RAW event lists, before any binning. + overlap = overlap_error(events1, events2) + if overlap: + return self.create_result( + success=False, data=None, message=overlap, error=None + ) + + start1, stop1 = _time_bounds(events1) + start2, stop2 = _time_bounds(events2) + start = max(start1, start2) + stop = min(stop1, stop2) + span = stop - start + what = "the overlapping time range" + for message in ( + _span_error(span, dt, what), + _bin_cap_error(span, dt, what), + ): + if message: + return self.create_result( + success=False, data=None, message=message, error=None + ) + + warnings_out: List[str] = [] + for event_list, name in ( + (events1, event_list_1_name), + (events2, event_list_2_name), + ): + resolution = _resolution_warning(event_list, dt, f"'{name}'") + if resolution and resolution not in warnings_out: + warnings_out.append(resolution) + + with collect_warnings(warnings_out): + lc1, lc2, grid_start, grid_stop = _shared_grid_lightcurves( + events1, events2, dt + ) + + cropped = any( + first < grid_start - dt or last > grid_stop + dt + for first, last in ((start1, stop1), (start2, stop2)) + ) + if cropped: + warnings_out.append( + "Both event lists were binned onto a shared " + f"{dt}s grid over their common time range " + f"{grid_start:.3f}-{grid_stop:.3f}s ({lc1.n} bins); " + "events outside that range were excluded." + ) + + if norm == "variance": + for lc, label in ( + (lc1, f"'{event_list_1_name}'"), + (lc2, f"'{event_list_2_name}'"), + ): + variance_warning = _variance_warning(lc, label) + if variance_warning: + warnings_out.append(variance_warning) + + correlation = CrossCorrelation(lc1, lc2, mode=mode, norm=norm) + + data = self._build_payload(correlation, mode, norm, warnings_out) + return self.create_result( + success=True, + data=data, + message=( + f"Cross-correlation computed for '{event_list_1_name}' x " + f"'{event_list_2_name}' ({data['n']} lags, dt={data['dt']}s)" + ), + ) + + except Exception as exception: # pragma: no cover - defensive + return self.handle_error( + exception, + "Cross-correlation", + event_list_1=event_list_1_name, + event_list_2=event_list_2_name, + dt=dt, + mode=mode, + norm=norm, + ) + + def _build_payload( + self, + correlation: CrossCorrelation, + mode: str, + norm: str, + warnings_out: List[str], + ) -> Dict[str, Any]: + """Serialise a stingray correlation object into the API payload.""" + # Evaluated on the raw array: finite_list() replaces NaN with None. + nan_warning = _nan_corr_warning(correlation.corr) + if nan_warning and nan_warning not in warnings_out: + warnings_out.append(nan_warning) + + time_shift: Optional[float] = None + if nan_warning is None: + shift = float(correlation.time_shift) + if np.isfinite(shift): + time_shift = shift + + return { + "time_lags": finite_list(correlation.time_lags), + "corr": finite_list(correlation.corr), + "time_shift": time_shift, + "dt": float(correlation.dt), + "n": int(correlation.n), + "mode": mode, + "norm": norm, + "warnings": warnings_out, + } diff --git a/python-backend/services/data_service.py b/python-backend/services/data_service.py new file mode 100644 index 0000000..c4c502a --- /dev/null +++ b/python-backend/services/data_service.py @@ -0,0 +1,3224 @@ +""" +Data service for EventList operations. + +Handles loading, saving, and managing event lists. +Includes lazy loading support for large files. +""" + +import asyncio +import gzip +import os +import re +import tempfile +import threading +import time +import warnings +from concurrent.futures import ThreadPoolExecutor, as_completed +from contextlib import ExitStack, contextmanager +from pathlib import Path +from typing import ( + Any, + AsyncGenerator, + BinaryIO, + Callable, + Dict, + Generator, + List, + Optional, +) + +import numpy as np +import psutil +from astropy.io import fits +from stingray import EventList +from stingray.io import FITSTimeseriesReader + +try: + import h5py +except ImportError: # Optional format support must not prevent app startup. + h5py = None + +from models.event_formats import ( + require_batch_input_formats, + require_input_event_format, +) + +from .base_service import BaseService +from .remote_source import ( + GENERAL_HTTPS_POLICY, + RemoteSourceCancelled, + RemoteSourceClient, + RemoteSourceError, +) +from .utility_helpers import ( + MAX_FITS_INSPECT_BYTES, + MAX_RMF_BYTES, + GrantedReadFile, + open_verified_read_grant, + validate_derived_name, +) + + +SPOOL_MEMORY_LIMIT = 64 * 1024**2 +REMOTE_EVENT_LIMIT = MAX_FITS_INSPECT_BYTES +COPY_CHUNK_SIZE = 1024 * 1024 +MAX_PUBLIC_SCIENTIFIC_WARNINGS = 32 +MAX_PUBLIC_WARNING_CHARS = 4096 +_PRIVATE_LOCATOR = re.compile( + r"(?:[A-Za-z][A-Za-z0-9+.-]*://|stingray-input-|" + r"(?:^|[\s'\"(<])(?:/[^\s'\"<>]+|[A-Za-z]:[\\/]|\\\\))" +) + + +def _to_python_float(val): + """Convert numpy numeric types to Python float for JSON serialization.""" + if val is None: + return None + try: + # Handle numpy scalars (including longdouble/float128) + return float(val) + except (TypeError, ValueError): + return None + + +def _to_python_float_list(arr): + """Convert numpy array to list of Python floats for JSON serialization.""" + if arr is None: + return None + try: + # Convert each element explicitly to handle longdouble + return [float(x) for x in arr] + except (TypeError, ValueError): + return arr.tolist() if hasattr(arr, "tolist") else list(arr) + + +def _safe_scientific_warning(captured_warning: Any) -> str | None: + """Keep useful bounded warnings while dropping locator-bearing details.""" + if issubclass(captured_warning.category, ResourceWarning): + return None + text = str(captured_warning.message)[:MAX_PUBLIC_WARNING_CHARS] + if _PRIVATE_LOCATOR.search(text): + return f"{captured_warning.category.__name__} details were omitted" + return text + + +@contextmanager +def _open_granted_source( + file_path: str, + file_grant: str | None, + pinned_source: GrantedReadFile | None, +) -> Generator[GrantedReadFile, None, None]: + """Yield an already pinned source or verify and pin a native grant now.""" + if pinned_source is not None: + yield pinned_source + return + if not file_grant: + raise PermissionError("A native read grant is required for the selected file") + source_context = open_verified_read_grant(file_path, file_grant) + try: + source = source_context.__enter__() + except Exception: + # Native open failures can contain the selected pathname. Keep that + # capability out of API responses and framework exception logs. + raise PermissionError( + "The selected file could not be verified; select it again" + ) from None + + body_failed = False + try: + yield source + except BaseException: + body_failed = True + raise + finally: + try: + source_context.__exit__(None, None, None) + except Exception: + if not body_failed: + raise OSError("The selected input could not be closed safely") from None + + +@contextmanager +def _spooled_copy( + source: GrantedReadFile, + *, + max_bytes: int, + cancellation_check=None, +) -> Generator[BinaryIO, None, None]: + """Copy one pinned input into a bounded anonymous application-owned file.""" + if source.size_bytes < 0 or source.size_bytes > max_bytes: + raise ValueError(f"Selected input exceeds the {max_bytes}-byte safety cap") + before = os.fstat(source.stream.fileno()) + if before.st_size != source.size_bytes: + raise OSError("Selected input changed before it could be retained") + with tempfile.SpooledTemporaryFile( + max_size=SPOOL_MEMORY_LIMIT, mode="w+b" + ) as spool: + source.stream.seek(0) + copied = 0 + while True: + _raise_if_cancelled(cancellation_check) + chunk = source.stream.read(COPY_CHUNK_SIZE) + if not chunk: + break + copied += len(chunk) + if copied > max_bytes: + raise ValueError( + f"Selected input exceeds the {max_bytes}-byte safety cap" + ) + spool.write(chunk) + if copied != source.size_bytes: + raise OSError("Selected input changed while it was being retained") + after = os.fstat(source.stream.fileno()) + before_identity = ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) + after_identity = ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ) + if after_identity != before_identity: + raise OSError("Selected input changed while it was being retained") + _raise_if_cancelled(cancellation_check) + spool.seek(0) + yield spool + + +def _rewind(stream: BinaryIO | None) -> None: + if stream is not None: + stream.seek(0) + + +def _raise_if_cancelled(cancellation_check) -> None: + if cancellation_check is not None and cancellation_check(): + raise InterruptedError("The data operation was cancelled") + + +@contextmanager +def _decoded_event_stream( + stream: BinaryIO, + *, + fmt: str, + cancellation_check=None, +) -> Generator[BinaryIO, None, None]: + """Decode gzip OGIP input into another capped anonymous spool.""" + _rewind(stream) + magic = stream.read(2) + _rewind(stream) + if fmt not in {"ogip", "fits"} or magic != b"\x1f\x8b": + yield stream + return + + with gzip.GzipFile(fileobj=stream, mode="rb") as compressed: + with tempfile.SpooledTemporaryFile( + max_size=SPOOL_MEMORY_LIMIT, mode="w+b" + ) as decoded: + copied = 0 + while True: + _raise_if_cancelled(cancellation_check) + chunk = compressed.read(COPY_CHUNK_SIZE) + if not chunk: + break + copied += len(chunk) + if copied > MAX_FITS_INSPECT_BYTES: + raise ValueError( + "Decompressed event input exceeds the scientific input cap" + ) + decoded.write(chunk) + decoded.seek(0) + _raise_if_cancelled(cancellation_check) + yield decoded + + +@contextmanager +def _cloned_stream( + stream: BinaryIO, *, cancellation_check=None +) -> Generator[BinaryIO, None, None]: + """Give a reader that closes file objects its own bounded anonymous clone.""" + with tempfile.SpooledTemporaryFile( + max_size=SPOOL_MEMORY_LIMIT, mode="w+b" + ) as clone: + _rewind(stream) + copied = 0 + while True: + _raise_if_cancelled(cancellation_check) + chunk = stream.read(COPY_CHUNK_SIZE) + if not chunk: + break + copied += len(chunk) + if copied > MAX_FITS_INSPECT_BYTES: + raise ValueError("Event input exceeds the scientific input cap") + clone.write(chunk) + _rewind(stream) + clone.seek(0) + _raise_if_cancelled(cancellation_check) + yield clone + + +@contextmanager +def _scientific_reader_source( + stream: BinaryIO, + *, + fmt: str, + cancellation_check=None, +) -> Generator[BinaryIO | str, None, None]: + """Adapt a retained stream for third-party readers that reopen by name. + + Astropy table/HDF5 readers consume file objects safely. Stingray's OGIP + reader reopens its input multiple times, so it receives a private 0700 + directory snapshot rather than the originally selected pathname. + """ + if fmt not in {"ogip", "fits"}: + _rewind(stream) + yield stream + return + + with tempfile.TemporaryDirectory(prefix="stingray-input-") as directory: + snapshot = Path(directory) / "events.evt" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + descriptor = os.open(snapshot, flags, 0o600) + try: + _rewind(stream) + copied = 0 + while True: + _raise_if_cancelled(cancellation_check) + chunk = stream.read(COPY_CHUNK_SIZE) + if not chunk: + break + copied += len(chunk) + if copied > MAX_FITS_INSPECT_BYTES: + raise ValueError("Event input exceeds the scientific input cap") + remaining = memoryview(chunk) + while remaining: + written = os.write(descriptor, remaining) + if written <= 0: + raise OSError("Could not retain the selected event input") + remaining = remaining[written:] + os.fsync(descriptor) + _raise_if_cancelled(cancellation_check) + finally: + os.close(descriptor) + yield str(snapshot) + + +class DataService(BaseService): + """ + Service for EventList data operations. + + Handles loading, saving, and managing event lists without any UI dependencies. + """ + + def _fix_inverted_gti(self, event_list: EventList) -> bool: + """ + Fix inverted GTI intervals in an EventList (in-place). + + When Stingray loads unsorted data without a GTI extension, it sets + GTI to [time[0], time[-1]] which can have start > stop for unsorted times. + This causes Stingray's internal check_gtis to fail on later operations. + + This method sorts each GTI interval to ensure start <= stop. + + Args: + event_list: The EventList to fix (modified in-place) + + Returns: + True if any GTI was fixed, False otherwise + """ + if event_list.gti is None or len(event_list.gti) == 0: + return False + + fixed_any = False + fixed_gti = [] + for g in event_list.gti: + start, stop = g[0], g[1] + if stop < start: + # Swap to fix inverted interval + fixed_gti.append([stop, start]) + fixed_any = True + else: + fixed_gti.append([start, stop]) + + if fixed_any: + event_list.gti = np.array(fixed_gti) + + return fixed_any + + def _validate_gti(self, event_list: EventList) -> List[str]: + """ + Validate GTI (Good Time Intervals) for an EventList. + + Checks for: + - Empty or missing GTI + - Invalid GTI intervals (stop <= start) + - Events falling outside GTI boundaries + + Args: + event_list: The EventList to validate + + Returns: + List of warning messages (empty if no issues found) + """ + warnings: List[str] = [] + + # Check 1: Empty or missing GTI + if event_list.gti is None or len(event_list.gti) == 0: + warnings.append( + "GTI is empty or missing - no valid observation intervals defined" + ) + return warnings # Can't do further checks without GTI + + # Check 2: Invalid GTI intervals (stop <= start) + invalid_intervals = [] + for i, (start, stop) in enumerate(event_list.gti): + if stop <= start: + invalid_intervals.append(i) + if invalid_intervals: + warnings.append( + f"Found {len(invalid_intervals)} invalid GTI interval(s) where stop <= start " + f"(indices: {invalid_intervals[:5]}{'...' if len(invalid_intervals) > 5 else ''})" + ) + + # Check 3: Events outside GTI boundaries + if event_list.time is not None and len(event_list.time) > 0: + times = event_list.time + gti = event_list.gti + + # Check if each event falls within at least one GTI + inside_gti = np.zeros(len(times), dtype=bool) + for start, stop in gti: + inside_gti |= (times >= start) & (times <= stop) + + events_outside = np.sum(~inside_gti) + if events_outside > 0: + total_events = len(times) + percent_outside = (events_outside / total_events) * 100 + warnings.append( + f"{events_outside} events ({percent_outside:.2f}%) fall outside GTI boundaries" + ) + + return warnings + + def _validate_data_quality(self, event_list: EventList) -> List[Dict[str, Any]]: + """ + Validate data quality beyond GTI checks. + + Performs comprehensive validation including: + - NaN values in time/energy arrays + - Time ordering (monotonically increasing) + - Negative energy/PI values + + Args: + event_list: The EventList to validate + + Returns: + List of all validation check results with type, status, severity, message, and details + """ + validations: List[Dict[str, Any]] = [] + + # Check 1: NaN in time array + if event_list.time is not None and len(event_list.time) > 0: + nan_count = int(np.sum(np.isnan(event_list.time))) + validations.append( + { + "type": "nan_time", + "name": "Time Array NaN Check", + "description": "Check for NaN (Not a Number) values in time array", + "status": "fail" if nan_count > 0 else "pass", + "severity": "error" if nan_count > 0 else "pass", + "message": f"Found {nan_count} NaN values" + if nan_count > 0 + else "No NaN values found", + "count": nan_count, + "total": len(event_list.time), + } + ) + + # Check 2: Time ordering (monotonically increasing) + time_diffs = np.diff(event_list.time) + disorder_count = int(np.sum(time_diffs < 0)) + validations.append( + { + "type": "time_ordering", + "name": "Time Ordering Check", + "description": "Check if time values are monotonically increasing", + "status": "fail" if disorder_count > 0 else "pass", + "severity": "warning" if disorder_count > 0 else "pass", + "message": f"Found {disorder_count} time inversions (not monotonically increasing)" + if disorder_count > 0 + else "Time values are monotonically increasing", + "count": disorder_count, + "total": len(event_list.time) - 1, + } + ) + else: + validations.append( + { + "type": "nan_time", + "name": "Time Array NaN Check", + "description": "Check for NaN values in time array", + "status": "skip", + "severity": "skip", + "message": "No time data available", + "count": 0, + "total": 0, + } + ) + validations.append( + { + "type": "time_ordering", + "name": "Time Ordering Check", + "description": "Check if time values are monotonically increasing", + "status": "skip", + "severity": "skip", + "message": "No time data available", + "count": 0, + "total": 0, + } + ) + + # Check 3: NaN in energy array + if event_list.energy is not None and len(event_list.energy) > 0: + nan_energy = int(np.sum(np.isnan(event_list.energy))) + validations.append( + { + "type": "nan_energy", + "name": "Energy Array NaN Check", + "description": "Check for NaN values in energy array", + "status": "fail" if nan_energy > 0 else "pass", + "severity": "error" if nan_energy > 0 else "pass", + "message": f"Found {nan_energy} NaN values" + if nan_energy > 0 + else "No NaN values found", + "count": nan_energy, + "total": len(event_list.energy), + } + ) + + # Check 4: Negative energy values + neg_energy = int(np.sum(event_list.energy < 0)) + validations.append( + { + "type": "negative_energy", + "name": "Negative Energy Check", + "description": "Check for negative energy values (physically invalid)", + "status": "fail" if neg_energy > 0 else "pass", + "severity": "error" if neg_energy > 0 else "pass", + "message": f"Found {neg_energy} negative energy values" + if neg_energy > 0 + else "All energy values are non-negative", + "count": neg_energy, + "total": len(event_list.energy), + } + ) + else: + validations.append( + { + "type": "nan_energy", + "name": "Energy Array NaN Check", + "description": "Check for NaN values in energy array", + "status": "skip", + "severity": "skip", + "message": "No energy data available", + "count": 0, + "total": 0, + } + ) + validations.append( + { + "type": "negative_energy", + "name": "Negative Energy Check", + "description": "Check for negative energy values", + "status": "skip", + "severity": "skip", + "message": "No energy data available", + "count": 0, + "total": 0, + } + ) + + # Check 5: Negative PI values + if event_list.pi is not None and len(event_list.pi) > 0: + neg_pi = int(np.sum(event_list.pi < 0)) + validations.append( + { + "type": "negative_pi", + "name": "Negative PI Check", + "description": "Check for negative PI (Pulse Invariant) channel values", + "status": "fail" if neg_pi > 0 else "pass", + "severity": "error" if neg_pi > 0 else "pass", + "message": f"Found {neg_pi} negative PI values" + if neg_pi > 0 + else "All PI values are non-negative", + "count": neg_pi, + "total": len(event_list.pi), + } + ) + else: + validations.append( + { + "type": "negative_pi", + "name": "Negative PI Check", + "description": "Check for negative PI channel values", + "status": "skip", + "severity": "skip", + "message": "No PI data available", + "count": 0, + "total": 0, + } + ) + + # Check 6: GTI validity (start < stop for all intervals) + if event_list.gti is not None and len(event_list.gti) > 0: + invalid_gti = sum(1 for g in event_list.gti if g[1] <= g[0]) + validations.append( + { + "type": "gti_validity", + "name": "GTI Interval Check", + "description": "Check that all GTI intervals have valid start < stop times", + "status": "fail" if invalid_gti > 0 else "pass", + "severity": "error" if invalid_gti > 0 else "pass", + "message": f"Found {invalid_gti} invalid GTI intervals (stop <= start)" + if invalid_gti > 0 + else "All GTI intervals are valid", + "count": invalid_gti, + "total": len(event_list.gti), + } + ) + else: + validations.append( + { + "type": "gti_validity", + "name": "GTI Interval Check", + "description": "Check that all GTI intervals have valid start < stop times", + "status": "skip", + "severity": "skip", + "message": "No GTI data available", + "count": 0, + "total": 0, + } + ) + + return validations + + def _parse_fits_header_string(self, header_str: str) -> Dict[str, str]: + """ + Parse a FITS header string into a dictionary. + + FITS headers have 80-character lines with format: + KEYWORD = value / comment + or + KEYWORD = 'string value' / comment + + Args: + header_str: Raw FITS header string + + Returns: + Dictionary of keyword -> value mappings + """ + parsed: Dict[str, str] = {} + + # Split into 80-character cards (FITS standard) + # Some headers may be newline-separated instead + if "\n" in header_str: + lines = header_str.split("\n") + else: + # Split into 80-char chunks + lines = [header_str[i : i + 80] for i in range(0, len(header_str), 80)] + + for line in lines: + if not line or len(line) < 8: + continue + + # Skip COMMENT, HISTORY, and END cards + keyword = line[:8].strip() + if not keyword or keyword in ("COMMENT", "HISTORY", "END", ""): + continue + + # Check for value indicator '=' + if len(line) > 9 and line[8] == "=": + value_part = line[9:].strip() + + # Handle quoted string values + if value_part.startswith("'"): + # Find closing quote (may contain escaped quotes '') + end_quote = 1 + while end_quote < len(value_part): + if value_part[end_quote] == "'": + if ( + end_quote + 1 < len(value_part) + and value_part[end_quote + 1] == "'" + ): + end_quote += 2 # Skip escaped quote + else: + break + else: + end_quote += 1 + value = value_part[1:end_quote].replace("''", "'").strip() + else: + # Numeric or boolean value - take until comment marker + if "/" in value_part: + value = value_part.split("/")[0].strip() + else: + value = value_part.strip() + + # Handle boolean + if value == "T": + value = "True" + elif value == "F": + value = "False" + + if value: + parsed[keyword] = value + + return parsed + + def _extract_fits_header(self, event_list: EventList) -> Dict[str, Any]: + """ + Extract key FITS header information from an EventList. + + Extracts commonly used header keywords including: + - Object name, observation ID + - RA/Dec coordinates + - Exposure, ontime, livetime + - Observation dates + - Creator software + + Args: + event_list: The EventList to extract headers from + + Returns: + Dictionary containing extracted header information + """ + header_info: Dict[str, Any] = {} + raw_header = getattr(event_list, "header", None) + + if raw_header is None: + return header_info + + # Parse string headers into a dictionary first + header_dict: Dict[str, str] = {} + if isinstance(raw_header, str): + header_dict = self._parse_fits_header_string(raw_header) + elif isinstance(raw_header, dict): + header_dict = {str(k): str(v) for k, v in raw_header.items()} + elif hasattr(raw_header, "get"): + # Handle astropy Header-like objects - convert to dict + try: + for key in raw_header.keys(): + if key and key.strip() and key not in ("COMMENT", "HISTORY", ""): + val = raw_header.get(key) + if val is not None: + header_dict[str(key)] = str(val) + except Exception: + pass + + if not header_dict: + return header_info + + # Define key headers to extract with output key and type conversion + key_headers = [ + ("OBJECT", "object", str), + ("OBS_ID", "obs_id", str), + ("RA_NOM", "ra_nom", float), + ("DEC_NOM", "dec_nom", float), + ("RA_OBJ", "ra_obj", float), + ("DEC_OBJ", "dec_obj", float), + ("EXPOSURE", "exposure", float), + ("ONTIME", "ontime", float), + ("LIVETIME", "livetime", float), + ("DATE-OBS", "date_obs", str), + ("DATE-END", "date_end", str), + ("TSTART", "tstart", float), + ("TSTOP", "tstop", float), + ("CREATOR", "creator", str), + ("TELESCOP", "telescop", str), + ("INSTRUME", "instrume", str), + ("DATAMODE", "datamode", str), + ("OBSERVER", "observer", str), + ] + + for fits_key, output_key, type_func in key_headers: + if fits_key in header_dict: + value = header_dict[fits_key] + try: + if type_func is float: + header_info[output_key] = _to_python_float(float(value)) + else: + header_info[output_key] = type_func(value) + except (ValueError, TypeError): + header_info[output_key] = str(value) + + # Include full raw header + raw_header_dict = {} + for k, v in header_dict.items(): + try: + raw_header_dict[str(k)] = str(v) + except Exception: + pass + if raw_header_dict: + header_info["raw_header"] = raw_header_dict + + return header_info + + def _detect_fits_file_type(self, file_stream: BinaryIO | str) -> Dict[str, Any]: + """ + Detect the type of a FITS file by reading its headers. + + This pre-check prevents cryptic Stingray errors when users try to load + Light Curve files as Event Lists (or other type mismatches). + + Returns: + Dictionary with: + - file_type: 'event_list', 'light_curve', 'spectrum', 'unknown' + - details: Dict with HDUCLAS1, EXTNAME, columns found + - is_event_list: bool + - error_message: str or None (user-friendly message if not event list) + """ + result: Dict[str, Any] = { + "file_type": "unknown", + "details": {}, + "is_event_list": False, + "error_message": None, + } + + try: + if not isinstance(file_stream, str): + _rewind(file_stream) + with fits.open(file_stream, memmap=False) as hdulist: + for hdu in hdulist: + if hdu.name in ["PRIMARY", ""]: + continue + + header = hdu.header + extname = header.get("EXTNAME", "").upper() + hduclas1 = header.get("HDUCLAS1", "").upper() + + result["details"] = { + "extname": extname, + "hduclas1": hduclas1, + "extension_name": hdu.name, + } + + # Check for Light Curve + if "LIGHT" in hduclas1 or extname == "RATE": + result["file_type"] = "light_curve" + result["is_event_list"] = False + result["error_message"] = ( + f"This is a Light Curve file (HDUCLAS1='{hduclas1}', " + f"EXTNAME='{extname}'). " + f"Use the Light Curve analysis tools instead of Event List loading." + ) + return result + + # Check for Event List + if extname == "EVENTS" or hduclas1 == "EVENTS": + result["file_type"] = "event_list" + result["is_event_list"] = True + return result + + # Check for Spectrum + if "SPECTRUM" in hduclas1 or extname == "SPECTRUM": + result["file_type"] = "spectrum" + result["is_event_list"] = False + result["error_message"] = ( + f"This is a Spectrum file (HDUCLAS1='{hduclas1}'). " + f"Use spectral analysis tools instead of Event List loading." + ) + return result + + # If we get here, couldn't determine type - allow attempt + result["file_type"] = "unknown" + result["is_event_list"] = True # Allow unknown files to attempt loading + + except Exception as e: + # If we can't read headers, let the normal loading handle errors + result["file_type"] = "unknown" + result["is_event_list"] = True + result["details"]["error"] = type(e).__name__ + + return result + + def load_event_list( + self, + file_path: str, + name: str, + fmt: str = "ogip", + rmf_file: Optional[str] = None, + additional_columns: Optional[List[str]] = None, + high_precision: bool = False, + skip_checks: bool = False, + notes: Optional[str] = None, + file_grant: str | None = None, + rmf_grant: Optional[str] = None, + *, + _file_source: GrantedReadFile | None = None, + _rmf_source: GrantedReadFile | None = None, + _cancellation_check=None, + ) -> Dict[str, Any]: + """Verify native grants and load only from retained anonymous streams.""" + fmt = require_input_event_format(fmt) + name_error = validate_derived_name(name) + if name_error: + raise ValueError(name_error) + if (rmf_file is None) != (rmf_grant is None) and _rmf_source is None: + raise ValueError("rmf_file and rmf_grant must be provided together") + with ExitStack() as stack: + selected = stack.enter_context( + _open_granted_source(file_path, file_grant, _file_source) + ) + retained_stream = stack.enter_context( + _spooled_copy( + selected, + max_bytes=MAX_FITS_INSPECT_BYTES, + cancellation_check=_cancellation_check, + ) + ) + event_stream = stack.enter_context( + _decoded_event_stream( + retained_stream, + fmt=fmt, + cancellation_check=_cancellation_check, + ) + ) + rmf_stream = None + if rmf_file is not None or _rmf_source is not None: + rmf_selected = stack.enter_context( + _open_granted_source( + rmf_file or "", rmf_grant, _rmf_source + ) + ) + rmf_stream = stack.enter_context( + _spooled_copy( + rmf_selected, + max_bytes=MAX_RMF_BYTES, + cancellation_check=_cancellation_check, + ) + ) + return self._load_event_list_from_stream( + event_stream=event_stream, + source_name=Path(file_path).name, + name=name, + fmt=fmt, + rmf_stream=rmf_stream, + additional_columns=additional_columns, + high_precision=high_precision, + skip_checks=skip_checks, + notes=notes, + cancellation_check=_cancellation_check, + ) + + def _load_event_list_from_stream( + self, + event_stream: BinaryIO, + source_name: str, + name: str, + fmt: str, + rmf_stream: BinaryIO | None, + additional_columns: Optional[List[str]], + high_precision: bool, + skip_checks: bool, + notes: Optional[str], + cancellation_check=None, + ) -> Dict[str, Any]: + """ + Load an EventList from a file. + + Args: + file_path: Path to the event file + name: Name to assign to the loaded event list + fmt: File format (ogip, hdf5, hea, etc.) + rmf_file: Optional path to RMF file + additional_columns: Optional list of additional columns to read + high_precision: Use numpy.float128 for time array (pulsar timing) + skip_checks: Skip time ordering and GTI validation (performance) + notes: Optional user notes/comments about this data + + Returns: + Result dictionary with the EventList data + """ + try: + # Validate the name doesn't already exist + if self.state.has_event_data(name): + return self.create_result( + success=False, + data=None, + message=f"An event list with the name '{name}' already exists.", + error=None, + ) + + # Auto-detect format from file extension if not explicitly specified differently + file_ext = os.path.splitext(source_name)[1].lower() + if file_ext in [".hdf5", ".h5"]: + fmt = "hdf5" + elif file_ext in [".ecsv"]: + fmt = "ascii.ecsv" + # Otherwise use the provided fmt (default: ogip for FITS files) + + # Detect file type before attempting to load (for FITS files) + is_fits = fmt in ("ogip", "fits") + if is_fits: + with _cloned_stream( + event_stream, cancellation_check=cancellation_check + ) as detection_stream: + file_type_info = self._detect_fits_file_type(detection_stream) + if not file_type_info["is_event_list"]: + return self.create_result( + success=False, + data=None, + message=f"Cannot load '{name}' as Event List: " + f"{file_type_info['error_message']}", + error=f"File type: {file_type_info['file_type']}", + ) + + # Capture Stingray/library warnings during loading + stingray_warnings: List[str] = [] + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") # Catch all warnings + + # Load the event list using Stingray + _rewind(rmf_stream) + with _scientific_reader_source( + event_stream, + fmt=fmt, + cancellation_check=cancellation_check, + ) as reader_source: + if fmt == "hdf5": + if h5py is None: + raise RuntimeError("HDF5 support is unavailable") + with h5py.File(reader_source, "r") as hdf5_source: + event_list = EventList.read(hdf5_source, fmt=fmt) + else: + event_list = EventList.read( + reader_source, + fmt=fmt, + rmf_file=rmf_stream, + additional_columns=additional_columns, + high_precision=high_precision, + skip_checks=skip_checks, + ) + + # Collect warning messages + for w in caught_warnings: + safe_warning = _safe_scientific_warning(w) + if safe_warning is not None: + stingray_warnings.append(safe_warning) + if len(stingray_warnings) >= MAX_PUBLIC_SCIENTIFIC_WARNINGS: + break + + # Fix inverted GTI intervals (common with unsorted data) + # This must be done before storing, as Stingray's check_gtis + # will fail on later operations if GTI has start > stop + gti_was_fixed = self._fix_inverted_gti(event_list) + if gti_was_fixed: + stingray_warnings.append( + "GTI intervals were inverted (start > stop) and have been automatically fixed. " + "This typically occurs with unsorted event data." + ) + + # Store user notes on the event list object + if notes: + event_list.notes = notes + + # Add to state manager + _raise_if_cancelled(cancellation_check) + self.state.add_event_data(name, event_list) + + # Validate GTI and collect warnings + gti_warnings = self._validate_gti(event_list) + + # Run comprehensive data quality validation + validation_issues = self._validate_data_quality(event_list) + + # Prepare serializable summary (use helper for numpy type conversion) + summary = { + "name": name, + "n_events": len(event_list.time), + "time_range": [ + _to_python_float(event_list.time.min()), + _to_python_float(event_list.time.max()), + ], + "has_energy": event_list.energy is not None, + "has_pi": event_list.pi is not None, + "gti_count": len(event_list.gti) if event_list.gti is not None else 0, + "gti_warnings": gti_warnings if gti_warnings else None, + "stingray_warnings": stingray_warnings if stingray_warnings else None, + "validation_issues": validation_issues if validation_issues else None, + "notes": notes if notes else None, + } + + # Build message with warnings if present + message = f"EventList '{name}' loaded successfully ({len(event_list.time)} events)" + if gti_warnings: + message += f" [GTI warnings: {len(gti_warnings)}]" + if stingray_warnings: + message += f" [Stingray warnings: {len(stingray_warnings)}]" + if validation_issues: + error_count = sum( + 1 for v in validation_issues if v["severity"] == "error" + ) + warn_count = sum( + 1 for v in validation_issues if v["severity"] == "warning" + ) + if error_count > 0: + message += f" [Data errors: {error_count}]" + if warn_count > 0: + message += f" [Data warnings: {warn_count}]" + + return self.create_result( + success=True, + data=summary, + message=message, + ) + + except Exception: + return self.create_result( + success=False, + data=None, + message="The selected event file could not be read", + error="event_read_failed", + ) + + def load_event_list_from_url( + self, + url: str, + name: str, + fmt: str = "ogip", + rmf_file: Optional[str] = None, + rmf_grant: Optional[str] = None, + additional_columns: Optional[List[str]] = None, + high_precision: bool = False, + skip_checks: bool = False, + notes: Optional[str] = None, + *, + _rmf_source: GrantedReadFile | None = None, + _cancellation_check=None, + ) -> Dict[str, Any]: + """Fetch one bounded HTTPS source and load it from an anonymous spool.""" + fmt = require_input_event_format(fmt) + name_error = validate_derived_name(name) + if name_error: + raise ValueError(name_error) + if (rmf_file is None) != (rmf_grant is None) and _rmf_source is None: + raise ValueError("rmf_file and rmf_grant must be provided together") + with ExitStack() as stack: + retained_rmf = _rmf_source + if retained_rmf is None and rmf_file is not None: + retained_rmf = stack.enter_context( + _open_granted_source(rmf_file, rmf_grant, None) + ) + return asyncio.run( + self._load_event_list_from_url_async( + url=url, + name=name, + fmt=fmt, + rmf_file=rmf_file, + rmf_grant=rmf_grant, + additional_columns=additional_columns, + high_precision=high_precision, + skip_checks=skip_checks, + notes=notes, + rmf_source=retained_rmf, + cancellation_check=_cancellation_check, + ) + ) + + async def _load_event_list_from_url_async( + self, + *, + url: str, + name: str, + fmt: str, + rmf_file: str | None, + rmf_grant: str | None, + additional_columns: List[str] | None, + high_precision: bool, + skip_checks: bool, + notes: str | None, + rmf_source: GrantedReadFile | None, + cancellation_check, + ) -> Dict[str, Any]: + if self.state.has_event_data(name): + return self.create_result( + success=False, + data=None, + message=f"An event list with the name '{name}' already exists.", + error=None, + ) + + client = RemoteSourceClient(GENERAL_HTTPS_POLICY) + try: + with tempfile.SpooledTemporaryFile( + max_size=SPOOL_MEMORY_LIMIT, mode="w+b" + ) as event_stream: + async with client.stream( + url, + max_bytes=REMOTE_EVENT_LIMIT, + cancellation_check=cancellation_check, + ) as remote_stream: + async for chunk in remote_stream.aiter_bytes(): + event_stream.write(chunk) + event_stream.seek(0) + return await asyncio.to_thread( + self._load_remote_stream, + event_stream, + name, + fmt, + rmf_file, + rmf_grant, + additional_columns, + high_precision, + skip_checks, + notes, + rmf_source, + cancellation_check, + ) + except RemoteSourceCancelled: + return self.create_result( + success=False, + data=None, + message="Remote load was cancelled", + error="cancelled", + ) + except RemoteSourceError as error: + return self.create_result( + success=False, + data=None, + message="The selected remote event source could not be retrieved", + error=type(error).__name__, + ) + except Exception: + return self.create_result( + success=False, + data=None, + message="The selected remote event source could not be read", + error="remote_event_read_failed", + ) + + def _load_remote_stream( + self, + event_stream: BinaryIO, + name: str, + fmt: str, + rmf_file: str | None, + rmf_grant: str | None, + additional_columns: List[str] | None, + high_precision: bool, + skip_checks: bool, + notes: str | None, + rmf_source: GrantedReadFile | None, + cancellation_check=None, + ) -> Dict[str, Any]: + if (rmf_file is None) != (rmf_grant is None) and rmf_source is None: + raise ValueError("rmf_file and rmf_grant must be provided together") + with ExitStack() as stack: + rmf_stream = None + if rmf_file is not None or rmf_source is not None: + selected = stack.enter_context( + _open_granted_source( + rmf_file or "", rmf_grant, rmf_source + ) + ) + rmf_stream = stack.enter_context( + _spooled_copy( + selected, + max_bytes=MAX_RMF_BYTES, + cancellation_check=cancellation_check, + ) + ) + decoded_stream = stack.enter_context( + _decoded_event_stream( + event_stream, + fmt=fmt, + cancellation_check=cancellation_check, + ) + ) + result = self._load_event_list_from_stream( + event_stream=decoded_stream, + source_name=( + "remote.hdf5" + if fmt == "hdf5" + else "remote.ecsv" + if fmt == "ascii.ecsv" + else "remote.evt" + ), + name=name, + fmt=fmt, + rmf_stream=rmf_stream, + additional_columns=additional_columns, + high_precision=high_precision, + skip_checks=skip_checks, + notes=notes, + cancellation_check=cancellation_check, + ) + if result.get("success"): + result["message"] = f"EventList '{name}' loaded successfully from URL" + return result + + async def load_event_list_from_url_stream( + self, + url: str, + name: str, + fmt: str = "ogip", + rmf_file: Optional[str] = None, + rmf_grant: Optional[str] = None, + additional_columns: Optional[List[str]] = None, + high_precision: bool = False, + skip_checks: bool = False, + notes: Optional[str] = None, + ) -> AsyncGenerator[Dict[str, Any], None]: + """Stream bounded remote progress without exposing the selected URL.""" + fmt = require_input_event_format(fmt) + name_error = validate_derived_name(name) + if name_error: + raise ValueError(name_error) + if self.state.has_event_data(name): + yield { + "type": "error", + "error": f"An event list with the name '{name}' already exists.", + } + return + + cancellation_signal = threading.Event() + client = RemoteSourceClient(GENERAL_HTTPS_POLICY) + try: + with ExitStack() as stack: + if (rmf_file is None) != (rmf_grant is None): + raise ValueError("rmf_file and rmf_grant must be provided together") + retained_rmf = None + if rmf_file is not None: + retained_rmf = stack.enter_context( + _open_granted_source(rmf_file, rmf_grant, None) + ) + event_stream = stack.enter_context( + tempfile.SpooledTemporaryFile( + max_size=SPOOL_MEMORY_LIMIT, mode="w+b" + ) + ) + async with client.stream( + url, + max_bytes=REMOTE_EVENT_LIMIT, + cancellation_check=cancellation_signal.is_set, + ) as remote_stream: + total_bytes = remote_stream.info.content_length or 0 + async for chunk in remote_stream.aiter_bytes(): + event_stream.write(chunk) + downloaded = remote_stream.bytes_read + percent = ( + downloaded / total_bytes * 100 if total_bytes > 0 else 0 + ) + yield { + "type": "progress", + "bytes_downloaded": downloaded, + "total_bytes": total_bytes, + "percent": round(percent, 1), + } + await asyncio.sleep(0) + + yield { + "type": "processing", + "message": "Download complete, loading event list...", + } + event_stream.seek(0) + processing_task = asyncio.create_task( + asyncio.to_thread( + self._load_remote_stream, + event_stream, + name, + fmt, + rmf_file, + rmf_grant, + additional_columns, + high_precision, + skip_checks, + notes, + retained_rmf, + cancellation_signal.is_set, + ) + ) + try: + result = await asyncio.shield(processing_task) + except asyncio.CancelledError: + # to_thread workers are not cancelled with their awaiting + # task. Signal the worker and drain it before ExitStack + # closes the pinned RMF and anonymous event snapshot. + cancellation_signal.set() + while not processing_task.done(): + try: + await asyncio.shield(processing_task) + except asyncio.CancelledError: + # Repeated disconnect/cancel signals must not break + # ownership before the non-cancellable thread exits. + continue + try: + processing_task.result() + except BaseException: + pass + raise + if result.get("success"): + yield { + "type": "complete", + "data": result.get("data"), + "message": result.get("message"), + } + else: + yield { + "type": "error", + "error": result.get("message") or "Remote load failed", + } + except RemoteSourceCancelled: + yield {"type": "error", "error": "Remote load was cancelled"} + except RemoteSourceError: + yield { + "type": "error", + "error": "The selected remote event source could not be retrieved", + } + except asyncio.CancelledError: + raise + except Exception: + yield { + "type": "error", + "error": "Error loading the selected remote event list", + } + finally: + cancellation_signal.set() + + def delete_event_list(self, name: str) -> Dict[str, Any]: + """Delete an EventList from state.""" + try: + if not self.state.has_event_data(name): + return self.create_result( + success=False, + data=None, + message=f"No event list found with name '{name}'", + error=None, + ) + + self.state.remove_event_data(name) + + return self.create_result( + success=True, + data={"name": name}, + message=f"EventList '{name}' deleted successfully", + ) + + except Exception as e: + return self.handle_error(e, "Deleting event list", name=name) + + def get_event_list_info(self, name: str) -> Dict[str, Any]: + """ + Get information about an EventList. + + Args: + name: Name of the event list + + Returns: + Result dictionary with event list information + """ + try: + if not self.state.has_event_data(name): + return self.create_result( + success=False, + data=None, + message=f"No event list found with name '{name}'", + error=None, + ) + + event_list = self.state.get_event_data(name) + + # Basic info - use helper functions for numpy type conversion + duration = _to_python_float(event_list.time.max() - event_list.time.min()) + info = { + "name": name, + "n_events": len(event_list.time), + "time_range": [ + _to_python_float(event_list.time.min()), + _to_python_float(event_list.time.max()), + ], + "duration": duration, + "has_energy": event_list.energy is not None, + "has_pi": event_list.pi is not None, + "gti_count": len(event_list.gti) if event_list.gti is not None else 0, + "mjdref": _to_python_float(event_list.mjdref), + } + + # GTI details + if event_list.gti is not None and len(event_list.gti) > 0: + info["gti_list"] = [ + [_to_python_float(g[0]), _to_python_float(g[1])] + for g in event_list.gti + ] + info["total_gti_time"] = _to_python_float( + sum(g[1] - g[0] for g in event_list.gti) + ) + + # Energy/PI range + if event_list.energy is not None: + info["energy_range"] = [ + _to_python_float(event_list.energy.min()), + _to_python_float(event_list.energy.max()), + ] + if event_list.pi is not None: + info["pi_range"] = [int(event_list.pi.min()), int(event_list.pi.max())] + + # Mission metadata (if available) + if hasattr(event_list, "mission") and event_list.mission: + info["mission"] = str(event_list.mission) + if hasattr(event_list, "instr") and event_list.instr: + info["instrument"] = str(event_list.instr) + + # Time statistics + if len(event_list.time) > 1: + # Sort times to get accurate time differences + sorted_times = np.sort(event_list.time) + time_diffs = sorted_times[1:] - sorted_times[:-1] + info["mean_count_rate"] = ( + _to_python_float(len(event_list.time) / duration) + if duration and duration > 0 + else 0 + ) + info["min_time_diff"] = _to_python_float(time_diffs.min()) + info["max_time_diff"] = _to_python_float(time_diffs.max()) + info["mean_time_diff"] = _to_python_float(np.mean(time_diffs)) + info["median_time_diff"] = _to_python_float(np.median(time_diffs)) + info["std_time_diff"] = _to_python_float(np.std(time_diffs)) + + # Per-GTI rates + if event_list.gti is not None and len(event_list.gti) > 0: + per_gti_rates = [] + for start, stop in event_list.gti: + mask = (event_list.time >= start) & (event_list.time <= stop) + gti_events = int(np.sum(mask)) + gti_duration = float(stop - start) + rate = gti_events / gti_duration if gti_duration > 0 else 0 + per_gti_rates.append( + { + "start": _to_python_float(start), + "stop": _to_python_float(stop), + "events": gti_events, + "duration": _to_python_float(gti_duration), + "rate": _to_python_float(rate), + } + ) + info["per_gti_rates"] = per_gti_rates + + # Notes + info["notes"] = getattr(event_list, "notes", None) or None + + return self.create_result( + success=True, + data=info, + message=f"EventList '{name}' info retrieved", + ) + + except Exception as e: + return self.handle_error(e, "Getting event list info", name=name) + + def list_event_lists(self) -> Dict[str, Any]: + """List all loaded EventLists.""" + try: + event_data = self.state.get_event_data() + + summaries = [] + for name, event_list in event_data: + summaries.append( + { + "name": name, + "n_events": len(event_list.time), + "time_range": [ + _to_python_float(event_list.time.min()), + _to_python_float(event_list.time.max()), + ], + "has_energy": event_list.energy is not None, + "has_pi": event_list.pi is not None, + "gti_count": len(event_list.gti) + if event_list.gti is not None + else 0, + } + ) + + return self.create_result( + success=True, + data=summaries, + message=f"Found {len(summaries)} event list(s)", + ) + + except Exception as e: + return self.handle_error(e, "Listing event lists") + + def check_file_size( + self, + file_path: str, + file_grant: str | None = None, + *, + _file_source: GrantedReadFile | None = None, + _cancellation_check=None, + ) -> Dict[str, Any]: + """Check file size and provide loading recommendations based on available RAM.""" + try: + with _open_granted_source(file_path, file_grant, _file_source) as selected: + file_size = selected.size_bytes + file_size_mb = file_size / (1024**2) + file_size_gb = file_size / (1024**3) + + # Get memory info first - we need this for smart recommendations + memory_info = self._get_memory_info() + available_ram_mb = memory_info["available_mb"] + + # Estimate memory needed to load the EventList + # FITS files typically expand to ~3x file size in memory + estimated_memory_mb = self._estimate_memory_usage(file_size, "fits") / ( + 1024**2 + ) + + # Calculate what percentage of available RAM this would use + ram_usage_percent = ( + (estimated_memory_mb / available_ram_mb) * 100 + if available_ram_mb > 0 + else 100 + ) + + # Determine risk level based on RAM usage percentage + # This is smarter than just file size - adapts to user's system + if ram_usage_percent > 80: + risk_level = "critical" # Would use >80% of available RAM + elif ram_usage_percent > 50: + risk_level = "risky" # Would use >50% of available RAM + elif ram_usage_percent > 30: + risk_level = "caution" # Would use >30% of available RAM + else: + risk_level = "safe" # Would use <30% of available RAM + + # Recommend lazy loading if it would use more than 30% of available RAM + recommend_lazy = ram_usage_percent > 30 + + return self.create_result( + success=True, + data={ + "file_size_bytes": file_size, + "file_size_mb": file_size_mb, + "file_size_gb": file_size_gb, + "risk_level": risk_level, + "recommend_lazy": recommend_lazy, + "estimated_memory_mb": estimated_memory_mb, + "ram_usage_percent": round(ram_usage_percent, 1), + "memory_info": memory_info, + }, + message=f"File size: {file_size_mb:.2f} MB, Est. RAM usage: {ram_usage_percent:.1f}% of available", + ) + + except Exception: + return self.create_result( + success=False, + data=None, + message="The selected file size could not be checked", + error="file_size_check_failed", + ) + + def clear_all_event_lists(self) -> Dict[str, Any]: + """Clear all loaded event lists from memory.""" + try: + count = self.state.clear_event_data() + + return self.create_result( + success=True, + data={"count": count}, + message=f"Cleared {count} event list(s) from memory", + ) + + except Exception as e: + return self.handle_error(e, "Clearing all event lists") + + def _get_memory_info(self) -> Dict[str, Any]: + """Get current system memory information.""" + vm = psutil.virtual_memory() + process = psutil.Process() + return { + "total_mb": vm.total / (1024**2), + "available_mb": vm.available / (1024**2), + "used_mb": vm.used / (1024**2), + "percent": vm.percent, + "process_mb": process.memory_info().rss / (1024**2), + } + + def _estimate_memory_usage(self, file_size: int, fmt: str = "fits") -> int: + """ + Estimate memory needed to load file into EventList. + + Based on Stingray's official benchmarks: + - FITS event file: ~3x file size + - HDF5: ~2x file size + """ + fmt = require_input_event_format(fmt) + multipliers = { + "fits": 3, + "ogip": 3, + "hdf5": 2, + } + multiplier = multipliers.get(fmt, 3) + return int(file_size * multiplier) + + def _can_load_safely( + self, + file_size: int, + safety_margin: float = 0.5, + fmt: str = "fits", + ) -> bool: + """Check if file can be safely loaded into memory.""" + fmt = require_input_event_format(fmt) + available_ram = psutil.virtual_memory().available + needed_ram = self._estimate_memory_usage(file_size, fmt) + safe_limit = available_ram * safety_margin + return needed_ram < safe_limit + + def get_event_list_full_preview( + self, name: str, time_limit: int = 10 + ) -> Dict[str, Any]: + """ + Get full preview of an EventList with all attributes. + + Args: + name: Name of the event list + time_limit: Number of time entries to show in preview + + Returns: + Result dictionary with comprehensive preview data + """ + try: + if not self.state.has_event_data(name): + return self.create_result( + success=False, + data=None, + message=f"No event list found with name '{name}'", + error=None, + ) + + event_list = self.state.get_event_data(name) + + # Build comprehensive preview + # Use helper functions to convert numpy types (including longdouble) to Python types + preview = { + "name": name, + # Core data - use explicit float conversion for longdouble support + "times_preview": _to_python_float_list(event_list.time[:time_limit]), + "n_events": len(event_list.time), + "time_range": [ + _to_python_float(event_list.time.min()), + _to_python_float(event_list.time.max()), + ], + "duration": _to_python_float( + event_list.time.max() - event_list.time.min() + ), + # Energy data + "has_energy": event_list.energy is not None, + "energy_preview": ( + _to_python_float_list(event_list.energy[:time_limit]) + if event_list.energy is not None + else None + ), + "energy_range": ( + [ + _to_python_float(event_list.energy.min()), + _to_python_float(event_list.energy.max()), + ] + if event_list.energy is not None + else None + ), + # PI data + "has_pi": event_list.pi is not None, + "pi_preview": ( + [int(x) for x in event_list.pi[:time_limit]] + if event_list.pi is not None + else None + ), + "pi_range": ( + [int(event_list.pi.min()), int(event_list.pi.max())] + if event_list.pi is not None + else None + ), + # GTI data - sort each interval to handle inverted GTIs from unsorted data + # When Stingray loads unsorted data without a GTI extension, it sets + # GTI to [time[0], time[-1]] which can have start > stop for unsorted times + "gti_count": 0, # Will be set below + "gti_list": None, # Will be set below + "total_gti_time": None, # Will be set below + # Reference time - mjdref is often numpy.longdouble + "mjdref": _to_python_float(event_list.mjdref), + # Metadata (if available) - convert to string to be safe + "mission": str(getattr(event_list, "mission", None)) + if getattr(event_list, "mission", None) + else None, + "instrument": str(getattr(event_list, "instr", None)) + if getattr(event_list, "instr", None) + else None, + "detector_id": ( + str(getattr(event_list, "detector_id", None)) + if hasattr(event_list, "detector_id") + and event_list.detector_id is not None + else None + ), + "ephem": str(getattr(event_list, "ephem", None)) + if getattr(event_list, "ephem", None) + else None, + "timeref": str(getattr(event_list, "timeref", None)) + if getattr(event_list, "timeref", None) + else None, + "timesys": str(getattr(event_list, "timesys", None)) + if getattr(event_list, "timesys", None) + else None, + # Statistics + "mean_count_rate": None, + "min_time_diff": None, + "max_time_diff": None, + } + + # Process GTI data - sort each interval to handle inverted GTIs from unsorted data + # When Stingray loads unsorted data without a GTI extension, it sets + # GTI to [time[0], time[-1]] which can have start > stop for unsorted times + valid_gti = None + if event_list.gti is not None and len(event_list.gti) > 0: + # Sort each GTI interval to ensure [start, stop] order (start <= stop) + valid_gti = [ + [ + _to_python_float(min(g[0], g[1])), + _to_python_float(max(g[0], g[1])), + ] + for g in event_list.gti + ] + preview["gti_count"] = len(valid_gti) if valid_gti else 0 + preview["gti_list"] = valid_gti + preview["total_gti_time"] = ( + _to_python_float(sum(g[1] - g[0] for g in valid_gti)) + if valid_gti + else None + ) + + # Calculate time statistics + duration = preview["duration"] + if duration and duration > 0: + preview["mean_count_rate"] = _to_python_float( + len(event_list.time) / duration + ) + + if len(event_list.time) > 1: + sorted_times = np.sort(event_list.time) + time_diffs = sorted_times[1:] - sorted_times[:-1] + preview["min_time_diff"] = _to_python_float(time_diffs.min()) + preview["max_time_diff"] = _to_python_float(time_diffs.max()) + preview["mean_time_diff"] = _to_python_float(time_diffs.mean()) + # Enhanced time statistics + preview["median_time_diff"] = _to_python_float(np.median(time_diffs)) + preview["std_time_diff"] = _to_python_float(np.std(time_diffs)) + + # Per-GTI rates - use the validated/sorted GTI intervals + if valid_gti: + per_gti_rates = [] + for gti_entry in valid_gti: + start, stop = gti_entry[0], gti_entry[1] + mask = (event_list.time >= start) & (event_list.time <= stop) + gti_events = int(np.sum(mask)) + gti_duration = float(stop - start) + rate = gti_events / gti_duration if gti_duration > 0 else 0 + per_gti_rates.append( + { + "start": _to_python_float(start), + "stop": _to_python_float(stop), + "events": gti_events, + "duration": _to_python_float(gti_duration), + "rate": _to_python_float(rate), + } + ) + preview["per_gti_rates"] = per_gti_rates + + # Check for additional columns + additional_cols = [] + for attr in dir(event_list): + if not attr.startswith("_") and attr not in [ + "time", + "energy", + "pi", + "gti", + "mjdref", + "mission", + "instr", + "detector_id", + "ephem", + "timeref", + "timesys", + "header", + "notes", + "ncounts", + "dt", + "n", + ]: + val = getattr(event_list, attr, None) + if isinstance(val, np.ndarray) and len(val) == len(event_list.time): + additional_cols.append(attr) + preview["additional_columns"] = additional_cols + + # User notes + preview["notes"] = getattr(event_list, "notes", None) or None + + # Data quality validation + validation_issues = self._validate_data_quality(event_list) + preview["validation_issues"] = ( + validation_issues if validation_issues else None + ) + + # FITS header information + header_info = self._extract_fits_header(event_list) + preview["header_info"] = header_info if header_info else None + + return self.create_result( + success=True, + data=preview, + message=f"Full preview for '{name}' retrieved", + ) + + except Exception as e: + return self.handle_error(e, "Getting event list full preview", name=name) + + # ========================================================================= + # PARTIAL LOADING METHODS + # These methods use FITSTimeseriesReader to load only a portion of the file + # ========================================================================= + # + # TODO: Implement true chunk-based lazy loading for streaming analysis + # + # FITSTimeseriesReader supports genuine lazy/streaming I/O via: + # - reader.split_by_number_of_samples(N) -> Generator yielding N-event chunks + # - reader.filter_at_time_intervals(intervals) -> Generator for time ranges + # - reader.apply_gti_lists(gti_lists) -> Generator for GTI-based splits + # + # This would allow processing huge files without loading them fully: + # for chunk in reader.split_by_number_of_samples(100000): + # ps = AveragedPowerspectrum(chunk, segment_size=128) + # # Aggregate results... + # + # Implementation would require: + # 1. Store FITSTimeseriesReader objects in StateManager (not just EventLists) + # 2. Create generator-based iteration endpoints + # 3. Implement chunked analysis methods (works well with Averaged* classes) + # + # Note: Only "averaged" analysis methods (AveragedPowerspectrum, etc.) benefit + # from chunking. Single-FFT methods need all data at once. + # ========================================================================= + + def load_event_list_by_time_range( + self, + file_path: str, + name: str, + start_time: float, + end_time: float, + fmt: str = "ogip", + notes: Optional[str] = None, + file_grant: str | None = None, + *, + _file_source: GrantedReadFile | None = None, + _cancellation_check=None, + ) -> Dict[str, Any]: + """Load a time slice after retaining the exact granted input.""" + fmt = require_input_event_format(fmt) + name_error = validate_derived_name(name) + if name_error: + raise ValueError(name_error) + with ExitStack() as stack: + selected = stack.enter_context( + _open_granted_source(file_path, file_grant, _file_source) + ) + retained_event_stream = stack.enter_context( + _spooled_copy( + selected, + max_bytes=MAX_FITS_INSPECT_BYTES, + cancellation_check=_cancellation_check, + ) + ) + retained_times_stream = stack.enter_context( + _spooled_copy( + selected, + max_bytes=MAX_FITS_INSPECT_BYTES, + cancellation_check=_cancellation_check, + ) + ) + event_stream = stack.enter_context( + _decoded_event_stream( + retained_event_stream, + fmt=fmt, + cancellation_check=_cancellation_check, + ) + ) + times_stream = stack.enter_context( + _decoded_event_stream( + retained_times_stream, + fmt=fmt, + cancellation_check=_cancellation_check, + ) + ) + event_source = stack.enter_context( + _scientific_reader_source( + event_stream, + fmt=fmt, + cancellation_check=_cancellation_check, + ) + ) + times_source = stack.enter_context( + _scientific_reader_source( + times_stream, + fmt=fmt, + cancellation_check=_cancellation_check, + ) + ) + return self._load_event_list_by_time_range_from_stream( + event_source, + times_source, + name, + start_time, + end_time, + fmt, + notes, + _cancellation_check, + ) + + def _load_event_list_by_time_range_from_stream( + self, + event_stream: BinaryIO | str, + times_stream: BinaryIO | str, + name: str, + start_time: float, + end_time: float, + fmt: str, + notes: Optional[str], + cancellation_check=None, + ) -> Dict[str, Any]: + """ + Load events within a specific time range using true lazy loading. + + Uses FITSTimeseriesReader.filter_at_time_intervals() to load only + events within the specified time window without reading the entire file. + + Args: + file_path: Path to the FITS event file + name: Name to assign to the loaded event list + start_time: Start time (in seconds from file start or absolute) + end_time: End time (in seconds from file start or absolute) + fmt: File format (only FITS formats supported for lazy loading) + notes: Optional user notes/comments about this data + + Returns: + Result dictionary with the filtered EventList + """ + try: + # Validate the name doesn't already exist + if self.state.has_event_data(name): + return self.create_result( + success=False, + data=None, + message=f"An event list with the name '{name}' already exists.", + error=None, + ) + + # Check format - only FITS supports true lazy loading + is_fits = fmt in ("ogip", "fits") + if not is_fits: + return self.create_result( + success=False, + data=None, + message=f"True lazy loading only supports FITS formats. Got: {fmt}", + error="Unsupported format for lazy loading", + ) + + # Detect file type before attempting to load + if isinstance(event_stream, str): + file_type_info = self._detect_fits_file_type(event_stream) + else: + with _cloned_stream( + event_stream, cancellation_check=cancellation_check + ) as detection_stream: + file_type_info = self._detect_fits_file_type(detection_stream) + if not file_type_info["is_event_list"]: + return self.create_result( + success=False, + data=None, + message=f"Cannot load '{name}' as Event List: " + f"{file_type_info['error_message']}", + error=f"File type: {file_type_info['file_type']}", + ) + + # Create the reader + if not isinstance(event_stream, str): + _rewind(event_stream) + reader = FITSTimeseriesReader( + event_stream, output_class=EventList, data_kind="events" + ) + + # Get file metadata + original_gti = reader.gti + mjdref = getattr(reader, "mjdref", 0.0) + mission = getattr(reader, "mission", None) + instr = getattr(reader, "instr", None) + + # Get total event count for reporting + if not isinstance(times_stream, str): + _rewind(times_stream) + times_reader = FITSTimeseriesReader(times_stream, data_kind="times") + total_events = len(times_reader[:]) + + # Calculate absolute times if relative times are provided + # If start_time is small (< 1000), treat as relative to GTI start + if original_gti is not None and len(original_gti) > 0: + gti_start = float(original_gti[0, 0]) + gti_end = float(original_gti[-1, 1]) + total_duration = float(np.sum(original_gti[:, 1] - original_gti[:, 0])) + + # If times look relative (small values), convert to absolute + if start_time < 1000 and end_time < 1000: + abs_start = gti_start + start_time + abs_end = gti_start + end_time + else: + abs_start = start_time + abs_end = end_time + + # Clamp to valid range + abs_start = max(abs_start, gti_start) + abs_end = min(abs_end, gti_end) + else: + abs_start = start_time + abs_end = end_time + total_duration = 0.0 + + if abs_start >= abs_end: + return self.create_result( + success=False, + data=None, + message=f"Invalid time range: start ({abs_start}) >= end ({abs_end})", + error="Invalid time range", + ) + + # Use filter_at_time_intervals to get events in the time range + # This returns a generator - we get the first (and only) result + time_intervals = [[abs_start, abs_end]] + event_list = None + + for filtered_events in reader.filter_at_time_intervals(time_intervals): + event_list = filtered_events + break # Only one interval + + if event_list is None or len(event_list.time) == 0: + return self.create_result( + success=False, + data=None, + message=f"No events found in time range [{start_time}, {end_time}]", + error="Empty time range", + ) + + # Set metadata that might not be transferred + event_list.mjdref = _to_python_float(mjdref) or 0.0 + if mission: + event_list.mission = mission + if instr: + event_list.instr = instr + + # Store user notes on the event list object + if notes: + event_list.notes = notes + + # Fix inverted GTI intervals (common with unsorted data) + gti_was_fixed = self._fix_inverted_gti(event_list) + + # Add to state manager + _raise_if_cancelled(cancellation_check) + self.state.add_event_data(name, event_list) + + # Validate GTI and collect warnings + gti_warnings = self._validate_gti(event_list) + if gti_was_fixed: + gti_warnings.insert( + 0, "GTI intervals were inverted and automatically fixed." + ) + + # Run comprehensive data quality validation + validation_issues = self._validate_data_quality(event_list) + + # Calculate loaded duration + loaded_duration = abs_end - abs_start + + summary = { + "name": name, + "n_events": len(event_list.time), + "time_range": [ + _to_python_float(event_list.time.min()), + _to_python_float(event_list.time.max()), + ], + "has_energy": event_list.energy is not None, + "has_pi": event_list.pi is not None, + "gti_count": len(event_list.gti) if event_list.gti is not None else 0, + "gti_warnings": gti_warnings if gti_warnings else None, + "validation_issues": validation_issues if validation_issues else None, + "notes": notes if notes else None, + "lazy_loading_info": { + "method": "time_range", + "requested_range": [start_time, end_time], + "actual_range": [abs_start, abs_end], + "loaded_duration": loaded_duration, + "total_file_duration": total_duration, + "total_file_events": total_events, + "events_loaded_percent": (len(event_list.time) / total_events * 100) + if total_events > 0 + else 0, + }, + } + + message = ( + f"Lazy loaded '{name}': {len(event_list.time)} events " + f"({summary['lazy_loading_info']['events_loaded_percent']:.1f}% of file) " + f"from time range [{start_time:.1f}s - {end_time:.1f}s]" + ) + if gti_warnings: + message += f" [GTI warnings: {len(gti_warnings)}]" + if validation_issues: + error_count = sum( + 1 for v in validation_issues if v["severity"] == "error" + ) + warn_count = sum( + 1 for v in validation_issues if v["severity"] == "warning" + ) + if error_count > 0: + message += f" [Data errors: {error_count}]" + if warn_count > 0: + message += f" [Data warnings: {warn_count}]" + + return self.create_result( + success=True, + data=summary, + message=message, + ) + + except Exception: + return self.create_result( + success=False, + data=None, + message="The selected event time range could not be read", + error="event_range_read_failed", + ) + + def load_event_list_by_event_count( + self, + file_path: str, + name: str, + start_index: int = 0, + count: int = 10000, + fmt: str = "ogip", + notes: Optional[str] = None, + file_grant: str | None = None, + *, + _file_source: GrantedReadFile | None = None, + _cancellation_check=None, + ) -> Dict[str, Any]: + """Load an event slice after retaining the exact granted input.""" + fmt = require_input_event_format(fmt) + name_error = validate_derived_name(name) + if name_error: + raise ValueError(name_error) + with ExitStack() as stack: + selected = stack.enter_context( + _open_granted_source(file_path, file_grant, _file_source) + ) + retained_event_stream = stack.enter_context( + _spooled_copy( + selected, + max_bytes=MAX_FITS_INSPECT_BYTES, + cancellation_check=_cancellation_check, + ) + ) + retained_times_stream = stack.enter_context( + _spooled_copy( + selected, + max_bytes=MAX_FITS_INSPECT_BYTES, + cancellation_check=_cancellation_check, + ) + ) + event_stream = stack.enter_context( + _decoded_event_stream( + retained_event_stream, + fmt=fmt, + cancellation_check=_cancellation_check, + ) + ) + times_stream = stack.enter_context( + _decoded_event_stream( + retained_times_stream, + fmt=fmt, + cancellation_check=_cancellation_check, + ) + ) + event_source = stack.enter_context( + _scientific_reader_source( + event_stream, + fmt=fmt, + cancellation_check=_cancellation_check, + ) + ) + times_source = stack.enter_context( + _scientific_reader_source( + times_stream, + fmt=fmt, + cancellation_check=_cancellation_check, + ) + ) + return self._load_event_list_by_event_count_from_stream( + event_source, + times_source, + name, + start_index, + count, + fmt, + notes, + _cancellation_check, + ) + + def _load_event_list_by_event_count_from_stream( + self, + event_stream: BinaryIO | str, + times_stream: BinaryIO | str, + name: str, + start_index: int, + count: int, + fmt: str, + notes: Optional[str], + cancellation_check=None, + ) -> Dict[str, Any]: + """ + Load a specific number of events using true lazy loading. + + Uses FITSTimeseriesReader slicing to load only the requested events + without reading the entire file into memory. + + Args: + file_path: Path to the FITS event file + name: Name to assign to the loaded event list + start_index: Starting event index (0-based) + count: Number of events to load + fmt: File format (only FITS formats supported for lazy loading) + notes: Optional user notes/comments about this data + + Returns: + Result dictionary with the sliced EventList + """ + try: + # Validate the name doesn't already exist + if self.state.has_event_data(name): + return self.create_result( + success=False, + data=None, + message=f"An event list with the name '{name}' already exists.", + error=None, + ) + + # Check format + is_fits = fmt in ("ogip", "fits") + if not is_fits: + return self.create_result( + success=False, + data=None, + message=f"True lazy loading only supports FITS formats. Got: {fmt}", + error="Unsupported format for lazy loading", + ) + + # Detect file type before attempting to load + if isinstance(event_stream, str): + file_type_info = self._detect_fits_file_type(event_stream) + else: + with _cloned_stream( + event_stream, cancellation_check=cancellation_check + ) as detection_stream: + file_type_info = self._detect_fits_file_type(detection_stream) + if not file_type_info["is_event_list"]: + return self.create_result( + success=False, + data=None, + message=f"Cannot load '{name}' as Event List: " + f"{file_type_info['error_message']}", + error=f"File type: {file_type_info['file_type']}", + ) + + # Create the reader + if not isinstance(event_stream, str): + _rewind(event_stream) + reader = FITSTimeseriesReader( + event_stream, output_class=EventList, data_kind="events" + ) + + # Get file metadata + original_gti = reader.gti + mjdref = getattr(reader, "mjdref", 0.0) + mission = getattr(reader, "mission", None) + instr = getattr(reader, "instr", None) + + # Get total event count + if not isinstance(times_stream, str): + _rewind(times_stream) + times_reader = FITSTimeseriesReader(times_stream, data_kind="times") + all_times = times_reader[:] + total_events = len(all_times) + + if original_gti is not None and len(original_gti) > 0: + total_duration = float(np.sum(original_gti[:, 1] - original_gti[:, 0])) + else: + total_duration = ( + float(all_times.max() - all_times.min()) + if total_events > 0 + else 0.0 + ) + + # Validate indices + if start_index < 0: + start_index = 0 + if start_index >= total_events: + return self.create_result( + success=False, + data=None, + message=f"Start index {start_index} exceeds total events {total_events}", + error="Invalid start index", + ) + + end_index = min(start_index + count, total_events) + actual_count = end_index - start_index + + # Use slice to load only requested events + event_list = reader[start_index:end_index] + + if event_list is None or len(event_list.time) == 0: + return self.create_result( + success=False, + data=None, + message=f"No events found in range [{start_index}:{end_index}]", + error="Empty event range", + ) + + # Set metadata that might not be transferred + event_list.mjdref = _to_python_float(mjdref) or 0.0 + if mission: + event_list.mission = mission + if instr: + event_list.instr = instr + + # Store user notes on the event list object + if notes: + event_list.notes = notes + + # Fix inverted GTI intervals (common with unsorted data) + gti_was_fixed = self._fix_inverted_gti(event_list) + + # Add to state manager + _raise_if_cancelled(cancellation_check) + self.state.add_event_data(name, event_list) + + # Validate GTI and collect warnings + gti_warnings = self._validate_gti(event_list) + if gti_was_fixed: + gti_warnings.insert( + 0, "GTI intervals were inverted and automatically fixed." + ) + + # Run comprehensive data quality validation + validation_issues = self._validate_data_quality(event_list) + + summary = { + "name": name, + "n_events": len(event_list.time), + "time_range": [ + _to_python_float(event_list.time.min()), + _to_python_float(event_list.time.max()), + ], + "has_energy": event_list.energy is not None, + "has_pi": event_list.pi is not None, + "gti_count": len(event_list.gti) if event_list.gti is not None else 0, + "gti_warnings": gti_warnings if gti_warnings else None, + "validation_issues": validation_issues if validation_issues else None, + "notes": notes if notes else None, + "lazy_loading_info": { + "method": "event_count", + "start_index": start_index, + "end_index": end_index, + "events_requested": count, + "events_loaded": actual_count, + "total_file_events": total_events, + "total_file_duration": total_duration, + "events_loaded_percent": (actual_count / total_events * 100) + if total_events > 0 + else 0, + }, + } + + message = ( + f"Lazy loaded '{name}': {actual_count} events " + f"({summary['lazy_loading_info']['events_loaded_percent']:.1f}% of file) " + f"from indices [{start_index}:{end_index}]" + ) + if gti_warnings: + message += f" [GTI warnings: {len(gti_warnings)}]" + if validation_issues: + error_count = sum( + 1 for v in validation_issues if v["severity"] == "error" + ) + warn_count = sum( + 1 for v in validation_issues if v["severity"] == "warning" + ) + if error_count > 0: + message += f" [Data errors: {error_count}]" + if warn_count > 0: + message += f" [Data warnings: {warn_count}]" + + return self.create_result( + success=True, + data=summary, + message=message, + ) + + except Exception: + return self.create_result( + success=False, + data=None, + message="The selected event range could not be read", + error="event_count_read_failed", + ) + + def get_file_metadata( + self, + file_path: str, + fmt: str = "ogip", + file_grant: str | None = None, + *, + _file_source: GrantedReadFile | None = None, + ) -> Dict[str, Any]: + """Inspect metadata only through the exact granted input.""" + fmt = require_input_event_format(fmt) + with ExitStack() as stack: + selected = stack.enter_context( + _open_granted_source(file_path, file_grant, _file_source) + ) + retained_stream = stack.enter_context( + _spooled_copy(selected, max_bytes=MAX_FITS_INSPECT_BYTES) + ) + stream = stack.enter_context( + _decoded_event_stream(retained_stream, fmt=fmt) + ) + return self._get_file_metadata_from_stream(stream, selected.size_bytes, fmt) + + def _get_file_metadata_from_stream( + self, + file_stream: BinaryIO, + file_size: int, + fmt: str, + ) -> Dict[str, Any]: + """ + Get metadata from a FITS file without loading the full data. + + Uses FITSTimeseriesReader to efficiently read only metadata: + - Total event count + - Time range + - GTI information + - Mission/instrument info + - Available columns + + This is useful for previewing large files before deciding + what portion to load. + + Args: + file_path: Path to the FITS event file + fmt: File format + + Returns: + Result dictionary with file metadata + """ + try: + # Check format + is_fits = fmt in ("ogip", "fits") + if not is_fits: + return self.create_result( + success=False, + data=None, + message=f"Metadata preview only supports FITS formats. Got: {fmt}", + error="Unsupported format", + ) + + file_size_mb = file_size / (1024**2) + file_size_gb = file_size / (1024**3) + + # Read metadata directly from FITS headers - NO full data loading! + # This is much faster than using FITSTimeseriesReader for large files + total_events = 0 + time_min = None + time_max = None + available_columns = [] + mjdref = 0.0 + mission = None + instr = None + original_gti = None + + _rewind(file_stream) + with fits.open(file_stream, memmap=False) as hdulist: + # Find the EVENTS or EVT extension + events_hdu = None + for hdu in hdulist: + if hdu.name.upper() in ["EVENTS", "EVT"]: + events_hdu = hdu + break + + if events_hdu is not None: + # Get row count from header (NAXIS2) - no data loading! + total_events = events_hdu.header.get("NAXIS2", 0) + available_columns = [col.name for col in events_hdu.columns] + + # Get MJDREF from header + mjdref = events_hdu.header.get("MJDREF", 0.0) + if mjdref == 0.0: + # Some files split MJDREF into integer and fractional parts + mjdrefi = events_hdu.header.get("MJDREFI", 0) + mjdreff = events_hdu.header.get("MJDREFF", 0.0) + mjdref = mjdrefi + mjdreff + + # Get mission/instrument from header + mission = events_hdu.header.get( + "TELESCOP", None + ) or events_hdu.header.get("MISSION", None) + instr = events_hdu.header.get("INSTRUME", None) + + # Get time range from header keywords if available (fast!) + tstart = events_hdu.header.get("TSTART", None) + tstop = events_hdu.header.get("TSTOP", None) + + if tstart is not None and tstop is not None: + time_min = float(tstart) + time_max = float(tstop) + elif total_events > 0: + # Fallback: read only first and last few rows (much faster than full load) + time_col = events_hdu.data["TIME"] + time_min = float(time_col[0]) + time_max = float(time_col[-1]) + + # Read GTI extension (usually small, OK to load fully) + gti_hdu = None + for hdu in hdulist: + if hdu.name.upper() in ["GTI", "STDGTI"]: + gti_hdu = hdu + break + + if gti_hdu is not None and gti_hdu.data is not None: + start_col = ( + gti_hdu.data["START"] + if "START" in gti_hdu.columns.names + else None + ) + stop_col = ( + gti_hdu.data["STOP"] + if "STOP" in gti_hdu.columns.names + else None + ) + if start_col is not None and stop_col is not None: + original_gti = np.column_stack([start_col, stop_col]) + + # Calculate duration + duration = ( + (time_max - time_min) + if (time_min is not None and time_max is not None) + else 0.0 + ) + + # GTI info + gti_count = len(original_gti) if original_gti is not None else 0 + total_gti_time = None + if original_gti is not None and len(original_gti) > 0: + total_gti_time = float(np.sum(original_gti[:, 1] - original_gti[:, 0])) + + # Determine risk level for loading + if file_size_gb > 10: + risk_level = "critical" + elif file_size_gb > 5: + risk_level = "risky" + elif file_size_gb > 1: + risk_level = "caution" + else: + risk_level = "safe" + + metadata = { + "file_size_mb": file_size_mb, + "file_size_gb": file_size_gb, + "risk_level": risk_level, + "total_events": total_events, + "time_range": [time_min, time_max], + "duration": duration, + "gti_count": gti_count, + "total_gti_time": total_gti_time, + "gti_list": ( + [ + [_to_python_float(g[0]), _to_python_float(g[1])] + for g in original_gti + ] + if original_gti is not None + else None + ), + "mjdref": _to_python_float(mjdref), + "mission": mission, + "instrument": instr, + "available_columns": available_columns, + "recommended_loading": self._recommend_loading_strategy( + total_events, file_size_gb, risk_level + ), + } + + return self.create_result( + success=True, + data=metadata, + message=f"Metadata retrieved: {total_events} events, {file_size_mb:.1f} MB, {gti_count} GTI", + ) + + except Exception: + return self.create_result( + success=False, + data=None, + message="The selected file metadata could not be read", + error="metadata_read_failed", + ) + + def _recommend_loading_strategy( + self, + total_events: int, + file_size_gb: float, + risk_level: str, + ) -> Dict[str, Any]: + """ + Recommend a loading strategy based on file characteristics. + + Returns recommendations for how to load the file efficiently. + + Strategies: + - 'full': Safe to load the entire file + - 'time_range': Use partial loading by time range + - 'event_count': Use partial loading by event count (for very large files) + """ + recommendations = { + "can_load_full": risk_level in ["safe"], + "recommend_lazy": risk_level in ["caution", "risky", "critical"], + "suggested_chunk_size": None, + "suggested_time_chunk": None, + "strategy": "full", + } + + if risk_level == "critical": + # Very large file (>10GB) - must use partial loading + recommendations["strategy"] = "event_count" + recommendations["suggested_chunk_size"] = min( + 100000, max(1000, total_events // 10) + ) + recommendations["suggested_time_chunk"] = 100.0 # seconds + elif risk_level == "risky": + # Large file (5-10GB) - strongly recommend partial loading + recommendations["strategy"] = "time_range" + recommendations["suggested_chunk_size"] = min( + 500000, max(10000, total_events // 5) + ) + recommendations["suggested_time_chunk"] = 500.0 + elif risk_level == "caution": + # Medium file (1-5GB) - partial loading recommended + recommendations["strategy"] = "time_range" + recommendations["suggested_chunk_size"] = min( + 1000000, max(50000, total_events // 2) + ) + recommendations["suggested_time_chunk"] = 1000.0 + else: + # Small file (<1GB) - safe to load fully + recommendations["strategy"] = "full" + + return recommendations + + # ========================================================================= + # BATCH LOADING METHODS + # Load multiple files in parallel using ThreadPoolExecutor + # ========================================================================= + + def _get_risk_level(self, ram_percent: float) -> str: + """Determine risk level based on RAM usage percentage.""" + if ram_percent > 80: + return "critical" + elif ram_percent > 50: + return "risky" + elif ram_percent > 30: + return "caution" + else: + return "safe" + + def check_batch_file_size( + self, + files: List[Dict[str, str]], + *, + _file_sources: List[GrantedReadFile] | None = None, + ) -> Dict[str, Any]: + """Check a bounded batch after atomically pinning every selected input.""" + try: + with ExitStack() as stack: + sources: List[GrantedReadFile] = [] + for index, item in enumerate(files): + retained = ( + _file_sources[index] if _file_sources is not None else None + ) + sources.append( + stack.enter_context( + _open_granted_source( + item["file_path"], + item.get("file_grant"), + retained, + ) + ) + ) + + memory_info = self._get_memory_info() + available_ram_mb = memory_info["available_mb"] + files_info = [] + total_size_mb = 0.0 + total_estimated_ram_mb = 0.0 + + for item, source in zip(files, sources): + size_bytes = source.size_bytes + size_mb = size_bytes / (1024**2) + suffix = source.path.suffix.lower() + fmt = ( + "fits" + if suffix in {".fits", ".fit", ".fts", ".evt", ".gz"} + else "hdf5" + if suffix in {".hdf5", ".h5"} + else "fits" + ) + estimated_ram_mb = self._estimate_memory_usage(size_bytes, fmt) / ( + 1024**2 + ) + ram_percent = ( + estimated_ram_mb / available_ram_mb * 100 + if available_ram_mb > 0 + else 100 + ) + files_info.append( + { + "file_name": source.path.name, + "size_mb": round(size_mb, 1), + "estimated_ram_mb": round(estimated_ram_mb, 1), + "ram_percent": round(ram_percent, 1), + "risk_level": self._get_risk_level(ram_percent), + } + ) + total_size_mb += size_mb + total_estimated_ram_mb += estimated_ram_mb + + total_ram_percent = ( + total_estimated_ram_mb / available_ram_mb * 100 + if available_ram_mb > 0 + else 100 + ) + return self.create_result( + success=True, + data={ + "files": files_info, + "total": { + "size_mb": round(total_size_mb, 1), + "estimated_ram_mb": round(total_estimated_ram_mb, 1), + "ram_percent": round(total_ram_percent, 1), + "risk_level": self._get_risk_level(total_ram_percent), + }, + "available_ram_mb": round(available_ram_mb, 1), + "file_count": len(files), + "recommend_partial_loading": total_ram_percent > 30, + }, + message=( + f"Checked {len(files)} files: {total_size_mb:.1f} MB total, " + f"~{total_ram_percent:.0f}% of available RAM" + ), + ) + except Exception: + return self.create_result( + success=False, + data=None, + message="The selected batch sizes could not be checked", + error="batch_size_check_failed", + ) + + def load_batch_event_lists( + self, + files: List[Dict[str, Any]], + use_same_settings: bool = True, + shared_fmt: str = "ogip", + shared_rmf_file: Optional[str] = None, + shared_rmf_grant: Optional[str] = None, + shared_additional_columns: Optional[List[str]] = None, + shared_high_precision: bool = False, + shared_skip_checks: bool = False, + shared_use_partial_loading: bool = False, + shared_partial_mode: str = "time_range", + shared_time_range_start: Optional[float] = None, + shared_time_range_end: Optional[float] = None, + shared_event_start_index: Optional[int] = None, + shared_event_count: Optional[int] = None, + max_workers: Optional[int] = None, + *, + _file_sources: List[GrantedReadFile] | None = None, + _rmf_sources: List[GrantedReadFile | None] | None = None, + _shared_rmf_source: GrantedReadFile | None = None, + _cancellation_check=None, + _on_file_complete: Callable[[Dict[str, Any]], None] | None = None, + ) -> Dict[str, Any]: + """Pin an entire batch before loading and never reopen selected paths.""" + files, shared_fmt = require_batch_input_formats(files, shared_fmt) + for item in files: + name_error = validate_derived_name(item.get("name", "")) + if name_error: + raise ValueError(name_error) + start_time = time.time() + if not files: + return self.create_result( + success=False, message="No files provided for batch loading" + ) + + names = [item.get("name", "") for item in files] + duplicate_names = sorted({name for name in names if names.count(name) > 1}) + if duplicate_names: + return self.create_result( + success=False, message=f"Duplicate names in request: {duplicate_names}" + ) + existing_names = [ + name for name in names if name and self.state.has_event_data(name) + ] + if existing_names: + return self.create_result( + success=False, message=f"Names already exist in state: {existing_names}" + ) + if (shared_rmf_file is None) != (shared_rmf_grant is None) and ( + _shared_rmf_source is None + ): + raise ValueError( + "shared_rmf_file and shared_rmf_grant must be provided together" + ) + + with ExitStack() as stack: + retained_files: List[GrantedReadFile] = [] + retained_rmfs: List[GrantedReadFile | None] = [] + for index, item in enumerate(files): + private_source = ( + _file_sources[index] if _file_sources is not None else None + ) + retained_files.append( + stack.enter_context( + _open_granted_source( + item["file_path"], + item.get("file_grant"), + private_source, + ) + ) + ) + + shared_source = None + if shared_rmf_file is not None or _shared_rmf_source is not None: + shared_source = stack.enter_context( + _open_granted_source( + shared_rmf_file or "", + shared_rmf_grant, + _shared_rmf_source, + ) + ) + + for index, item in enumerate(files): + private_rmf = _rmf_sources[index] if _rmf_sources is not None else None + if use_same_settings: + retained_rmfs.append(shared_source) + continue + rmf_path = item.get("rmf_file") + rmf_grant = item.get("rmf_grant") + if (rmf_path is None) != (rmf_grant is None) and private_rmf is None: + raise ValueError("rmf_file and rmf_grant must be provided together") + if rmf_path is None and private_rmf is None: + retained_rmfs.append(None) + else: + retained_rmfs.append( + stack.enter_context( + _open_granted_source( + rmf_path or "", + rmf_grant, + private_rmf, + ) + ) + ) + + def load_single(index: int) -> Dict[str, Any]: + item = files[index] + name = item["name"] + if use_same_settings: + fmt = shared_fmt + rmf_path = shared_rmf_file + columns = shared_additional_columns + high_precision = shared_high_precision + skip_checks = shared_skip_checks + use_partial = shared_use_partial_loading + partial_mode = shared_partial_mode + range_start = shared_time_range_start + range_end = shared_time_range_end + event_start = shared_event_start_index + event_count = shared_event_count + notes = None + else: + fmt = item.get("fmt", "ogip") + rmf_path = item.get("rmf_file") + columns = item.get("additional_columns") + high_precision = bool(item.get("high_precision", False)) + skip_checks = bool(item.get("skip_checks", False)) + use_partial = bool(item.get("use_partial_loading", False)) + partial_mode = item.get("partial_mode", "time_range") + range_start = item.get("time_range_start") + range_end = item.get("time_range_end") + event_start = item.get("event_start_index") + event_count = item.get("event_count") + notes = item.get("notes") + + try: + if use_partial and partial_mode == "time_range": + if range_start is None or range_end is None: + raise ValueError( + "Partial time-range loading requires both endpoints" + ) + result = self.load_event_list_by_time_range( + item["file_path"], + name, + range_start, + range_end, + fmt, + notes, + _file_source=retained_files[index], + _cancellation_check=_cancellation_check, + ) + elif use_partial: + result = self.load_event_list_by_event_count( + item["file_path"], + name, + event_start or 0, + event_count or 10000, + fmt, + notes, + _file_source=retained_files[index], + _cancellation_check=_cancellation_check, + ) + else: + result = self.load_event_list( + item["file_path"], + name, + fmt=fmt, + rmf_file=rmf_path, + additional_columns=columns, + high_precision=high_precision, + skip_checks=skip_checks, + notes=notes, + _file_source=retained_files[index], + _rmf_source=retained_rmfs[index], + _cancellation_check=_cancellation_check, + ) + return { + "success": bool(result.get("success")), + "name": name, + "data": result.get("data"), + "message": result.get("message"), + "error": result.get("error") or result.get("message"), + } + except Exception: + return { + "success": False, + "name": name, + "error": "The selected file could not be loaded", + } + + worker_count = max_workers or min(os.cpu_count() or 4, len(files), 8) + worker_count = max(1, min(worker_count, len(files), 8)) + # One shared seekable RMF stream must never be consumed concurrently. + if shared_source is not None: + worker_count = 1 + + successful: List[Dict[str, Any]] = [] + failed: List[Dict[str, Any]] = [] + with ThreadPoolExecutor(max_workers=worker_count) as executor: + _raise_if_cancelled(_cancellation_check) + futures = { + executor.submit(load_single, index): index + for index in range(len(files)) + } + for future in as_completed(futures): + _raise_if_cancelled(_cancellation_check) + result = future.result() + if result["success"]: + normalized = { + "success": True, + "name": result["name"], + "data": result.get("data"), + } + successful.append( + {**normalized, "message": result.get("message")} + ) + else: + normalized = { + "success": False, + "name": result["name"], + "error": result.get("error") or "Unknown error", + } + failed.append(normalized) + if _on_file_complete is not None: + _on_file_complete(normalized) + + total_time_ms = (time.time() - start_time) * 1000 + total_events = sum( + item.get("data", {}).get("n_events", 0) if item.get("data") else 0 + for item in successful + ) + return self.create_result( + success=not failed, + data={ + "successful": successful, + "failed": failed, + "summary": { + "total_files": len(files), + "success_count": len(successful), + "failure_count": len(failed), + "total_events_loaded": total_events, + "total_time_ms": round(total_time_ms, 1), + "workers_used": worker_count, + }, + }, + message=( + f"Loaded {len(successful)}/{len(files)} files " + f"({total_events:,} total events) in {total_time_ms:.0f}ms" + ), + ) + + async def load_batch_event_lists_stream( + self, + files: List[Dict[str, Any]], + use_same_settings: bool = True, + shared_fmt: str = "ogip", + shared_rmf_file: Optional[str] = None, + shared_rmf_grant: Optional[str] = None, + shared_additional_columns: Optional[List[str]] = None, + shared_high_precision: bool = False, + shared_skip_checks: bool = False, + shared_use_partial_loading: bool = False, + shared_partial_mode: str = "time_range", + shared_time_range_start: Optional[float] = None, + shared_time_range_end: Optional[float] = None, + shared_event_start_index: Optional[int] = None, + shared_event_count: Optional[int] = None, + max_workers: Optional[int] = None, + ) -> AsyncGenerator[Dict[str, Any], None]: + """Stream one redacted completion event as each file finishes.""" + # Format policy is an admission decision, not a runtime SSE failure. + # Preserve fail-fast behavior before any worker or native I/O exists. + files, shared_fmt = require_batch_input_formats(files, shared_fmt) + cancellation_signal = threading.Event() + loop = asyncio.get_running_loop() + completion_queue: asyncio.Queue[Dict[str, Any] | object] = asyncio.Queue( + maxsize=len(files) + 1 + ) + terminal = object() + + def enqueue(item: Dict[str, Any] | object) -> None: + loop.call_soon_threadsafe(completion_queue.put_nowait, item) + + def run_loader() -> Dict[str, Any]: + try: + return self.load_batch_event_lists( + files, + use_same_settings, + shared_fmt, + shared_rmf_file, + shared_rmf_grant, + shared_additional_columns, + shared_high_precision, + shared_skip_checks, + shared_use_partial_loading, + shared_partial_mode, + shared_time_range_start, + shared_time_range_end, + shared_event_start_index, + shared_event_count, + max_workers, + _cancellation_check=cancellation_signal.is_set, + _on_file_complete=enqueue, + ) + finally: + enqueue(terminal) + + worker = asyncio.create_task(asyncio.to_thread(run_loader)) + + async def drain_worker() -> None: + cancellation_signal.set() + while not worker.done(): + try: + await asyncio.shield(worker) + except asyncio.CancelledError: + continue + except BaseException: + break + try: + worker.result() + except BaseException: + pass + + completed = 0 + try: + while True: + item = await completion_queue.get() + if item is terminal: + break + completed += 1 + success = bool(item.get("success")) + event = { + "type": "file_complete", + "name": item.get("name", ""), + "success": success, + "completed": completed, + "total": len(files), + } + if success: + event["data"] = item.get("data") + else: + event["error"] = item.get( + "error", "The selected file could not be loaded" + ) + yield event + await asyncio.sleep(0) + try: + result = await asyncio.shield(worker) + except Exception: + yield { + "type": "error", + "error": "The selected batch could not be admitted or loaded", + } + return + finally: + await drain_worker() + if result.get("data") is None: + yield { + "type": "error", + "error": result.get("message") or "Batch load failed", + } + return + + data = result["data"] + summary = data["summary"] + yield { + "type": "complete", + "total_time_ms": summary["total_time_ms"], + "success_count": summary["success_count"], + "failure_count": summary["failure_count"], + "total_events": summary["total_events_loaded"], + "workers_used": summary["workers_used"], + } diff --git a/python-backend/services/deadtime_service.py b/python-backend/services/deadtime_service.py new file mode 100644 index 0000000..d04df13 --- /dev/null +++ b/python-backend/services/deadtime_service.py @@ -0,0 +1,401 @@ +""" +Dead-time service for dead-time model and FAD corrections. + +Implemented per docs/superpowers/plans/2026-07-29-quicklook-remaining-pages.md. +""" + +from typing import Any, Dict, List, Optional + +import numpy as np +from stingray import AveragedPowerspectrum, EventList +from stingray.deadtime.fad import FAD + +from .analysis_helpers import ( + collect_warnings, + finite_list, + overlap_error, + segment_size_error, +) +from .base_service import BaseService + +# Below this many averaged segments the FAD correction is unreliable +# (Bachetti & Huppenkothen 2018 / stingray docstring recommendation). +MIN_FAD_SEGMENTS = 30 + + +def _gti_exposure(event_list) -> float: + """Total good time in seconds (sum of GTI durations, not last - first). + + EventList.gti is auto-derived from the event times when it was never set, + so this is always defined for a non-empty list. + """ + gti = np.atleast_2d(np.asarray(event_list.gti, dtype=float)) + return float(np.sum(gti[:, 1] - gti[:, 0])) + + +def _finite_or_none(value) -> Optional[float]: + """Coerce a stingray-meta scalar to a JSON-safe float, or None. + + Every array column in this service is sanitized with `finite_list`, but + scalars pulled straight out of `results.meta` are not. FAD's smoothed + Fourier difference (`smooth_real`) can be exactly zero -- e.g. when the + two inputs are byte-identical -- which turns `fad_delta` (and, in + principle, any other meta scalar derived from that same division) into + NaN. An unguarded `float(nan)` would pass success=True all the way to + `json.dumps(..., allow_nan=False)` and blow up the response. This is the + scalar equivalent of `finite_list`. + """ + try: + f = float(value) + except (TypeError, ValueError): + return None + return f if np.isfinite(f) else None + + +def _detached_copy(event_list) -> EventList: + """A throwaway EventList sharing the stored arrays but owning its own GTI. + + stingray's FAD() does `data1.gti = data2.gti = cross_two_gtis(...)`, which + would otherwise permanently overwrite the GTIs of the EventLists held in + StateManager (and race with concurrent requests). Attribute assignment on + this wrapper cannot reach the stored object, and `time` is shared by + reference so large event lists are not duplicated. + """ + return EventList(time=event_list.time, gti=np.array(event_list.gti, copy=True)) + + +class DeadtimeService(BaseService): + """Service for dead-time correction operations.""" + + def calculate_pds_correction( + self, + event_list_name: str, + dt: float, + segment_size: float, + dead_time: float, + background_rate: float = 0.0, + limit_k: int = 200, + ) -> Dict[str, Any]: + """ + Model-based (Zhang+95, non-paralyzable) dead-time correction of a PDS. + + Args: + event_list_name: Name of the EventList in state + dt: Time binning in seconds + segment_size: Segment length in seconds for the averaged PDS + dead_time: Detector dead time per event in seconds + background_rate: Background count rate in counts/s + limit_k: Number of terms in the Zhang+95 series expansion + + Returns: + Result dictionary with uncorrected and corrected power spectra + """ + try: + if not self.state.has_event_data(event_list_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_name}' not found", + error=None, + ) + + if dead_time <= 0: + return self.create_result( + success=False, + data=None, + message=( + f"dead_time must be positive (got {dead_time}s); " + "there is nothing to correct for a zero dead time" + ), + error=None, + ) + + seg_error = segment_size_error(segment_size, dt) + if seg_error: + return self.create_result( + success=False, data=None, message=seg_error, error=None + ) + + event_list = self.state.get_event_data(event_list_name) + n_events = int(len(event_list.time)) + if n_events == 0: + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_name}' contains no events", + error=None, + ) + + exposure = _gti_exposure(event_list) + if exposure <= 0: + return self.create_result( + success=False, + data=None, + message=( + f"EventList '{event_list_name}' has no good-time exposure; " + "cannot derive a count rate" + ), + error=None, + ) + if segment_size > exposure: + return self.create_result( + success=False, + data=None, + message=( + f"segment_size ({segment_size}s) is longer than the total " + f"good-time exposure ({exposure:.1f}s)" + ), + error=None, + ) + + # `rate` for the Zhang model is the DETECTED rate, i.e. the events + # that survived dead time divided by the live exposure. + rate = n_events / exposure + + # stingray raises a bare ValueError from inside the numba model for + # this; pre-empt it with a message naming the actual numbers. + occupancy = (rate + background_rate) * dead_time + if occupancy >= 1: + return self.create_result( + success=False, + data=None, + message=( + "(rate + background_rate) x dead_time must be less than 1 " + "for a physical correction: detected rate " + f"{rate:.2f} c/s + background {background_rate:.2f} c/s " + f"over dead time {dead_time}s gives {occupancy:.2f}. " + "Lower the dead time or check the event list." + ), + error=None, + ) + + warning_messages: List[str] = [] + with collect_warnings(warning_messages): + # norm is hard-locked to "leahy": deadtime_correct() applies + # `2 / model` without ever checking self.norm, i.e. it assumes + # the Leahy white-noise level of 2. Any other norm would be + # silently mis-corrected. + pds = AveragedPowerspectrum.from_events( + event_list, dt=dt, segment_size=segment_size, norm="leahy" + ) + corrected = pds.deadtime_correct( + dead_time=dead_time, + rate=rate, + background_rate=background_rate, + limit_k=limit_k, + ) + + result_data = { + "freq": finite_list(pds.freq), + "power_uncorrected": finite_list(pds.power), + "power_corrected": finite_list(corrected.power), + "rate": float(rate), + "n_events": n_events, + "exposure": exposure, + "n_segments": int(pds.m), + "dt": float(dt), + "segment_size": float(segment_size), + "dead_time": float(dead_time), + "background_rate": float(background_rate), + "limit_k": int(limit_k), + "norm": "leahy", + "warnings": warning_messages, + } + + return self.create_result( + success=True, + data=result_data, + message=( + f"Dead-time corrected PDS ({int(pds.m)} segments, " + f"detected rate {rate:.1f} c/s)" + ), + ) + + except Exception as e: + return self.handle_error( + e, + "Applying model dead-time correction", + event_list=event_list_name, + dt=dt, + segment_size=segment_size, + dead_time=dead_time, + ) + + def calculate_fad_correction( + self, + event_list_1_name: str, + event_list_2_name: str, + dt: float, + segment_size: float, + norm: str = "frac", + smoothing_length: Optional[float] = None, + ) -> Dict[str, Any]: + """ + Frequency Amplitude Difference correction from two independent detectors. + + Args: + event_list_1_name: Name of the first detector's EventList + event_list_2_name: Name of the second detector's EventList + dt: Time binning in seconds + segment_size: Segment length in seconds + norm: Power spectrum normalization (frac/leahy/abs/none) + smoothing_length: Sigma (standard deviation) of the Gaussian + filter stingray uses to smooth the FAD diagnostic in + frequency space, in units of frequency BINS (array samples) + -- NOT seconds, despite the field name. stingray passes this + value straight to `scipy.ndimage.gaussian_filter1d` on the + per-segment power spectrum, whose bin spacing is + df = 1/segment_size, so the physical smoothing width in Hz + is smoothing_length / segment_size: the same numeric value + smooths a different physical frequency range depending on + segment_size. When left as None, stingray defaults to + `3 * segment_size` (segment_size in seconds, reused verbatim + as a bin count), which happens to give a + segment_size-independent physical width of 3 Hz -- unlike + any value supplied explicitly here. + + Returns: + Result dictionary with FAD-corrected periodograms + """ + try: + for name in (event_list_1_name, event_list_2_name): + if not self.state.has_event_data(name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{name}' not found", + error=None, + ) + + seg_error = segment_size_error(segment_size, dt) + if seg_error: + return self.create_result( + success=False, data=None, message=seg_error, error=None + ) + + events1 = self.state.get_event_data(event_list_1_name) + events2 = self.state.get_event_data(event_list_2_name) + + # Preflight the degenerate inputs stingray fails on cryptically + # deep inside FAD (a bare IndexError/AssertionError/ZeroDivision + # with no mention of which event list or GTI caused it): + # - an event list with no events at all -- note `EventList.time` + # is `None`, not an empty array, when the list is genuinely + # empty, so `len(events.time)` cannot be used directly here. + # - an event list with events but zero good-time exposure (e.g. + # a fully-screened observation with `gti` reduced to zero + # rows). + for name, events in ( + (event_list_1_name, events1), + (event_list_2_name, events2), + ): + n_events = 0 if events.time is None else len(events.time) + if n_events == 0: + return self.create_result( + success=False, + data=None, + message=f"EventList '{name}' contains no events", + error=None, + ) + if _gti_exposure(events) <= 0: + return self.create_result( + success=False, + data=None, + message=( + f"EventList '{name}' has no good-time exposure " + "(its GTI is empty); cannot compute the FAD " + "diagnostic" + ), + error=None, + ) + + ovl_error = overlap_error(events1, events2, segment_size) + if ovl_error: + return self.create_result( + success=False, data=None, message=ovl_error, error=None + ) + + warning_messages: List[str] = [] + with collect_warnings(warning_messages): + # strict=False (stingray's default): a non-compliant FAD + # diagnostic must degrade to a warning, never a RuntimeError. + results = FAD( + _detached_copy(events1), + _detached_copy(events2), + segment_size, + dt=dt, + norm=norm, + smoothing_length=smoothing_length, + strict=False, + ) + + n_segments = int(results.meta["M"]) + # `fad_delta` is `(std - stdtheor) / stdtheor`, where `std` comes + # from dividing by the smoothed Fourier difference; that smoothed + # value can be exactly zero (e.g. the two inputs are + # byte-identical), producing a NaN that must never reach the + # response unguarded (json.dumps(..., allow_nan=False) would + # raise and take the whole request down with an unhandled 500). + fad_delta = _finite_or_none(results.meta["fad_delta"]) + if n_segments < MIN_FAD_SEGMENTS: + warning_messages.append( + f"Only {n_segments} segments were averaged (fewer than " + f"{MIN_FAD_SEGMENTS}); the FAD correction is unreliable below " + "that. Shorten segment_size or use a longer observation." + ) + if fad_delta is None: + warning_messages.append( + "The FAD compliance diagnostic (fad_delta) could not be " + "computed: the smoothed Fourier difference between the " + "two inputs was zero, which happens when the two event " + "lists are identical or otherwise fully correlated. Use " + "two independent, simultaneous detectors; fad_delta and " + "the compliance check are unavailable for this run." + ) + elif not bool(results.meta["is_compliant"]): + warning_messages.append( + "FAD diagnostic failed: the scatter of the smoothed Fourier " + "difference deviates from theory by " + f"{fad_delta * 100:.1f}%. The two event " + "lists may not be independent simultaneous detectors." + ) + + # `cs` is complex (the corrected cross spectrum); serialize its + # magnitude for the headline trace and keep the real part (the + # signed cospectrum) alongside it. + cs = np.asarray(results["cs"]) + + result_data = { + "freq": finite_list(results["freq"]), + "pds1": finite_list(results["pds1"]), + "pds2": finite_list(results["pds2"]), + "ptot": finite_list(results["ptot"]), + "cs": finite_list(np.abs(cs)), + "cs_real": finite_list(cs.real), + "n_segments": n_segments, + "dt": _finite_or_none(results.meta["dt"]), + "segment_size": float(segment_size), + "norm": str(results.meta["norm"]), + "smoothing_length": _finite_or_none(results.meta["smoothing_length"]), + "is_compliant": bool(results.meta["is_compliant"]), + "fad_delta": fad_delta, + "warnings": warning_messages, + } + + return self.create_result( + success=True, + data=result_data, + message=( + f"FAD correction computed ({n_segments} segments, norm={norm})" + ), + ) + + except Exception as e: + return self.handle_error( + e, + "Applying FAD dead-time correction", + event_list_1=event_list_1_name, + event_list_2=event_list_2_name, + dt=dt, + segment_size=segment_size, + ) diff --git a/python-backend/services/gti_service.py b/python-backend/services/gti_service.py new file mode 100644 index 0000000..d809803 --- /dev/null +++ b/python-backend/services/gti_service.py @@ -0,0 +1,1623 @@ +"""Scientific GTI inspection, set operations, masking, and segmentation. + +The service deliberately validates GTIs before calling Stingray. In +particular, it never sorts or merges renderer input merely to make an invalid +array acceptable: interval-specific errors are returned to the user instead. +""" + +# Service boundaries intentionally translate unexpected scientific-library +# exceptions through the application's standardized ErrorHandler envelope. +# ruff: noqa: BLE001 + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sized +from decimal import Decimal, InvalidOperation +from itertools import islice +from typing import Any + +import numpy as np +from stingray.gti import ( + append_gtis, + check_gtis, + check_separate, + create_gti_mask, + cross_two_gtis, + find_large_bad_time_intervals, + get_btis, + merge_gtis, + split_gtis_by_exposure, + time_intervals_from_gtis, +) + +from .analysis_helpers import collect_warnings +from .base_service import BaseService +from .utility_helpers import ( + MAX_EXACT_OUTPUT, + MAX_EXPORT_ROWS, + MAX_GTI_ROWS, + MAX_STATE_SNAPSHOT_BYTES, + MAX_STATE_SNAPSHOT_CELLS, + bounded_plot_preview, + finite_or_none, + json_safe, + operation_provenance, + validate_derived_name, +) + +GTI_TIME_REFERENCES = {"absolute_mission_time", "relative_seconds"} +# A boolean mask is comparatively small, but filtering an EventList also copies +# every event-aligned array. Reuse the shared two-million-row safety ceiling. +MAX_MASK_EVENTS = MAX_EXPORT_ROWS + + +def _strict_finite_scalar(value: Any, label: str) -> tuple[float | None, str | None]: + """Parse one renderer scalar without accepting booleans or numeric text.""" + if isinstance(value, (bool, np.bool_, str, bytes, bytearray, memoryview, Mapping)): + return None, f"{label} must be a finite number" + try: + result = float(value) + except (TypeError, ValueError, OverflowError): + return None, f"{label} must be a finite number" + if not math.isfinite(result): + return None, f"{label} must be finite" + return result, None + + +def _validate_time_reference(time_reference: str) -> str | None: + if time_reference not in GTI_TIME_REFERENCES: + allowed = ", ".join(sorted(GTI_TIME_REFERENCES)) + return f"time_reference must be one of: {allowed}" + return None + + +def _validate_gti_array( + gtis: Any, + *, + label: str = "GTIs", + allow_empty: bool = False, + max_rows: int = MAX_GTI_ROWS, +) -> tuple[np.ndarray | None, str | None]: + """Return a strict, ordered ``Nx2`` GTI array or a row-specific error.""" + if gtis is None: + return None, f"{label} are missing" + if isinstance(gtis, (bool, np.bool_, str, bytes, bytearray, memoryview, Mapping)): + return None, f"{label} must be an array of [start, stop] rows" + + rows: Any + row_count_is_lower_bound = False + if type(gtis) is np.ndarray: + if gtis.ndim == 0: + return None, f"{label} must be an array of [start, stop] rows" + row_count = int(gtis.shape[0]) + rows = gtis + elif type(gtis) in (list, tuple): + row_count = len(gtis) + rows = gtis + else: + shape = getattr(gtis, "shape", None) + if shape is not None: + try: + dimensions = tuple(shape) + except TypeError: + dimensions = () + if not dimensions: + return None, f"{label} must be an array of [start, stop] rows" + if isinstance(dimensions[0], (int, np.integer)): + hinted_rows = int(dimensions[0]) + if hinted_rows > max_rows: + return None, ( + f"{label} contain {hinted_rows:,} rows; the cap is {max_rows:,}" + ) + + if isinstance(gtis, Sized): + try: + hinted_rows = len(gtis) + except (TypeError, ValueError, OverflowError): + hinted_rows = None + if hinted_rows is not None and hinted_rows > max_rows: + return None, ( + f"{label} contain {hinted_rows:,} rows; the cap is {max_rows:,}" + ) + + try: + rows = list(islice(iter(gtis), max_rows + 1)) + except (TypeError, ValueError): + return None, f"{label} must be an array of [start, stop] rows" + row_count = len(rows) + row_count_is_lower_bound = row_count > max_rows + + if row_count > max_rows: + qualifier = "at least " if row_count_is_lower_bound else "" + return None, ( + f"{label} contain {qualifier}{row_count:,} rows; the cap is {max_rows:,}" + ) + if row_count == 0: + if allow_empty: + return np.empty((0, 2), dtype=np.longdouble), None + return None, f"{label} must contain at least one [start, stop] interval" + + parsed = np.empty((row_count, 2), dtype=np.longdouble) + previous_start: np.longdouble | None = None + previous_stop: np.longdouble | None = None + + for index, row in enumerate(rows): + row_number = index + 1 + if isinstance( + row, (bool, np.bool_, str, bytes, bytearray, memoryview, Mapping) + ): + return None, f"{label} interval {row_number} must be a [start, stop] row" + + values: Any + value_count_is_lower_bound = False + if type(row) is np.ndarray: + if row.ndim != 1: + return None, ( + f"{label} interval {row_number} must be a [start, stop] row" + ) + value_count = int(row.size) + values = row + elif type(row) in (list, tuple): + value_count = len(row) + values = row + else: + shape = getattr(row, "shape", None) + if shape is not None: + try: + dimensions = tuple(shape) + except TypeError: + dimensions = () + if len(dimensions) != 1: + return None, ( + f"{label} interval {row_number} must be a [start, stop] row" + ) + if ( + isinstance(dimensions[0], (int, np.integer)) + and int(dimensions[0]) != 2 + ): + return None, ( + f"{label} interval {row_number} has {int(dimensions[0])} " + "value(s); exactly [start, stop] is required" + ) + + if isinstance(row, Sized): + try: + hinted_values = len(row) + except (TypeError, ValueError, OverflowError): + hinted_values = None + if hinted_values is not None and hinted_values != 2: + return None, ( + f"{label} interval {row_number} has {hinted_values} value(s); " + "exactly [start, stop] is required" + ) + + try: + values = list(islice(iter(row), 3)) + except (TypeError, ValueError): + return None, ( + f"{label} interval {row_number} must be a [start, stop] row" + ) + value_count = len(values) + value_count_is_lower_bound = value_count > 2 + + if value_count != 2: + qualifier = "at least " if value_count_is_lower_bound else "" + return None, ( + f"{label} interval {row_number} has {qualifier}{value_count} value(s); " + "exactly [start, stop] is required" + ) + + start, start_error = _strict_finite_scalar( + values[0], f"{label} interval {row_number} start" + ) + if start_error: + return None, start_error + stop, stop_error = _strict_finite_scalar( + values[1], f"{label} interval {row_number} stop" + ) + if stop_error: + return None, stop_error + assert start is not None and stop is not None + + if stop <= start: + return None, ( + f"{label} interval {row_number} must have positive length: " + f"stop ({stop}) must be greater than start ({start})" + ) + with np.errstate(over="ignore", invalid="ignore"): + duration = stop - start + if not np.isfinite(duration): + return None, ( + f"{label} interval {row_number} has a duration that cannot be " + "represented as finite seconds; use a narrower time range" + ) + if previous_start is not None and start < previous_start: + return None, ( + f"{label} interval {row_number} starts at {start}, before interval " + f"{row_number - 1} starts at {float(previous_start)}; preserve time order" + ) + if previous_stop is not None and start < previous_stop: + return None, ( + f"{label} interval {row_number} starts at {start} and overlaps interval " + f"{row_number - 1}, which stops at {float(previous_stop)}" + ) + + parsed[index] = (start, stop) + previous_start = parsed[index, 0] + previous_stop = parsed[index, 1] + + with np.errstate(over="ignore", invalid="ignore"): + lengths = parsed[:, 1] - parsed[:, 0] + exposure = np.sum(lengths, dtype=np.longdouble) + span = parsed[-1, 1] - parsed[0, 0] + separations = parsed[1:, 0] - parsed[:-1, 1] + if not np.isfinite(exposure): + return None, ( + f"Total {label.removesuffix('s')} exposure cannot be represented as finite " + "seconds; use fewer intervals or a narrower time range" + ) + if not np.isfinite(span): + return None, ( + f"Overall {label.removesuffix('s')} span cannot be represented as finite " + "seconds; use a narrower time range" + ) + bad_separation = np.flatnonzero(~np.isfinite(separations)) + if bad_separation.size: + interval_number = int(bad_separation[0]) + 2 + return None, ( + f"{label} separation before interval {interval_number} cannot be represented " + "as finite seconds; use a narrower time range" + ) + + # Keep Stingray itself as the final scientific validator after the stricter + # application checks (Stingray permits zero-duration intervals). + try: + check_gtis(parsed) + except ( + TypeError, + ValueError, + ) as exc: # pragma: no cover - defensive upstream guard + return None, f"{label} failed Stingray validation: {exc}" + return parsed, None + + +def _normalise_upstream_gtis(value: Any, *, label: str) -> np.ndarray: + """Normalize only Stingray's inconsistent empty return shapes to ``(0, 2)``.""" + if value is None: + return np.empty((0, 2), dtype=np.longdouble) + result = np.asanyarray(value, dtype=np.longdouble) + if result.size == 0: + return np.empty((0, 2), dtype=np.longdouble) + if result.ndim != 2 or result.shape[1] != 2: + raise RuntimeError( + f"Stingray returned malformed {label} with shape {result.shape}" + ) + if len(result) > MAX_GTI_ROWS: + raise RuntimeError( + f"Stingray returned {len(result):,} {label} rows; the cap is {MAX_GTI_ROWS:,}" + ) + return result + + +def _stored_event_list_gti(event_list: Any) -> Any: + """Return only an explicitly stored EventList GTI. + + In Stingray 2.2.10, reading the public ``EventList.gti`` property lazily + synthesizes ``[time[0], time[-1]]`` when event times exist but no GTI was + supplied. Utilities inspection and masking must distinguish that missing + metadata from an explicit interval, so this boundary deliberately reads + the backing value without invoking the synthesizing property. + """ + return getattr(event_list, "_gti", None) + + +def _float64_ulp_tolerance(*values: Any, factor: float = 4.0) -> np.longdouble: + """Return a small tolerance for arithmetic performed from JSON floats. + + Renderer numbers and Stingray's scalar parameters enter this boundary as + binary64 values even though GTI arithmetic is promoted to ``longdouble``. + Comparing an upstream endpoint with its source boundary therefore needs a + few binary64 ULPs, not Stingray's much larger absolute epsilon. + """ + largest_ulp = 0.0 + for value in values: + numeric = abs(float(value)) + if not math.isfinite(numeric): + continue + if numeric == 0.0: + ulp = np.nextafter(0.0, 1.0) + else: + next_value = np.nextafter(numeric, math.inf) + if math.isfinite(float(next_value)): + ulp = float(next_value - numeric) + else: + ulp = float(numeric - np.nextafter(numeric, 0.0)) + largest_ulp = max(largest_ulp, ulp) + return np.longdouble(largest_ulp) * factor + + +def _step_resolution_error( + source_gtis: np.ndarray, + step: float, + label: str, +) -> str | None: + """Reject output steps that collapse when serialized as absolute floats.""" + timestamp_resolution = _float64_ulp_tolerance(*source_gtis.reshape(-1), factor=1.0) + if np.longdouble(step) >= timestamp_resolution: + return None + return ( + f"{label} ({step:.12g} s) is smaller than the binary64 timestamp " + f"resolution ({float(timestamp_resolution):.12g} s) at the supplied epoch; " + "use a larger value or subtract a common epoch and provide relative-second GTIs" + ) + + +def _first_collapsed_binary64_interval(gtis: np.ndarray) -> int | None: + """Return the one-based row whose JSON float endpoints would collapse.""" + serialized = np.asarray(gtis, dtype=float) + collapsed = np.flatnonzero(serialized[:, 1] <= serialized[:, 0]) + return int(collapsed[0]) + 1 if collapsed.size else None + + +def _relative_gtis_for_step( + source_gtis: np.ndarray, + step: float, + *, + allow_upward_snap: bool, +) -> tuple[np.longdouble, np.ndarray]: + """Translate GTIs and remove only epoch-resolution endpoint residue. + + Subtracting two binary64 mission timestamps can make a duration differ + from an integer number of steps by less than either absolute endpoint's + ULP. Fixed-window generation may snap in either direction because results + are checked against the original GTIs after translation. Exposure + splitting snaps downward only: an upward product can change Stingray's + otherwise-correct small-origin chunk grouping. + """ + origin = source_gtis[0, 0] + relative_gtis = source_gtis - origin + step_value = np.longdouble(step) + + for index, (source_start, source_stop) in enumerate(source_gtis): + relative_length = relative_gtis[index, 1] - relative_gtis[index, 0] + with np.errstate(over="ignore", divide="ignore", invalid="ignore"): + quotient = relative_length / step_value + if ( + not np.isfinite(quotient) + or quotient > MAX_GTI_ROWS + 1 + or quotient < np.longdouble(0.5) + ): + continue + nearest_count = round(float(quotient)) + if nearest_count <= 0: + continue + nearest_length = np.longdouble(nearest_count) * step_value + # At most half an endpoint ULP can be attributed to round-to-nearest + # subtraction residue. One or more full ULPs are representable source + # exposure and must never be quantized away. + tolerance = _float64_ulp_tolerance( + source_start, + source_stop, + factor=0.5, + ) + if abs(relative_length - nearest_length) <= tolerance: + snapped_length = nearest_length + if not allow_upward_snap: + # Repeated binary multiplication can land above Stingray's + # favorable chunk edge (for example, 3 * 0.1). Form the + # human-facing decimal product, round it once to binary64, + # and never enlarge the supplied relative GTI. + try: + exact_decimal_length = Decimal(str(step)) * nearest_count + decimal_length = float(exact_decimal_length) + except (InvalidOperation, OverflowError, ValueError): + continue + if not math.isfinite(decimal_length): + continue + if Decimal.from_float(decimal_length) > exact_decimal_length: + decimal_length = float(np.nextafter(decimal_length, -math.inf)) + snapped_length = np.longdouble(decimal_length) + if snapped_length <= 0 or snapped_length > relative_length: + continue + snapped_stop = relative_gtis[index, 0] + snapped_length + if ( + not allow_upward_snap + and snapped_stop - relative_gtis[index, 0] > snapped_length + ): + snapped_stop = np.nextafter(snapped_stop, -math.inf) + relative_gtis[index, 1] = snapped_stop + + return origin, relative_gtis + + +def _contained_fixed_segments( + starts: Any, + stops: Any, + source_gtis: np.ndarray, +) -> tuple[np.ndarray, int]: + """Keep only upstream fixed segments fully contained in one source GTI. + + ``time_intervals_from_gtis`` uses an absolute 1e-5 second epsilon. That + tolerance can emit a segment whose stop is later than its source GTI. We + retain the public helper's valid output while enforcing the scientific + containment invariant at the application boundary. + """ + result = _normalise_upstream_gtis( + np.column_stack((starts, stops)), label="fixed-duration segments" + ) + contained: list[np.ndarray] = [] + omitted = 0 + source_index = 0 + previous_source_index: int | None = None + + for segment_index, segment in enumerate(result, start=1): + candidate = np.asarray(segment, dtype=np.longdouble).copy() + start, stop = candidate + if not np.isfinite(start) or not np.isfinite(stop) or stop <= start: + raise RuntimeError( + "Stingray returned an invalid fixed-duration segment at " + f"index {segment_index}" + ) + + while source_index < len(source_gtis) and start >= source_gtis[source_index, 1]: + source_index += 1 + if source_index >= len(source_gtis): + omitted += 1 + continue + + source_start, source_stop = source_gtis[source_index] + if start < source_start: + tolerance = _float64_ulp_tolerance(start, source_start) + if source_start - start > tolerance: + raise RuntimeError( + "Stingray returned a fixed-duration segment before its source GTI " + f"at index {segment_index}" + ) + candidate[0] = source_start + if stop > source_stop: + tolerance = _float64_ulp_tolerance(stop, source_stop) + if stop - source_stop > tolerance: + omitted += 1 + continue + candidate[1] = source_stop + + # np.arange and ``starts + segment_size`` can round the same internal + # boundary in opposite directions. Snap only adjacent endpoints that + # differ by a few ULPs; material gaps or overlaps remain untouched. + if contained and previous_source_index == source_index: + previous_stop = contained[-1][1] + tolerance = _float64_ulp_tolerance(candidate[0], previous_stop) + if abs(candidate[0] - previous_stop) <= tolerance: + candidate[0] = previous_stop + + if candidate[1] <= candidate[0]: + raise RuntimeError( + "Stingray returned an invalid fixed-duration segment after " + f"endpoint normalization at index {segment_index}" + ) + contained.append(candidate) + previous_source_index = source_index + + if not contained: + return np.empty((0, 2), dtype=np.longdouble), omitted + return np.asarray(contained, dtype=np.longdouble), omitted + + +def _interval_payload(gtis: np.ndarray) -> dict[str, Any]: + """Create exact interval rows, summary statistics, and a bounded plot trace.""" + if len(gtis) == 0: + return { + "intervals": [], + "interval_count": 0, + "lengths_s": [], + "separations_s": [], + "total_exposure_s": 0.0, + "overall_time_span_s": 0.0, + "duty_cycle": None, + "plot": { + "starts": [], + "stops": [], + "interval_indices": [], + "stride": 1, + "source_points": 0, + }, + } + + starts = gtis[:, 0] + stops = gtis[:, 1] + lengths = stops - starts + separations = starts[1:] - stops[:-1] + span = stops[-1] - starts[0] + exposure = np.sum(lengths, dtype=np.longdouble) + plot = bounded_plot_preview(starts, stops, np.arange(1, len(gtis) + 1)) + return { + "intervals": [ + { + "index": index + 1, + "start": float(start), + "stop": float(stop), + "length_s": float(stop - start), + } + for index, (start, stop) in enumerate(gtis) + ], + "interval_count": len(gtis), + "lengths_s": lengths.astype(float).tolist(), + "separations_s": separations.astype(float).tolist(), + "total_exposure_s": float(exposure), + "overall_time_span_s": float(span), + "duty_cycle": float(exposure / span) if span > 0 else None, + "plot": { + "starts": plot["arrays"][0], + "stops": plot["arrays"][1], + "interval_indices": plot["arrays"][2], + "stride": plot["stride"], + "source_points": plot["source_points"], + }, + } + + +def _safe_data(data: dict[str, Any], warning_messages: list[str]) -> dict[str, Any]: + safe = json_safe(data, warning_messages) + safe["warnings"] = list(dict.fromkeys(warning_messages)) + return safe + + +class GTIService(BaseService): + """Service implementing the Utilities GTI workbench.""" + + def inspect(self, event_list_name: str) -> dict[str, Any]: + """Inspect the effective GTIs of a detached loaded EventList snapshot.""" + try: + event_list = self.state.copy_event_data( + event_list_name, + max_events=MAX_MASK_EVENTS, + max_cells=MAX_STATE_SNAPSHOT_CELLS, + max_bytes=MAX_STATE_SNAPSHOT_BYTES, + ) + if event_list is None: + return self.create_result( + success=False, + message=f"EventList '{event_list_name}' not found", + error=None, + ) + + warning_messages: list[str] = [] + effective_gti = _stored_event_list_gti(event_list) + if effective_gti is None: + gtis = np.empty((0, 2), dtype=np.longdouble) + gti_status = "missing" + warning_messages.append( + "This EventList has no effective GTI; exposure and duty cycle are unavailable." + ) + else: + gtis, validation_error = _validate_gti_array( + effective_gti, + label="Effective GTIs", + allow_empty=True, + ) + if validation_error: + return self.create_result( + success=False, + message=f"Stored EventList GTIs are invalid: {validation_error}", + error=None, + ) + assert gtis is not None + if len(gtis) == 0: + gti_status = "empty" + warning_messages.append( + "This EventList has an empty effective GTI and therefore zero good-time exposure." + ) + else: + gti_status = "available" + + event_time = getattr(event_list, "time", None) + event_count = 0 if event_time is None else int(np.size(event_time)) + mjdref = finite_or_none( + getattr(event_list, "mjdref", None), warning_messages, "mjdref" + ) + data = { + "event_list_name": event_list_name, + "event_count": event_count, + "gti_status": gti_status, + "gti_origin": "effective_event_list_gti", + "time_unit": "s", + "time_reference": "absolute_mission_time", + "mjdref": mjdref, + **_interval_payload(gtis), + "provenance": operation_provenance( + "gti.inspect", + input_source={"type": "event_list", "name": event_list_name}, + parameters={}, + ), + } + return self.create_result( + success=True, + data=_safe_data(data, warning_messages), + message=( + f"Inspected {len(gtis)} effective GTI interval(s) " + f"for EventList '{event_list_name}'" + ), + ) + except Exception as exc: # pragma: no cover - ErrorHandler integration + return self.handle_error( + exc, "Inspecting EventList GTIs", event_list=event_list_name + ) + + def validate( + self, + gtis: Any, + time_reference: str = "absolute_mission_time", + ) -> dict[str, Any]: + """Validate manually entered GTIs without sorting or merging them.""" + try: + reference_error = _validate_time_reference(time_reference) + if reference_error: + return self.create_result( + success=False, message=reference_error, error=None + ) + array, validation_error = _validate_gti_array(gtis) + if validation_error: + return self.create_result( + success=False, message=validation_error, error=None + ) + assert array is not None + warning_messages: list[str] = [] + data = { + "valid": True, + "time_unit": "s", + "time_reference": time_reference, + **_interval_payload(array), + "provenance": operation_provenance( + "gti.validate", + input_source={"type": "manual_gtis"}, + parameters={"time_reference": time_reference}, + ), + } + return self.create_result( + success=True, + data=_safe_data(data, warning_messages), + message=f"Validated {len(array)} GTI interval(s)", + ) + except Exception as exc: # pragma: no cover - ErrorHandler integration + return self.handle_error(exc, "Validating GTIs") + + def set_operation( + self, + left_gtis: Any, + right_gtis: Any, + operation: str, + time_reference: str = "absolute_mission_time", + ) -> dict[str, Any]: + """Intersect, union, or append two independently valid GTI sets.""" + try: + if operation not in {"intersection", "union", "append"}: + return self.create_result( + success=False, + message="operation must be one of: intersection, union, append", + error=None, + ) + reference_error = _validate_time_reference(time_reference) + if reference_error: + return self.create_result( + success=False, message=reference_error, error=None + ) + left, left_error = _validate_gti_array(left_gtis, label="Left GTIs") + if left_error: + return self.create_result(success=False, message=left_error, error=None) + right, right_error = _validate_gti_array(right_gtis, label="Right GTIs") + if right_error: + return self.create_result( + success=False, message=right_error, error=None + ) + assert left is not None and right is not None + + maximum_output_rows = ( + len(left) + len(right) - 1 + if operation == "intersection" + else len(left) + len(right) + ) + if maximum_output_rows > MAX_GTI_ROWS: + return self.create_result( + success=False, + message=( + f"The requested {operation} can produce up to " + f"{maximum_output_rows:,} rows; the cap is {MAX_GTI_ROWS:,}" + ), + error=None, + ) + + warning_messages: list[str] = [] + if operation == "intersection": + result = cross_two_gtis(left, right) + strategy = "exact shared good time" + elif operation == "union": + result = merge_gtis([left, right], "union") + strategy = "union with overlapping and touching intervals coalesced" + else: + if not check_separate(left, right): + return self.create_result( + success=False, + message=( + "Append requires mutually exclusive GTI sets; use union when " + "the two sets overlap" + ), + error=None, + ) + result = append_gtis(left, right) + strategy = ( + "append mutually exclusive sets; touching boundaries are joined" + ) + + result_array = _normalise_upstream_gtis(result, label=f"{operation} GTIs") + if operation == "intersection" and len(result_array) == 0: + warning_messages.append( + "The GTI sets have no shared positive-duration good time." + ) + if operation in {"union", "append"} and len(result_array) < len(left) + len( + right + ): + warning_messages.append( + "Touching or overlapping boundaries were coalesced according to the selected strategy." + ) + + data = { + "operation": operation, + "merge_strategy": strategy, + "time_unit": "s", + "time_reference": time_reference, + **_interval_payload(result_array), + "provenance": operation_provenance( + f"gti.{operation}", + input_source={"type": "two_manual_gti_sets"}, + parameters={ + "operation": operation, + "time_reference": time_reference, + "left_interval_count": len(left), + "right_interval_count": len(right), + }, + ), + } + return self.create_result( + success=True, + data=_safe_data(data, warning_messages), + message=f"Computed GTI {operation}: {len(result_array)} interval(s)", + ) + except Exception as exc: # pragma: no cover - ErrorHandler integration + return self.handle_error(exc, f"Computing GTI {operation}") + + def bad_time_intervals( + self, + gtis: Any, + start_time: Any, + stop_time: Any, + time_reference: str = "absolute_mission_time", + ) -> dict[str, Any]: + """Return the complement of GTIs inside an explicit observation range.""" + try: + reference_error = _validate_time_reference(time_reference) + if reference_error: + return self.create_result( + success=False, message=reference_error, error=None + ) + start, start_error = _strict_finite_scalar(start_time, "start_time") + if start_error: + return self.create_result( + success=False, message=start_error, error=None + ) + stop, stop_error = _strict_finite_scalar(stop_time, "stop_time") + if stop_error: + return self.create_result(success=False, message=stop_error, error=None) + assert start is not None and stop is not None + if stop <= start: + return self.create_result( + success=False, + message="stop_time must be greater than start_time", + error=None, + ) + + array, validation_error = _validate_gti_array(gtis, allow_empty=True) + if validation_error: + return self.create_result( + success=False, message=validation_error, error=None + ) + assert array is not None + if len(array) and array[0, 0] < start: + return self.create_result( + success=False, + message=( + f"GTIs interval 1 starts at {float(array[0, 0])}, before " + f"the defined observation start {start}" + ), + error=None, + ) + if len(array) and array[-1, 1] > stop: + return self.create_result( + success=False, + message=( + f"GTIs interval {len(array)} stops at {float(array[-1, 1])}, " + f"after the defined observation stop {stop}" + ), + error=None, + ) + + possible_bti_rows = ( + 1 + if len(array) == 0 + else ( + len(array) - 1 + int(array[0, 0] > start) + int(array[-1, 1] < stop) + ) + ) + if possible_bti_rows > MAX_GTI_ROWS: + return self.create_result( + success=False, + message=( + "The requested complement can produce " + f"{possible_bti_rows:,} bad-time rows; the cap is " + f"{MAX_GTI_ROWS:,}" + ), + error=None, + ) + + raw_result = get_btis(array, start_time=start, stop_time=stop) + result = _normalise_upstream_gtis(raw_result, label="bad-time intervals") + warning_messages: list[str] = [] + if len(result): + positive = result[:, 1] > result[:, 0] + if not np.all(positive): + omitted = int(np.count_nonzero(~positive)) + warning_messages.append( + f"Omitted {omitted} zero-duration boundary interval(s) returned " + "by Stingray for touching GTIs." + ) + result = result[positive] + + data = { + "time_unit": "s", + "time_reference": time_reference, + "observation_start": start, + "observation_stop": stop, + "good_exposure_s": float(np.sum(array[:, 1] - array[:, 0])) + if len(array) + else 0.0, + "bad_exposure_s": float(np.sum(result[:, 1] - result[:, 0])) + if len(result) + else 0.0, + **_interval_payload(result), + "provenance": operation_provenance( + "gti.bad_time_intervals", + input_source={"type": "manual_gtis"}, + parameters={ + "start_time": start, + "stop_time": stop, + "time_reference": time_reference, + }, + ), + } + return self.create_result( + success=True, + data=_safe_data(data, warning_messages), + message=f"Generated {len(result)} bad-time interval(s)", + ) + except Exception as exc: # pragma: no cover - ErrorHandler integration + return self.handle_error(exc, "Generating bad-time intervals") + + def _prepare_mask( + self, event_list_name: str, gtis: Any + ) -> tuple[Any, np.ndarray | None, np.ndarray | None, list[str], str | None]: + """Snapshot an EventList and compute a scientifically bounded GTI mask.""" + event_list = self.state.copy_event_data( + event_list_name, + max_events=MAX_MASK_EVENTS, + max_cells=MAX_STATE_SNAPSHOT_CELLS, + max_bytes=MAX_STATE_SNAPSHOT_BYTES, + ) + if event_list is None: + return None, None, None, [], f"EventList '{event_list_name}' not found" + + time = getattr(event_list, "time", None) + if time is None or np.size(time) == 0: + return ( + event_list, + None, + None, + [], + (f"EventList '{event_list_name}' contains no event times"), + ) + time_array = np.asanyarray(time) + if time_array.ndim != 1: + return ( + event_list, + None, + None, + [], + "EventList time data must be one-dimensional", + ) + if len(time_array) > MAX_MASK_EVENTS: + return ( + event_list, + None, + None, + [], + ( + f"EventList '{event_list_name}' contains {len(time_array):,} events; " + f"GTI masking is capped at {MAX_MASK_EVENTS:,} events" + ), + ) + if not np.all(np.isfinite(time_array)): + bad_index = int(np.flatnonzero(~np.isfinite(time_array))[0]) + return ( + event_list, + None, + None, + [], + (f"EventList time[{bad_index}] must be finite before GTI masking"), + ) + displaced = np.flatnonzero(np.diff(time_array) < 0) + if displaced.size: + row = int(displaced[0] + 2) + return ( + event_list, + None, + None, + [], + ( + f"EventList times are not ordered at event {row}; GTI masking requires " + "nondecreasing time" + ), + ) + + requested, requested_error = _validate_gti_array(gtis, label="Requested GTIs") + if requested_error: + return event_list, None, None, [], requested_error + assert requested is not None + + effective_value = _stored_event_list_gti(event_list) + warning_messages: list[str] = [] + if effective_value is None: + return ( + event_list, + None, + None, + [], + ( + f"EventList '{event_list_name}' has no effective GTI to intersect " + "with the requested intervals" + ), + ) + effective, effective_error = _validate_gti_array( + effective_value, + label="Effective EventList GTIs", + allow_empty=True, + ) + if effective_error: + return event_list, None, None, [], effective_error + assert effective is not None + + possible_intersections = ( + len(effective) + len(requested) - 1 if len(effective) else 0 + ) + if possible_intersections > MAX_GTI_ROWS: + return ( + event_list, + None, + None, + [], + ( + "Intersecting requested and effective GTIs can produce up to " + f"{possible_intersections:,} rows; the cap is {MAX_GTI_ROWS:,}" + ), + ) + + if len(effective) == 0: + applied = np.empty((0, 2), dtype=np.longdouble) + mask = np.zeros(len(time_array), dtype=bool) + warning_messages.append( + "The source EventList has zero effective good-time exposure; no events are retained." + ) + else: + applied = _normalise_upstream_gtis( + cross_two_gtis(effective, requested), + label="effective mask GTIs", + ) + if len(applied) == 0: + mask = np.zeros(len(time_array), dtype=bool) + warning_messages.append( + "The requested GTIs do not intersect the source EventList's effective GTIs." + ) + else: + raw_dt = getattr(event_list, "dt", None) + if raw_dt is None: + mask_dt = 0.0 + warning_messages.append( + "The EventList has no time-bin width (dt); events were treated " + "as point timestamps when applying GTIs." + ) + else: + mask_dt, dt_error = _strict_finite_scalar(raw_dt, "EventList.dt") + if dt_error: + return event_list, None, None, warning_messages, dt_error + assert mask_dt is not None + if mask_dt < 0: + return ( + event_list, + None, + None, + warning_messages, + "EventList.dt must be non-negative", + ) + with collect_warnings(warning_messages): + mask = np.asanyarray( + create_gti_mask(time_array, applied, dt=mask_dt), + dtype=bool, + ) + if mask.shape != time_array.shape: + raise RuntimeError( + "Stingray returned a GTI mask whose shape does not match EventList.time" + ) + + requested_exposure = np.sum(requested[:, 1] - requested[:, 0]) + applied_exposure = ( + np.sum(applied[:, 1] - applied[:, 0]) if len(applied) else np.longdouble(0) + ) + tolerance = np.finfo(float).eps * max(1.0, abs(float(requested_exposure))) * 16 + if float(requested_exposure - applied_exposure) > tolerance: + warning_messages.append( + "Requested intervals were clipped to the source EventList's effective GTIs; " + "retained exposure reports the intersection." + ) + if not np.any(mask): + warning_messages.append("The GTI mask retains no events.") + + return event_list, applied, mask, warning_messages, None + + def mask_preview(self, event_list_name: str, gtis: Any) -> dict[str, Any]: + """Preview a GTI filter without changing application state.""" + try: + event_list, applied, mask, warning_messages, preparation_error = ( + self._prepare_mask(event_list_name, gtis) + ) + if preparation_error: + return self.create_result( + success=False, message=preparation_error, error=None + ) + assert event_list is not None and applied is not None and mask is not None + time = np.asanyarray(event_list.time) + retained_count = int(np.count_nonzero(mask)) + exact_count = min(len(time), MAX_EXACT_OUTPUT) + plot = bounded_plot_preview(time, mask.astype(np.int8)) + exposure = ( + float(np.sum(applied[:, 1] - applied[:, 0])) if len(applied) else 0.0 + ) + data = { + "event_list_name": event_list_name, + "source_event_count": len(time), + "retained_event_count": retained_count, + "rejected_event_count": int(len(time) - retained_count), + "retained_exposure_s": exposure, + "time_unit": "s", + "time_reference": "absolute_mission_time", + "applied_gtis": _interval_payload(applied), + "mask_preview": { + "time": time[:exact_count].astype(float).tolist(), + "retained": mask[:exact_count].tolist(), + "shown": exact_count, + "total": len(time), + "truncated": exact_count < len(time), + }, + "plot": { + "time": plot["arrays"][0], + "retained": plot["arrays"][1], + "stride": plot["stride"], + "source_points": plot["source_points"], + }, + "provenance": operation_provenance( + "gti.mask_preview", + input_source={"type": "event_list", "name": event_list_name}, + parameters={"requested_gtis": gtis}, + ), + } + return self.create_result( + success=True, + data=_safe_data(data, warning_messages), + message=( + f"GTI mask preview retains {retained_count:,} of {len(time):,} events" + ), + ) + except Exception as exc: # pragma: no cover - ErrorHandler integration + return self.handle_error( + exc, "Previewing a GTI mask", event_list=event_list_name + ) + + def save_masked( + self, + event_list_name: str, + gtis: Any, + destination_name: str, + ) -> dict[str, Any]: + """Save a detached GTI-filtered EventList under a unique state name.""" + try: + name_error = validate_derived_name(destination_name) + if name_error: + return self.create_result(success=False, message=name_error, error=None) + if self.state.has_event_data(destination_name): + return self.create_result( + success=False, + message=f"EventList '{destination_name}' already exists; choose a unique name", + error=None, + ) + + event_list, applied, mask, warning_messages, preparation_error = ( + self._prepare_mask(event_list_name, gtis) + ) + if preparation_error: + return self.create_result( + success=False, message=preparation_error, error=None + ) + assert event_list is not None and applied is not None and mask is not None + + # Reuse the exact preview mask. Stingray 2.2.10's + # apply_gtis(..., inplace=False) recomputes that mask, leaves the + # copied source GTI on its result, and rejects an empty GTI. Public + # apply_mask avoids all three quirks. + derived = event_list.apply_mask(mask, inplace=False) + derived.gti = np.array(applied, dtype=np.longdouble, copy=True) + + if not self.state.add_event_data_if_absent(destination_name, derived): + return self.create_result( + success=False, + message=( + f"EventList '{destination_name}' was created concurrently; " + "choose a unique name" + ), + error=None, + ) + + retained_count = 0 if derived.time is None else len(derived.time) + provenance = operation_provenance( + "gti.save_masked", + input_source={"type": "event_list", "name": event_list_name}, + parameters={ + "destination_name": destination_name, + "requested_gtis": gtis, + }, + derived_object={"type": "event_list", "name": destination_name}, + ) + data = { + "source_event_list_name": event_list_name, + "destination_name": destination_name, + "time_unit": "s", + "time_reference": "absolute_mission_time", + "source_event_count": len(mask), + "retained_event_count": retained_count, + "rejected_event_count": int(len(mask) - retained_count), + "retained_exposure_s": ( + float(np.sum(applied[:, 1] - applied[:, 0])) + if len(applied) + else 0.0 + ), + "applied_gtis": _interval_payload(applied), + "provenance": provenance, + } + return self.create_result( + success=True, + data=_safe_data(data, warning_messages), + message=( + f"Saved filtered EventList '{destination_name}' with " + f"{retained_count:,} event(s)" + ), + ) + except Exception as exc: # pragma: no cover - ErrorHandler integration + return self.handle_error( + exc, + "Saving a GTI-filtered EventList", + event_list=event_list_name, + destination=destination_name, + ) + + def fixed_segments( + self, + gtis: Any, + segment_size: Any, + time_reference: str = "absolute_mission_time", + ) -> dict[str, Any]: + """Generate non-overlapping fixed-duration intervals within GTIs.""" + try: + reference_error = _validate_time_reference(time_reference) + if reference_error: + return self.create_result( + success=False, message=reference_error, error=None + ) + size, size_error = _strict_finite_scalar(segment_size, "segment_size") + if size_error: + return self.create_result(success=False, message=size_error, error=None) + assert size is not None + if size <= 0: + return self.create_result( + success=False, message="segment_size must be positive", error=None + ) + array, validation_error = _validate_gti_array(gtis) + if validation_error: + return self.create_result( + success=False, message=validation_error, error=None + ) + assert array is not None + + origin, relative_gtis = _relative_gtis_for_step( + array, + size, + allow_upward_snap=True, + ) + lengths = relative_gtis[:, 1] - relative_gtis[:, 0] + epsilon = 1e-5 + predicted = 0 + for length in lengths: + if length + epsilon < size: + continue + numerator = length - size + epsilon + if numerator >= np.longdouble(size) * MAX_GTI_ROWS: + predicted = MAX_GTI_ROWS + 1 + break + quotient = numerator / np.longdouble(size) + predicted += math.floor(float(quotient)) + 1 + if predicted > MAX_GTI_ROWS: + break + if predicted == 0: + return self.create_result( + success=False, + message=( + f"No GTI is at least segment_size ({size}s); reduce the segment size" + ), + error=None, + ) + if predicted > MAX_GTI_ROWS: + return self.create_result( + success=False, + message=( + f"segment_size would generate approximately {predicted:,} intervals; " + f"the cap is {MAX_GTI_ROWS:,}" + ), + error=None, + ) + resolution_error = _step_resolution_error(array, size, "segment_size") + if resolution_error: + return self.create_result( + success=False, + message=resolution_error, + error=None, + ) + + # Upstream asserts instead of returning an empty pair. The + # preflight above gives users a meaningful message; this guard also + # verifies that a future upstream result remains non-empty. + # Absolute mission epochs amplify np.arange rounding. Stingray's + # public helper is still authoritative, but receives a translated + # GTI array so its arithmetic is performed near zero. + starts, stops = time_intervals_from_gtis(relative_gtis, size) + relative_result, omitted_outside_gtis = _contained_fixed_segments( + starts, stops, relative_gtis + ) + translated_result = relative_result + origin + result, omitted_after_translation = _contained_fixed_segments( + translated_result[:, 0], + translated_result[:, 1], + array, + ) + omitted_outside_gtis += omitted_after_translation + if len(result) == 0: + return self.create_result( + success=False, + message=( + f"No GTI fully contains a segment of {size}s; " + "reduce the segment size" + ), + error=None, + ) + collapsed_row = _first_collapsed_binary64_interval(result) + if collapsed_row is not None: + return self.create_result( + success=False, + message=( + "A generated fixed-duration interval cannot be represented " + "with distinct binary64 absolute timestamps at row " + f"{collapsed_row}; use a larger segment_size or relative-second GTIs" + ), + error=None, + ) + + warning_messages: list[str] = [] + if omitted_outside_gtis: + warning_messages.append( + f"Omitted {omitted_outside_gtis} candidate segment(s) returned by " + "Stingray because they exceeded a source GTI boundary." + ) + source_lengths = array[:, 1] - array[:, 0] + source_exposure_value = np.sum(source_lengths, dtype=np.longdouble) + segmented_exposure_value = np.sum( + result[:, 1] - result[:, 0], dtype=np.longdouble + ) + exposure_tolerance = ( + np.longdouble(np.finfo(float).eps) + * max(np.longdouble(1.0), abs(source_exposure_value)) + * 16 + ) + if segmented_exposure_value - source_exposure_value > exposure_tolerance: + raise RuntimeError( + "Fixed-duration segments exceed the source good-time exposure" + ) + if ( + abs(segmented_exposure_value - source_exposure_value) + <= exposure_tolerance + ): + segmented_exposure_value = source_exposure_value + source_exposure = float(source_exposure_value) + segmented_exposure = float(segmented_exposure_value) + unused = float(source_exposure_value - segmented_exposure_value) + if unused > np.finfo(float).eps * max(1.0, source_exposure) * 16: + warning_messages.append( + f"{unused:.12g} s of remainder shorter than one full segment was omitted." + ) + data = { + "segment_size_s": size, + "source_exposure_s": source_exposure, + "segmented_exposure_s": segmented_exposure, + "unused_exposure_s": unused, + "time_unit": "s", + "time_reference": time_reference, + **_interval_payload(result), + "provenance": operation_provenance( + "gti.fixed_segments", + input_source={"type": "manual_gtis"}, + parameters={ + "segment_size": size, + "time_reference": time_reference, + }, + ), + } + return self.create_result( + success=True, + data=_safe_data(data, warning_messages), + message=f"Generated {len(result)} fixed-duration segment(s)", + ) + except Exception as exc: # pragma: no cover - ErrorHandler integration + return self.handle_error(exc, "Generating fixed-duration GTI segments") + + def split_by_exposure( + self, + gtis: Any, + exposure_per_chunk: Any, + new_interval_if_gti_sep: Any = None, + time_reference: str = "absolute_mission_time", + ) -> dict[str, Any]: + """Split GTIs into Stingray's approximate-exposure chunk groups.""" + try: + reference_error = _validate_time_reference(time_reference) + if reference_error: + return self.create_result( + success=False, message=reference_error, error=None + ) + exposure, exposure_error = _strict_finite_scalar( + exposure_per_chunk, "exposure_per_chunk" + ) + if exposure_error: + return self.create_result( + success=False, message=exposure_error, error=None + ) + assert exposure is not None + if exposure <= 0: + return self.create_result( + success=False, + message="exposure_per_chunk must be positive", + error=None, + ) + + separation: float | None = None + if new_interval_if_gti_sep is not None: + separation, separation_error = _strict_finite_scalar( + new_interval_if_gti_sep, "new_interval_if_gti_sep" + ) + if separation_error: + return self.create_result( + success=False, message=separation_error, error=None + ) + assert separation is not None + if separation <= 0: + return self.create_result( + success=False, + message="new_interval_if_gti_sep must be positive when supplied", + error=None, + ) + + array, validation_error = _validate_gti_array(gtis) + if validation_error: + return self.create_result( + success=False, message=validation_error, error=None + ) + assert array is not None + + with np.errstate(over="ignore", invalid="ignore"): + lengths = array[:, 1] - array[:, 0] + nonfinite_lengths = np.flatnonzero(~np.isfinite(lengths)) + if nonfinite_lengths.size: + interval_number = int(nonfinite_lengths[0]) + 1 + return self.create_result( + success=False, + message=( + f"GTIs interval {interval_number} has a duration that cannot " + "be represented as finite seconds; use a narrower time range" + ), + error=None, + ) + with np.errstate(over="ignore", invalid="ignore"): + source_exposure_value = np.sum(lengths, dtype=np.longdouble) + if not np.isfinite(source_exposure_value): + return self.create_result( + success=False, + message=( + "Total GTI exposure cannot be represented as finite seconds; " + "use fewer intervals or a narrower time range" + ), + error=None, + ) + + predicted_rows = 0 + for length in lengths: + with np.errstate(over="ignore", divide="ignore", invalid="ignore"): + quotient = length / np.longdouble(exposure) + remaining_rows = MAX_GTI_ROWS - predicted_rows + if not np.isfinite(quotient) or quotient > remaining_rows: + predicted_rows = MAX_GTI_ROWS + 1 + break + predicted_rows += max(1, math.ceil(float(quotient))) + if predicted_rows > MAX_GTI_ROWS: + break + if predicted_rows > MAX_GTI_ROWS: + return self.create_result( + success=False, + message=( + "exposure_per_chunk would require approximately " + f"{predicted_rows:,} GTI rows; the cap is {MAX_GTI_ROWS:,}" + ), + error=None, + ) + resolution_error = _step_resolution_error( + array, + exposure, + "exposure_per_chunk", + ) + if resolution_error: + return self.create_result( + success=False, + message=resolution_error, + error=None, + ) + + # As with fixed segments, split relative times so public Stingray + # arithmetic is independent of a large absolute mission epoch. + origin, relative_gtis = _relative_gtis_for_step( + array, + exposure, + allow_upward_snap=False, + ) + effective_separation = separation + if separation is not None and not find_large_bad_time_intervals( + relative_gtis, + separation, + ): + # Stingray 2.2.10 indexes an empty compulsory-edge array when + # the optional threshold finds no qualifying source gap. + # In that no-op case, use its ordinary public split path. + effective_separation = None + raw_chunks = split_gtis_by_exposure( + relative_gtis, + exposure, + new_interval_if_gti_sep=effective_separation, + ) + # 2.2.10 returns either a list of 2-D arrays or a 3-D ndarray, + # including ``(1, 0, 2)`` for empty input. Empty input is rejected + # above; normalize only the container shape here. + chunks: list[np.ndarray] = [] + for index, raw_chunk in enumerate(raw_chunks): + chunk = _normalise_upstream_gtis( + raw_chunk, label=f"exposure chunk {index + 1}" + ) + if len(chunk) == 0: + # Stingray 2.2.10 can append an empty trailing chunk when + # np.arange lands exactly on the relative exposure edge. + continue + chunks.append(chunk + origin) + if not chunks: + raise RuntimeError("Stingray returned no exposure chunks") + serialized_row = 0 + for chunk in chunks: + collapsed_row = _first_collapsed_binary64_interval(chunk) + if collapsed_row is not None: + return self.create_result( + success=False, + message=( + "A split exposure interval cannot be represented with " + "distinct binary64 absolute timestamps at output row " + f"{serialized_row + collapsed_row}; use a larger " + "exposure_per_chunk or relative-second GTIs" + ), + error=None, + ) + serialized_row += len(chunk) + total_rows = sum(len(chunk) for chunk in chunks) + if total_rows > MAX_GTI_ROWS: + raise RuntimeError( + f"Stingray returned {total_rows:,} split GTI rows; " + f"the cap is {MAX_GTI_ROWS:,}" + ) + + source_exposure = float(source_exposure_value) + output_exposure = float( + sum(np.sum(chunk[:, 1] - chunk[:, 0]) for chunk in chunks) + ) + if not math.isclose( + source_exposure, + output_exposure, + rel_tol=1e-12, + abs_tol=np.finfo(float).eps * max(1.0, source_exposure) * 16, + ): + raise RuntimeError( + "Stingray exposure splitting did not preserve total good-time exposure" + ) + + chunk_payloads: list[dict[str, Any]] = [] + flat_starts: list[float] = [] + flat_stops: list[float] = [] + flat_chunk_indices: list[int] = [] + for index, chunk in enumerate(chunks): + payload = _interval_payload(chunk) + chunk_payloads.append( + { + "chunk_index": index + 1, + **payload, + } + ) + flat_starts.extend(chunk[:, 0].astype(float).tolist()) + flat_stops.extend(chunk[:, 1].astype(float).tolist()) + flat_chunk_indices.extend([index + 1] * len(chunk)) + + plot = bounded_plot_preview(flat_starts, flat_stops, flat_chunk_indices) + warning_messages = [ + ( + "Stingray's exposure split is approximate: chunks preserve GTI " + "boundaries and can differ from the requested exposure." + ) + ] + data = { + "exposure_per_chunk_s": exposure, + "new_interval_if_gti_sep_s": separation, + "source_exposure_s": source_exposure, + "output_exposure_s": output_exposure, + "chunk_count": len(chunks), + "interval_count": total_rows, + "chunks": chunk_payloads, + "plot": { + "starts": plot["arrays"][0], + "stops": plot["arrays"][1], + "chunk_indices": plot["arrays"][2], + "stride": plot["stride"], + "source_points": plot["source_points"], + }, + "time_unit": "s", + "time_reference": time_reference, + "provenance": operation_provenance( + "gti.split_by_exposure", + input_source={"type": "manual_gtis"}, + parameters={ + "exposure_per_chunk": exposure, + "new_interval_if_gti_sep": separation, + "time_reference": time_reference, + }, + ), + } + return self.create_result( + success=True, + data=_safe_data(data, warning_messages), + message=f"Split GTIs into {len(chunks)} approximate-exposure chunk(s)", + ) + except Exception as exc: # pragma: no cover - ErrorHandler integration + return self.handle_error(exc, "Splitting GTIs by exposure") diff --git a/python-backend/services/io_utility_service.py b/python-backend/services/io_utility_service.py new file mode 100644 index 0000000..e3b2bc7 --- /dev/null +++ b/python-backend/services/io_utility_service.py @@ -0,0 +1,3343 @@ +"""Safe file inspection, RMF calibration, and tabular export utilities. + +The existing data-ingestion service remains the only way to load arbitrary +files into application state. This service only inspects explicitly selected +files, derives calibrated copies of loaded EventLists, and exports objects that +are already in state. +""" + +from __future__ import annotations + +import copy +import importlib +import json +import math +import os +from collections.abc import Iterable, Mapping, Sized +from contextlib import ExitStack +from decimal import Decimal, InvalidOperation +from itertools import islice +from numbers import Integral, Real +from typing import Any, BinaryIO + +import numpy as np +from astropy import units as u +from astropy.io import fits +from astropy.table import Column, MaskedColumn, Table +from astropy.table import meta as astropy_table_meta +from astropy.table import serialize as astropy_table_serialize +from astropy.utils.data_info import serialize_context_as +from astropy.utils.masked import Masked +from stingray.io import high_precision_keyword_read, pi_to_energy, read_rmf + +from .analysis_helpers import collect_warnings +from .base_service import BaseService +from .secure_publication import open_secure_publication +from .state_manager import _enforce_precopy_caps +from .utility_helpers import ( + MAX_ARRAY_INPUT, + MAX_EXPORT_ROWS, + MAX_FITS_INSPECT_BYTES, + MAX_RMF_BYTES, + bounded_plot_preview, + duplicate_binary_stream, + json_safe, + open_verified_read_grant, + operation_provenance, + validate_derived_name, + validate_file_size, +) + +MAX_INSPECT_HDUS = 512 +MAX_COLUMNS_PER_HDU = 1_024 +MAX_RMF_ROWS = 1_000_000 +MAX_RMF_PI_WORK = 20_000_000 +MAX_EXACT_CHANNEL = 2**53 - 1 +MAX_EVENT_PREVIEW_ROWS = 500 +MAX_EXPORT_COLUMNS = 1_024 +MAX_EXPORT_CELLS = 20_000_000 +MAX_EXPORT_ESTIMATED_BYTES = 512 * 1024**2 + +FITS_EXTENSIONS = {".fits", ".fit", ".fts", ".evt", ".rmf", ".rsp"} +EXPORT_EXTENSIONS = { + "csv": ".csv", + "ecsv": ".ecsv", + "json": ".json", + "fits": ".fits", + "hdf5": ".hdf5", +} +EXPORT_OBJECT_TYPES = {"event_list", "lightcurve", "analysis_result"} + +HDF5_SCHEMA = "stingray-explorer.hdf5.v1" +HDF5_GROUP_PATH = "stingray_explorer" +HDF5_TABLE_PATH = f"{HDF5_GROUP_PATH}/table" +HDF5_MANIFEST_PATH = f"{HDF5_GROUP_PATH}/column_manifest" +HDF5_ASTROPY_METADATA_PATH = f"{HDF5_TABLE_PATH}.__table_column_meta__" +HDF5_EXTENSION = ".hdf5" +HDF5_MANIFEST_MAX_BYTES = 2 * 1024**2 +HDF5_UNAVAILABLE_REASON = ( + "HDF5 export requires the optional 'h5py' runtime dependency, which is not " + "available." +) +HDF5_SUPPORTED_COLUMN_KINDS = {"b", "i", "u", "f", "U"} +HDF5_SUPPORTED_ITEM_SIZES = { + "b": {1}, + "i": {1, 2, 4, 8}, + "u": {1, 2, 4, 8}, + "f": {2, 4, 8}, +} +HDF5_VERIFICATION_CHECKS = [ + "schema_and_layout", + "row_count", + "ordered_columns", + "logical_dtypes", + "masks_and_values", + "units", + "column_metadata_and_fill_values", + "object_metadata_gti_and_provenance", +] + +TIMING_KEYWORDS = ( + "MJDREF", + "MJDREFI", + "MJDREFF", + "MJD-OBS", + "MJD-END", + "TIMESYS", + "TIMEREF", + "TREFPOS", + "TIMEUNIT", + "TIMEZERO", + "TIMEZERI", + "TIMEZERF", + "TSTART", + "TSTARTI", + "TSTARTF", + "TSTOP", + "TSTOPI", + "TSTOPF", + "TIMEDEL", + "TIMEPIXR", + "DATE-OBS", + "DATE-END", + "CLOCKAPP", +) + +NUMERIC_TIMING_KEYWORDS = { + "MJDREF", + "MJDREFI", + "MJDREFF", + "MJD-OBS", + "MJD-END", + "TIMEZERO", + "TIMEZERI", + "TIMEZERF", + "TSTART", + "TSTARTI", + "TSTARTF", + "TSTOP", + "TSTOPI", + "TSTOPF", + "TIMEDEL", + "TIMEPIXR", +} + +SPLIT_HIGH_PRECISION_TIMING_KEYWORDS = { + "TSTART": ("TSTARTI", "TSTARTF"), + "TSTOP": ("TSTOPI", "TSTOPF"), + # high_precision_keyword_read truncates an eight-character keyword before + # adding I/F so the FITS-compatible split spelling is TIMEZERI/TIMEZERF. + "TIMEZERO": ("TIMEZERI", "TIMEZERF"), +} + +ANALYSIS_RESERVED_FIELDS = {"warnings", "provenance", "parameters", "metadata"} +ANALYSIS_HDF5_FILL_REASON_ATTRIBUTE = "_stingray_hdf5_fill_reason" + + +def _display_fits_value(value: Any) -> Any: + """Return one FITS scalar without introducing non-JSON numeric values.""" + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, (float, np.floating, np.integer)): + converted = float(value) + return converted if math.isfinite(converted) else str(value) + return str(value) + + +def _raw_decimal_card_value(header: fits.Header, keyword: str) -> str: + """Return the numeric value field from the original 80-byte FITS card. + + Astropy normally exposes FITS numeric cards as binary64 values. The card + image still retains the decimal token that was actually written, which is + important for split MJD reference epochs that intentionally carry more + digits than binary64 can round-trip. + """ + card = header.cards[keyword] + image = card.image[:80] + if image[8:10] != "= ": + return str(card.value) + value_field = image[10:].split("/", 1)[0].strip() + if value_field.startswith("'"): + return str(card.value) + # FITS permits Fortran-style D exponents, while Decimal accepts E. + return value_field.replace("D", "E").replace("d", "e") + + +def _decimal_mjdref(header: fits.Header) -> dict[str, Any] | None: + """Read MJDREF with Stingray and retain the original FITS components. + + ``high_precision_keyword_read`` is the installed public Stingray helper. + Its numeric precision is platform-dependent, so the API also returns an + exact decimal sum constructed from the original FITS card text. + """ + has_direct = "MJDREF" in header + has_integer = "MJDREFI" in header + has_fraction = "MJDREFF" in header + if not has_direct and has_integer != has_fraction: + raise ValueError("MJDREFI/MJDREFF split reference is incomplete") + + stingray_value = high_precision_keyword_read(header, "MJDREF") + if stingray_value is None: + return None + + components: dict[str, str] = {} + try: + if "MJDREF" in header: + components["MJDREF"] = _raw_decimal_card_value(header, "MJDREF") + exact = Decimal(components["MJDREF"]) + else: + components["MJDREFI"] = _raw_decimal_card_value(header, "MJDREFI") + components["MJDREFF"] = _raw_decimal_card_value(header, "MJDREFF") + exact = Decimal(components["MJDREFI"]) + Decimal(components["MJDREFF"]) + except (InvalidOperation, KeyError, TypeError, ValueError): + exact = Decimal(str(stingray_value)) + + if not exact.is_finite() or not np.isfinite(np.longdouble(stingray_value)): + raise ValueError("MJDREF is non-finite") + + return { + "decimal": format(exact, "f"), + "stingray_value": np.format_float_positional( + np.longdouble(stingray_value), unique=True, trim="-" + ), + "source_keywords": components, + } + + +def _timing_metadata( + header: fits.Header, warning_messages: list[str], hdu_label: str +) -> dict[str, Any]: + raw: dict[str, Any] = {} + invalid_timing = False + for keyword in TIMING_KEYWORDS: + if keyword not in header: + continue + value = header[keyword] + if keyword in NUMERIC_TIMING_KEYWORDS: + try: + decimal_value = Decimal(str(value).strip()) + except (InvalidOperation, TypeError, ValueError): + raw[keyword] = None + invalid_timing = True + warning_messages.append( + f"{hdu_label} timing keyword {keyword} is not numeric and is " + "represented as null." + ) + continue + else: + if not decimal_value.is_finite(): + raw[keyword] = None + invalid_timing = True + warning_messages.append( + f"{hdu_label} timing keyword {keyword} is non-finite and is " + "represented as null." + ) + continue + if keyword == "TIMEDEL" and decimal_value <= 0: + raw[keyword] = None + invalid_timing = True + warning_messages.append( + f"{hdu_label} timing keyword TIMEDEL must be greater than zero " + "and is represented as null." + ) + continue + if keyword == "TIMEPIXR" and not Decimal(0) <= decimal_value <= Decimal( + 1 + ): + raw[keyword] = None + invalid_timing = True + warning_messages.append( + f"{hdu_label} timing keyword TIMEPIXR must be between 0 and 1 " + "and is represented as null." + ) + continue + raw[keyword] = _display_fits_value(value) + + high_precision: dict[str, dict[str, Any]] = {} + for keyword, ( + integer_keyword, + fraction_keyword, + ) in SPLIT_HIGH_PRECISION_TIMING_KEYWORDS.items(): + has_direct = keyword in header + has_integer = integer_keyword in header + has_fraction = fraction_keyword in header + if not has_direct and not has_integer and not has_fraction: + continue + if not has_direct and has_integer != has_fraction: + raw[keyword] = None + invalid_timing = True + warning_messages.append( + f"{hdu_label} split timing keyword {keyword} is incomplete; both " + f"{integer_keyword} and {fraction_keyword} are required." + ) + continue + + source_keywords: dict[str, str] = {} + try: + if has_direct: + source_keywords[keyword] = _raw_decimal_card_value(header, keyword) + exact = Decimal(source_keywords[keyword]) + else: + source_keywords[integer_keyword] = _raw_decimal_card_value( + header, integer_keyword + ) + source_keywords[fraction_keyword] = _raw_decimal_card_value( + header, fraction_keyword + ) + exact = Decimal(source_keywords[integer_keyword]) + Decimal( + source_keywords[fraction_keyword] + ) + stingray_value = high_precision_keyword_read(header, keyword) + if ( + stingray_value is None + or not exact.is_finite() + or not np.isfinite(np.longdouble(stingray_value)) + ): + raise ValueError("non-finite split timing value") + except (InvalidOperation, TypeError, ValueError): + raw[keyword] = None + invalid_timing = True + warning_messages.append( + f"{hdu_label} timing keyword {keyword} is invalid or non-finite and " + "is represented as null." + ) + continue + + high_precision[keyword] = { + "decimal": format(exact, "f"), + "stingray_value": np.format_float_positional( + np.longdouble(stingray_value), unique=True, trim="-" + ), + "source_keywords": source_keywords, + } + if not has_direct: + raw[keyword] = _display_fits_value(np.longdouble(stingray_value)) + + if has_direct and has_integer and has_fraction: + try: + split = Decimal( + _raw_decimal_card_value(header, integer_keyword) + ) + Decimal(_raw_decimal_card_value(header, fraction_keyword)) + except (InvalidOperation, TypeError, ValueError): + split = exact + if split.is_finite() and split != exact: + warning_messages.append( + f"{hdu_label} declares conflicting {keyword} and " + f"{integer_keyword}/{fraction_keyword} values; the direct " + f"{keyword} value was used." + ) + + split_conflict = False + if all(keyword in header for keyword in ("MJDREF", "MJDREFI", "MJDREFF")): + try: + direct = Decimal(_raw_decimal_card_value(header, "MJDREF")) + split = Decimal(_raw_decimal_card_value(header, "MJDREFI")) + Decimal( + _raw_decimal_card_value(header, "MJDREFF") + ) + split_conflict = ( + direct.is_finite() and split.is_finite() and direct != split + ) + except (InvalidOperation, TypeError, ValueError): + pass + if split_conflict: + warning_messages.append( + f"{hdu_label} declares conflicting MJDREF and MJDREFI/MJDREFF values; " + "the direct MJDREF value was used." + ) + + try: + mjdref = _decimal_mjdref(header) + except (InvalidOperation, TypeError, ValueError): + mjdref = None + status = "invalid" + note = "MJDREF is invalid or non-finite and was not interpreted." + warning_messages.append(f"{hdu_label} MJDREF is invalid or non-finite.") + else: + status = "available" if mjdref is not None else "missing" + note = ( + "MJDREF is preserved as an exact decimal assembled from its original " + "FITS card or MJDREFI/MJDREFF components." + if mjdref is not None + else "No MJDREF or MJDREFI/MJDREFF time-reference keyword is present in this HDU." + ) + if split_conflict: + note += " Conflicting split reference cards are present." + if invalid_timing and status != "invalid": + status = "invalid" + note += " One or more timing keywords are invalid and were represented as null." + return { + "mjdref": mjdref, + "status": status, + "note": note, + "keywords": raw, + "high_precision_keywords": high_precision, + } + + +def _hdu_kind(hdu: fits.hdu.base.ExtensionHDU | fits.PrimaryHDU) -> str: + if isinstance(hdu, fits.BinTableHDU): + return "binary_table" + if isinstance(hdu, fits.TableHDU): + return "ascii_table" + if isinstance(hdu, fits.PrimaryHDU): + return "primary" + if isinstance(hdu, (fits.ImageHDU, fits.CompImageHDU)): + return "image" + return type(hdu).__name__ + + +def _inspect_fits(stream: BinaryIO) -> tuple[list[dict[str, Any]], list[str], str]: + """Inspect FITS headers without touching table/image data arrays.""" + warning_messages: list[str] = [ + "FITS DATASUM/CHECKSUM values were not verified during header-only inspection." + ] + with collect_warnings(warning_messages): + with ( + duplicate_binary_stream(stream) as fits_stream, + fits.open( + fits_stream, + mode="readonly", + memmap=True, + lazy_load_hdus=True, + # checksum=True can force DATASUM reads of large HDUs, defeating + # this inspector's deliberate header-only boundary. + checksum=False, + ) as hdul, + ): + if len(hdul) > MAX_INSPECT_HDUS: + raise ValueError( + f"FITS file has {len(hdul):,} HDUs; the inspection cap is {MAX_INSPECT_HDUS:,}" + ) + + summaries: list[dict[str, Any]] = [] + detected_type = "fits" + for index, hdu in enumerate(hdul): + header = hdu.header + kind = _hdu_kind(hdu) + columns: list[dict[str, Any]] = [] + if isinstance(hdu, (fits.BinTableHDU, fits.TableHDU)): + column_count = int(header.get("TFIELDS", 0) or 0) + if column_count > MAX_COLUMNS_PER_HDU: + raise ValueError( + f"HDU {index} has {column_count:,} columns; the inspection cap is " + f"{MAX_COLUMNS_PER_HDU:,}" + ) + columns = [ + { + "name": column.name, + "format": str(column.format), + "unit": str(column.unit) if column.unit else None, + } + for column in hdu.columns + ] + + axes = [ + int(header[f"NAXIS{axis}"]) + for axis in range(1, int(header.get("NAXIS", 0) or 0) + 1) + ] + row_count = ( + int(header.get("NAXIS2", 0) or 0) + if isinstance(hdu, (fits.BinTableHDU, fits.TableHDU)) + else None + ) + name = str(header.get("EXTNAME", hdu.name or "PRIMARY")) + column_names = {column["name"].upper() for column in columns} + if name.upper() == "EBOUNDS" and {"CHANNEL", "E_MIN", "E_MAX"}.issubset( + column_names + ): + detected_type = "rmf" + + summaries.append( + { + "index": index, + "name": name, + "type": kind, + "row_count": row_count, + "dimensions": axes, + "columns": columns, + "timing": _timing_metadata( + header, warning_messages, f"HDU {index} ({name})" + ), + } + ) + + mjdrefs = { + Decimal(summary["timing"]["mjdref"]["decimal"]) + for summary in summaries + if summary["timing"]["mjdref"] is not None + } + if len(mjdrefs) > 1: + warning_messages.append( + "Different HDUs declare different MJDREF values; the file's time " + "reference is ambiguous and must be interpreted per HDU." + ) + return summaries, warning_messages, detected_type + + +def _energy_unit_scales( + ebounds: fits.BinTableHDU, +) -> tuple[tuple[float, float] | None, list[str]]: + """Validate EBOUNDS units and return per-column conversions to keV.""" + warnings_out: list[str] = [] + units: list[str | None] = [] + for name in ("E_MIN", "E_MAX"): + unit = ebounds.columns[name].unit + units.append(str(unit).strip() if unit else None) + scales: list[float | None] = [] + for name, unit_text in zip(("E_MIN", "E_MAX"), units, strict=True): + if unit_text is None: + scales.append(None) + continue + try: + parsed_unit = u.Unit(unit_text) + except (TypeError, ValueError) as exc: + raise ValueError( + f"RMF {name} unit '{unit_text}' is not a recognized physical unit" + ) from exc + if not parsed_unit.is_equivalent(u.keV): + raise ValueError( + f"RMF {name} unit '{unit_text}' is not energy-equivalent and cannot " + "be calibrated to keV" + ) + scale = float(parsed_unit.to(u.keV)) + if not math.isfinite(scale) or scale <= 0: + raise ValueError(f"RMF {name} unit '{unit_text}' has an invalid keV scale") + scales.append(scale) + if scales[0] is None or scales[1] is None: + warnings_out.append( + "E_MIN/E_MAX units are missing; calibrated energy conversion is disabled." + ) + return None, warnings_out + scale_min, scale_max = scales + if not math.isclose(scale_min, scale_max, rel_tol=0.0, abs_tol=0.0): + warnings_out.append( + f"E_MIN and E_MAX use different energy units ({units[0]} and {units[1]}); " + "each bound was independently normalized to keV." + ) + return (scale_min, scale_max), warnings_out + + +def _load_valid_rmf( + stream: BinaryIO, *, require_energy_unit: bool +) -> tuple[np.ndarray, np.ndarray, np.ndarray, str | None, list[str]]: + """Load a bounded EBOUNDS table through public Stingray I/O and validate it.""" + validate_file_size(stream, MAX_RMF_BYTES, "RMF file") + with ( + duplicate_binary_stream(stream) as fits_stream, + fits.open( + fits_stream, + mode="readonly", + memmap=True, + lazy_load_hdus=True, + # Only inspect NAXIS2/columns before the allocation cap. The public + # read_rmf call below reopens with checksum=True after that cap passes. + checksum=False, + ) as hdul, + ): + matches = [hdu for hdu in hdul if hdu.name.upper() == "EBOUNDS"] + if len(matches) != 1: + raise ValueError( + "RMF must contain exactly one EBOUNDS binary-table extension" + ) + ebounds = matches[0] + if not isinstance(ebounds, fits.BinTableHDU): + raise ValueError("EBOUNDS must be a FITS binary table") + row_count = int(ebounds.header.get("NAXIS2", 0) or 0) + if row_count < 1: + raise ValueError("RMF EBOUNDS contains no channel rows") + if row_count > MAX_RMF_ROWS: + raise ValueError( + f"RMF EBOUNDS has {row_count:,} rows; the supported cap is {MAX_RMF_ROWS:,}" + ) + names = {name.upper() for name in (ebounds.columns.names or [])} + missing = sorted({"CHANNEL", "E_MIN", "E_MAX"} - names) + if missing: + raise ValueError( + f"RMF EBOUNDS is missing required column(s): {', '.join(missing)}" + ) + energy_scales, warnings_out = _energy_unit_scales(ebounds) + + # Public Stingray 2.2.10 API. It opens with memmap=False, which is why the + # NAXIS2 allocation cap is checked from the lazy header before this call. + with collect_warnings(warnings_out): + with duplicate_binary_stream(stream) as rmf_stream: + channels_raw, e_min_raw, e_max_raw = read_rmf(rmf_stream) + if any("verification failed" in message.lower() for message in warnings_out): + raise ValueError("RMF FITS checksum or DATASUM verification failed") + channels_array = np.asarray(channels_raw) + e_min = np.asarray(e_min_raw, dtype=float) + e_max = np.asarray(e_max_raw, dtype=float) + + if energy_scales is not None: + with np.errstate(over="ignore", invalid="ignore"): + e_min = e_min * energy_scales[0] + e_max = e_max * energy_scales[1] + + if any(array.ndim != 1 for array in (channels_array, e_min, e_max)): + raise ValueError("RMF EBOUNDS columns must be one-dimensional") + if not (len(channels_array) == len(e_min) == len(e_max) == row_count): + raise ValueError("RMF EBOUNDS columns have inconsistent lengths") + for array, label in ((e_min, "E_MIN"), (e_max, "E_MAX")): + bad = np.flatnonzero(~np.isfinite(array)) + if bad.size: + raise ValueError(f"RMF {label}[{int(bad[0])}] must be finite") + if channels_array.dtype.kind not in {"i", "u"}: + raise ValueError("RMF CHANNEL values must use an integer FITS column") + if channels_array.dtype.kind == "i" and np.any(channels_array < 0): + raise ValueError("RMF CHANNEL values must be non-negative integers") + too_large = np.flatnonzero(channels_array > MAX_EXACT_CHANNEL) + if too_large.size: + index = int(too_large[0]) + raise ValueError( + f"RMF CHANNEL[{index}] exceeds the exact JSON/JavaScript integer cap " + f"of {MAX_EXACT_CHANNEL:,}" + ) + channels = channels_array.astype(np.int64, copy=False) + unique, counts = np.unique(channels, return_counts=True) + duplicated = unique[counts > 1] + if duplicated.size: + raise ValueError(f"RMF CHANNEL contains duplicate value {int(duplicated[0])}") + negative_bounds = np.flatnonzero((e_min < 0) | (e_max <= 0)) + if negative_bounds.size: + raise ValueError( + f"RMF energy bounds row {int(negative_bounds[0])} must be non-negative " + "photon energies with E_MAX > 0" + ) + invalid_bounds = np.flatnonzero(e_min >= e_max) + if invalid_bounds.size: + index = int(invalid_bounds[0]) + raise ValueError(f"RMF energy bounds row {index} must satisfy E_MIN < E_MAX") + if require_energy_unit and energy_scales is None: + raise ValueError(warnings_out[0]) + energy_unit = "keV" if energy_scales is not None else None + return channels, e_min, e_max, energy_unit, warnings_out + + +def _validate_pi_values(values: Any, *, maximum: int) -> np.ndarray: + if isinstance(values, (bool, np.bool_)): + raise ValueError("PI values must be a one-dimensional array, not a boolean") + if isinstance(values, (str, bytes, bytearray, memoryview)): + raise ValueError("PI values must be a one-dimensional array, not text") + if isinstance(values, Mapping): + raise ValueError("PI values must be a one-dimensional array, not a mapping") + + if isinstance(values, np.ndarray): + if values.ndim != 1: + raise ValueError("PI values must be a one-dimensional array") + if values.size > maximum: + raise ValueError( + f"PI values contains {values.size:,} values; the cap is {maximum:,}" + ) + materialized: Any = values + elif isinstance(values, (list, tuple)): + if len(values) > maximum: + raise ValueError( + f"PI values contains {len(values):,} values; the cap is {maximum:,}" + ) + materialized = values + else: + shape = getattr(values, "shape", None) + if shape is not None: + try: + dimensions = tuple(shape) + except TypeError: + dimensions = () + if len(dimensions) != 1: + raise ValueError("PI values must be a one-dimensional array") + if ( + isinstance(dimensions[0], (int, np.integer)) + and int(dimensions[0]) > maximum + ): + raise ValueError( + f"PI values contains {int(dimensions[0]):,} values; the cap is " + f"{maximum:,}" + ) + if isinstance(values, Sized): + try: + hinted_length = len(values) + except (TypeError, ValueError, OverflowError): + hinted_length = None + if hinted_length is not None and hinted_length > maximum: + raise ValueError( + f"PI values contains {hinted_length:,} values; the cap is " + f"{maximum:,}" + ) + try: + materialized = list(islice(iter(values), maximum + 1)) + except (TypeError, ValueError) as exc: + raise ValueError( + f"PI values must contain only numeric values ({exc})" + ) from exc + if len(materialized) > maximum: + raise ValueError( + f"PI values contains at least {len(materialized):,} values; the cap is " + f"{maximum:,}" + ) + + if len(materialized) < 1: + raise ValueError("PI values must contain at least 1 value(s)") + if any( + isinstance(item, Iterable) + and not isinstance(item, (str, bytes, bytearray, memoryview)) + for item in materialized + ): + raise ValueError("PI values must be a one-dimensional array") + + exact = np.empty(len(materialized), dtype=np.int64) + for index, item in enumerate(materialized): + if isinstance(item, (bool, np.bool_)): + raise ValueError("PI values must contain integer channels, not booleans") + if isinstance(item, Integral): + integer = int(item) + elif isinstance(item, Real): + numeric = float(item) + if not math.isfinite(numeric): + raise ValueError(f"PI values[{index}] must be finite") + if not numeric.is_integer(): + raise ValueError(f"PI values[{index}] must be an integer channel") + integer = int(numeric) + else: + raise ValueError(f"PI values[{index}] must be an integer channel") + if integer < 0: + raise ValueError(f"PI values[{index}] must be non-negative") + if integer > MAX_EXACT_CHANNEL: + raise ValueError( + f"PI values[{index}] exceeds the exact JSON/JavaScript integer cap " + f"of {MAX_EXACT_CHANNEL:,}" + ) + exact[index] = integer + return exact + + +def _energy_midpoints(e_min: np.ndarray, e_max: np.ndarray) -> np.ndarray: + """Compute finite midpoints without overflowing ``e_min + e_max``.""" + midpoints = e_min / 2.0 + e_max / 2.0 + bad = np.flatnonzero(~np.isfinite(midpoints)) + if bad.size: + raise ValueError( + f"RMF energy midpoint at row {int(bad[0])} is not finite after keV conversion" + ) + return midpoints + + +def _calibrate_pi( + pi_values: Any, + rmf_stream: BinaryIO, + *, + maximum: int, +) -> tuple[np.ndarray, np.ndarray, str, list[str]]: + pis = _validate_pi_values(pi_values, maximum=maximum) + channels, e_min, e_max, unit, warnings_out = _load_valid_rmf( + rmf_stream, require_energy_unit=True + ) + unique_pis, inverse = np.unique(pis, return_inverse=True) + covered_unique = np.isin(unique_pis, channels) + covered = covered_unique[inverse] + if not np.all(covered): + missing = np.unique(pis[~covered]) + shown = ", ".join(str(int(value)) for value in missing[:10]) + suffix = " ..." if missing.size > 10 else "" + raise ValueError( + "Every PI value must have an exact RMF EBOUNDS channel match; missing " + f"channel(s): {shown}{suffix}" + ) + + work = len(channels) * len(unique_pis) + if work > MAX_RMF_PI_WORK: + raise ValueError( + "RMF conversion would require " + f"{work:,} channel comparisons ({len(channels):,} RMF channels x " + f"{len(unique_pis):,} unique PI values); the work cap is " + f"{MAX_RMF_PI_WORK:,}" + ) + + # Public Stingray call, after defending against its silent zero-for-miss + # behavior, malformed EBOUNDS inputs, and O(channels * PI) implementation. + # Supplying only unique PI values avoids repeating upstream comparisons for + # duplicate event channels. + with collect_warnings(warnings_out): + with duplicate_binary_stream(rmf_stream) as upstream_stream: + upstream_unique = np.asarray( + pi_to_energy(unique_pis, upstream_stream), dtype=float + ) + if upstream_unique.shape != unique_pis.shape: + raise ValueError("Stingray returned an unexpected calibrated-energy shape") + bad = np.flatnonzero(~np.isfinite(upstream_unique)) + if bad.size: + warnings_out.append( + "Stingray's raw-unit midpoint calculation produced a non-finite value; " + "the returned calibration uses overflow-safe, unit-normalized RMF bounds." + ) + + # Stingray 2.2.10 ignores TUNITn and returns the numeric midpoint in the + # source units. Use the already validated, per-bound keV values so mixed + # but energy-equivalent E_MIN/E_MAX units remain scientifically meaningful. + midpoints = _energy_midpoints(e_min, e_max) + channel_to_energy = { + int(channel): float(midpoint) + for channel, midpoint in zip(channels, midpoints, strict=True) + } + unique_energies = np.asarray( + [channel_to_energy[int(pi)] for pi in unique_pis], dtype=float + ) + energies = unique_energies[inverse] + assert unit is not None + return pis, energies, unit, warnings_out + + +def _estimated_value_bytes( + value: Any, limit: int, active: set[int] | None = None +) -> int: + """Estimate serialized size while counting every repeated reference. + + Shared values are expanded once per occurrence by JSON/ECSV/FITS metadata + serialization, so they must be charged once per occurrence here as well. + Only references on the active recursion path are special: those are cycles + and cannot be represented by the supported export formats. + """ + if active is None: + active = set() + if value is None or isinstance(value, (bool, float, np.number)): + return 32 + if type(value) is int: + bit_length = abs(value).bit_length() + decimal_bytes = 1 + (bit_length * 30_103) // 100_000 + if value < 0: + decimal_bytes += 1 + return max(32, int(value.__sizeof__()), decimal_bytes) + if isinstance(value, str): + return len(value.encode("utf-8")) + if isinstance(value, (bytes, bytearray)): + return len(value) + if isinstance(value, np.ndarray) and value.dtype.kind != "O": + return int(value.nbytes) + identity = id(value) + if identity in active: + raise ValueError( + "Analysis result metadata contains a cycle and cannot be serialized safely" + ) + active.add(identity) + try: + total = 0 + if isinstance(value, dict): + iterable = value.items() + for key, item in iterable: + total += _estimated_value_bytes(key, limit - total, active) + total += _estimated_value_bytes(item, limit - total, active) + if total > limit: + return total + return total + if isinstance(value, np.ndarray): + # Object arrays store pointers in ``nbytes``; their referenced + # values are what serializers actually expand. + total = int(value.nbytes) + iterable = value.flat + elif isinstance(value, (list, tuple)): + iterable = value + else: + return len(str(value).encode("utf-8")) + for item in iterable: + total += _estimated_value_bytes(item, limit - total, active) + if total > limit: + return total + return total + finally: + active.remove(identity) + + +def _analysis_non_column_fields(result: dict[str, Any]) -> set[str]: + """Return explicitly declared top-level metadata fields.""" + source_metadata = result.get("metadata") + if source_metadata is None: + return set() + if not isinstance(source_metadata, dict): + raise ValueError("Analysis result metadata must be a mapping for export") + declared = source_metadata.get("non_column_fields", []) + if declared is None: + return set() + if not isinstance(declared, (list, tuple)) or any( + not isinstance(name, str) or not name for name in declared + ): + raise ValueError( + "Analysis result metadata.non_column_fields must be a list of field names" + ) + fields = set(declared) + reserved = fields & ANALYSIS_RESERVED_FIELDS + if reserved: + raise ValueError( + "Analysis result metadata.non_column_fields cannot include reserved field " + f"'{sorted(reserved)[0]}'" + ) + missing = fields - result.keys() + if missing: + raise ValueError( + "Analysis result metadata.non_column_fields references unknown field " + f"'{sorted(missing)[0]}'" + ) + return fields + + +def _analysis_column_array(value: Any, key: str) -> np.ndarray: + """Return a supported one-dimensional array for one analysis column. + + Timing estimators legitimately produce nullable floating-point sequences. + NumPy otherwise turns those into object arrays, indistinguishable from + nested arbitrary Python values. Accept exactly ``None`` plus real scalar + numbers and normalize ``None`` to a mask. An unmasked NaN remains an + unmasked NaN, so formats capable of representing masks can preserve the + scientific distinction between missing and non-finite values. + """ + source_is_masked = isinstance(value, (MaskedColumn, Masked)) or np.ma.isMaskedArray( + value + ) + masked_value = np.ma.asarray(value) + array = np.asarray(masked_value.data) + if array.ndim != 1 or array.dtype.kind != "O": + if array.ndim == 1 and source_is_masked: + return np.ma.array( + array, + mask=np.ma.getmaskarray(masked_value), + copy=False, + ) + return array + + normalized: list[float] = [] + mask = np.ma.getmaskarray(masked_value).copy() + added_missing_mask = False + for index, item in enumerate(array): + if mask[index]: + # A masked payload is scientifically absent. Do not reject a + # valid nullable numeric column merely because its hidden storage + # uses an arbitrary object sentinel. + normalized.append(float("nan")) + continue + if item is None: + normalized.append(float("nan")) + mask[index] = True + added_missing_mask = True + continue + if isinstance(item, (bool, np.bool_)) or not isinstance(item, Real): + raise ValueError( + f"Analysis result column '{key}' contains nested or object values and " + "cannot be losslessly represented by the supported export formats" + ) + if isinstance(item, Integral) and abs(int(item)) > MAX_EXACT_CHANNEL: + raise ValueError( + f"Analysis result column '{key}' contains an integer that cannot be " + "represented exactly by the nullable numeric export encoding" + ) + normalized.append(float(item)) + normalized_array = np.asarray(normalized, dtype=float) + if source_is_masked or added_missing_mask: + return np.ma.array(normalized_array, mask=mask, copy=False) + return normalized_array + + +def _normalized_analysis_column( + original: Any, + normalized: np.ndarray, + name: str, +) -> Column: + """Build a normalized column without discarding masks or display metadata.""" + values = np.ma.asarray(normalized) + data = np.asarray(values.data) + masked = np.ma.isMaskedArray(normalized) + source_is_masked = isinstance( + original, (MaskedColumn, Masked) + ) or np.ma.isMaskedArray(original) + common = { + "name": name, + "unit": getattr(original, "unit", None), + "format": getattr(original, "format", None), + "description": getattr(original, "description", None), + "meta": copy.deepcopy(getattr(original, "meta", {})), + "copy": False, + } + if not masked: + return Column(data, **common) + + fill_value = np.ma.default_fill_value(data) + fill_reason: str | None = None + if source_is_masked and hasattr(original, "fill_value"): + try: + fill_value = ( + np.asarray(getattr(original, "fill_value"), dtype=data.dtype) + .reshape(()) + .item() + ) + except (TypeError, ValueError, OverflowError): + fill_reason = ( + f"HDF5 export cannot preserve column '{name}' custom fill value " + "after nullable numeric normalization" + ) + replacement = MaskedColumn( + data, + mask=np.ma.getmaskarray(values), + fill_value=fill_value, + **common, + ) + if fill_reason is not None: + setattr(replacement, ANALYSIS_HDF5_FILL_REASON_ATTRIBUTE, fill_reason) + return replacement + + +def _validate_analysis_dtype(array: np.ndarray, key: str) -> None: + """Reject dtypes that any advertised export format cannot encode.""" + if array.dtype.kind == "c": + raise ValueError( + f"Analysis result column '{key}' is complex; export separate real and " + "imaginary columns for a lossless representation" + ) + if array.dtype.kind in {"M", "m", "S", "V"}: + raise ValueError( + f"Analysis result column '{key}' uses {array.dtype}, which is not " + "losslessly supported by every enabled export format" + ) + if array.dtype.kind == "U" and any(not str(item).isascii() for item in array.flat): + raise ValueError( + f"Analysis result column '{key}' contains non-ASCII Unicode text, which " + "is not losslessly supported by every enabled export format" + ) + if array.dtype.kind == "O": + raise ValueError( + f"Analysis result column '{key}' contains nested or object values and " + "cannot be losslessly represented by the supported export formats" + ) + + +def _validate_analysis_allocation( + row_count: int, column_count: int, estimated_bytes: int +) -> None: + if column_count > MAX_EXPORT_COLUMNS: + raise ValueError( + f"Analysis result has {column_count:,} columns; the export column cap is " + f"{MAX_EXPORT_COLUMNS:,}" + ) + cells = row_count * column_count + if cells > MAX_EXPORT_CELLS: + raise ValueError( + f"Analysis result has {cells:,} cells; the export cell cap is " + f"{MAX_EXPORT_CELLS:,}" + ) + if estimated_bytes > MAX_EXPORT_ESTIMATED_BYTES: + raise ValueError( + f"Analysis result is estimated at {estimated_bytes / 1024**2:.1f} MiB; " + "the export estimated-size cap is " + f"{MAX_EXPORT_ESTIMATED_BYTES / 1024**2:.1f} MiB" + ) + + +def _analysis_result_row_count(result: Any) -> int: + """Validate tabular shape and return rows before copying/building a Table.""" + if isinstance(result, Table): + row_count = len(result) + if row_count <= MAX_EXPORT_ROWS: + _validate_analysis_allocation(row_count, len(result.colnames), 0) + estimated_bytes = sum( + int(np.asarray(result[name]).nbytes) for name in result.colnames + ) + _estimated_value_bytes(dict(result.meta), MAX_EXPORT_ESTIMATED_BYTES) + _validate_analysis_allocation( + row_count, len(result.colnames), estimated_bytes + ) + for name in result.colnames: + array = _analysis_column_array(result[name], name) + if array.ndim != 1: + raise ValueError( + f"Analysis result column '{name}' has {array.ndim} dimensions " + "and cannot be losslessly represented as one tabular column" + ) + _validate_analysis_dtype(array, name) + return row_count + if not isinstance(result, dict): + raise ValueError("Analysis result is not a tabular mapping") + for key in result: + if not isinstance(key, str): + raise ValueError( + "Analysis result field names must be text; found a field name " + f"of type {type(key).__name__}" + ) + + non_column_fields = _analysis_non_column_fields(result) + reserved = ANALYSIS_RESERVED_FIELDS | non_column_fields + expected_length: int | None = None + column_names: set[str] = set() + estimated_bytes = _estimated_value_bytes( + {key: result[key] for key in reserved if key in result}, + MAX_EXPORT_ESTIMATED_BYTES, + ) + for key, value in result.items(): + if key in reserved: + continue + if isinstance(value, dict): + raise ValueError( + f"Analysis result field '{key}' is nested or non-tabular and cannot be " + "losslessly represented by the supported export formats" + ) + try: + raw_length = None if isinstance(value, (str, bytes)) else len(value) + except TypeError: + raw_length = None + if raw_length is not None and raw_length > MAX_EXPORT_ROWS: + return raw_length + + value_bytes = _estimated_value_bytes( + value, MAX_EXPORT_ESTIMATED_BYTES - estimated_bytes + ) + if estimated_bytes + value_bytes > MAX_EXPORT_ESTIMATED_BYTES: + _validate_analysis_allocation( + raw_length or expected_length or 0, + len(column_names) + 1, + estimated_bytes + value_bytes, + ) + + array = _analysis_column_array(value, str(key)) + if array.ndim == 0: + if isinstance(value, u.Quantity) and value.isscalar: + estimated_bytes += value_bytes + continue + scalar = array.item() + if isinstance(scalar, (complex, np.complexfloating)): + raise ValueError( + f"Analysis result scalar '{key}' is complex; export explicit real " + "and imaginary values instead" + ) + if scalar is None or isinstance(scalar, (str, bool, int, float)): + estimated_bytes += _estimated_value_bytes( + scalar, MAX_EXPORT_ESTIMATED_BYTES - estimated_bytes + ) + continue + raise ValueError( + f"Analysis result field '{key}' is nested or non-tabular and cannot be " + "losslessly represented by the supported export formats" + ) + if array.ndim != 1: + raise ValueError( + f"Analysis result field '{key}' has {array.ndim} dimensions and cannot " + "be losslessly represented as one tabular column" + ) + _validate_analysis_dtype(array, str(key)) + if expected_length is None: + expected_length = len(array) + elif len(array) != expected_length: + raise ValueError( + "Analysis result contains one-dimensional columns with different lengths" + ) + column_names.add(str(key)) + estimated_bytes += max(value_bytes, int(array.nbytes)) + _validate_analysis_allocation( + expected_length, len(column_names), estimated_bytes + ) + + if expected_length is None: + raise ValueError( + "Analysis result does not contain exportable one-dimensional columns" + ) + source_metadata = result.get("metadata") + if source_metadata is not None: + assert isinstance(source_metadata, dict) + units = source_metadata.get("units", {}) + if units is not None: + if not isinstance(units, dict): + raise ValueError("Analysis result metadata.units must be a mapping") + for column_name, unit_label in units.items(): + if column_name not in column_names: + raise ValueError( + f"Analysis result metadata.units references unknown column " + f"'{column_name}'" + ) + try: + u.Unit(unit_label) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Analysis result column '{column_name}' has invalid unit " + f"'{unit_label}'" + ) from exc + provenance = result.get("provenance") + if provenance is not None and not isinstance(provenance, dict): + raise ValueError("Analysis result provenance must be a mapping for export") + _validate_analysis_allocation(expected_length, len(column_names), estimated_bytes) + return expected_length + + +def _analysis_table(result: Any) -> Table: + row_count = _analysis_result_row_count(result) + if row_count > MAX_EXPORT_ROWS: + raise ValueError( + f"Analysis result has {row_count:,} rows; the export cap is " + f"{MAX_EXPORT_ROWS:,}" + ) + if isinstance(result, Table): + table = result.copy(copy_data=True) + hdf5_fill_reasons: dict[str, str] = {} + for name in table.colnames: + original = table[name] + array = _analysis_column_array(original, name) + if np.asarray(original).dtype.kind == "O" and array.dtype.kind != "O": + replacement = _normalized_analysis_column(original, array, name) + fill_reason = getattr( + replacement, ANALYSIS_HDF5_FILL_REASON_ATTRIBUTE, None + ) + if fill_reason is not None: + hdf5_fill_reasons[name] = str(fill_reason) + table.replace_column( + name, + replacement, + ) + _reject_complex_columns(table) + if hdf5_fill_reasons: + setattr( + table, + ANALYSIS_HDF5_FILL_REASON_ATTRIBUTE, + hdf5_fill_reasons, + ) + return table + if not isinstance(result, dict): + raise ValueError("Analysis result is not a tabular mapping") + + non_column_fields = _analysis_non_column_fields(result) + reserved = ANALYSIS_RESERVED_FIELDS | non_column_fields + columns: dict[str, Any] = {} + hdf5_fill_reasons: dict[str, str] = {} + metadata: dict[str, Any] = {} + expected_length: int | None = None + for key, value in result.items(): + if key in reserved: + continue + array = _analysis_column_array(value, str(key)) + if array.ndim == 0: + if isinstance(value, u.Quantity) and value.isscalar: + metadata[str(key)] = value.copy() + continue + scalar = array.item() + if isinstance(scalar, (complex, np.complexfloating)): + raise ValueError( + f"Analysis result scalar '{key}' is complex; export explicit real " + "and imaginary values instead" + ) + if scalar is None or isinstance(scalar, (str, bool, int, float)): + metadata[str(key)] = scalar + continue + raise ValueError( + f"Analysis result field '{key}' is nested or non-tabular and cannot be " + "losslessly represented by the supported export formats" + ) + if array.ndim != 1: + raise ValueError( + f"Analysis result field '{key}' has {array.ndim} dimensions and cannot " + "be losslessly represented as one tabular column" + ) + _validate_analysis_dtype(array, str(key)) + if expected_length is None: + expected_length = len(array) + elif len(array) != expected_length: + raise ValueError( + "Analysis result contains one-dimensional columns with different lengths" + ) + normalized_column = _normalized_analysis_column(value, array, str(key)) + fill_reason = getattr( + normalized_column, ANALYSIS_HDF5_FILL_REASON_ATTRIBUTE, None + ) + if fill_reason is not None: + hdf5_fill_reasons[str(key)] = str(fill_reason) + columns[str(key)] = normalized_column + + if expected_length is None or not columns: + raise ValueError( + "Analysis result does not contain exportable one-dimensional columns" + ) + table = Table(columns) + if hdf5_fill_reasons: + setattr( + table, + ANALYSIS_HDF5_FILL_REASON_ATTRIBUTE, + hdf5_fill_reasons, + ) + table.meta.update(metadata) + for key in ANALYSIS_RESERVED_FIELDS: + if key in result: + table.meta[key] = result[key] + for key in non_column_fields: + table.meta[key] = result[key] + + source_metadata = result.get("metadata") + if source_metadata is not None: + if not isinstance(source_metadata, dict): + raise ValueError("Analysis result metadata must be a mapping for export") + units = source_metadata.get("units", {}) + if units is not None: + if not isinstance(units, dict): + raise ValueError("Analysis result metadata.units must be a mapping") + for column_name, unit_label in units.items(): + if column_name not in table.colnames: + raise ValueError( + f"Analysis result metadata.units references unknown column " + f"'{column_name}'" + ) + try: + declared_unit = u.Unit(unit_label) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Analysis result column '{column_name}' has invalid unit " + f"'{unit_label}'" + ) from exc + existing_unit = table[column_name].unit + if existing_unit is not None: + if not existing_unit.is_equivalent(declared_unit): + raise ValueError( + f"Analysis result column '{column_name}' unit " + f"'{existing_unit}' conflicts with metadata unit " + f"'{declared_unit}'" + ) + table[column_name].convert_unit_to(declared_unit) + else: + table[column_name].unit = declared_unit + return table + + +def _reject_complex_columns(table: Table) -> None: + """Fail closed on columns unsupported by every enabled export format.""" + for name in table.colnames: + values = np.asarray(table[name]) + if values.ndim != 1: + raise ValueError( + f"Analysis result column '{name}' has {values.ndim} dimensions and " + "cannot be losslessly represented as one tabular column" + ) + _validate_analysis_dtype(values, name) + + +def _gti_array_for_export( + obj: Any, *, preserve_dtype: bool = False +) -> np.ndarray | None: + """Return only explicitly stored GTIs, including a zero-row GTI. + + Stingray's public ``gti`` property synthesizes and caches ``[time[0], + time[-1]]`` when ``_gti`` is ``None``. Export must not turn that implicit + convenience interval into source data, so inspect the backing value first. + """ + try: + gti = obj._gti + except AttributeError: + gti = getattr(obj, "gti", None) + if gti is None: + return None + return _normalize_gti_array(gti, preserve_dtype=preserve_dtype) + + +def _normalize_gti_array(gti: Any, *, preserve_dtype: bool = False) -> np.ndarray: + """Validate and copy one explicit seconds-based GTI value.""" + array = np.asarray(gti) + if not preserve_dtype: + array = np.asarray(array, dtype=float) + elif array.dtype.kind not in {"f", "i", "u"}: + try: + array = np.asarray(gti, dtype=np.longdouble) + except (TypeError, ValueError, OverflowError) as exception: + raise ValueError( + "Loaded object GTIs must be numeric for export" + ) from exception + if array.size == 0: + return np.empty( + (0, 2), + dtype=array.dtype if preserve_dtype else float, + ) + if array.ndim != 2 or array.shape[1] != 2: + raise ValueError("Loaded object GTIs must have shape (n, 2) for export") + bad = np.argwhere(~np.isfinite(array)) + if bad.size: + row, column = (int(value) for value in bad[0]) + raise ValueError(f"Loaded object GTI[{row}, {column}] must be finite") + return np.array(array, copy=True) + + +def _apply_known_units( + table: Table, + object_type: str, + obj: Any, + explicit_gti: np.ndarray | None, +) -> None: + """Restore units that Stingray's ``to_astropy_table`` currently omits.""" + known_units: dict[str, u.UnitBase] = {} + if object_type in {"event_list", "lightcurve"}: + known_units["time"] = u.s + if object_type == "event_list": + known_units["energy"] = u.keV + elif object_type == "lightcurve": + known_units.update( + { + "counts": u.ct, + "counts_err": u.ct, + "countrate": u.ct / u.s, + "countrate_err": u.ct / u.s, + "bin_lo": u.s, + "bin_hi": u.s, + "dt": u.s, + "bg_counts": u.ct, + "bg_ratio": u.dimensionless_unscaled, + "frac_exp": u.dimensionless_unscaled, + } + ) + + for name in table.colnames: + unit = known_units.get(name.lower()) + if unit is not None and getattr(table[name], "unit", None) is None: + table[name].unit = unit + + if object_type in {"event_list", "lightcurve"}: + table.meta.setdefault("time_unit", "s") + dt = getattr(obj, "dt", None) + if dt is not None: + table.meta.setdefault("dt_unit", "s") + if explicit_gti is not None: + table.meta["gti"] = explicit_gti + table.meta["gti_time_unit"] = "s" + table.meta["gti_status"] = "present" + else: + table.meta.pop("gti", None) + table.meta.pop("gti_time_unit", None) + table.meta["gti_status"] = "missing" + if object_type == "event_list" and "energy" in { + name.lower() for name in table.colnames + }: + table.meta.setdefault("energy_unit", "keV") + if object_type == "lightcurve": + table.meta.setdefault("count_unit", "ct") + rmf_provenance = getattr(obj, "rmf_conversion_provenance", None) + if rmf_provenance is not None: + if not isinstance(rmf_provenance, dict): + raise ValueError("RMF conversion provenance must be a mapping for export") + table.meta["rmf_conversion_provenance"] = rmf_provenance + + +def _object_row_count(obj: Any, object_type: str) -> int: + if object_type == "event_list": + values = getattr(obj, "time", None) + elif object_type == "lightcurve": + values = getattr(obj, "time", None) + else: + row_count = _analysis_result_row_count(obj) + if row_count > MAX_EXPORT_ROWS: + return row_count + _enforce_precopy_caps( + obj, + "Analysis result", + max_rows=MAX_EXPORT_ROWS, + max_columns=MAX_EXPORT_COLUMNS, + max_cells=MAX_EXPORT_CELLS, + max_bytes=MAX_EXPORT_ESTIMATED_BYTES, + ) + return row_count + if values is None: + raise ValueError(f"Loaded {object_type.replace('_', ' ')} has no time array") + _enforce_precopy_caps( + obj, + f"Loaded {object_type.replace('_', ' ')}", + max_rows=MAX_EXPORT_ROWS, + max_columns=MAX_EXPORT_COLUMNS, + max_cells=MAX_EXPORT_CELLS, + max_bytes=MAX_EXPORT_ESTIMATED_BYTES, + ) + array = np.asarray(values) + if array.ndim != 1: + raise ValueError( + f"Loaded {object_type.replace('_', ' ')} time must be one-dimensional" + ) + row_count = len(array) + return row_count + + +def _table_for_object( + obj: Any, + object_type: str, + *, + preserve_timing_precision: bool = False, +) -> Table: + if object_type == "analysis_result": + table = _analysis_table(obj) + explicit_gti = None + else: + # Capture the backing GTI before to_astropy_table accesses the public + # property and can synthesize/cache an implicit interval. + explicit_gti = _gti_array_for_export( + obj, preserve_dtype=preserve_timing_precision + ) + table = obj.to_astropy_table() + # Stingray declares its scientific scalar fields through meta_attrs(), + # but Lightcurve.to_astropy_table() currently omits several of them. + # Add every declared value that the public serializer left out so the + # HDF5 semantic comparison sees the complete source projection. + if preserve_timing_precision: + for attribute in obj.meta_attrs(): + if attribute == "gti" or attribute in table.meta: + continue + table.meta[attribute] = copy.deepcopy(getattr(obj, attribute)) + if object_type == "lightcurve": + for attribute in obj.array_attrs(): + values = getattr(obj, attribute, None) + if values is None or attribute in table.colnames: + continue + array = ( + np.ma.asarray(values) + if isinstance(values, Masked) or np.ma.isMaskedArray(values) + else np.asarray(values) + ) + if array.ndim != 1 or len(array) != len(table): + raise ValueError( + f"Loaded Lightcurve {attribute} must be a one-dimensional " + "array aligned with time" + ) + table[attribute] = _normalized_analysis_column(values, array, attribute) + dt = np.asarray(getattr(obj, "dt", None)) + if dt.ndim == 1: + if len(dt) != len(table): + raise ValueError( + "Loaded Lightcurve per-bin dt must be aligned with time" + ) + table["dt"] = np.array(dt, copy=True) + if preserve_timing_precision: + # Preserve additional application provenance and other non-null + # public scalar fields that Stingray does not declare through + # meta_attrs(). Aligned scientific arrays are represented as + # columns above or by Stingray's own table projection. + for attribute, value in vars(obj).items(): + if ( + attribute.startswith("_") + or attribute in table.colnames + or attribute in table.meta + or value is None + ): + continue + try: + aligned = np.ndim(value) >= 1 and len(value) == len(table) + except TypeError: + aligned = False + if aligned: + raise ValueError( + f"Loaded {object_type.replace('_', ' ')} field '{attribute}' " + "was not represented as a scientific column" + ) + table.meta[attribute] = copy.deepcopy(value) + if not table.colnames: + raise ValueError("Loaded object has no exportable columns") + _reject_complex_columns(table) + _apply_known_units(table, object_type, obj, explicit_gti) + return table + + +def _fits_hdul_for_table( + table: Table, object_type: str, obj: Any, warnings_out: list[str] +) -> fits.HDUList: + """Build a generic, explicitly non-OGIP FITS table export.""" + primary = fits.PrimaryHDU() + primary.header["CREATOR"] = "Stingray Explorer" + extension_name = "EVENTS" if object_type == "event_list" else "DATA" + # FITS headers cannot represent ndarray/dict metadata such as GTIs and + # provenance. Build the data HDU from columns only, then preserve GTIs in + # their own extension below. This also prevents Astropy from silently + # dropping metadata while emitting process-global warnings. + fits_table = table.copy(copy_data=True) + fits_table.meta.clear() + data_hdu = fits.table_to_hdu(fits_table) + data_hdu.name = extension_name + data_hdu.header["HDUCLASS"] = "STINGRAY" + data_hdu.header["HDUCLAS1"] = "GENERIC" + mjdref = getattr(obj, "mjdref", None) + if mjdref is not None and np.isfinite(float(mjdref)): + mjd_decimal = Decimal(str(mjdref)) + mjd_integer = int(mjd_decimal) + # The single keyword keeps Stingray's explicit ``fmt='fits'`` generic + # reader useful; the split cards retain the source components for + # FITS-aware consumers. + data_hdu.header["MJDREF"] = float(mjd_decimal) + data_hdu.header["MJDREFI"] = mjd_integer + data_hdu.header["MJDREFF"] = float(mjd_decimal - Decimal(mjd_integer)) + dt = getattr(obj, "dt", None) + if dt is not None and np.asarray(dt).ndim == 0 and np.isfinite(float(dt)): + data_hdu.header["TIMEDEL"] = float(dt) + if any(name.lower() == "time" for name in table.colnames): + data_hdu.header["TIMEUNIT"] = "s" + data_hdu.header.add_history( + "Generic Stingray/Astropy table export; this file is not an OGIP event product." + ) + + hdus: list[fits.hdu.base.ExtensionHDU | fits.PrimaryHDU] = [primary, data_hdu] + if object_type in {"event_list", "lightcurve"}: + if table.meta.get("gti_status") == "present": + gti_array = _normalize_gti_array(table.meta["gti"]) + gti_table = Table( + { + "START": u.Quantity(gti_array[:, 0], u.s), + "STOP": u.Quantity(gti_array[:, 1], u.s), + } + ) + gti_hdu = fits.table_to_hdu(gti_table) + gti_hdu.name = "GTI" + gti_hdu.header["HDUCLASS"] = "STINGRAY" + gti_hdu.header["HDUCLAS1"] = "GTI" + gti_hdu.header["TIMEUNIT"] = "s" + for keyword in ("MJDREF", "MJDREFI", "MJDREFF"): + if keyword in data_hdu.header: + gti_hdu.header[keyword] = data_hdu.header[keyword] + hdus.append(gti_hdu) + if table.meta: + safe_metadata = json_safe(dict(table.meta), warnings_out, "metadata") + encoded_metadata = json.dumps( + safe_metadata, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + ).encode("ascii") + metadata_hdu = fits.BinTableHDU.from_columns( + [ + fits.Column( + name="JSON", + format=f"{max(1, len(encoded_metadata))}A", + array=np.asarray([encoded_metadata]), + ) + ], + name="METADATA", + ) + metadata_hdu.header["HDUCLASS"] = "STINGRAY" + metadata_hdu.header["HDUCLAS1"] = "METADATA" + metadata_hdu.header["SCHEMA"] = "stingray-explorer.metadata.v1" + hdus.append(metadata_hdu) + return fits.HDUList(hdus) + + +def _json_export_payload( + table: Table, + *, + object_type: str, + object_name: str, + warnings_out: list[str], +) -> dict[str, Any]: + columns: dict[str, Any] = {} + for name in table.colnames: + masked_values = np.ma.asarray(table[name]) + values = np.asarray(masked_values.data) + masked = np.ma.getmaskarray(masked_values) + masked_count = int(np.count_nonzero(masked)) + if masked_count: + warnings_out.append( + f"columns.{name} contains {masked_count:,} masked value(s); they are " + "represented as null." + ) + if values.dtype.kind == "f": + finite = np.isfinite(values) + # A masked nullable value may use NaN only as its hidden storage. + # Report non-finite values only when they are scientifically + # present, not when the mask already declares them missing. + nonfinite_count = int(np.count_nonzero(~finite & ~masked)) + if nonfinite_count: + warnings_out.append( + f"columns.{name} contains {nonfinite_count:,} non-finite value(s); " + "they are represented as null." + ) + if nonfinite_count or masked_count: + columns[name] = [ + float(value) if is_finite and not is_masked else None + for value, is_finite, is_masked in zip( + values, finite, masked, strict=True + ) + ] + continue + safe_values = json_safe(values, warnings_out, f"columns.{name}") + if masked_count: + safe_values = [ + None if is_masked else value + for value, is_masked in zip(safe_values, masked, strict=True) + ] + columns[name] = safe_values + return { + "schema": "stingray-explorer.tabular.v1", + "object_type": object_type, + "object_name": object_name, + "row_count": len(table), + "columns": columns, + "column_units": { + name: ( + str(table[name].unit) + if getattr(table[name], "unit", None) is not None + else None + ) + for name in table.colnames + }, + "metadata": json_safe(dict(table.meta), warnings_out, "metadata"), + } + + +def _optional_hdf5_runtime() -> tuple[Any | None, str | None]: + """Load h5py lazily so the rest of General I/O remains independently usable.""" + try: + module = importlib.import_module("h5py") + except Exception: + return None, HDF5_UNAVAILABLE_REASON + if not callable(getattr(module, "File", None)): + return None, HDF5_UNAVAILABLE_REASON + return module, None + + +def _hdf5_capability() -> dict[str, Any]: + module, reason = _optional_hdf5_runtime() + available = module is not None + return { + "supported": available, + "notes": ( + "Versioned Stingray Explorer HDF5 table with a complete semantic " + "reopen comparison before publication." + if available + else "Optional HDF5 runtime dependency is unavailable." + ), + "reason": reason, + "extensions": [HDF5_EXTENSION], + "dependency": { + "name": "h5py", + "available": available, + "version": str(getattr(module, "__version__", "unknown")) + if available + else None, + }, + } + + +def _hdf5_object_name_reason(object_name: str) -> str | None: + if "\0" in object_name: + return "HDF5 export does not support NUL characters in object names" + try: + object_name.encode("utf-8") + except UnicodeEncodeError: + return "HDF5 export requires object names that are valid UTF-8 text" + return None + + +def _hdf5_dtype_reason(dtype: np.dtype[Any], column_name: str) -> str | None: + kind = dtype.kind + if kind not in HDF5_SUPPORTED_COLUMN_KINDS: + return ( + f"HDF5 export does not support column '{column_name}' with logical " + f"dtype {dtype}; supported logical kinds are boolean, integer, " + "floating point, and ASCII Unicode text" + ) + if kind == "U": + if dtype.itemsize <= 0 or dtype.itemsize % np.dtype("U1").itemsize != 0: + return ( + f"HDF5 export cannot represent column '{column_name}' with " + f"logical dtype {dtype}" + ) + return None + if dtype.itemsize not in HDF5_SUPPORTED_ITEM_SIZES[kind]: + return ( + f"HDF5 export does not support column '{column_name}' with logical " + f"dtype {dtype}; its {dtype.itemsize}-byte width is unsupported" + ) + return None + + +def _hdf5_metadata_reason( + value: Any, + location: str, + active: set[int] | None = None, +) -> str | None: + """Return why metadata is outside the explicitly verified HDF5 subset.""" + if active is None: + active = set() + if value is None or type(value) in {str, bool, int, float}: + return None + if isinstance(value, Masked) or np.ma.isMaskedArray(value): + return f"HDF5 export does not support masked metadata at '{location}'" + if isinstance(value, np.generic): + return _hdf5_dtype_reason(np.asarray(value).dtype, location) + if isinstance(value, u.UnitBase): + return _hdf5_unit_reason(value, location) + if isinstance(value, u.Quantity): + if type(value) is not u.Quantity: + return ( + f"HDF5 export does not support Quantity subclass " + f"'{type(value).__name__}' at '{location}'" + ) + quantity_dtype = np.asarray(value.value).dtype + reason = _hdf5_dtype_reason(quantity_dtype, location) + if reason is not None: + return reason + if quantity_dtype.kind in {"b", "i", "u"}: + return ( + f"HDF5 export does not support integer or boolean Quantity " + f"metadata at '{location}' because Astropy reopens it as " + "floating point" + ) + return _hdf5_unit_reason(value.unit, location) + if type(value) is np.ndarray: + return _hdf5_dtype_reason(value.dtype, location) + if isinstance(value, np.ndarray): + return ( + f"HDF5 export does not support ndarray subclass " + f"'{type(value).__name__}' at '{location}'" + ) + if isinstance(value, (bytes, bytearray, memoryview)): + return f"HDF5 export does not support binary metadata at '{location}'" + + identity = id(value) + if identity in active: + return f"HDF5 export metadata at '{location}' contains a cycle" + active.add(identity) + try: + if type(value) is dict: + for key, item in value.items(): + if not isinstance(key, str): + return f"HDF5 export metadata at '{location}' has a non-text key" + reason = _hdf5_metadata_reason(item, f"{location}.{key}", active) + if reason is not None: + return reason + return None + if isinstance(value, Mapping): + return ( + f"HDF5 export does not support metadata mapping subclass " + f"'{type(value).__name__}' at '{location}'" + ) + if type(value) in {list, tuple}: + for index, item in enumerate(value): + reason = _hdf5_metadata_reason(item, f"{location}[{index}]", active) + if reason is not None: + return reason + return None + if isinstance(value, (list, tuple)): + return ( + f"HDF5 export does not support metadata sequence subclass " + f"'{type(value).__name__}' at '{location}'" + ) + finally: + active.remove(identity) + return ( + f"HDF5 export does not support metadata value '{location}' of type " + f"{type(value).__name__}" + ) + + +def _hdf5_unit_reason(unit: u.UnitBase, location: str) -> str | None: + """Reject units the standalone reader cannot reconstruct exactly.""" + try: + encoded = unit.to_string() + decoded = u.Unit(encoded, parse_strict="raise") + except Exception: + return ( + f"HDF5 export cannot reopen the unit '{unit}' at '{location}' " + "without an external custom-unit definition" + ) + if decoded != unit: + return f"HDF5 export cannot round-trip the unit at '{location}' exactly" + return None + + +def _canonicalize_hdf5_metadata(value: Any) -> Any: + """Normalize supported scalar types to Astropy's stable YAML representation.""" + if isinstance(value, np.generic): + kind = np.asarray(value).dtype.kind + if kind == "b": + return bool(value) + if kind in {"i", "u"}: + return int(value) + if kind == "f": + return float(value) + if kind == "U": + return str(value) + if isinstance(value, u.Quantity): + if value.isscalar: + scalar = _canonicalize_hdf5_metadata(np.asarray(value.value)[()]) + return u.Quantity(scalar, value.unit, copy=True) + return value.copy() + if type(value) is np.ndarray: + return value.copy() + if type(value) is dict: + return {key: _canonicalize_hdf5_metadata(item) for key, item in value.items()} + if type(value) is list: + return [_canonicalize_hdf5_metadata(item) for item in value] + if type(value) is tuple: + return tuple(_canonicalize_hdf5_metadata(item) for item in value) + return value + + +def _is_masked_column(column: Any) -> bool: + return isinstance(column, MaskedColumn) or np.ma.isMaskedArray(column) + + +def _encode_hdf5_fill_value(value: Any, dtype: np.dtype[Any]) -> dict[str, Any]: + """Encode one dtype-coerced scalar without losing non-finite float values.""" + scalar = np.asarray(value, dtype=dtype).reshape(()).item() + if dtype.kind == "b": + return {"kind": "bool", "value": bool(scalar)} + if dtype.kind in {"i", "u"}: + return {"kind": "integer", "value": str(int(scalar))} + if dtype.kind == "f": + converted = float(scalar) + if math.isnan(converted): + encoded = "nan" + elif math.isinf(converted): + encoded = "+inf" if converted > 0 else "-inf" + else: + encoded = converted.hex() + return {"kind": "float", "value": encoded} + if dtype.kind == "U": + return {"kind": "unicode", "value": str(scalar)} + raise ValueError(f"Cannot encode an HDF5 fill value for logical dtype {dtype}") + + +def _logical_dtype_from_manifest(entry: Mapping[str, Any]) -> np.dtype[Any]: + kind = entry.get("dtype_kind") + itemsize = entry.get("dtype_itemsize") + if ( + not isinstance(kind, str) + or not isinstance(itemsize, int) + or isinstance(itemsize, bool) + ): + raise ValueError("HDF5 column manifest contains an invalid logical dtype") + if kind == "b" and itemsize == 1: + return np.dtype("?") + if kind in {"i", "u", "f"} and itemsize in HDF5_SUPPORTED_ITEM_SIZES[kind]: + return np.dtype(f"{kind}{itemsize}").newbyteorder("=") + if kind == "U" and itemsize > 0 and itemsize % np.dtype("U1").itemsize == 0: + return np.dtype(f"U{itemsize // np.dtype('U1').itemsize}") + raise ValueError("HDF5 column manifest declares an unsupported logical dtype") + + +def _decode_hdf5_fill_value( + encoded: Any, dtype: np.dtype[Any] +) -> np.generic | str | bool: + if not isinstance(encoded, Mapping): + raise ValueError("HDF5 column manifest contains an invalid fill value") + kind = encoded.get("kind") + value = encoded.get("value") + if kind == "bool" and isinstance(value, bool): + decoded: Any = value + elif kind == "integer" and isinstance(value, str): + decoded = int(value) + elif kind == "float" and isinstance(value, str): + if value == "nan": + decoded = float("nan") + elif value == "+inf": + decoded = float("inf") + elif value == "-inf": + decoded = float("-inf") + else: + decoded = float.fromhex(value) + elif kind == "unicode" and isinstance(value, str): + decoded = value + else: + raise ValueError("HDF5 column manifest contains an invalid fill value") + return np.asarray(decoded, dtype=dtype).reshape(()).item() + + +def _replace_table_column( + table: Table, + name: str, + data: np.ndarray, + *, + masked: bool, + mask: np.ndarray | None = None, + fill_value: Any = None, +) -> None: + original = table[name] + common = { + "name": name, + "unit": getattr(original, "unit", None), + "format": getattr(original, "format", None), + "description": getattr(original, "description", None), + "meta": copy.deepcopy(getattr(original, "meta", {})), + "copy": False, + } + if masked: + replacement = MaskedColumn( + data, + mask=mask, + fill_value=fill_value, + **common, + ) + else: + replacement = Column(data, **common) + table.replace_column(name, replacement) + + +def _prepare_hdf5_table(table: Table) -> tuple[Table, list[dict[str, Any]]]: + """Canonicalize endian representation and create an ordered schema manifest.""" + if "__serialized_columns__" in table.meta: + raise ValueError( + "HDF5 export does not support the reserved top-level metadata key " + "'__serialized_columns__'" + ) + metadata_reason = _hdf5_metadata_reason(table.meta, "metadata") + if metadata_reason is not None: + raise ValueError(metadata_reason) + table_fill_reasons = getattr(table, ANALYSIS_HDF5_FILL_REASON_ATTRIBUTE, {}) + if isinstance(table_fill_reasons, Mapping) and table_fill_reasons: + first_name = next(iter(table_fill_reasons)) + raise ValueError(str(table_fill_reasons[first_name])) + source_names = set(table.colnames) + for name in table.colnames: + if _is_masked_column(table[name]) and f"{name}.mask" in source_names: + raise ValueError( + f"HDF5 export cannot serialize masked column '{name}' because " + f"column '{name}.mask' conflicts with its schema mask field" + ) + for name in table.colnames: + fill_reason = getattr(table[name], ANALYSIS_HDF5_FILL_REASON_ATTRIBUTE, None) + if fill_reason is not None: + raise ValueError(str(fill_reason)) + + canonical = table.copy(copy_data=True) + canonical.meta = _canonicalize_hdf5_metadata(canonical.meta) + manifest: list[dict[str, Any]] = [] + for name in canonical.colnames: + column = canonical[name] + if not isinstance(column, Column): + raise ValueError( + f"HDF5 export does not support mixin column '{name}' of type " + f"{type(column).__name__}" + ) + if not isinstance(name, str) or not name: + raise ValueError("HDF5 export requires non-empty text column names") + if not name.isprintable(): + raise ValueError( + f"HDF5 export does not support control characters in column " + f"name {name!r}" + ) + column_format = getattr(column, "format", None) + if column_format is not None and not isinstance(column_format, str): + raise ValueError( + f"HDF5 export does not support callable or non-text format " + f"metadata on column '{name}'" + ) + description = getattr(column, "description", None) + if description is not None and not isinstance(description, str): + raise ValueError( + f"HDF5 export does not support non-text description metadata " + f"on column '{name}'" + ) + values = np.ma.asarray(column) + data = np.asarray(values.data) + if data.ndim != 1: + raise ValueError(f"HDF5 export requires one-dimensional column '{name}'") + reason = _hdf5_dtype_reason(data.dtype, name) + if reason is not None: + raise ValueError(reason) + if data.dtype.kind == "U" and any( + not str(item).isascii() for item in data.flat + ): + raise ValueError( + f"HDF5 export column '{name}' contains non-ASCII Unicode text; " + "this schema only verifies fixed-width ASCII text losslessly" + ) + + dtype = data.dtype.newbyteorder("=") + masked = _is_masked_column(column) + mask = np.ma.getmaskarray(values) if masked else None + fill_value = getattr(column, "fill_value", None) if masked else None + if data.dtype.byteorder not in {"=", "|"}: + data = data.astype(dtype, copy=True) + _replace_table_column( + canonical, + name, + data, + masked=masked, + mask=mask, + fill_value=fill_value, + ) + column = canonical[name] + values = np.ma.asarray(column) + data = np.asarray(values.data) + + column_meta_reason = _hdf5_metadata_reason( + getattr(column, "meta", {}), f"columns.{name}.meta" + ) + if column_meta_reason is not None: + raise ValueError(column_meta_reason) + column.meta = _canonicalize_hdf5_metadata(column.meta) + column_unit = getattr(column, "unit", None) + if column_unit is not None: + unit_reason = _hdf5_unit_reason(column_unit, f"columns.{name}.unit") + if unit_reason is not None: + raise ValueError(unit_reason) + entry: dict[str, Any] = { + "name": name, + "dtype_kind": data.dtype.kind, + "dtype_itemsize": data.dtype.itemsize, + "masked": masked, + "unit": str(column_unit) if column_unit is not None else None, + } + if masked: + entry["fill_value"] = _encode_hdf5_fill_value(column.fill_value, data.dtype) + entry["mask_stored"] = bool(np.any(mask)) + manifest.append(entry) + + manifest_bytes = len( + json.dumps( + manifest, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + ).encode("utf-8") + ) + if manifest_bytes > HDF5_MANIFEST_MAX_BYTES: + raise ValueError("HDF5 column manifest exceeds the 2 MiB schema cap") + try: + with serialize_context_as("hdf5"): + serialized_table = astropy_table_serialize.represent_mixins_as_columns( + canonical + ) + yaml_lines = astropy_table_meta.get_yaml_from_table(serialized_table) + except Exception as exception: + raise ValueError( + "HDF5 export metadata cannot be represented by the installed " + "Astropy runtime" + ) from exception + if not isinstance(yaml_lines, list) or any( + not isinstance(line, str) for line in yaml_lines + ): + raise ValueError("HDF5 export metadata serialization is invalid") + yaml_width = max( + (len(line.encode("utf-8")) for line in yaml_lines), + default=0, + ) + yaml_bytes = len(yaml_lines) * yaml_width + table_bytes = sum( + int(np.asarray(serialized_table[name]).nbytes) + for name in serialized_table.colnames + ) + estimated_bytes = table_bytes + yaml_bytes + manifest_bytes + if estimated_bytes > MAX_EXPORT_ESTIMATED_BYTES: + raise ValueError( + f"HDF5 serialized table is estimated at least " + f"{estimated_bytes / 1024**2:.1f} MiB; the export size cap is " + f"{MAX_EXPORT_ESTIMATED_BYTES / 1024**2:.1f} MiB" + ) + return canonical, manifest + + +def _hdf5_object_support_reason( + obj: Any, + object_type: str, + object_name: str | None = None, +) -> str | None: + """Check one bounded object without mutating application state.""" + if object_name is not None: + name_reason = _hdf5_object_name_reason(object_name) + if name_reason is not None: + return name_reason + try: + # A shallow object copy keeps array allocation bounded while isolating + # Stingray's lazy GTI/property caches from the catalog operation. + table = _table_for_object( + copy.copy(obj), object_type, preserve_timing_precision=True + ) + _prepare_hdf5_table(table) + except Exception as exception: + return str(exception) + return None + + +def _write_hdf5_table( + stream: BinaryIO, + h5py_module: Any, + table: Table, + manifest: list[dict[str, Any]], + *, + object_type: str, + object_name: str, +) -> None: + name_reason = _hdf5_object_name_reason(object_name) + if name_reason is not None: + raise ValueError(name_reason) + manifest_json = json.dumps( + manifest, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + ) + manifest_encoded = manifest_json.encode("utf-8") + if len(manifest_encoded) > HDF5_MANIFEST_MAX_BYTES: + raise ValueError("HDF5 column manifest exceeds the 2 MiB schema cap") + with h5py_module.File(stream, "w") as handle: + table.write( + handle, + format="hdf5", + path=HDF5_TABLE_PATH, + serialize_meta=True, + ) + group = handle[HDF5_GROUP_PATH] + group.attrs["schema"] = HDF5_SCHEMA + group.attrs["table_path"] = HDF5_TABLE_PATH + group.attrs["object_type"] = object_type + group.attrs["object_name"] = object_name + group.attrs["row_count"] = len(table) + handle.create_dataset( + HDF5_MANIFEST_PATH, + shape=(), + dtype=f"S{max(1, len(manifest_encoded))}", + data=np.bytes_(manifest_encoded), + ) + handle.flush() + + +def _text_hdf5_attribute(value: Any, name: str) -> str: + if isinstance(value, bytes): + try: + value = value.decode("utf-8") + except UnicodeDecodeError as exception: + raise ValueError(f"HDF5 {name} attribute is not UTF-8") from exception + if not isinstance(value, str): + raise ValueError(f"HDF5 {name} attribute is invalid") + return value + + +def _require_hdf5_hard_link( + handle: Any, + h5py_module: Any, + path: str, + label: str, +) -> None: + link = handle.get(path, getlink=True) + if not isinstance(link, h5py_module.HardLink): + raise ValueError(f"HDF5 verification found a noncanonical {label} link") + + +def _require_self_contained_hdf5_dataset(dataset: Any, label: str) -> None: + if bool(dataset.is_virtual) or bool(dataset.external): + raise ValueError( + f"HDF5 verification found a non-self-contained {label} dataset" + ) + + +def _validated_hdf5_manifest( + manifest: Any, +) -> tuple[list[dict[str, Any]], list[tuple[str, np.dtype[Any]]]]: + """Validate the bounded manifest before any table dataset is materialized.""" + if not isinstance(manifest, list): + raise ValueError("HDF5 column manifest must be a list") + if not manifest or len(manifest) > MAX_EXPORT_COLUMNS: + raise ValueError( + f"HDF5 column manifest must contain between 1 and " + f"{MAX_EXPORT_COLUMNS:,} columns" + ) + seen: set[str] = set() + physical_fields: list[tuple[str, np.dtype[Any]]] = [] + for entry in manifest: + if not isinstance(entry, dict): + raise ValueError("HDF5 column manifest contains an invalid entry") + masked = entry.get("masked") + required_keys = { + "name", + "dtype_kind", + "dtype_itemsize", + "masked", + "unit", + } + if masked is True: + required_keys.update({"fill_value", "mask_stored"}) + if type(masked) is not bool or set(entry) != required_keys: + raise ValueError("HDF5 column manifest contains an invalid entry") + name = entry.get("name") + if ( + not isinstance(name, str) + or not name + or not name.isprintable() + or name in seen + ): + raise ValueError("HDF5 column manifest contains an invalid column name") + seen.add(name) + unit = entry.get("unit") + if unit is not None and not isinstance(unit, str): + raise ValueError("HDF5 column manifest contains an invalid unit") + logical_dtype = _logical_dtype_from_manifest(entry) + physical_fields.append((name, logical_dtype)) + if masked: + # Validate the scalar now; the restored column uses the same decode. + _decode_hdf5_fill_value(entry.get("fill_value"), logical_dtype) + mask_stored = entry.get("mask_stored") + if type(mask_stored) is not bool: + raise ValueError("HDF5 column manifest contains an invalid entry") + if mask_stored: + physical_fields.append((f"{name}.mask", np.dtype("?"))) + return manifest, physical_fields + + +def _hdf5_dataset_logical_bytes(dataset: Any) -> int: + return int(dataset.size) * int(dataset.dtype.itemsize) + + +def _validate_hdf5_table_storage( + handle: Any, + h5py_module: Any, + *, + row_count: int, + manifest: list[dict[str, Any]], + physical_fields: list[tuple[str, np.dtype[Any]]], + manifest_bytes: int, +) -> None: + """Bound and validate all datasets before Astropy allocates a Table.""" + _require_hdf5_hard_link(handle, h5py_module, HDF5_TABLE_PATH, "table dataset") + table_dataset = handle[HDF5_TABLE_PATH] + if not isinstance(table_dataset, h5py_module.Dataset): + raise ValueError("HDF5 verification found an invalid table dataset") + _require_self_contained_hdf5_dataset(table_dataset, "table") + if len(table_dataset.shape) != 1 or int(table_dataset.shape[0]) != row_count: + raise ValueError("HDF5 schema row count does not match the table dataset") + if table_dataset.dtype.hasobject or table_dataset.dtype.metadata is not None: + raise ValueError("HDF5 table dataset contains an unbounded variable dtype") + actual_fields = table_dataset.dtype.names + expected_names = tuple(name for name, _dtype in physical_fields) + if actual_fields is None or tuple(actual_fields) != expected_names: + raise ValueError("HDF5 table storage fields do not match the column manifest") + expected_storage_fields: list[tuple[str, np.dtype[Any]]] = [] + for name, logical_dtype in physical_fields: + field_dtype = table_dataset.dtype.fields[name][0] + if field_dtype.hasobject or field_dtype.subdtype is not None: + raise ValueError( + f"HDF5 table storage field '{name}' has an unsupported dtype" + ) + if logical_dtype.kind == "U": + expected_kind = "S" + expected_itemsize = logical_dtype.itemsize // np.dtype("U1").itemsize + expected_storage_dtype = h5py_module.string_dtype( + encoding="ascii", + length=expected_itemsize, + ) + else: + expected_kind = logical_dtype.kind + expected_itemsize = logical_dtype.itemsize + expected_storage_dtype = logical_dtype.newbyteorder("=") + expected_storage_fields.append((name, expected_storage_dtype)) + allowed_metadata = field_dtype.metadata is None or ( + expected_kind == "S" and field_dtype.metadata == {"h5py_encoding": "ascii"} + ) + if ( + field_dtype.kind != expected_kind + or field_dtype.itemsize != expected_itemsize + or not allowed_metadata + ): + raise ValueError( + f"HDF5 table storage field '{name}' does not match its logical dtype" + ) + + # High-level NumPy dtypes do not expose every HDF5 type property. Compare + # the complete low-level compound type against the exact packed type our + # writer creates so padding, byte order, integer precision/offset/sign, + # string padding/cset, and boolean enum semantics are all canonical. + expected_storage_dtype = np.dtype(expected_storage_fields, align=False) + actual_hdf_type = table_dataset.id.get_type() + expected_hdf_type = h5py_module.h5t.py_create( + expected_storage_dtype, + logical=True, + ) + try: + if not actual_hdf_type.equal(expected_hdf_type): + raise ValueError( + "HDF5 table storage has a noncanonical compound layout or member type" + ) + finally: + expected_hdf_type.close() + actual_hdf_type.close() + + cells = row_count * len(manifest) + if cells > MAX_EXPORT_CELLS: + raise ValueError( + f"HDF5 table has {cells:,} cells; the export cell cap is " + f"{MAX_EXPORT_CELLS:,}" + ) + if HDF5_ASTROPY_METADATA_PATH not in handle: + raise ValueError("HDF5 verification could not find Astropy table metadata") + _require_hdf5_hard_link( + handle, + h5py_module, + HDF5_ASTROPY_METADATA_PATH, + "Astropy table metadata", + ) + metadata_dataset = handle[HDF5_ASTROPY_METADATA_PATH] + if not isinstance(metadata_dataset, h5py_module.Dataset): + raise ValueError("HDF5 verification found invalid Astropy table metadata") + _require_self_contained_hdf5_dataset(metadata_dataset, "Astropy table metadata") + if ( + len(metadata_dataset.shape) != 1 + or int(metadata_dataset.size) <= 0 + or metadata_dataset.dtype.kind != "S" + or metadata_dataset.dtype.itemsize <= 0 + or metadata_dataset.dtype.hasobject + ): + raise ValueError("HDF5 verification found invalid Astropy table metadata") + metadata_bytes = _hdf5_dataset_logical_bytes(metadata_dataset) + table_bytes = _hdf5_dataset_logical_bytes(table_dataset) + estimated_bytes = manifest_bytes + metadata_bytes + table_bytes + if estimated_bytes > MAX_EXPORT_ESTIMATED_BYTES: + raise ValueError( + f"HDF5 artifact is estimated at least " + f"{estimated_bytes / 1024**2:.1f} MiB; the export size cap is " + f"{MAX_EXPORT_ESTIMATED_BYTES / 1024**2:.1f} MiB" + ) + + +def _restore_hdf5_columns(table: Table, manifest: list[dict[str, Any]]) -> Table: + if len(manifest) != len(table.colnames): + raise ValueError("HDF5 column manifest length does not match the table") + for index, entry in enumerate(manifest): + if not isinstance(entry, dict): + raise ValueError("HDF5 column manifest contains an invalid entry") + name = entry.get("name") + if not isinstance(name, str) or table.colnames[index] != name: + raise ValueError("HDF5 column manifest order does not match the table") + logical_dtype = _logical_dtype_from_manifest(entry) + column = table[name] + values = np.ma.asarray(column) + data = np.asarray(values.data) + masked = entry.get("masked") + if not isinstance(masked, bool): + raise ValueError("HDF5 column manifest has an invalid mask declaration") + actual_masked = _is_masked_column(column) + if actual_masked != masked: + raise ValueError(f"HDF5 column '{name}' mask capability changed") + declared_unit = entry.get("unit") + actual_unit = ( + str(column.unit) if getattr(column, "unit", None) is not None else None + ) + if actual_unit != declared_unit: + raise ValueError(f"HDF5 column '{name}' unit manifest changed") + + if logical_dtype.kind == "U": + character_count = logical_dtype.itemsize // np.dtype("U1").itemsize + if data.dtype.kind == "S" and data.dtype.itemsize == character_count: + try: + restored_data = data.astype(logical_dtype) + except UnicodeDecodeError as exception: + raise ValueError( + f"HDF5 column '{name}' is not valid ASCII text" + ) from exception + elif data.dtype == logical_dtype: + restored_data = data + else: + raise ValueError(f"HDF5 column '{name}' logical dtype changed") + else: + if ( + data.dtype.kind != logical_dtype.kind + or data.dtype.itemsize != logical_dtype.itemsize + ): + raise ValueError(f"HDF5 column '{name}' logical dtype changed") + restored_data = ( + data.astype(logical_dtype, copy=True) + if data.dtype.byteorder not in {"=", "|"} + else data + ) + + fill_value = None + if masked: + fill_value = _decode_hdf5_fill_value(entry.get("fill_value"), logical_dtype) + if restored_data is not data or masked: + _replace_table_column( + table, + name, + restored_data, + masked=masked, + mask=np.ma.getmaskarray(values) if masked else None, + fill_value=fill_value, + ) + return table + + +def _read_hdf5_table( + stream: BinaryIO, + h5py_module: Any, + *, + object_type: str, + object_name: str, +) -> tuple[Table, list[dict[str, Any]]]: + with h5py_module.File(stream, "r") as handle: + if HDF5_GROUP_PATH not in handle: + raise ValueError("HDF5 verification could not find the schema group") + _require_hdf5_hard_link(handle, h5py_module, HDF5_GROUP_PATH, "schema group") + group = handle[HDF5_GROUP_PATH] + if not isinstance(group, h5py_module.Group): + raise ValueError("HDF5 verification found an invalid schema group") + schema = _text_hdf5_attribute(group.attrs.get("schema"), "schema") + if schema != HDF5_SCHEMA: + raise ValueError("HDF5 verification found an unexpected schema marker") + table_path = _text_hdf5_attribute(group.attrs.get("table_path"), "table path") + if table_path != HDF5_TABLE_PATH or HDF5_TABLE_PATH not in handle: + raise ValueError("HDF5 verification found an unexpected table path") + stored_type = _text_hdf5_attribute( + group.attrs.get("object_type"), "object type" + ) + stored_name = _text_hdf5_attribute( + group.attrs.get("object_name"), "object name" + ) + if stored_type != object_type or stored_name != object_name: + raise ValueError("HDF5 verification found unexpected object identity") + row_count = group.attrs.get("row_count") + if not isinstance(row_count, (int, np.integer)) or isinstance( + row_count, (bool, np.bool_) + ): + raise ValueError("HDF5 verification found an invalid row count") + row_count = int(row_count) + if row_count < 0 or row_count > MAX_EXPORT_ROWS: + raise ValueError( + f"HDF5 table row count exceeds the export cap of {MAX_EXPORT_ROWS:,}" + ) + if HDF5_MANIFEST_PATH not in handle: + raise ValueError("HDF5 verification could not find the column manifest") + _require_hdf5_hard_link( + handle, h5py_module, HDF5_MANIFEST_PATH, "column manifest" + ) + manifest_dataset = handle[HDF5_MANIFEST_PATH] + if not isinstance(manifest_dataset, h5py_module.Dataset): + raise ValueError("HDF5 verification found an invalid column manifest") + if ( + manifest_dataset.shape != () + or manifest_dataset.dtype.kind != "S" + or manifest_dataset.dtype.itemsize <= 0 + or manifest_dataset.dtype.itemsize > HDF5_MANIFEST_MAX_BYTES + or manifest_dataset.dtype.hasobject + ): + raise ValueError( + "HDF5 column manifest storage exceeds or violates the 2 MiB schema cap" + ) + _require_self_contained_hdf5_dataset(manifest_dataset, "column manifest") + manifest_bytes = int(manifest_dataset.dtype.itemsize) + manifest_text = _text_hdf5_attribute(manifest_dataset[()], "column manifest") + if len(manifest_text.encode("utf-8")) > HDF5_MANIFEST_MAX_BYTES: + raise ValueError("HDF5 column manifest exceeds the 2 MiB schema cap") + try: + manifest = json.loads(manifest_text) + except json.JSONDecodeError as exception: + raise ValueError("HDF5 column manifest is invalid JSON") from exception + manifest, physical_fields = _validated_hdf5_manifest(manifest) + _validate_hdf5_table_storage( + handle, + h5py_module, + row_count=row_count, + manifest=manifest, + physical_fields=physical_fields, + manifest_bytes=manifest_bytes, + ) + reopened = Table.read( + handle, + format="hdf5", + path=HDF5_TABLE_PATH, + ) + if len(reopened) != row_count: + raise ValueError("HDF5 schema row count does not match the table") + return _restore_hdf5_columns(reopened, manifest), manifest + + +def _semantic_array_equal(expected: np.ndarray, actual: np.ndarray) -> bool: + if expected.shape != actual.shape: + return False + if expected.dtype.kind == "f" and actual.dtype.kind == "f": + return bool(np.array_equal(expected, actual, equal_nan=True)) + return bool(np.array_equal(expected, actual)) + + +def _assert_hdf5_semantic_equal(expected: Any, actual: Any, location: str) -> None: + if isinstance(expected, u.Quantity): + if not isinstance(actual, u.Quantity) or expected.unit != actual.unit: + raise ValueError(f"HDF5 verification changed {location} units") + _assert_hdf5_semantic_equal(expected.value, actual.value, location) + return + if isinstance(expected, u.UnitBase): + if not isinstance(actual, u.UnitBase) or expected != actual: + raise ValueError(f"HDF5 verification changed {location}") + return + if isinstance(expected, np.ndarray): + if not isinstance(actual, np.ndarray): + raise ValueError(f"HDF5 verification changed {location} type") + if ( + expected.dtype.kind != actual.dtype.kind + or expected.dtype.itemsize != actual.dtype.itemsize + ): + raise ValueError(f"HDF5 verification changed {location} dtype") + if not _semantic_array_equal(expected, actual): + raise ValueError(f"HDF5 verification changed {location} values") + return + if isinstance(expected, Mapping): + if not isinstance(actual, Mapping) or set(expected) != set(actual): + raise ValueError(f"HDF5 verification changed {location} keys") + for key in expected: + _assert_hdf5_semantic_equal(expected[key], actual[key], f"{location}.{key}") + return + if isinstance(expected, (list, tuple)): + if not isinstance(actual, type(expected)) or len(expected) != len(actual): + raise ValueError(f"HDF5 verification changed {location} sequence") + for index, (expected_item, actual_item) in enumerate( + zip(expected, actual, strict=True) + ): + _assert_hdf5_semantic_equal( + expected_item, actual_item, f"{location}[{index}]" + ) + return + if isinstance(expected, np.generic): + if isinstance(expected, np.floating): + expected = float(expected) + else: + expected = expected.item() + if isinstance(actual, np.generic): + if isinstance(actual, np.floating): + actual = float(actual) + else: + actual = actual.item() + if isinstance(expected, bool) or isinstance(actual, bool): + if type(expected) is not type(actual) or expected != actual: + raise ValueError(f"HDF5 verification changed {location}") + return + if isinstance(expected, float) and isinstance(actual, (int, float)): + if math.isnan(expected) and isinstance(actual, float) and math.isnan(actual): + return + if expected == actual: + return + raise ValueError(f"HDF5 verification changed {location}") + if expected != actual: + raise ValueError(f"HDF5 verification changed {location}") + + +def _verify_hdf5_table(expected: Table, actual: Table) -> list[str]: + if len(expected) != len(actual): + raise ValueError("HDF5 semantic verification changed the row count") + if expected.colnames != actual.colnames: + raise ValueError("HDF5 semantic verification changed the column order") + for name in expected.colnames: + expected_column = expected[name] + actual_column = actual[name] + expected_values = np.ma.asarray(expected_column) + actual_values = np.ma.asarray(actual_column) + expected_data = np.asarray(expected_values.data) + actual_data = np.asarray(actual_values.data) + if ( + expected_data.dtype.kind != actual_data.dtype.kind + or expected_data.dtype.itemsize != actual_data.dtype.itemsize + ): + raise ValueError( + f"HDF5 semantic verification changed column '{name}' logical dtype" + ) + if _is_masked_column(expected_column) != _is_masked_column(actual_column): + raise ValueError( + f"HDF5 semantic verification changed column '{name}' mask capability" + ) + expected_mask = np.ma.getmaskarray(expected_values) + actual_mask = np.ma.getmaskarray(actual_values) + if not np.array_equal(expected_mask, actual_mask): + raise ValueError( + f"HDF5 semantic verification changed column '{name}' masks" + ) + # Masked payload bytes are deliberately non-scientific and serializers + # may normalize them. The mask and fill semantics are checked + # separately; compare values only where the column says they exist. + if not _semantic_array_equal( + expected_data[~expected_mask], actual_data[~actual_mask] + ): + raise ValueError( + f"HDF5 semantic verification changed column '{name}' values" + ) + expected_unit = getattr(expected_column, "unit", None) + actual_unit = getattr(actual_column, "unit", None) + if expected_unit != actual_unit: + raise ValueError( + f"HDF5 semantic verification changed column '{name}' units" + ) + for attribute in ("description", "format", "meta"): + _assert_hdf5_semantic_equal( + getattr(expected_column, attribute, None), + getattr(actual_column, attribute, None), + f"column '{name}' {attribute}", + ) + if _is_masked_column(expected_column): + _assert_hdf5_semantic_equal( + np.asarray(expected_column.fill_value, dtype=expected_data.dtype), + np.asarray(actual_column.fill_value, dtype=actual_data.dtype), + f"column '{name}' fill value", + ) + _assert_hdf5_semantic_equal( + dict(expected.meta), dict(actual.meta), "table metadata" + ) + return list(HDF5_VERIFICATION_CHECKS) + + +class IOUtilityService(BaseService): + """General I/O Utilities service with exact native-file grant checks.""" + + def inspect_file(self, file_path: str, file_grant: str) -> dict[str, Any]: + try: + with open_verified_read_grant(file_path, file_grant) as granted: + path = granted.path + size = validate_file_size( + granted.stream, MAX_FITS_INSPECT_BYTES, "Selected file" + ) + suffix = path.suffix.lower() + granted.stream.seek(0) + stream = granted.stream + magic = stream.read(30) + looks_like_fits = magic.startswith(b"SIMPLE =") + + base_data: dict[str, Any] = { + "path": str(path), + "filename": path.name, + "extension": suffix or None, + "size_bytes": size, + "supported": False, + "detected_type": "unknown", + "hdus": [], + "warnings": [], + } + if suffix not in FITS_EXTENSIONS and not looks_like_fits: + base_data["warnings"] = [ + "This inspector currently supports FITS-family files only; use Data " + "Ingestion for loading other tabular files." + ] + base_data["provenance"] = operation_provenance( + "inspect_file", + input_source={"kind": "native_file", "path": str(path)}, + parameters={"header_only": True}, + ) + return self.create_result( + success=True, + data=base_data, + message=f"'{path.name}' is not a supported inspection format", + warnings=base_data["warnings"], + ) + + hdus, warning_messages, detected_type = _inspect_fits(granted.stream) + base_data.update( + { + "supported": True, + "detected_type": detected_type, + "hdus": hdus, + "warnings": warning_messages, + "provenance": operation_provenance( + "inspect_file", + input_source={"kind": "native_file", "path": str(path)}, + parameters={"header_only": True, "lazy_data": True}, + ), + } + ) + return self.create_result( + success=True, + data=base_data, + message=f"Inspected {len(hdus)} FITS HDU(s) in '{path.name}'", + warnings=warning_messages, + ) + except Exception as exception: + return self.handle_error( + exception, "Inspecting selected file", file=file_path + ) + + def inspect_rmf(self, rmf_path: str, rmf_grant: str) -> dict[str, Any]: + try: + with open_verified_read_grant(rmf_path, rmf_grant) as granted: + path = granted.path + file_size = granted.size_bytes + channels, e_min, e_max, unit, warning_messages = _load_valid_rmf( + granted.stream, require_energy_unit=False + ) + midpoints = _energy_midpoints(e_min, e_max) + order = np.argsort(channels) + sorted_channels = channels[order] + gaps = np.diff(sorted_channels) != 1 + if np.any(gaps): + warning_messages.append( + "RMF channels are not contiguous; conversion remains available only for " + "exact listed channel values." + ) + preview_count = min(len(channels), MAX_EVENT_PREVIEW_ROWS) + rows = [ + { + "channel": int(channels[index]), + "energy_min": float(e_min[index]), + "energy_max": float(e_max[index]), + "energy_midpoint": float(midpoints[index]), + } + for index in range(preview_count) + ] + data = { + "path": str(path), + "filename": path.name, + "size_bytes": file_size, + "channel_count": len(channels), + "channel_min": int(np.min(channels)), + "channel_max": int(np.max(channels)), + "energy_min": float(np.min(e_min)), + "energy_max": float(np.max(e_max)), + "energy_unit": unit, + "conversion_supported": unit is not None, + "contiguous_channels": not bool(np.any(gaps)), + "preview_rows": rows, + "preview_truncated": preview_count < len(channels), + "warnings": warning_messages, + "provenance": operation_provenance( + "inspect_rmf", + input_source={"kind": "native_rmf", "path": str(path)}, + parameters={"ebounds_only": True}, + calibrated=True, + ), + } + return self.create_result( + success=True, + data=data, + message=f"Inspected {len(channels):,} RMF channel(s)", + warnings=warning_messages, + ) + except Exception as exception: + return self.handle_error(exception, "Inspecting RMF", file=rmf_path) + + def convert_pi_values( + self, + pi_values: Any, + rmf_path: str, + rmf_grant: str, + ) -> dict[str, Any]: + try: + with open_verified_read_grant(rmf_path, rmf_grant) as granted: + path = granted.path + pis, energies, unit, warning_messages = _calibrate_pi( + pi_values, granted.stream, maximum=MAX_ARRAY_INPUT + ) + rows = [ + {"index": index, "pi": int(pi), "energy": float(energy)} + for index, (pi, energy) in enumerate(zip(pis, energies, strict=True)) + ] + data = { + "rows": rows, + "count": len(rows), + "energy_unit": unit, + "plot": bounded_plot_preview(pis, energies), + "warnings": warning_messages, + "provenance": operation_provenance( + "rmf_pi_to_energy", + input_source={"kind": "pasted_pi", "count": len(pis)}, + parameters={"rmf_path": str(path)}, + calibrated=True, + energy_unit=unit, + ), + } + return self.create_result( + success=True, + data=data, + message=f"Converted {len(pis):,} PI value(s) to calibrated energy", + warnings=warning_messages, + ) + except Exception as exception: + return self.handle_error( + exception, "Converting PI values with RMF", file=rmf_path + ) + + def convert_event_list( + self, + event_list_name: str, + rmf_path: str, + rmf_grant: str, + save_as: str | None = None, + ) -> dict[str, Any]: + try: + with open_verified_read_grant(rmf_path, rmf_grant) as granted: + path = granted.path + if save_as is not None: + name_error = validate_derived_name(save_as) + if name_error: + raise ValueError(name_error) + source = self.state.copy_event_data( + event_list_name, + max_events=MAX_EXPORT_ROWS, + max_columns=MAX_EXPORT_COLUMNS, + max_cells=MAX_EXPORT_CELLS, + max_bytes=MAX_EXPORT_ESTIMATED_BYTES, + ) + if source is None: + raise ValueError(f"EventList '{event_list_name}' not found") + source_pi = getattr(source, "pi", None) + if source_pi is None: + raise ValueError( + f"EventList '{event_list_name}' has no PI channel data" + ) + if getattr(source, "time", None) is None or len(source.time) != len( + source_pi + ): + raise ValueError( + "EventList PI and time arrays must have the same length" + ) + + pis, energies, unit, warning_messages = _calibrate_pi( + source_pi, granted.stream, maximum=MAX_EXPORT_ROWS + ) + # ``source`` is already a StateManager deep copy. Only this + # detached object receives energy/provenance attributes. + # The deep copy already contains the source PI array byte-for-byte; + # keep it untouched (including dtype) and add only calibrated energy. + source.energy = np.array(energies, copy=True) + provenance = operation_provenance( + "rmf_event_list_pi_to_energy", + input_source={"kind": "loaded_event_list", "name": event_list_name}, + parameters={"rmf_path": str(path), "save_as": save_as}, + calibrated=True, + energy_unit=unit, + ) + source.rmf_conversion_provenance = provenance + + saved = False + if save_as is not None: + if not self.state.add_event_data_if_absent(save_as, source): + raise ValueError(f"EventList name '{save_as}' already exists") + saved = True + + preview_count = min(len(pis), MAX_EVENT_PREVIEW_ROWS) + preview_rows = [ + { + "index": index, + "pi": int(pis[index]), + "energy": float(energies[index]), + } + for index in range(preview_count) + ] + data = { + "source_name": event_list_name, + "saved": saved, + "saved_name": save_as if saved else None, + "event_count": len(pis), + "energy_unit": unit, + "preview_rows": preview_rows, + "preview_truncated": preview_count < len(pis), + "plot": bounded_plot_preview(pis, energies), + "pi_preserved": True, + "warnings": warning_messages, + "provenance": provenance, + } + action = f"saved as '{save_as}'" if saved else "previewed without saving" + return self.create_result( + success=True, + data=data, + message=f"Calibrated {len(pis):,} EventList PI value(s); {action}", + warnings=warning_messages, + ) + except Exception as exception: + return self.handle_error( + exception, + "Converting EventList PI with RMF", + event_list=event_list_name, + file=rmf_path, + ) + + @staticmethod + def _format_capabilities( + object_type: str, + hdf5_capability: dict[str, Any] | None = None, + ) -> dict[str, dict[str, Any]]: + common = { + "csv": { + "supported": True, + "notes": ( + "Tabular values only; column units, metadata, and GTIs are not " + "preserved." + ), + }, + "ecsv": { + "supported": True, + "notes": "Astropy ECSV table with serializable metadata.", + }, + "json": { + "supported": True, + "notes": "Strict JSON tabular envelope; non-finite values become null with warnings.", + }, + "fits": { + "supported": True, + "notes": "Generic FITS binary table, not an OGIP/HEASoft event product.", + }, + "hdf5": copy.deepcopy(hdf5_capability or _hdf5_capability()), + } + if object_type in {"event_list", "lightcurve"}: + common["fits"]["notes"] += ( + " A separate GTI extension is included when available." + ) + return common + + def list_exportable_objects(self) -> dict[str, Any]: + try: + hdf5_capability = _hdf5_capability() + enabled_formats = ["csv", "ecsv", "json", "fits"] + if hdf5_capability["supported"]: + enabled_formats.append("hdf5") + objects: list[dict[str, Any]] = [] + sources = ( + ("event_list", self.state.get_event_data()), + ("lightcurve", self.state.get_lightcurve_data()), + ("analysis_result", self.state.get_analysis_result()), + ) + for object_type, entries in sources: + for name, obj in entries: + reason: str | None = None + try: + row_count = _object_row_count(obj, object_type) + if row_count > MAX_EXPORT_ROWS: + reason = ( + f"Object has {row_count:,} rows; the export cap is " + f"{MAX_EXPORT_ROWS:,}" + ) + except Exception as exception: + row_count = None + reason = str(exception) + formats = list(enabled_formats) if reason is None else [] + format_reasons: dict[str, str] = {} + if reason is None: + if hdf5_capability["supported"]: + hdf5_reason = _hdf5_object_support_reason( + obj, object_type, name + ) + else: + hdf5_reason = hdf5_capability["reason"] + if hdf5_reason is not None: + format_reasons["hdf5"] = hdf5_reason + if "hdf5" in formats: + formats.remove("hdf5") + objects.append( + { + "object_type": object_type, + "name": name, + "row_count": row_count, + "exportable": reason is None, + "formats": formats, + "format_reasons": format_reasons, + "reason": reason, + } + ) + + matrix = { + object_type: self._format_capabilities(object_type, hdf5_capability) + for object_type in sorted(EXPORT_OBJECT_TYPES) + } + excluded_formats = { + "pickle": "Unsafe deserialization format; intentionally unsupported." + } + if not hdf5_capability["supported"]: + excluded_formats["hdf5"] = hdf5_capability["reason"] + data = { + "objects": objects, + "capability_matrix": matrix, + "format_allowlist": enabled_formats, + "excluded_formats": excluded_formats, + "row_cap": MAX_EXPORT_ROWS, + "provenance": operation_provenance( + "list_exportable_objects", + input_source={"kind": "application_state"}, + parameters={}, + ), + } + return self.create_result( + success=True, + data=data, + message=f"Found {sum(item['exportable'] for item in objects)} exportable object(s)", + ) + except Exception as exception: + return self.handle_error(exception, "Listing exportable objects") + + def _copy_export_object(self, object_type: str, object_name: str) -> Any: + if object_type == "event_list": + result = self.state.copy_event_data( + object_name, + max_events=MAX_EXPORT_ROWS, + max_columns=MAX_EXPORT_COLUMNS, + max_cells=MAX_EXPORT_CELLS, + max_bytes=MAX_EXPORT_ESTIMATED_BYTES, + ) + elif object_type == "lightcurve": + result = self.state.copy_lightcurve_data( + object_name, + max_points=MAX_EXPORT_ROWS, + max_columns=MAX_EXPORT_COLUMNS, + max_cells=MAX_EXPORT_CELLS, + max_bytes=MAX_EXPORT_ESTIMATED_BYTES, + ) + elif object_type == "analysis_result": + result = self.state.copy_analysis_result( + object_name, + max_rows=MAX_EXPORT_ROWS, + max_columns=MAX_EXPORT_COLUMNS, + max_cells=MAX_EXPORT_CELLS, + max_bytes=MAX_EXPORT_ESTIMATED_BYTES, + ) + else: + raise ValueError( + "object_type must be one of: event_list, lightcurve, analysis_result" + ) + if result is None: + label = object_type.replace("_", " ").title() + raise ValueError(f"{label} '{object_name}' not found") + return result + + def export_object( + self, + object_type: str, + object_name: str, + export_format: str, + destination_path: str, + destination_grant: str, + ) -> dict[str, Any]: + context_stack = ExitStack() + warning_messages: list[str] = [] + try: + publication = context_stack.enter_context( + open_secure_publication( + destination_path, + destination_grant, + ) + ) + path = publication.path + destination_name = publication.filename + if destination_name in {"", ".", ".."} or os.sep in destination_name: + raise ValueError("The selected destination filename is invalid") + if os.altsep is not None and os.altsep in destination_name: + raise ValueError("The selected destination filename is invalid") + + publication.revalidate("The selected destination path changed") + requested_format = export_format.lower().strip() + if requested_format not in EXPORT_EXTENSIONS: + raise ValueError( + "format must be one of: " + ", ".join(EXPORT_EXTENSIONS) + ) + expected_extension = EXPORT_EXTENSIONS[requested_format] + if path.suffix.lower() != expected_extension: + raise ValueError( + f"{requested_format.upper()} export requires the exact " + f"'{expected_extension}' filename extension" + ) + h5py_module = None + if requested_format == "hdf5": + h5py_module, hdf5_reason = _optional_hdf5_runtime() + if h5py_module is None: + raise RuntimeError(hdf5_reason or HDF5_UNAVAILABLE_REASON) + name_reason = _hdf5_object_name_reason(object_name) + if name_reason is not None: + raise ValueError(name_reason) + publication.assert_destination_available() + obj = self._copy_export_object(object_type, object_name) + row_count = _object_row_count(obj, object_type) + if row_count > MAX_EXPORT_ROWS: + raise ValueError( + f"Object has {row_count:,} rows; the export cap is {MAX_EXPORT_ROWS:,}" + ) + table = _table_for_object( + obj, + object_type, + preserve_timing_precision=requested_format == "hdf5", + ) + if len(table) != row_count: + raise ValueError( + "Export table row count does not match the loaded object" + ) + hdf5_manifest: list[dict[str, Any]] | None = None + if requested_format == "hdf5": + table, hdf5_manifest = _prepare_hdf5_table(table) + + # Write and verify in a private same-directory staging area. The + # platform adapter exposes the user-visible destination only after + # verification, using an exclusive no-replacement publication. + publication.reserve_staging(expected_extension) + if ( + object_type in {"event_list", "lightcurve"} + and table.meta.get("gti_status") == "missing" + ): + warning_messages.append( + "The source has no explicit GTI; no GTI was synthesized for export." + ) + if requested_format == "csv": + warning_messages.append( + "CSV stores tabular values only; column units, object metadata, and " + "GTIs are not preserved." + ) + elif requested_format == "fits": + warning_messages.append( + "FITS output is a generic binary-table export, not an OGIP/HEASoft " + "event product, and may not preserve every object metadata field." + ) + + if requested_format == "hdf5": + mode = "w+b" + elif requested_format == "fits": + mode = "wb" + else: + mode = "w" + with publication.open_writer( + mode, + encoding=None if "b" in mode else "utf-8", + ) as stream: + with collect_warnings(warning_messages): + if requested_format == "csv": + table.write(stream, format="ascii.csv", overwrite=False) + elif requested_format == "ecsv": + table.write(stream, format="ascii.ecsv", overwrite=False) + elif requested_format == "fits": + with _fits_hdul_for_table( + table, object_type, obj, warning_messages + ) as hdul: + hdul.writeto(stream, checksum=True) + elif requested_format == "hdf5": + assert h5py_module is not None + assert hdf5_manifest is not None + _write_hdf5_table( + stream, + h5py_module, + table, + hdf5_manifest, + object_type=object_type, + object_name=object_name, + ) + else: + payload = _json_export_payload( + table, + object_type=object_type, + object_name=object_name, + warnings_out=warning_messages, + ) + json.dump( + payload, + stream, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + ) + + # Astropy's ASCII registry expects a binary-readable file object; + # JSON's standard decoder expects text. + read_mode = "r" if requested_format == "json" else "rb" + hdf5_checks: list[str] | None = None + with publication.open_reader( + read_mode, + encoding=None if read_mode == "rb" else "utf-8", + ) as reopened_stream: + if requested_format in {"csv", "ecsv"}: + reopened = Table.read( + reopened_stream, + format=( + "ascii.csv" if requested_format == "csv" else "ascii.ecsv" + ), + ) + reopened_rows = len(reopened) + elif requested_format == "fits": + with fits.open( + reopened_stream, checksum=True, memmap=False + ) as hdul: + hdul.verify("exception") + for hdu in hdul: + if ( + "CHECKSUM" not in hdu.header + or hdu.verify_checksum() != 1 + ): + raise ValueError("FITS CHECKSUM verification failed") + if "DATASUM" not in hdu.header or hdu.verify_datasum() != 1: + raise ValueError("FITS DATASUM verification failed") + reopened_rows = int(hdul[1].header.get("NAXIS2", 0) or 0) + if object_type in {"event_list", "lightcurve"}: + if table.meta.get("gti_status") == "present": + expected_gti = _normalize_gti_array(table.meta["gti"]) + if "GTI" not in hdul: + raise ValueError( + "FITS verification could not find the GTI extension" + ) + actual_gti_rows = int( + hdul["GTI"].header.get("NAXIS2", -1) + ) + if actual_gti_rows != len(expected_gti): + raise ValueError( + "FITS GTI verification found an unexpected row count" + ) + if table.meta and "METADATA" not in hdul: + raise ValueError( + "FITS verification could not find object metadata" + ) + elif requested_format == "hdf5": + assert h5py_module is not None + assert hdf5_manifest is not None + reopened, reopened_manifest = _read_hdf5_table( + reopened_stream, + h5py_module, + object_type=object_type, + object_name=object_name, + ) + _assert_hdf5_semantic_equal( + hdf5_manifest, + reopened_manifest, + "HDF5 column manifest", + ) + hdf5_checks = _verify_hdf5_table(table, reopened) + reopened_rows = len(reopened) + else: + reopened_json = json.load(reopened_stream) + if reopened_json.get("schema") != "stingray-explorer.tabular.v1": + raise ValueError("JSON verification found an unexpected schema") + reopened_rows = int(reopened_json.get("row_count", -1)) + if reopened_rows != row_count: + raise ValueError( + f"Reopened artifact has {reopened_rows:,} rows; expected {row_count:,}" + ) + + byte_size = publication.verified_size() + + # Publish the already verified artifact through the platform's + # single-operation, exclusive no-replacement primitive. + warning_messages.extend(publication.publish()) + provenance_parameters = { + "format": requested_format, + "destination_path": str(path), + "exclusive_non_overwrite": True, + "reopen_verified": True, + } + if requested_format == "hdf5": + provenance_parameters["schema"] = HDF5_SCHEMA + data = { + "path": str(path), + "bytes": byte_size, + "format": requested_format, + "row_count": row_count, + "object_type": object_type, + "object_name": object_name, + "verified": True, + "warnings": warning_messages, + "provenance": operation_provenance( + "export_loaded_object", + input_source={"kind": object_type, "name": object_name}, + parameters=provenance_parameters, + ), + } + if requested_format == "hdf5": + assert h5py_module is not None + assert hdf5_checks is not None + data["verification"] = { + "schema": HDF5_SCHEMA, + "table_path": HDF5_TABLE_PATH, + "semantic_round_trip": True, + "checks": hdf5_checks, + "h5py_version": str(getattr(h5py_module, "__version__", "unknown")), + } + return self.create_result( + success=True, + data=data, + message=f"Exported {row_count:,} row(s) to '{path.name}'", + warnings=warning_messages, + ) + except Exception as exception: + return self.handle_error( + exception, + "Exporting loaded object", + object_type=object_type, + object_name=object_name, + destination=destination_path, + ) + finally: + context_stack.close() diff --git a/python-backend/services/job_manager.py b/python-backend/services/job_manager.py new file mode 100644 index 0000000..8fbcf0e --- /dev/null +++ b/python-backend/services/job_manager.py @@ -0,0 +1,1064 @@ +""" +Job Manager for background task queue. + +This module provides a thread-safe job queue system with SSE streaming +for real-time progress updates. Jobs are executed in a ThreadPoolExecutor +to avoid blocking the main event loop. +""" + +import asyncio +import logging +import queue +import threading +from concurrent.futures import Future, ThreadPoolExecutor +from contextlib import ExitStack +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, AsyncGenerator, Callable, Dict, List, Optional + +from models.event_formats import ( + require_batch_input_formats, + require_input_event_format, +) +from models.job import Job, JobStatus, JobType +from services.utility_helpers import ( + GrantedReadFile, + open_verified_read_grant, + validate_derived_name, +) + +logger = logging.getLogger(__name__) + +# Maximum number of completed jobs to retain +MAX_COMPLETED_JOBS = 100 +MAX_ACTIVE_JOBS = 32 +MAX_RETAINED_CAPABILITIES = 256 +_SUBMITTING = object() + + +@dataclass +class _JobResources: + """Private capability owner; none of these fields enter a public Job DTO.""" + + stack: ExitStack + private: Dict[str, Any] = field(default_factory=dict) + release_reservation: Callable[[], None] | None = None + _closed: bool = False + _lock: threading.Lock = field(default_factory=threading.Lock) + + def close(self) -> None: + with self._lock: + if self._closed: + return + self._closed = True + try: + self.stack.close() + finally: + if self.release_reservation is not None: + self.release_reservation() + + +class JobManager: + """ + Thread-safe background job manager with SSE streaming support. + + Manages a queue of background jobs, executing them in a thread pool + while providing real-time progress updates via SSE. + """ + + def __init__( + self, + state_manager: Any, + data_service: Any, + max_workers: int = 4, + ) -> None: + """ + Initialize the job manager. + + Args: + state_manager: StateManager instance for checking name conflicts + data_service: DataService instance for executing load operations + max_workers: Maximum number of concurrent worker threads + """ + self._state_manager = state_manager + self._data_service = data_service + self._lock = threading.RLock() + + # Job storage: id -> Job + self._jobs: Dict[str, Job] = {} + + # Thread pool for background execution + self._executor = ThreadPoolExecutor( + max_workers=max_workers, thread_name_prefix="job_worker" + ) + + # Futures for tracking running jobs + self._futures: Dict[str, Future | object] = {} + self._resources: Dict[str, _JobResources] = {} + self._created_job_ids: set[str] = set() + self._capacity_lock = threading.Lock() + self._reserved_jobs = 0 + self._retained_capabilities = 0 + + # SSE update queue for broadcasting job updates + self._update_queue: queue.Queue[Dict[str, Any]] = queue.Queue(maxsize=1000) + + # Active SSE connections counter + self._active_connections = 0 + + logger.info(f"JobManager initialized with {max_workers} workers") + + def shutdown(self) -> None: + """Shutdown the job manager and its thread pool.""" + logger.info("Shutting down JobManager...") + with self._lock: + jobs_to_cancel = [job for job in self._jobs.values() if job.is_active] + for job in jobs_to_cancel: + self._broadcast_created_once_locked(job) + job.cancel() + self._broadcast_update("job_cancelled", job) + self._executor.shutdown(wait=False, cancel_futures=True) + with self._lock: + finished_ids = [ + job_id + for job_id, future in self._futures.items() + if isinstance(future, Future) and (future.cancelled() or future.done()) + ] + resources = [self._resources.pop(job_id, None) for job_id in finished_ids] + for resource in resources: + if resource is not None: + resource.close() + logger.info("JobManager shutdown complete") + + # ========================================================================= + # Job Management + # ========================================================================= + + def get_job(self, job_id: str) -> Optional[Job]: + """Get a job by ID.""" + with self._lock: + return self._jobs.get(job_id) + + def list_jobs( + self, + include_completed: bool = True, + limit: int = 50, + ) -> List[Job]: + """ + List all jobs, newest first. + + Args: + include_completed: Include completed/failed/cancelled jobs + limit: Maximum number of jobs to return + + Returns: + List of jobs sorted by creation time (newest first) + """ + with self._lock: + jobs = list(self._jobs.values()) + + if not include_completed: + jobs = [j for j in jobs if j.is_active] + + # Sort by created_at descending (newest first) + jobs.sort(key=lambda j: j.created_at, reverse=True) + + return jobs[:limit] + + def get_active_jobs(self) -> List[Job]: + """Get all active (pending or running) jobs.""" + with self._lock: + return [j for j in self._jobs.values() if j.is_active] + + def cancel_job(self, job_id: str) -> bool: + """ + Cancel a pending job. + + Only pending jobs can be cancelled. Running jobs cannot be + interrupted (thread pool limitation). + + Args: + job_id: ID of the job to cancel + + Returns: + True if job was cancelled, False if not found or not cancellable + """ + with self._lock: + job = self._jobs.get(job_id) + if not job: + return False + + if job.status not in (JobStatus.PENDING, JobStatus.RUNNING): + return False + + future = self._futures.get(job_id) + submitting = future is _SUBMITTING + + # Mark the public state first: Future.cancel() invokes completion + # callbacks synchronously and must not transiently publish failure. + if not submitting: + self._broadcast_created_once_locked(job) + job.cancel() + if not submitting: + self._broadcast_update("job_cancelled", job) + + cancelled_before_start = bool( + isinstance(future, Future) and not future.done() and future.cancel() + ) + logger.info(f"Cancelled job {job_id}") + if cancelled_before_start: + resource = self._resources.pop(job_id, None) + if resource is not None: + resource.close() + return True + + def clear_completed_jobs(self) -> int: + """ + Remove all completed/failed/cancelled jobs. + + Returns: + Number of jobs removed + """ + with self._lock: + to_remove = [] + for job_id, job in self._jobs.items(): + future = self._futures.get(job_id) + if job.is_finished and ( + future is None or (isinstance(future, Future) and future.done()) + ): + to_remove.append(job_id) + + for job_id in to_remove: + del self._jobs[job_id] + self._futures.pop(job_id, None) + self._created_job_ids.discard(job_id) + resource = self._resources.pop(job_id, None) + if resource is not None: + resource.close() + + logger.info(f"Cleared {len(to_remove)} completed jobs") + return len(to_remove) + + def _cleanup_old_jobs(self) -> None: + """Remove oldest completed jobs if exceeding limit.""" + with self._lock: + finished_jobs = [] + for job_id, job in self._jobs.items(): + future = self._futures.get(job_id) + if job.is_finished and ( + future is None or (isinstance(future, Future) and future.done()) + ): + finished_jobs.append((job_id, job)) + + if len(finished_jobs) <= MAX_COMPLETED_JOBS: + return + + # Sort by completed_at (oldest first) + finished_jobs.sort(key=lambda x: x[1].completed_at or "") + + # Remove oldest jobs exceeding limit + to_remove = len(finished_jobs) - MAX_COMPLETED_JOBS + for job_id, _ in finished_jobs[:to_remove]: + del self._jobs[job_id] + self._futures.pop(job_id, None) + self._created_job_ids.discard(job_id) + resource = self._resources.pop(job_id, None) + if resource is not None: + resource.close() + + logger.debug(f"Cleaned up {to_remove} old completed jobs") + + # ========================================================================= + # Name Conflict Checking + # ========================================================================= + + def check_name_conflict(self, name: str) -> Dict[str, Any]: + """ + Check if a name conflicts with existing data or pending jobs. + + Args: + name: Name to check + + Returns: + Dict with conflict status and suggested alternative if needed + """ + with self._lock: + # Check against loaded event lists + if self._state_manager.has_event_data(name): + return { + "has_conflict": True, + "conflict_source": "loaded_data", + "suggested_name": self._suggest_unique_name(name), + } + + # Check against pending/running job names + for job in self._jobs.values(): + if job.is_active: + # Check job's target name(s) + job_name = job.params.get("name") + if job_name == name: + return { + "has_conflict": True, + "conflict_source": "pending_job", + "job_id": job.id, + "suggested_name": self._suggest_unique_name(name), + } + + # For batch jobs, check all file names + files = job.params.get("files", []) + for f in files: + if f.get("name") == name: + return { + "has_conflict": True, + "conflict_source": "pending_job", + "job_id": job.id, + "suggested_name": self._suggest_unique_name(name), + } + + return {"has_conflict": False} + + def _suggest_unique_name(self, base_name: str) -> str: + """Generate a unique name by appending a number suffix.""" + # Get all existing names + existing_names = set() + + # From state manager + existing_names.update(self._state_manager.list_event_names()) + + # From active jobs + for job in self._jobs.values(): + if job.is_active: + if job.params.get("name"): + existing_names.add(job.params["name"]) + for f in job.params.get("files", []): + if f.get("name"): + existing_names.add(f["name"]) + + # Find a unique name + if base_name not in existing_names: + return base_name + + counter = 1 + while f"{base_name}_{counter}" in existing_names: + counter += 1 + + return f"{base_name}_{counter}" + + # ========================================================================= + # Job Submission + # ========================================================================= + + def _reserve_submission(self, capability_count: int) -> None: + with self._capacity_lock: + if self._reserved_jobs >= MAX_ACTIVE_JOBS: + raise RuntimeError("The background job queue is full") + if ( + self._retained_capabilities + capability_count + > MAX_RETAINED_CAPABILITIES + ): + raise RuntimeError("The background input capacity is full") + self._reserved_jobs += 1 + self._retained_capabilities += capability_count + + def _release_submission(self, capability_count: int) -> None: + with self._capacity_lock: + self._reserved_jobs = max(0, self._reserved_jobs - 1) + self._retained_capabilities = max( + 0, self._retained_capabilities - capability_count + ) + + def _resource_owner( + self, + stack: ExitStack, + private: Dict[str, Any], + capability_count: int, + ) -> _JobResources: + return _JobResources( + stack, + private, + lambda: self._release_submission(capability_count), + ) + + def _start_job(self, job: Job) -> bool: + with self._lock: + self._broadcast_created_once_locked(job) + if job.status != JobStatus.PENDING: + return False + job.start() + self._broadcast_update("job_started", job) + return True + + def _broadcast_created_once_locked(self, job: Job) -> None: + if job.id in self._created_job_ids: + return + self._created_job_ids.add(job.id) + self._broadcast_update("job_created", job) + + def _update_running_job( + self, + job: Job, + progress: float, + message: str, + completed_items: int | None = None, + ) -> bool: + with self._lock: + if job.status != JobStatus.RUNNING: + return False + job.update_progress(progress, message, completed_items) + self._broadcast_update("job_progress", job) + return True + + def _complete_running_job(self, job: Job, result: Dict[str, Any] | None) -> bool: + with self._lock: + if job.status != JobStatus.RUNNING: + return False + job.complete(result) + self._broadcast_update("job_completed", job) + return True + + def _fail_active_job(self, job: Job, message: str) -> bool: + with self._lock: + if job.status not in (JobStatus.PENDING, JobStatus.RUNNING): + return False + job.fail(message) + self._broadcast_update("job_failed", job) + return True + + @staticmethod + def _pin_read( + stack: ExitStack, + file_path: str, + file_grant: str | None, + ) -> GrantedReadFile: + if not file_grant: + raise PermissionError( + "A native read grant is required for the selected file" + ) + try: + return stack.enter_context(open_verified_read_grant(file_path, file_grant)) + except Exception: + raise PermissionError( + "The selected file could not be verified; select it again" + ) from None + + def _schedule( + self, + job: Job, + resources: _JobResources, + target, + ) -> Job: + with self._lock: + self._jobs[job.id] = job + self._resources[job.id] = resources + self._futures[job.id] = _SUBMITTING + self._cleanup_old_jobs() + try: + future = self._executor.submit(target, job) + except Exception: + with self._lock: + self._jobs.pop(job.id, None) + self._resources.pop(job.id, None) + if self._futures.get(job.id) is _SUBMITTING: + self._futures.pop(job.id, None) + self._created_job_ids.discard(job.id) + resources.close() + raise + with self._lock: + self._futures[job.id] = future + future.add_done_callback( + lambda completed: self._on_job_complete(job.id, completed) + ) + was_cancelled = job.status == JobStatus.CANCELLED + self._broadcast_created_once_locked(job) + if was_cancelled: + self._broadcast_update("job_cancelled", job) + if was_cancelled: + future.cancel() + return job + + def _private_resources(self, job_id: str) -> Dict[str, Any]: + with self._lock: + resources = self._resources.get(job_id) + if resources is None: + raise RuntimeError("Private job resources are unavailable") + return resources.private + + def submit_load_job( + self, + file_path: str, + name: str, + fmt: str = "ogip", + rmf_file: Optional[str] = None, + additional_columns: Optional[List[str]] = None, + high_precision: bool = False, + skip_checks: bool = False, + notes: Optional[str] = None, + use_partial_loading: bool = False, + partial_mode: str = "time_range", + time_range_start: Optional[float] = None, + time_range_end: Optional[float] = None, + event_start_index: Optional[int] = None, + event_count: Optional[int] = None, + file_grant: str | None = None, + rmf_grant: str | None = None, + ) -> Job: + """Pin all local inputs before scheduling a single load.""" + fmt = require_input_event_format(fmt) + name_error = validate_derived_name(name) + if name_error: + raise ValueError(name_error) + if (rmf_file is None) != (rmf_grant is None): + raise ValueError("rmf_file and rmf_grant must be provided together") + + capability_count = 1 + int(rmf_file is not None) + self._reserve_submission(capability_count) + stack = ExitStack() + try: + file_source = self._pin_read(stack, file_path, file_grant) + rmf_source = ( + self._pin_read(stack, rmf_file, rmf_grant) + if rmf_file is not None + else None + ) + except Exception: + stack.close() + self._release_submission(capability_count) + raise + + job = Job( + type=JobType.LOAD_EVENT_LIST, + display_name=f"Load {name}", + params={ + "name": name, + "fmt": fmt, + "additional_columns": additional_columns, + "high_precision": high_precision, + "skip_checks": skip_checks, + "notes": notes, + "use_partial_loading": use_partial_loading, + "partial_mode": partial_mode, + "time_range_start": time_range_start, + "time_range_end": time_range_end, + "event_start_index": event_start_index, + "event_count": event_count, + }, + ) + resources = self._resource_owner( + stack, + { + "file_path": file_path, + "file_source": file_source, + "rmf_file": rmf_file, + "rmf_source": rmf_source, + }, + capability_count, + ) + scheduled = self._schedule(job, resources, self._execute_load_job) + logger.info("Submitted local load job %s", job.id) + return scheduled + + def submit_batch_load_job( + self, + files: List[Dict[str, Any]], + use_same_settings: bool = True, + shared_fmt: str = "ogip", + shared_rmf_file: Optional[str] = None, + shared_additional_columns: Optional[List[str]] = None, + shared_high_precision: bool = False, + shared_skip_checks: bool = False, + shared_use_partial_loading: bool = False, + shared_partial_mode: str = "time_range", + shared_time_range_start: Optional[float] = None, + shared_time_range_end: Optional[float] = None, + shared_event_start_index: Optional[int] = None, + shared_event_count: Optional[int] = None, + shared_rmf_grant: str | None = None, + ) -> Job: + """Pin every batch input before scheduling any scientific work.""" + files, shared_fmt = require_batch_input_formats(files, shared_fmt) + for item in files: + name_error = validate_derived_name(item.get("name", "")) + if name_error: + raise ValueError(name_error) + if (shared_rmf_file is None) != (shared_rmf_grant is None): + raise ValueError( + "shared_rmf_file and shared_rmf_grant must be provided together" + ) + + capability_count = len(files) + if use_same_settings: + capability_count += int(shared_rmf_file is not None) + else: + capability_count += sum(item.get("rmf_file") is not None for item in files) + self._reserve_submission(capability_count) + stack = ExitStack() + retained_files: List[GrantedReadFile] = [] + retained_rmfs: List[GrantedReadFile | None] = [] + private_files: List[Dict[str, Any]] = [] + try: + shared_source = ( + self._pin_read(stack, shared_rmf_file, shared_rmf_grant) + if use_same_settings and shared_rmf_file is not None + else None + ) + for item in files: + retained_files.append( + self._pin_read(stack, item["file_path"], item.get("file_grant")) + ) + if use_same_settings: + retained_rmfs.append(shared_source) + else: + rmf_path = item.get("rmf_file") + rmf_grant = item.get("rmf_grant") + if (rmf_path is None) != (rmf_grant is None): + raise ValueError( + "rmf_file and rmf_grant must be provided together" + ) + retained_rmfs.append( + self._pin_read(stack, rmf_path, rmf_grant) + if rmf_path is not None + else None + ) + private_files.append( + { + key: value + for key, value in item.items() + if key not in {"file_grant", "rmf_grant"} + } + ) + except Exception: + stack.close() + self._release_submission(capability_count) + raise + + job = Job( + type=JobType.LOAD_BATCH, + display_name=f"Batch load ({len(files)} files)", + total_items=len(files), + params={ + "files": [{"name": item.get("name", "")} for item in files], + "use_same_settings": use_same_settings, + "shared_fmt": shared_fmt, + "shared_additional_columns": shared_additional_columns, + "shared_high_precision": shared_high_precision, + "shared_skip_checks": shared_skip_checks, + "shared_use_partial_loading": shared_use_partial_loading, + "shared_partial_mode": shared_partial_mode, + "shared_time_range_start": shared_time_range_start, + "shared_time_range_end": shared_time_range_end, + "shared_event_start_index": shared_event_start_index, + "shared_event_count": shared_event_count, + }, + ) + resources = self._resource_owner( + stack, + { + "files": private_files, + "file_sources": retained_files, + "rmf_sources": retained_rmfs, + "shared_rmf_file": shared_rmf_file, + "shared_rmf_source": shared_source, + }, + capability_count, + ) + scheduled = self._schedule(job, resources, self._execute_batch_load_job) + logger.info("Submitted batch load job %s for %d files", job.id, len(files)) + return scheduled + + def submit_url_load_job( + self, + url: str, + name: str, + fmt: str = "ogip", + rmf_file: Optional[str] = None, + additional_columns: Optional[List[str]] = None, + high_precision: bool = False, + skip_checks: bool = False, + notes: Optional[str] = None, + rmf_grant: str | None = None, + ) -> Job: + """Retain any local RMF while keeping the URL out of public job state.""" + fmt = require_input_event_format(fmt) + name_error = validate_derived_name(name) + if name_error: + raise ValueError(name_error) + if (rmf_file is None) != (rmf_grant is None): + raise ValueError("rmf_file and rmf_grant must be provided together") + + capability_count = int(rmf_file is not None) + self._reserve_submission(capability_count) + stack = ExitStack() + try: + rmf_source = ( + self._pin_read(stack, rmf_file, rmf_grant) + if rmf_file is not None + else None + ) + except Exception: + stack.close() + self._release_submission(capability_count) + raise + + job = Job( + type=JobType.LOAD_FROM_URL, + display_name=f"Remote load {name}", + params={ + "name": name, + "fmt": fmt, + "additional_columns": additional_columns, + "high_precision": high_precision, + "skip_checks": skip_checks, + "notes": notes, + }, + ) + resources = self._resource_owner( + stack, + { + "url": url, + "rmf_file": rmf_file, + "rmf_source": rmf_source, + }, + capability_count, + ) + scheduled = self._schedule(job, resources, self._execute_url_load_job) + logger.info("Submitted remote load job %s", job.id) + return scheduled + + # ========================================================================= + # Job Execution + # ========================================================================= + + def _execute_load_job(self, job: Job) -> None: + """Execute a single file job using only private pinned resources.""" + if not self._start_job(job): + return + try: + params = job.params + private = self._private_resources(job.id) + if not self._update_running_job(job, 0.1, "Loading selected event file..."): + return + + if params.get("use_partial_loading"): + if params.get("partial_mode") == "time_range": + result = self._data_service.load_event_list_by_time_range( + file_path=private["file_path"], + name=params["name"], + start_time=params["time_range_start"], + end_time=params["time_range_end"], + fmt=params["fmt"], + notes=params.get("notes"), + _file_source=private["file_source"], + _cancellation_check=lambda: job.status == JobStatus.CANCELLED, + ) + else: + result = self._data_service.load_event_list_by_event_count( + file_path=private["file_path"], + name=params["name"], + start_index=params.get("event_start_index") or 0, + count=params.get("event_count") or 10000, + fmt=params["fmt"], + notes=params.get("notes"), + _file_source=private["file_source"], + _cancellation_check=lambda: job.status == JobStatus.CANCELLED, + ) + else: + result = self._data_service.load_event_list( + file_path=private["file_path"], + name=params["name"], + fmt=params["fmt"], + rmf_file=private.get("rmf_file"), + additional_columns=params.get("additional_columns"), + high_precision=params.get("high_precision", False), + skip_checks=params.get("skip_checks", False), + notes=params.get("notes"), + _file_source=private["file_source"], + _rmf_source=private.get("rmf_source"), + _cancellation_check=lambda: job.status == JobStatus.CANCELLED, + ) + + if result.get("success"): + if self._complete_running_job(job, result.get("data")): + logger.info("Local load job %s completed", job.id) + else: + if self._fail_active_job( + job, "The selected event file could not be loaded" + ): + logger.error("Local load job %s failed", job.id) + except Exception as error: + self._fail_active_job(job, "The selected event file could not be loaded") + logger.error( + "Local load job %s failed with %s", job.id, type(error).__name__ + ) + + def _execute_batch_load_job(self, job: Job) -> None: + """Execute a batch sequentially so cancellation and shared RMF reads are safe.""" + if not self._start_job(job): + return + try: + params = job.params + private = self._private_resources(job.id) + files = private["files"] + total = len(files) + successful = [] + failed = [] + + for index, file_config in enumerate(files): + name = file_config["name"] + if not self._update_running_job( + job, + index / total, + f"Loading {index + 1}/{total}: {name}", + completed_items=index, + ): + return + + if params.get("use_same_settings"): + fmt = params.get("shared_fmt", "ogip") + rmf_file = private.get("shared_rmf_file") + columns = params.get("shared_additional_columns") + high_precision = params.get("shared_high_precision", False) + skip_checks = params.get("shared_skip_checks", False) + use_partial = params.get("shared_use_partial_loading", False) + partial_mode = params.get("shared_partial_mode", "time_range") + range_start = params.get("shared_time_range_start") + range_end = params.get("shared_time_range_end") + event_start = params.get("shared_event_start_index") + event_count = params.get("shared_event_count") + else: + fmt = file_config.get("fmt", "ogip") + rmf_file = file_config.get("rmf_file") + columns = file_config.get("additional_columns") + high_precision = file_config.get("high_precision", False) + skip_checks = file_config.get("skip_checks", False) + use_partial = file_config.get("use_partial_loading", False) + partial_mode = file_config.get("partial_mode", "time_range") + range_start = file_config.get("time_range_start") + range_end = file_config.get("time_range_end") + event_start = file_config.get("event_start_index") + event_count = file_config.get("event_count") + notes = file_config.get("notes") + + try: + if use_partial and partial_mode == "time_range": + result = self._data_service.load_event_list_by_time_range( + file_path=file_config["file_path"], + name=name, + start_time=range_start, + end_time=range_end, + fmt=fmt, + notes=notes, + _file_source=private["file_sources"][index], + _cancellation_check=lambda: job.status + == JobStatus.CANCELLED, + ) + elif use_partial: + result = self._data_service.load_event_list_by_event_count( + file_path=file_config["file_path"], + name=name, + start_index=event_start or 0, + count=event_count or 10000, + fmt=fmt, + notes=notes, + _file_source=private["file_sources"][index], + _cancellation_check=lambda: job.status + == JobStatus.CANCELLED, + ) + else: + result = self._data_service.load_event_list( + file_path=file_config["file_path"], + name=name, + fmt=fmt, + rmf_file=rmf_file, + additional_columns=columns, + high_precision=high_precision, + skip_checks=skip_checks, + notes=notes, + _file_source=private["file_sources"][index], + _rmf_source=private["rmf_sources"][index], + _cancellation_check=lambda: job.status + == JobStatus.CANCELLED, + ) + if result.get("success"): + successful.append({"name": name, "data": result.get("data")}) + else: + failed.append( + { + "name": name, + "error": "The selected file could not be loaded", + } + ) + except Exception as error: + failed.append( + { + "name": name, + "error": "The selected file could not be loaded", + } + ) + logger.error( + "Batch job %s item failed with %s", + job.id, + type(error).__name__, + ) + + if not self._update_running_job( + job, 1.0, "Complete", completed_items=total + ): + return + result = { + "successful": successful, + "failed": failed, + "success_count": len(successful), + "failure_count": len(failed), + "total_files": total, + } + if not successful and failed: + self._fail_active_job(job, f"All {total} selected files failed to load") + else: + self._complete_running_job(job, result) + logger.info( + "Batch job %s finished with %d/%d successful", + job.id, + len(successful), + total, + ) + except Exception as error: + self._fail_active_job(job, "The selected batch could not be loaded") + logger.error("Batch job %s failed with %s", job.id, type(error).__name__) + + def _execute_url_load_job(self, job: Job) -> None: + """Execute a private bounded remote-source job.""" + if not self._start_job(job): + return + try: + params = job.params + private = self._private_resources(job.id) + if not self._update_running_job( + job, 0.1, "Downloading selected remote source..." + ): + return + result = self._data_service.load_event_list_from_url( + url=private["url"], + name=params["name"], + fmt=params["fmt"], + rmf_file=private.get("rmf_file"), + additional_columns=params.get("additional_columns"), + high_precision=params.get("high_precision", False), + skip_checks=params.get("skip_checks", False), + notes=params.get("notes"), + _rmf_source=private.get("rmf_source"), + _cancellation_check=lambda: job.status == JobStatus.CANCELLED, + ) + if result.get("success"): + if self._complete_running_job(job, result.get("data")): + logger.info("Remote load job %s completed", job.id) + else: + if self._fail_active_job( + job, "The remote event source could not be loaded" + ): + logger.error("Remote load job %s failed", job.id) + except Exception as error: + self._fail_active_job(job, "The remote event source could not be loaded") + logger.error( + "Remote load job %s failed with %s", job.id, type(error).__name__ + ) + + def _on_job_complete(self, job_id: str, future: Future) -> None: + """Callback when a job future completes.""" + with self._lock: + self._futures.pop(job_id, None) + resource = self._resources.pop(job_id, None) + if resource is not None: + resource.close() + + if future.cancelled(): + return + + # Handle any unexpected exceptions from the future + try: + future.result() # Will re-raise any exception + except Exception as error: + job = self.get_job(job_id) + if job and job.is_active: + self._fail_active_job(job, "The background job failed unexpectedly") + logger.error("Job %s future raised %s", job_id, type(error).__name__) + + # ========================================================================= + # SSE Streaming + # ========================================================================= + + def _broadcast_update(self, event_type: str, job: Job) -> None: + """Broadcast a job update to all SSE connections.""" + update = { + "type": event_type, + "timestamp": datetime.now(timezone.utc).isoformat(), + "job": job.to_dict(), + } + + try: + self._update_queue.put_nowait(update) + except queue.Full: + # Queue full, drop oldest and try again + try: + self._update_queue.get_nowait() + self._update_queue.put_nowait(update) + except queue.Empty: + pass + + async def stream_updates( + self, + heartbeat_interval: float = 30.0, + ) -> AsyncGenerator[Dict[str, Any], None]: + """ + Async generator that yields job updates for SSE streaming. + + Includes periodic heartbeat events to keep the connection alive. + + Args: + heartbeat_interval: Seconds between heartbeat events + + Yields: + Job update dictionaries ready for JSON serialization + """ + self._active_connections += 1 + last_heartbeat = asyncio.get_event_loop().time() + + # First, send current state of all active jobs + active_jobs = self.get_active_jobs() + if active_jobs: + yield { + "type": "initial_state", + "timestamp": datetime.now(timezone.utc).isoformat(), + "jobs": [job.to_dict() for job in active_jobs], + } + + try: + while True: + # Check for updates in the queue + try: + update = self._update_queue.get_nowait() + yield update + last_heartbeat = asyncio.get_event_loop().time() + except queue.Empty: + # No updates, check if we need a heartbeat + current_time = asyncio.get_event_loop().time() + if current_time - last_heartbeat >= heartbeat_interval: + yield { + "type": "heartbeat", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + last_heartbeat = current_time + + # Small sleep to yield control + await asyncio.sleep(0.1) + + finally: + self._active_connections -= 1 + + @property + def active_connections(self) -> int: + """Get the number of active SSE connections.""" + return self._active_connections + + +# Global singleton instance (initialized in main.py lifespan) +job_manager: Optional[JobManager] = None diff --git a/python-backend/services/lightcurve_service.py b/python-backend/services/lightcurve_service.py new file mode 100644 index 0000000..bc0802f --- /dev/null +++ b/python-backend/services/lightcurve_service.py @@ -0,0 +1,310 @@ +""" +Lightcurve service for lightcurve operations. + +Handles creation and manipulation of lightcurves. +""" + +from typing import Any, Dict, List, Optional + +import numpy as np +from stingray import Lightcurve + +from .base_service import BaseService + +# Cap on points transferred for plotting. The full-resolution Lightcurve stays +# in StateManager; only the JSON payload is strided. +DEFAULT_MAX_PLOT_POINTS = 200_000 + + +def _decimate_for_plot(time, counts, max_points): + """Stride-decimate arrays for display. Returns (time, counts, stride). + + max_points: Cap on points in the JSON payload; None or 0 sends full resolution. + """ + n = len(time) + if not max_points or max_points < 0 or n <= max_points: + return time, counts, 1 + stride = int(np.ceil(n / max_points)) + return time[::stride], counts[::stride], stride + + +class LightcurveService(BaseService): + """ + Service for Lightcurve operations. + + Handles creation and manipulation of lightcurves without any UI dependencies. + """ + + def create_lightcurve_from_event_list( + self, + event_list_name: str, + dt: float, + output_name: str, + gti: Optional[List[List[float]]] = None, + max_points: Optional[int] = DEFAULT_MAX_PLOT_POINTS, + ) -> Dict[str, Any]: + """ + Create a Lightcurve from an EventList. + + Args: + event_list_name: Name of the EventList in state + dt: Time binning in seconds + output_name: Name to save the lightcurve as + gti: Optional Good Time Intervals + max_points: Cap on points in the JSON payload; None or 0 sends full resolution. + + Returns: + Result dictionary with lightcurve data + """ + try: + if not self.state.has_event_data(event_list_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_name}' not found", + error=None, + ) + + event_list = self.state.get_event_data(event_list_name) + + # Create lightcurve from event list + lc = event_list.to_lc(dt=dt) + + # Apply GTIs if provided + if gti is not None: + gti_array = np.array(gti) + lc = lc.apply_gtis(gti_array) + + # Save to state + self.state.add_lightcurve_data(output_name, lc) + + # Prepare response data + plot_time, plot_counts, stride = _decimate_for_plot( + lc.time, lc.counts, max_points + ) + lc_data = { + "name": output_name, + "time": plot_time.astype(float).tolist(), + "counts": plot_counts.astype(float).tolist(), + "dt": float(lc.dt), + "n_bins": len(lc.time), + "plot_stride": stride, + "time_range": [float(lc.time.min()), float(lc.time.max())], + "count_rate_mean": float(np.mean(lc.counts / lc.dt)), + } + + return self.create_result( + success=True, + data=lc_data, + message=f"Lightcurve '{output_name}' created (dt={dt}s, {len(lc.time)} bins)", + ) + + except Exception as e: + return self.handle_error( + e, "Creating lightcurve", event_list=event_list_name, dt=dt + ) + + def create_lightcurve_from_arrays( + self, + times: List[float], + counts: List[float], + dt: float, + output_name: str, + ) -> Dict[str, Any]: + """ + Create a Lightcurve from time and count arrays. + + Args: + times: Array of time values + counts: Array of count values + dt: Time binning in seconds + output_name: Name to save the lightcurve as + + Returns: + Result dictionary with lightcurve data + """ + try: + times_arr = np.array(times) + counts_arr = np.array(counts) + + lc = Lightcurve(times_arr, counts_arr, dt=dt, skip_checks=True) + + # Save to state + self.state.add_lightcurve_data(output_name, lc) + + lc_data = { + "name": output_name, + "time": lc.time.astype(float).tolist(), + "counts": lc.counts.astype(float).tolist(), + "dt": float(lc.dt), + "n_bins": len(lc.time), + } + + return self.create_result( + success=True, + data=lc_data, + message=f"Lightcurve '{output_name}' created from arrays", + ) + + except Exception as e: + return self.handle_error(e, "Creating lightcurve from arrays", dt=dt) + + def rebin_lightcurve( + self, + name: str, + rebin_factor: float, + output_name: str, + max_points: Optional[int] = DEFAULT_MAX_PLOT_POINTS, + ) -> Dict[str, Any]: + """ + Rebin a lightcurve. + + Args: + name: Name of the lightcurve to rebin + rebin_factor: Rebinning factor + output_name: Name for the rebinned lightcurve + max_points: Cap on points in the JSON payload; None or 0 sends full resolution. + + Returns: + Result dictionary with rebinned lightcurve data + """ + try: + if not self.state.has_lightcurve_data(name): + return self.create_result( + success=False, + data=None, + message=f"Lightcurve '{name}' not found", + error=None, + ) + + lightcurve = self.state.get_lightcurve_data(name) + # stingray's first positional param is dt_new (absolute); we promise + # factor semantics, so pass f= explicitly. + rebinned_lc = lightcurve.rebin(f=rebin_factor) + + # Save to state + self.state.add_lightcurve_data(output_name, rebinned_lc) + + plot_time, plot_counts, stride = _decimate_for_plot( + rebinned_lc.time, rebinned_lc.counts, max_points + ) + lc_data = { + "name": output_name, + "time": plot_time.astype(float).tolist(), + "counts": plot_counts.astype(float).tolist(), + "dt": float(rebinned_lc.dt), + "n_bins": len(rebinned_lc.time), + "plot_stride": stride, + "count_rate_mean": float(np.mean(rebinned_lc.counts / rebinned_lc.dt)), + } + + return self.create_result( + success=True, + data=lc_data, + message=f"Lightcurve rebinned (factor={rebin_factor})", + ) + + except Exception as e: + return self.handle_error( + e, "Rebinning lightcurve", name=name, rebin_factor=rebin_factor + ) + + def get_lightcurve_data( + self, name: str, max_points: Optional[int] = DEFAULT_MAX_PLOT_POINTS + ) -> Dict[str, Any]: + """ + Get lightcurve data for plotting. + + Args: + name: Name of the lightcurve + max_points: Cap on points in the JSON payload; None or 0 sends full resolution. + + Returns: + Result dictionary with lightcurve data + """ + try: + if not self.state.has_lightcurve_data(name): + return self.create_result( + success=False, + data=None, + message=f"Lightcurve '{name}' not found", + error=None, + ) + + lc = self.state.get_lightcurve_data(name) + + plot_time, plot_counts, stride = _decimate_for_plot( + lc.time, lc.counts, max_points + ) + lc_data = { + "name": name, + "time": plot_time.astype(float).tolist(), + "counts": plot_counts.astype(float).tolist(), + "dt": float(lc.dt), + "n_bins": len(lc.time), + "plot_stride": stride, + "time_range": [float(lc.time.min()), float(lc.time.max())], + "count_rate_mean": float(np.mean(lc.counts / lc.dt)), + "count_stats": { + "mean": float(np.mean(lc.counts)), + "std": float(np.std(lc.counts)), + "min": float(np.min(lc.counts)), + "max": float(np.max(lc.counts)), + }, + } + + return self.create_result( + success=True, + data=lc_data, + message=f"Lightcurve '{name}' data retrieved", + ) + + except Exception as e: + return self.handle_error(e, "Getting lightcurve data", name=name) + + def list_lightcurves(self) -> Dict[str, Any]: + """List all loaded lightcurves.""" + try: + lc_data = self.state.get_lightcurve_data() + + summaries = [] + for name, lc in lc_data: + summaries.append( + { + "name": name, + "n_bins": len(lc.time), + "dt": float(lc.dt), + "time_range": [float(lc.time.min()), float(lc.time.max())], + } + ) + + return self.create_result( + success=True, + data=summaries, + message=f"Found {len(summaries)} lightcurve(s)", + ) + + except Exception as e: + return self.handle_error(e, "Listing lightcurves") + + def delete_lightcurve(self, name: str) -> Dict[str, Any]: + """Delete a lightcurve from state.""" + try: + if not self.state.has_lightcurve_data(name): + return self.create_result( + success=False, + data=None, + message=f"Lightcurve '{name}' not found", + error=None, + ) + + self.state.remove_lightcurve_data(name) + + return self.create_result( + success=True, + data={"name": name}, + message=f"Lightcurve '{name}' deleted", + ) + + except Exception as e: + return self.handle_error(e, "Deleting lightcurve", name=name) diff --git a/python-backend/services/misc_service.py b/python-backend/services/misc_service.py new file mode 100644 index 0000000..725ba7f --- /dev/null +++ b/python-backend/services/misc_service.py @@ -0,0 +1,1859 @@ +"""Curated public ``stingray.utils`` helpers for the Utilities workbench. + +The service deliberately exposes a small, typed surface rather than arbitrary +function dispatch. Every numerical result is produced by Stingray's public +API; this module adds bounded allocation, defensive validation, provenance and +JSON-safe presentation around those calls. +""" + +from __future__ import annotations + +import ast +import inspect +import math +import textwrap +from typing import Any, Iterable, Optional + +import numpy as np +import stingray +from stingray.utils import ( + baseline_als, + create_window, + equal_count_energy_ranges, + fix_segment_size_to_integer_samples, + nearest_power_of_two, + optimal_bin_time, + poisson_symmetrical_errors, + rebin_data, + rebin_data_log, + standard_error as stingray_standard_error, +) + +from .analysis_helpers import collect_warnings +from .base_service import BaseService +from .utility_helpers import ( + MAX_ARRAY_INPUT, + MAX_EXACT_OUTPUT, + MAX_MATRIX_CELLS, + MAX_STATE_SNAPSHOT_BYTES, + MAX_STATE_SNAPSHOT_CELLS, + bounded_plot_preview, + json_safe, + operation_provenance, + validate_finite_array, +) + +MAX_BASELINE_ITERATIONS = 100 +MAX_ENERGY_RANGES = 1_000 +MAX_FFT_SAMPLES = 2**24 +MAX_POISSON_LOOKUP_COUNT = MAX_MATRIX_CELLS + + +def _derive_supported_windows() -> tuple[str, ...]: + """Extract the exact allowlist used by the installed public function. + + ``create_window`` has no public capabilities function. Its implementation + does, however, define one local ``windows`` list which is the authoritative + validation source (including upstream's historical ``blackmann`` spelling). + Parsing that literal avoids maintaining a second list that can drift from + the installed Stingray version. + """ + + try: + tree = ast.parse(textwrap.dedent(inspect.getsource(create_window))) + except (OSError, TypeError, SyntaxError): + tree = None + + if tree is not None: + for node in ast.walk(tree): + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if not any( + isinstance(target, ast.Name) and target.id == "windows" + for target in targets + ): + continue + value = node.value + if not isinstance(value, (ast.List, ast.Tuple)): + break + names = tuple( + element.value + for element in value.elts + if isinstance(element, ast.Constant) and isinstance(element.value, str) + ) + if names and len(names) == len(value.elts): + return names + break + + # Frozen/PyInstaller builds commonly retain bytecode while omitting + # retrievable source. The literal allowlist remains in ``co_consts``; + # verify each candidate through the public API before accepting it. + code = getattr(create_window, "__code__", None) + for constant in getattr(code, "co_consts", ()): + if not ( + isinstance(constant, (tuple, list)) + and constant + and all(isinstance(item, str) for item in constant) + ): + continue + candidate = tuple(constant) + try: + for name in candidate: + create_window(2, name) + except (TypeError, ValueError, IndexError): + continue + return candidate + raise RuntimeError( + "The installed create_window implementation has no derivable window allowlist" + ) + + +SUPPORTED_WINDOWS = _derive_supported_windows() + + +def _legacy_linear_rebin_uncertainty_behavior() -> bool: + """Characterize whether installed ``rebin_data`` expects variances. + + Stingray 2.2.10 square-roots the *sum* of the values passed as ``yerr``. + Upstream PR #953 changed it to square standard uncertainties before that + sum. A tiny public-API characterization keeps the workaround correct if + the environment is later upgraded to a release containing the fix. + """ + + _, _, error, _ = rebin_data( + np.asarray([0.5, 1.5]), + np.asarray([0.0, 0.0]), + 2.0, + yerr=np.asarray([2.0, 2.0]), + method="sum", + dx=1.0, + ) + return bool(np.isclose(np.asarray(error, dtype=float)[0], 2.0)) + + +LINEAR_REBIN_NEEDS_VARIANCE_INPUT = _legacy_linear_rebin_uncertainty_behavior() + + +def _finite_scalar( + value: Any, + label: str, + *, + minimum: Optional[float] = None, + maximum: Optional[float] = None, + minimum_inclusive: bool = True, + maximum_inclusive: bool = True, +) -> tuple[Optional[float], Optional[str]]: + """Validate a real scalar without accepting booleans as numbers.""" + + if isinstance(value, (bool, np.bool_)): + return None, f"{label} must be a finite number, not a boolean" + try: + result = float(value) + except (TypeError, ValueError, OverflowError): + return None, f"{label} must be a finite number" + if not math.isfinite(result): + return None, f"{label} must be finite" + if minimum is not None: + bad = result < minimum if minimum_inclusive else result <= minimum + if bad: + operator = ">=" if minimum_inclusive else ">" + return None, f"{label} must be {operator} {minimum}" + if maximum is not None: + bad = result > maximum if maximum_inclusive else result >= maximum + if bad: + operator = "<=" if maximum_inclusive else "<" + return None, f"{label} must be {operator} {maximum}" + return result, None + + +def _bounded_integer( + value: Any, + label: str, + *, + minimum: int, + maximum: int, +) -> tuple[Optional[int], Optional[str]]: + """Validate an exact integer with an explicit inclusive range.""" + + if isinstance(value, (bool, np.bool_)): + return None, f"{label} must be an integer between {minimum} and {maximum}" + try: + numeric = float(value) + except (TypeError, ValueError, OverflowError): + return None, f"{label} must be an integer between {minimum} and {maximum}" + if not math.isfinite(numeric) or not numeric.is_integer(): + return None, f"{label} must be an integer between {minimum} and {maximum}" + integer = int(numeric) + if integer < minimum or integer > maximum: + return None, f"{label} must be between {minimum} and {maximum}" + return integer, None + + +def _named_preview(names: Iterable[str], *arrays: Iterable[Any]) -> dict[str, Any]: + """Name the aligned arrays returned by the shared bounded preview helper.""" + + raw = bounded_plot_preview(*arrays) + return { + "values": dict(zip(names, raw["arrays"])), + "stride": raw["stride"], + "source_points": raw["source_points"], + } + + +def _uniform_spacing_match( + x: np.ndarray, + spacing: float, +) -> tuple[bool, bool]: + """Check a nominally uniform grid without mistaking float ULPs for jitter. + + Subtracting two large, nearby coordinates can only represent their spacing + to roughly the ULP of those coordinates. The tolerance below combines the + service's ordinary relative tolerance with the IEEE-754 rounding bound for + both stored endpoints and their subtraction. ``ulp_accommodation_used`` + tells callers when the grid passes only because that representational bound + is necessary, so the normalization can be disclosed to the user. + """ + + observed = np.diff(x) + ordinary_tolerance = 1e-10 * abs(spacing) + 16 * np.finfo(float).eps * max( + 1.0, abs(spacing) + ) + endpoint_roundoff = 0.5 * (np.abs(np.spacing(x[:-1])) + np.abs(np.spacing(x[1:]))) + subtraction_roundoff = 0.5 * np.abs(np.spacing(observed)) + ulp_tolerance = endpoint_roundoff + subtraction_roundoff + error = np.abs(observed - spacing) + matches_ordinary = bool(np.all(error <= ordinary_tolerance)) + matches_with_ulps = bool( + np.all(error <= np.maximum(ordinary_tolerance, ulp_tolerance)) + ) + return matches_with_ulps, matches_with_ulps and not matches_ordinary + + +def _normalization_scale(values: np.ndarray) -> float: + """Return a finite homogeneous scale that avoids square/sum overflow.""" + + maximum = float(np.max(np.abs(values))) + return maximum if maximum > 0.0 else 1.0 + + +def _normalize_finite_input( + values: np.ndarray, + scale: float, + *, + label: str, +) -> tuple[Optional[np.ndarray], Optional[str]]: + """Apply homogeneous conditioning without erasing nonzero input values.""" + + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + normalized = np.asarray(values, dtype=float) / scale + if np.any((values != 0.0) & (normalized == 0.0)): + return ( + None, + f"{label} spans too wide a finite dynamic range to condition without " + "losing nonzero values; the result was withheld", + ) + return normalized, None + + +def _restore_scaled_output( + values: Any, + scale: float, + *, + label: str, +) -> tuple[Optional[np.ndarray], Optional[str]]: + """Undo homogeneous conditioning and fail closed on range overflow.""" + + normalized = np.asarray(values, dtype=float) + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + restored = normalized * scale + underflowed = (normalized != 0.0) & (restored == 0.0) + if not np.all(np.isfinite(restored)) or np.any(underflowed): + return ( + None, + f"{label} is outside the finite float range; the result was withheld", + ) + return restored, None + + +class MiscService(BaseService): + """Service wrapper around the supported public ``stingray.utils`` tools.""" + + def _invalid(self, message: str) -> dict[str, Any]: + return self.create_result(success=False, message=message, error=None) + + def _success( + self, + data: dict[str, Any], + message: str, + warnings: Optional[list[str]] = None, + ) -> dict[str, Any]: + warning_list = list(dict.fromkeys(warnings or [])) + safe_data = json_safe(data, warning_list) + safe_data["warnings"] = list(dict.fromkeys(warning_list)) + return self.create_result( + success=True, data=safe_data, message=message, error=None + ) + + @staticmethod + def _xy_arrays( + x: Iterable[Any], + y: Iterable[Any], + *, + min_size: int, + ) -> tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[str]]: + x_array, error = validate_finite_array(x, label="x", min_size=1) + if error: + return None, None, error + y_array, error = validate_finite_array(y, label="y", min_size=1) + if error: + return None, None, error + assert x_array is not None and y_array is not None + if x_array.shape != y_array.shape: + return None, None, "x and y must have the same length" + if x_array.size < min_size: + return ( + None, + None, + f"x and y must contain at least {min_size} values each", + ) + bad_spacing = np.flatnonzero(np.diff(x_array) <= 0) + if bad_spacing.size: + index = int(bad_spacing[0]) + return ( + None, + None, + f"x must be strictly increasing; x[{index + 1}] is not greater than x[{index}]", + ) + return x_array, y_array, None + + def capabilities(self) -> dict[str, Any]: + """Return installed capabilities, defaults and allocation limits.""" + + data = { + "window_types": list(SUPPORTED_WINDOWS), + "rebin": { + "modes": ["linear", "logarithmic"], + "linear_methods": ["sum", "mean"], + "logarithmic_method": "mean", + "linear_uncertainty_workaround_required": ( + LINEAR_REBIN_NEEDS_VARIANCE_INPUT + ), + "linear_uncertainty_workaround_reference": ( + "StingraySoftware/stingray#953" + ), + "linear_uncertainty_support": ( + "uniform x spacing and an integer dx_new / dx ratio only; " + "fractional-overlap uncertainty weights are not squared upstream" + ), + }, + "baseline_defaults": { + "lambda": 1e11, + "asymmetry": 0.001, + "iterations": 10, + "offset_correction": False, + }, + "limits": { + "max_array_values": MAX_ARRAY_INPUT, + "max_exact_output_values": MAX_EXACT_OUTPUT, + "max_matrix_cells": MAX_MATRIX_CELLS, + "max_baseline_iterations": MAX_BASELINE_ITERATIONS, + "max_fft_samples": MAX_FFT_SAMPLES, + "max_poisson_count": MAX_POISSON_LOOKUP_COUNT, + "max_energy_ranges": MAX_ENERGY_RANGES, + }, + "runtime_advisories": { + "nearest_power_of_two": ( + "Results are fail-closed if installed Stingray 2.2.10 returns " + "a value that is not mathematically nearest." + ) + }, + "provenance": operation_provenance( + "misc_capabilities", + input_source={"kind": "installed_runtime"}, + parameters={}, + window_allowlist_source="installed create_window source literal", + ), + } + return self._success(data, "Miscellaneous utility capabilities loaded") + + def linear_rebin( + self, + x: Iterable[Any], + y: Iterable[Any], + dx_new: Any, + *, + y_error: Optional[Iterable[Any]] = None, + method: str = "sum", + dx: Optional[Any] = None, + ) -> dict[str, Any]: + """Linearly rebin x/y data while propagating standard uncertainties.""" + + try: + x_array, y_array, error = self._xy_arrays(x, y, min_size=2) + if error: + return self._invalid(error) + assert x_array is not None and y_array is not None + + resolution, error = _finite_scalar( + dx_new, "dx_new", minimum=0.0, minimum_inclusive=False + ) + if error: + return self._invalid(error) + assert resolution is not None + + if method not in {"sum", "mean"}: + return self._invalid("method must be either 'sum' or 'mean'") + + old_resolution: Optional[float] = None + if dx is not None: + old_resolution, error = _finite_scalar( + dx, "dx", minimum=0.0, minimum_inclusive=False + ) + if error: + return self._invalid(error) + + # Validate whole-interval coverage before calculating any rebin + # factor. Otherwise an extreme but finite ``dx_new`` can overflow + # ``round(dx_new / dx)`` and leak a technical exception before the + # scientifically meaningful "no complete bin" check runs. + tail_resolution = ( + old_resolution + if old_resolution is not None + else float(x_array[-1] - x_array[-2]) + ) + with np.errstate(over="ignore", invalid="ignore"): + covered_span = float((x_array[-1] - x_array[0]) + tail_resolution) + if not math.isfinite(covered_span) or covered_span <= 0: + return self._invalid( + "The covered input span is not representable as a positive " + "finite number" + ) + span_tolerance = 8.0 * max( + abs(float(np.spacing(covered_span))), + abs(float(np.spacing(resolution))), + ) + if resolution > covered_span + span_tolerance: + return self._invalid( + "dx_new is wider than the covered input span, so no complete " + f"output bin fits (dx_new={resolution:g}, covered span={covered_span:g})" + ) + + errors: Optional[np.ndarray] = None + uniform_dx: Optional[float] = None + canonical_rebin_factor: Optional[int] = None + ulp_accommodation_used = False + if y_error is not None: + errors, error = validate_finite_array( + y_error, + label="y_error", + min_size=2, + ) + if error: + return self._invalid(error) + assert errors is not None + if errors.shape != y_array.shape: + return self._invalid("y_error must have the same length as x and y") + bad = np.flatnonzero(errors < 0) + if bad.size: + return self._invalid(f"y_error[{int(bad[0])}] must be non-negative") + + observed_spacing = np.diff(x_array) + if old_resolution is not None: + uniform_dx = old_resolution + spacing_matches, ulp_accommodation_used = _uniform_spacing_match( + x_array, uniform_dx + ) + else: + # The median is robust to the alternating adjacent spacings + # produced when a small cadence is stored on a large offset. + uniform_dx = float(np.median(observed_spacing)) + spacing_matches, ulp_accommodation_used = _uniform_spacing_match( + x_array, uniform_dx + ) + if spacing_matches: + nearest_factor = round(resolution / uniform_dx) + if nearest_factor >= 1: + candidate_dx = resolution / nearest_factor + candidate_matches, candidate_used_ulps = ( + _uniform_spacing_match(x_array, candidate_dx) + ) + if candidate_matches: + uniform_dx = candidate_dx + ulp_accommodation_used = ( + ulp_accommodation_used or candidate_used_ulps + ) + + if not spacing_matches: + return self._invalid( + "y_error propagation is supported only for uniformly spaced x " + "values; an explicit dx must also match that spacing" + ) + assert uniform_dx is not None + rebin_factor = resolution / uniform_dx + if not math.isclose( + rebin_factor, + round(rebin_factor), + rel_tol=1e-10, + abs_tol=1e-12, + ): + return self._invalid( + "y_error propagation requires an integer dx_new / dx ratio. " + "Stingray 2.2.10 does not square fractional-overlap weights, " + "so uncertainty propagation would be scientifically incorrect." + ) + canonical_rebin_factor = int(round(rebin_factor)) + + if errors is None: + candidate_dx = ( + old_resolution + if old_resolution is not None + else float(np.median(np.diff(x_array))) + ) + spacing_matches, candidate_used_ulps = _uniform_spacing_match( + x_array, candidate_dx + ) + candidate_factor = resolution / candidate_dx + if ( + spacing_matches + and candidate_factor >= 1 + and math.isclose( + candidate_factor, + round(candidate_factor), + rel_tol=1e-10, + abs_tol=1e-12, + ) + ): + uniform_dx = candidate_dx + canonical_rebin_factor = int(round(candidate_factor)) + ulp_accommodation_used = candidate_used_ulps + + dx_old = ( + np.diff(x_array) + if old_resolution is None + else np.asarray([old_resolution], dtype=float) + ) + effective_old_resolution = uniform_dx if errors is not None else None + resolution_comparison = ( + np.asarray([effective_old_resolution], dtype=float) + if effective_old_resolution is not None + else dx_old + ) + if np.any(resolution < resolution_comparison): + return self._invalid( + "dx_new must be at least as large as every old x resolution" + ) + + estimated_bins = ( + math.ceil( + (float(x_array[-1] - x_array[0]) + float(dx_old[-1])) / resolution + ) + + 2 + ) + if estimated_bins > MAX_EXACT_OUTPUT: + return self._invalid( + f"linear rebin would allocate about {estimated_bins:,} bins; " + f"the cap is {MAX_EXACT_OUTPUT:,}" + ) + + # Both supported aggregations are homogeneous in y, and propagated + # standard uncertainties are homogeneous in sigma. Normalize before + # the public Stingray call so its intermediate sums and squares do not + # overflow or underflow for otherwise representable finite results. + candidate_scale = _normalization_scale(y_array) + conditioning_boundary = math.sqrt(np.finfo(float).max) + underflow_boundary = math.sqrt(np.finfo(float).tiny) + y_scale = ( + candidate_scale + if candidate_scale > conditioning_boundary + or candidate_scale < underflow_boundary + else 1.0 + ) + normalized_y = y_array / y_scale + error_scale = _normalization_scale(errors) if errors is not None else 1.0 + normalized_errors = errors / error_scale if errors is not None else None + + warning_list: list[str] = [] + stingray_error_input = normalized_errors + workaround_applied = bool( + errors is not None and LINEAR_REBIN_NEEDS_VARIANCE_INPUT + ) + if workaround_applied: + # Stingray 2.2.10 sums yerr and then square-roots it. Supplying + # variances yields sqrt(sum(sigma^2)), exactly the #953 fix, + # after the checks above rule out fractional-overlap weights. + stingray_error_input = np.square(normalized_errors) + warning_list.append( + "Installed Stingray rebin_data treats yerr as variances. " + "Squared standard uncertainties were supplied as the compatibility " + "workaround from upstream PR #953; Stingray controlled the bin " + "boundaries and aggregation for whole, non-overlapping samples." + ) + + x_origin = float(x_array[0]) + coordinate_scale = 1.0 + stingray_resolution = resolution + if canonical_rebin_factor is not None: + assert uniform_dx is not None + # Integer sample coordinates avoid two installed-2.2.10 edge + # defects without changing the public scientific operation: + # large absolute origins create fractional-overlap artifacts, + # and float modulo can misclassify an exact whole-bin span and + # silently discard its final complete bin. + stingray_x = np.arange(x_array.size, dtype=float) + stingray_dx = 1.0 + stingray_resolution = float(canonical_rebin_factor) + coordinate_scale = uniform_dx + if ulp_accommodation_used: + warning_list.append( + "The x values match a uniform grid only after accounting for " + "floating-point ULPs at their absolute scale. Stingray " + "rebin_data was evaluated on an equivalent origin-relative " + "grid to avoid precision-induced fractional overlaps; output " + "coordinates were translated back." + ) + with np.errstate(over="ignore", invalid="ignore"): + raw_span_ratio = ( + x_array[-1] - x_array[0] + uniform_dx + ) / resolution + if ( + x_array.size % canonical_rebin_factor == 0 + and math.isfinite(float(raw_span_ratio)) + and float(raw_span_ratio) % 1 > 0 + ): + warning_list.append( + "The installed Stingray 2.2.10 float-modulo edge check would " + "misclassify this exact whole-bin span. The equivalent integer " + "sample grid was used so no complete trailing bin was discarded." + ) + else: + stingray_x = x_array - x_origin + stingray_dx = old_resolution + + with collect_warnings(warning_list): + x_bin, y_bin, error_bin, samples = rebin_data( + stingray_x, + normalized_y, + stingray_resolution, + yerr=stingray_error_input, + method=method, + dx=stingray_dx, + ) + x_bin = np.asarray(x_bin, dtype=float) * coordinate_scale + x_origin + if not np.all(np.isfinite(x_bin)): + return self._invalid( + "linear-rebin x coordinates are outside the finite float range; " + "the result was withheld" + ) + normalized_y_bin = np.asarray(y_bin, dtype=float) + y_bin, output_error = _restore_scaled_output( + normalized_y_bin, + y_scale, + label="linear-rebin y output", + ) + if output_error: + return self._invalid(output_error) + assert y_bin is not None + normalized_error_bin = np.asarray(error_bin, dtype=float) + if errors is not None: + error_bin, output_error = _restore_scaled_output( + normalized_error_bin, + error_scale, + label="linear-rebin uncertainty output", + ) + if output_error: + return self._invalid(output_error) + assert error_bin is not None + else: + error_bin = normalized_error_bin + + if canonical_rebin_factor is not None: + expected_bins = x_array.size // canonical_rebin_factor + if len(y_bin) != expected_bins: + return self._invalid( + "Installed Stingray returned an unexpected number of complete " + "linear-rebin bins; the result was withheld" + ) + complete_count = expected_bins * canonical_rebin_factor + if complete_count: + grouped = normalized_y[:complete_count].reshape( + expected_bins, canonical_rebin_factor + ) + expected_y = ( + np.sum(grouped, axis=1) + if method == "sum" + else np.mean(grouped, axis=1) + ) + if not np.allclose( + normalized_y_bin, + expected_y, + rtol=1e-12, + atol=1e-14, + ): + return self._invalid( + "Installed Stingray did not preserve the complete uniform " + "input bins; the result was withheld" + ) + + original_preview_arrays: list[np.ndarray] = [x_array, y_array] + original_preview_names = ["x", "y"] + rebinned_preview_arrays: list[np.ndarray] = [x_bin, y_bin] + rebinned_preview_names = ["x", "y"] + if errors is not None: + original_preview_arrays.append(errors) + original_preview_names.append("y_error") + rebinned_preview_arrays.append(error_bin) + rebinned_preview_names.append("y_error") + + parameters = { + "dx_new": resolution, + "dx": old_resolution, + "method": method, + "uncertainties_supplied": errors is not None, + } + data = { + "mode": "linear", + "method": method, + "units": { + "x": "same as input x", + "y": "same as input y", + "y_error": "same as input y", + "samples_per_bin": "input samples", + }, + "original": { + "x": x_array, + "y": y_array, + "y_error": errors, + }, + "rebinned": { + "x": x_bin, + "y": y_bin, + "y_error": error_bin if errors is not None else None, + "samples_per_bin": samples, + }, + "error_semantics": ( + "independent one-standard-deviation uncertainties propagated " + "in quadrature" + if errors is not None + else None + ), + "plot_preview": { + "original": _named_preview( + original_preview_names, *original_preview_arrays + ), + "rebinned": _named_preview( + rebinned_preview_names, *rebinned_preview_arrays + ), + }, + "provenance": operation_provenance( + "linear_rebin", + input_source={"kind": "pasted_values"}, + parameters=parameters, + uncertainty_compatibility={ + "workaround_applied": workaround_applied, + "reference": "StingraySoftware/stingray#953", + "input_to_stingray": ( + "variance" if workaround_applied else "standard_uncertainty" + ) + if errors is not None + else None, + }, + coordinate_processing={ + "origin_relative_stingray_input": True, + "uniform_grid_reexpressed": canonical_rebin_factor is not None, + "ulp_accommodation_used": ulp_accommodation_used, + }, + numeric_conditioning={ + "homogeneous_y_scale": y_scale, + "homogeneous_uncertainty_scale": ( + error_scale if errors is not None else None + ), + }, + ), + } + return self._success(data, "Data rebinned linearly", warning_list) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "linear rebinning") + + def logarithmic_rebin( + self, + x: Iterable[Any], + y: Iterable[Any], + factor: Any, + *, + y_error: Optional[Iterable[Any]] = None, + dx: Optional[Any] = None, + ) -> dict[str, Any]: + """Logarithmically rebin x/y data using Stingray's mean-only helper.""" + + try: + x_array, y_array, error = self._xy_arrays(x, y, min_size=2) + if error: + return self._invalid(error) + assert x_array is not None and y_array is not None + if np.any(x_array <= 0): + index = int(np.flatnonzero(x_array <= 0)[0]) + return self._invalid( + f"x[{index}] must be positive for logarithmic rebinning" + ) + + growth, error = _finite_scalar( + factor, "factor", minimum=0.0, minimum_inclusive=False + ) + if error: + return self._invalid(error) + assert growth is not None + + old_resolution: Optional[float] = None + if dx is not None: + old_resolution, error = _finite_scalar( + dx, "dx", minimum=0.0, minimum_inclusive=False + ) + if error: + return self._invalid(error) + initial_resolution = ( + float(np.median(np.diff(x_array))) + if old_resolution is None + else old_resolution + ) + + # Mirror the installed scalar boundary-growth loop, including its + # realized (rounded) width. At a large absolute origin with a tiny + # dx, ``edge + dx * (1 + factor)`` can repeatedly round to exactly + # ``edge + dx``. Upstream then keeps the same width for an enormous + # number of iterations and effectively hangs the request. + first_edge = float(x_array[0] * 0.5) + edge = float(first_edge + initial_resolution) + if ( + not math.isfinite(first_edge) + or not math.isfinite(edge) + or edge <= first_edge + ): + return self._invalid( + "the initial logarithmic bin width is not representable at the " + "absolute x scale" + ) + width = initial_resolution + estimated_bins = 1 + while edge <= float(x_array[-1]): + with np.errstate(over="ignore", invalid="ignore"): + next_edge = float(edge + width * (1.0 + growth)) + if not math.isfinite(next_edge): + return self._invalid( + "factor produces non-finite logarithmic bin edges" + ) + realized_width = next_edge - edge + if realized_width <= width: + return self._invalid( + "the logarithmic bin width cannot grow at the absolute x " + "scale with this factor; the public Stingray call was withheld " + "to avoid a non-progressing edge loop" + ) + edge = next_edge + width = realized_width + estimated_bins += 1 + if estimated_bins > MAX_EXACT_OUTPUT: + return self._invalid( + f"logarithmic rebin would allocate more than " + f"{MAX_EXACT_OUTPUT:,} bins" + ) + + errors: Optional[np.ndarray] = None + if y_error is not None: + errors, error = validate_finite_array( + y_error, + label="y_error", + min_size=2, + ) + if error: + return self._invalid(error) + assert errors is not None + if errors.shape != y_array.shape: + return self._invalid("y_error must have the same length as x and y") + bad = np.flatnonzero(errors < 0) + if bad.size: + return self._invalid(f"y_error[{int(bad[0])}] must be non-negative") + + y_scale = _normalization_scale(y_array) + normalized_y = y_array / y_scale + error_scale = _normalization_scale(errors) if errors is not None else 1.0 + normalized_errors = errors / error_scale if errors is not None else None + + warning_list: list[str] = [] + with collect_warnings(warning_list): + x_bin, y_bin, error_bin, samples = rebin_data_log( + x_array, + normalized_y, + growth, + y_err=normalized_errors, + dx=old_resolution, + ) + + x_bin = np.asarray(x_bin, dtype=float) + if not np.all(np.isfinite(x_bin)): + return self._invalid( + "logarithmic-rebin x coordinates are outside the finite float " + "range; the result was withheld" + ) + y_bin, output_error = _restore_scaled_output( + y_bin, + y_scale, + label="logarithmic-rebin y output", + ) + if output_error: + return self._invalid(output_error) + assert y_bin is not None + if errors is not None: + error_bin, output_error = _restore_scaled_output( + error_bin, + error_scale, + label="logarithmic-rebin uncertainty output", + ) + if output_error: + return self._invalid(output_error) + assert error_bin is not None + else: + error_bin = np.asarray(error_bin, dtype=float) + + original_preview_arrays: list[np.ndarray] = [x_array, y_array] + original_preview_names = ["x", "y"] + rebinned_preview_arrays: list[np.ndarray] = [x_bin, y_bin] + rebinned_preview_names = ["x", "y"] + if errors is not None: + original_preview_arrays.append(errors) + original_preview_names.append("y_error") + rebinned_preview_arrays.append(error_bin) + rebinned_preview_names.append("y_error") + + parameters = { + "factor": growth, + "dx": old_resolution, + "method": "mean", + "uncertainties_supplied": errors is not None, + } + data = { + "mode": "logarithmic", + "method": "mean", + "units": { + "x": "same as input x", + "y": "same as input y", + "y_error": "same as input y", + "samples_per_bin": "input samples", + }, + "original": { + "x": x_array, + "y": y_array, + "y_error": errors, + }, + "rebinned": { + "x": x_bin, + "y": y_bin, + "y_error": error_bin if errors is not None else None, + "samples_per_bin": samples, + }, + "error_semantics": ( + "standard uncertainty of the arithmetic mean, computed as " + "sqrt(sum(sigma_i^2)) / N and assuming independent input errors" + if errors is not None + else None + ), + "plot_preview": { + "original": _named_preview( + original_preview_names, *original_preview_arrays + ), + "rebinned": _named_preview( + rebinned_preview_names, *rebinned_preview_arrays + ), + }, + "provenance": operation_provenance( + "logarithmic_rebin", + input_source={"kind": "pasted_values"}, + parameters=parameters, + numeric_conditioning={ + "homogeneous_y_scale": y_scale, + "homogeneous_uncertainty_scale": ( + error_scale if errors is not None else None + ), + }, + ), + } + return self._success(data, "Data rebinned logarithmically", warning_list) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "logarithmic rebinning") + + def estimate_baseline( + self, + x: Iterable[Any], + y: Iterable[Any], + *, + lam: Any = 1e11, + asymmetry: Any = 0.001, + iterations: Any = 10, + offset_correction: bool = False, + ) -> dict[str, Any]: + """Estimate and subtract an asymmetric least-squares baseline.""" + + try: + x_array, y_array, error = self._xy_arrays(x, y, min_size=3) + if error: + return self._invalid(error) + assert x_array is not None and y_array is not None + + smoothing, error = _finite_scalar( + lam, "lambda", minimum=0.0, minimum_inclusive=False + ) + if error: + return self._invalid(error) + probability, error = _finite_scalar( + asymmetry, + "asymmetry", + minimum=0.0, + maximum=1.0, + minimum_inclusive=False, + maximum_inclusive=False, + ) + if error: + return self._invalid(error) + n_iterations, error = _bounded_integer( + iterations, + "iterations", + minimum=1, + maximum=MAX_BASELINE_ITERATIONS, + ) + if error: + return self._invalid(error) + if not isinstance(offset_correction, (bool, np.bool_)): + return self._invalid("offset_correction must be a boolean") + assert smoothing is not None and probability is not None + assert n_iterations is not None + + candidate_scale = _normalization_scale(y_array) + conditioning_boundary = math.sqrt(np.finfo(float).max) + underflow_boundary = math.sqrt(np.finfo(float).tiny) + y_scale = ( + candidate_scale + if candidate_scale > conditioning_boundary + or candidate_scale < underflow_boundary + else 1.0 + ) + normalized_y = y_array / y_scale + warning_list: list[str] = [] + with collect_warnings(warning_list): + corrected, baseline = baseline_als( + x_array, + normalized_y, + lam=smoothing, + p=probability, + niter=n_iterations, + return_baseline=True, + offset_correction=bool(offset_correction), + ) + + baseline, output_error = _restore_scaled_output( + baseline, + y_scale, + label="baseline output", + ) + if output_error: + return self._invalid(output_error) + corrected, output_error = _restore_scaled_output( + corrected, + y_scale, + label="baseline-corrected output", + ) + if output_error: + return self._invalid(output_error) + assert baseline is not None and corrected is not None + + parameters = { + "lambda": smoothing, + "asymmetry": probability, + "iterations": n_iterations, + "offset_correction": bool(offset_correction), + } + data = { + "x": x_array, + "original": y_array, + "baseline": baseline, + "corrected": corrected, + "units": { + "x": "same as input x", + "original": "same as input y", + "baseline": "same as input y", + "corrected": "same as input y", + }, + "plot_preview": _named_preview( + ["x", "original", "baseline", "corrected"], + x_array, + y_array, + baseline, + corrected, + ), + "provenance": operation_provenance( + "asymmetric_least_squares_baseline", + input_source={"kind": "pasted_values"}, + parameters=parameters, + numeric_conditioning={"homogeneous_y_scale": y_scale}, + ), + } + return self._success( + data, "Asymmetric least-squares baseline estimated", warning_list + ) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "baseline estimation") + + def generate_window( + self, n_samples: Any, window_type: Any = "uniform" + ) -> dict[str, Any]: + """Generate one of the windows supported by installed Stingray.""" + + try: + count, error = _bounded_integer( + n_samples, + "n_samples", + minimum=2, + maximum=MAX_EXACT_OUTPUT, + ) + if error: + return self._invalid(error) + if not isinstance(window_type, str): + return self._invalid("window_type must be a string") + canonical_type = window_type.lower() + if canonical_type not in SUPPORTED_WINDOWS: + return self._invalid( + "window_type must be one of: " + ", ".join(SUPPORTED_WINDOWS) + ) + assert count is not None + + warning_list: list[str] = [] + with collect_warnings(warning_list): + window = create_window(count, canonical_type) + indices = np.arange(count, dtype=int) + + window_sum = float(np.sum(window)) + energy = float(np.sum(np.square(window))) + equivalent_noise_bandwidth: Optional[float] + if np.isclose(window_sum, 0.0, rtol=0.0, atol=np.finfo(float).eps): + equivalent_noise_bandwidth = None + warning_list.append( + "Equivalent noise bandwidth is undefined because this short " + "window has zero coherent sum." + ) + else: + equivalent_noise_bandwidth = count * energy / window_sum**2 + + data = { + "window_type": canonical_type, + "n_samples": count, + "sample_index": indices, + "window": window, + "units": { + "sample_index": "sample", + "window": "dimensionless", + "summary": "dimensionless unless named in bins", + }, + "summary": { + "minimum": np.min(window), + "maximum": np.max(window), + "sum": window_sum, + "mean": np.mean(window), + "rms": np.sqrt(np.mean(np.square(window))), + "energy": energy, + "coherent_gain": np.mean(window), + "equivalent_noise_bandwidth_bins": equivalent_noise_bandwidth, + }, + "plot_preview": _named_preview( + ["sample_index", "window"], indices, window + ), + "provenance": operation_provenance( + "create_window", + input_source={"kind": "parameters"}, + parameters={ + "n_samples": count, + "window_type": canonical_type, + }, + supported_window_types=list(SUPPORTED_WINDOWS), + window_allowlist_source="installed create_window source literal", + ), + } + return self._success( + data, f"{canonical_type} window generated", warning_list + ) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "window generation") + + def calculate_optimal_bin_time( + self, fft_length: Any, proposed_bin_time: Any + ) -> dict[str, Any]: + """Adjust a bin time so an FFT interval has a power-of-two sample count.""" + + try: + length, error = _finite_scalar( + fft_length, + "fft_length", + minimum=0.0, + minimum_inclusive=False, + ) + if error: + return self._invalid(error) + proposed, error = _finite_scalar( + proposed_bin_time, + "proposed_bin_time", + minimum=0.0, + minimum_inclusive=False, + ) + if error: + return self._invalid(error) + assert length is not None and proposed is not None + if proposed > length: + return self._invalid("proposed_bin_time must not exceed fft_length") + requested_samples = length / proposed + if ( + not math.isfinite(requested_samples) + or requested_samples > MAX_FFT_SAMPLES + ): + return self._invalid( + f"the requested FFT would require {requested_samples:,.0f} samples; " + f"the cap is {MAX_FFT_SAMPLES:,}" + ) + + adjusted = float(optimal_bin_time(length, proposed)) + if not math.isfinite(adjusted) or adjusted <= 0.0: + return self._invalid( + "installed Stingray returned a non-finite or non-positive FFT " + "bin time; the result was withheld" + ) + reconstructed_samples = length / adjusted + if not math.isfinite(reconstructed_samples): + return self._invalid( + "the adjusted FFT sample count is not representable; the result " + "was withheld" + ) + sample_count = int(round(reconstructed_samples)) + if sample_count <= 0 or sample_count & (sample_count - 1): + return self._invalid( + "installed Stingray did not produce a positive power-of-two FFT " + "sample count at this numeric scale; the result was withheld" + ) + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + reconstructed_length = adjusted * sample_count + reconstruction_tolerance = max( + 1e-12 * abs(length), + 8.0 * abs(float(np.spacing(length))), + 8.0 * abs(float(np.spacing(reconstructed_length))), + ) + if ( + not math.isfinite(reconstructed_length) + or abs(reconstructed_length - length) > reconstruction_tolerance + ): + return self._invalid( + "the adjusted FFT bin time does not reconstruct fft_length with " + "a representable power-of-two sample count; the result was withheld" + ) + delta = adjusted - proposed + changed = not math.isclose(adjusted, proposed, rel_tol=1e-12, abs_tol=0.0) + warnings = [] + if changed: + warnings.append( + f"Bin time changed by {delta:.12g} ({delta / proposed * 100:.6g}%)." + ) + parameters = { + "fft_length": length, + "proposed_bin_time": proposed, + } + data = { + "requested_bin_time": proposed, + "adjusted_bin_time": adjusted, + "sample_count": sample_count, + "delta": delta, + "fractional_change": delta / proposed, + "changed": changed, + "units": "same time units as fft_length and proposed_bin_time", + "provenance": operation_provenance( + "optimal_bin_time", + input_source={"kind": "parameters"}, + parameters=parameters, + ), + } + return self._success(data, "Optimal FFT bin time calculated", warnings) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "optimal FFT bin-time calculation") + + def calculate_nearest_power_of_two(self, value: Any) -> dict[str, Any]: + """Return Stingray's nearest integral power of two.""" + + try: + requested, error = _bounded_integer( + value, + "value", + minimum=2, + maximum=MAX_FFT_SAMPLES, + ) + if error: + return self._invalid(error) + assert requested is not None + nearest = int(nearest_power_of_two(requested)) + lower = 2 ** int(math.floor(math.log2(requested))) + upper = lower * 2 + mathematically_nearest = ( + lower if requested - lower < upper - requested else upper + ) + if nearest != mathematically_nearest: + return self._invalid( + f"installed Stingray {stingray.__version__} " + f"returned {nearest} for value {requested}, but the nearest power " + f"of two is {mathematically_nearest}. The result is withheld rather " + "than bypassing the public API; use a value for which the installed " + "helper is correct or upgrade Stingray." + ) + delta = nearest - requested + changed = nearest != requested + warnings = [] + if changed: + warnings.append( + f"Value changed by {delta:.12g} ({delta / requested * 100:.6g}%)." + ) + data = { + "requested_value": requested, + "nearest_power_of_two": nearest, + "delta": delta, + "fractional_change": delta / requested, + "changed": changed, + "units": "dimensionless", + "provenance": operation_provenance( + "nearest_power_of_two", + input_source={"kind": "parameters"}, + parameters={"value": requested}, + ), + } + return self._success(data, "Nearest power of two calculated", warnings) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "nearest-power-of-two calculation") + + def adjust_segment_size( + self, + segment_size: Any, + dt: Any, + *, + tolerance: Any = 0.01, + ) -> dict[str, Any]: + """Adjust a segment to an integer number of samples using Stingray.""" + + try: + requested, error = _finite_scalar( + segment_size, + "segment_size", + minimum=0.0, + minimum_inclusive=False, + ) + if error: + return self._invalid(error) + sample_time, error = _finite_scalar( + dt, "dt", minimum=0.0, minimum_inclusive=False + ) + if error: + return self._invalid(error) + rounding_tolerance, error = _finite_scalar( + tolerance, + "tolerance", + minimum=0.0, + maximum=1.0, + maximum_inclusive=False, + ) + if error: + return self._invalid(error) + assert requested is not None and sample_time is not None + assert rounding_tolerance is not None + if requested < sample_time: + return self._invalid("segment_size must be at least one dt") + requested_samples = requested / sample_time + if ( + not math.isfinite(requested_samples) + or requested_samples > MAX_FFT_SAMPLES + ): + return self._invalid( + f"segment would contain {requested_samples:,.0f} samples; " + f"the cap is {MAX_FFT_SAMPLES:,}" + ) + + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + adjusted, sample_count = fix_segment_size_to_integer_samples( + requested, + sample_time, + tolerance=rounding_tolerance, + ) + adjusted = float(adjusted) + sample_count = int(sample_count) + if ( + not math.isfinite(adjusted) + or adjusted <= 0.0 + or sample_count <= 0 + or sample_count > MAX_FFT_SAMPLES + ): + return self._invalid( + "installed Stingray returned a non-finite or invalid adjusted " + "segment size; the result was withheld" + ) + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + reconstructed_size = sample_count * sample_time + if ( + not math.isfinite(reconstructed_size) + or reconstructed_size <= 0.0 + or reconstructed_size != adjusted + ): + return self._invalid( + "the adjusted segment size does not reconstruct from its sample " + "count and dt within the finite float range; the result was withheld" + ) + delta = adjusted - requested + changed = not math.isclose(adjusted, requested, rel_tol=1e-12, abs_tol=0.0) + warnings = [] + if changed: + warnings.append( + f"Segment size changed by {delta:.12g} " + f"({delta / requested * 100:.6g}%)." + ) + parameters = { + "segment_size": requested, + "dt": sample_time, + "tolerance": rounding_tolerance, + } + data = { + "requested_segment_size": requested, + "adjusted_segment_size": adjusted, + "sample_count": sample_count, + "delta": delta, + "fractional_change": delta / requested, + "changed": changed, + "units": "same time units as segment_size and dt", + "provenance": operation_provenance( + "fix_segment_size_to_integer_samples", + input_source={"kind": "parameters"}, + parameters=parameters, + ), + } + return self._success( + data, "Segment size adjusted to whole samples", warnings + ) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "segment-size adjustment") + + def poisson_errors(self, counts: Iterable[Any]) -> dict[str, Any]: + """Calculate Stingray's one-sigma symmetrized frequentist Poisson errors.""" + + try: + count_array, error = validate_finite_array( + counts, + label="counts", + min_size=1, + ) + if error: + return self._invalid(error) + assert count_array is not None + negative = np.flatnonzero(count_array < 0) + if negative.size: + return self._invalid(f"counts[{int(negative[0])}] must be non-negative") + noninteger = np.flatnonzero(count_array != np.floor(count_array)) + if noninteger.size: + return self._invalid( + f"counts[{int(noninteger[0])}] must be an integer Poisson count" + ) + maximum = int(np.max(count_array)) + if maximum > MAX_POISSON_LOOKUP_COUNT: + return self._invalid( + f"largest count is {maximum:,}; Stingray's lookup allocation is " + f"capped at {MAX_POISSON_LOOKUP_COUNT:,}" + ) + + integer_counts = count_array.astype(np.int64) + warning_list: list[str] = [] + with collect_warnings(warning_list): + errors = poisson_symmetrical_errors(integer_counts) + data = { + "counts": integer_counts, + "symmetric_error": errors, + "confidence_sigma": 1.0, + "units": { + "counts": "count", + "symmetric_error": "count", + "confidence_sigma": "standard deviations", + }, + "assumptions": ( + "Counts are independent Poisson observations. Stingray averages " + "the absolute lower and upper frequentist-confidence offsets to " + "report one approximately symmetric one-sigma error." + ), + "plot_preview": _named_preview( + ["counts", "symmetric_error"], integer_counts, errors + ), + "provenance": operation_provenance( + "poisson_symmetrical_errors", + input_source={"kind": "pasted_values"}, + parameters={"confidence_sigma": 1.0}, + ), + } + return self._success( + data, "Symmetric Poisson errors calculated", warning_list + ) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "Poisson error calculation") + + def standard_error( + self, + samples: Iterable[Iterable[Any]], + *, + mean: Optional[Iterable[Any]] = None, + ) -> dict[str, Any]: + """Calculate column-wise SEM for a bounded rectangular sample matrix.""" + + try: + if isinstance(samples, (str, bytes)): + return self._invalid("samples must be a two-dimensional numeric matrix") + + # Enforce the total-cell cap while materializing iterables, before + # NumPy allocates a dense matrix. Per-axis Pydantic limits alone do + # not constrain the product of nested row and column counts, and + # direct service callers may provide generators. + prepared_rows: list[list[Any]] = [] + total_cells = 0 + try: + sample_rows = iter(samples) + except TypeError: + return self._invalid("samples must be a two-dimensional numeric matrix") + + for row_index, row in enumerate(sample_rows): + if row_index >= MAX_MATRIX_CELLS: + return self._invalid( + f"samples exceeds the cap of {MAX_MATRIX_CELLS:,} total cells" + ) + if isinstance(row, (str, bytes)): + return self._invalid(f"samples[{row_index}] must be a numeric row") + try: + row_values = iter(row) + except TypeError: + return self._invalid(f"samples[{row_index}] must be a numeric row") + + prepared_row: list[Any] = [] + for column_index, value in enumerate(row_values): + if total_cells >= MAX_MATRIX_CELLS: + return self._invalid( + f"samples exceeds the cap of {MAX_MATRIX_CELLS:,} total cells" + ) + if isinstance( + value, + (bool, np.bool_, str, bytes, complex, np.complexfloating), + ): + return self._invalid( + f"samples[{row_index}][{column_index}] must be a finite real number" + ) + prepared_row.append(value) + total_cells += 1 + prepared_rows.append(prepared_row) + + try: + matrix = np.asarray(prepared_rows, dtype=float) + except (TypeError, ValueError, OverflowError) as exc: + return self._invalid( + f"samples must be a rectangular numeric matrix ({exc})" + ) + if matrix.ndim != 2: + return self._invalid("samples must be a two-dimensional matrix") + rows, columns = matrix.shape + if rows < 2: + return self._invalid("samples must contain at least two rows") + if columns < 1: + return self._invalid("samples must contain at least one column") + if matrix.size > MAX_MATRIX_CELLS: + return self._invalid( + f"samples contains {matrix.size:,} cells; the cap is " + f"{MAX_MATRIX_CELLS:,}" + ) + bad = np.argwhere(~np.isfinite(matrix)) + if bad.size: + row, column = (int(v) for v in bad[0]) + return self._invalid(f"samples[{row}][{column}] must be finite") + + # Column-wise homogeneous scaling keeps both the arithmetic mean and + # Stingray's squared deviations representable at extreme magnitudes. + column_scales = np.max(np.abs(matrix), axis=0) + column_scales = np.where(column_scales > 0.0, column_scales, 1.0) + normalized_matrix = matrix / column_scales + normalized_calculated_mean = np.mean(normalized_matrix, axis=0) + calculated_mean, output_error = _restore_scaled_output( + normalized_calculated_mean, + 1.0, + label="normalized arithmetic sample mean", + ) + if output_error: + return self._invalid(output_error) + assert calculated_mean is not None + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + calculated_mean = calculated_mean * column_scales + if not np.all(np.isfinite(calculated_mean)): + return self._invalid( + "column-wise arithmetic sample mean is outside the finite float " + "range; the result was withheld" + ) + mean_source = "calculated_arithmetic_mean" + warning_list: list[str] = [] + if mean is None: + reference_mean = calculated_mean + else: + reference_mean, error = validate_finite_array( + mean, + label="mean", + min_size=columns, + max_size=columns, + ) + if error: + return self._invalid(error) + assert reference_mean is not None + if reference_mean.shape != (columns,): + return self._invalid( + f"mean must contain exactly {columns} values, one per column" + ) + mean_source = "provided" + if not np.allclose( + reference_mean, + calculated_mean, + rtol=1e-10, + atol=1e-12, + ): + return self._invalid( + "mean must match the column-wise arithmetic sample mean; " + "omit it to have the service calculate the mean" + ) + + normalized_reference_mean = reference_mean / column_scales + with collect_warnings(warning_list): + normalized_errors = stingray_standard_error( + normalized_matrix, + normalized_reference_mean, + ) + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + errors = np.asarray(normalized_errors, dtype=float) * column_scales + underflowed = (np.asarray(normalized_errors) != 0.0) & (errors == 0.0) + if not np.all(np.isfinite(errors)) or np.any(underflowed): + return self._invalid( + "standard-error output is outside the finite float range; " + "the result was withheld" + ) + column_index = np.arange(columns, dtype=int) + parameters = { + "rows": rows, + "columns": columns, + "mean_source": mean_source, + } + data = { + "mean": reference_mean, + "calculated_sample_mean": calculated_mean, + "standard_error": errors, + "sample_count": rows, + "column_count": columns, + "mean_source": mean_source, + "units": { + "mean": "same as input samples", + "calculated_sample_mean": "same as input samples", + "standard_error": "same as input samples", + "sample_count": "samples", + "column_count": "columns", + }, + "assumptions": ( + "Rows are independent samples of the same column-wise quantities; " + "the calculation uses sample variance with n-1 degrees of freedom " + "and divides it by n before taking the square root." + ), + "plot_preview": _named_preview( + ["column_index", "mean", "standard_error"], + column_index, + reference_mean, + errors, + ), + "provenance": operation_provenance( + "standard_error", + input_source={"kind": "pasted_matrix"}, + parameters=parameters, + numeric_conditioning={ + "homogeneous_column_scales": column_scales, + }, + ), + } + return self._success( + data, "Column-wise standard errors calculated", warning_list + ) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "standard-error calculation") + + def equal_count_ranges( + self, + *, + n_ranges: Any, + energies: Optional[Iterable[Any]] = None, + event_list_name: Optional[str] = None, + energy_min: Optional[Any] = None, + energy_max: Optional[Any] = None, + energy_unit: str = "keV", + ) -> dict[str, Any]: + """Create approximately equal-count energy ranges from one exact source.""" + + try: + has_values = energies is not None + has_event = event_list_name is not None + if has_values == has_event: + return self._invalid( + "provide exactly one energy source: energies or event_list_name" + ) + + if not isinstance(energy_unit, str) or not energy_unit.strip(): + return self._invalid("energy_unit must be a non-empty unit label") + if energy_unit != energy_unit.strip() or len(energy_unit) > 32: + return self._invalid( + "energy_unit must be at most 32 characters with no surrounding whitespace" + ) + + source: dict[str, Any] + source_snapshot = False + if has_event: + if not isinstance(event_list_name, str) or not event_list_name: + return self._invalid("event_list_name must be a non-empty string") + if energy_unit != "keV": + return self._invalid( + "loaded EventList.energy values use keV by the public Stingray " + "contract; energy_unit must be 'keV' for this source" + ) + event_list = self.state.copy_event_data( + event_list_name, + max_events=MAX_ARRAY_INPUT, + max_cells=MAX_STATE_SNAPSHOT_CELLS, + max_bytes=MAX_STATE_SNAPSHOT_BYTES, + ) + if event_list is None: + return self._invalid(f"EventList '{event_list_name}' was not found") + source_snapshot = True + event_energies = getattr(event_list, "energy", None) + if event_energies is None: + return self._invalid( + f"EventList '{event_list_name}' has no energy data" + ) + energy_array, error = validate_finite_array( + event_energies, + label=f"EventList '{event_list_name}' energy", + min_size=2, + ) + source = {"kind": "event_list", "name": event_list_name} + # Stingray's public EventList contract defines energy in keV. + energy_unit = "keV" + else: + energy_array, error = validate_finite_array( + energies, + label="energies", + min_size=2, + ) + source = {"kind": "pasted_values"} + if error: + return self._invalid(error) + assert energy_array is not None + + range_count, error = _bounded_integer( + n_ranges, + "n_ranges", + minimum=1, + maximum=MAX_ENERGY_RANGES, + ) + if error: + return self._invalid(error) + assert range_count is not None + + lower: Optional[float] = None + upper: Optional[float] = None + if energy_min is not None: + lower, error = _finite_scalar(energy_min, "energy_min") + if error: + return self._invalid(error) + if energy_max is not None: + upper, error = _finite_scalar(energy_max, "energy_max") + if error: + return self._invalid(error) + effective_lower = float(np.min(energy_array)) if lower is None else lower + effective_upper = float(np.max(energy_array)) if upper is None else upper + if effective_lower >= effective_upper: + return self._invalid("energy_min must be smaller than energy_max") + + selected_mask = (energy_array >= effective_lower) & ( + energy_array <= effective_upper + ) + selected = energy_array[selected_mask] + if selected.size < range_count: + return self._invalid( + f"only {selected.size:,} energies fall in the requested range; " + f"at least {range_count:,} are required for {range_count:,} bins" + ) + + warning_list: list[str] = [] + with collect_warnings(warning_list): + edges = np.asarray( + equal_count_energy_ranges( + energy_array, + range_count, + emin=lower, + emax=upper, + ), + dtype=float, + ) + if edges.shape != (range_count + 1,): + return self._invalid( + "Stingray returned an unexpected energy-edge shape" + ) + if not np.all(np.isfinite(edges)): + return self._invalid("Stingray returned non-finite energy edges") + duplicate = np.flatnonzero(np.diff(edges) <= 0) + if duplicate.size: + return self._invalid( + "the energy distribution cannot form the requested number of " + "positive-width equal-count ranges; reduce n_ranges" + ) + + counts, _ = np.histogram(selected, bins=edges) + sorted_selected = np.sort(selected) + ranks = np.arange(sorted_selected.size, dtype=int) + parameters = { + "n_ranges": range_count, + "energy_min": lower, + "energy_max": upper, + "energy_unit": energy_unit, + } + data = { + "bin_edges": edges, + "counts": counts, + "n_ranges": range_count, + "selected_count": int(selected.size), + "excluded_count": int(energy_array.size - selected.size), + "energy_min": float(edges[0]), + "energy_max": float(edges[-1]), + "energy_unit": energy_unit, + "plot_preview": _named_preview( + ["rank", "energy"], ranks, sorted_selected + ), + "provenance": operation_provenance( + "equal_count_energy_ranges", + input_source=source, + parameters=parameters, + source_snapshot=source_snapshot, + ), + } + return self._success( + data, "Equal-count energy ranges calculated", warning_list + ) + except Exception as exc: # pragma: no cover - defensive boundary + return self.handle_error(exc, "equal-count energy-range calculation") + + +__all__ = [ + "LINEAR_REBIN_NEEDS_VARIANCE_INPUT", + "MAX_BASELINE_ITERATIONS", + "MAX_ENERGY_RANGES", + "MAX_FFT_SAMPLES", + "MAX_POISSON_LOOKUP_COUNT", + "MiscService", + "SUPPORTED_WINDOWS", +] diff --git a/python-backend/services/mission_io_service.py b/python-backend/services/mission_io_service.py new file mode 100644 index 0000000..88406fa --- /dev/null +++ b/python-backend/services/mission_io_service.py @@ -0,0 +1,2042 @@ +"""Mission-aware inspection and approximate PI conversion utilities. + +The service deliberately keeps two calibration paths distinct: + +* :func:`stingray.mission_support.get_rough_conversion_function` provides the + approximate, mission-specific estimates exposed here. +* RMF-based calibration is the precise path and belongs to General I/O. + +No selected FITS path is opened until its Electron-issued file grant has been +verified. Mission-specific FITS interpretation is run on an in-memory copy +and is summarized without changing the source file. +""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Mapping, Sequence +from decimal import Decimal, InvalidOperation +from typing import Any, BinaryIO, Optional + +import numpy as np +from astropy.io import fits +from stingray.io import get_key_from_mission_info +from stingray.mission_support import ( + get_rough_conversion_function, + mission_specific_event_interpretation, + read_mission_info, +) + +from services.base_service import BaseService +from services.utility_helpers import ( + MAX_ARRAY_INPUT, + MAX_EXPORT_ROWS, + MAX_FITS_INSPECT_BYTES, + MAX_STATE_SNAPSHOT_BYTES, + MAX_STATE_SNAPSHOT_CELLS, + duplicate_binary_stream, + json_safe, + open_verified_read_grant, + operation_provenance, + validate_derived_name, + validate_file_size, + validate_finite_array, +) + + +PREVIEW_ROWS = 500 +MAX_INTERPRET_FITS_BYTES = 256 * 1024**2 +MAX_FITS_HDUS = 512 +XTE_PCA_EPOCH_MJD_MIN_EXCLUSIVE = 50_081.0 +XTE_PCA_EPOCH_MJD_MAX_INCLUSIVE = 55_931.0 +TIME_UNIT_SECONDS = { + "s": 1.0, + "sec": 1.0, + "second": 1.0, + "seconds": 1.0, + "ms": 1e-3, + "us": 1e-6, + "ns": 1e-9, + "min": 60.0, + "minute": 60.0, + "minutes": 60.0, + "h": 3600.0, + "hr": 3600.0, + "hour": 3600.0, + "hours": 3600.0, + "d": 86400.0, + "day": 86400.0, + "days": 86400.0, +} +RAW_CARD_PREFIX = "__STINGRAY_EXPLORER_RAW_CARD__" + +# These are naming aliases, not a hard-coded support list. Capability rows +# continue to come from the runtime xselect database returned by Stingray. +MISSION_NAME_ALIASES = { + "chandra": "axaf", + "rxte": "xte", + "xmm-newton": "xmm", +} + +MAPPING_KEYS = { + "event_hdu": "events", + "gti_hdu": "gti", + "time_column": "time", + "energy_or_channel_column": "ecol", + "detector_column": "ccol", + "instrument_keyword": "instkey", + "mode_keyword": "dmodekey", +} + + +def _clean_text(value: Any) -> Optional[str]: + """Return a non-empty, stripped string or ``None``.""" + if value is None: + return None + result = str(value).strip() + if not result or result.upper() == "NONE": + return None + return result + + +def _safe_float(value: Any) -> Optional[float]: + """Return a finite scalar float or ``None``.""" + if isinstance(value, (bool, np.bool_)): + return None + try: + result = float(value) + except (TypeError, ValueError, OverflowError): + return None + return result if math.isfinite(result) else None + + +def _normalise_mapping_value(value: Any) -> Any: + """Translate xselect's ``NONE`` sentinel and retain useful mappings.""" + if isinstance(value, str): + return None if value.strip().upper() == "NONE" else value.strip() + if isinstance(value, (list, tuple)): + return [_normalise_mapping_value(item) for item in value] + return value + + +def _runtime_choices(value: Any) -> list[str]: + """Return non-empty runtime database choices without inventing delimiters.""" + normalized = _normalise_mapping_value(value) + raw_values = normalized if isinstance(normalized, list) else [normalized] + return [clean for item in raw_values if (clean := _clean_text(item)) is not None] + + +def _canonical_runtime_choice(requested: Any, available: Any) -> Optional[str]: + """Resolve a user spelling to an exact runtime database choice.""" + clean = _clean_text(requested) + if clean is None: + return None + for choice in _runtime_choices(available): + if choice.casefold() == clean.casefold(): + return choice + return None + + +def _header_to_mapping(raw_header: Any) -> dict[str, Any]: + """Convert a supported EventList header representation to a mapping.""" + if raw_header is None: + return {} + if isinstance(raw_header, fits.Header): + result: dict[str, Any] = {} + for key in raw_header.keys(): + normalized_key = str(key).upper() + if not key or normalized_key.strip() in {"COMMENT", "HISTORY"}: + continue + result[normalized_key] = raw_header.get(key) + try: + card_image = raw_header.cards[key].image + except (KeyError, AttributeError): + continue + if len(card_image) >= 11 and card_image[8:10] == "= ": + raw_value = card_image[10:].split("/", 1)[0].strip() + if raw_value: + result[f"{RAW_CARD_PREFIX}{normalized_key}"] = raw_value + return result + if isinstance(raw_header, Mapping): + return {str(key).upper(): value for key, value in raw_header.items()} + if not isinstance(raw_header, str): + return {} + + try: + separator = "\n" if "\n" in raw_header else "" + parsed = fits.Header.fromstring(raw_header, sep=separator) + return _header_to_mapping(parsed) + except Exception: + # A malformed optional header must not make EventList identification + # unusable. The EventList's explicit metadata attributes can still be + # reported, and the caller receives a missing-header warning. + return {} + + +def _field( + value: Any = None, + *, + raw_value: Any = None, + source: Optional[str] = None, + source_type: str = "missing", + inferred: bool = False, + override: bool = False, +) -> dict[str, Any]: + """Build a source-labelled identification field.""" + clean_value = _clean_text(value) + return { + "value": clean_value, + "raw_value": _clean_text(raw_value) if raw_value is not None else clean_value, + "source": source, + "source_type": source_type if clean_value is not None else "missing", + "inferred": bool(inferred and clean_value is not None), + "override": bool(override and clean_value is not None), + } + + +class MissionIOService(BaseService): + """Mission-specific read-only inspection and derived EventList operations.""" + + def _failure( + self, message: str, *, warnings: Optional[list[str]] = None + ) -> dict[str, Any]: + return self.create_result( + success=False, + data=None, + message=message, + error=None, + warnings=warnings or [], + ) + + @staticmethod + def _runtime_database() -> dict[str, tuple[str, dict[str, Any]]]: + """Return case-folded, de-duplicated runtime xselect mappings.""" + raw_database = read_mission_info() + if not isinstance(raw_database, dict): + raise RuntimeError("Stingray returned an invalid mission database") + + database: dict[str, tuple[str, dict[str, Any]]] = {} + for raw_name, raw_info in raw_database.items(): + name = _clean_text(raw_name) + if name is None or not isinstance(raw_info, dict): + continue + # Stingray applies a few mission-specific database patches only + # when a mission is requested explicitly (for example XTE's PCUID + # column). Use the all-mission call to discover names, then load + # the public per-mission view for accurate mappings. + selected_info = read_mission_info(name) + if not isinstance(selected_info, dict): + selected_info = raw_info + key = name.casefold() + current = database.get(key) + # Prefer the all-uppercase spelling when xselect contains a + # duplicate that differs only in case (Astro-E2 in 2.2.10). + if current is None or (name.isupper() and not current[0].isupper()): + database[key] = (name, selected_info) + return database + + @staticmethod + def _resolve_mission( + value: Any, + database: dict[str, tuple[str, dict[str, Any]]], + ) -> tuple[Optional[str], Optional[dict[str, Any]], bool]: + """Resolve a mission against the runtime database and naming aliases.""" + clean = _clean_text(value) + if clean is None: + return None, None, False + key = clean.casefold() + inferred = False + if key not in database and key in MISSION_NAME_ALIASES: + key = MISSION_NAME_ALIASES[key] + inferred = True + if key in database: + canonical, info = database[key] + return canonical, info, inferred + return clean, None, inferred + + @staticmethod + def _find_header_value( + header_sources: Sequence[tuple[str, Mapping[str, Any]]], + keys: Sequence[Optional[str]], + ) -> tuple[Any, Optional[str]]: + """Find the first non-empty value for a prioritized header-key list.""" + normalized_keys: list[str] = [] + for key in keys: + clean = _clean_text(key) + if clean is not None and clean.upper() not in normalized_keys: + normalized_keys.append(clean.upper()) + + for source, header in header_sources: + for key in normalized_keys: + if key in header and _clean_text(header[key]) is not None: + return header[key], f"{source}.{key}" + return None, None + + @staticmethod + def _find_raw_card_value( + header_sources: Sequence[tuple[str, Mapping[str, Any]]], + keys: Sequence[str], + ) -> Optional[str]: + """Return the original FITS numeric lexeme when a Header retained it.""" + for _source, header in header_sources: + for key in keys: + marker = f"{RAW_CARD_PREFIX}{key.upper()}" + if marker in header: + return str(header[marker]).strip() + return None + + @classmethod + def _find_high_precision_header_value( + cls, + header_sources: Sequence[tuple[str, Mapping[str, Any]]], + key: str, + ) -> tuple[Any, Optional[str], Optional[str]]: + """Read a direct or public-Stingray-style split FITS keyword. + + Stingray's ``high_precision_keyword_read`` first reads ``KEY`` and + otherwise sums ``KEYI`` + ``KEYF`` (truncating an eight-character key + to seven characters before adding the suffix). This equivalent + mapping-level preflight retains the original card lexemes for display + while still letting the service validate malformed components before + calling mission calibration code. + """ + normalized_key = key.upper() + for source, header in header_sources: + if ( + normalized_key in header + and _clean_text(header[normalized_key]) is not None + ): + return ( + header[normalized_key], + f"{source}.{normalized_key}", + cls._find_raw_card_value([(source, header)], [normalized_key]), + ) + + split_base = ( + normalized_key[:7] if len(normalized_key) == 8 else normalized_key + ) + key_i = f"{split_base}I" + key_f = f"{split_base}F" + has_i = key_i in header and _clean_text(header[key_i]) is not None + has_f = key_f in header and _clean_text(header[key_f]) is not None + if not has_i and not has_f: + continue + split_source = f"{source}.{key_i} + {source}.{key_f}" + if not has_i or not has_f: + return float("nan"), split_source, None + parsed_i = _safe_float(header[key_i]) + parsed_f = _safe_float(header[key_f]) + if parsed_i is None or parsed_f is None: + return float("nan"), split_source, None + exact_i = cls._find_raw_card_value([(source, header)], [key_i]) or str( + header[key_i] + ) + exact_f = cls._find_raw_card_value([(source, header)], [key_f]) or str( + header[key_f] + ) + try: + exact = format( + Decimal(exact_i.replace("D", "E").replace("d", "e")) + + Decimal(exact_f.replace("D", "E").replace("d", "e")), + "f", + ) + except (InvalidOperation, ValueError): + exact = None + with np.errstate(over="ignore", invalid="ignore"): + combined = parsed_i + parsed_f + if not math.isfinite(combined): + return float("nan"), split_source, exact + return combined, split_source, exact + return None, None, None + + @staticmethod + def _apply_missing_only_override( + entry: dict[str, Any], + override_value: Any, + *, + field_name: str, + ) -> dict[str, Any]: + """Apply an override only when source metadata did not identify a value.""" + override = _clean_text(override_value) + if override is None: + return entry + existing = _clean_text(entry.get("value")) + if existing is not None: + if existing.casefold() != override.casefold(): + raise ValueError( + f"{field_name.capitalize()} metadata already identifies '{existing}'. " + "Overrides are accepted only when that metadata is missing." + ) + return entry + return _field( + override, + raw_value=override, + source=f"request.{field_name}_override", + source_type="override", + override=True, + ) + + def _mapping_for( + self, + mission_info: Optional[dict[str, Any]], + instrument: Optional[str], + mode: Optional[str], + ) -> Optional[dict[str, Any]]: + if mission_info is None: + return None + mapping: dict[str, Any] = {} + for output_key, database_key in MAPPING_KEYS.items(): + value = get_key_from_mission_info( + mission_info, + database_key, + None, + inst=instrument, + mode=mode, + ) + mapping[output_key] = _normalise_mapping_value(value) + return mapping + + @staticmethod + def _runtime_modes_for_instrument( + mission_info: Mapping[str, Any], instrument: Optional[str] + ) -> Any: + """Return every runtime-declared or nested mode for an instrument.""" + declared = _normalise_mapping_value( + get_key_from_mission_info( + mission_info, + "modes", + None, + inst=instrument, + ) + ) + modes = _runtime_choices(declared) + selected_instrument = _canonical_runtime_choice( + instrument, mission_info.get("instruments") + ) + scoped = ( + mission_info.get(selected_instrument) + if selected_instrument is not None + else None + ) + if isinstance(scoped, Mapping): + for key, value in scoped.items(): + clean = _clean_text(key) + if clean is not None and isinstance(value, Mapping): + if all(clean.casefold() != item.casefold() for item in modes): + modes.append(clean) + if not modes: + return declared + if len(modes) == 1 and not isinstance(declared, list): + return modes[0] + return modes + + def _identify_parts( + self, + *, + source: dict[str, Any], + header_sources: Sequence[tuple[str, Mapping[str, Any]]], + mission_attribute: Any = None, + instrument_attribute: Any = None, + mode_attribute: Any = None, + mission_override: Any = None, + instrument_override: Any = None, + mode_override: Any = None, + ) -> tuple[dict[str, Any], list[str]]: + """Identify mission fields and retain a source for every value.""" + warnings: list[str] = [] + database = self._runtime_database() + + mission_raw = _clean_text(mission_attribute) + mission_source = "EventList.mission" if mission_raw is not None else None + mission_source_type = "event_list_attribute" + if mission_raw is None: + mission_raw, mission_source = self._find_header_value( + header_sources, ["MISSION", "TELESCOP"] + ) + mission_source_type = "fits_header" + + canonical, mission_info, alias_inferred = self._resolve_mission( + mission_raw, database + ) + mission_entry = _field( + canonical, + raw_value=mission_raw, + source=mission_source, + source_type=mission_source_type, + inferred=alias_inferred, + ) + mission_entry = self._apply_missing_only_override( + mission_entry, mission_override, field_name="mission" + ) + canonical, mission_info, override_alias = self._resolve_mission( + mission_entry["value"], database + ) + mission_entry["value"] = canonical + mission_entry["inferred"] = bool(mission_entry["inferred"] or override_alias) + mission_entry["database_supported"] = mission_info is not None + + instrument_key = ( + get_key_from_mission_info(mission_info, "instkey", None) + if mission_info is not None + else None + ) + instrument_raw = _clean_text(instrument_attribute) + instrument_source = "EventList.instr" if instrument_raw is not None else None + instrument_source_type = "event_list_attribute" + if instrument_raw is None: + instrument_raw, instrument_source = self._find_header_value( + header_sources, [instrument_key, "INSTRUME", "DETNAM"] + ) + instrument_source_type = "fits_header" + instrument_entry = _field( + instrument_raw, + source=instrument_source, + source_type=instrument_source_type, + ) + instrument_entry = self._apply_missing_only_override( + instrument_entry, instrument_override, field_name="instrument" + ) + + instrument = instrument_entry["value"] + mode_key = ( + get_key_from_mission_info(mission_info, "dmodekey", None, inst=instrument) + if mission_info is not None + else None + ) + mode_raw = _clean_text(mode_attribute) + mode_source = "EventList.mode" if mode_raw is not None else None + mode_source_type = "event_list_attribute" + if mode_raw is None: + mode_raw, mode_source = self._find_header_value( + header_sources, [mode_key, "DATAMODE", "OBS_MODE"] + ) + mode_source_type = "fits_header" + mode_entry = _field(mode_raw, source=mode_source, source_type=mode_source_type) + mode_entry = self._apply_missing_only_override( + mode_entry, mode_override, field_name="mode" + ) + + mapping_validation_error = None + if mission_info is not None: + available_instruments = _normalise_mapping_value( + mission_info.get("instruments") + ) + instrument_choices = _runtime_choices(available_instruments) + selected_instrument = instrument_entry["value"] + if selected_instrument is not None and instrument_choices: + canonical_instrument = _canonical_runtime_choice( + selected_instrument, available_instruments + ) + if canonical_instrument is None: + mapping_validation_error = ( + f"Instrument '{selected_instrument}' is not defined for " + f"{mission_entry['value']}" + ) + else: + instrument_entry["value"] = canonical_instrument + + selected_mode = mode_entry["value"] + if selected_mode is not None and mapping_validation_error is None: + available_modes = self._runtime_modes_for_instrument( + mission_info, instrument_entry["value"] + ) + mode_choices = _runtime_choices(available_modes) + if ( + instrument_entry["value"] is None + and instrument_choices + and not mode_choices + ): + mapping_validation_error = ( + f"Mode '{selected_mode}' requires an instrument selection for " + f"{mission_entry['value']}" + ) + elif mode_choices: + canonical_mode = _canonical_runtime_choice( + selected_mode, available_modes + ) + if canonical_mode is None: + mapping_validation_error = ( + f"Mode '{selected_mode}' is not defined for " + f"{mission_entry['value']}" + + ( + f"/{instrument_entry['value']}" + if instrument_entry["value"] is not None + else "" + ) + ) + else: + mode_entry["value"] = canonical_mode + + if mapping_validation_error is not None: + warnings.append( + f"{mapping_validation_error}; no generic fallback mapping was applied." + ) + + if mission_entry["value"] is None: + warnings.append( + "Mission metadata is missing. Supply a mission override to use mission mappings " + "or approximate conversion." + ) + elif mission_info is None: + warnings.append( + f"Mission '{mission_entry['value']}' is not present in Stingray's runtime " + "xselect mission database." + ) + if instrument_entry["value"] is None: + warnings.append("Instrument metadata is missing.") + if mode_entry["value"] is None: + warnings.append("Observing-mode metadata is missing.") + + identified = { + "source": source, + "mission": mission_entry, + "instrument": instrument_entry, + "mode": mode_entry, + "mapping": self._mapping_for( + mission_info, + instrument_entry["value"], + mode_entry["value"], + ) + if mapping_validation_error is None + else None, + "mapping_validation_error": mapping_validation_error, + } + return identified, warnings + + @staticmethod + def _fits_header_sources( + stream: BinaryIO, + ) -> tuple[list[tuple[str, dict[str, Any]]], list[dict[str, Any]]]: + """Read FITS headers without materializing event-table data.""" + header_sources: list[tuple[str, dict[str, Any]]] = [] + hdu_summary: list[dict[str, Any]] = [] + with duplicate_binary_stream(stream) as fits_stream: + with fits.open( + fits_stream, mode="readonly", memmap=True, lazy_load_hdus=True + ) as hdulist: + if len(hdulist) > MAX_FITS_HDUS: + raise ValueError( + f"FITS file has {len(hdulist):,} HDUs; the inspection cap is " + f"{MAX_FITS_HDUS:,}" + ) + for index, hdu in enumerate(hdulist): + header = _header_to_mapping(hdu.header) + name = _clean_text(hdu.name) or str(index) + label = f"HDU[{index}:{name}]" + header_sources.append((label, header)) + hdu_summary.append( + { + "index": index, + "name": name, + "type": type(hdu).__name__, + "rows": int(hdu.header.get("NAXIS2", 0)) + if "NAXIS2" in hdu.header + else None, + } + ) + return header_sources, hdu_summary + + def _identify_event_list( + self, + name: str, + *, + max_events: int, + mission_override: Any = None, + instrument_override: Any = None, + mode_override: Any = None, + ) -> tuple[Optional[Any], Optional[dict[str, Any]], list[str], Optional[str]]: + try: + event_list = self.state.copy_event_data( + name, + max_events=max_events, + max_cells=MAX_STATE_SNAPSHOT_CELLS, + max_bytes=MAX_STATE_SNAPSHOT_BYTES, + ) + except ValueError as exc: + return None, None, [], str(exc) + if event_list is None: + return None, None, [], f"EventList '{name}' was not found" + header = _header_to_mapping(getattr(event_list, "header", None)) + header_sources = [("EventList.header", header)] if header else [] + try: + identified, warnings = self._identify_parts( + source={ + "type": "loaded_event_list", + "name": name, + "event_count": int(len(event_list.time)), + }, + header_sources=header_sources, + mission_attribute=getattr(event_list, "mission", None), + instrument_attribute=getattr(event_list, "instr", None), + mode_attribute=getattr(event_list, "mode", None), + mission_override=mission_override, + instrument_override=instrument_override, + mode_override=mode_override, + ) + except ValueError as exc: + return event_list, None, [], str(exc) + if getattr(event_list, "header", None) is not None and not header: + warnings.append( + "The EventList header could not be parsed; explicit attributes were used." + ) + identified["timing_metadata"] = self._timing_metadata( + event_list, header_sources, warnings + ) + return event_list, identified, warnings, None + + def _identify_fits( + self, + stream: BinaryIO, + *, + display_path: str, + size_bytes: int, + mission_override: Any = None, + instrument_override: Any = None, + mode_override: Any = None, + ) -> tuple[Optional[dict[str, Any]], list[str], Optional[str]]: + try: + headers, hdu_summary = self._fits_header_sources(stream) + identified, warnings = self._identify_parts( + source={ + "type": "selected_fits_file", + "path": display_path, + "size_bytes": size_bytes, + "hdu_count": len(hdu_summary), + }, + header_sources=headers, + mission_override=mission_override, + instrument_override=instrument_override, + mode_override=mode_override, + ) + identified["hdus"] = hdu_summary + identified["timing_metadata"] = self._timing_metadata( + None, headers, warnings + ) + return identified, warnings, None + except Exception as exc: + return None, [], f"Could not inspect the selected FITS headers: {exc}" + + @classmethod + def _timing_metadata( + cls, + event_list: Any, + header_sources: Sequence[tuple[str, Mapping[str, Any]]], + warnings: Optional[list[str]] = None, + ) -> dict[str, Any]: + """Return concise timing metadata with source labels.""" + output: dict[str, Any] = {} + warning_list = warnings if warnings is not None else [] + for output_name, keys in { + "mjd_observation": ["MJD-OBS", "MJD_OBS", "MJDOBS"], + "tstart": ["TSTART"], + "tstop": ["TSTOP"], + "timezero": ["TIMEZERO"], + "timedel": ["TIMEDEL"], + "timepixr": ["TIMEPIXR"], + }.items(): + if len(keys) == 1: + value, source, exact_value = cls._find_high_precision_header_value( + header_sources, keys[0] + ) + else: + value, source = cls._find_header_value(header_sources, keys) + exact_value = None + if value is None: + continue + parsed = _safe_float(value) + if parsed is None: + warning_list.append( + f"Ignored malformed or non-finite {keys[0]} value from {source}." + ) + continue + if output_name == "timedel" and parsed < 0: + warning_list.append( + f"Ignored invalid TIMEDEL value from {source}; TIMEDEL must be " + "non-negative." + ) + continue + if output_name == "timepixr" and not 0.0 <= parsed <= 1.0: + warning_list.append( + f"Ignored invalid TIMEPIXR value from {source}; TIMEPIXR must be " + "between 0 and 1." + ) + continue + output[output_name] = { + "value": parsed, + "source": source, + **({"decimal": exact_value} if exact_value is not None else {}), + } + + for output_name, keys in { + "timeunit": ["TIMEUNIT"], + "timesys": ["TIMESYS"], + "timeref": ["TIMEREF"], + "date_observation": ["DATE-OBS"], + "observation_id": ["OBS_ID", "OBSID"], + "object": ["OBJECT"], + }.items(): + value, source = cls._find_header_value(header_sources, keys) + if value is not None: + output[output_name] = {"value": value, "source": source} + + raw_mjdref, mjdref_source = cls._find_header_value(header_sources, ["MJDREF"]) + raw_mjdref_card = cls._find_raw_card_value(header_sources, ["MJDREF"]) + mjdref = _safe_float(raw_mjdref) + mjdref_decimal = ( + raw_mjdref_card + if raw_mjdref_card is not None and mjdref is not None + else str(raw_mjdref) + if mjdref is not None + else None + ) + mjdref_components = None + if raw_mjdref is not None and mjdref is None: + warning_list.append( + f"Ignored malformed non-finite MJDREF value from {mjdref_source}." + ) + if mjdref is None: + mjdrefi, source_i = cls._find_header_value(header_sources, ["MJDREFI"]) + mjdreff, source_f = cls._find_header_value(header_sources, ["MJDREFF"]) + raw_mjdrefi_card = cls._find_raw_card_value(header_sources, ["MJDREFI"]) + raw_mjdreff_card = cls._find_raw_card_value(header_sources, ["MJDREFF"]) + exact_i = raw_mjdrefi_card or ( + str(mjdrefi) if mjdrefi is not None else None + ) + exact_f = raw_mjdreff_card or ( + str(mjdreff) if mjdreff is not None else None + ) + base_i = _safe_float(mjdrefi) + base_f = _safe_float(mjdreff) + if (mjdrefi is None) != (mjdreff is None): + warning_list.append( + "Ignored incomplete split MJDREF metadata; both MJDREFI and " + "MJDREFF are required by Stingray's high-precision reader." + ) + if mjdrefi is not None and base_i is None: + warning_list.append( + f"Ignored malformed non-finite MJDREFI value from {source_i}." + ) + if mjdreff is not None and base_f is None: + warning_list.append( + f"Ignored malformed non-finite MJDREFF value from {source_f}; " + "it was not substituted with zero." + ) + if ( + mjdrefi is not None + and mjdreff is not None + and base_i is not None + and base_f is not None + ): + with np.errstate(over="ignore", invalid="ignore"): + combined_mjdref = base_i + base_f + if not math.isfinite(combined_mjdref): + warning_list.append( + "Ignored split MJDREF metadata because MJDREFI + MJDREFF " + "is not representable as a finite value." + ) + else: + mjdref = combined_mjdref + mjdref_source = f"{source_i} + {source_f}" + mjdref_components = { + "integer": {"value": exact_i, "source": source_i}, + "fraction": { + "value": exact_f, + "source": source_f, + }, + } + try: + mjdref_decimal = format( + Decimal(str(exact_i).replace("D", "E").replace("d", "e")) + + Decimal(str(exact_f).replace("D", "E").replace("d", "e")), + "f", + ) + except (InvalidOperation, ValueError): + mjdref_decimal = str(mjdref) + if mjdref is None and event_list is not None: + candidate = _safe_float(getattr(event_list, "mjdref", None)) + if candidate is not None and candidate > 0: + mjdref = candidate + mjdref_source = "EventList.mjdref" + mjdref_decimal = str(getattr(event_list, "mjdref")) + if mjdref is not None: + output["mjdref"] = { + "value": mjdref, + "decimal": mjdref_decimal, + "source": mjdref_source, + "components": mjdref_components, + } + return output + + def list_capabilities(self) -> dict[str, Any]: + """List runtime mission mappings and operation-specific support.""" + try: + raw_count = len(read_mission_info()) + database = self._runtime_database() + missions: list[dict[str, Any]] = [] + warnings: list[str] = [] + for key in sorted(database): + name, info = database[key] + interpreter = mission_specific_event_interpretation(name) + if key == "xte": + rough = { + "status": "conditional", + "approximate": True, + "dependencies": [ + "mission", + "instrument=PCA", + "epoch_mjd", + "detector_id", + ], + "message": ( + "Approximate RXTE PCA conversion requires an observation MJD and " + "a detector ID (PCU 0-4) for every PI/PHA channel." + ), + "epoch_mjd_domain": { + "minimum_exclusive": XTE_PCA_EPOCH_MJD_MIN_EXCLUSIVE, + "maximum_inclusive": XTE_PCA_EPOCH_MJD_MAX_INCLUSIVE, + }, + } + else: + try: + get_rough_conversion_function(name) + except (ValueError, TypeError, AttributeError, KeyError): + rough = { + "status": "unsupported", + "approximate": False, + "dependencies": [], + "message": "No public rough PI-to-energy conversion is available.", + } + else: + rough = { + "status": "supported", + "approximate": True, + "dependencies": ["mission"], + "message": "A public approximate PI-to-energy conversion is available.", + } + + instruments = _normalise_mapping_value(info.get("instruments")) + modes = _normalise_mapping_value(info.get("modes")) + instrument_choices = _runtime_choices(instruments) + if instrument_choices: + instrument_modes = { + str(instrument): selected_modes + for instrument in instrument_choices + if ( + selected_modes := self._runtime_modes_for_instrument( + info, str(instrument) + ) + ) + is not None + } + if instrument_modes: + modes = instrument_modes + + missions.append( + { + "mission": name, + "mapping": self._mapping_for(info, None, None), + "instruments": instruments, + "modes": modes, + "rough_pi_to_energy": rough, + "specialized_interpretation": { + "supported": interpreter is not None, + "scope": ( + "XTE PCA science-event FITS (XTE_SE, TEVTB2 and PHA)" + if key == "xte" and interpreter is not None + else None + ), + }, + } + ) + + data = { + "missions": missions, + "mission_count": len(missions), + "raw_database_entry_count": raw_count, + "database_source": "runtime stingray.mission_support.read_mission_info", + "support_note": ( + "Mission database mappings describe FITS layout only; they do not imply " + "rough-conversion or specialized-interpretation support." + ), + "precise_calibration": { + "method": "RMF-based PI-to-energy conversion", + "location": "General I/O", + }, + "provenance": operation_provenance( + "mission_io.list_capabilities", + input_source={ + "type": "runtime_mission_database", + "provider": "stingray.mission_support.read_mission_info", + }, + parameters={}, + read_only=True, + source_modified=False, + ), + } + safe_data = json_safe(data, warnings) + safe_data["warnings"] = list(warnings) + return self.create_result( + success=True, + data=safe_data, + message=f"Found {len(missions)} runtime mission mappings", + error=None, + warnings=warnings, + ) + except Exception as exc: + return self.handle_error(exc, "Listing runtime mission capabilities") + + def get_mission_info( + self, + mission: str, + *, + instrument: Optional[str] = None, + mode: Optional[str] = None, + ) -> dict[str, Any]: + """Return the selected runtime mapping and capability details.""" + try: + database = self._runtime_database() + canonical, info, inferred = self._resolve_mission(mission, database) + if canonical is None: + return self._failure("Mission is required") + if info is None: + return self._failure( + f"Mission '{canonical}' is not present in Stingray's runtime mission database" + ) + + warnings: list[str] = [] + if inferred: + warnings.append( + f"Mission name '{mission}' was mapped to xselect database name '{canonical}'." + ) + key = canonical.casefold() + interpreter = mission_specific_event_interpretation(canonical) + selected_instrument = _clean_text(instrument) + available_instruments = _normalise_mapping_value(info.get("instruments")) + instrument_choices = _runtime_choices(available_instruments) + if selected_instrument is not None and instrument_choices: + canonical_instrument = _canonical_runtime_choice( + selected_instrument, available_instruments + ) + if canonical_instrument is None: + return self._failure( + f"Instrument '{selected_instrument}' is not defined for {canonical}; " + f"available instruments: {', '.join(instrument_choices)}" + ) + selected_instrument = canonical_instrument + + selected_mode = _clean_text(mode) + available_modes = self._runtime_modes_for_instrument( + info, + selected_instrument, + ) + mode_choices = _runtime_choices(available_modes) + if ( + selected_mode is not None + and selected_instrument is None + and instrument_choices + and not mode_choices + ): + return self._failure( + f"Mode '{selected_mode}' requires an instrument selection for " + f"{canonical}; available instruments: {', '.join(instrument_choices)}" + ) + if selected_mode is not None and mode_choices: + canonical_mode = _canonical_runtime_choice( + selected_mode, available_modes + ) + if canonical_mode is None: + preview = ", ".join(mode_choices[:8]) + suffix = " ..." if len(mode_choices) > 8 else "" + return self._failure( + f"Mode '{selected_mode}' is not defined for {canonical}" + + ( + f"/{selected_instrument}" + if selected_instrument is not None + else "" + ) + + f"; available modes include: {preview}{suffix}" + ) + selected_mode = canonical_mode + specialized_supported = interpreter is not None and not ( + key == "xte" + and selected_instrument is not None + and selected_instrument.casefold() != "pca" + ) + if ( + key == "xte" + and selected_instrument is not None + and selected_instrument.casefold() != "pca" + ): + warnings.append( + "Specialized XTE interpretation is limited to PCA science-event FITS; " + f"instrument '{selected_instrument}' is not supported." + ) + if ( + key == "xte" + and selected_instrument is not None + and selected_instrument.casefold() != "pca" + ): + rough_status = "unsupported" + dependencies = [] + warnings.append( + "Approximate XTE PI-to-energy conversion is limited to the PCA; " + f"instrument '{selected_instrument}' is not supported." + ) + elif key == "xte": + rough_status = "conditional" + dependencies = ["instrument=PCA", "epoch_mjd", "detector_id"] + else: + try: + get_rough_conversion_function(canonical) + except (ValueError, TypeError, AttributeError, KeyError): + rough_status = "unsupported" + dependencies = [] + else: + rough_status = "supported" + dependencies = ["mission"] + + data = { + "mission": canonical, + "requested_mission": mission, + "mission_name_inferred": inferred, + "instrument": selected_instrument, + "mode": selected_mode, + "mapping": self._mapping_for(info, selected_instrument, selected_mode), + "available_instruments": available_instruments, + "available_modes": available_modes, + "capabilities": { + "rough_pi_to_energy": { + "status": rough_status, + "approximate": rough_status != "unsupported", + "dependencies": dependencies, + "epoch_mjd_domain": ( + { + "minimum_exclusive": XTE_PCA_EPOCH_MJD_MIN_EXCLUSIVE, + "maximum_inclusive": XTE_PCA_EPOCH_MJD_MAX_INCLUSIVE, + } + if key == "xte" and rough_status != "unsupported" + else None + ), + }, + "specialized_interpretation": { + "supported": specialized_supported, + "scope": ( + "XTE PCA science-event FITS (XTE_SE, TEVTB2 and PHA)" + if key == "xte" and interpreter is not None + else None + ), + }, + }, + "precise_calibration": { + "method": "RMF-based PI-to-energy conversion", + "location": "General I/O", + }, + "provenance": operation_provenance( + "mission_io.get_mission_info", + input_source={ + "type": "runtime_mission_database", + "provider": "stingray.mission_support.read_mission_info", + }, + parameters={ + "requested_mission": mission, + "resolved_mission": canonical, + "instrument": selected_instrument, + "mode": selected_mode, + }, + read_only=True, + source_modified=False, + ), + } + safe_data = json_safe(data, warnings) + safe_data["warnings"] = list(warnings) + return self.create_result( + success=True, + data=safe_data, + message=f"Runtime mapping for {canonical}", + error=None, + warnings=warnings, + ) + except Exception as exc: + return self.handle_error(exc, "Reading mission mapping", mission=mission) + + def identify_source( + self, + *, + event_list_name: Optional[str] = None, + file_path: Optional[str] = None, + file_grant: Optional[str] = None, + mission_override: Optional[str] = None, + instrument_override: Optional[str] = None, + mode_override: Optional[str] = None, + ) -> dict[str, Any]: + """Identify mission fields in one loaded EventList or selected FITS file.""" + if bool(event_list_name) == bool(file_path): + return self._failure( + "Select exactly one source: a loaded EventList or an explicitly selected FITS file" + ) + try: + if event_list_name: + _, identified, warnings, error = self._identify_event_list( + event_list_name, + max_events=MAX_EXPORT_ROWS, + mission_override=mission_override, + instrument_override=instrument_override, + mode_override=mode_override, + ) + if error: + return self._failure(error) + else: + if not file_grant: + return self._failure( + "A native file-selection grant is required for the selected FITS path" + ) + with open_verified_read_grant(file_path or "", file_grant) as granted: + size = validate_file_size( + granted.stream, + MAX_FITS_INSPECT_BYTES, + "Selected FITS file", + ) + identified, warnings, error = self._identify_fits( + granted.stream, + display_path=str(granted.path), + size_bytes=size, + mission_override=mission_override, + instrument_override=instrument_override, + mode_override=mode_override, + ) + if error: + return self._failure(error) + + assert identified is not None + identified["provenance"] = operation_provenance( + "mission_io.identify_source", + input_source=identified["source"], + parameters={ + "mission_override": _clean_text(mission_override), + "instrument_override": _clean_text(instrument_override), + "mode_override": _clean_text(mode_override), + }, + read_only=True, + source_modified=False, + ) + safe_data = json_safe(identified, warnings) + safe_data["warnings"] = list(warnings) + return self.create_result( + success=True, + data=safe_data, + message="Mission metadata inspected", + error=None, + warnings=warnings, + ) + except (PermissionError, FileNotFoundError, ValueError, OSError) as exc: + return self._failure(str(exc)) + except Exception as exc: + return self.handle_error(exc, "Identifying mission metadata") + + @staticmethod + def _validate_pi(values: Any) -> tuple[Optional[np.ndarray], Optional[str]]: + array, error = validate_finite_array( + values, + label="PI values", + min_size=1, + max_size=MAX_ARRAY_INPUT, + ) + if error: + return None, error + assert array is not None + negative = np.flatnonzero(array < 0) + if negative.size: + return None, f"PI values[{int(negative[0])}] must be non-negative" + fractional = np.flatnonzero(array != np.floor(array)) + if fractional.size: + return None, f"PI values[{int(fractional[0])}] must be an integer channel" + too_large = np.flatnonzero(array > np.iinfo(np.int32).max) + if too_large.size: + return ( + None, + f"PI values[{int(too_large[0])}] exceeds the supported channel range", + ) + return array.astype(np.int64), None + + @staticmethod + def _validate_detector_ids( + values: Any, + *, + expected_size: int, + ) -> tuple[Optional[np.ndarray], Optional[str]]: + array, error = validate_finite_array( + values, + label="Detector IDs", + min_size=1, + max_size=MAX_ARRAY_INPUT, + ) + if error: + return None, error + assert array is not None + fractional = np.flatnonzero(array != np.floor(array)) + if fractional.size: + return None, f"Detector IDs[{int(fractional[0])}] must be an integer" + invalid = np.flatnonzero((array < 0) | (array > 4)) + if invalid.size: + return ( + None, + f"Detector IDs[{int(invalid[0])}] must be in the RXTE PCU range 0-4", + ) + if array.size == 1 and expected_size > 1: + array = np.full(expected_size, array[0]) + elif array.size != expected_size: + return None, ( + f"Detector IDs contains {array.size} value(s), but {expected_size} PI values " + "were supplied; provide one ID to broadcast or one per channel" + ) + return array.astype(np.int64), None + + @classmethod + def _derive_event_epoch( + cls, + event_list: Any, + warnings: Optional[list[str]] = None, + ) -> tuple[Optional[float], Optional[str]]: + """Derive an observation MJD from explicit numeric timing metadata.""" + warning_list = warnings if warnings is not None else [] + header = _header_to_mapping(getattr(event_list, "header", None)) + header_sources = [("EventList.header", header)] if header else [] + mjd_observation = None + mjd_observation_source = None + for key in ["MJD-OBS", "MJD_OBS", "MJDOBS"]: + value, source = cls._find_header_value(header_sources, [key]) + parsed = _safe_float(value) + if parsed is not None: + mjd_observation = parsed + mjd_observation_source = source + break + + mjdref_value, mjdref_source = cls._find_header_value(header_sources, ["MJDREF"]) + mjdref = _safe_float(mjdref_value) + if mjdref_value is not None and mjdref is None: + return None, None + if mjdref_value is None: + part_i, source_i = cls._find_header_value(header_sources, ["MJDREFI"]) + part_f, source_f = cls._find_header_value(header_sources, ["MJDREFF"]) + parsed_i = _safe_float(part_i) + parsed_f = _safe_float(part_f) + if (part_i is None) != (part_f is None): + return None, None + if part_i is not None and (parsed_i is None or parsed_f is None): + return None, None + if parsed_i is not None and parsed_f is not None: + with np.errstate(over="ignore", invalid="ignore"): + combined_mjdref = parsed_i + parsed_f + if not math.isfinite(combined_mjdref): + return None, None + mjdref = combined_mjdref + mjdref_source = f"{source_i} + {source_f}" + if mjdref is None: + mjdref = _safe_float(getattr(event_list, "mjdref", None)) + if mjdref is not None and mjdref > 0: + mjdref_source = "EventList.mjdref" + else: + mjdref = None + + tstart_value, tstart_source, _ = cls._find_high_precision_header_value( + header_sources, "TSTART" + ) + tstart = _safe_float(tstart_value) + if tstart_value is not None and tstart is None: + return None, None + offset_days: Optional[float] = None + if tstart is not None: + timezero_value, timezero_source, _ = cls._find_high_precision_header_value( + header_sources, "TIMEZERO" + ) + timezero = 0.0 if timezero_value is None else _safe_float(timezero_value) + if timezero is None: + return None, None + + timedel_value, timedel_source, _ = cls._find_high_precision_header_value( + header_sources, "TIMEDEL" + ) + timedel = 0.0 if timedel_value is None else _safe_float(timedel_value) + if timedel is None or timedel < 0: + return None, None + + timepixr_value, timepixr_source, _ = cls._find_high_precision_header_value( + header_sources, "TIMEPIXR" + ) + timepixr = None if timepixr_value is None else _safe_float(timepixr_value) + if timepixr_value is not None and ( + timepixr is None or not 0.0 <= timepixr <= 1.0 + ): + return None, None + if ( + timepixr is not None + and not math.isclose(timepixr, 0.5, rel_tol=0.0, abs_tol=0.0) + and (timedel_value is None or timedel <= 0) + ): + # A non-central reference requires a real bin width. Treat a + # missing/zero TIMEDEL as malformed rather than inventing a + # zero correction that can select the wrong gain epoch. + return None, None + + # Match the installed public FITSTimeseriesReader timing transform: + # event times and t_start receive TIMEZERO plus the TIMEPIXR bin + # reference correction. Deriving the calibration epoch from raw + # TSTART alone can otherwise be wrong by whole days. + adjusted_tstart = tstart + timezero + timing_sources = [str(tstart_source)] + if timezero_value is not None: + timing_sources.append(str(timezero_source)) + if timepixr is not None: + adjusted_tstart += (0.5 - timepixr) * timedel + timing_sources.extend( + [str(timepixr_source), str(timedel_source or "TIMEDEL default 0")] + ) + if not math.isfinite(adjusted_tstart): + return None, None + + time_unit_value, time_unit_source = cls._find_header_value( + header_sources, ["TIMEUNIT"] + ) + time_unit = (_clean_text(time_unit_value) or "s").casefold() + seconds_per_unit = TIME_UNIT_SECONDS.get(time_unit) + if seconds_per_unit is None: + return None, None + offset_days = adjusted_tstart * seconds_per_unit / 86400.0 + if not math.isfinite(offset_days): + return None, None + tstart_source = " + ".join(timing_sources) + if time_unit_source is not None: + tstart_source = ( + f"{tstart_source} ({time_unit_source}={time_unit_value})" + ) + else: + explicit_t_start = _safe_float(getattr(event_list, "t_start", None)) + explicit_gti = getattr(event_list, "_gti", None) + gti_start: Optional[float] = None + if explicit_gti is not None: + with np.errstate(over="ignore", invalid="ignore"): + gti_array = np.asarray(explicit_gti, dtype=float) + if ( + gti_array.ndim == 2 + and gti_array.shape[0] > 0 + and gti_array.shape[1] == 2 + ): + gti_start = _safe_float(gti_array[0, 0]) + times = np.asarray(getattr(event_list, "time", []), dtype=float) + if explicit_t_start is not None: + tstart = explicit_t_start + tstart_source = "EventList.t_start" + elif gti_start is not None: + tstart = gti_start + tstart_source = "EventList._gti start" + elif times.size and np.isfinite(times).all(): + tstart = float(np.min(times)) + tstart_source = "minimum EventList.time" + if tstart is not None: + time_unit_value, time_unit_source = cls._find_header_value( + header_sources, ["TIMEUNIT"] + ) + time_unit = (_clean_text(time_unit_value) or "s").casefold() + seconds_per_unit = TIME_UNIT_SECONDS.get(time_unit) + if seconds_per_unit is None: + return None, None + offset_days = tstart * seconds_per_unit / 86400.0 + if time_unit_source is not None: + tstart_source = ( + f"{tstart_source} ({time_unit_source}={time_unit_value})" + ) + if mjdref is not None and offset_days is not None: + epoch = mjdref + offset_days + if math.isfinite(epoch): + if mjd_observation is not None: + comparison_tolerance = 8.0 * max( + abs(float(np.spacing(epoch))), + abs(float(np.spacing(mjd_observation))), + ) + if abs(epoch - mjd_observation) > comparison_tolerance: + warning_list.append( + f"MJD-OBS ({mjd_observation:g} from " + f"{mjd_observation_source}) conflicts with the event-time " + f"reference epoch ({epoch:g}); calibration used MJDREF plus " + "the timing applied to EventList events." + ) + return epoch, f"{mjdref_source} + {tstart_source} in days" + if mjd_observation is not None: + return mjd_observation, mjd_observation_source + return None, None + + def convert_pi_to_energy( + self, + *, + pi_values: Optional[Sequence[float]] = None, + event_list_name: Optional[str] = None, + mission_override: Optional[str] = None, + instrument_override: Optional[str] = None, + mode_override: Optional[str] = None, + epoch_mjd: Optional[float] = None, + detector_ids: Optional[Sequence[int]] = None, + save_as: Optional[str] = None, + ) -> dict[str, Any]: + """Run a public Stingray rough PI-to-energy conversion. + + This method never mutates a loaded source. ``save_as`` atomically adds + a detached EventList with the original PI channels and new energy array. + """ + if (pi_values is None) == (event_list_name is None): + return self._failure( + "Provide exactly one PI source: pasted PI values or a loaded EventList" + ) + if save_as is not None and event_list_name is None: + return self._failure( + "Save as is available only for a loaded EventList source" + ) + + warnings = [ + "APPROXIMATE conversion: rough mission relations are not a substitute for " + "RMF-calibrated energy conversion. Use RMF calibration in General I/O for " + "precise energies." + ] + source_event = None + identified: dict[str, Any] + if event_list_name is not None: + source_event, identified, identify_warnings, error = ( + self._identify_event_list( + event_list_name, + max_events=MAX_ARRAY_INPUT, + mission_override=mission_override, + instrument_override=instrument_override, + mode_override=mode_override, + ) + ) + if error: + return self._failure(error, warnings=warnings) + assert source_event is not None and identified is not None + warnings.extend(identify_warnings) + raw_pi = getattr(source_event, "pi", None) + if raw_pi is None: + return self._failure( + f"EventList '{event_list_name}' has no PI/channel data to convert", + warnings=warnings, + ) + input_source = {"type": "loaded_event_list", "name": event_list_name} + else: + try: + identified, identify_warnings = self._identify_parts( + source={"type": "pasted_pi_values"}, + header_sources=[], + mission_override=mission_override, + instrument_override=instrument_override, + mode_override=mode_override, + ) + except ValueError as exc: + return self._failure(str(exc), warnings=warnings) + warnings.extend(identify_warnings) + raw_pi = pi_values + input_source = {"type": "pasted_pi_values"} + + mapping_validation_error = identified.get("mapping_validation_error") + if mapping_validation_error is not None: + return self._failure(mapping_validation_error, warnings=warnings) + + pi_array, error = self._validate_pi(raw_pi) + if error: + return self._failure(error, warnings=warnings) + assert pi_array is not None + if source_event is not None: + time_array = np.asarray(getattr(source_event, "time", [])) + if time_array.ndim != 1: + return self._failure( + f"EventList '{event_list_name}' time data must be one-dimensional", + warnings=warnings, + ) + if time_array.size != pi_array.size: + return self._failure( + f"EventList '{event_list_name}' has {time_array.size:,} time value(s) " + f"but {pi_array.size:,} PI/channel value(s); conversion requires " + "one channel per event", + warnings=warnings, + ) + + mission = identified["mission"]["value"] + instrument = identified["instrument"]["value"] + if mission is None: + return self._failure( + "Mission metadata is missing; supply a clearly labelled mission override", + warnings=warnings, + ) + mission_key = mission.casefold() + + parsed_epoch = None + requested_epoch = None + epoch_source = None + if epoch_mjd is not None: + requested_epoch = _safe_float(epoch_mjd) + if requested_epoch is None or requested_epoch <= 0: + return self._failure( + "Epoch MJD must be a positive finite value", warnings=warnings + ) + if mission_key == "xte": + parsed_epoch = requested_epoch + epoch_source = "request.epoch_mjd" + else: + warnings.append( + f"Epoch MJD was supplied but is not used by the {mission} rough " + "conversion." + ) + elif mission_key == "xte" and source_event is not None: + parsed_epoch, epoch_source = self._derive_event_epoch( + source_event, warnings + ) + + detector_array = None + detector_source = None + if mission_key == "xte": + if instrument is None: + return self._failure( + "RXTE rough conversion requires instrument metadata; only PCA is supported", + warnings=warnings, + ) + if instrument.casefold() != "pca": + return self._failure( + f"RXTE instrument '{instrument}' is unsupported for rough conversion; " + "Stingray 2.2.10 supports PCA only", + warnings=warnings, + ) + if parsed_epoch is None: + return self._failure( + "RXTE PCA rough conversion requires the observation epoch in MJD", + warnings=warnings, + ) + if not ( + XTE_PCA_EPOCH_MJD_MIN_EXCLUSIVE + < parsed_epoch + <= XTE_PCA_EPOCH_MJD_MAX_INCLUSIVE + ): + return self._failure( + "RXTE PCA rough conversion supports epochs only in the calibrated " + f"range {XTE_PCA_EPOCH_MJD_MIN_EXCLUSIVE:g} < MJD <= " + f"{XTE_PCA_EPOCH_MJD_MAX_INCLUSIVE:g}; supply an epoch within " + "that range", + warnings=warnings, + ) + if np.any(pi_array > 255): + index = int(np.flatnonzero(pi_array > 255)[0]) + return self._failure( + f"PI values[{index}] must be in the RXTE PCA channel range 0-255", + warnings=warnings, + ) + + source_detector = ( + getattr(source_event, "detector_id", None) + if source_event is not None + else None + ) + if source_detector is not None and detector_ids is not None: + source_checked, source_error = self._validate_detector_ids( + source_detector, expected_size=pi_array.size + ) + requested_checked, requested_error = self._validate_detector_ids( + detector_ids, expected_size=pi_array.size + ) + if source_error: + return self._failure(source_error, warnings=warnings) + if requested_error: + return self._failure(requested_error, warnings=warnings) + if not np.array_equal(source_checked, requested_checked): + return self._failure( + "Detector IDs are already present on the EventList; a conflicting " + "request override is not allowed", + warnings=warnings, + ) + detector_array = source_checked + detector_source = "EventList.detector_id" + else: + detector_input = ( + source_detector if source_detector is not None else detector_ids + ) + if detector_input is None: + return self._failure( + "RXTE PCA rough conversion requires detector IDs (PCU 0-4), one to " + "broadcast or one per PI value", + warnings=warnings, + ) + detector_array, detector_error = self._validate_detector_ids( + detector_input, expected_size=pi_array.size + ) + if detector_error: + return self._failure(detector_error, warnings=warnings) + detector_source = ( + "EventList.detector_id" + if source_detector is not None + else "request.detector_ids" + ) + elif detector_ids is not None: + warnings.append( + f"Detector IDs were supplied but are not used by the {mission} rough conversion." + ) + + if mission_key == "axaf" and np.any(pi_array < 1): + index = int(np.flatnonzero(pi_array < 1)[0]) + return self._failure( + f"PI values[{index}] must be at least 1 for the AXAF/Chandra rough " + "conversion so the approximate photon energy remains non-negative", + warnings=warnings, + ) + + try: + conversion = get_rough_conversion_function( + mission, + instrument=instrument, + epoch=parsed_epoch, + ) + if mission_key == "xte": + assert detector_array is not None + energies = conversion(pi_array, detector_id=detector_array) + else: + energies = conversion(pi_array) + except (ValueError, TypeError, KeyError, IndexError, AttributeError) as exc: + if mission_key == "xte": + message = f"RXTE PCA rough conversion is unavailable for the supplied instrument/epoch: {exc}" + else: + message = ( + f"No public rough PI-to-energy conversion is available for mission " + f"'{mission}': {exc}" + ) + return self._failure(message, warnings=warnings) + + energy_array = np.asarray(energies, dtype=float) + if energy_array.shape != pi_array.shape: + return self._failure( + "Stingray returned an unexpected energy-array shape", warnings=warnings + ) + bad_energy = np.flatnonzero(~np.isfinite(energy_array)) + if bad_energy.size: + warnings.append( + f"Stingray returned {bad_energy.size:,} non-finite approximate energy " + "value(s); JSON previews represent those values as null." + ) + negative_energy = np.flatnonzero(energy_array < 0) + if negative_energy.size: + index = int(negative_energy[0]) + return self._failure( + "Stingray returned a negative approximate photon energy at PI value " + f"index {index}; the result was withheld", + warnings=warnings, + ) + + detector_parameters = None + if detector_array is not None: + canonical_detector_bytes = np.asarray(detector_array, dtype=" PREVIEW_ROWS), + } + + provenance = operation_provenance( + "mission_io.approximate_pi_to_energy", + input_source=input_source, + parameters={ + "mission": mission, + "instrument": instrument, + "mode": identified["mode"]["value"], + "epoch_mjd": parsed_epoch, + "requested_epoch_mjd": requested_epoch, + "detector_ids": detector_parameters, + }, + conversion_type="rough_approximate", + approximate=True, + energy_unit="keV", + precise_calibration_path="RMF-based conversion in General I/O", + ) + + saved_name = None + if save_as is not None: + name_error = validate_derived_name(save_as) + if name_error: + return self._failure(name_error, warnings=warnings) + assert source_event is not None and event_list_name is not None + source_event.energy = energy_array.copy() + source_event.pi = np.asarray(source_event.pi).copy() + if detector_array is not None: + source_event.detector_id = detector_array.copy() + if not _clean_text(getattr(source_event, "mission", None)): + source_event.mission = mission + if instrument is not None and not _clean_text( + getattr(source_event, "instr", None) + ): + source_event.instr = instrument + provenance_json = json.dumps( + json_safe(provenance, warnings), allow_nan=False, sort_keys=True + ) + source_event.mission_io_conversion_type = "rough_approximate" + source_event.mission_io_source_name = event_list_name + source_event.mission_io_mission = mission + source_event.mission_io_instrument = instrument or "" + source_event.mission_io_epoch_mjd = parsed_epoch + source_event.mission_io_provenance_json = provenance_json + note = ( + f"Mission I/O: APPROXIMATE rough PI-to-energy conversion from " + f"'{event_list_name}' ({mission}; precise path: RMF in General I/O)." + ) + old_notes = _clean_text(getattr(source_event, "notes", None)) + source_event.notes = f"{old_notes}\n{note}" if old_notes else note + if not self.state.add_event_data_if_absent(save_as, source_event): + return self._failure( + f"Destination EventList name '{save_as}' already exists; choose a unique name", + warnings=warnings, + ) + saved_name = save_as + + preview_count = min(pi_array.size, PREVIEW_ROWS) + rows = [] + for index in range(preview_count): + row: dict[str, Any] = { + "index": index, + "pi": int(pi_array[index]), + "energy_kev": ( + float(energy_array[index]) + if math.isfinite(float(energy_array[index])) + else None + ), + } + if detector_array is not None: + row["detector_id"] = int(detector_array[index]) + rows.append(row) + + data = { + "label": "APPROXIMATE rough PI-to-energy conversion", + "conversion_type": "rough_approximate", + "approximate": True, + "energy_unit": "keV", + "mission": identified["mission"], + "instrument": identified["instrument"], + "mode": identified["mode"], + "dependencies": { + "mission": {"required": True, "value": mission}, + "instrument": { + "required": mission_key == "xte", + "used": mission_key == "xte", + "value": instrument, + }, + "epoch_mjd": { + "required": mission_key == "xte", + "used": mission_key == "xte", + "value": parsed_epoch, + "requested_value": requested_epoch, + "source": epoch_source, + }, + "detector_id": { + "required": mission_key == "xte", + "used": mission_key == "xte", + "source": detector_source, + }, + }, + "count": int(pi_array.size), + "rows": rows, + "preview_count": preview_count, + "preview_truncated": preview_count < pi_array.size, + "saved_event_list": saved_name, + "precise_calibration": { + "method": "RMF-based PI-to-energy conversion", + "location": "General I/O", + }, + "provenance": provenance, + } + safe_data = json_safe(data, warnings) + safe_data["warnings"] = list(warnings) + return self.create_result( + success=True, + data=safe_data, + message=( + f"Approximately converted {pi_array.size} PI channel(s) to keV" + + (f" and saved EventList '{saved_name}'" if saved_name else "") + ), + error=None, + warnings=warnings, + ) + + def interpret_selected_fits( + self, + *, + file_path: str, + file_grant: str, + mission_override: Optional[str] = None, + instrument_override: Optional[str] = None, + mode_override: Optional[str] = None, + ) -> dict[str, Any]: + """Run supported mission-specific FITS interpretation on a bounded copy.""" + try: + with open_verified_read_grant(file_path, file_grant) as granted: + size = validate_file_size( + granted.stream, + MAX_INTERPRET_FITS_BYTES, + "Mission-specific interpretation input", + ) + identified, warnings, error = self._identify_fits( + granted.stream, + display_path=str(granted.path), + size_bytes=size, + mission_override=mission_override, + instrument_override=instrument_override, + mode_override=mode_override, + ) + if error: + return self._failure(error) + assert identified is not None + mission = identified["mission"]["value"] + if mission is None: + return self._failure( + "Mission metadata is missing; supply a mission override before interpretation", + warnings=warnings, + ) + interpreter = mission_specific_event_interpretation(mission) + if interpreter is None: + return self._failure( + f"Stingray 2.2.10 has no specialized event interpretation for mission " + f"'{mission}'. Only XTE is currently supported.", + warnings=warnings, + ) + instrument = _clean_text(identified["instrument"]["value"]) + if mission.casefold() == "xte": + if instrument is None: + return self._failure( + "XTE specialized interpretation requires instrument metadata; only " + "PCA science-event FITS is supported.", + warnings=warnings, + ) + if instrument.casefold() != "pca": + return self._failure( + f"XTE instrument '{instrument}' is unsupported for specialized " + "interpretation; only PCA science-event FITS is supported.", + warnings=warnings, + ) + + with duplicate_binary_stream(granted.stream) as fits_stream: + hdulist_context = fits.open( + fits_stream, + mode="readonly", + memmap=True, + lazy_load_hdus=True, + ) + with hdulist_context as hdulist: + if len(hdulist) > MAX_FITS_HDUS: + return self._failure( + f"FITS file has {len(hdulist):,} HDUs; the interpretation cap is " + f"{MAX_FITS_HDUS:,}", + warnings=warnings, + ) + if "XTE_SE" not in hdulist: + return self._failure( + "The selected XTE file has no XTE_SE extension. Stingray's public " + "interpreter currently supports science-event files only.", + warnings=warnings, + ) + source_hdu = hdulist["XTE_SE"] + row_count = int(source_hdu.header.get("NAXIS2", 0)) + if row_count > MAX_ARRAY_INPUT: + return self._failure( + f"XTE_SE contains {row_count:,} rows; the read-only interpretation " + f"cap is {MAX_ARRAY_INPUT:,}", + warnings=warnings, + ) + if ( + source_hdu.data is None + or "PHA" not in source_hdu.columns.names + ): + return self._failure( + "The XTE_SE extension has no PHA column to interpret", + warnings=warnings, + ) + copied_hdu = source_hdu.copy() + original_pha = np.asarray(copied_hdu.data["PHA"]).copy() + + interpreted_hdu = interpreter(copied_hdu) + interpreted_pha = np.asarray(interpreted_hdu.data["PHA"]) + if interpreted_pha.shape != original_pha.shape: + return self._failure( + "Stingray returned an unexpected interpreted PHA shape", + warnings=warnings, + ) + changed = original_pha != interpreted_pha + preview_count = min(original_pha.size, PREVIEW_ROWS) + rows = [ + { + "index": index, + "original_pha": int(original_pha[index]), + "interpreted_pha": int(interpreted_pha[index]), + "changed": bool(changed[index]), + } + for index in range(preview_count) + ] + warnings.append( + "Read-only interpretation changes local RXTE PHA channel encoding on an " + "in-memory copy; it does not calibrate channels to energy." + ) + provenance = operation_provenance( + "mission_io.mission_specific_event_interpretation", + input_source=identified["source"], + parameters={ + "mission": mission, + "instrument": identified["instrument"]["value"], + "mode": identified["mode"]["value"], + }, + read_only=True, + source_modified=False, + ) + data = { + "label": "Read-only mission-specific event interpretation", + "mission": identified["mission"], + "instrument": identified["instrument"], + "mode": identified["mode"], + "supported_scope": "XTE PCA science-event FITS (XTE_SE, TEVTB2 and PHA)", + "read_only": True, + "source_modified": False, + "hdu": "XTE_SE", + "event_count": int(original_pha.size), + "changed_count": int(np.count_nonzero(changed)), + "original_pha_range": ( + [int(np.min(original_pha)), int(np.max(original_pha))] + if original_pha.size + else None + ), + "interpreted_pha_range": ( + [int(np.min(interpreted_pha)), int(np.max(interpreted_pha))] + if interpreted_pha.size + else None + ), + "rows": rows, + "preview_count": preview_count, + "preview_truncated": preview_count < original_pha.size, + "provenance": provenance, + } + safe_data = json_safe(data, warnings) + safe_data["warnings"] = list(warnings) + return self.create_result( + success=True, + data=safe_data, + message="Mission-specific FITS interpretation summarized without changing the file", + error=None, + warnings=warnings, + ) + except ( + PermissionError, + FileNotFoundError, + ValueError, + OSError, + KeyError, + ) as exc: + return self._failure(str(exc)) + except Exception as exc: + return self.handle_error(exc, "Interpreting selected mission FITS file") diff --git a/python-backend/services/remote_source.py b/python-backend/services/remote_source.py new file mode 100644 index 0000000..84bbfa7 --- /dev/null +++ b/python-backend/services/remote_source.py @@ -0,0 +1,934 @@ +"""Security boundary for fetching caller-supplied remote resources. + +The application must not hand arbitrary URLs directly to ``httpx``. This +module keeps URL validation, DNS/peer checks, redirect handling, deadlines, +and response-size limits in one reusable place. It deliberately exposes only +redacted URLs in metadata and exceptions. +""" + +from __future__ import annotations + +import asyncio +import inspect +import ipaddress +import math +import re +import socket +import time +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode, unquote, unquote_to_bytes, urljoin, urlsplit + +import httpx + +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") +_HOST_LABEL = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z") +_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) +_DEFAULT_CHUNK_SIZE = 64 * 1024 +_MAX_CHUNK_SIZE = 1024 * 1024 +_MAX_REDIRECTS = 10 +_MAX_REMOTE_URL_CHARS = 4_096 +_MAX_DISPLAY_PATH_CHARS = 512 + +IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address +Resolver = Callable[[str, int], Awaitable[Sequence[str | IPAddress]]] +Clock = Callable[[], float] +CancellationCheck = Callable[[], bool | None | Awaitable[bool | None]] + + +class RemoteSourceError(Exception): + """Base class for remote-source failures safe to show to a user.""" + + +class RemoteSourcePolicyError(RemoteSourceError): + """The supplied URL does not satisfy the selected remote-source policy.""" + + +class RemoteSourceResolutionError(RemoteSourceError): + """DNS resolution failed or produced an unsafe address.""" + + +class RemoteSourcePeerError(RemoteSourceError): + """The connected peer does not match the addresses validated before I/O.""" + + +class RemoteSourceRedirectError(RemoteSourceError): + """A redirect was missing, invalid, or exceeded the configured bound.""" + + +class RemoteSourceHTTPError(RemoteSourceError): + """The remote server returned a non-success status.""" + + def __init__(self, status_code: int, display_url: str) -> None: + self.status_code = status_code + self.display_url = display_url + super().__init__(f"Remote server returned HTTP {status_code} for {display_url}") + + +class RemoteSourceSizeError(RemoteSourceError): + """A response exceeded its caller-selected byte limit.""" + + +class RemoteSourceTimeout(RemoteSourceError): + """A phase or total remote-source deadline expired.""" + + +class RemoteSourceCancelled(RemoteSourceError): + """The caller requested cancellation while consuming a response.""" + + +@dataclass(frozen=True) +class RemoteSourcePolicy: + """Restrictions applied to an initial URL and every redirect target.""" + + name: str + required_host: str | None = None + required_path_prefix: str | None = None + required_path: str | None = None + + def __post_init__(self) -> None: + if self.required_path_prefix is not None and self.required_path is not None: + raise ValueError("a remote policy cannot require both a path and a prefix") + + +GENERAL_HTTPS_POLICY = RemoteSourcePolicy(name="general HTTPS") +HEASARC_ARCHIVE_POLICY = RemoteSourcePolicy( + name="HEASARC archive", + required_host="heasarc.gsfc.nasa.gov", + required_path_prefix="/FTP/", +) +HEASARC_TAP_POLICY = RemoteSourcePolicy( + name="HEASARC TAP", + required_host="heasarc.gsfc.nasa.gov", + required_path="/xamin/vo/tap/sync", +) +CDS_SESAME_POLICY = RemoteSourcePolicy( + name="CDS Sesame", + required_host="cds.unistra.fr", + required_path="/cgi-bin/nph-sesame/SNV", +) + + +@dataclass(frozen=True) +class RemoteTimeouts: + """Per-phase httpx limits plus a monotonic end-to-end deadline.""" + + connect: float = 10.0 + read: float = 30.0 + write: float = 10.0 + pool: float = 5.0 + total: float = 300.0 + + def __post_init__(self) -> None: + for field_name in ("connect", "read", "write", "pool", "total"): + value = getattr(self, field_name) + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(value) + or value <= 0 + ): + raise ValueError(f"{field_name} timeout must be positive") + + def as_httpx_timeout(self) -> httpx.Timeout: + return httpx.Timeout( + connect=self.connect, + read=self.read, + write=self.write, + pool=self.pool, + ) + + +@dataclass(frozen=True) +class ValidatedRemoteURL: + """Canonical request URL and the public addresses approved for this hop.""" + + url: httpx.URL + display_url: str + host: str + port: int + addresses: frozenset[IPAddress] + + +@dataclass(frozen=True) +class RemoteResponseInfo: + """Non-sensitive metadata for a successfully opened response.""" + + display_url: str + status_code: int + content_length: int | None + content_type: str | None + redirect_count: int + + +def _has_control_characters(value: str) -> bool: + return any(ord(char) < 0x20 or 0x7F <= ord(char) <= 0x9F for char in value) + + +def _validate_percent_encoding(value: str) -> None: + index = 0 + while True: + index = value.find("%", index) + if index < 0: + return + if ( + index + 2 >= len(value) + or value[index + 1] not in _HEX_DIGITS + or value[index + 2] not in _HEX_DIGITS + ): + raise RemoteSourcePolicyError( + "Remote URL contains invalid percent encoding" + ) + index += 3 + + +def _validate_decoded_octets(value: str) -> None: + try: + decoded = unquote(value, encoding="utf-8", errors="strict") + except UnicodeDecodeError as error: + raise RemoteSourcePolicyError("Remote URL is not valid UTF-8") from error + if _has_control_characters(decoded): + raise RemoteSourcePolicyError("Remote URL contains control characters") + if "\\" in decoded: + raise RemoteSourcePolicyError("Remote URL backslashes are not allowed") + + +def _authority_port(netloc: str) -> str | None: + """Extract an explicit port without accepting ambiguous authority syntax.""" + if netloc.startswith("["): + close = netloc.find("]") + if close < 0: + raise RemoteSourcePolicyError("Remote URL has an invalid IPv6 authority") + suffix = netloc[close + 1 :] + if not suffix: + return None + if not suffix.startswith(":"): + raise RemoteSourcePolicyError("Remote URL has an invalid authority") + return suffix[1:] + + if netloc.count(":") > 1: + raise RemoteSourcePolicyError("IPv6 remote hosts must use brackets") + if ":" not in netloc: + return None + return netloc.rsplit(":", 1)[1] + + +def _canonical_host(host: str) -> str: + if not host or "%" in host or host.endswith("."): + raise RemoteSourcePolicyError("Remote URL has a non-canonical host") + + try: + address = ipaddress.ip_address(host) + except ValueError: + try: + ascii_host = host.encode("idna").decode("ascii").lower() + except UnicodeError as error: + raise RemoteSourcePolicyError("Remote URL host is invalid") from error + + if len(ascii_host) > 253 or any( + not _HOST_LABEL.fullmatch(label) for label in ascii_host.split(".") + ): + raise RemoteSourcePolicyError("Remote URL host is invalid") + return ascii_host + + return address.compressed.lower() + + +def _redacted_display_url(parts: Any, host: str, explicit_port: str | None) -> str: + display_host = f"[{host}]" if ":" in host else host + port = ":443" if explicit_port == "443" else "" + path = parts.path or "/" + if len(path) > _MAX_DISPLAY_PATH_CHARS: + path = f"{path[:_MAX_DISPLAY_PATH_CHARS]}..." + return f"https://{display_host}{port}{path}" + + +def redact_remote_url(value: str) -> str: + """Return a best-effort URL display that omits userinfo, query, and fragment.""" + try: + parts = urlsplit(value) + host = parts.hostname + if not host: + return "" + canonical_host = _canonical_host(host) + explicit_port = _authority_port(parts.netloc.rsplit("@", 1)[-1]) + if explicit_port and explicit_port != "443": + explicit_port = None + return _redacted_display_url(parts, canonical_host, explicit_port) + except (RemoteSourceError, UnicodeError, ValueError): + return "" + + +def _parse_url( + value: str, policy: RemoteSourcePolicy +) -> tuple[httpx.URL, str, str, int]: + if ( + not isinstance(value, str) + or not value + or len(value) > _MAX_REMOTE_URL_CHARS + or value != value.strip() + ): + raise RemoteSourcePolicyError("Remote URL must be a non-empty canonical string") + if _has_control_characters(value): + raise RemoteSourcePolicyError("Remote URL contains control characters") + _validate_percent_encoding(value) + _validate_decoded_octets(value) + if "#" in value: + raise RemoteSourcePolicyError("Remote URL fragments are not allowed") + + try: + parts = urlsplit(value) + except ValueError as error: + raise RemoteSourcePolicyError("Remote URL is invalid") from error + + if parts.scheme.lower() != "https": + raise RemoteSourcePolicyError("Remote URL must use HTTPS") + if not parts.netloc: + raise RemoteSourcePolicyError("Remote URL must include a host") + if "@" in parts.netloc or parts.username is not None or parts.password is not None: + raise RemoteSourcePolicyError("Remote URL credentials are not allowed") + + explicit_port = _authority_port(parts.netloc) + if explicit_port is not None: + if ( + not explicit_port + or not explicit_port.isascii() + or not explicit_port.isdigit() + ): + raise RemoteSourcePolicyError("Remote URL port is invalid") + if explicit_port != str(int(explicit_port)) or explicit_port != "443": + raise RemoteSourcePolicyError( + "Remote URL must use canonical HTTPS port 443" + ) + + host = _canonical_host(parts.hostname or "") + if policy.required_host is not None and host != policy.required_host: + raise RemoteSourcePolicyError( + f"Remote URL is outside the {policy.name} host boundary" + ) + + try: + decoded_path = unquote_to_bytes(parts.path).decode("utf-8", errors="strict") + except UnicodeDecodeError as error: + raise RemoteSourcePolicyError("Remote URL path is not valid UTF-8") from error + if policy.required_path_prefix is not None: + if not parts.path.startswith( + policy.required_path_prefix + ) or not decoded_path.startswith(policy.required_path_prefix): + raise RemoteSourcePolicyError( + f"Remote URL is outside the {policy.name} path boundary" + ) + path_segments = decoded_path.split("/") + if any(segment in {".", ".."} for segment in path_segments): + raise RemoteSourcePolicyError("Remote URL path traversal is not allowed") + if policy.required_path is not None and ( + parts.path != policy.required_path or decoded_path != policy.required_path + ): + raise RemoteSourcePolicyError( + f"Remote URL is outside the {policy.name} path boundary" + ) + + try: + request_url = httpx.URL(value) + except (TypeError, ValueError) as error: + raise RemoteSourcePolicyError("Remote URL is invalid") from error + if ( + request_url.scheme != "https" + or request_url.raw_host.decode("ascii") != host + or request_url.port not in {None, 443} + ): + raise RemoteSourcePolicyError("Remote URL has an ambiguous authority") + + return request_url, _redacted_display_url(parts, host, explicit_port), host, 443 + + +def _normalise_address(value: str | IPAddress) -> IPAddress: + try: + address = ( + value + if isinstance(value, (ipaddress.IPv4Address, ipaddress.IPv6Address)) + else ipaddress.ip_address(value) + ) + except (TypeError, ValueError) as error: + raise RemoteSourceResolutionError("DNS returned an invalid address") from error + + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + return address.ipv4_mapped + return address + + +def _address_is_unsafe(address: IPAddress) -> bool: + return ( + not address.is_global + or address.is_loopback + or address.is_private + or address.is_link_local + or address.is_multicast + or address.is_unspecified + or address.is_reserved + ) + + +async def default_resolver(host: str, port: int) -> Sequence[str]: + """Resolve a host without blocking the event loop.""" + loop = asyncio.get_running_loop() + records = await loop.getaddrinfo( + host, + port, + family=socket.AF_UNSPEC, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + ) + return tuple(record[4][0] for record in records) + + +async def _run_cancellation_check(check: CancellationCheck | None) -> None: + if check is None: + return + result = check() + if inspect.isawaitable(result): + result = await result + if result: + raise RemoteSourceCancelled("Remote transfer was cancelled") + + +def _content_length( + response: httpx.Response, max_bytes: int, display_url: str +) -> int | None: + values = [ + token.strip() + for value in response.headers.get_list("content-length") + for token in value.split(",") + ] + if not values: + return None + if any(not re.fullmatch(r"[0-9]+", value) for value in values): + raise RemoteSourceSizeError( + f"Remote response has an invalid Content-Length for {display_url}" + ) + if any(len(value) > 20 for value in values): + raise RemoteSourceSizeError( + f"Remote response exceeds the {max_bytes}-byte limit for {display_url}" + ) + lengths = {int(value) for value in values} + if len(lengths) != 1: + raise RemoteSourceSizeError( + f"Remote response has conflicting Content-Length values for {display_url}" + ) + length = lengths.pop() + if length > max_bytes: + raise RemoteSourceSizeError( + f"Remote response exceeds the {max_bytes}-byte limit for {display_url}" + ) + return length + + +class RemoteByteStream: + """One-shot, raw response body stream with deadline and byte accounting.""" + + def __init__( + self, + response: httpx.Response, + info: RemoteResponseInfo, + *, + max_bytes: int, + chunk_size: int, + read_timeout: float, + deadline: float, + clock: Clock, + cancellation_check: CancellationCheck | None, + ) -> None: + self.info = info + self.bytes_read = 0 + self._response = response + self._max_bytes = max_bytes + self._chunk_size = chunk_size + self._read_timeout = read_timeout + self._deadline = deadline + self._clock = clock + self._cancellation_check = cancellation_check + self._started = False + + def _remaining(self) -> float: + remaining = self._deadline - self._clock() + if remaining <= 0: + raise RemoteSourceTimeout( + f"Remote transfer deadline expired for {self.info.display_url}" + ) + return remaining + + async def aiter_bytes(self) -> AsyncIterator[bytes]: + """Yield raw wire bytes exactly once, never content-decoded bytes.""" + if self._started: + raise RuntimeError("Remote response body can only be consumed once") + self._started = True + + iterator = self._response.aiter_raw(chunk_size=self._chunk_size).__aiter__() + while True: + await _run_cancellation_check(self._cancellation_check) + timeout = min(self._read_timeout, self._remaining()) + try: + chunk = await asyncio.wait_for(iterator.__anext__(), timeout=timeout) + except StopAsyncIteration: + return + except asyncio.TimeoutError: + raise RemoteSourceTimeout( + f"Remote read timed out for {self.info.display_url}" + ) from None + except httpx.TimeoutException: + raise RemoteSourceTimeout( + f"Remote read timed out for {self.info.display_url}" + ) from None + except httpx.RequestError: + raise RemoteSourceError( + f"Remote read failed for {self.info.display_url}" + ) from None + + self._remaining() + await _run_cancellation_check(self._cancellation_check) + if not chunk: + continue + self.bytes_read += len(chunk) + if self.bytes_read > self._max_bytes: + raise RemoteSourceSizeError( + f"Remote response exceeds the {self._max_bytes}-byte limit for " + f"{self.info.display_url}" + ) + yield chunk + + +class RemoteSourceClient: + """Fetch remote content under a fixed URL policy.""" + + def __init__( + self, + policy: RemoteSourcePolicy = GENERAL_HTTPS_POLICY, + *, + resolver: Resolver = default_resolver, + transport: httpx.AsyncBaseTransport | None = None, + timeouts: RemoteTimeouts | None = None, + max_redirects: int = 5, + chunk_size: int = _DEFAULT_CHUNK_SIZE, + clock: Clock = time.monotonic, + ) -> None: + if ( + not isinstance(max_redirects, int) + or isinstance(max_redirects, bool) + or max_redirects < 0 + or max_redirects > _MAX_REDIRECTS + ): + raise ValueError(f"max_redirects must be between 0 and {_MAX_REDIRECTS}") + if ( + not isinstance(chunk_size, int) + or isinstance(chunk_size, bool) + or chunk_size <= 0 + or chunk_size > _MAX_CHUNK_SIZE + ): + raise ValueError( + f"chunk_size must be between 1 and {_MAX_CHUNK_SIZE} bytes" + ) + self.policy = policy + self._resolver = resolver + self._transport = transport + self._timeouts = timeouts or RemoteTimeouts() + self._max_redirects = max_redirects + self._chunk_size = chunk_size + self._clock = clock + + def _remaining(self, deadline: float, display_url: str) -> float: + remaining = deadline - self._clock() + if remaining <= 0: + raise RemoteSourceTimeout( + f"Remote transfer deadline expired for {display_url}" + ) + return remaining + + async def _validate_url(self, value: str, deadline: float) -> ValidatedRemoteURL: + request_url, display_url, host, port = _parse_url(value, self.policy) + self._remaining(deadline, display_url) + + try: + literal_address = _normalise_address(host) + except RemoteSourceResolutionError: + literal_address = None + + if literal_address is not None: + raw_addresses: Sequence[str | IPAddress] = (literal_address,) + else: + try: + raw_addresses = await asyncio.wait_for( + self._resolver(host, port), + timeout=self._remaining(deadline, display_url), + ) + except asyncio.TimeoutError: + raise RemoteSourceTimeout( + f"DNS resolution timed out for {display_url}" + ) from None + except (OSError, socket.gaierror): + raise RemoteSourceResolutionError( + f"DNS resolution failed for {display_url}" + ) from None + + self._remaining(deadline, display_url) + addresses = frozenset(_normalise_address(value) for value in raw_addresses) + if not addresses: + raise RemoteSourceResolutionError( + f"DNS returned no addresses for {display_url}" + ) + if any(_address_is_unsafe(address) for address in addresses): + raise RemoteSourceResolutionError( + f"DNS returned a non-public address for {display_url}" + ) + + return ValidatedRemoteURL( + url=request_url, + display_url=display_url, + host=host, + port=port, + addresses=addresses, + ) + + @staticmethod + def _validate_peer( + response: httpx.Response, + validated: ValidatedRemoteURL, + pinned_address: IPAddress, + ) -> None: + network_stream = response.extensions.get("network_stream") + if network_stream is None or not hasattr(network_stream, "get_extra_info"): + raise RemoteSourcePeerError( + f"Connected peer identity was unavailable for {validated.display_url}" + ) + + try: + server_address = network_stream.get_extra_info("server_addr") + except Exception: # noqa: BLE001 - untrusted transport metadata must fail closed + raise RemoteSourcePeerError( + f"Could not validate the connected peer for {validated.display_url}" + ) from None + if not isinstance(server_address, tuple) or len(server_address) < 2: + raise RemoteSourcePeerError( + f"Connected peer identity was unavailable for {validated.display_url}" + ) + if server_address[1] != validated.port: + raise RemoteSourcePeerError( + f"Connected peer used an unexpected port for {validated.display_url}" + ) + peer_value = server_address[0] + if "%" in str(peer_value): + raise RemoteSourcePeerError( + f"Connected peer was invalid for {validated.display_url}" + ) + try: + peer = _normalise_address(str(peer_value)) + except RemoteSourceResolutionError as error: + raise RemoteSourcePeerError( + f"Connected peer was invalid for {validated.display_url}" + ) from error + if _address_is_unsafe(peer): + raise RemoteSourcePeerError( + f"Connected peer was non-public for {validated.display_url}" + ) + if peer != pinned_address: + raise RemoteSourcePeerError( + f"Connected peer did not match the pinned address for " + f"{validated.display_url}" + ) + + @staticmethod + def _address_order(address: IPAddress) -> tuple[int, bytes]: + return address.version, address.packed + + @staticmethod + def _host_header(host: str) -> str: + return f"[{host}]" if ":" in host else host + + async def _send( + self, + client: httpx.AsyncClient, + validated: ValidatedRemoteURL, + deadline: float, + *, + method: str = "GET", + content: bytes | None = None, + headers: Mapping[str, str] | None = None, + ) -> httpx.Response: + last_failure_was_timeout = False + for pinned_address in sorted(validated.addresses, key=self._address_order): + # A numeric transport URL prevents a second DNS lookup. Host remains + # the validated authority and sni_hostname makes httpcore perform TLS + # certificate verification against that original authority. + pinned_url = validated.url.copy_with(host=str(pinned_address)) + client.cookies.clear() + request = client.build_request( + method, + pinned_url, + headers={ + "Accept-Encoding": "identity", + "Host": self._host_header(validated.host), + "User-Agent": "StingrayExplorer/remote-source", + **(dict(headers) if headers is not None else {}), + }, + content=content, + extensions={"sni_hostname": validated.host}, + ) + try: + response = await asyncio.wait_for( + client.send(request, stream=True), + timeout=self._remaining(deadline, validated.display_url), + ) + except httpx.ConnectTimeout: + last_failure_was_timeout = True + continue + except httpx.ConnectError: + last_failure_was_timeout = False + continue + except (asyncio.TimeoutError, httpx.TimeoutException): + raise RemoteSourceTimeout( + f"Remote request timed out for {validated.display_url}" + ) from None + except httpx.RequestError: + raise RemoteSourceError( + f"Remote request failed for {validated.display_url}" + ) from None + + try: + self._remaining(deadline, validated.display_url) + self._validate_peer(response, validated, pinned_address) + except Exception: + await response.aclose() + raise + return response + + if last_failure_was_timeout: + raise RemoteSourceTimeout( + f"Remote request timed out for {validated.display_url}" + ) from None + raise RemoteSourceError( + f"Remote request failed for {validated.display_url}" + ) from None + + async def _open_response( + self, + client: httpx.AsyncClient, + value: str, + deadline: float, + *, + method: str = "GET", + content: bytes | None = None, + headers: Mapping[str, str] | None = None, + allow_redirects: bool = True, + ) -> tuple[httpx.Response, ValidatedRemoteURL, int]: + current = value + redirect_count = 0 + + while True: + validated = await self._validate_url(current, deadline) + response = await self._send( + client, + validated, + deadline, + method=method, + content=content, + headers=headers, + ) + if response.status_code not in _REDIRECT_STATUSES: + return response, validated, redirect_count + + location = response.headers.get("location") + await response.aclose() + if not allow_redirects: + raise RemoteSourceRedirectError( + f"Remote redirect is not allowed for {validated.display_url}" + ) + if location is None: + raise RemoteSourceRedirectError( + f"Remote redirect was missing Location for {validated.display_url}" + ) + redirect_count += 1 + if redirect_count > self._max_redirects: + raise RemoteSourceRedirectError( + f"Remote redirect limit exceeded for {validated.display_url}" + ) + if _has_control_characters(location): + raise RemoteSourceRedirectError("Remote redirect Location was invalid") + current = urljoin(str(validated.url), location) + + @asynccontextmanager + async def _stream( + self, + url: str, + *, + max_bytes: int, + cancellation_check: CancellationCheck | None = None, + method: str = "GET", + content: bytes | None = None, + headers: Mapping[str, str] | None = None, + allow_redirects: bool = True, + ) -> AsyncIterator[RemoteByteStream]: + """Open a validated response and expose a capped raw-byte stream.""" + if ( + not isinstance(max_bytes, int) + or isinstance(max_bytes, bool) + or max_bytes <= 0 + ): + raise ValueError("max_bytes must be a positive integer") + + deadline = self._clock() + self._timeouts.total + await _run_cancellation_check(cancellation_check) + async with httpx.AsyncClient( + transport=self._transport, + timeout=self._timeouts.as_httpx_timeout(), + follow_redirects=False, + limits=httpx.Limits(max_keepalive_connections=0), + trust_env=False, + ) as client: + response, validated, redirect_count = await self._open_response( + client, + url, + deadline, + method=method, + content=content, + headers=headers, + allow_redirects=allow_redirects, + ) + try: + if not 200 <= response.status_code < 300: + raise RemoteSourceHTTPError( + response.status_code, validated.display_url + ) + encodings = { + value.strip().lower() + for header in response.headers.get_list("content-encoding") + for value in header.split(",") + if value.strip() + } + if encodings and encodings != {"identity"}: + raise RemoteSourceError( + f"Remote server ignored identity encoding for {validated.display_url}" + ) + content_length = _content_length( + response, max_bytes, validated.display_url + ) + info = RemoteResponseInfo( + display_url=validated.display_url, + status_code=response.status_code, + content_length=content_length, + content_type=response.headers.get("content-type"), + redirect_count=redirect_count, + ) + yield RemoteByteStream( + response, + info, + max_bytes=max_bytes, + chunk_size=self._chunk_size, + read_timeout=self._timeouts.read, + deadline=deadline, + clock=self._clock, + cancellation_check=cancellation_check, + ) + finally: + await response.aclose() + + @asynccontextmanager + async def stream( + self, + url: str, + *, + max_bytes: int, + cancellation_check: CancellationCheck | None = None, + ) -> AsyncIterator[RemoteByteStream]: + """Open a validated GET response and expose a capped raw-byte stream.""" + async with self._stream( + url, + max_bytes=max_bytes, + cancellation_check=cancellation_check, + ) as remote_stream: + yield remote_stream + + async def fetch_bytes( + self, + url: str, + *, + max_bytes: int, + cancellation_check: CancellationCheck | None = None, + ) -> tuple[bytes, RemoteResponseInfo]: + """Read a complete response while retaining all stream-level bounds.""" + async with self.stream( + url, + max_bytes=max_bytes, + cancellation_check=cancellation_check, + ) as remote_stream: + chunks = [chunk async for chunk in remote_stream.aiter_bytes()] + return b"".join(chunks), remote_stream.info + + async def fetch_text( + self, + url: str, + *, + max_bytes: int, + encoding: str = "utf-8", + cancellation_check: CancellationCheck | None = None, + ) -> tuple[str, RemoteResponseInfo]: + """Fetch a bounded text response suitable for directory listings.""" + body, info = await self.fetch_bytes( + url, + max_bytes=max_bytes, + cancellation_check=cancellation_check, + ) + try: + return body.decode(encoding), info + except (LookupError, UnicodeDecodeError) as error: + raise RemoteSourceError( + f"Remote text response was not valid {encoding} for {info.display_url}" + ) from error + + async def post_form_bytes( + self, + url: str, + fields: Mapping[str, str | int], + *, + max_bytes: int, + max_request_bytes: int = 64 * 1024, + accept: str = "application/x-votable+xml, text/xml, application/xml", + cancellation_check: CancellationCheck | None = None, + ) -> tuple[bytes, RemoteResponseInfo]: + """POST one bounded form body without following or replaying redirects.""" + if not isinstance(fields, Mapping): + raise TypeError("fields must be a mapping") + try: + encoded_fields = { + key: str(value) + for key, value in fields.items() + if isinstance(key, str) and isinstance(value, (str, int)) + } + if len(encoded_fields) != len(fields): + raise ValueError + body = urlencode(encoded_fields, doseq=False).encode("ascii") + except (TypeError, ValueError, UnicodeEncodeError) as error: + raise RemoteSourcePolicyError("Remote form fields are invalid") from error + if ( + not isinstance(max_request_bytes, int) + or isinstance(max_request_bytes, bool) + or max_request_bytes <= 0 + ): + raise ValueError("max_request_bytes must be a positive integer") + if len(body) > max_request_bytes: + raise RemoteSourceSizeError("Remote form request exceeds its byte limit") + + async with self._stream( + url, + max_bytes=max_bytes, + cancellation_check=cancellation_check, + method="POST", + content=body, + headers={ + "Accept": accept, + "Content-Type": "application/x-www-form-urlencoded", + }, + allow_redirects=False, + ) as remote_stream: + chunks = [chunk async for chunk in remote_stream.aiter_bytes()] + return b"".join(chunks), remote_stream.info diff --git a/python-backend/services/secure_publication.py b/python-backend/services/secure_publication.py new file mode 100644 index 0000000..274fc76 --- /dev/null +++ b/python-backend/services/secure_publication.py @@ -0,0 +1,715 @@ +"""Format-independent secure publication primitives. + +Scientific serializers should only receive the streams exposed by this module. +Path authorization, private staging, descriptor identity checks, exclusive +publication, and best-effort identity-aware cleanup stay platform-specific. +""" + +from __future__ import annotations + +import io +import os +import secrets +import stat +from abc import ABC, abstractmethod +from collections.abc import Generator +from contextlib import AbstractContextManager, contextmanager +from pathlib import Path +from typing import BinaryIO, TextIO + +from .utility_helpers import ( + GrantedWindowsWriteDestination, + GrantedWriteDestination, + open_verified_write_grant, + revalidate_admitted_file_grant, +) + +PublicationStream = BinaryIO | TextIO +FileIdentity = tuple[int, int] + + +class SecurePublication(ABC): + """Platform-neutral lifecycle for publishing one verified artifact.""" + + @property + @abstractmethod + def path(self) -> Path: + """Return the authorized user-visible destination path.""" + + @property + @abstractmethod + def filename(self) -> str: + """Return the destination's final, single-component filename.""" + + @abstractmethod + def revalidate(self, changed_message: str) -> None: + """Revalidate the destination grant and pinned parent identity.""" + + @abstractmethod + def assert_destination_available(self) -> None: + """Reject any existing entry at the authorized destination.""" + + @abstractmethod + def reserve_staging(self, extension: str) -> None: + """Reserve a private, same-directory staging artifact exclusively.""" + + @abstractmethod + def open_writer( + self, mode: str, *, encoding: str | None + ) -> AbstractContextManager[PublicationStream]: + """Open the exclusively reserved artifact and flush it on success.""" + + @abstractmethod + def open_reader( + self, mode: str, *, encoding: str | None + ) -> AbstractContextManager[PublicationStream]: + """Reopen the owned staging artifact for scientific verification.""" + + @abstractmethod + def verified_size(self) -> int: + """Return the nonzero byte size after a final ownership check.""" + + @abstractmethod + def publish(self) -> list[str]: + """Publish without replacement and return nonfatal cleanup warnings.""" + + @abstractmethod + def close(self) -> None: + """Close handles and remove only staging entries still believed owned.""" + + +def _identity(file_stat: os.stat_result) -> FileIdentity: + return (file_stat.st_dev, file_stat.st_ino) + + +def _unlink_owned_entry( + directory_descriptor: int, + name: str, + identity: FileIdentity, +) -> None: + """Best-effort removal after a no-follow identity check. + + Portable POSIX does not provide an atomic compare-identity-and-unlink + operation. The random, mode-0700 staging directory and pinned directory + descriptor are the primary boundary; this check avoids knowingly unlinking + a replacement but cannot eliminate a same-user swap between stat and unlink. + """ + try: + current = os.stat( + name, + dir_fd=directory_descriptor, + follow_symlinks=False, + ) + except FileNotFoundError: + return + if stat.S_ISREG(current.st_mode) and _identity(current) == identity: + os.unlink(name, dir_fd=directory_descriptor) + + +def _rmdir_owned_entry( + parent_descriptor: int, + name: str, + identity: FileIdentity, +) -> bool: + """Best-effort removal after a no-follow identity check. + + As with ``_unlink_owned_entry``, portable POSIX cannot combine the identity + comparison and removal into one operation. A mismatch is retained, but a + same-user replacement in the small stat-to-rmdir interval cannot be ruled + out. The unguessable, mode-0700 staging directory is the primary boundary. + """ + try: + current = os.stat( + name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + except FileNotFoundError: + return True + if not stat.S_ISDIR(current.st_mode): + return False + if _identity(current) != identity: + return False + os.rmdir(name, dir_fd=parent_descriptor) + return True + + +@contextmanager +def _descriptor_stream( + descriptor: int, + mode: str, + *, + encoding: str | None, +) -> Generator[PublicationStream, None, None]: + """Expose a descriptor without transferring its ownership to the stream. + + ``closefd=False`` makes this helper the sole descriptor owner even when + stream construction or teardown fails. That keeps error paths predictable + and also mirrors the explicit handle ownership required by the Windows + adapter. + """ + raw_stream: io.FileIO | None = None + stream: PublicationStream | None = None + try: + if mode not in {"r", "rb", "w", "wb", "w+b"}: + raise ValueError(f"Unsupported secure publication stream mode: {mode}") + raw_stream = io.FileIO( + descriptor, + mode.replace("b", ""), + closefd=False, + ) + if "b" in mode: + stream = raw_stream + else: + stream = io.TextIOWrapper(raw_stream, encoding=encoding or "utf-8") + yield stream + finally: + try: + if stream is not None: + stream.close() + elif raw_stream is not None: + raw_stream.close() + finally: + os.close(descriptor) + + +class PosixSecurePublication(SecurePublication): + """Descriptor-relative POSIX publication with exclusive hard-link publish.""" + + def __init__( + self, + destination: GrantedWriteDestination, + destination_path: str, + destination_grant: str, + ) -> None: + self._path = destination.path + self._parent_descriptor = destination.parent_descriptor + self._filename = destination.filename + self._destination_path = destination_path + self._destination_grant = destination_grant + self._admitted_at = destination.admitted_at + + self._staging_descriptor = -1 + self._staging_name: str | None = None + self._staging_identity: FileIdentity | None = None + self._artifact_name: str | None = None + self._artifact_identity: FileIdentity | None = None + self._writer_descriptor = -1 + self._writer_completed = False + self._writer_active = False + self._reader_completed = False + self._reader_active = False + self._size_verified = False + self._published = False + self._failed = False + self._closed = False + + @property + def path(self) -> Path: + return self._path + + @property + def filename(self) -> str: + return self._filename + + def revalidate(self, changed_message: str) -> None: + verified_path = revalidate_admitted_file_grant( + self._destination_path, + self._destination_grant, + access="write", + must_exist=False, + admitted_at=self._admitted_at, + ) + if verified_path != self._path: + raise PermissionError(changed_message) + + def assert_destination_available(self) -> None: + try: + os.stat( + self._filename, + dir_fd=self._parent_descriptor, + follow_symlinks=False, + ) + except FileNotFoundError: + return + raise FileExistsError(f"Destination already exists: {self._path}") + + def reserve_staging(self, extension: str) -> None: + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if self._published: + raise RuntimeError("The secure publication is already published") + if self._staging_name is not None: + raise RuntimeError("A private staging artifact is already reserved") + + for _ in range(10): + candidate_name = f".stingray-export-{secrets.token_hex(16)}" + try: + os.mkdir( + candidate_name, + 0o700, + dir_fd=self._parent_descriptor, + ) + except FileExistsError: + continue + self._staging_name = candidate_name + break + else: + raise FileExistsError("Could not reserve a private export staging area") + + directory_flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + directory_flags |= os.O_CLOEXEC + if hasattr(os, "O_DIRECTORY"): + directory_flags |= os.O_DIRECTORY + if hasattr(os, "O_NOFOLLOW"): + directory_flags |= os.O_NOFOLLOW + self._staging_descriptor = os.open( + self._staging_name, + directory_flags, + dir_fd=self._parent_descriptor, + ) + staging_stat = os.fstat(self._staging_descriptor) + if not stat.S_ISDIR(staging_stat.st_mode): + raise PermissionError("The private export staging area was replaced") + self._staging_identity = _identity(staging_stat) + + self._artifact_name = f"artifact{extension}" + # HDF5's file-object driver needs one seekable bidirectional stream. + # Reserving O_RDWR avoids reopening the stage by pathname. + flags = os.O_RDWR | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open( + self._artifact_name, + flags, + 0o600, + dir_fd=self._staging_descriptor, + ) + try: + reserved_stat = os.fstat(descriptor) + except Exception: + os.close(descriptor) + raise + self._artifact_identity = _identity(reserved_stat) + self._writer_descriptor = descriptor + + def _require_artifact(self) -> tuple[str, FileIdentity]: + if self._artifact_name is None or self._artifact_identity is None: + raise RuntimeError("No private staging artifact is reserved") + return self._artifact_name, self._artifact_identity + + @contextmanager + def open_writer( + self, mode: str, *, encoding: str | None + ) -> Generator[PublicationStream, None, None]: + self._require_artifact() + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if self._writer_completed: + raise RuntimeError("The private staging writer already completed") + if self._writer_active: + raise RuntimeError("The private staging writer is already active") + if self._writer_descriptor < 0: + raise RuntimeError("The private staging writer is unavailable") + descriptor = self._writer_descriptor + self._writer_descriptor = -1 + self._writer_active = True + try: + with _descriptor_stream(descriptor, mode, encoding=encoding) as stream: + yield stream + stream.flush() + os.fsync(descriptor) + except BaseException: + self._failed = True + raise + finally: + self._writer_active = False + self._writer_completed = True + + def _assert_staged_identity(self, changed_message: str) -> os.stat_result: + artifact_name, artifact_identity = self._require_artifact() + current = os.stat( + artifact_name, + dir_fd=self._staging_descriptor, + follow_symlinks=False, + ) + if not stat.S_ISREG(current.st_mode) or _identity(current) != artifact_identity: + raise PermissionError(changed_message) + return current + + @contextmanager + def open_reader( + self, mode: str, *, encoding: str | None + ) -> Generator[PublicationStream, None, None]: + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if not self._writer_completed: + raise RuntimeError("The private staging writer has not completed") + if self._reader_completed: + raise RuntimeError("The private staging reader already completed") + if self._reader_active: + raise RuntimeError("The private staging reader is already active") + self.revalidate("Written artifact path changed during verification") + artifact_name, artifact_identity = self._require_artifact() + self._assert_staged_identity( + "Export destination was replaced before verification" + ) + + read_flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + read_flags |= os.O_NOFOLLOW + read_descriptor = os.open( + artifact_name, + read_flags, + dir_fd=self._staging_descriptor, + ) + self._reader_active = True + try: + read_stat = os.fstat(read_descriptor) + if _identity(read_stat) != artifact_identity: + raise PermissionError( + "Export destination was replaced before verification" + ) + stream_descriptor = read_descriptor + read_descriptor = -1 + with _descriptor_stream( + stream_descriptor, + mode, + encoding=encoding, + ) as stream: + yield stream + except BaseException: + self._failed = True + raise + finally: + if read_descriptor >= 0: + os.close(read_descriptor) + self._reader_active = False + self._reader_completed = True + + def verified_size(self) -> int: + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if not self._reader_completed: + raise RuntimeError("Scientific reopen verification has not completed") + if self._published: + raise RuntimeError("The secure publication is already published") + final_stat = self._assert_staged_identity( + "Export destination was replaced during verification" + ) + byte_size = final_stat.st_size + if byte_size < 1: + raise ValueError("Written artifact is empty") + self._size_verified = True + return byte_size + + def publish(self) -> list[str]: + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if self._published: + raise RuntimeError("The secure publication is already published") + if not self._size_verified: + raise RuntimeError("The staged artifact has not completed verification") + self.revalidate("The selected destination path changed") + artifact_name, artifact_identity = self._require_artifact() + try: + os.link( + artifact_name, + self._filename, + src_dir_fd=self._staging_descriptor, + dst_dir_fd=self._parent_descriptor, + follow_symlinks=False, + ) + except FileExistsError as exc: + raise FileExistsError(f"Destination already exists: {self._path}") from exc + published_stat = os.stat( + self._filename, + dir_fd=self._parent_descriptor, + follow_symlinks=False, + ) + if ( + not stat.S_ISREG(published_stat.st_mode) + or _identity(published_stat) != artifact_identity + ): + raise PermissionError( + "Export destination changed during atomic publication" + ) + + warnings: list[str] = [] + try: + _unlink_owned_entry( + self._staging_descriptor, + artifact_name, + artifact_identity, + ) + os.close(self._staging_descriptor) + self._staging_descriptor = -1 + if ( + self._staging_name is None + or self._staging_identity is None + or not _rmdir_owned_entry( + self._parent_descriptor, + self._staging_name, + self._staging_identity, + ) + ): + raise OSError("private staging directory identity changed") + except OSError as cleanup_error: + warnings.append( + "Export succeeded, but its private staging artifact could not be " + f"removed safely ({cleanup_error})." + ) + + # Successful publication never retries a failed name-based cleanup. + # A retry after an identity mismatch could target an unrelated entry. + self._artifact_name = None + self._artifact_identity = None + self._staging_name = None + self._staging_identity = None + self._published = True + return warnings + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self._writer_descriptor >= 0: + try: + os.close(self._writer_descriptor) + except OSError: + pass + self._writer_descriptor = -1 + if ( + self._staging_descriptor >= 0 + and self._artifact_name is not None + and self._artifact_identity is not None + ): + try: + _unlink_owned_entry( + self._staging_descriptor, + self._artifact_name, + self._artifact_identity, + ) + except OSError: + pass + if self._staging_descriptor >= 0: + try: + os.close(self._staging_descriptor) + except OSError: + pass + self._staging_descriptor = -1 + if self._staging_name is not None and self._staging_identity is not None: + try: + _rmdir_owned_entry( + self._parent_descriptor, + self._staging_name, + self._staging_identity, + ) + except OSError: + # Never broaden cleanup or delete an unexpected entry. + pass + + +class WindowsSecurePublication(SecurePublication): + """Handle-relative NTFS publication with a no-replacement rename.""" + + def __init__( + self, + destination: GrantedWindowsWriteDestination, + destination_path: str, + destination_grant: str, + ) -> None: + from .windows_secure_fs import WindowsPublicationReservation + + self._path = destination.path + self._filename = destination.filename + self._destination_path = destination_path + self._destination_grant = destination_grant + self._admitted_at = destination.admitted_at + self._reservation = WindowsPublicationReservation( + destination.parent, + destination.filename, + ) + self._writer_completed = False + self._writer_active = False + self._reader_completed = False + self._reader_active = False + self._size_verified = False + self._published = False + self._failed = False + self._closed = False + + @property + def path(self) -> Path: + return self._path + + @property + def filename(self) -> str: + return self._filename + + def revalidate(self, changed_message: str) -> None: + verified_path = revalidate_admitted_file_grant( + self._destination_path, + self._destination_grant, + access="write", + must_exist=False, + admitted_at=self._admitted_at, + ) + if verified_path != self._path: + raise PermissionError(changed_message) + + def assert_destination_available(self) -> None: + self._reservation.assert_destination_available() + + def reserve_staging(self, extension: str) -> None: + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if self._published: + raise RuntimeError("The secure publication is already published") + self._reservation.reserve(extension) + + @contextmanager + def open_writer( + self, mode: str, *, encoding: str | None + ) -> Generator[PublicationStream, None, None]: + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if self._writer_completed: + raise RuntimeError("The private staging writer already completed") + if self._writer_active: + raise RuntimeError("The private staging writer is already active") + self._writer_active = True + try: + descriptor = self._reservation.duplicate_fd(writable=True) + with _descriptor_stream(descriptor, mode, encoding=encoding) as stream: + yield stream + stream.flush() + self._reservation.flush() + except BaseException: + self._failed = True + raise + finally: + self._writer_active = False + self._writer_completed = True + + @contextmanager + def open_reader( + self, mode: str, *, encoding: str | None + ) -> Generator[PublicationStream, None, None]: + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if not self._writer_completed: + raise RuntimeError("The private staging writer has not completed") + if self._reader_completed: + raise RuntimeError("The private staging reader already completed") + if self._reader_active: + raise RuntimeError("The private staging reader is already active") + self.revalidate("Written artifact path changed during verification") + self._reader_active = True + try: + descriptor = self._reservation.duplicate_fd(writable=False) + with _descriptor_stream(descriptor, mode, encoding=encoding) as stream: + yield stream + except BaseException: + self._failed = True + raise + finally: + self._reader_active = False + self._reader_completed = True + + def verified_size(self) -> int: + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if not self._reader_completed: + raise RuntimeError("Scientific reopen verification has not completed") + if self._published: + raise RuntimeError("The secure publication is already published") + size = self._reservation.verified_size() + if size < 1: + raise ValueError("Written artifact is empty") + self._size_verified = True + return size + + def publish(self) -> list[str]: + if self._closed: + raise RuntimeError("The secure publication is already closed") + if self._failed: + raise RuntimeError("The secure publication has already failed") + if self._published: + raise RuntimeError("The secure publication is already published") + if not self._size_verified: + raise RuntimeError("The staged artifact has not completed verification") + self.revalidate("The selected destination path changed") + try: + warnings = self._reservation.publish() + except FileExistsError as exc: + raise FileExistsError(f"Destination already exists: {self._path}") from exc + self._published = True + return warnings + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._reservation.close() + + +def _platform_name() -> str: + return os.name + + +@contextmanager +def open_secure_publication( + destination_path: str, + destination_grant: str, +) -> Generator[SecurePublication, None, None]: + """Open the secure publication implementation for the current platform.""" + platform = _platform_name() + if platform not in {"posix", "nt"}: + raise NotImplementedError( + "Secure export publication is not supported on this platform" + ) + + with open_verified_write_grant( + destination_path, + destination_grant, + ) as destination: + if platform == "posix": + if not isinstance(destination, GrantedWriteDestination): + raise PermissionError("A POSIX destination grant is required") + publication: SecurePublication = PosixSecurePublication( + destination, + destination_path, + destination_grant, + ) + else: + if not isinstance(destination, GrantedWindowsWriteDestination): + raise PermissionError("A Windows destination grant is required") + publication = WindowsSecurePublication( + destination, + destination_path, + destination_grant, + ) + try: + yield publication + finally: + publication.close() diff --git a/python-backend/services/spectrum_service.py b/python-backend/services/spectrum_service.py new file mode 100644 index 0000000..777baef --- /dev/null +++ b/python-backend/services/spectrum_service.py @@ -0,0 +1,583 @@ +""" +Spectrum service for spectral analysis operations. + +Handles power spectrum, cross spectrum, and dynamical power spectrum operations. +""" + +from typing import Any, Dict, Optional + +import numpy as np +from stingray import ( + AveragedCrossspectrum, + AveragedPowerspectrum, + Crossspectrum, + DynamicalPowerspectrum, + Powerspectrum, +) + +from .base_service import BaseService + + +def _power_to_lists(power) -> tuple: + """Split a (possibly complex) power array into JSON-safe magnitude and phase lists. + + Returns (power_list, phase_list_or_None). Non-finite values become None so + strict JSON (and JS JSON.parse) never sees NaN/Infinity. + """ + arr = np.asarray(power) + if np.iscomplexobj(arr): + mag = np.abs(arr) + phase = np.angle(arr) + return _finite_list(mag), _finite_list(phase) + return _finite_list(arr), None + + +def _finite_list(arr) -> list: + """Convert a float array to a list, replacing non-finite values with None.""" + values = np.asarray(arr, dtype=float) + if np.isfinite(values).all(): + return values.tolist() + return [float(v) if np.isfinite(v) else None for v in values] + + +def _segment_size_error(segment_size: float, dt: float) -> Optional[str]: + """Human-readable rejection for segment sizes that stingray fails on cryptically. + + Needs at least 3 time bins per segment to produce a non-empty spectrum. + """ + if segment_size / dt < 3: + return ( + f"segment_size ({segment_size}s) must be at least 3x dt ({dt}s) " + "to produce a non-empty spectrum" + ) + return None + + +def _overlap_error( + events1, events2, segment_size: Optional[float] = None +) -> Optional[str]: + """Readable rejection when two event lists share no time overlap. + + Optional segment_size check: if provided and the overlap is shorter than + one segment, stingray will produce zero segments (cryptic error), so we + reject early with a human-readable message. + """ + if len(events1.time) == 0 or len(events2.time) == 0: + return "one of the event lists contains no events" + start = max(float(events1.time[0]), float(events2.time[0])) + stop = min(float(events1.time[-1]), float(events2.time[-1])) + if stop <= start: + return ( + "the two event lists have no overlapping time range " + f"({events1.time[0]:.1f}-{events1.time[-1]:.1f}s vs " + f"{events2.time[0]:.1f}-{events2.time[-1]:.1f}s)" + ) + if segment_size is not None and (stop - start) < segment_size: + return ( + f"the overlapping time range ({stop - start:.1f}s) is shorter than " + f"the segment size ({segment_size}s)" + ) + return None + + +class SpectrumService(BaseService): + """ + Service for spectral analysis operations. + + Handles power spectrum, cross spectrum, and dynamical power spectrum. + """ + + def create_power_spectrum( + self, + event_list_name: str, + dt: float, + norm: str = "leahy", + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a power spectrum from an EventList. + + Args: + event_list_name: Name of the EventList in state + dt: Time binning in seconds + norm: Normalization type ("leahy", "frac", "abs", "none") + output_name: Optional name to save the spectrum + + Returns: + Result dictionary with power spectrum data + """ + try: + if not self.state.has_event_data(event_list_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_name}' not found", + error=None, + ) + + event_list = self.state.get_event_data(event_list_name) + lc = event_list.to_lc(dt=dt) + ps = Powerspectrum(lc, norm=norm) + + # Save if name provided + if output_name: + self.state.add_spectrum_data(output_name, ps) + + power_list, phase_list = _power_to_lists(ps.power) + ps_data = { + "name": output_name, + "freq": ps.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "norm": norm, + "n_freq": len(ps.freq), + "df": float(ps.df), + "freq_range": [float(ps.freq[0]), float(ps.freq[-1])], + } + + return self.create_result( + success=True, + data=ps_data, + message=f"Power spectrum created (dt={dt}s, norm={norm})", + ) + + except Exception as e: + return self.handle_error( + e, + "Creating power spectrum", + event_list=event_list_name, + dt=dt, + norm=norm, + ) + + def create_averaged_power_spectrum( + self, + event_list_name: str, + dt: float, + segment_size: float, + norm: str = "leahy", + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create an averaged power spectrum from an EventList. + + Args: + event_list_name: Name of the EventList in state + dt: Time binning in seconds + segment_size: Segment size in seconds for averaging + norm: Normalization type + output_name: Optional name to save the spectrum + + Returns: + Result dictionary with averaged power spectrum data + """ + try: + if not self.state.has_event_data(event_list_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_name}' not found", + error=None, + ) + + seg_error = _segment_size_error(segment_size, dt) + if seg_error: + return self.create_result( + success=False, data=None, message=seg_error, error=None + ) + + event_list = self.state.get_event_data(event_list_name) + lc = event_list.to_lc(dt=dt) + ps = AveragedPowerspectrum.from_lightcurve(lc, segment_size, norm=norm) + + if output_name: + self.state.add_spectrum_data(output_name, ps) + + power_list, phase_list = _power_to_lists(ps.power) + ps_data = { + "name": output_name, + "freq": ps.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "norm": norm, + "n_freq": len(ps.freq), + "df": float(ps.df), + "segment_size": segment_size, + "n_segments": int(ps.m) if hasattr(ps, "m") else None, + } + + return self.create_result( + success=True, + data=ps_data, + message=f"Averaged power spectrum created (segment={segment_size}s)", + ) + + except Exception as e: + return self.handle_error( + e, + "Creating averaged power spectrum", + event_list=event_list_name, + dt=dt, + segment_size=segment_size, + norm=norm, + ) + + def create_cross_spectrum( + self, + event_list_1_name: str, + event_list_2_name: str, + dt: float, + norm: str = "leahy", + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a cross spectrum from two EventLists. + + Args: + event_list_1_name: Name of first EventList + event_list_2_name: Name of second EventList + dt: Time binning in seconds + norm: Normalization type + output_name: Optional name to save the spectrum + + Returns: + Result dictionary with cross spectrum data + """ + try: + if not self.state.has_event_data(event_list_1_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_1_name}' not found", + error=None, + ) + + if not self.state.has_event_data(event_list_2_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_2_name}' not found", + error=None, + ) + + event_list_1 = self.state.get_event_data(event_list_1_name) + event_list_2 = self.state.get_event_data(event_list_2_name) + + overlap_error = _overlap_error(event_list_1, event_list_2) + if overlap_error: + return self.create_result( + success=False, data=None, message=overlap_error, error=None + ) + + cs = Crossspectrum.from_events( + events1=event_list_1, + events2=event_list_2, + dt=dt, + norm=norm, + ) + + if output_name: + self.state.add_spectrum_data(output_name, cs) + + power_list, phase_list = _power_to_lists(cs.power) + cs_data = { + "name": output_name, + "freq": cs.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "norm": norm, + "n_freq": len(cs.freq), + "df": float(cs.df), + } + + return self.create_result( + success=True, + data=cs_data, + message=f"Cross spectrum created (dt={dt}s)", + ) + + except Exception as e: + return self.handle_error( + e, + "Creating cross spectrum", + event_list_1=event_list_1_name, + event_list_2=event_list_2_name, + dt=dt, + norm=norm, + ) + + def create_averaged_cross_spectrum( + self, + event_list_1_name: str, + event_list_2_name: str, + dt: float, + segment_size: float, + norm: str = "leahy", + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create an averaged cross spectrum from two EventLists. + + Args: + event_list_1_name: Name of first EventList + event_list_2_name: Name of second EventList + dt: Time binning in seconds + segment_size: Segment size in seconds + norm: Normalization type + output_name: Optional name to save the spectrum + + Returns: + Result dictionary with averaged cross spectrum data + """ + try: + if not self.state.has_event_data(event_list_1_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_1_name}' not found", + error=None, + ) + + if not self.state.has_event_data(event_list_2_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_2_name}' not found", + error=None, + ) + + seg_error = _segment_size_error(segment_size, dt) + if seg_error: + return self.create_result( + success=False, data=None, message=seg_error, error=None + ) + + event_list_1 = self.state.get_event_data(event_list_1_name) + event_list_2 = self.state.get_event_data(event_list_2_name) + + overlap_error = _overlap_error( + event_list_1, event_list_2, segment_size=segment_size + ) + if overlap_error: + return self.create_result( + success=False, data=None, message=overlap_error, error=None + ) + + lc1 = event_list_1.to_lc(dt=dt) + lc2 = event_list_2.to_lc(dt=dt) + + cs = AveragedCrossspectrum.from_lightcurve( + lc1=lc1, + lc2=lc2, + segment_size=segment_size, + norm=norm, + ) + + if output_name: + self.state.add_spectrum_data(output_name, cs) + + power_list, phase_list = _power_to_lists(cs.power) + cs_data = { + "name": output_name, + "freq": cs.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "norm": norm, + "n_freq": len(cs.freq), + "df": float(cs.df), + "segment_size": segment_size, + "n_segments": int(cs.m) if hasattr(cs, "m") else None, + } + + return self.create_result( + success=True, + data=cs_data, + message=f"Averaged cross spectrum created (segment={segment_size}s)", + ) + + except Exception as e: + return self.handle_error( + e, + "Creating averaged cross spectrum", + event_list_1=event_list_1_name, + event_list_2=event_list_2_name, + dt=dt, + segment_size=segment_size, + norm=norm, + ) + + def create_dynamical_power_spectrum( + self, + event_list_name: str, + dt: float, + segment_size: float, + norm: str = "leahy", + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a dynamical power spectrum from an EventList. + + Args: + event_list_name: Name of the EventList in state + dt: Time binning in seconds + segment_size: Segment size in seconds + norm: Normalization type + output_name: Optional name to save the spectrum + + Returns: + Result dictionary with dynamical power spectrum data + """ + try: + if not self.state.has_event_data(event_list_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_name}' not found", + error=None, + ) + + seg_error = _segment_size_error(segment_size, dt) + if seg_error: + return self.create_result( + success=False, data=None, message=seg_error, error=None + ) + + event_list = self.state.get_event_data(event_list_name) + lc = event_list.to_lc(dt=dt) + dps = DynamicalPowerspectrum(lc, segment_size=segment_size, norm=norm) + + if output_name: + self.state.add_spectrum_data(output_name, dps) + + dps_data = { + "name": output_name, + "freq": dps.freq.tolist(), + "time": dps.time.astype(float).tolist(), + "dyn_ps": [_finite_list(row) for row in dps.dyn_ps], + "norm": norm, + "segment_size": segment_size, + "shape": list(dps.dyn_ps.shape), + } + + return self.create_result( + success=True, + data=dps_data, + message=f"Dynamical power spectrum created (segment={segment_size}s)", + ) + + except Exception as e: + return self.handle_error( + e, + "Creating dynamical power spectrum", + event_list=event_list_name, + dt=dt, + segment_size=segment_size, + norm=norm, + ) + + def rebin_spectrum( + self, + name: str, + rebin_factor: float, + log: bool = False, + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Rebin a spectrum. + + Args: + name: Name of the spectrum to rebin + rebin_factor: Rebinning factor + log: If True, use logarithmic rebinning + output_name: Optional name for rebinned spectrum + + Returns: + Result dictionary with rebinned spectrum data + """ + try: + if not self.state.has_spectrum_data(name): + return self.create_result( + success=False, + data=None, + message=f"Spectrum '{name}' not found", + error=None, + ) + + spectrum = self.state.get_spectrum_data(name) + + if log: + rebinned = spectrum.rebin_log(rebin_factor) + else: + # stingray rebin()'s positional arg is df (Hz), not a factor; use f= + rebinned = spectrum.rebin(f=rebin_factor) + + if output_name: + self.state.add_spectrum_data(output_name, rebinned) + + power_list, phase_list = _power_to_lists(rebinned.power) + data = { + "name": output_name, + "freq": rebinned.freq.tolist(), + "power": power_list, + "power_phase": phase_list, + "norm": getattr(rebinned, "norm", None), + "n_freq": len(rebinned.freq), + } + + return self.create_result( + success=True, + data=data, + message=f"Spectrum rebinned ({'log' if log else 'linear'}, factor={rebin_factor})", + ) + + except Exception as e: + return self.handle_error( + e, "Rebinning spectrum", name=name, rebin_factor=rebin_factor, log=log + ) + + def list_spectra(self) -> Dict[str, Any]: + """List all loaded spectra.""" + try: + spec_data = self.state.get_spectrum_data() + + summaries = [] + for name, spec in spec_data: + spec_type = type(spec).__name__ + summaries.append( + { + "name": name, + "type": spec_type, + "n_freq": len(spec.freq) if hasattr(spec, "freq") else None, + } + ) + + return self.create_result( + success=True, + data=summaries, + message=f"Found {len(summaries)} spectrum/spectra", + ) + + except Exception as e: + return self.handle_error(e, "Listing spectra") + + def delete_spectrum(self, name: str) -> Dict[str, Any]: + """Delete a spectrum from state.""" + try: + if not self.state.has_spectrum_data(name): + return self.create_result( + success=False, + data=None, + message=f"Spectrum '{name}' not found", + error=None, + ) + + self.state.remove_spectrum_data(name) + + return self.create_result( + success=True, + data={"name": name}, + message=f"Spectrum '{name}' deleted", + ) + + except Exception as e: + return self.handle_error(e, "Deleting spectrum", name=name) diff --git a/python-backend/services/state_manager.py b/python-backend/services/state_manager.py new file mode 100644 index 0000000..247b100 --- /dev/null +++ b/python-backend/services/state_manager.py @@ -0,0 +1,719 @@ +""" +State management for Stingray Explorer backend. + +Manages loaded data including EventLists, Lightcurves, and analysis results. +""" + +import copy +import gc +import logging +import sys +import threading +from itertools import chain +from typing import Any, Dict, List, Optional + +import numpy as np +from astropy import units as u +from astropy.table import Column +from astropy.utils.masked import Masked + +logger = logging.getLogger(__name__) + + +def _bounded_utf8_size(value: str, limit: Optional[int]) -> int: + """Count UTF-8 bytes without allocating an encoded copy of the full string.""" + if limit is not None and len(value) > limit: + # Every Unicode code point occupies at least one UTF-8 byte. + return limit + 1 + + total = 0 + for offset in range(0, len(value), 4096): + total += len(value[offset : offset + 4096].encode("utf-8")) + if limit is not None and total > limit: + return limit + 1 + return total + + +def _bounded_key_size(value: Any, limit: Optional[int]) -> int: + """Estimate a mapping key without constructing an unbounded string form.""" + if isinstance(value, str): + return _bounded_utf8_size(value, limit) + if isinstance(value, (bytes, bytearray)): + size = len(value) + return min(size, limit + 1) if limit is not None else size + size = max(32, int(sys.getsizeof(value))) + return min(size, limit + 1) if limit is not None else size + + +def _object_payload_items(value: np.ndarray) -> Any: + """Return referenced object values or reject opaque structured references.""" + if not value.dtype.hasobject: + return () + if value.dtype.kind != "O": + raise ValueError( + "Stored state contains a structured dtype with object references and " + "cannot be copied safely" + ) + return enumerate(np.asarray(value).flat) + + +def _precopy_metrics( + value: Any, + *, + max_rows: Optional[int], + max_cells: Optional[int], + max_bytes: Optional[int], + active: Optional[set[int]] = None, +) -> tuple[int, int, int]: + """Estimate rows, scalar cells, and bytes without copying nested state.""" + if active is None: + active = set() + if value is None or isinstance(value, (bool, float, np.number)): + return 0, 1, 32 + if type(value) is int: + bit_length = abs(value).bit_length() + decimal_bytes = 1 + (bit_length * 30_103) // 100_000 + if value < 0: + decimal_bytes += 1 + return 0, 1, max(32, int(sys.getsizeof(value)), decimal_bytes) + if isinstance(value, str): + return 0, 1, _bounded_utf8_size(value, max_bytes) + if isinstance(value, (bytes, bytearray)): + return 0, 1, len(value) + if type(value) is np.ndarray and not value.dtype.hasobject: + rows = int(value.shape[0]) if value.ndim else 0 + return rows, int(value.size), int(value.nbytes) + + identity = id(value) + if identity in active: + raise ValueError( + "Stored state contains a cyclic value and cannot be copied safely" + ) + active.add(identity) + try: + if isinstance(value, Masked): + own_rows = int(value.shape[0]) if value.ndim else 0 + own_cells = int(value.size) + own_bytes = int(value.nbytes) + int(np.asarray(value.mask).nbytes) + unit = getattr(value, "unit", None) + data_items = _object_payload_items(value) + items = chain( + data_items, + (("unit", unit.to_string() if unit is not None else None),), + ) + elif isinstance(value, u.Quantity): + own_rows = int(value.shape[0]) if value.ndim else 0 + own_cells = int(value.size) + own_bytes = int(value.nbytes) + data_items = _object_payload_items(value) + items = chain(data_items, (("unit", value.unit.to_string()),)) + elif isinstance(value, Column): + own_rows = int(value.shape[0]) if value.ndim else 0 + own_cells = int(value.size) + own_bytes = int(value.nbytes) + column_items: list[tuple[Any, Any]] = [ + ("metadata", value.meta), + ("description", value.description), + ("format", value.format), + ( + "unit", + value.unit.to_string() if value.unit is not None else None, + ), + ] + if isinstance(value, np.ma.MaskedArray): + mask = np.ma.getmask(value) + if mask is not np.ma.nomask: + own_bytes += int(np.asarray(mask).nbytes) + column_items.append(("fill_value", value.fill_value)) + data_items = _object_payload_items(value) + items = chain(data_items, column_items) + elif type(value) is np.ndarray: + own_rows = int(value.shape[0]) if value.ndim else 0 + own_cells = int(value.size) + own_bytes = int(value.nbytes) + if max_rows is not None and own_rows > max_rows: + return own_rows, own_cells, own_bytes + items = _object_payload_items(value) + elif isinstance(value, np.ndarray): + own_rows = int(value.shape[0]) if value.ndim else 0 + own_cells = int(value.size) + own_bytes = int(value.nbytes) + if max_rows is not None and own_rows > max_rows: + return own_rows, own_cells, own_bytes + data_items = _object_payload_items(value) + items = chain(data_items, vars(value).items()) + elif isinstance(value, dict): + items = value.items() + own_rows = 0 + own_cells = 0 + own_bytes = 0 + elif isinstance(value, (list, tuple)): + own_rows = len(value) + own_cells = 0 + own_bytes = 0 + if max_rows is not None and own_rows > max_rows: + return own_rows, own_cells, own_bytes + items = enumerate(value) + elif hasattr(value, "colnames") and hasattr(value, "meta"): + own_rows = len(value) + own_cells = 0 + own_bytes = 0 + if max_rows is not None and own_rows > max_rows: + return own_rows, own_cells, own_bytes + items = chain( + ((name, value[name]) for name in value.colnames), + (("metadata", value.meta),), + ) + elif hasattr(value, "__dict__"): + items = vars(value).items() + own_rows = 0 + own_cells = 0 + own_bytes = 0 + else: + # Avoid invoking an arbitrary, potentially huge ``__str__`` only to + # estimate a value that will be rejected before deepcopy. + return 0, 1, max(32, int(sys.getsizeof(value))) + + observed_rows = own_rows + cells = own_cells + estimated_bytes = own_bytes + if max_rows is not None and observed_rows > max_rows: + return observed_rows, cells, estimated_bytes + if max_cells is not None and cells > max_cells: + return observed_rows, cells, estimated_bytes + if max_bytes is not None and estimated_bytes > max_bytes: + return observed_rows, cells, estimated_bytes + for key, item in items: + remaining_bytes = ( + None if max_bytes is None else max(0, max_bytes - estimated_bytes) + ) + key_bytes = _bounded_key_size(key, remaining_bytes) + estimated_bytes += key_bytes + if max_bytes is not None and estimated_bytes > max_bytes: + return observed_rows, cells, estimated_bytes + child_rows, child_cells, child_bytes = _precopy_metrics( + item, + max_rows=max_rows, + max_cells=(None if max_cells is None else max(0, max_cells - cells)), + max_bytes=( + None if max_bytes is None else max(0, max_bytes - estimated_bytes) + ), + active=active, + ) + observed_rows = max(observed_rows, child_rows) + cells += child_cells + estimated_bytes += child_bytes + if max_rows is not None and observed_rows > max_rows: + return observed_rows, cells, estimated_bytes + if max_cells is not None and cells > max_cells: + return observed_rows, cells, estimated_bytes + if max_bytes is not None and estimated_bytes > max_bytes: + return observed_rows, cells, estimated_bytes + return observed_rows, cells, estimated_bytes + finally: + active.remove(identity) + + +def _precopy_column_count(value: Any) -> int: + """Count top-level tabular columns without constructing a Table.""" + if hasattr(value, "colnames"): + return len(value.colnames) + if isinstance(value, dict): + reserved = {"warnings", "provenance", "parameters", "metadata"} + metadata = value.get("metadata") + if isinstance(metadata, dict): + declared = metadata.get("non_column_fields", []) + if isinstance(declared, (list, tuple)): + reserved.update(name for name in declared if isinstance(name, str)) + count = 0 + for key, item in value.items(): + if key in reserved or isinstance(item, dict): + continue + # Do not call np.asarray here: a large Python sequence would be + # duplicated before the row/cell cap has run. One top-level + # sequence is conservatively one candidate column; detailed shape + # validation remains in the I/O service after the cap. + if isinstance(item, (list, tuple)): + count += 1 + elif isinstance(item, np.ndarray) and item.ndim == 1: + count += 1 + elif getattr(item, "ndim", 0) == 1: + count += 1 + return count + if hasattr(value, "array_attrs") and hasattr(value, "main_array_attr"): + main_name = str(value.main_array_attr) + main_value = getattr(value, main_name, None) + try: + main_rows = len(main_value) + except TypeError: + main_rows = None + names = {main_name} if main_rows is not None else set() + # Stingray's array_attrs() calls np.asanyarray on every attribute. A + # user-supplied Python list could therefore be materialized before the + # allocation cap. Inspect stored values and lengths only here. + for name, item in vars(value).items(): + if isinstance(item, (str, bytes, bytearray, dict)) or item is None: + continue + if isinstance(item, np.ndarray): + aligned = item.ndim >= 1 and len(item) == main_rows + elif isinstance(item, (list, tuple)): + aligned = len(item) == main_rows + else: + aligned = getattr(item, "ndim", 0) == 1 and len(item) == main_rows + if aligned: + names.add(str(name)) + return len(names) + return 0 + + +def _enforce_precopy_caps( + value: Any, + label: str, + *, + max_rows: Optional[int], + max_cells: Optional[int], + max_bytes: Optional[int], + max_columns: Optional[int] = None, +) -> None: + if max_columns is not None: + columns = _precopy_column_count(value) + if columns > max_columns: + raise ValueError( + f"{label} has {columns:,} columns; the operation column cap is " + f"{max_columns:,}" + ) + rows, cells, estimated_bytes = _precopy_metrics( + value, + max_rows=max_rows, + max_cells=max_cells, + max_bytes=max_bytes, + ) + if max_rows is not None and rows > max_rows: + raise ValueError( + f"{label} has {rows:,} rows; the operation cap is {max_rows:,}" + ) + if max_cells is not None and cells > max_cells: + raise ValueError( + f"{label} has at least {cells:,} cells; the operation cell cap is " + f"{max_cells:,}" + ) + if max_bytes is not None and estimated_bytes > max_bytes: + raise ValueError( + f"{label} is estimated at least {estimated_bytes / 1024**2:.1f} MiB; " + f"the operation size cap is {max_bytes / 1024**2:.1f} MiB" + ) + + +def _force_garbage_collection() -> int: + """ + Force garbage collection across all generations. + + This is more thorough than a single gc.collect() call. + See: https://docs.python.org/3/library/gc.html + + Returns: + Total number of unreachable objects collected + """ + collected = 0 + # Collect all generations (0, 1, 2) for thorough cleanup + for generation in range(3): + collected += gc.collect(generation) + return collected + + +class StateManager: + """ + Thread-safe state manager for Stingray Explorer. + + Manages the application state including loaded event lists, + lightcurves, and analysis results. + """ + + def __init__(self): + """Initialize the state manager.""" + self._event_data: Dict[str, Any] = {} + self._lightcurve_data: Dict[str, Any] = {} + self._spectrum_data: Dict[str, Any] = {} + self._analysis_results: Dict[str, Any] = {} + self._lock = threading.RLock() + + # Event data methods + def add_event_data(self, name: str, event_list: Any) -> None: + """ + Add an event list to state. + + Args: + name: Unique name for the event list + event_list: Stingray EventList object + """ + with self._lock: + self._event_data[name] = event_list + + def add_event_data_if_absent(self, name: str, event_list: Any) -> bool: + """Atomically add an EventList without replacing an existing name.""" + with self._lock: + if name in self._event_data: + return False + self._event_data[name] = event_list + return True + + def get_event_data(self, name: Optional[str] = None) -> Any: + """ + Get event list(s) from state. + + Args: + name: Name of specific event list, or None for all + + Returns: + Single EventList if name provided, otherwise list of (name, event_list) tuples + """ + with self._lock: + if name is not None: + return self._event_data.get(name) + return list(self._event_data.items()) + + def copy_event_data( + self, + name: str, + max_events: Optional[int] = None, + max_columns: Optional[int] = None, + max_cells: Optional[int] = None, + max_bytes: Optional[int] = None, + ) -> Any: + """Return a detached snapshot of an EventList, or ``None`` if absent. + + The copy is made while holding the state lock so a concurrent delete or + clear cannot null the stored arrays halfway through the snapshot. When + ``max_events`` is supplied, the stored time-array length is checked + before deepcopy allocates another full EventList. + """ + with self._lock: + event_list = self._event_data.get(name) + if event_list is not None and any( + limit is not None + for limit in (max_events, max_columns, max_cells, max_bytes) + ): + _enforce_precopy_caps( + event_list, + f"EventList '{name}'", + max_rows=max_events, + max_columns=max_columns, + max_cells=max_cells, + max_bytes=max_bytes, + ) + return copy.deepcopy(event_list) if event_list is not None else None + + def has_event_data(self, name: str) -> bool: + """Check if an event list with given name exists.""" + with self._lock: + return name in self._event_data + + def remove_event_data(self, name: str) -> bool: + """ + Remove an event list from state and free memory. + + Args: + name: Name of the event list to remove + + Returns: + True if removed, False if not found + """ + with self._lock: + if name in self._event_data: + # Pop the object from dict (removes reference from dict) + event_list = self._event_data.pop(name) + # Explicitly set internal arrays to None to help GC + # This breaks any internal references + if hasattr(event_list, "time"): + event_list.time = None + if hasattr(event_list, "energy"): + event_list.energy = None + if hasattr(event_list, "pi"): + event_list.pi = None + if hasattr(event_list, "gti"): + event_list.gti = None + # Delete the local reference + del event_list + # Force garbage collection + collected = _force_garbage_collection() + logger.debug( + f"Removed event list '{name}', collected {collected} objects" + ) + return True + return False + + def list_event_names(self) -> List[str]: + """Get list of all event list names.""" + with self._lock: + return list(self._event_data.keys()) + + def clear_event_data(self) -> int: + """ + Clear all event lists from state and free memory. + + Returns: + Number of event lists cleared + """ + with self._lock: + count = len(self._event_data) + # Clear internal arrays for each event list to help GC + for event_list in self._event_data.values(): + if hasattr(event_list, "time"): + event_list.time = None + if hasattr(event_list, "energy"): + event_list.energy = None + if hasattr(event_list, "pi"): + event_list.pi = None + if hasattr(event_list, "gti"): + event_list.gti = None + # Clear the dictionary + self._event_data.clear() + # Force garbage collection + collected = _force_garbage_collection() + logger.debug(f"Cleared {count} event lists, collected {collected} objects") + return count + + # Lightcurve data methods + def add_lightcurve_data(self, name: str, lightcurve: Any) -> None: + """ + Add a lightcurve to state. + + Args: + name: Unique name for the lightcurve + lightcurve: Stingray Lightcurve object + """ + with self._lock: + self._lightcurve_data[name] = lightcurve + + def add_lightcurve_data_if_absent(self, name: str, lightcurve: Any) -> bool: + """Atomically add a Lightcurve without replacing an existing name.""" + with self._lock: + if name in self._lightcurve_data: + return False + self._lightcurve_data[name] = lightcurve + return True + + def get_lightcurve_data(self, name: Optional[str] = None) -> Any: + """ + Get lightcurve(s) from state. + + Args: + name: Name of specific lightcurve, or None for all + + Returns: + Single Lightcurve if name provided, otherwise list of (name, lightcurve) tuples + """ + with self._lock: + if name is not None: + return self._lightcurve_data.get(name) + return list(self._lightcurve_data.items()) + + def copy_lightcurve_data( + self, + name: str, + max_points: Optional[int] = None, + max_columns: Optional[int] = None, + max_cells: Optional[int] = None, + max_bytes: Optional[int] = None, + ) -> Any: + """Return a detached Lightcurve snapshot after an optional pre-copy cap.""" + with self._lock: + lightcurve = self._lightcurve_data.get(name) + if lightcurve is not None and any( + limit is not None + for limit in (max_points, max_columns, max_cells, max_bytes) + ): + _enforce_precopy_caps( + lightcurve, + f"Lightcurve '{name}'", + max_rows=max_points, + max_columns=max_columns, + max_cells=max_cells, + max_bytes=max_bytes, + ) + return copy.deepcopy(lightcurve) if lightcurve is not None else None + + def has_lightcurve_data(self, name: str) -> bool: + """Check if a lightcurve with given name exists.""" + with self._lock: + return name in self._lightcurve_data + + def remove_lightcurve_data(self, name: str) -> bool: + """Remove a lightcurve from state and free memory.""" + with self._lock: + if name in self._lightcurve_data: + lc = self._lightcurve_data.pop(name) + # Clear numpy arrays + if hasattr(lc, "time"): + lc.time = None + if hasattr(lc, "counts"): + lc.counts = None + if hasattr(lc, "count_err"): + lc.count_err = None + del lc + _force_garbage_collection() + return True + return False + + def list_lightcurve_names(self) -> List[str]: + """Get list of all lightcurve names.""" + with self._lock: + return list(self._lightcurve_data.keys()) + + # Spectrum data methods + def add_spectrum_data(self, name: str, spectrum: Any) -> None: + """Add a spectrum to state.""" + with self._lock: + self._spectrum_data[name] = spectrum + + def add_spectrum_data_if_absent(self, name: str, spectrum: Any) -> bool: + """Atomically add a spectrum without replacing an existing name.""" + with self._lock: + if name in self._spectrum_data: + return False + self._spectrum_data[name] = spectrum + return True + + def get_spectrum_data(self, name: Optional[str] = None) -> Any: + """Get spectrum(s) from state.""" + with self._lock: + if name is not None: + return self._spectrum_data.get(name) + return list(self._spectrum_data.items()) + + def copy_spectrum_data(self, name: str) -> Any: + """Return a detached spectrum snapshot, or ``None`` if absent.""" + with self._lock: + spectrum = self._spectrum_data.get(name) + return copy.deepcopy(spectrum) if spectrum is not None else None + + def has_spectrum_data(self, name: str) -> bool: + """Check if a spectrum with given name exists.""" + with self._lock: + return name in self._spectrum_data + + def remove_spectrum_data(self, name: str) -> bool: + """Remove a spectrum from state and free memory.""" + with self._lock: + if name in self._spectrum_data: + spectrum = self._spectrum_data.pop(name) + # Clear numpy arrays (Powerspectrum/Crossspectrum have freq, power, etc.) + if hasattr(spectrum, "freq"): + spectrum.freq = None + if hasattr(spectrum, "power"): + spectrum.power = None + if hasattr(spectrum, "power_err"): + spectrum.power_err = None + del spectrum + _force_garbage_collection() + return True + return False + + def list_spectrum_names(self) -> List[str]: + """Get list of all spectrum names.""" + with self._lock: + return list(self._spectrum_data.keys()) + + # Analysis results methods + def add_analysis_result(self, name: str, result: Any) -> None: + """Add an analysis result to state.""" + with self._lock: + self._analysis_results[name] = result + + def add_analysis_result_if_absent(self, name: str, result: Any) -> bool: + """Atomically add an analysis result without replacing a name.""" + with self._lock: + if name in self._analysis_results: + return False + self._analysis_results[name] = result + return True + + def get_analysis_result(self, name: Optional[str] = None) -> Any: + """Get analysis result(s) from state.""" + with self._lock: + if name is not None: + return self._analysis_results.get(name) + return list(self._analysis_results.items()) + + def copy_analysis_result( + self, + name: str, + max_rows: Optional[int] = None, + max_columns: Optional[int] = None, + max_cells: Optional[int] = None, + max_bytes: Optional[int] = None, + ) -> Any: + """Return a detached analysis-result snapshot after an optional row cap.""" + with self._lock: + result = self._analysis_results.get(name) + if result is not None and any( + limit is not None + for limit in (max_rows, max_columns, max_cells, max_bytes) + ): + _enforce_precopy_caps( + result, + f"Analysis result '{name}'", + max_rows=max_rows, + max_columns=max_columns, + max_cells=max_cells, + max_bytes=max_bytes, + ) + return copy.deepcopy(result) if result is not None else None + + def has_analysis_result(self, name: str) -> bool: + """Check whether an analysis result name exists.""" + with self._lock: + return name in self._analysis_results + + def list_analysis_result_names(self) -> List[str]: + """Return all stored analysis-result names.""" + with self._lock: + return list(self._analysis_results.keys()) + + def remove_analysis_result(self, name: str) -> bool: + """Remove an analysis result from state.""" + with self._lock: + if name in self._analysis_results: + del self._analysis_results[name] + return True + return False + + # Utility methods + def clear_all(self) -> None: + """Clear all state data and free memory.""" + with self._lock: + # Clear internal arrays for all objects to help GC + for event_list in self._event_data.values(): + for attr in ["time", "energy", "pi", "gti"]: + if hasattr(event_list, attr): + setattr(event_list, attr, None) + + for lc in self._lightcurve_data.values(): + for attr in ["time", "counts", "count_err"]: + if hasattr(lc, attr): + setattr(lc, attr, None) + + for spectrum in self._spectrum_data.values(): + for attr in ["freq", "power", "power_err"]: + if hasattr(spectrum, attr): + setattr(spectrum, attr, None) + + # Clear all dictionaries + self._event_data.clear() + self._lightcurve_data.clear() + self._spectrum_data.clear() + self._analysis_results.clear() + + # Force garbage collection + collected = _force_garbage_collection() + logger.debug(f"Cleared all state data, collected {collected} objects") + + def get_summary(self) -> Dict[str, int]: + """Get a summary of stored data counts.""" + with self._lock: + return { + "event_lists": len(self._event_data), + "lightcurves": len(self._lightcurve_data), + "spectra": len(self._spectrum_data), + "analysis_results": len(self._analysis_results), + } diff --git a/python-backend/services/statistics_service.py b/python-backend/services/statistics_service.py new file mode 100644 index 0000000..2589e20 --- /dev/null +++ b/python-backend/services/statistics_service.py @@ -0,0 +1,915 @@ +"""Statistical utility service backed by the public Stingray 2.2.10 API.""" + +# Service boundaries intentionally catch unexpected library failures and route +# them through BaseService's standardized error handler. +# ruff: noqa: BLE001 + +from __future__ import annotations + +import math +from numbers import Integral, Real +from typing import Any + +from stingray import stats as stingray_stats + +from .analysis_helpers import collect_warnings +from .base_service import BaseService +from .utility_helpers import finite_or_none, json_safe, operation_provenance + +# Plain Python integers dispatch to the int32 Numba overload used by Stingray's +# trial-correction ufuncs. Keeping parameters within this bound avoids a +# platform-dependent OverflowError while still allowing realistic searches. +MAX_COUNT_PARAMETER = 2_147_483_647 + +PROBABILITY_UNITS = "dimensionless probability" +LOG_PROBABILITY_UNITS = "natural logarithm of a dimensionless probability" + + +def _number_error( + value: Any, + label: str, + *, + minimum: float | None = None, + maximum: float | None = None, + minimum_inclusive: bool = True, + maximum_inclusive: bool = True, +) -> str | None: + """Return a readable validation error for a finite real scalar.""" + if isinstance(value, bool) or not isinstance(value, Real): + return f"{label} must be a finite number" + try: + numeric = float(value) + except (TypeError, ValueError, OverflowError): + return f"{label} must be a finite number" + if not math.isfinite(numeric): + return f"{label} must be finite" + if minimum is not None: + invalid = numeric < minimum if minimum_inclusive else numeric <= minimum + if invalid: + relation = "at least" if minimum_inclusive else "greater than" + return f"{label} must be {relation} {minimum:g}" + if maximum is not None: + invalid = numeric > maximum if maximum_inclusive else numeric >= maximum + if invalid: + relation = "at most" if maximum_inclusive else "less than" + return f"{label} must be {relation} {maximum:g}" + return None + + +def _count_error(value: Any, label: str, *, minimum: int = 1) -> str | None: + """Return a readable validation error for a bounded integer count.""" + if isinstance(value, bool) or not isinstance(value, Integral): + return f"{label} must be an integer" + integer = int(value) + if integer < minimum: + return f"{label} must be at least {minimum}" + if integer > MAX_COUNT_PARAMETER: + return f"{label} must not exceed {MAX_COUNT_PARAMETER:,}" + return None + + +def _append_underflow_warning( + probability: float | None, + log_probability: float | None, + warning_messages: list[str], +) -> None: + """Explain a linear-probability underflow without discarding its zero.""" + if probability == 0.0 and log_probability is not None: + warning = ( + "The linear probability underflowed to 0.0 in floating-point; " + "the finite natural-log probability preserves the significance." + ) + if warning not in warning_messages: + warning_messages.append(warning) + + +class StatisticsService(BaseService): + """Stateless wrappers around supported public functions in ``stingray.stats``.""" + + def _invalid(self, message: str) -> dict[str, Any]: + return self.create_result( + success=False, + data=None, + message=message, + error=None, + ) + + def _finish( + self, + core: dict[str, Any], + warning_messages: list[str], + *, + operation: str, + parameters: dict[str, Any], + public_api_calls: list[str], + message: str, + ) -> dict[str, Any]: + """Sanitize a successful payload and attach reproducibility metadata.""" + data = json_safe(core, warning_messages) + data["provenance"] = json_safe( + operation_provenance( + operation, + input_source="user-supplied scalar inputs", + parameters=parameters, + public_api_calls=public_api_calls, + ), + warning_messages, + "provenance", + ) + data["warnings"] = warning_messages + return self.create_result(success=True, data=data, message=message) + + def gaussian_significance( + self, + *, + probability: float | None = None, + log_probability: float | None = None, + sidedness: str = "one-sided", + ) -> dict[str, Any]: + """Convert a tail probability to Gaussian sigma with explicit sidedness. + + Stingray implements the one-sided upper-tail convention ``Q^-1(p)``. + For a two-sided total probability, half of the probability belongs to + each tail, so this service passes ``p / 2`` (or ``ln(p) - ln(2)``) to + Stingray and exposes that transformed effective tail in the response. + """ + try: + if sidedness not in {"one-sided", "two-sided"}: + return self._invalid("sidedness must be 'one-sided' or 'two-sided'") + if (probability is None) == (log_probability is None): + return self._invalid( + "Provide exactly one of probability or log_probability" + ) + + if probability is not None: + error = _number_error( + probability, + "probability", + minimum=0.0, + maximum=1.0, + minimum_inclusive=False, + maximum_inclusive=False, + ) + if error: + return self._invalid(error) + input_probability = float(probability) + input_log_probability = math.log(input_probability) + input_mode = "probability" + else: + error = _number_error( + log_probability, + "log_probability", + maximum=0.0, + maximum_inclusive=False, + ) + if error: + return self._invalid( + f"{error}; log_probability is the natural logarithm of p" + ) + input_probability = None + input_log_probability = float(log_probability) + input_mode = "log_probability" + + tail_adjustment = math.log(2.0) if sidedness == "two-sided" else 0.0 + effective_log_probability = input_log_probability - tail_adjustment + effective_probability = math.exp(effective_log_probability) + + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + if input_mode == "probability" and effective_probability > 0.0: + raw_sigma = stingray_stats.equivalent_gaussian_Nsigma( + effective_probability + ) + public_call = "stingray.stats.equivalent_gaussian_Nsigma" + else: + raw_sigma = stingray_stats.equivalent_gaussian_Nsigma_from_logp( + effective_log_probability + ) + public_call = "stingray.stats.equivalent_gaussian_Nsigma_from_logp" + + sigma = finite_or_none(raw_sigma, warning_messages, "Gaussian sigma") + effective_probability_value = finite_or_none( + effective_probability, + warning_messages, + "effective one-sided probability", + ) + _append_underflow_warning( + effective_probability_value, + effective_log_probability, + warning_messages, + ) + parameters = { + "probability": probability, + "log_probability": log_probability, + "sidedness": sidedness, + } + core = { + "calculation": "gaussian_significance", + "input_mode": input_mode, + "input_probability": input_probability, + "input_log_probability": input_log_probability, + "effective_one_sided_probability": effective_probability_value, + "effective_one_sided_log_probability": effective_log_probability, + "sigma": sigma, + "sidedness": sidedness, + "tail": "upper", + "direction": "probability_to_gaussian_sigma", + "units": { + "input_probability": PROBABILITY_UNITS, + "input_log_probability": LOG_PROBABILITY_UNITS, + "sigma": "standard deviations from the Gaussian mean", + }, + } + return self._finish( + core, + warning_messages, + operation="statistics.gaussian_significance", + parameters=parameters, + public_api_calls=[public_call], + message="Converted tail probability to Gaussian significance", + ) + except Exception as exc: + return self.handle_error( + exc, + "Converting probability to Gaussian significance", + probability=probability, + log_probability=log_probability, + sidedness=sidedness, + ) + + def convert_trials( + self, + *, + direction: str, + probability: float, + n_trials: int, + ) -> dict[str, Any]: + """Apply Stingray's independent-trial forward or inverse correction.""" + try: + if direction not in {"single-to-multi", "multi-to-single"}: + return self._invalid( + "direction must be 'single-to-multi' or 'multi-to-single'" + ) + error = _number_error( + probability, + "probability", + minimum=0.0, + maximum=1.0, + ) + if error: + return self._invalid(error) + if direction == "multi-to-single" and float(probability) == 1.0: + return self._invalid( + "probability must be less than 1 for multi-to-single conversion; " + "the inverse is numerically ill-conditioned at 1" + ) + error = _count_error(n_trials, "n_trials") + if error: + return self._invalid(error) + + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + if direction == "single-to-multi": + raw_output = stingray_stats.p_multitrial_from_single_trial( + float(probability), int(n_trials) + ) + public_call = "stingray.stats.p_multitrial_from_single_trial" + else: + raw_output = stingray_stats.p_single_trial_from_p_multitrial( + float(probability), int(n_trials) + ) + public_call = "stingray.stats.p_single_trial_from_p_multitrial" + + output_probability = finite_or_none( + raw_output, warning_messages, "trial-corrected probability" + ) + parameters = { + "direction": direction, + "probability": probability, + "n_trials": n_trials, + } + core = { + "calculation": "trial_correction", + "direction": direction, + "input_probability": float(probability), + "output_probability": output_probability, + "n_trials": int(n_trials), + "independence_assumption": ( + "Trials are assumed to be statistically independent." + ), + "units": { + "input_probability": PROBABILITY_UNITS, + "output_probability": PROBABILITY_UNITS, + }, + } + return self._finish( + core, + warning_messages, + operation="statistics.trial_correction", + parameters=parameters, + public_api_calls=[public_call], + message="Converted probability across independent trials", + ) + except Exception as exc: + return self.handle_error( + exc, + "Converting trial probability", + direction=direction, + probability=probability, + n_trials=n_trials, + ) + + def evaluate_pds( + self, + *, + power: float, + n_trials: int = 1, + n_summed_spectra: int = 1, + n_rebin: int = 1, + ) -> dict[str, Any]: + """Evaluate an observed Leahy-normalized PDS power.""" + errors = [ + _number_error(power, "power", minimum=0.0), + _count_error(n_trials, "n_trials"), + _count_error(n_summed_spectra, "n_summed_spectra"), + _count_error(n_rebin, "n_rebin"), + ] + error = next((item for item in errors if item), None) + if error: + return self._invalid(error) + if not math.isfinite(float(power) * int(n_summed_spectra) * int(n_rebin)): + return self._invalid( + "power x n_summed_spectra x n_rebin must remain finite" + ) + try: + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + raw_probability = stingray_stats.pds_probability( + float(power), + ntrial=int(n_trials), + n_summed_spectra=int(n_summed_spectra), + n_rebin=int(n_rebin), + ) + raw_log_probability = stingray_stats.pds_logprobability( + float(power), + ntrial=int(n_trials), + n_summed_spectra=int(n_summed_spectra), + n_rebin=int(n_rebin), + ) + return self._finish_evaluation( + family="pds", + observed_statistic=float(power), + raw_probability=raw_probability, + raw_log_probability=raw_log_probability, + parameters={ + "power": power, + "n_trials": n_trials, + "n_summed_spectra": n_summed_spectra, + "n_rebin": n_rebin, + }, + echoed_parameters={ + "n_trials": int(n_trials), + "n_summed_spectra": int(n_summed_spectra), + "n_rebin": int(n_rebin), + }, + warning_messages=warning_messages, + public_api_calls=[ + "stingray.stats.pds_probability", + "stingray.stats.pds_logprobability", + ], + statistic_units="dimensionless Leahy-normalized power", + tail="upper", + ) + except Exception as exc: + return self.handle_error(exc, "Evaluating PDS probability", power=power) + + def detect_pds( + self, + *, + false_alarm_probability: float, + n_trials: int = 1, + n_summed_spectra: int = 1, + n_rebin: int = 1, + ) -> dict[str, Any]: + """Calculate a Leahy PDS threshold for an overall false-alarm rate.""" + parameters = { + "false_alarm_probability": false_alarm_probability, + "n_trials": n_trials, + "n_summed_spectra": n_summed_spectra, + "n_rebin": n_rebin, + } + error = self._detection_parameters_error( + false_alarm_probability, + n_trials, + (n_summed_spectra, "n_summed_spectra"), + (n_rebin, "n_rebin"), + ) + if error: + return self._invalid(error) + try: + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + raw_level = stingray_stats.pds_detection_level( + epsilon=float(false_alarm_probability), + ntrial=int(n_trials), + n_summed_spectra=int(n_summed_spectra), + n_rebin=int(n_rebin), + ) + return self._finish_detection( + family="pds", + false_alarm_probability=float(false_alarm_probability), + raw_level=raw_level, + parameters=parameters, + echoed_parameters={ + "n_trials": int(n_trials), + "n_summed_spectra": int(n_summed_spectra), + "n_rebin": int(n_rebin), + }, + warning_messages=warning_messages, + public_api_call="stingray.stats.pds_detection_level", + statistic_units="dimensionless Leahy-normalized power", + tail="upper", + ) + except Exception as exc: + return self.handle_error( + exc, "Calculating PDS detection level", **parameters + ) + + def evaluate_z2( + self, + *, + z2: float, + harmonics: int = 2, + n_trials: int = 1, + n_summed_spectra: int = 1, + ) -> dict[str, Any]: + """Evaluate an observed averaged Z-squared-n statistic.""" + errors = [ + _number_error(z2, "z2", minimum=0.0), + _count_error(harmonics, "harmonics"), + _count_error(n_trials, "n_trials"), + _count_error(n_summed_spectra, "n_summed_spectra"), + ] + error = next((item for item in errors if item), None) + if error: + return self._invalid(error) + if not math.isfinite(float(z2) * int(n_summed_spectra)): + return self._invalid("z2 x n_summed_spectra must remain finite") + try: + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + raw_probability = stingray_stats.z2_n_probability( + float(z2), + int(harmonics), + ntrial=int(n_trials), + n_summed_spectra=int(n_summed_spectra), + ) + raw_log_probability = stingray_stats.z2_n_logprobability( + float(z2), + int(harmonics), + ntrial=int(n_trials), + n_summed_spectra=int(n_summed_spectra), + ) + return self._finish_evaluation( + family="z2_n", + observed_statistic=float(z2), + raw_probability=raw_probability, + raw_log_probability=raw_log_probability, + parameters={ + "z2": z2, + "harmonics": harmonics, + "n_trials": n_trials, + "n_summed_spectra": n_summed_spectra, + }, + echoed_parameters={ + "harmonics": int(harmonics), + "n_trials": int(n_trials), + "n_summed_spectra": int(n_summed_spectra), + }, + warning_messages=warning_messages, + public_api_calls=[ + "stingray.stats.z2_n_probability", + "stingray.stats.z2_n_logprobability", + ], + statistic_units="dimensionless Z-squared-n statistic", + tail="upper", + ) + except Exception as exc: + return self.handle_error(exc, "Evaluating Z-squared-n probability", z2=z2) + + def detect_z2( + self, + *, + false_alarm_probability: float, + harmonics: int = 2, + n_trials: int = 1, + n_summed_spectra: int = 1, + ) -> dict[str, Any]: + """Calculate a Z-squared-n threshold for an overall false-alarm rate.""" + parameters = { + "false_alarm_probability": false_alarm_probability, + "harmonics": harmonics, + "n_trials": n_trials, + "n_summed_spectra": n_summed_spectra, + } + error = self._detection_parameters_error( + false_alarm_probability, + n_trials, + (harmonics, "harmonics"), + (n_summed_spectra, "n_summed_spectra"), + ) + if error: + return self._invalid(error) + try: + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + raw_level = stingray_stats.z2_n_detection_level( + n=int(harmonics), + epsilon=float(false_alarm_probability), + ntrial=int(n_trials), + n_summed_spectra=int(n_summed_spectra), + ) + return self._finish_detection( + family="z2_n", + false_alarm_probability=float(false_alarm_probability), + raw_level=raw_level, + parameters=parameters, + echoed_parameters={ + "harmonics": int(harmonics), + "n_trials": int(n_trials), + "n_summed_spectra": int(n_summed_spectra), + }, + warning_messages=warning_messages, + public_api_call="stingray.stats.z2_n_detection_level", + statistic_units="dimensionless Z-squared-n statistic", + tail="upper", + ) + except Exception as exc: + return self.handle_error( + exc, "Calculating Z-squared-n detection level", **parameters + ) + + def evaluate_fold( + self, + *, + statistic: float, + n_phase_bins: int, + n_trials: int = 1, + ) -> dict[str, Any]: + """Evaluate an observed epoch-folding statistic.""" + errors = [ + _number_error(statistic, "statistic", minimum=0.0), + _count_error(n_phase_bins, "n_phase_bins", minimum=3), + _count_error(n_trials, "n_trials"), + ] + error = next((item for item in errors if item), None) + if error: + return self._invalid(error) + try: + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + raw_probability = stingray_stats.fold_profile_probability( + float(statistic), int(n_phase_bins), ntrial=int(n_trials) + ) + raw_log_probability = stingray_stats.fold_profile_logprobability( + float(statistic), int(n_phase_bins), ntrial=int(n_trials) + ) + return self._finish_evaluation( + family="epoch_folding", + observed_statistic=float(statistic), + raw_probability=raw_probability, + raw_log_probability=raw_log_probability, + parameters={ + "statistic": statistic, + "n_phase_bins": n_phase_bins, + "n_trials": n_trials, + }, + echoed_parameters={ + "n_phase_bins": int(n_phase_bins), + "n_trials": int(n_trials), + }, + warning_messages=warning_messages, + public_api_calls=[ + "stingray.stats.fold_profile_probability", + "stingray.stats.fold_profile_logprobability", + ], + statistic_units="dimensionless epoch-folding statistic", + tail="upper", + ) + except Exception as exc: + return self.handle_error( + exc, "Evaluating epoch-folding probability", statistic=statistic + ) + + def detect_fold( + self, + *, + false_alarm_probability: float, + n_phase_bins: int, + n_trials: int = 1, + ) -> dict[str, Any]: + """Calculate an epoch-folding threshold for an overall false-alarm rate.""" + parameters = { + "false_alarm_probability": false_alarm_probability, + "n_phase_bins": n_phase_bins, + "n_trials": n_trials, + } + error = self._detection_parameters_error( + false_alarm_probability, + n_trials, + (n_phase_bins, "n_phase_bins", 3), + ) + if error: + return self._invalid(error) + try: + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + raw_level = stingray_stats.fold_detection_level( + int(n_phase_bins), + epsilon=float(false_alarm_probability), + ntrial=int(n_trials), + ) + return self._finish_detection( + family="epoch_folding", + false_alarm_probability=float(false_alarm_probability), + raw_level=raw_level, + parameters=parameters, + echoed_parameters={ + "n_phase_bins": int(n_phase_bins), + "n_trials": int(n_trials), + }, + warning_messages=warning_messages, + public_api_call="stingray.stats.fold_detection_level", + statistic_units="dimensionless epoch-folding statistic", + tail="upper", + ) + except Exception as exc: + return self.handle_error( + exc, "Calculating epoch-folding detection level", **parameters + ) + + def evaluate_pdm( + self, + *, + statistic: float, + n_samples: int, + n_phase_bins: int, + n_trials: int = 1, + ) -> dict[str, Any]: + """Evaluate an observed phase-dispersion statistic (lower tail).""" + error = self._pdm_parameters_error( + statistic=statistic, + n_samples=n_samples, + n_phase_bins=n_phase_bins, + n_trials=n_trials, + ) + if error: + return self._invalid(error) + try: + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + raw_probability = stingray_stats.phase_dispersion_probability( + float(statistic), + int(n_samples), + int(n_phase_bins), + ntrial=int(n_trials), + ) + raw_log_probability = stingray_stats.phase_dispersion_logprobability( + float(statistic), + int(n_samples), + int(n_phase_bins), + ntrial=int(n_trials), + ) + return self._finish_evaluation( + family="phase_dispersion", + observed_statistic=float(statistic), + raw_probability=raw_probability, + raw_log_probability=raw_log_probability, + parameters={ + "statistic": statistic, + "n_samples": n_samples, + "n_phase_bins": n_phase_bins, + "n_trials": n_trials, + }, + echoed_parameters={ + "n_samples": int(n_samples), + "n_phase_bins": int(n_phase_bins), + "n_trials": int(n_trials), + }, + warning_messages=warning_messages, + public_api_calls=[ + "stingray.stats.phase_dispersion_probability", + "stingray.stats.phase_dispersion_logprobability", + ], + statistic_units="dimensionless phase-dispersion statistic", + tail="lower", + ) + except Exception as exc: + return self.handle_error( + exc, "Evaluating phase-dispersion probability", statistic=statistic + ) + + def detect_pdm( + self, + *, + false_alarm_probability: float, + n_samples: int, + n_phase_bins: int, + n_trials: int = 1, + ) -> dict[str, Any]: + """Calculate a PDM lower-tail threshold for an overall false-alarm rate.""" + parameters = { + "false_alarm_probability": false_alarm_probability, + "n_samples": n_samples, + "n_phase_bins": n_phase_bins, + "n_trials": n_trials, + } + error = self._pdm_parameters_error( + statistic=None, + n_samples=n_samples, + n_phase_bins=n_phase_bins, + n_trials=n_trials, + ) + if not error: + error = _number_error( + false_alarm_probability, + "false_alarm_probability", + minimum=0.0, + maximum=1.0, + minimum_inclusive=False, + maximum_inclusive=False, + ) + if error: + return self._invalid(error) + try: + warning_messages: list[str] = [] + with collect_warnings(warning_messages): + raw_level = stingray_stats.phase_dispersion_detection_level( + int(n_samples), + int(n_phase_bins), + epsilon=float(false_alarm_probability), + ntrial=int(n_trials), + ) + return self._finish_detection( + family="phase_dispersion", + false_alarm_probability=float(false_alarm_probability), + raw_level=raw_level, + parameters=parameters, + echoed_parameters={ + "n_samples": int(n_samples), + "n_phase_bins": int(n_phase_bins), + "n_trials": int(n_trials), + }, + warning_messages=warning_messages, + public_api_call="stingray.stats.phase_dispersion_detection_level", + statistic_units="dimensionless phase-dispersion statistic", + tail="lower", + ) + except Exception as exc: + return self.handle_error( + exc, "Calculating phase-dispersion detection level", **parameters + ) + + def _finish_evaluation( + self, + *, + family: str, + observed_statistic: float, + raw_probability: Any, + raw_log_probability: Any, + parameters: dict[str, Any], + echoed_parameters: dict[str, int], + warning_messages: list[str], + public_api_calls: list[str], + statistic_units: str, + tail: str, + ) -> dict[str, Any]: + probability = finite_or_none( + raw_probability, warning_messages, "false-alarm probability" + ) + log_probability = finite_or_none( + raw_log_probability, + warning_messages, + "natural-log false-alarm probability", + ) + _append_underflow_warning(probability, log_probability, warning_messages) + core = { + "family": family, + "calculation": "probability", + "direction": "observed_statistic_to_false_alarm_probability", + "observed_statistic": observed_statistic, + "probability": probability, + "log_probability": log_probability, + "probability_scope": "overall_post_trial", + **echoed_parameters, + "tail": tail, + "more_significant_when": "larger" if tail == "upper" else "smaller", + "units": { + "observed_statistic": statistic_units, + "probability": PROBABILITY_UNITS, + "log_probability": LOG_PROBABILITY_UNITS, + }, + } + return self._finish( + core, + warning_messages, + operation=f"statistics.{family}.probability", + parameters=parameters, + public_api_calls=public_api_calls, + message=f"Evaluated {family.replace('_', ' ')} false-alarm probability", + ) + + def _finish_detection( + self, + *, + family: str, + false_alarm_probability: float, + raw_level: Any, + parameters: dict[str, Any], + echoed_parameters: dict[str, int], + warning_messages: list[str], + public_api_call: str, + statistic_units: str, + tail: str, + ) -> dict[str, Any]: + detection_level = finite_or_none(raw_level, warning_messages, "detection level") + comparison = ">=" if tail == "upper" else "<=" + core = { + "family": family, + "calculation": "detection_level", + "direction": "false_alarm_probability_to_detection_level", + "false_alarm_probability": false_alarm_probability, + "false_alarm_probability_scope": "overall_post_trial", + "detection_level": detection_level, + **echoed_parameters, + "tail": tail, + "decision_rule": f"observed_statistic {comparison} detection_level", + "units": { + "false_alarm_probability": PROBABILITY_UNITS, + "detection_level": statistic_units, + }, + } + return self._finish( + core, + warning_messages, + operation=f"statistics.{family}.detection_level", + parameters=parameters, + public_api_calls=[public_api_call], + message=f"Calculated {family.replace('_', ' ')} detection level", + ) + + def _detection_parameters_error( + self, + false_alarm_probability: Any, + n_trials: Any, + *counts: tuple[Any, str] | tuple[Any, str, int], + ) -> str | None: + error = _number_error( + false_alarm_probability, + "false_alarm_probability", + minimum=0.0, + maximum=1.0, + minimum_inclusive=False, + maximum_inclusive=False, + ) + if error: + return error + error = _count_error(n_trials, "n_trials") + if error: + return error + for count in counts: + value, label = count[:2] + minimum = count[2] if len(count) == 3 else 1 + error = _count_error(value, label, minimum=minimum) + if error: + return error + return None + + def _pdm_parameters_error( + self, + *, + statistic: Any | None, + n_samples: Any, + n_phase_bins: Any, + n_trials: Any, + ) -> str | None: + if statistic is not None: + error = _number_error( + statistic, + "statistic", + minimum=0.0, + maximum=1.0, + ) + if error: + return error + for value, label, minimum in ( + (n_samples, "n_samples", 3), + (n_phase_bins, "n_phase_bins", 2), + (n_trials, "n_trials", 1), + ): + error = _count_error(value, label, minimum=minimum) + if error: + return error + if int(n_samples) <= int(n_phase_bins): + return "n_samples must be greater than n_phase_bins" + return None diff --git a/python-backend/services/timing_service.py b/python-backend/services/timing_service.py new file mode 100644 index 0000000..551b294 --- /dev/null +++ b/python-backend/services/timing_service.py @@ -0,0 +1,487 @@ +""" +Timing service for timing analysis operations. + +Handles bispectrum, power colors, and other timing analysis. +""" + +from typing import Any, Dict, Optional + +import numpy as np +from stingray import Bispectrum, DynamicalPowerspectrum + +from .base_service import BaseService +from .utility_helpers import operation_provenance + + +def _finite_list(arr) -> list: + """Convert a float array to a list, replacing non-finite values with None.""" + values = np.asarray(arr, dtype=float) + if np.isfinite(values).all(): + return values.tolist() + return [float(v) if np.isfinite(v) else None for v in values] + + +def _segment_size_error(segment_size: float, dt: float) -> Optional[str]: + """Human-readable rejection for segment sizes that stingray fails on cryptically. + + Needs at least 3 time bins per segment to produce a non-empty spectrum. + """ + if segment_size / dt < 3: + return ( + f"segment_size ({segment_size}s) must be at least 3x dt ({dt}s) " + "to produce a non-empty spectrum" + ) + return None + + +def _overlap_error( + events1, events2, segment_size: Optional[float] = None +) -> Optional[str]: + """Readable rejection when two event lists share no time overlap. + + Optional segment_size check: if provided and the overlap is shorter than + one segment, stingray will produce zero segments (cryptic error), so we + reject early with a human-readable message. + """ + if len(events1.time) == 0 or len(events2.time) == 0: + return "one of the event lists contains no events" + start = max(float(events1.time[0]), float(events2.time[0])) + stop = min(float(events1.time[-1]), float(events2.time[-1])) + if stop <= start: + return ( + "the two event lists have no overlapping time range " + f"({events1.time[0]:.1f}-{events1.time[-1]:.1f}s vs " + f"{events2.time[0]:.1f}-{events2.time[-1]:.1f}s)" + ) + if segment_size is not None and (stop - start) < segment_size: + return ( + f"the overlapping time range ({stop - start:.1f}s) is shorter than " + f"the segment size ({segment_size}s)" + ) + return None + + +class TimingService(BaseService): + """ + Service for timing analysis operations. + + Handles bispectrum, power colors, and higher-order timing analysis. + """ + + def create_bispectrum( + self, + event_list_name: str, + dt: float, + maxlag: int = 25, + scale: str = "unbiased", + window: str = "uniform", + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a bispectrum from an EventList. + + The bispectrum is used to detect non-linear interactions and + non-Gaussian features in the data. + + Args: + event_list_name: Name of the EventList in state + dt: Time binning in seconds + maxlag: Maximum lag for bispectrum calculation + scale: Scaling type ("biased" or "unbiased") + window: Window function type + output_name: Optional name to save the result + + Returns: + Result dictionary with bispectrum data + """ + try: + if not self.state.has_event_data(event_list_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_name}' not found", + error=None, + ) + + event_list = self.state.get_event_data(event_list_name) + lc = event_list.to_lc(dt=dt) + bs = Bispectrum(lc, maxlag=maxlag, scale=scale, window=window) + + if output_name: + self.state.add_analysis_result(output_name, bs) + + bs_data = { + "name": output_name, + "freq": bs.freq.tolist(), + "lags": bs.lags.tolist(), + "bispec_mag": bs.bispec_mag.tolist(), + "bispec_phase": bs.bispec_phase.tolist(), + # cum3 omitted from the payload — large and unused by the UI; + # recompute server-side if ever needed. + "maxlag": maxlag, + "scale": scale, + "window": window, + } + + return self.create_result( + success=True, + data=bs_data, + message=f"Bispectrum created (maxlag={maxlag})", + ) + + except Exception as e: + return self.handle_error( + e, + "Creating bispectrum", + event_list=event_list_name, + dt=dt, + maxlag=maxlag, + ) + + def calculate_power_colors( + self, + event_list_name: str, + dt: float, + segment_size: float, + freq_ranges: Dict[str, tuple], + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Calculate power colors from frequency bands. + + Power colors are ratios of band-mean power in different frequency bands, + useful for source classification and state analysis. + + Args: + event_list_name: Name of the EventList in state + dt: Time binning in seconds + segment_size: Segment size in seconds + freq_ranges: Dictionary of frequency ranges + output_name: Optional name to save the result + + Returns: + Result dictionary with power colors + """ + try: + if not self.state.has_event_data(event_list_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_name}' not found", + error=None, + ) + + seg_error = _segment_size_error(segment_size, dt) + if seg_error: + return self.create_result( + success=False, data=None, message=seg_error, error=None + ) + + event_list = self.state.get_event_data(event_list_name) + lc = event_list.to_lc(dt=dt) + dps = DynamicalPowerspectrum(lc, segment_size=segment_size, norm="leahy") + + # Mean power in each frequency band per time segment. + # dps.dyn_ps has shape (n_freq, n_time); mask along axis 0 (freq). + power_colors = {} + for band_name, (f_min, f_max) in freq_ranges.items(): + mask = (dps.freq >= f_min) & (dps.freq < f_max) + band_mean_power = dps.dyn_ps[mask, :].mean(axis=0) + power_colors[band_name] = _finite_list(band_mean_power) + + result_data = { + "name": output_name, + "power_colors": power_colors, + "time": dps.time.astype(float).tolist(), + "freq_ranges": freq_ranges, + } + + if output_name: + self.state.add_analysis_result(output_name, result_data) + + return self.create_result( + success=True, + data=result_data, + message=f"Power colors calculated for {len(freq_ranges)} bands", + ) + + except Exception as e: + return self.handle_error( + e, + "Calculating power colors", + event_list=event_list_name, + dt=dt, + segment_size=segment_size, + ) + + def calculate_time_lags( + self, + event_list_1_name: str, + event_list_2_name: str, + dt: float, + segment_size: float, + freq_range: Optional[tuple] = None, + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Calculate time lags between two event lists. + + Args: + event_list_1_name: Name of first EventList + event_list_2_name: Name of second EventList + dt: Time binning in seconds + segment_size: Segment size in seconds + freq_range: Optional frequency range to calculate lags for + output_name: Optional name to save the result + + Returns: + Result dictionary with time lags + """ + try: + if not self.state.has_event_data(event_list_1_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_1_name}' not found", + error=None, + ) + + if not self.state.has_event_data(event_list_2_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_2_name}' not found", + error=None, + ) + + seg_error = _segment_size_error(segment_size, dt) + if seg_error: + return self.create_result( + success=False, data=None, message=seg_error, error=None + ) + + from stingray import AveragedCrossspectrum + + event_list_1 = self.state.get_event_data(event_list_1_name) + event_list_2 = self.state.get_event_data(event_list_2_name) + + overlap_error = _overlap_error( + event_list_1, event_list_2, segment_size=segment_size + ) + if overlap_error: + return self.create_result( + success=False, data=None, message=overlap_error, error=None + ) + + lc1 = event_list_1.to_lc(dt=dt) + lc2 = event_list_2.to_lc(dt=dt) + + cs = AveragedCrossspectrum.from_lightcurve( + lc1=lc1, + lc2=lc2, + segment_size=segment_size, + norm="leahy", + ) + + # Stingray's time_lag() returns (lag, lag_err) for averaged spectra. + lag_result = cs.time_lag() + if isinstance(lag_result, tuple): + time_lags, time_lags_err = lag_result + else: + time_lags, time_lags_err = lag_result, None + + freq = np.asarray(cs.freq, dtype=float) + time_lags = np.real(np.asarray(time_lags)) + if time_lags_err is not None: + time_lags_err = np.real(np.asarray(time_lags_err)) + + if time_lags.shape != freq.shape: + raise ValueError( + f"Unexpected time_lag() result shape {time_lags.shape}" + ) + + if freq_range: + mask = (freq >= freq_range[0]) & (freq <= freq_range[1]) + freq = freq[mask] + time_lags = time_lags[mask] + if time_lags_err is not None: + time_lags_err = time_lags_err[mask] + + lag_units = {"freq": "Hz", "time_lags": "s"} + if time_lags_err is not None: + lag_units["time_lags_err"] = "s" + result_data = { + "name": output_name, + "freq": freq.tolist(), + "time_lags": _finite_list(time_lags), + "time_lags_err": ( + _finite_list(time_lags_err) if time_lags_err is not None else None + ), + "freq_range": freq_range, + "metadata": { + "units": lag_units, + "non_column_fields": ["freq_range"], + }, + "provenance": operation_provenance( + "timing_time_lags", + input_source={ + "kind": "event_list_pair", + "names": [event_list_1_name, event_list_2_name], + }, + parameters={ + "dt": dt, + "segment_size": segment_size, + "freq_range": freq_range, + "output_name": output_name, + }, + ), + } + + if output_name: + self.state.add_analysis_result(output_name, result_data) + + return self.create_result( + success=True, + data=result_data, + message="Time lags calculated", + ) + + except Exception as e: + return self.handle_error( + e, + "Calculating time lags", + event_list_1=event_list_1_name, + event_list_2=event_list_2_name, + dt=dt, + segment_size=segment_size, + ) + + def calculate_coherence( + self, + event_list_1_name: str, + event_list_2_name: str, + dt: float, + segment_size: float, + output_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Calculate coherence between two event lists. + + Args: + event_list_1_name: Name of first EventList + event_list_2_name: Name of second EventList + dt: Time binning in seconds + segment_size: Segment size in seconds + output_name: Optional name to save the result + + Returns: + Result dictionary with coherence data + """ + try: + if not self.state.has_event_data(event_list_1_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_1_name}' not found", + error=None, + ) + + if not self.state.has_event_data(event_list_2_name): + return self.create_result( + success=False, + data=None, + message=f"EventList '{event_list_2_name}' not found", + error=None, + ) + + seg_error = _segment_size_error(segment_size, dt) + if seg_error: + return self.create_result( + success=False, data=None, message=seg_error, error=None + ) + + from stingray import AveragedCrossspectrum + + event_list_1 = self.state.get_event_data(event_list_1_name) + event_list_2 = self.state.get_event_data(event_list_2_name) + + overlap_error = _overlap_error( + event_list_1, event_list_2, segment_size=segment_size + ) + if overlap_error: + return self.create_result( + success=False, data=None, message=overlap_error, error=None + ) + + lc1 = event_list_1.to_lc(dt=dt) + lc2 = event_list_2.to_lc(dt=dt) + + cs = AveragedCrossspectrum.from_lightcurve( + lc1=lc1, + lc2=lc2, + segment_size=segment_size, + norm="leahy", + ) + + # Stingray's coherence() returns (coherence, uncertainty) for + # averaged cross spectra (Vaughan & Nowak 1997). + coh_result = cs.coherence() + if isinstance(coh_result, tuple): + coherence_vals, coherence_err = coh_result + else: + coherence_vals, coherence_err = coh_result, None + + coherence_vals = np.real(np.asarray(coherence_vals)) + if coherence_vals.shape != np.asarray(cs.freq).shape: + raise ValueError( + f"Unexpected coherence() result shape {coherence_vals.shape}" + ) + + # Uncertainty formula goes negative where coh > 1; report magnitude as the half-width. + coherence_units = {"freq": "Hz", "coherence": "1"} + if coherence_err is not None: + coherence_units["coherence_err"] = "1" + result_data = { + "name": output_name, + "freq": cs.freq.tolist(), + "coherence": _finite_list(coherence_vals), + "coherence_err": ( + _finite_list(np.abs(np.real(np.asarray(coherence_err)))) + if coherence_err is not None + else None + ), + "segment_size": segment_size, + "n_segments": int(cs.m) if hasattr(cs, "m") else None, + "metadata": {"units": coherence_units}, + "provenance": operation_provenance( + "timing_coherence", + input_source={ + "kind": "event_list_pair", + "names": [event_list_1_name, event_list_2_name], + }, + parameters={ + "dt": dt, + "segment_size": segment_size, + "output_name": output_name, + }, + ), + } + + if output_name: + self.state.add_analysis_result(output_name, result_data) + + return self.create_result( + success=True, + data=result_data, + message="Coherence calculated", + ) + + except Exception as e: + return self.handle_error( + e, + "Calculating coherence", + event_list_1=event_list_1_name, + event_list_2=event_list_2_name, + dt=dt, + segment_size=segment_size, + ) diff --git a/python-backend/services/utility_helpers.py b/python-backend/services/utility_helpers.py new file mode 100644 index 0000000..6bcdf07 --- /dev/null +++ b/python-backend/services/utility_helpers.py @@ -0,0 +1,960 @@ +"""Shared validation, provenance, preview, and file-grant helpers for Utilities.""" + +from __future__ import annotations + +import hashlib +import hmac +import math +import os +import re +import stat +import time +from collections.abc import Generator, Iterable, Mapping, Sized +from contextlib import contextmanager +from dataclasses import dataclass +from decimal import Decimal +from itertools import islice +from pathlib import Path +from typing import Any, BinaryIO + +import numpy as np +import stingray + +MAX_ARRAY_INPUT = 100_000 +MAX_MATRIX_CELLS = 200_000 +MAX_GTI_ROWS = 10_000 +MAX_EXACT_OUTPUT = 100_000 +MAX_PLOT_POINTS = 5_000 +MAX_EXPORT_ROWS = 2_000_000 +MAX_STATE_SNAPSHOT_CELLS = 2_000_000 +MAX_STATE_SNAPSHOT_BYTES = 256 * 1024**2 +MAX_FITS_INSPECT_BYTES = 8 * 1024**3 +MAX_RMF_BYTES = 512 * 1024**2 + +DERIVED_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$") +FILE_GRANT_SECRET_ENV = "STINGRAY_FILE_GRANT_SECRET" +FILE_GRANT_VERSION = "v2" +FILE_GRANT_TTL_SECONDS = 10 * 60 +FILE_GRANT_MAX_FUTURE_SECONDS = 15 * 60 +MIN_FILE_GRANT_SECRET_BYTES = 32 +MAX_FILE_GRANT_TOKEN_CHARS = 512 +MAX_FILE_GRANT_EXPIRY_DIGITS = 20 +MAX_FILE_GRANT_IDENTITY_DIGITS = 40 +MAX_JSON_SAFE_WARNING_GROUPS = 32 +SECURE_DIR_FD_OPERATIONS_SUPPORTED = all( + operation in os.supports_dir_fd + for operation in (os.open, os.mkdir, os.stat, os.unlink, os.rmdir, os.link) +) + + +class FileGrantEligibilityError(ValueError): + """A selected path cannot satisfy the platform's secure-file boundary.""" + + +@dataclass(frozen=True) +class GrantedReadFile: + """An identity-verified file held open for the whole scientific read.""" + + path: Path + stream: BinaryIO + size_bytes: int + + +@dataclass(frozen=True) +class GrantedWriteDestination: + """A destination whose selected parent directory is held open and pinned.""" + + path: Path + parent_descriptor: int + filename: str + admitted_at: int + + +@dataclass(frozen=True) +class GrantedWindowsWriteDestination: + """A Windows destination whose reparse-free prefix handles stay pinned.""" + + path: Path + parent: Any + filename: str + admitted_at: int + + +@dataclass(frozen=True) +class IssuedFileGrant: + """One short-lived grant issued by the Python filesystem runtime.""" + + path: Path + grant: str + expires_at: int + + +@dataclass(frozen=True) +class _ParsedFileGrant: + expires_at: int + device: int + inode: int + digest: str + + +@dataclass(frozen=True) +class _ParsedWindowsFileGrant: + expires_at: int + volume_serial: int + file_id: bytes + digest: str + + +@contextmanager +def duplicate_binary_stream(stream: BinaryIO) -> Generator[BinaryIO, None, None]: + """Yield an independently closable descriptor for a pinned input file.""" + descriptor = os.dup(stream.fileno()) + try: + with os.fdopen(descriptor, "rb", closefd=True) as duplicate: + descriptor = -1 + duplicate.seek(0) + yield duplicate + finally: + if descriptor >= 0: + os.close(descriptor) + + +def validate_derived_name(name: str) -> str | None: + """Return an actionable error for an invalid derived-object name.""" + if name != name.strip(): + return "Destination name must not start or end with whitespace" + if not DERIVED_NAME_RE.fullmatch(name): + return ( + "Destination name must be 1-64 characters, start with a letter or " + "digit, and contain only letters, digits, spaces, '.', '_' or '-'" + ) + return None + + +def validate_finite_array( + values: Iterable[Any], + *, + label: str, + min_size: int = 1, + max_size: int = MAX_ARRAY_INPUT, +) -> tuple[np.ndarray | None, str | None]: + """Convert a one-dimensional numeric input after bounded finite checks.""" + if isinstance(values, (bool, np.bool_)): + return None, f"{label} must contain real numbers, not booleans" + if isinstance(values, (str, bytes, bytearray, memoryview)): + return None, f"{label} must be a one-dimensional array, not text" + if isinstance(values, Mapping): + return None, f"{label} must be a one-dimensional array, not a mapping" + + materialized: Any + if type(values) is np.ndarray: + if values.ndim != 1: + return None, f"{label} must be a one-dimensional array" + if values.size < min_size: + return None, f"{label} must contain at least {min_size} value(s)" + if values.size > max_size: + return None, ( + f"{label} contains {values.size:,} values; the cap is {max_size:,}" + ) + materialized = values + elif type(values) in (list, tuple): + value_count = len(values) + if value_count < min_size: + return None, f"{label} must contain at least {min_size} value(s)" + if value_count > max_size: + return None, ( + f"{label} contains {value_count:,} values; the cap is {max_size:,}" + ) + materialized = values + else: + # Array-like inputs often expose their dimensions without requiring a + # conversion. Use those hints before touching an iterator so an + # oversized request cannot force an unbounded temporary allocation. + shape = getattr(values, "shape", None) + if shape is not None: + try: + dimensions = tuple(shape) + except TypeError: + dimensions = () + if len(dimensions) != 1: + return None, f"{label} must be a one-dimensional array" + if ( + isinstance(dimensions[0], (int, np.integer)) + and int(dimensions[0]) > max_size + ): + return None, ( + f"{label} contains {int(dimensions[0]):,} values; the cap is " + f"{max_size:,}" + ) + + hinted_size = getattr(values, "size", None) + if isinstance(hinted_size, (int, np.integer)): + if hinted_size < min_size: + return None, f"{label} must contain at least {min_size} value(s)" + if hinted_size > max_size: + return None, ( + f"{label} contains {hinted_size:,} values; the cap is {max_size:,}" + ) + + if isinstance(values, Sized): + try: + hinted_length = len(values) + except (TypeError, ValueError, OverflowError): + hinted_length = None + if hinted_length is not None: + if hinted_length < min_size: + return None, f"{label} must contain at least {min_size} value(s)" + if hinted_length > max_size: + return None, ( + f"{label} contains {hinted_length:,} values; the cap is " + f"{max_size:,}" + ) + + try: + materialized = list(islice(iter(values), max_size + 1)) + except (TypeError, ValueError) as exc: + return None, f"{label} must contain only numeric values ({exc})" + if len(materialized) > max_size: + return None, ( + f"{label} contains at least {len(materialized):,} values; the cap is " + f"{max_size:,}" + ) + + if isinstance(materialized, (list, tuple)): + if any(isinstance(item, (bool, np.bool_)) for item in materialized): + return None, f"{label} must contain real numbers, not booleans" + if any( + isinstance(item, (complex, np.complexfloating)) for item in materialized + ): + return None, f"{label} must contain real numbers, not complex values" + if any( + isinstance(item, (str, bytes, bytearray, memoryview)) + for item in materialized + ): + return None, f"{label} must contain real numbers, not text values" + if any(isinstance(item, Mapping) for item in materialized): + return None, f"{label} must contain real numbers, not mappings" + # Nested iterables are already known not to satisfy the 1-D contract. + # Reject them before NumPy can duplicate all of their cells in an + # object array merely to discover the extra dimension. + if any( + isinstance(item, Iterable) + and not isinstance(item, (str, bytes, bytearray, memoryview)) + for item in materialized + ): + return None, f"{label} must be a one-dimensional array" + + try: + object_array = np.asarray(materialized, dtype=object) + if any(isinstance(item, (bool, np.bool_)) for item in object_array.flat): + return None, f"{label} must contain real numbers, not booleans" + if any( + isinstance(item, (complex, np.complexfloating)) + for item in object_array.flat + ): + return None, f"{label} must contain real numbers, not complex values" + if any( + isinstance(item, (str, bytes, bytearray, memoryview)) + for item in object_array.flat + ): + return None, f"{label} must contain real numbers, not text values" + if any(isinstance(item, Mapping) for item in object_array.flat): + return None, f"{label} must contain real numbers, not mappings" + array = np.asarray(materialized, dtype=float) + except (TypeError, ValueError) as exc: + return None, f"{label} must contain only numeric values ({exc})" + if array.ndim != 1: + return None, f"{label} must be a one-dimensional array" + if array.size < min_size: + return None, f"{label} must contain at least {min_size} value(s)" + if array.size > max_size: + return None, f"{label} contains {array.size:,} values; the cap is {max_size:,}" + bad = np.flatnonzero(~np.isfinite(array)) + if bad.size: + index = int(bad[0]) + return None, f"{label}[{index}] must be finite" + return array, None + + +def finite_or_none(value: Any, warnings: list[str], label: str) -> float | None: + """Convert a numeric scalar to JSON-safe float, warning on non-finite output.""" + try: + result = float(np.asarray(value).reshape(())) + except (TypeError, ValueError): + warnings.append(f"{label} could not be represented as a scalar and was omitted") + return None + if math.isfinite(result): + return result + warnings.append(f"{label} is non-finite and is represented as null") + return None + + +class _JsonWarningCollector: + """Aggregate repeated conversion warnings without retaining one per value.""" + + def __init__(self) -> None: + self.issues: dict[tuple[str, str], tuple[int, str]] = {} + self.omitted_values = 0 + + def add(self, kind: str, group: str, representative: str) -> None: + key = (kind, group) + if key in self.issues: + count, first = self.issues[key] + self.issues[key] = (count + 1, first) + elif len(self.issues) < MAX_JSON_SAFE_WARNING_GROUPS: + self.issues[key] = (1, representative) + else: + self.omitted_values += 1 + + def flush(self, warnings: list[str]) -> None: + for (kind, group), (count, representative) in self.issues.items(): + if kind == "nonfinite": + warning = ( + f"{representative} is non-finite and is represented as null" + if count == 1 + else f"{group} contains {count:,} non-finite values represented as " + f"null; first at {representative}" + ) + else: + warning = ( + f"{representative} is complex and cannot be represented in this " + "real-valued result" + if count == 1 + else f"{group} contains {count:,} complex values that cannot be " + f"represented in this real-valued result; first at {representative}" + ) + if warning not in warnings: + warnings.append(warning) + if self.omitted_values: + warnings.append( + f"{self.omitted_values:,} additional JSON-sanitizing issue(s) were " + "omitted from warnings" + ) + + +def _json_safe( + value: Any, + collector: _JsonWarningCollector, + label: str, + sequence_group: str | None = None, +) -> Any: + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, Decimal): + if value.is_finite(): + converted = float(value) + if math.isfinite(converted): + return converted + collector.add("nonfinite", sequence_group or label, label) + return None + if isinstance(value, dict): + return { + str(key): _json_safe( + item, + collector, + f"{label}.{key}", + sequence_group, + ) + for key, item in value.items() + } + if isinstance(value, np.ndarray) and value.ndim == 0: + return _json_safe(value.item(), collector, label, sequence_group) + if isinstance(value, (list, tuple, np.ndarray)): + group = sequence_group or label + return [ + _json_safe(item, collector, f"{label}[{index}]", group) + for index, item in enumerate(value) + ] + if isinstance(value, (complex, np.complexfloating)): + collector.add("complex", sequence_group or label, label) + return None + if isinstance(value, (float, np.floating, np.integer)): + result = float(value) + if math.isfinite(result): + return int(value) if isinstance(value, np.integer) else result + collector.add("nonfinite", sequence_group or label, label) + return None + if hasattr(value, "tolist"): + return _json_safe(value.tolist(), collector, label, sequence_group) + return str(value) + + +def json_safe(value: Any, warnings: list[str], label: str = "result") -> Any: + """Recursively convert scientific values to strict JSON with bounded warnings.""" + collector = _JsonWarningCollector() + converted = _json_safe(value, collector, label) + collector.flush(warnings) + return converted + + +def bounded_plot_preview( + *arrays: Iterable[Any], max_points: int = MAX_PLOT_POINTS +) -> dict[str, Any]: + """Return aligned decimated plot arrays without changing exact result arrays.""" + if max_points < 1: + raise ValueError("max_points must be a positive integer") + converted = [np.asarray(array) for array in arrays] + if not converted: + return {"arrays": [], "stride": 1, "source_points": 0} + size = len(converted[0]) + if any(len(array) != size for array in converted): + raise ValueError("Plot preview arrays must have the same length") + stride = max(1, math.ceil(size / max_points)) + return { + "arrays": [array[::stride].tolist() for array in converted], + "stride": stride, + "source_points": size, + } + + +def operation_provenance( + operation: str, + *, + input_source: Any, + parameters: dict[str, Any], + **extra: Any, +) -> dict[str, Any]: + """Create the common provenance block returned by every Utility operation.""" + return { + "operation": operation, + "input_source": input_source, + "parameters": parameters, + "stingray_version": stingray.__version__, + **extra, + } + + +def _canonical_path(file_path: str, *, must_exist: bool) -> Path: + if not isinstance(file_path, str) or not file_path or "\x00" in file_path: + raise ValueError( + "A non-empty file path selected through the native dialog is required" + ) + if os.name == "nt": + from .windows_secure_fs import canonicalize_windows_path + + # Existence and type are checked through retained, reparse-safe native + # handles by the issuer/verifier. Do not call Path.resolve() here: on + # Windows it would follow the very reparse points we must reject. + return canonicalize_windows_path(file_path) + + candidate = Path(file_path).expanduser() + if must_exist: + resolved = candidate.resolve(strict=True) + if not resolved.is_file(): + raise ValueError("The selected input path is not a regular file") + return resolved + + if candidate.name in {"", ".", ".."}: + raise ValueError("The selected destination filename is invalid") + parent = candidate.parent.resolve(strict=True) + if not parent.is_dir(): + raise ValueError("The selected destination directory does not exist") + return parent / candidate.name + + +def validated_file_grant_secret(secret: str | None) -> bytes | None: + """Return a sufficiently strong UTF-8 grant secret, or ``None``.""" + if not isinstance(secret, str): + return None + encoded = secret.encode("utf-8") + return encoded if len(encoded) >= MIN_FILE_GRANT_SECRET_BYTES else None + + +def _configured_file_grant_secret(secret: str | None = None) -> bytes: + configured = secret if secret is not None else os.environ.get(FILE_GRANT_SECRET_ENV) + encoded = validated_file_grant_secret(configured) + if encoded is None: + raise PermissionError( + "Native file grants are unavailable because the backend was not " + "launched by Electron with a strong per-launch secret" + ) + return encoded + + +def _parse_file_grant(grant: str) -> _ParsedFileGrant | _ParsedWindowsFileGrant: + from .windows_secure_fs import WINDOWS_FILE_GRANT_VERSION + + try: + if not isinstance(grant, str) or len(grant) > MAX_FILE_GRANT_TOKEN_CHARS: + raise ValueError + parts = grant.split(".") + if len(parts) != 5: + raise ValueError + version, expires_text, first_identity, second_identity, digest = parts + if version not in {FILE_GRANT_VERSION, WINDOWS_FILE_GRANT_VERSION}: + raise ValueError + if version == WINDOWS_FILE_GRANT_VERSION: + if ( + not expires_text + or len(expires_text) > MAX_FILE_GRANT_EXPIRY_DIGITS + or any(character not in "0123456789" for character in expires_text) + or len(first_identity) != 16 + or any( + character not in "0123456789abcdef" for character in first_identity + ) + or len(second_identity) != 32 + or any( + character not in "0123456789abcdef" for character in second_identity + ) + ): + raise ValueError + if len(digest) != hashlib.sha256().digest_size * 2 or any( + character not in "0123456789abcdef" for character in digest + ): + raise ValueError + return _ParsedWindowsFileGrant( + expires_at=int(expires_text), + volume_serial=int(first_identity, 16), + file_id=bytes.fromhex(second_identity), + digest=digest, + ) + + device_text = first_identity + inode_text = second_identity + numeric_fields = ( + (expires_text, MAX_FILE_GRANT_EXPIRY_DIGITS), + (device_text, MAX_FILE_GRANT_IDENTITY_DIGITS), + (inode_text, MAX_FILE_GRANT_IDENTITY_DIGITS), + ) + if any( + not value + or len(value) > maximum + or any(character not in "0123456789" for character in value) + for value, maximum in numeric_fields + ): + raise ValueError + if len(digest) != hashlib.sha256().digest_size * 2: + raise ValueError + # Enforce the canonical lowercase representation emitted by the issuer. + if any(character not in "0123456789abcdef" for character in digest): + raise ValueError + return _ParsedFileGrant( + expires_at=int(expires_text), + device=int(device_text), + inode=int(inode_text), + digest=digest, + ) + except (AttributeError, TypeError, ValueError, IndexError) as exc: + raise PermissionError("The native file selection grant is malformed") from exc + + +def _file_grant_message( + *, + access: str, + expires_at: int, + path: Path, + device: int, + inode: int, +) -> bytes: + return ( + f"{FILE_GRANT_VERSION}\0{access}\0{expires_at}\0{path}\0{device}\0{inode}" + ).encode() + + +def _windows_file_grant_message( + *, + access: str, + expires_at: int, + path: Path, + volume_serial: int, + file_id: bytes, +) -> bytes: + from .windows_secure_fs import WINDOWS_FILE_GRANT_VERSION + + return ( + f"{WINDOWS_FILE_GRANT_VERSION}\0{access}\0{expires_at}\0{path}\0" + f"{volume_serial:016x}\0{file_id.hex()}" + ).encode() + + +def issue_file_grant( + file_path: str, + *, + access: str, + secret: str | None = None, +) -> IssuedFileGrant: + """Issue a platform-versioned grant using Python's native file identity.""" + if access not in {"read", "write"}: + raise ValueError("Invalid file-grant access mode") + secret_bytes = _configured_file_grant_secret(secret) + try: + resolved = _canonical_path(file_path, must_exist=access == "read") + except ValueError as exc: + if os.name == "nt": + raise FileGrantEligibilityError(str(exc)) from exc + raise + if os.name == "nt": + from .windows_secure_fs import WINDOWS_FILE_GRANT_VERSION, pin_windows_path + + identity_path = resolved if access == "read" else resolved.parent + try: + with pin_windows_path( + identity_path, + directory=access == "write", + ) as pinned: + identity = pinned.identity + except PermissionError as exc: + raise FileGrantEligibilityError(str(exc)) from exc + expires_at = int(time.time()) + FILE_GRANT_TTL_SECONDS + message = _windows_file_grant_message( + access=access, + expires_at=expires_at, + path=resolved, + volume_serial=identity.volume_serial, + file_id=identity.file_id, + ) + digest = hmac.new(secret_bytes, message, hashlib.sha256).hexdigest() + grant = ( + f"{WINDOWS_FILE_GRANT_VERSION}.{expires_at}." + f"{identity.volume_hex}.{identity.file_id_hex}.{digest}" + ) + return IssuedFileGrant(path=resolved, grant=grant, expires_at=expires_at) + + identity_path = resolved if access == "read" else resolved.parent + identity = identity_path.stat() + if access == "read" and not stat.S_ISREG(identity.st_mode): + raise ValueError("The selected input path is not a regular file") + if access == "write" and not stat.S_ISDIR(identity.st_mode): + raise ValueError("The selected destination directory does not exist") + + expires_at = int(time.time()) + FILE_GRANT_TTL_SECONDS + message = _file_grant_message( + access=access, + expires_at=expires_at, + path=resolved, + device=identity.st_dev, + inode=identity.st_ino, + ) + digest = hmac.new(secret_bytes, message, hashlib.sha256).hexdigest() + grant = ( + f"{FILE_GRANT_VERSION}.{expires_at}.{identity.st_dev}." + f"{identity.st_ino}.{digest}" + ) + return IssuedFileGrant(path=resolved, grant=grant, expires_at=expires_at) + + +def _verify_file_grant_at_time( + file_path: str, + grant: str, + *, + access: str, + must_exist: bool, + validation_time: int, +) -> Path: + if access not in {"read", "write"}: + raise ValueError("Invalid file-grant access mode") + secret = _configured_file_grant_secret() + parsed = _parse_file_grant(grant) + + if parsed.expires_at < validation_time: + raise PermissionError( + "The native file selection grant has expired; select the file again" + ) + if parsed.expires_at > validation_time + FILE_GRANT_MAX_FUTURE_SECONDS: + raise PermissionError("The native file selection grant expiry is invalid") + + resolved = _canonical_path(file_path, must_exist=must_exist) + if os.name == "nt": + from .windows_secure_fs import pin_windows_path + + if not isinstance(parsed, _ParsedWindowsFileGrant): + raise PermissionError( + "This native file grant is not valid for the Windows filesystem" + ) + message = _windows_file_grant_message( + access=access, + expires_at=parsed.expires_at, + path=resolved, + volume_serial=parsed.volume_serial, + file_id=parsed.file_id, + ) + expected = hmac.new(secret, message, hashlib.sha256).hexdigest() + if not hmac.compare_digest(parsed.digest, expected): + raise PermissionError( + "The path does not match the native file selection grant" + ) + identity_path = resolved if access == "read" else resolved.parent + with pin_windows_path( + identity_path, + directory=access == "write", + ) as pinned: + if ( + pinned.identity.volume_serial != parsed.volume_serial + or pinned.identity.file_id != parsed.file_id + ): + if access == "read": + raise PermissionError( + "The selected input file identity changed; select the file again" + ) + raise PermissionError( + "The selected destination directory identity changed; choose it again" + ) + return resolved + + if not isinstance(parsed, _ParsedFileGrant): + raise PermissionError( + "This native file grant is not valid for the POSIX filesystem" + ) + message = _file_grant_message( + access=access, + expires_at=parsed.expires_at, + path=resolved, + device=parsed.device, + inode=parsed.inode, + ) + expected = hmac.new(secret, message, hashlib.sha256).hexdigest() + if not hmac.compare_digest(parsed.digest, expected): + raise PermissionError("The path does not match the native file selection grant") + identity_path = resolved if access == "read" else resolved.parent + current = identity_path.stat() + if access == "read": + if not stat.S_ISREG(current.st_mode): + raise PermissionError("The selected input is no longer a regular file") + changed_message = ( + "The selected input file identity changed; select the file again" + ) + else: + if not stat.S_ISDIR(current.st_mode): + raise PermissionError("The selected destination directory is unavailable") + changed_message = ( + "The selected destination directory identity changed; choose it again" + ) + if (current.st_dev, current.st_ino) != (parsed.device, parsed.inode): + raise PermissionError(changed_message) + return resolved + + +def verify_file_grant( + file_path: str, + grant: str, + *, + access: str, + must_exist: bool, +) -> Path: + """Verify a Python-issued HMAC grant for exactly one selected path. + + The renderer receives the selected absolute path and a short-lived token, + but never receives the issuer/signing secret shared by Electron main and + FastAPI. It therefore cannot substitute an adjacent or manually typed path. + """ + return _verify_file_grant_at_time( + file_path, + grant, + access=access, + must_exist=must_exist, + validation_time=int(time.time()), + ) + + +def revalidate_admitted_file_grant( + file_path: str, + grant: str, + *, + access: str, + must_exist: bool, + admitted_at: int, +) -> Path: + """Revalidate an already-admitted grant at its immutable admission time. + + This is an internal capability operation: no request model accepts + ``admitted_at``. Long-running writes therefore retain their admission, + while signature, canonical path, future-window, and filesystem identity + checks are still recomputed before verification/publication. + """ + if isinstance(admitted_at, bool) or not isinstance(admitted_at, int): + raise RuntimeError("The native file grant admission time is invalid") + return _verify_file_grant_at_time( + file_path, + grant, + access=access, + must_exist=must_exist, + validation_time=admitted_at, + ) + + +@contextmanager +def open_verified_read_grant( + file_path: str, + grant: str, +) -> Generator[GrantedReadFile, None, None]: + """Open one granted file and pin all reads to its verified native identity. + + POSIX uses device/inode identity; Windows uses the volume serial and + 128-bit ``FILE_ID_INFO`` identity while retaining a reparse-free handle for + every path prefix. In both cases, replacing the pathname after validation + cannot redirect Astropy or Stingray to a different file. + """ + path = verify_file_grant( + file_path, + grant, + access="read", + must_exist=True, + ) + if os.name == "nt": + from .windows_secure_fs import pin_windows_path + + parsed = _parse_file_grant(grant) + if not isinstance(parsed, _ParsedWindowsFileGrant): + raise PermissionError("A Windows FILE_ID_INFO grant is required") + with pin_windows_path(path, directory=False) as pinned: + if ( + pinned.identity.volume_serial != parsed.volume_serial + or pinned.identity.file_id != parsed.file_id + ): + raise PermissionError( + "The selected input file identity changed; select the file again" + ) + descriptor = pinned.api.duplicate_to_fd(pinned.handle, writable=False) + try: + with os.fdopen(descriptor, "rb", closefd=True) as stream: + descriptor = -1 + yield GrantedReadFile( + path=path, + stream=stream, + size_bytes=pinned.api.size(pinned.handle), + ) + finally: + if descriptor >= 0: + os.close(descriptor) + return + + flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags) + try: + opened = os.fstat(descriptor) + # Re-parse only the authenticated identity fields. verify_file_grant + # already validated their shape, expiry, and signature. + parsed = _parse_file_grant(grant) + granted_identity = (parsed.device, parsed.inode) + if not stat.S_ISREG(opened.st_mode): + raise PermissionError("The selected input is no longer a regular file") + if (opened.st_dev, opened.st_ino) != granted_identity: + raise PermissionError( + "The selected input file identity changed; select the file again" + ) + with os.fdopen(descriptor, "rb", closefd=True) as stream: + descriptor = -1 + yield GrantedReadFile( + path=path, + stream=stream, + size_bytes=opened.st_size, + ) + finally: + if descriptor >= 0: + os.close(descriptor) + + +@contextmanager +def open_verified_write_grant( + file_path: str, + grant: str, +) -> Generator[ + GrantedWriteDestination | GrantedWindowsWriteDestination, + None, + None, +]: + """Pin the identity-verified destination directory for an export. + + Export mutations use a retained POSIX directory descriptor or the complete + reparse-free Windows prefix-handle chain, followed only by relative native + operations. This prevents an ancestor rename/replacement after validation + from redirecting writes or cleanup into a different directory. + """ + admitted_at = int(time.time()) + if os.name == "nt": + from .windows_secure_fs import pin_windows_path + + path = _verify_file_grant_at_time( + file_path, + grant, + access="write", + must_exist=False, + validation_time=admitted_at, + ) + parsed = _parse_file_grant(grant) + if not isinstance(parsed, _ParsedWindowsFileGrant): + raise PermissionError("A Windows FILE_ID_INFO grant is required") + with pin_windows_path( + path.parent, + directory=True, + writable_directory=True, + ) as parent: + if ( + parent.identity.volume_serial != parsed.volume_serial + or parent.identity.file_id != parsed.file_id + ): + raise PermissionError( + "The selected destination directory identity changed; choose it again" + ) + yield GrantedWindowsWriteDestination( + path=path, + parent=parent, + filename=path.name, + admitted_at=admitted_at, + ) + return + + if not SECURE_DIR_FD_OPERATIONS_SUPPORTED: + raise PermissionError( + "Secure native exports are unavailable on this platform because " + "directory-relative file operations are unsupported" + ) + path = _verify_file_grant_at_time( + file_path, + grant, + access="write", + must_exist=False, + validation_time=admitted_at, + ) + flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path.parent, flags) + try: + opened = os.fstat(descriptor) + parsed = _parse_file_grant(grant) + granted_identity = (parsed.device, parsed.inode) + if not stat.S_ISDIR(opened.st_mode): + raise PermissionError("The selected destination directory is unavailable") + if (opened.st_dev, opened.st_ino) != granted_identity: + raise PermissionError( + "The selected destination directory identity changed; choose it again" + ) + yield GrantedWriteDestination( + path=path, + parent_descriptor=descriptor, + filename=path.name, + admitted_at=admitted_at, + ) + finally: + os.close(descriptor) + + +def assert_new_destination(path: Path) -> None: + """Reject existing destinations; Utility exports never overwrite silently.""" + if path.exists() or path.is_symlink(): + raise FileExistsError(f"Destination already exists: {path}") + + +def validate_file_size(path: Path | BinaryIO, maximum: int, label: str) -> int: + """Return selected file size after an explicit cap check.""" + size = ( + os.fstat(path.fileno()).st_size + if hasattr(path, "fileno") + else path.stat().st_size + ) + if size > maximum: + raise ValueError( + f"{label} is {size / 1024**2:.1f} MiB; the supported cap is {maximum / 1024**2:.1f} MiB" + ) + return size diff --git a/python-backend/services/varenergy_service.py b/python-backend/services/varenergy_service.py new file mode 100644 index 0000000..6e2e8d8 --- /dev/null +++ b/python-backend/services/varenergy_service.py @@ -0,0 +1,1018 @@ +""" +Var-energy service for energy-dependent variability spectra. + +Implemented per docs/superpowers/plans/2026-07-29-quicklook-remaining-pages.md. + +Covers rms, lag, excess-variance, a combined counts/rms/lag overview and the +covariance spectrum (unsegmented and segment-averaged), all built on +``stingray.varenergyspectrum``. + +Several stingray 2.2.10 quirks are worked around here and documented at their +call sites: ``ExcessVarianceSpectrum`` discards its own results and counts +inter-GTI gaps as real zero-count bins (see +``_GtiAwareExcessVarianceSpectrum``), the FFT length and the frequency mask are +derived from different roundings of ``segment_size / bin_time`` (see +``_fit_segment_to_bins``), and the legacy ``stingray.covariancespectrum`` +module corrupts its light curves (see ``covariance_spectrum``). +""" + +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +from stingray.gti import create_gti_mask +from stingray.utils import excess_variance +from stingray.varenergyspectrum import ( + CountSpectrum, + CovarianceSpectrum, + ExcessVarianceSpectrum, + LagSpectrum, + RmsSpectrum, +) + +from .analysis_helpers import collect_warnings, finite_list, segment_size_error +from .base_service import BaseService + +VALID_NORMS = ("frac", "abs") +VALID_EXCESS_VARIANCE_NORMALIZATIONS = ("fvar", "none") + +# stingray's own tolerance in gti.time_intervals_from_gtis. +_GTI_EPSILON = 1e-5 + +# The all-NaN advisory has to be per spectrum kind: rms/lag/covariance are +# computed against a reference band over segments of segment_size, but +# ExcessVarianceSpectrum builds no reference band at all (its +# _spectrum_function passes only_base=True) and neither the endpoint nor the +# page exposes a segment_size, so the shared wording named three controls that +# do not exist there. +NAN_ADVICE = { + "default": ( + "the {kind} spectrum could not be computed for any energy band " + "(stingray returns NaN when the reference band shows no variability " + "above the Poisson noise floor). Try a longer segment_size, a coarser " + "bin_time, fewer energy bands, or a source with real variability." + ), + "excess variance": ( + "the excess variance spectrum could not be computed for any energy " + "band (stingray returns NaN when a band's measured variance sits at " + "or below its own Poisson noise level, so the excess variance comes " + "out negative). Try a coarser bin_time or fewer energy bands to put " + "more counts in each light-curve bin, or a source with real " + "variability." + ), +} + + +def _nan_advice(kind: str) -> str: + return NAN_ADVICE.get(kind, NAN_ADVICE["default"]).format(kind=kind) + + +# numpy's floating-point warnings surface verbatim through catch_warnings and +# read as gibberish in the UI ("invalid value encountered in sqrt"), so wrap +# them in a sentence that says what it means for the plot. +_NUMPY_WARNING_PREFIXES = ( + "invalid value encountered", + "divide by zero encountered", + "overflow encountered", + "underflow encountered", +) + + +def _humanize_warnings(messages: List[str]) -> List[str]: + """Wrap bare numpy float warnings in something a user can act on.""" + readable: List[str] = [] + for text in messages: + if text.startswith(_NUMPY_WARNING_PREFIXES): + text = ( + "undefined maths while computing this spectrum " + f"(numpy: {text}); any affected energy bands are returned as null" + ) + if text not in readable: + readable.append(text) + return readable + + +def _excess_variance_in_gtis(lightcurve, normalization: str) -> Tuple[float, float]: + """``stingray.utils.excess_variance`` over the in-GTI bins only. + + ``VarEnergySpectrum._construct_lightcurves`` builds ONE light curve running + from ``gti[0, 0]`` to ``gti[-1, -1]`` (``Lightcurve.make_lightcurve`` with + ``tseg=tstop - tstart``) and never masks the inter-GTI gaps, so every bin + that falls in a slew/occultation/SAA gap is a genuine 0-count bin in + ``lc.counts``. ``excess_variance`` then takes ``np.var(lc.counts)`` over + that raw array, so the gaps - not the source - set the variance: a constant + Poisson source observed in two 100 s GTIs 800 s apart reports + ``F_var ~ 2.0 +/- 0.011`` instead of a value consistent with zero. + + Masking is the correct fix rather than averaging per-GTI results: F_var + (Vaughan et al. 2003) is a sample variance over the *observed* bins, so the + estimator only wants the bins that were actually exposed, and pooling them + keeps a single well-determined mean count rate. ``create_gti_mask`` is the + same helper ``Lightcurve.apply_gtis`` uses; besides the gaps it also drops + the partially-exposed bins straddling a GTI edge, which would otherwise + read as low outliers. + """ + gti = getattr(lightcurve, "gti", None) + if gti is None or len(gti) == 0 or len(lightcurve.time) == 0: + return excess_variance(lightcurve, normalization) + + inside = create_gti_mask(lightcurve.time, gti, dt=lightcurve.dt) + if inside.all(): + return excess_variance(lightcurve, normalization) + if np.count_nonzero(inside) < 2: + # A variance over fewer than two exposed bins is meaningless; NaN lets + # finite_list()/_add_nan_advice explain it like any other empty band. + return float("nan"), float("nan") + return excess_variance(lightcurve.apply_mask(inside, inplace=False), normalization) + + +class _GtiAwareExcessVarianceSpectrum(ExcessVarianceSpectrum): + """``ExcessVarianceSpectrum`` that skips gap bins and computes only once. + + Two stingray 2.2.10 problems are fixed by this single override: + + * gap contamination - see ``_excess_variance_in_gtis``; + * duplicated work - ``VarEnergySpectrum.__init__`` calls + ``self._spectrum_function()`` and throws the return value away. Every + sibling class assigns ``self.spectrum[i]`` in place, but + ``ExcessVarianceSpectrum`` returns ``(spec, spec_err)`` instead, so the + constructed object's ``.spectrum`` stays at its all-NaN initial value and + the service used to recover the numbers by running the whole + one-light-curve-per-energy-band computation a second time. Storing the + arrays here makes the constructor's own call the only one: identical + numbers, half the light curves and half the peak allocation. + """ + + def _spectrum_function(self): + spectrum = np.zeros(len(self.energy_intervals), dtype=float) + spectrum_error = np.zeros_like(spectrum) + for index, energy_interval in enumerate(self.energy_intervals): + lightcurve = self._construct_lightcurves( + energy_interval, exclude=False, only_base=True + ) + spectrum[index], spectrum_error[index] = _excess_variance_in_gtis( + lightcurve, self.normalization + ) + self.spectrum = spectrum + self.spectrum_error = spectrum_error + return spectrum, spectrum_error + + +def _longest_gti(event_list) -> Optional[float]: + """Duration of the longest single good-time interval, in seconds. + + stingray asserts ``No GTIs are equal to or longer than segment_size``, so + the longest *single* interval (not the summed exposure) is the limit. + """ + gti = getattr(event_list, "gti", None) + if gti is not None and len(gti) > 0: + spans = np.asarray(gti, dtype=float) + return float(np.max(spans[:, 1] - spans[:, 0])) + times = getattr(event_list, "time", None) + if times is None or len(times) == 0: + return None + return float(times[-1] - times[0]) + + +def _n_segments_hint(event_list, segment_size: float) -> int: + """How many whole segments fit in the GTIs (stingray's own m may differ).""" + gti = getattr(event_list, "gti", None) + if gti is None or len(gti) == 0: + span = _longest_gti(event_list) or 0.0 + return int(span // segment_size) + spans = np.asarray(gti, dtype=float) + return int(np.sum(np.floor((spans[:, 1] - spans[:, 0]) / segment_size))) + + +def _gti_usage(event_list, segment_size: float) -> Dict[str, Any]: + """How much of the exposure a given ``segment_size`` actually reaches. + + ``gti.time_intervals_from_gtis`` skips every good-time interval shorter + than ``segment_size`` outright (``if g[1] - g[0] + epsilon < segment_size: + continue``) and only uses whole segments inside the ones it keeps, without + warning. A multi-orbit observation segmented at the length of its longest + GTI therefore contributes a small fraction of its exposure. + """ + gti = getattr(event_list, "gti", None) + if gti is None or len(gti) == 0 or segment_size <= 0: + return { + "n_gtis_total": 0, + "n_gtis_used": 0, + "exposure_total": 0.0, + "exposure_used": 0.0, + } + spans = np.asarray(gti, dtype=float) + durations = spans[:, 1] - spans[:, 0] + kept = durations + _GTI_EPSILON >= segment_size + segments = np.floor((durations[kept] + _GTI_EPSILON) / segment_size) + return { + "n_gtis_total": int(durations.size), + "n_gtis_used": int(np.count_nonzero(kept)), + "exposure_total": float(np.sum(durations)), + "exposure_used": float(np.sum(segments) * segment_size), + } + + +def _gti_usage_warning( + usage: Dict[str, Any], segment_size: float, derived_from_longest_gti: bool +) -> Optional[str]: + """Say out loud which whole good-time intervals a ``segment_size`` drops. + + Only whole dropped GTIs are reported; the sub-segment remainder at the end + of a kept GTI is normal segmenting and would be noise in the UI (the exact + numbers are in ``exposure_used``/``exposure_total`` either way). + """ + total = usage["exposure_total"] + used = usage["exposure_used"] + dropped_gtis = usage["n_gtis_total"] - usage["n_gtis_used"] + if total <= 0 or dropped_gtis <= 0: + return None + fraction = used / total + if derived_from_longest_gti: + lead = ( + f"this single segment is the longest good-time interval " + f"({segment_size:g}s), so the {dropped_gtis} shorter " + f"{'GTI was' if dropped_gtis == 1 else 'GTIs were'} skipped " + "entirely" + ) + else: + lead = ( + f"segment_size ({segment_size:g}s) is longer than {dropped_gtis} " + f"of the {usage['n_gtis_total']} good-time intervals, which " + "stingray skips entirely" + ) + return ( + f"{lead}: {used:g}s of the {total:g}s of exposure " + f"({fraction:.0%}) contributed to this spectrum, not the whole " + "observation" + ) + + +class VarEnergyService(BaseService): + """Service for rms/lag/excess-variance/covariance energy spectra.""" + + # ------------------------------------------------------------------ + # shared validation + # ------------------------------------------------------------------ + + def _resolve_event_list(self, event_list_name: str): + """Return (event_list, error_message). Exactly one is not None.""" + if not self.state.has_event_data(event_list_name): + return None, f"EventList '{event_list_name}' not found" + event_list = self.state.get_event_data(event_list_name) + if event_list.time is None or len(event_list.time) == 0: + return None, f"EventList '{event_list_name}' contains no events" + if getattr(event_list, "energy", None) is None: + return None, ( + f"EventList '{event_list_name}' has no energy column; " + "energy-resolved spectra need per-event energies" + ) + return event_list, None + + def _energy_spec_error( + self, energy_min: float, energy_max: float, n_bands: int, log_bands: bool + ) -> Optional[str]: + if energy_min >= energy_max: + return ( + f"energy_min ({energy_min} keV) must be below " + f"energy_max ({energy_max} keV)" + ) + if n_bands < 2: + return f"n_bands ({n_bands}) must be at least 2 to make a spectrum" + if log_bands and energy_min <= 0: + return ( + f"energy_min ({energy_min} keV) must be above zero for " + "log-spaced energy bands" + ) + return None + + def _freq_interval_error( + self, freq_min: float, freq_max: float, bin_time: float + ) -> Optional[str]: + if bin_time <= 0: + return f"bin_time ({bin_time}s) must be positive" + if freq_min < 0: + return f"freq_min ({freq_min} Hz) must not be negative" + if freq_min >= freq_max: + return f"freq_min ({freq_min} Hz) must be below freq_max ({freq_max} Hz)" + nyquist = 1.0 / (2.0 * bin_time) + if freq_max > nyquist: + return ( + f"freq_max ({freq_max} Hz) is above the Nyquist frequency " + f"({nyquist:g} Hz) for bin_time {bin_time}s; lower freq_max or " + "use a smaller bin_time" + ) + return None + + def _segment_error( + self, event_list, segment_size: float, bin_time: float + ) -> Optional[str]: + if segment_size <= 0: + return f"segment_size ({segment_size}s) must be positive" + error = segment_size_error(segment_size, bin_time) + if error: + return error + span = _longest_gti(event_list) + if span is not None and segment_size > span: + return ( + f"segment_size ({segment_size}s) is longer than the longest " + f"good-time interval ({span:g}s); use a smaller segment_size" + ) + return None + + def _norm_error(self, norm: str) -> Optional[str]: + if norm not in VALID_NORMS: + return ( + f"norm '{norm}' is not supported; use one of " + f"{', '.join(VALID_NORMS)}" + ) + return None + + def _ref_band( + self, ref_min: Optional[float], ref_max: Optional[float] + ) -> Tuple[Optional[List[float]], Optional[str]]: + """Validate the optional reference band. Returns (band, error).""" + if ref_min is None and ref_max is None: + return None, None + if ref_min is None or ref_max is None: + return None, ( + "ref_min and ref_max must be given together (or both left " + "empty to use the full band as reference)" + ) + if ref_min >= ref_max: + return None, ( + f"ref_min ({ref_min} keV) must be below ref_max ({ref_max} keV)" + ) + return [float(ref_min), float(ref_max)], None + + def _ref_band_events_error(self, event_list, ref_band) -> Optional[str]: + """Reject a reference band that holds no events. + + stingray computes the reference PDS once, up front: + ``LagSpectrum._spectrum_function`` and + ``ComplexCovarianceSpectrum._spectrum_function`` call + ``avg_pds_from_timeseries(ref_events, ...)`` and immediately read + ``results.meta["m"]``/``results["power"]``. Unlike the per-subject-band + loop a few lines below (which guards with ``if results_cross is None or + results_ps is None: continue``) that dereference is unguarded, and + ``avg_pds_from_timeseries`` returns ``None`` when the band is empty, so + an out-of-range reference band surfaces as ``'NoneType' object has no + attribute 'meta'`` / ``'NoneType' object is not subscriptable``. + """ + if ref_band is None: + return None + energies = getattr(event_list, "energy", None) + if energies is None: + return None + values = np.asarray(energies, dtype=float) + if values.size == 0: + return None + in_band = (values >= ref_band[0]) & (values < ref_band[1]) + if np.any(in_band): + return None + return ( + f"the reference band ({ref_band[0]:g}-{ref_band[1]:g} keV) contains " + f"no events; this event list covers {np.nanmin(values):g}-" + f"{np.nanmax(values):g} keV. Choose a reference band inside that " + "range, or leave ref_min/ref_max empty to use the full band" + ) + + def _fit_segment_to_bins( + self, segment_size: float, bin_time: float, span: Optional[float] + ) -> Tuple[float, Optional[str]]: + """Snap ``segment_size`` to a whole number of ``bin_time`` bins. + + stingray sizes the FFT with ``utils.fix_segment_size_to_integer_samples`` + (the floor of ``segment_size / bin_time``, unless it is within 1% of the + ceiling) but builds the frequency mask in + ``VarEnergySpectrum._get_good_frequency_bins`` from + ``np.rint(segment_size / bin_time)``. When those two roundings disagree + the mask is one element longer than the power array and + ``sub_power[good]`` raises ``IndexError: boolean index did not match + indexed array`` (e.g. bin_time=0.03s with the page's default + segment_size=8s); when they happen to have equal length the mask is + silently applied to the wrong frequency grid, integrating power above + the requested freq_max and normalising with the wrong delta_nu. + + Making segment_size an exact multiple of bin_time makes floor(), rint() + and the 1%-tolerance branch all land on the same bin count, so both + failure modes disappear. The adjustment is reported to the caller so it + can be surfaced instead of applied behind the user's back. + """ + if bin_time <= 0 or segment_size <= 0: + return segment_size, None + n_bins = int(np.rint(segment_size / bin_time)) + # Rounding up must not push the segment past the longest GTI, which + # stingray asserts on. + while n_bins > 1 and span is not None and n_bins * bin_time > span: + n_bins -= 1 + adjusted = float(n_bins * bin_time) + if abs(adjusted - segment_size) <= 1e-9 * max(1.0, abs(segment_size)): + return segment_size, None + return adjusted, ( + f"segment_size was adjusted from {segment_size:g}s to {adjusted:g}s " + f"({n_bins} x bin_time {bin_time:g}s) so that it spans a whole " + "number of time bins; stingray's FFT length and its frequency grid " + "disagree otherwise" + ) + + def _freq_resolution_error( + self, freq_min: float, freq_max: float, segment_size: float, bin_time: float + ) -> Optional[str]: + """Reject a frequency window that contains no Fourier bin. + + ``_get_good_frequency_bins`` selects ``freq >= freq_min & freq < + freq_max`` from ``fftfreq(segment_size / bin_time, bin_time)``, whose + positive entries are the multiples of ``1 / segment_size``. If no + multiple lands in the window the mask is all-False, ``np.mean`` runs on + an empty slice, and every band comes back null behind a bare "Mean of + empty slice." warning. + """ + if bin_time <= 0 or segment_size <= 0: + return None + n_bins = int(np.rint(segment_size / bin_time)) + if n_bins < 3: + return None # already rejected by segment_size_error + delta_nu = 1.0 / (n_bins * bin_time) + highest = ((n_bins - 1) // 2) * delta_nu + lowest = max(1, int(np.ceil(freq_min / delta_nu - 1e-9))) * delta_nu + if lowest <= highest and lowest < freq_max: + return None + return ( + f"no Fourier frequency bin falls inside {freq_min:g}-{freq_max:g} Hz: " + f"the frequency resolution is 1/segment_size = {delta_nu:g} Hz, so " + f"the sampled frequencies are the multiples of {delta_nu:g} Hz from " + f"{delta_nu:g} Hz to {highest:g} Hz. Widen the frequency range or " + "use a longer segment_size" + ) + + def _validate_timing( + self, + event_list, + bin_time: float, + segment_size: float, + freq_min: float, + freq_max: float, + ) -> Tuple[float, List[str], Optional[str]]: + """Validate bin_time, segment_size and the frequency window together. + + Returns ``(segment_size, notes, error)``: ``segment_size`` snapped to a + whole number of bins, ``notes`` recording that adjustment for the + payload's ``warnings``, and ``error`` the first readable rejection. + """ + error = self._freq_interval_error(freq_min, freq_max, bin_time) + if error: + return segment_size, [], error + error = self._segment_error(event_list, segment_size, bin_time) + if error: + return segment_size, [], error + + segment_size, note = self._fit_segment_to_bins( + segment_size, bin_time, _longest_gti(event_list) + ) + notes = [note] if note else [] + error = self._freq_resolution_error( + freq_min, freq_max, segment_size, bin_time + ) + return segment_size, notes, error + + def _energy_spec( + self, energy_min: float, energy_max: float, n_bands: int, log_bands: bool + ) -> Tuple[float, float, int, str]: + return ( + float(energy_min), + float(energy_max), + int(n_bands), + "log" if log_bands else "lin", + ) + + def _add_nan_advice(self, values, warnings: List[str], kind: str) -> None: + """Explain an entirely non-finite spectrum instead of showing bare gaps.""" + array = np.asarray(values, dtype=float) + if array.size and not np.isfinite(array).any(): + advice = _nan_advice(kind) + if advice not in warnings: + warnings.append(advice) + + # ------------------------------------------------------------------ + # rms-spectrum + # ------------------------------------------------------------------ + + def rms_spectrum( + self, + event_list_name: str, + bin_time: float, + segment_size: float, + freq_min: float, + freq_max: float, + energy_min: float, + energy_max: float, + n_bands: int = 5, + log_bands: bool = False, + norm: str = "frac", + ) -> Dict[str, Any]: + """Fractional (or absolute) rms as a function of energy. + + No reference band: stingray's ``ref_band`` is silently ignored by + ``RmsSpectrum`` for a single event list, so the endpoint does not + expose a control that would do nothing. + """ + try: + event_list, error = self._resolve_event_list(event_list_name) + if error: + return self.create_result(False, None, error, None) + + segment_size, notes, timing_error = self._validate_timing( + event_list, bin_time, segment_size, freq_min, freq_max + ) + for check in ( + self._norm_error(norm), + self._energy_spec_error(energy_min, energy_max, n_bands, log_bands), + timing_error, + ): + if check: + return self.create_result(False, None, check, None) + + usage = _gti_usage(event_list, float(segment_size)) + warnings: List[str] = list(notes) + gti_note = _gti_usage_warning(usage, float(segment_size), False) + if gti_note: + warnings.append(gti_note) + with collect_warnings(warnings): + spectrum = RmsSpectrum( + event_list, + energy_spec=self._energy_spec( + energy_min, energy_max, n_bands, log_bands + ), + freq_interval=[float(freq_min), float(freq_max)], + bin_time=float(bin_time), + segment_size=float(segment_size), + norm=norm, + ) + self._add_nan_advice(spectrum.spectrum, warnings, "rms") + + data = { + "energy": finite_list(spectrum.energy), + "spectrum": finite_list(spectrum.spectrum), + "spectrum_error": finite_list(spectrum.spectrum_error), + "freq_range": [float(freq_min), float(freq_max)], + "norm": norm, + "n_segments_hint": _n_segments_hint(event_list, float(segment_size)), + "warnings": _humanize_warnings(warnings), + } + return self.create_result( + True, data, f"Computed rms spectrum in {n_bands} energy bands" + ) + except Exception as exc: + return self.handle_error( + exc, "Calculating rms spectrum", event_list=event_list_name + ) + + # ------------------------------------------------------------------ + # lag-spectrum + # ------------------------------------------------------------------ + + def lag_spectrum( + self, + event_list_name: str, + bin_time: float, + segment_size: float, + freq_min: float, + freq_max: float, + energy_min: float, + energy_max: float, + n_bands: int = 5, + log_bands: bool = False, + ref_min: Optional[float] = None, + ref_max: Optional[float] = None, + ) -> Dict[str, Any]: + """Time lag (seconds) as a function of energy.""" + try: + event_list, error = self._resolve_event_list(event_list_name) + if error: + return self.create_result(False, None, error, None) + + ref_band, ref_error = self._ref_band(ref_min, ref_max) + segment_size, notes, timing_error = self._validate_timing( + event_list, bin_time, segment_size, freq_min, freq_max + ) + for check in ( + ref_error, + self._ref_band_events_error(event_list, ref_band), + self._energy_spec_error(energy_min, energy_max, n_bands, log_bands), + timing_error, + ): + if check: + return self.create_result(False, None, check, None) + + usage = _gti_usage(event_list, float(segment_size)) + warnings: List[str] = list(notes) + gti_note = _gti_usage_warning(usage, float(segment_size), False) + if gti_note: + warnings.append(gti_note) + with collect_warnings(warnings): + spectrum = LagSpectrum( + event_list, + freq_interval=[float(freq_min), float(freq_max)], + energy_spec=self._energy_spec( + energy_min, energy_max, n_bands, log_bands + ), + ref_band=ref_band, + bin_time=float(bin_time), + segment_size=float(segment_size), + ) + self._add_nan_advice(spectrum.spectrum, warnings, "lag") + + data = { + "energy": finite_list(spectrum.energy), + "spectrum": finite_list(spectrum.spectrum), + "spectrum_error": finite_list(spectrum.spectrum_error), + "freq_range": [float(freq_min), float(freq_max)], + "ref_band": ref_band, + "n_segments_hint": _n_segments_hint(event_list, float(segment_size)), + "warnings": _humanize_warnings(warnings), + } + return self.create_result( + True, data, f"Computed lag spectrum in {n_bands} energy bands" + ) + except Exception as exc: + return self.handle_error( + exc, "Calculating lag spectrum", event_list=event_list_name + ) + + # ------------------------------------------------------------------ + # excess-variance + # ------------------------------------------------------------------ + + def excess_variance_spectrum( + self, + event_list_name: str, + bin_time: float, + energy_min: float, + energy_max: float, + n_bands: int = 5, + log_bands: bool = False, + normalization: str = "fvar", + ) -> Dict[str, Any]: + """Excess variance (F_var or unnormalized) as a function of energy. + + No frequency range and no segment size: ``ExcessVarianceSpectrum`` + stores ``freq_interval`` but never reads it, and ignores + ``segment_size`` entirely — it always builds one light curve spanning + the whole GTI at ``bin_time`` resolution. That light curve also runs + straight through the inter-GTI gaps, which is why this endpoint uses + ``_GtiAwareExcessVarianceSpectrum`` rather than stingray's class. + """ + try: + event_list, error = self._resolve_event_list(event_list_name) + if error: + return self.create_result(False, None, error, None) + + if normalization not in VALID_EXCESS_VARIANCE_NORMALIZATIONS: + return self.create_result( + False, + None, + f"normalization '{normalization}' is not supported; use one " + f"of {', '.join(VALID_EXCESS_VARIANCE_NORMALIZATIONS)}", + None, + ) + if bin_time <= 0: + return self.create_result( + False, None, f"bin_time ({bin_time}s) must be positive", None + ) + energy_error = self._energy_spec_error( + energy_min, energy_max, n_bands, log_bands + ) + if energy_error: + return self.create_result(False, None, energy_error, None) + + warnings: List[str] = [] + with collect_warnings(warnings): + # _GtiAwareExcessVarianceSpectrum masks the inter-GTI gap bins + # (which stingray counts as real zero-count bins) and stores its + # results on the object, so the constructor's own call to + # _spectrum_function() is the only one that runs — see the class + # docstring for both stingray 2.2.10 bugs it stands in for. + spectrum = _GtiAwareExcessVarianceSpectrum( + events=event_list, + # Required positionally by stingray but never read by + # _spectrum_function(); the sampled frequency range is set + # by bin_time and the GTI length instead. + freq_interval=[0.0, 1.0 / (2.0 * float(bin_time))], + energy_spec=self._energy_spec( + energy_min, energy_max, n_bands, log_bands + ), + bin_time=float(bin_time), + normalization=normalization, + ) + self._add_nan_advice(spectrum.spectrum, warnings, "excess variance") + + data = { + "energy": finite_list(spectrum.energy), + "spectrum": finite_list(spectrum.spectrum), + "spectrum_error": finite_list(spectrum.spectrum_error), + "normalization": normalization, + "warnings": _humanize_warnings(warnings), + } + return self.create_result( + True, + data, + f"Computed excess-variance spectrum in {n_bands} energy bands", + ) + except Exception as exc: + return self.handle_error( + exc, "Calculating excess-variance spectrum", event_list=event_list_name + ) + + # ------------------------------------------------------------------ + # variable-energy-spectrum (counts + rms + lag overview) + # ------------------------------------------------------------------ + + def variable_energy_spectrum( + self, + event_list_name: str, + bin_time: float, + segment_size: float, + freq_min: float, + freq_max: float, + energy_min: float, + energy_max: float, + n_bands: int = 5, + log_bands: bool = False, + ref_min: Optional[float] = None, + ref_max: Optional[float] = None, + ) -> Dict[str, Any]: + """Counts, fractional rms and lag versus energy from one set of params.""" + try: + event_list, error = self._resolve_event_list(event_list_name) + if error: + return self.create_result(False, None, error, None) + + ref_band, ref_error = self._ref_band(ref_min, ref_max) + segment_size, notes, timing_error = self._validate_timing( + event_list, bin_time, segment_size, freq_min, freq_max + ) + for check in ( + ref_error, + self._ref_band_events_error(event_list, ref_band), + self._energy_spec_error(energy_min, energy_max, n_bands, log_bands), + timing_error, + ): + if check: + return self.create_result(False, None, check, None) + + energy_spec = self._energy_spec(energy_min, energy_max, n_bands, log_bands) + freq_interval = [float(freq_min), float(freq_max)] + usage = _gti_usage(event_list, float(segment_size)) + warnings: List[str] = list(notes) + gti_note = _gti_usage_warning(usage, float(segment_size), False) + if gti_note: + warnings.append(gti_note) + with collect_warnings(warnings): + counts = CountSpectrum(event_list, energy_spec) + # RmsSpectrum ignores ref_band for a single event list, so the + # reference band only reaches the lag panel. + rms = RmsSpectrum( + event_list, + energy_spec=energy_spec, + freq_interval=freq_interval, + bin_time=float(bin_time), + segment_size=float(segment_size), + norm="frac", + ) + lag = LagSpectrum( + event_list, + freq_interval=freq_interval, + energy_spec=energy_spec, + ref_band=ref_band, + bin_time=float(bin_time), + segment_size=float(segment_size), + ) + self._add_nan_advice(rms.spectrum, warnings, "rms") + self._add_nan_advice(lag.spectrum, warnings, "lag") + + data = { + "energy": finite_list(counts.energy), + "counts": { + "spectrum": finite_list(counts.spectrum), + "error": finite_list(counts.spectrum_error), + }, + "rms": { + "spectrum": finite_list(rms.spectrum), + "error": finite_list(rms.spectrum_error), + }, + "lag": { + "spectrum": finite_list(lag.spectrum), + "error": finite_list(lag.spectrum_error), + }, + "freq_range": freq_interval, + "ref_band": ref_band, + "norm": "frac", + "n_segments_hint": _n_segments_hint(event_list, float(segment_size)), + "warnings": _humanize_warnings(warnings), + } + return self.create_result( + True, + data, + f"Computed counts, rms and lag spectra in {n_bands} energy bands", + ) + except Exception as exc: + return self.handle_error( + exc, "Calculating variable-energy spectrum", event_list=event_list_name + ) + + # ------------------------------------------------------------------ + # covariance spectra + # ------------------------------------------------------------------ + + def covariance_spectrum( + self, + event_list_name: str, + bin_time: float, + freq_min: float, + freq_max: float, + energy_min: float, + energy_max: float, + n_bands: int = 5, + log_bands: bool = False, + ref_min: Optional[float] = None, + ref_max: Optional[float] = None, + norm: str = "abs", + ) -> Dict[str, Any]: + """Covariance spectrum over the whole observation (a single segment). + + Deliberately NOT built on the legacy ``stingray.covariancespectrum`` + module: its ``Covariancespectrum``/``AveragedCovariancespectrum`` pass + the full ``(time, energy)`` array to ``Lightcurve.make_lightcurve``, + which flattens it and histograms the *energy* column as arrival times + (verified: the bins covering 0.3-12 s carry ~10x the true rate), and + ``AveragedCovariancespectrum`` additionally always computes + ``nbins = int(segment_size / segment_size) = 1``, so it only ever looks + at the first segment. Both are unusable, so this endpoint uses + ``varenergyspectrum.CovarianceSpectrum`` with a single segment + spanning the longest good-time interval. + + "Whole observation" means the longest *single* GTI, not the summed + exposure: ``gti.time_intervals_from_gtis`` starts every segment at a GTI + boundary and skips any GTI shorter than the segment, so no single-segment + choice can span a gap. The longest GTI is the largest such segment, but + on a multi-GTI observation it drops the shorter ones, so the payload + reports ``n_gtis_used``/``n_gtis_total`` and + ``exposure_used``/``exposure_total`` and adds a warning naming the lost + exposure rather than letting the result read as the full observation. + (Picking a shorter segment would keep more exposure but silently change + the frequency resolution the user's freq_min/freq_max are measured + against, so the choice is surfaced instead of second-guessed; + ``avg_covariance_spectrum`` is the endpoint for choosing it explicitly.) + """ + try: + event_list, error = self._resolve_event_list(event_list_name) + if error: + return self.create_result(False, None, error, None) + segment_size = _longest_gti(event_list) + if not segment_size or segment_size <= 0: + return self.create_result( + False, + None, + f"EventList '{event_list_name}' has no usable good-time interval", + None, + ) + except Exception as exc: + return self.handle_error( + exc, "Calculating covariance spectrum", event_list=event_list_name + ) + return self._covariance( + event_list_name=event_list_name, + event_list=event_list, + bin_time=bin_time, + segment_size=float(segment_size), + freq_min=freq_min, + freq_max=freq_max, + energy_min=energy_min, + energy_max=energy_max, + n_bands=n_bands, + log_bands=log_bands, + ref_min=ref_min, + ref_max=ref_max, + norm=norm, + segment_derived_from_longest_gti=True, + ) + + def avg_covariance_spectrum( + self, + event_list_name: str, + bin_time: float, + segment_size: float, + freq_min: float, + freq_max: float, + energy_min: float, + energy_max: float, + n_bands: int = 5, + log_bands: bool = False, + ref_min: Optional[float] = None, + ref_max: Optional[float] = None, + norm: str = "abs", + ) -> Dict[str, Any]: + """Covariance spectrum averaged over segments of ``segment_size``.""" + try: + event_list, error = self._resolve_event_list(event_list_name) + if error: + return self.create_result(False, None, error, None) + except Exception as exc: + return self.handle_error( + exc, "Calculating covariance spectrum", event_list=event_list_name + ) + return self._covariance( + event_list_name=event_list_name, + event_list=event_list, + bin_time=bin_time, + segment_size=segment_size, + freq_min=freq_min, + freq_max=freq_max, + energy_min=energy_min, + energy_max=energy_max, + n_bands=n_bands, + log_bands=log_bands, + ref_min=ref_min, + ref_max=ref_max, + norm=norm, + ) + + def _covariance( + self, + event_list_name: str, + event_list, + bin_time: float, + segment_size: float, + freq_min: float, + freq_max: float, + energy_min: float, + energy_max: float, + n_bands: int, + log_bands: bool, + ref_min: Optional[float], + ref_max: Optional[float], + norm: str, + segment_derived_from_longest_gti: bool = False, + ) -> Dict[str, Any]: + try: + ref_band, ref_error = self._ref_band(ref_min, ref_max) + segment_size, notes, timing_error = self._validate_timing( + event_list, bin_time, segment_size, freq_min, freq_max + ) + for check in ( + ref_error, + self._ref_band_events_error(event_list, ref_band), + self._norm_error(norm), + self._energy_spec_error(energy_min, energy_max, n_bands, log_bands), + timing_error, + ): + if check: + return self.create_result(False, None, check, None) + + usage = _gti_usage(event_list, float(segment_size)) + warnings: List[str] = list(notes) + gti_note = _gti_usage_warning( + usage, float(segment_size), segment_derived_from_longest_gti + ) + if gti_note: + warnings.append(gti_note) + with collect_warnings(warnings): + spectrum = CovarianceSpectrum( + event_list, + energy_spec=self._energy_spec( + energy_min, energy_max, n_bands, log_bands + ), + ref_band=ref_band, + freq_interval=[float(freq_min), float(freq_max)], + bin_time=float(bin_time), + segment_size=float(segment_size), + norm=norm, + ) + self._add_nan_advice(spectrum.spectrum, warnings, "covariance") + + data = { + "energy": finite_list(spectrum.energy), + "spectrum": finite_list(spectrum.spectrum), + "spectrum_error": finite_list(spectrum.spectrum_error), + "freq_range": [float(freq_min), float(freq_max)], + "ref_band": ref_band, + "norm": norm, + "segment_size": float(segment_size), + "n_segments_hint": _n_segments_hint(event_list, float(segment_size)), + # Honesty about how much of the observation this segmenting + # actually used; the UI chip reads "(full GTI)" otherwise. + **usage, + "warnings": _humanize_warnings(warnings), + } + dropped = usage["n_gtis_total"] - usage["n_gtis_used"] + message = f"Computed covariance spectrum in {n_bands} energy bands" + if dropped > 0: + message += ( + f" from {usage['n_gtis_used']} of " + f"{usage['n_gtis_total']} good-time intervals" + ) + return self.create_result(True, data, message) + except Exception as exc: + return self.handle_error( + exc, "Calculating covariance spectrum", event_list=event_list_name + ) diff --git a/python-backend/services/windows_secure_fs.py b/python-backend/services/windows_secure_fs.py new file mode 100644 index 0000000..180d208 --- /dev/null +++ b/python-backend/services/windows_secure_fs.py @@ -0,0 +1,887 @@ +"""Windows NTFS primitives for native grants and secure publication. + +This module is importable on every platform, but its handle-owning APIs are +available only on Windows. Windows paths are walked one component at a time +from a retained drive-root handle. Every prefix is opened without +``FILE_SHARE_DELETE`` and every reparse point is rejected, so later pathname +replacement cannot redirect an authorized operation. + +The supported Windows boundary is deliberately narrow: absolute local paths +on fixed NTFS volumes. UNC/device paths, alternate data streams, reparse +points, and Win32 ambiguous names are rejected rather than approximated. +""" + +from __future__ import annotations + +import ctypes +import ntpath +import os +import re +import secrets +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ctypes import wintypes + +WINDOWS_FILE_GRANT_VERSION = "v3" + +_DRIVE_RE = re.compile(r"^[A-Za-z]:$") +_RESERVED_STEMS = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{index}" for index in range(1, 10)), + *(f"LPT{index}" for index in range(1, 10)), + "COM¹", + "COM²", + "COM³", + "LPT¹", + "LPT²", + "LPT³", +} +_INVALID_COMPONENT_CHARS = frozenset('<>:"|?*') + +# Access masks and Win32/NT constants. +DELETE = 0x00010000 +SYNCHRONIZE = 0x00100000 +FILE_READ_DATA = 0x0001 +FILE_LIST_DIRECTORY = 0x0001 +FILE_ADD_FILE = 0x0002 +FILE_ADD_SUBDIRECTORY = 0x0004 +FILE_TRAVERSE = 0x0020 +FILE_READ_ATTRIBUTES = 0x0080 +GENERIC_READ = 0x80000000 +GENERIC_WRITE = 0x40000000 + +FILE_SHARE_READ = 0x00000001 +FILE_SHARE_WRITE = 0x00000002 +FILE_SHARE_DELETE = 0x00000004 +OPEN_EXISTING = 3 + +FILE_ATTRIBUTE_DIRECTORY = 0x00000010 +FILE_ATTRIBUTE_HIDDEN = 0x00000002 +FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400 +FILE_ATTRIBUTE_NOT_CONTENT_INDEXED = 0x00002000 +FILE_FLAG_BACKUP_SEMANTICS = 0x02000000 +FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000 + +FILE_DIRECTORY_FILE = 0x00000001 +FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020 +FILE_NON_DIRECTORY_FILE = 0x00000040 +FILE_OPEN_REPARSE_POINT = 0x00200000 +FILE_OPEN = 0x00000001 +FILE_CREATE = 0x00000002 +OBJ_CASE_INSENSITIVE = 0x00000040 + +FILE_DISPOSITION_INFO_CLASS = 4 +FILE_ATTRIBUTE_TAG_INFO_CLASS = 9 +FILE_ID_INFO_CLASS = 18 +FILE_RENAME_INFORMATION_CLASS = 10 + +DRIVE_FIXED = 3 +DUPLICATE_SAME_ACCESS = 0x00000002 +SDDL_REVISION_1 = 1 + +ERROR_FILE_NOT_FOUND = 2 +ERROR_PATH_NOT_FOUND = 3 +ERROR_ALREADY_EXISTS = 183 +ERROR_FILE_EXISTS = 80 + + +class _UNICODE_STRING(ctypes.Structure): + _fields_ = [ + ("Length", wintypes.USHORT), + ("MaximumLength", wintypes.USHORT), + ("Buffer", wintypes.LPWSTR), + ] + + +class _OBJECT_ATTRIBUTES(ctypes.Structure): + _fields_ = [ + ("Length", wintypes.ULONG), + ("RootDirectory", wintypes.HANDLE), + ("ObjectName", ctypes.POINTER(_UNICODE_STRING)), + ("Attributes", wintypes.ULONG), + ("SecurityDescriptor", wintypes.LPVOID), + ("SecurityQualityOfService", wintypes.LPVOID), + ] + + +class _IO_STATUS_BLOCK(ctypes.Structure): + _fields_ = [ + ("Status", wintypes.LPVOID), + ("Information", ctypes.c_size_t), + ] + + +class _FILE_ID_128(ctypes.Structure): + _fields_ = [("Identifier", wintypes.BYTE * 16)] + + +class _FILE_ID_INFO(ctypes.Structure): + _fields_ = [ + ("VolumeSerialNumber", ctypes.c_ulonglong), + ("FileId", _FILE_ID_128), + ] + + +class _FILE_ATTRIBUTE_TAG_INFO(ctypes.Structure): + _fields_ = [ + ("FileAttributes", wintypes.DWORD), + ("ReparseTag", wintypes.DWORD), + ] + + +class _FILE_DISPOSITION_INFO(ctypes.Structure): + _fields_ = [("DeleteFile", wintypes.BOOLEAN)] + + +class _FILE_RENAME_OPERATION(ctypes.Union): + _fields_ = [ + ("ReplaceIfExists", ctypes.c_ubyte), + ("Flags", ctypes.c_uint32), + ] + + +class _FILE_RENAME_INFORMATION(ctypes.Structure): + _anonymous_ = ("Operation",) + _fields_ = [ + ("Operation", _FILE_RENAME_OPERATION), + ("RootDirectory", ctypes.c_void_p), + ("FileNameLength", ctypes.c_uint32), + ("FileName", ctypes.c_uint16 * 1), + ] + + +def _build_file_rename_information( + parent_handle: int, + filename: str, +) -> tuple[ctypes.Array[Any], int]: + """Build the native FILE_RENAME_INFORMATION variable-length buffer.""" + _validate_windows_component(filename) + encoded_name = filename.encode("utf-16-le") + buffer_size = ctypes.sizeof(_FILE_RENAME_INFORMATION) + len(encoded_name) + buffer = ctypes.create_string_buffer(buffer_size) + rename_info = ctypes.cast( + buffer, + ctypes.POINTER(_FILE_RENAME_INFORMATION), + ).contents + rename_info.ReplaceIfExists = False + rename_info.RootDirectory = parent_handle + rename_info.FileNameLength = len(encoded_name) + ctypes.memmove( + ctypes.addressof(buffer) + _FILE_RENAME_INFORMATION.FileName.offset, + encoded_name, + len(encoded_name), + ) + return buffer, buffer_size + + +@dataclass(frozen=True) +class WindowsFileIdentity: + """Stable NTFS identity returned by ``FileIdInfo``.""" + + volume_serial: int + file_id: bytes + + @property + def volume_hex(self) -> str: + return f"{self.volume_serial:016x}" + + @property + def file_id_hex(self) -> str: + return self.file_id.hex() + + +def _validate_windows_component(component: str) -> None: + if not component: + raise ValueError("Windows paths cannot contain empty components") + if component in {".", ".."}: + raise ValueError("Windows paths cannot contain dot components") + if "\\" in component or "/" in component: + raise ValueError("A Windows native operation requires one path component") + if component.endswith((" ", ".")): + raise ValueError("Windows path components cannot end in a space or period") + if len(component) > 255: + raise ValueError("A Windows path component exceeds the NTFS limit") + if any(ord(character) < 32 for character in component): + raise ValueError("Windows paths cannot contain control characters") + if any(character in _INVALID_COMPONENT_CHARS for character in component): + if ":" in component: + raise ValueError("Windows alternate data streams are not supported") + raise ValueError("The Windows path contains a reserved character") + # Win32 recognizes device names even before an extension and ignores + # spaces immediately before that extension (for example ``COM1 .txt``). + stem = component.split(".", 1)[0].rstrip(" ").upper() + if stem in _RESERVED_STEMS: + raise ValueError("The Windows path contains a reserved device name") + + +def validate_windows_path_text(file_path: str) -> None: + """Reject Windows path forms with ambiguous or unsupported semantics.""" + if not isinstance(file_path, str) or not file_path or "\x00" in file_path: + raise ValueError("A non-empty native Windows path is required") + if any(ord(character) < 32 for character in file_path): + raise ValueError("Windows paths cannot contain control characters") + + normalized_separators = file_path.replace("/", "\\") + lowered = normalized_separators.casefold() + if lowered.startswith(("\\\\", "\\?\\", "\\.\\", "\\??\\")): + raise ValueError("UNC and Windows device paths are not supported") + + drive, tail = ntpath.splitdrive(normalized_separators) + if not _DRIVE_RE.fullmatch(drive) or not tail.startswith("\\"): + raise ValueError("An absolute local Windows drive path is required") + + components = [] if tail == "\\" else tail.split("\\")[1:] + for component in components: + _validate_windows_component(component) + + +def canonicalize_windows_path(file_path: str) -> Path: + """Return one canonical absolute Windows path without following links.""" + validate_windows_path_text(file_path) + normalized = ntpath.normpath(file_path.replace("/", "\\")) + drive, tail = ntpath.splitdrive(normalized) + canonical = f"{drive.upper()}{tail}" + validate_windows_path_text(canonical) + return Path(canonical) + + +def _handle_value(handle: Any) -> int: + if isinstance(handle, int): + return handle + value = getattr(handle, "value", None) + if value is None: + raise OSError("Windows returned an invalid native handle") + return int(value) + + +class WindowsNativeApi: + """Small, explicitly typed wrapper around the required Windows APIs.""" + + def __init__(self) -> None: + if os.name != "nt": + raise OSError("Windows native filesystem APIs are unavailable") + + self.kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + self.ntdll = ctypes.WinDLL("ntdll") + self.advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) + + self.kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + self.kernel32.CreateFileW.restype = wintypes.HANDLE + self.kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + self.kernel32.CloseHandle.restype = wintypes.BOOL + self.kernel32.GetDriveTypeW.argtypes = [wintypes.LPCWSTR] + self.kernel32.GetDriveTypeW.restype = wintypes.UINT + self.kernel32.GetVolumeInformationByHandleW.argtypes = [ + wintypes.HANDLE, + wintypes.LPWSTR, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ctypes.POINTER(wintypes.DWORD), + ctypes.POINTER(wintypes.DWORD), + wintypes.LPWSTR, + wintypes.DWORD, + ] + self.kernel32.GetVolumeInformationByHandleW.restype = wintypes.BOOL + self.kernel32.GetFileInformationByHandleEx.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + wintypes.LPVOID, + wintypes.DWORD, + ] + self.kernel32.GetFileInformationByHandleEx.restype = wintypes.BOOL + self.kernel32.SetFileInformationByHandle.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + wintypes.LPVOID, + wintypes.DWORD, + ] + self.kernel32.SetFileInformationByHandle.restype = wintypes.BOOL + self.kernel32.FlushFileBuffers.argtypes = [wintypes.HANDLE] + self.kernel32.FlushFileBuffers.restype = wintypes.BOOL + self.kernel32.GetFileSizeEx.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(ctypes.c_longlong), + ] + self.kernel32.GetFileSizeEx.restype = wintypes.BOOL + self.kernel32.GetCurrentProcess.argtypes = [] + self.kernel32.GetCurrentProcess.restype = wintypes.HANDLE + self.kernel32.DuplicateHandle.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + wintypes.HANDLE, + ctypes.POINTER(wintypes.HANDLE), + wintypes.DWORD, + wintypes.BOOL, + wintypes.DWORD, + ] + self.kernel32.DuplicateHandle.restype = wintypes.BOOL + self.kernel32.LocalFree.argtypes = [wintypes.HLOCAL] + self.kernel32.LocalFree.restype = wintypes.HLOCAL + + self.ntdll.NtCreateFile.argtypes = [ + ctypes.POINTER(wintypes.HANDLE), + wintypes.DWORD, + ctypes.POINTER(_OBJECT_ATTRIBUTES), + ctypes.POINTER(_IO_STATUS_BLOCK), + ctypes.POINTER(ctypes.c_longlong), + wintypes.ULONG, + wintypes.ULONG, + wintypes.ULONG, + wintypes.ULONG, + wintypes.LPVOID, + wintypes.ULONG, + ] + self.ntdll.NtCreateFile.restype = ctypes.c_long + self.ntdll.NtSetInformationFile.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(_IO_STATUS_BLOCK), + wintypes.LPVOID, + wintypes.ULONG, + ctypes.c_int, + ] + self.ntdll.NtSetInformationFile.restype = ctypes.c_long + self.ntdll.RtlNtStatusToDosError.argtypes = [ctypes.c_long] + self.ntdll.RtlNtStatusToDosError.restype = wintypes.ULONG + + self.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + ctypes.POINTER(wintypes.LPVOID), + ctypes.POINTER(wintypes.ULONG), + ] + self.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = ( + wintypes.BOOL + ) + + def _raise_last_error(self, message: str) -> None: + error = ctypes.get_last_error() + raise OSError(error, f"{message}: {ctypes.FormatError(error)}") + + def close(self, handle: int) -> None: + if handle >= 0 and not self.kernel32.CloseHandle(wintypes.HANDLE(handle)): + self._raise_last_error("Could not close a Windows filesystem handle") + + def open_root(self, root: str, *, writable_directory: bool = False) -> int: + if self.kernel32.GetDriveTypeW(root) != DRIVE_FIXED: + raise PermissionError( + "Secure native files require a fixed local Windows volume" + ) + desired_access = ( + FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE + ) + if writable_directory: + desired_access |= FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY + handle = self.kernel32.CreateFileW( + root, + desired_access, + FILE_SHARE_READ | FILE_SHARE_WRITE, + None, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + None, + ) + handle_value = _handle_value(handle) + if handle_value == ctypes.c_void_p(-1).value: + self._raise_last_error("Could not open the selected Windows volume") + try: + filesystem = ctypes.create_unicode_buffer(32) + serial = wintypes.DWORD() + maximum_component = wintypes.DWORD() + flags = wintypes.DWORD() + if not self.kernel32.GetVolumeInformationByHandleW( + wintypes.HANDLE(handle_value), + None, + 0, + ctypes.byref(serial), + ctypes.byref(maximum_component), + ctypes.byref(flags), + filesystem, + len(filesystem), + ): + self._raise_last_error("Could not inspect the selected Windows volume") + if filesystem.value.upper() != "NTFS": + raise PermissionError( + "Secure native files currently require a local NTFS volume" + ) + self.assert_not_reparse(handle_value) + except BaseException: + self.close(handle_value) + raise + return handle_value + + def open_relative( + self, + root_handle: int, + name: str, + *, + desired_access: int, + disposition: int, + directory: bool | None, + attributes: int = 0, + security_descriptor: Any | None = None, + share_delete: bool = False, + ) -> int: + _validate_windows_component(name) + name_buffer = ctypes.create_unicode_buffer(name) + name_bytes = len(name.encode("utf-16-le")) + unicode_name = _UNICODE_STRING( + Length=name_bytes, + MaximumLength=name_bytes + ctypes.sizeof(wintypes.WCHAR), + Buffer=ctypes.cast(name_buffer, wintypes.LPWSTR), + ) + object_attributes = _OBJECT_ATTRIBUTES( + Length=ctypes.sizeof(_OBJECT_ATTRIBUTES), + RootDirectory=wintypes.HANDLE(root_handle), + ObjectName=ctypes.pointer(unicode_name), + Attributes=OBJ_CASE_INSENSITIVE, + SecurityDescriptor=security_descriptor, + SecurityQualityOfService=None, + ) + io_status = _IO_STATUS_BLOCK() + options = FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT + if directory is True: + options |= FILE_DIRECTORY_FILE + elif directory is False: + options |= FILE_NON_DIRECTORY_FILE + output = wintypes.HANDLE() + status = self.ntdll.NtCreateFile( + ctypes.byref(output), + desired_access, + ctypes.byref(object_attributes), + ctypes.byref(io_status), + None, + attributes, + FILE_SHARE_READ + | FILE_SHARE_WRITE + | (FILE_SHARE_DELETE if share_delete else 0), + disposition, + options, + None, + 0, + ) + if status < 0: + error = int(self.ntdll.RtlNtStatusToDosError(status)) + raise OSError(error, ctypes.FormatError(error)) + return _handle_value(output) + + def attributes(self, handle: int) -> int: + info = _FILE_ATTRIBUTE_TAG_INFO() + if not self.kernel32.GetFileInformationByHandleEx( + wintypes.HANDLE(handle), + FILE_ATTRIBUTE_TAG_INFO_CLASS, + ctypes.byref(info), + ctypes.sizeof(info), + ): + self._raise_last_error("Could not inspect a Windows filesystem entry") + return int(info.FileAttributes) + + def assert_not_reparse(self, handle: int) -> None: + if self.attributes(handle) & FILE_ATTRIBUTE_REPARSE_POINT: + raise PermissionError( + "Windows reparse points, junctions, and symbolic links are not supported" + ) + + def identity(self, handle: int) -> WindowsFileIdentity: + info = _FILE_ID_INFO() + if not self.kernel32.GetFileInformationByHandleEx( + wintypes.HANDLE(handle), + FILE_ID_INFO_CLASS, + ctypes.byref(info), + ctypes.sizeof(info), + ): + self._raise_last_error("Could not read the stable Windows file identity") + return WindowsFileIdentity( + volume_serial=int(info.VolumeSerialNumber), + file_id=bytes(info.FileId.Identifier), + ) + + @contextmanager + def private_security_descriptor(self) -> Generator[wintypes.LPVOID, None, None]: + """Yield a protected inheritable DACL for atomic stage creation.""" + # SYSTEM, Administrators, and the object's owner retain full control. + # OI/CI makes the same protected boundary inherit to child artifacts. + security_descriptor = wintypes.LPVOID() + if not self.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW( + "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;OW)", + SDDL_REVISION_1, + ctypes.byref(security_descriptor), + None, + ): + self._raise_last_error("Could not create a private staging ACL") + try: + yield security_descriptor + finally: + self.kernel32.LocalFree(security_descriptor) + + def set_disposition(self, handle: int) -> None: + disposition = _FILE_DISPOSITION_INFO(DeleteFile=True) + if not self.kernel32.SetFileInformationByHandle( + wintypes.HANDLE(handle), + FILE_DISPOSITION_INFO_CLASS, + ctypes.byref(disposition), + ctypes.sizeof(disposition), + ): + self._raise_last_error("Could not remove an owned Windows staging entry") + + def flush(self, handle: int) -> None: + if not self.kernel32.FlushFileBuffers(wintypes.HANDLE(handle)): + self._raise_last_error("Could not flush the Windows staging artifact") + + def size(self, handle: int) -> int: + value = ctypes.c_longlong() + if not self.kernel32.GetFileSizeEx( + wintypes.HANDLE(handle), ctypes.byref(value) + ): + self._raise_last_error("Could not inspect the Windows staging artifact") + return int(value.value) + + def rename_no_replace( + self, + handle: int, + parent_handle: int, + filename: str, + ) -> None: + buffer, buffer_size = _build_file_rename_information( + parent_handle, + filename, + ) + io_status = _IO_STATUS_BLOCK() + status = self.ntdll.NtSetInformationFile( + wintypes.HANDLE(handle), + ctypes.byref(io_status), + buffer, + buffer_size, + FILE_RENAME_INFORMATION_CLASS, + ) + if status < 0: + error = int(self.ntdll.RtlNtStatusToDosError(status)) + if error in {ERROR_ALREADY_EXISTS, ERROR_FILE_EXISTS}: + raise FileExistsError(error, "The export destination already exists") + raise OSError( + error, + f"Could not publish the Windows artifact: {ctypes.FormatError(error)}", + ) + + def duplicate_to_fd(self, handle: int, *, writable: bool) -> int: + import msvcrt + + process = self.kernel32.GetCurrentProcess() + duplicate = wintypes.HANDLE() + if not self.kernel32.DuplicateHandle( + process, + wintypes.HANDLE(handle), + process, + ctypes.byref(duplicate), + 0, + False, + DUPLICATE_SAME_ACCESS, + ): + self._raise_last_error("Could not duplicate a Windows staging handle") + duplicate_value = _handle_value(duplicate) + flags = os.O_BINARY | (os.O_RDWR if writable else os.O_RDONLY) + try: + descriptor = msvcrt.open_osfhandle(duplicate_value, flags) + except BaseException: + self.close(duplicate_value) + raise + try: + os.lseek(descriptor, 0, os.SEEK_SET) + except BaseException: + os.close(descriptor) + raise + return descriptor + + +class PinnedWindowsPath: + """A reparse-free path whose complete prefix handle chain stays retained.""" + + def __init__( + self, + path: Path, + api: WindowsNativeApi, + handles: list[int], + identity: WindowsFileIdentity, + *, + directory: bool, + ) -> None: + self.path = path + self.api = api + self._handles = handles + self.identity = identity + self.directory = directory + + @property + def handle(self) -> int: + if not self._handles: + raise RuntimeError("The pinned Windows path is already closed") + return self._handles[-1] + + def close(self) -> None: + while self._handles: + handle = self._handles.pop() + try: + self.api.close(handle) + except OSError: + pass + + +@contextmanager +def pin_windows_path( + path: Path, + *, + directory: bool, + writable_directory: bool = False, +) -> Generator[PinnedWindowsPath, None, None]: + """Open and retain every component of an existing local NTFS path.""" + canonical = canonicalize_windows_path(str(path)) + drive, tail = ntpath.splitdrive(str(canonical)) + root = f"{drive}\\" + components = [] if tail == "\\" else tail.split("\\")[1:] + api = WindowsNativeApi() + handles: list[int] = [] + try: + root_handle = api.open_root( + root, + writable_directory=writable_directory and not components, + ) + handles.append(root_handle) + api.assert_not_reparse(root_handle) + current = root_handle + for index, component in enumerate(components): + is_final = index == len(components) - 1 + component_is_directory = directory or not is_final + access = FILE_READ_ATTRIBUTES | SYNCHRONIZE + if component_is_directory: + access |= FILE_LIST_DIRECTORY | FILE_TRAVERSE + if is_final and writable_directory: + access |= FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY + else: + access |= FILE_READ_DATA + opened = api.open_relative( + current, + component, + desired_access=access, + disposition=FILE_OPEN, + directory=component_is_directory, + ) + handles.append(opened) + api.assert_not_reparse(opened) + current = opened + attributes = api.attributes(current) + if directory != bool(attributes & FILE_ATTRIBUTE_DIRECTORY): + raise PermissionError("The selected Windows path type changed") + pinned = PinnedWindowsPath( + canonical, + api, + handles, + api.identity(current), + directory=directory, + ) + handles = [] + try: + yield pinned + finally: + pinned.close() + finally: + while handles: + try: + api.close(handles.pop()) + except OSError: + pass + + +class WindowsPublicationReservation: + """Handle-owned private NTFS staging and no-replacement publication.""" + + def __init__(self, parent: PinnedWindowsPath, filename: str) -> None: + self.parent = parent + self.api = parent.api + self.filename = filename + self.stage_handle = -1 + self.stage_name: str | None = None + self.artifact_handle = -1 + self.artifact_name: str | None = None + self.artifact_identity: WindowsFileIdentity | None = None + self.published = False + + def assert_destination_available(self) -> None: + try: + existing = self.api.open_relative( + self.parent.handle, + self.filename, + desired_access=FILE_READ_ATTRIBUTES | SYNCHRONIZE, + disposition=FILE_OPEN, + directory=None, + ) + except OSError as exc: + if exc.errno in {ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND}: + return + raise + try: + raise FileExistsError(f"Destination already exists: {self.filename}") + finally: + self.api.close(existing) + + def reserve(self, extension: str) -> None: + if self.stage_handle >= 0: + raise RuntimeError("A Windows staging artifact is already reserved") + try: + with self.api.private_security_descriptor() as private_descriptor: + for _ in range(10): + candidate = f".stingray-export-{secrets.token_hex(16)}" + try: + stage_handle = self.api.open_relative( + self.parent.handle, + candidate, + desired_access=FILE_LIST_DIRECTORY + | FILE_ADD_FILE + | FILE_ADD_SUBDIRECTORY + | FILE_TRAVERSE + | FILE_READ_ATTRIBUTES + | DELETE + | SYNCHRONIZE, + disposition=FILE_CREATE, + directory=True, + attributes=FILE_ATTRIBUTE_HIDDEN + | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, + security_descriptor=private_descriptor, + ) + except OSError as exc: + if exc.errno in {ERROR_ALREADY_EXISTS, ERROR_FILE_EXISTS}: + continue + raise + self.stage_handle = stage_handle + self.stage_name = candidate + break + else: + raise FileExistsError( + "Could not reserve a private Windows staging area" + ) + + self.api.assert_not_reparse(self.stage_handle) + self.artifact_name = f"artifact{extension}" + self.artifact_handle = self.api.open_relative( + self.stage_handle, + self.artifact_name, + desired_access=GENERIC_READ + | GENERIC_WRITE + | FILE_READ_ATTRIBUTES + | DELETE + | SYNCHRONIZE, + disposition=FILE_CREATE, + directory=False, + attributes=FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, + security_descriptor=private_descriptor, + ) + self.api.assert_not_reparse(self.artifact_handle) + self.artifact_identity = self.api.identity(self.artifact_handle) + except BaseException: + self.close() + raise + + def duplicate_fd(self, *, writable: bool) -> int: + if self.artifact_handle < 0 or self.artifact_identity is None: + raise RuntimeError("No Windows staging artifact is reserved") + if self.api.identity(self.artifact_handle) != self.artifact_identity: + raise PermissionError("The Windows staging artifact identity changed") + return self.api.duplicate_to_fd(self.artifact_handle, writable=writable) + + def flush(self) -> None: + if self.artifact_handle < 0: + raise RuntimeError("No Windows staging artifact is reserved") + self.api.flush(self.artifact_handle) + + def verified_size(self) -> int: + if self.artifact_handle < 0 or self.artifact_identity is None: + raise RuntimeError("No Windows staging artifact is reserved") + if self.api.identity(self.artifact_handle) != self.artifact_identity: + raise PermissionError("The Windows staging artifact identity changed") + return self.api.size(self.artifact_handle) + + def publish(self) -> list[str]: + if self.artifact_handle < 0 or self.artifact_identity is None: + raise RuntimeError("No Windows staging artifact is reserved") + self.api.rename_no_replace( + self.artifact_handle, + self.parent.handle, + self.filename, + ) + # The namespace transition has occurred. Never delete this handle in a + # later failure path: it now denotes the user's published artifact. + self.published = True + final_handle = -1 + try: + final_handle = self.api.open_relative( + self.parent.handle, + self.filename, + desired_access=FILE_READ_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE, + disposition=FILE_OPEN, + directory=False, + share_delete=True, + ) + self.api.assert_not_reparse(final_handle) + if self.api.identity(final_handle) != self.artifact_identity: + raise PermissionError( + "The Windows export destination changed during publication" + ) + finally: + if final_handle >= 0: + self.api.close(final_handle) + + warnings: list[str] = [] + try: + self._close_artifact(delete=False) + self._close_stage(delete=True) + except OSError as cleanup_error: + warnings.append( + "Export succeeded, but its private Windows staging directory " + f"could not be removed ({cleanup_error})." + ) + return warnings + + def _close_artifact(self, *, delete: bool) -> None: + if self.artifact_handle < 0: + return + handle = self.artifact_handle + self.artifact_handle = -1 + try: + if delete: + self.api.set_disposition(handle) + finally: + self.api.close(handle) + + def _close_stage(self, *, delete: bool) -> None: + if self.stage_handle < 0: + return + handle = self.stage_handle + self.stage_handle = -1 + try: + if delete: + self.api.set_disposition(handle) + finally: + self.api.close(handle) + + def close(self) -> None: + try: + self._close_artifact(delete=not self.published) + except OSError: + pass + try: + self._close_stage(delete=True) + except OSError: + pass diff --git a/python-backend/tests/__init__.py b/python-backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-backend/tests/backend_auth.py b/python-backend/tests/backend_auth.py new file mode 100644 index 0000000..2cb83d4 --- /dev/null +++ b/python-backend/tests/backend_auth.py @@ -0,0 +1,6 @@ +"""Shared launch credential for tests that exercise the fully secured app.""" + +TEST_BACKEND_SESSION_SECRET = "test-backend-session-" + "a" * 64 +TEST_BACKEND_AUTH_HEADERS = { + "X-Stingray-Session": TEST_BACKEND_SESSION_SECRET, +} diff --git a/python-backend/tests/conftest.py b/python-backend/tests/conftest.py new file mode 100644 index 0000000..0f37260 --- /dev/null +++ b/python-backend/tests/conftest.py @@ -0,0 +1,38 @@ +"""Shared fixtures for backend service tests. + +python-backend is not an installable package (hyphenated dir name), so tests +add it to sys.path and import the same way main.py does (cwd=python-backend). +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import numpy as np +import pytest +from stingray import EventList + +from services.state_manager import StateManager + + +def make_event_list( + seed: int, n_events: int = 20000, length: float = 64.0 +) -> EventList: + """Deterministic synthetic event list spanning [0, length] seconds.""" + rng = np.random.default_rng(seed) + times = np.sort(rng.uniform(0.0, length, n_events)) + energy = rng.uniform(0.5, 10.0, n_events) + return EventList(time=times, energy=energy, gti=[[0.0, length]]) + + +@pytest.fixture() +def state_manager() -> StateManager: + return StateManager() + + +@pytest.fixture() +def loaded_state(state_manager: StateManager) -> StateManager: + state_manager.add_event_data("ev1", make_event_list(1)) + state_manager.add_event_data("ev2", make_event_list(2)) + return state_manager diff --git a/python-backend/tests/test_analysis_helpers.py b/python-backend/tests/test_analysis_helpers.py new file mode 100644 index 0000000..7706e0f --- /dev/null +++ b/python-backend/tests/test_analysis_helpers.py @@ -0,0 +1,379 @@ +"""Tests for the shared analysis helpers. + +Covers the two defects fixed in analysis_helpers.py: + +* ``collect_warnings`` used ``warnings.catch_warnings(record=True)`` with no + serialization, so two concurrent captures (every route dispatches through + ``asyncio.to_thread``) could swap sinks and permanently corrupt the + process-global warning hooks. +* ``overlap_error`` read ``time[0]``/``time[-1]`` as the span, which is wrong + for an unsorted event list. +""" + +import os +import subprocess +import sys +import threading +import warnings +from pathlib import Path + +import numpy as np +import pytest + +from services import analysis_helpers +from services.analysis_helpers import collect_warnings, overlap_error + +BACKEND_ROOT = Path(__file__).resolve().parents[1] + +# Child processes select their warnings mode with -X only, so the mode is never +# inherited from however this suite itself happens to have been launched. +CHILD_ENV = {k: v for k, v in os.environ.items() + if k != "PYTHON_CONTEXT_AWARE_WARNINGS"} + + +class FakeEvents: + """Minimal stand-in for an EventList: overlap_error only touches ``.time``.""" + + def __init__(self, time): + self.time = np.asarray(time, dtype=float) + + +# -------------------------------------------------------------------------- +# collect_warnings thread safety +# -------------------------------------------------------------------------- + + +# Every wait below is bounded: under the lock-based fallback the two blocks +# genuinely cannot overlap, so an unbounded wait would deadlock the suite. +_RENDEZVOUS_TIMEOUT = 0.5 + + +def _run_interleaved_capture(): + """Drive two collect_warnings blocks through the worst-case interleave and + return ``(sink_a, sink_b, errors)``. + + The ordering is forced, not raced -- A enters, B enters, A warns, A exits, + B exits. Against the original unsynchronized implementation that is exactly + the sequence which (1) records A's warning into B's sink and (2) leaves the + process-global hook pointing at A's finished list once B exits. A correct + implementation must put A's warning in A's sink and leave B's sink empty. + """ + sink_a: list = [] + sink_b: list = [] + errors: list = [] + a_inside = threading.Event() + b_inside = threading.Event() + a_exited = threading.Event() + + def worker_a(): + try: + with collect_warnings(sink_a): + a_inside.set() + # Under the lock this times out (B is still waiting to acquire); + # without it, B really is inside its own capture by now. + b_inside.wait(timeout=_RENDEZVOUS_TIMEOUT) + warnings.warn("FROM-A", UserWarning) + except Exception as exc: # pragma: no cover - surfaced via `errors` + errors.append(exc) + finally: + a_exited.set() + + def worker_b(): + try: + a_inside.wait(timeout=10) # A must open its capture first + with collect_warnings(sink_b): + b_inside.set() + # Stay open until A has exited, so the exit order is non-LIFO. + a_exited.wait(timeout=_RENDEZVOUS_TIMEOUT) + except Exception as exc: # pragma: no cover - surfaced via `errors` + errors.append(exc) + + threads = [ + threading.Thread(target=worker_a), + threading.Thread(target=worker_b), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + assert not thread.is_alive(), "collect_warnings deadlocked" + return sink_a, sink_b, errors + + +def test_concurrent_collect_warnings_never_swaps_sinks(): + """The warning raised in thread A must land in A's sink, not B's.""" + sink_a, sink_b, errors = _run_interleaved_capture() + + assert errors == [] + assert sink_a == ["FROM-A"], f"thread A lost its own warning: {sink_a!r}" + assert sink_b == [], f"thread B stole another request's warning: {sink_b!r}" + + +@pytest.mark.skipif( + analysis_helpers.CONTEXT_AWARE_WARNINGS, + reason=( + "there is no module-global capture state to orphan in context-aware " + "mode, and replacing _showwarnmsg_impl (this test's probe) bypasses " + "context-local recording outright" + ), +) +def test_concurrent_collect_warnings_leaves_global_state_intact(): + """A non-LIFO interleave must not orphan ``warnings._showwarnmsg_impl``. + + Before the fix, one bad interleave left the module-global hook pointing at a + finished capture's list, silently swallowing every warning raised anywhere in + the process afterwards. + """ + # Stand in for the process-wide delivery hook (under pytest the real one is + # the plugin's own recorder). If a capture orphans the global, this sentinel + # is what gets clobbered, exactly as stderr delivery would be in production. + delivered: list = [] + + def sentinel(msg): + delivered.append(str(msg.message)) + + original_impl = warnings._showwarnmsg_impl + saved_filters = warnings.filters[:] + warnings._showwarnmsg_impl = sentinel + try: + _, _, errors = _run_interleaved_capture() + assert errors == [] + + # The hook must still be ours, not some finished capture's list.append. + assert warnings._showwarnmsg_impl is sentinel, ( + "collect_warnings orphaned the global warning hook: " + f"{warnings._showwarnmsg_impl!r}" + ) + warnings.simplefilter("always") + warnings.warn("AFTER-THE-FACT", UserWarning) + assert delivered == ["AFTER-THE-FACT"], ( + "warnings raised outside any capture were swallowed: " + f"{delivered!r}, hook={warnings._showwarnmsg_impl!r}" + ) + finally: + warnings._showwarnmsg_impl = original_impl + warnings.filters[:] = saved_filters + warnings._filters_mutated() + + +def test_lock_fallback_is_active_when_context_aware_warnings_is_off(): + """The two branches are chosen by the interpreter flag, not by luck.""" + if analysis_helpers.CONTEXT_AWARE_WARNINGS: + assert analysis_helpers._CAPTURE_LOCK is None + else: + assert analysis_helpers._CAPTURE_LOCK is not None + + +# The context-aware branch cannot be toggled inside a running interpreter +# (sys.flags is read-only), so exercise it in a subprocess launched with the +# -X flag. Skipped automatically on interpreters that lack the mode. +_CONTEXT_AWARE_CHILD = r""" +import sys, threading, warnings +sys.path.insert(0, {backend!r}) +from services import analysis_helpers +from services.analysis_helpers import collect_warnings + +assert analysis_helpers.CONTEXT_AWARE_WARNINGS, "flag did not take effect" +assert analysis_helpers._CAPTURE_LOCK is None, "lock-free branch not selected" + +sinks = {{"A": [], "B": []}} +a_inside = threading.Event() +b_inside = threading.Event() +a_exited = threading.Event() + +original_impl = warnings._showwarnmsg_impl + + +def worker_a(): + with collect_warnings(sinks["A"]): + a_inside.set() + assert b_inside.wait(10), "B never entered its capture" + warnings.warn("FROM-A", UserWarning) + a_exited.set() + + +def worker_b(): + assert a_inside.wait(10), "A never entered its capture" + with collect_warnings(sinks["B"]): + b_inside.set() + assert a_exited.wait(10), "A never exited its capture" + + +threads = [threading.Thread(target=worker_a), threading.Thread(target=worker_b)] +for t in threads: + t.start() +for t in threads: + t.join(20) + assert not t.is_alive(), "deadlock" + +assert sinks["A"] == ["FROM-A"], sinks +assert sinks["B"] == [], sinks +assert warnings._showwarnmsg_impl is original_impl, "global hook orphaned" +print("OK") +""" + + +@pytest.mark.skipif( + not hasattr(sys.flags, "context_aware_warnings"), + reason="interpreter has no context-aware warnings mode (needs Python 3.14+)", +) +def test_context_aware_branch_isolates_overlapping_captures(): + """With -X context_aware_warnings=1 the captures are isolated lock-free. + + This is the branch production/dev launches take (electron/pythonManager.ts + sets PYTHON_CONTEXT_AWARE_WARNINGS=1, `npm run python:dev` passes -X), so it + needs coverage even though the test suite itself runs in the default mode. + """ + script = _CONTEXT_AWARE_CHILD.format(backend=str(BACKEND_ROOT)) + proc = subprocess.run( + [sys.executable, "-X", "context_aware_warnings=1", "-c", script], + capture_output=True, + text=True, + timeout=120, + env=CHILD_ENV, + ) + assert proc.returncode == 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}" + assert "OK" in proc.stdout + + +# The context-aware path does not reset warnings.showwarning on entry the way +# the legacy path does, so a global showwarning replacement that fails to chain +# would silently empty every `data.warnings` array. main.py's lifespan installs +# exactly such a replacement (utils/log_stream.py), so pin the real posture. +_LOG_STREAM_CHILD = r""" +import logging, sys, threading, warnings +sys.path.insert(0, {backend!r}) +from services import analysis_helpers +from services.analysis_helpers import collect_warnings +from utils.log_stream import log_stream_manager + +assert analysis_helpers.CONTEXT_AWARE_WARNINGS is {expect_context_aware!r}, ( + "wrong warnings mode: %r" % (analysis_helpers.CONTEXT_AWARE_WARNINGS,) +) + +# Exactly what main.py's lifespan does before serving any request. +log_stream_manager.install(log_level=logging.CRITICAL) +assert warnings.showwarning is not warnings._showwarning_orig, "hook not installed" + +sink = [] + + +def run(): + with collect_warnings(sink): + warnings.warn("SIMON says: Low count rate in the subject band", UserWarning) + + +t = threading.Thread(target=run) +t.start() +t.join(10) +assert not t.is_alive(), "deadlock" +assert sink == ["SIMON says: Low count rate in the subject band"], sink +print("OK") +""" + + +@pytest.mark.parametrize( + "extra_args, expect_context_aware", + [ + pytest.param([], False, id="lock-fallback"), + pytest.param( + ["-X", "context_aware_warnings=1"], True, id="context-aware" + ), + ], +) +def test_capture_survives_the_log_stream_showwarning_hook( + extra_args, expect_context_aware +): + """Warnings still reach the sink with the app's global hook installed.""" + if expect_context_aware and not hasattr(sys.flags, "context_aware_warnings"): + pytest.skip("interpreter has no context-aware warnings mode") + script = _LOG_STREAM_CHILD.format( + backend=str(BACKEND_ROOT), expect_context_aware=expect_context_aware + ) + proc = subprocess.run( + [sys.executable, *extra_args, "-c", script], + capture_output=True, + text=True, + timeout=120, + env=CHILD_ENV, + ) + assert proc.returncode == 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}" + assert "OK" in proc.stdout + + +def test_collect_warnings_deduplicates_and_releases_the_lock_on_error(): + """Sanity: the wrapper still dedupes, and an exception does not wedge it.""" + sink: list = [] + with collect_warnings(sink): + warnings.warn("dupe", UserWarning) + warnings.warn("dupe", UserWarning) + warnings.warn("other", UserWarning) + assert sink == ["dupe", "other"] + + with pytest.raises(RuntimeError): + with collect_warnings([]): + raise RuntimeError("boom") + + # The lock must have been released, so a later capture still works. + after: list = [] + with collect_warnings(after): + warnings.warn("after-error", UserWarning) + assert after == ["after-error"] + + +# -------------------------------------------------------------------------- +# overlap_error on unsorted event times +# -------------------------------------------------------------------------- + + +def test_overlap_error_accepts_unsorted_lists_that_do_overlap(): + """Two fully simultaneous observations must not be rejected when unsorted. + + Permuting the times so that ``time[0] > time[-1]`` used to make + ``stop <= start``, producing a bogus "no overlapping time range" rejection. + """ + sorted_times = np.linspace(0.0, 100.0, 501) + shuffled = sorted_times.copy() + rng = np.random.default_rng(7) + rng.shuffle(shuffled) + assert shuffled[0] > shuffled[-1], "fixture is not exercising the bug" + + unsorted_events = FakeEvents(shuffled) + sorted_events = FakeEvents(sorted_times) + + assert overlap_error(unsorted_events, sorted_events) is None + assert overlap_error(sorted_events, unsorted_events) is None + assert overlap_error(unsorted_events, unsorted_events) is None + + +def test_overlap_error_uses_true_span_for_the_segment_size_check(): + """The segment check must measure the real overlap, not the stored endpoints.""" + shuffled = np.array([50.0, 0.0, 100.0, 25.0, 49.0]) + events = FakeEvents(shuffled) + other = FakeEvents(np.linspace(0.0, 100.0, 11)) + + # True overlap is 100s: a 60s segment fits, a 120s segment does not. + assert overlap_error(events, other, segment_size=60.0) is None + message = overlap_error(events, other, segment_size=120.0) + assert message is not None + assert "100.0s" in message + + +def test_overlap_error_still_rejects_genuinely_disjoint_lists(): + """The min/max fix must not weaken the real rejection, and must report the + true spans in the message.""" + early = FakeEvents([5.0, 1.0, 9.0, 3.0]) + late = FakeEvents([40.0, 20.0, 30.0]) + + message = overlap_error(early, late) + assert message is not None + assert "no overlapping time range" in message + assert "1.0-9.0s" in message + assert "20.0-40.0s" in message + + +def test_overlap_error_rejects_empty_lists(): + assert ( + overlap_error(FakeEvents([]), FakeEvents([1.0, 2.0])) + == "one of the event lists contains no events" + ) diff --git a/python-backend/tests/test_archive_crawl_security.py b/python-backend/tests/test_archive_crawl_security.py new file mode 100644 index 0000000..7f25e04 --- /dev/null +++ b/python-backend/tests/test_archive_crawl_security.py @@ -0,0 +1,354 @@ +"""Security contract tests for bounded HEASARC directory crawling.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import httpx +import pytest +import services.archive_service as archive_module +from main import BACKEND_SESSION_HEADER, create_app +from routes.archive_routes import router as archive_router +from services.archive_service import ( + ARCHIVE_CRAWL_HOP_SECONDS, + MAX_ARCHIVE_DIRECTORY_ENTRIES, + MAX_ARCHIVE_DIRECTORY_HTML_BYTES, + ArchiveService, +) +from services.remote_source import ( + HEASARC_ARCHIVE_POLICY, + RemoteSourceSizeError, +) + +SESSION_SECRET = "archive-crawl-session-secret-at-least-32-bytes" +FILE_GRANT_SECRET = "archive-crawl-file-grant-secret-at-least-32-bytes" +LIST_ROUTE = "/api/archive/list-files" +OBSID = "0123456789" +ROOT_URL = f"https://heasarc.gsfc.nasa.gov/FTP/xmm/data/rev0/{OBSID}/" + + +class FakeListingRemote: + def __init__(self, responses: dict[str, bytes], *, after_fetch=None) -> None: + self.responses = responses + self.after_fetch = after_fetch + self.calls: list[dict[str, object]] = [] + + async def fetch_bytes( + self, + url, + *, + max_bytes, + cancellation_check=None, + ): + self.calls.append( + { + "url": url, + "max_bytes": max_bytes, + "cancellation_check": cancellation_check, + } + ) + body = self.responses[url] + if len(body) > max_bytes: + raise RemoteSourceSizeError("bounded fake response was too large") + if self.after_fetch is not None: + self.after_fetch() + return body, SimpleNamespace( + status_code=200, + content_type="text/html; charset=utf-8", + ) + + +def install_listing_remote(monkeypatch, remote: FakeListingRemote): + constructions: list[dict[str, object]] = [] + + def create_client(policy, **kwargs): + constructions.append({"policy": policy, **kwargs}) + return remote + + monkeypatch.setattr(archive_module, "RemoteSourceClient", create_client) + return constructions + + +async def crawl(service: ArchiveService, **kwargs): + return await service.list_observation_files( + mission="XMM-Newton", + obsid=OBSID, + recursive=True, + max_depth=3, + **kwargs, + ) + + +def test_list_files_route_is_registered_exactly_once(): + matching = [ + route + for route in archive_router.routes + if route.path == "/list-files" and "POST" in (route.methods or set()) + ] + + assert len(matching) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "hostile_href", + [ + "https://evil.example/x", + "//evil.example/x", + "sub%2Fescape/", + "%2e%2e/", + "sub%5cescape/", + "safe/?token=SECRET", + "safe/#frag", + "%00.evt", + "%GG", + "%FF", + "%252e%252e/", + "%252fetc/", + ], +) +async def test_non_child_href_is_rejected_before_any_child_hop( + hostile_href, + state_manager, + monkeypatch, +): + html = f'hostilesafe' + remote = FakeListingRemote({ROOT_URL: html.encode()}) + install_listing_remote(monkeypatch, remote) + + result = await crawl(ArchiveService(state_manager)) + + assert result == { + "success": False, + "data": None, + "message": "The archive directory listing failed validation", + "error": "The archive crawl was rejected safely", + } + assert [call["url"] for call in remote.calls] == [ROOT_URL] + serialized = json.dumps(result) + assert hostile_href not in serialized + assert "SECRET" not in serialized + + +@pytest.mark.asyncio +async def test_benign_navigation_is_skipped_and_file_survives( + state_manager, + monkeypatch, +): + html = """ + + Parent + Icon + Sort + event_cl.evt + + """ + remote = FakeListingRemote({ROOT_URL: html.encode()}) + install_listing_remote(monkeypatch, remote) + + result = await crawl(ArchiveService(state_manager)) + + assert result["success"] is True + assert result["data"]["total_files"] == 1 + assert result["data"]["files"] == [ + { + "path": "event_cl.evt", + "name": "event_cl.evt", + "is_directory": False, + "file_type": "event", + "size_bytes": None, + "size_display": "Unknown", + "full_url": ROOT_URL + "event_cl.evt", + } + ] + assert len(remote.calls) == 1 + + +@pytest.mark.asyncio +async def test_every_crawl_hop_uses_bounded_remote_source_without_head( + state_manager, + monkeypatch, +): + sub_url = ROOT_URL + "sub/" + remote = FakeListingRemote( + { + ROOT_URL: b'subroot.log', + sub_url: b'child.evt', + } + ) + constructions = install_listing_remote(monkeypatch, remote) + + class ForbiddenRawClient: + def __init__(self, *_args, **_kwargs): + raise AssertionError("crawl must not construct a raw httpx client") + + monkeypatch.setattr(httpx, "AsyncClient", ForbiddenRawClient) + + def cancelled(): + return False + + result = await crawl( + ArchiveService(state_manager), + cancellation_check=cancelled, + ) + + assert result["success"] is True + assert [call["url"] for call in remote.calls] == [ROOT_URL, sub_url] + assert all( + call["max_bytes"] == MAX_ARCHIVE_DIRECTORY_HTML_BYTES + and call["cancellation_check"] is cancelled + for call in remote.calls + ) + assert len(constructions) == 2 + assert all(item["policy"] == HEASARC_ARCHIVE_POLICY for item in constructions) + assert all( + item["timeouts"].total <= ARCHIVE_CRAWL_HOP_SECONDS + and item["max_redirects"] == 3 + for item in constructions + ) + root_file = result["data"]["files"][1] + child_file = result["data"]["files"][0]["children"][0] + assert root_file["size_bytes"] is None + assert child_file["size_bytes"] is None + + +@pytest.mark.asyncio +async def test_crawl_rejects_oversized_directory_body( + state_manager, + monkeypatch, +): + remote = FakeListingRemote( + {ROOT_URL: b"x" * (MAX_ARCHIVE_DIRECTORY_HTML_BYTES + 1)} + ) + install_listing_remote(monkeypatch, remote) + + result = await crawl(ArchiveService(state_manager)) + + assert result["success"] is False + assert len(remote.calls) == 1 + assert remote.calls[0]["max_bytes"] == MAX_ARCHIVE_DIRECTORY_HTML_BYTES + + +@pytest.mark.asyncio +async def test_crawl_rejects_too_many_entries_without_child_fanout( + state_manager, + monkeypatch, +): + html = "".join( + f'dir' + for index in range(MAX_ARCHIVE_DIRECTORY_ENTRIES + 1) + ) + remote = FakeListingRemote({ROOT_URL: html.encode()}) + install_listing_remote(monkeypatch, remote) + + result = await crawl(ArchiveService(state_manager)) + + assert result["success"] is False + assert [call["url"] for call in remote.calls] == [ROOT_URL] + + +@pytest.mark.asyncio +async def test_shared_directory_budget_fails_closed_before_next_hop( + state_manager, + monkeypatch, +): + monkeypatch.setattr(archive_module, "MAX_ARCHIVE_CRAWL_DIRECTORIES", 2) + first_url = ROOT_URL + "first/" + remote = FakeListingRemote( + { + ROOT_URL: b'firstsecond', + first_url: b'one', + } + ) + install_listing_remote(monkeypatch, remote) + + result = await crawl(ArchiveService(state_manager)) + + assert result["success"] is False + assert [call["url"] for call in remote.calls] == [ROOT_URL, first_url] + assert result["data"] is None + + +@pytest.mark.asyncio +async def test_shared_entry_budget_fails_closed_across_directories( + state_manager, + monkeypatch, +): + monkeypatch.setattr(archive_module, "MAX_ARCHIVE_CRAWL_ENTRIES", 2) + sub_url = ROOT_URL + "sub/" + remote = FakeListingRemote( + { + ROOT_URL: b'sub', + sub_url: b'onetwo', + } + ) + install_listing_remote(monkeypatch, remote) + + result = await crawl(ArchiveService(state_manager)) + + assert result["success"] is False + assert result["data"] is None + assert [call["url"] for call in remote.calls] == [ROOT_URL, sub_url] + + +@pytest.mark.asyncio +async def test_total_crawl_deadline_stops_before_child_hop( + state_manager, + monkeypatch, +): + clock = {"now": 0.0} + monkeypatch.setattr(archive_module.time, "monotonic", lambda: clock["now"]) + remote = FakeListingRemote( + {ROOT_URL: b'child'}, + after_fetch=lambda: clock.update(now=121.0), + ) + install_listing_remote(monkeypatch, remote) + + result = await crawl(ArchiveService(state_manager)) + + assert result["success"] is False + assert result["message"] == "The archive directory listing timed out" + assert [call["url"] for call in remote.calls] == [ROOT_URL] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + {"mission": "XMM-Newton", "obsid": OBSID, "max_depth": -1}, + {"mission": "XMM-Newton", "obsid": OBSID, "max_depth": 4}, + {"mission": "XMM-Newton", "obsid": OBSID, "max_depth": True}, + {"mission": "XMM-Newton", "obsid": OBSID, "recursive": "true"}, + {"mission": "XMM-Newton", "obsid": OBSID, "recursive": 1}, + {"mission": "XMM-Newton", "obsid": "../escape"}, + {"mission": "XMM-Newton", "obsid": "x" * 129}, + {"mission": "XMM-Newton", "obsid": OBSID, "obs_time": "x" * 65}, + {"mission": "XMM-Newton", "obsid": OBSID, "extra": True}, + { + "mission": "RXTE", + "obsid": OBSID, + "obs_data": {"prnb": "12345", "extra": True}, + }, + ], +) +async def test_list_route_rejects_unbounded_or_coercive_requests( + payload, + state_manager, +): + app = create_app( + session_secret=SESSION_SECRET, + file_grant_secret=FILE_GRANT_SECRET, + ) + app.state.state_manager = state_manager + app.state.performance_monitor = None + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + headers={BACKEND_SESSION_HEADER: SESSION_SECRET}, + ) as client: + response = await client.post(LIST_ROUTE, json=payload) + + assert response.status_code == 422 + assert "../escape" not in response.text + assert "x" * 65 not in response.text diff --git a/python-backend/tests/test_archive_download_security.py b/python-backend/tests/test_archive_download_security.py new file mode 100644 index 0000000..33b5197 --- /dev/null +++ b/python-backend/tests/test_archive_download_security.py @@ -0,0 +1,1045 @@ +"""Security contract tests for HEASARC downloads and native publication.""" + +from __future__ import annotations + +import hashlib +import io +import json +import threading +import time +from contextlib import asynccontextmanager, contextmanager +from types import SimpleNamespace + +import httpx +import pytest +import services.archive_service as archive_module +import services.utility_helpers as grant_module +from main import BACKEND_SESSION_HEADER, create_app +from pydantic import ValidationError +from routes.archive_routes import DownloadToDiskRequest, download_to_disk +from services.archive_service import ( + ARCHIVE_DOWNLOAD_CHUNK_BYTES, + ARCHIVE_DOWNLOAD_TIMEOUTS, + MAX_AGGREGATE_ARCHIVE_DOWNLOAD_BYTES, + MAX_ARCHIVE_DOWNLOAD_BYTES, + MAX_CONCURRENT_ARCHIVE_DOWNLOADS, + ArchiveService, +) +from services.remote_source import ( + HEASARC_ARCHIVE_POLICY, + RemoteSourceCancelled, + RemoteSourceError, + RemoteSourceHTTPError, +) +from services.utility_helpers import ( + FILE_GRANT_SECRET_ENV, + FILE_GRANT_TTL_SECONDS, + issue_file_grant, +) + +TEST_SECRET = "archive-download-test-secret-that-is-at-least-32-bytes" +APPROVED_URL = "https://heasarc.gsfc.nasa.gov/FTP/nicer/data/file.evt" +SESSION_SECRET = "archive-download-session-secret-at-least-32-bytes" +DOWNLOAD_ROUTE = "/api/archive/download-to-disk" + + +class FakeRemoteStream: + def __init__( + self, + chunks: list[bytes], + *, + content_length: int | None = None, + status_code: int = 200, + failure: Exception | None = None, + ) -> None: + self.info = SimpleNamespace( + content_length=content_length, + status_code=status_code, + ) + self._chunks = chunks + self._failure = failure + + async def aiter_bytes(self): + for chunk in self._chunks: + yield chunk + if self._failure is not None: + raise self._failure + + +class FakeRemoteClient: + def __init__(self, remote_stream: FakeRemoteStream) -> None: + self.remote_stream = remote_stream + self.calls: list[dict[str, object]] = [] + + @asynccontextmanager + async def stream(self, url, *, max_bytes, cancellation_check=None): + self.calls.append( + { + "url": url, + "max_bytes": max_bytes, + "cancellation_check": cancellation_check, + } + ) + yield self.remote_stream + + +@pytest.fixture(autouse=True) +def file_grant_secret(monkeypatch): + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, TEST_SECRET) + + +def write_grant(path) -> str: + return issue_file_grant(str(path), access="write").grant + + +def install_remote_client(monkeypatch, remote_client: FakeRemoteClient): + construction: dict[str, object] = {} + + def create_client(policy, **kwargs): + construction["policy"] = policy + construction.update(kwargs) + return remote_client + + monkeypatch.setattr(archive_module, "RemoteSourceClient", create_client) + return construction + + +async def collect_download(service: ArchiveService, **kwargs): + return [event async for event in service.download_file_to_disk(**kwargs)] + + +@pytest.mark.asyncio +async def test_download_reopens_hashes_and_exclusively_publishes( + tmp_path, state_manager, monkeypatch +): + body = b"verified HEASARC bytes" * 50 + remote_client = FakeRemoteClient( + FakeRemoteStream([body[:37], body[37:]], content_length=len(body)) + ) + construction = install_remote_client(monkeypatch, remote_client) + destination = tmp_path / "download.evt" + service = ArchiveService(state_manager) + + events = await collect_download( + service, + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + + assert destination.read_bytes() == body + assert [event["type"] for event in events] == ["progress", "progress", "complete"] + complete = events[-1] + assert complete == { + "type": "complete", + "file_name": "download.evt", + "size_bytes": len(body), + "sha256": hashlib.sha256(body).hexdigest(), + "warnings": [], + } + assert "destination_path" not in json.dumps(events) + assert "destination_grant" not in json.dumps(events) + assert str(destination) not in json.dumps(events) + assert construction == { + "policy": HEASARC_ARCHIVE_POLICY, + "timeouts": ARCHIVE_DOWNLOAD_TIMEOUTS, + "max_redirects": 5, + "chunk_size": ARCHIVE_DOWNLOAD_CHUNK_BYTES, + } + assert remote_client.calls[0]["max_bytes"] == MAX_ARCHIVE_DOWNLOAD_BYTES + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +def test_download_request_requires_a_strict_bounded_grant(): + with pytest.raises(ValidationError): + DownloadToDiskRequest.model_validate( + {"url": APPROVED_URL, "destination_path": "/tmp/download.evt"} + ) + with pytest.raises(ValidationError): + DownloadToDiskRequest.model_validate( + { + "url": APPROVED_URL, + "destination_path": "/tmp/download.evt", + "destination_grant": "grant", + "unexpected": True, + } + ) + with pytest.raises(ValidationError): + DownloadToDiskRequest.model_validate( + { + "url": APPROVED_URL, + "destination_path": "/tmp/download.evt", + "destination_grant": "x" * 513, + } + ) + + +@pytest.mark.asyncio +async def test_route_does_not_reflect_missing_or_malformed_grants( + tmp_path, state_manager +): + destination = tmp_path / "private-destination.evt" + malformed = "malformed-secret-grant" + app = create_app( + session_secret=SESSION_SECRET, + file_grant_secret=TEST_SECRET, + ) + app.state.state_manager = state_manager + app.state.performance_monitor = None + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + headers={BACKEND_SESSION_HEADER: SESSION_SECRET}, + ) as client: + missing = await client.post( + DOWNLOAD_ROUTE, + json={ + "url": APPROVED_URL, + "destination_path": str(destination), + }, + ) + rejected = await client.post( + DOWNLOAD_ROUTE, + json={ + "url": APPROVED_URL, + "destination_path": str(destination), + "destination_grant": malformed, + }, + ) + + assert missing.status_code == 422 + assert str(destination) not in missing.text + assert rejected.status_code == 200 + assert "authorization is invalid" in rejected.text + assert str(destination) not in rejected.text + assert malformed not in rejected.text + assert not destination.exists() + + +@pytest.mark.asyncio +async def test_route_disconnect_closes_the_resource_owning_generator(): + cleanup_completed = False + captured_cancellation_check = None + + class DisconnectingRequest: + async def is_disconnected(self): + return True + + class ResourceOwningService: + async def download_file_to_disk(self, **kwargs): + nonlocal cleanup_completed, captured_cancellation_check + captured_cancellation_check = kwargs["cancellation_check"] + try: + yield { + "type": "progress", + "bytes_downloaded": 1, + "total_bytes": 2, + "percent": 50.0, + } + finally: + cleanup_completed = True + + request = DisconnectingRequest() + response = await download_to_disk( + DownloadToDiskRequest( + url=APPROVED_URL, + destination_path="/native/download.evt", + destination_grant="synthetic-grant", + ), + request, + ResourceOwningService(), + ) + + assert [chunk async for chunk in response.body_iterator] == [] + assert cleanup_completed is True + assert captured_cancellation_check == request.is_disconnected + + +@pytest.mark.asyncio +async def test_disconnect_after_publication_suppresses_sse_but_preserves_commit( + tmp_path, + state_manager, + monkeypatch, +): + body = b"verified bytes committed before the late disconnect" + destination = tmp_path / "committed.evt" + remote_client = FakeRemoteClient(FakeRemoteStream([body], content_length=len(body))) + install_remote_client(monkeypatch, remote_client) + + class DisconnectAfterPublishRequest: + async def is_disconnected(self): + return destination.exists() + + response = await download_to_disk( + DownloadToDiskRequest( + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(destination), + ), + DisconnectAfterPublishRequest(), + ArchiveService(state_manager), + ) + + chunks = [chunk async for chunk in response.body_iterator] + assert len(chunks) == 1 + assert '"type": "progress"' in chunks[0] + assert '"type": "complete"' not in chunks[0] + assert destination.read_bytes() == body + assert list(tmp_path.glob(".stingray-export-*")) == [] + + replay = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + assert replay == [ + { + "type": "error", + "error": "A file already exists at the selected destination", + } + ] + assert len(remote_client.calls) == 1 + + +@pytest.mark.asyncio +async def test_early_route_disconnect_cleans_staging_and_releases_claim( + tmp_path, + state_manager, + monkeypatch, +): + body = b"private bytes that must not survive an early disconnect" + destination = tmp_path / "cancelled.evt" + remote_client = FakeRemoteClient(FakeRemoteStream([body], content_length=len(body))) + install_remote_client(monkeypatch, remote_client) + grant = write_grant(destination) + + class DisconnectedRequest: + async def is_disconnected(self): + return True + + response = await download_to_disk( + DownloadToDiskRequest( + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=grant, + ), + DisconnectedRequest(), + ArchiveService(state_manager), + ) + + assert [chunk async for chunk in response.body_iterator] == [] + assert not destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + retry = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=grant, + ) + assert retry[-1]["type"] == "complete" + assert destination.read_bytes() == body + + +@pytest.mark.asyncio +@pytest.mark.parametrize("grant", ["malformed", "v2.1.2.3.bad"]) +async def test_malformed_grant_never_starts_remote_io( + grant, tmp_path, state_manager, monkeypatch +): + destination = tmp_path / "download.evt" + + def forbidden_client(*_args, **_kwargs): + raise AssertionError("remote client must not be constructed") + + monkeypatch.setattr(archive_module, "RemoteSourceClient", forbidden_client) + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=grant, + ) + + assert events == [ + { + "type": "error", + "error": ( + "The save authorization is invalid or expired; choose the " + "destination again" + ), + } + ] + assert not destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@pytest.mark.asyncio +async def test_grant_for_another_destination_is_rejected( + tmp_path, state_manager, monkeypatch +): + destination = tmp_path / "download.evt" + other_destination = tmp_path / "other.evt" + monkeypatch.setattr( + archive_module, + "RemoteSourceClient", + lambda *_args, **_kwargs: pytest.fail("remote I/O must not start"), + ) + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(other_destination), + ) + + assert events[0]["type"] == "error" + assert "authorization" in events[0]["error"] + assert not destination.exists() + + +@pytest.mark.asyncio +async def test_expired_destination_grant_is_rejected_before_remote_io( + tmp_path, state_manager, monkeypatch +): + destination = tmp_path / "download.evt" + issued = issue_file_grant(str(destination), access="write") + monkeypatch.setattr( + grant_module.time, + "time", + lambda: issued.expires_at + FILE_GRANT_TTL_SECONDS + 1, + ) + monkeypatch.setattr( + archive_module, + "RemoteSourceClient", + lambda *_args, **_kwargs: pytest.fail("remote I/O must not start"), + ) + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=issued.grant, + ) + + assert events[-1]["type"] == "error" + assert "authorization" in events[-1]["error"] + assert not destination.exists() + + +@pytest.mark.asyncio +async def test_grant_expiry_after_admission_does_not_abort_long_download( + tmp_path, state_manager, monkeypatch +): + destination = tmp_path / "long-download.evt" + issued = issue_file_grant(str(destination), access="write") + body = b"download admitted while the write grant was fresh" + + class GrantExpiringRemoteStream(FakeRemoteStream): + async def aiter_bytes(self): + yield body + monkeypatch.setattr( + grant_module.time, + "time", + lambda: issued.expires_at + 1, + ) + + remote_client = FakeRemoteClient( + GrantExpiringRemoteStream([body], content_length=len(body)) + ) + install_remote_client(monkeypatch, remote_client) + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=issued.grant, + ) + + assert events[-1]["type"] == "complete" + assert events[-1]["sha256"] == hashlib.sha256(body).hexdigest() + assert grant_module.time.time() > issued.expires_at + assert destination.read_bytes() == body + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@pytest.mark.asyncio +async def test_read_grant_cannot_authorize_an_archive_destination( + tmp_path, state_manager, monkeypatch +): + destination = tmp_path / "selected-input.evt" + destination.write_bytes(b"existing input") + read_grant = issue_file_grant(str(destination), access="read").grant + monkeypatch.setattr( + archive_module, + "RemoteSourceClient", + lambda *_args, **_kwargs: pytest.fail("remote I/O must not start"), + ) + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=read_grant, + ) + + assert events[-1]["type"] == "error" + assert "authorization" in events[-1]["error"] + assert destination.read_bytes() == b"existing input" + + +@pytest.mark.asyncio +async def test_existing_destination_is_never_overwritten( + tmp_path, state_manager, monkeypatch +): + destination = tmp_path / "download.evt" + destination.write_bytes(b"user-owned") + monkeypatch.setattr( + archive_module, + "RemoteSourceClient", + lambda *_args, **_kwargs: pytest.fail("remote I/O must not start"), + ) + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + + assert events == [ + {"type": "error", "error": "A file already exists at the selected destination"} + ] + assert destination.read_bytes() == b"user-owned" + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure, expected_error", + [ + (RemoteSourceCancelled("cancelled with /private/path"), "Download cancelled"), + ( + RemoteSourceError("failed at https://example.invalid/?secret=leaked"), + "The HEASARC download failed validation", + ), + ], +) +async def test_cancel_or_remote_failure_cleans_only_private_staging( + failure, expected_error, tmp_path, state_manager, monkeypatch +): + remote_client = FakeRemoteClient( + FakeRemoteStream([b"partial"], content_length=20, failure=failure) + ) + install_remote_client(monkeypatch, remote_client) + destination = tmp_path / "download.evt" + unrelated = tmp_path / "unrelated.txt" + unrelated.write_bytes(b"keep") + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL + "?token=not-for-events", + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + + assert events[-1] == {"type": "error", "error": expected_error} + assert all(event["type"] == "progress" for event in events[:-1]) + assert not destination.exists() + assert unrelated.read_bytes() == b"keep" + assert list(tmp_path.glob(".stingray-export-*")) == [] + serialized = json.dumps(events) + assert "private" not in serialized + assert "not-for-events" not in serialized + assert "leaked" not in serialized + + +@pytest.mark.asyncio +async def test_content_length_mismatch_never_publishes( + tmp_path, state_manager, monkeypatch +): + remote_client = FakeRemoteClient(FakeRemoteStream([b"short"], content_length=10)) + install_remote_client(monkeypatch, remote_client) + destination = tmp_path / "download.evt" + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + + assert events[-1] == { + "type": "error", + "error": "The HEASARC download failed validation", + } + assert not destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@pytest.mark.asyncio +async def test_partial_content_response_is_never_published( + tmp_path, state_manager, monkeypatch +): + remote_client = FakeRemoteClient( + FakeRemoteStream( + [b"internally consistent partial bytes"], + content_length=35, + status_code=206, + ) + ) + install_remote_client(monkeypatch, remote_client) + destination = tmp_path / "download.evt" + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + + assert events == [ + { + "type": "error", + "error": "The HEASARC download failed validation", + } + ] + assert not destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@pytest.mark.asyncio +async def test_replayed_grant_cannot_start_parallel_destination_download( + tmp_path, state_manager, monkeypatch +): + remote_client = FakeRemoteClient( + FakeRemoteStream([b"private in-progress bytes"], content_length=25) + ) + install_remote_client(monkeypatch, remote_client) + destination = tmp_path / "download.evt" + grant = write_grant(destination) + first = ArchiveService(state_manager).download_file_to_disk( + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=grant, + ) + + try: + assert (await anext(first))["type"] == "progress" + replay_events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=grant, + ) + + assert replay_events == [ + { + "type": "error", + "error": "A download is already using the selected destination", + } + ] + assert len(remote_client.calls) == 1 + finally: + await first.aclose() + assert not destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@pytest.mark.asyncio +async def test_process_wide_archive_download_capacity_is_fail_fast_and_bounded( + tmp_path, state_manager, monkeypatch +): + assert MAX_CONCURRENT_ARCHIVE_DOWNLOADS == 2 + assert ( + MAX_AGGREGATE_ARCHIVE_DOWNLOAD_BYTES + == MAX_CONCURRENT_ARCHIVE_DOWNLOADS * MAX_ARCHIVE_DOWNLOAD_BYTES + ) + remote_client = FakeRemoteClient( + FakeRemoteStream([b"private in-progress bytes"], content_length=25) + ) + install_remote_client(monkeypatch, remote_client) + active_downloads = [] + rejected_destination = tmp_path / "over-capacity.evt" + try: + for index in range(MAX_CONCURRENT_ARCHIVE_DOWNLOADS): + destination = tmp_path / f"active-{index}.evt" + stream = ArchiveService(state_manager).download_file_to_disk( + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + assert (await anext(stream))["type"] == "progress" + active_downloads.append(stream) + + rejected = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(rejected_destination), + destination_grant=write_grant(rejected_destination), + ) + + assert rejected == [ + { + "type": "error", + "error": "Too many archive downloads are already active", + } + ] + assert len(remote_client.calls) == MAX_CONCURRENT_ARCHIVE_DOWNLOADS + finally: + for stream in active_downloads: + await stream.aclose() + assert not rejected_destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@pytest.mark.asyncio +async def test_publication_context_closes_before_terminal_success_is_yielded( + tmp_path, state_manager, monkeypatch +): + class TrackedPublication: + def __init__(self): + self.path = tmp_path / "download.evt" + self.filename = "download.evt" + self.data = b"" + self.context_closed = False + + def revalidate(self, _message): + return None + + def assert_destination_available(self): + return None + + def reserve_staging(self, _extension): + return None + + @contextmanager + def open_writer(self, _mode, *, encoding=None): + del encoding + stream = io.BytesIO() + yield stream + self.data = stream.getvalue() + + @contextmanager + def open_reader(self, _mode, *, encoding=None): + del encoding + yield io.BytesIO(self.data) + + def verified_size(self): + return len(self.data) + + def publish(self): + return [] + + publication = TrackedPublication() + + @contextmanager + def tracked_publication(*_args, **_kwargs): + try: + yield publication + finally: + publication.context_closed = True + + monkeypatch.setattr(archive_module, "open_secure_publication", tracked_publication) + remote_client = FakeRemoteClient( + FakeRemoteStream([b"verified bytes"], content_length=14) + ) + install_remote_client(monkeypatch, remote_client) + stream = ArchiveService(state_manager).download_file_to_disk( + url=APPROVED_URL, + destination_path=str(publication.path), + destination_grant="synthetic-grant", + ) + + assert (await anext(stream))["type"] == "progress" + complete = await anext(stream) + + assert complete["type"] == "complete" + assert publication.context_closed is True + with pytest.raises(StopAsyncIteration): + await anext(stream) + + +@pytest.mark.asyncio +async def test_cancellation_interrupts_slow_reopen_verification_and_joins_worker( + tmp_path, state_manager, monkeypatch +): + reader_started = threading.Event() + body = b"x" * 100 + + class SlowReader: + def __init__(self): + self.offset = 0 + self.read_count = 0 + + def read(self, _maximum): + reader_started.set() + time.sleep(0.02) + self.read_count += 1 + if self.offset >= len(body): + return b"" + chunk = body[self.offset : self.offset + 1] + self.offset += 1 + return chunk + + slow_reader = SlowReader() + + class CancellablePublication: + def __init__(self): + self.path = tmp_path / "cancelled-verification.evt" + self.filename = self.path.name + self.data = b"" + self.context_closed = False + self.published = False + + def revalidate(self, _message): + return None + + def assert_destination_available(self): + return None + + def reserve_staging(self, _extension): + return None + + @contextmanager + def open_writer(self, _mode, *, encoding=None): + del encoding + stream = io.BytesIO() + yield stream + self.data = stream.getvalue() + + @contextmanager + def open_reader(self, _mode, *, encoding=None): + del encoding + yield slow_reader + + def verified_size(self): + return len(self.data) + + def publish(self): + self.published = True + return [] + + publication = CancellablePublication() + + @contextmanager + def tracked_publication(*_args, **_kwargs): + try: + yield publication + finally: + publication.context_closed = True + + monkeypatch.setattr(archive_module, "open_secure_publication", tracked_publication) + remote_client = FakeRemoteClient(FakeRemoteStream([body], content_length=len(body))) + install_remote_client(monkeypatch, remote_client) + + async def cancellation_requested(): + return reader_started.is_set() + + started_at = time.monotonic() + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(publication.path), + destination_grant="synthetic-grant", + cancellation_check=cancellation_requested, + ) + elapsed = time.monotonic() - started_at + + assert events[-1] == {"type": "error", "error": "Download cancelled"} + assert publication.published is False + assert publication.context_closed is True + assert slow_reader.read_count < len(body) + assert elapsed < 1.0 + + +@pytest.mark.asyncio +async def test_cancellation_joins_slow_descriptor_owning_writer_before_teardown( + tmp_path, + state_manager, + monkeypatch, +): + writer_started = threading.Event() + writer_closed = threading.Event() + body = b"queued private bytes" + + class SlowWriter(io.BytesIO): + def write(self, chunk): + writer_started.set() + time.sleep(0.05) + return super().write(chunk) + + class CancellablePublication: + def __init__(self): + self.path = tmp_path / "cancelled-write.evt" + self.filename = self.path.name + self.context_closed = False + self.published = False + + def revalidate(self, _message): + return None + + def assert_destination_available(self): + return None + + def reserve_staging(self, _extension): + return None + + @contextmanager + def open_writer(self, _mode, *, encoding=None): + del encoding + try: + yield SlowWriter() + finally: + writer_closed.set() + + def publish(self): + self.published = True + return [] + + publication = CancellablePublication() + + @contextmanager + def tracked_publication(*_args, **_kwargs): + try: + yield publication + finally: + publication.context_closed = True + + monkeypatch.setattr(archive_module, "open_secure_publication", tracked_publication) + remote_client = FakeRemoteClient(FakeRemoteStream([body], content_length=len(body))) + install_remote_client(monkeypatch, remote_client) + + async def cancellation_requested(): + return writer_started.is_set() + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(publication.path), + destination_grant="synthetic-grant", + cancellation_check=cancellation_requested, + ) + + assert events[-1] == {"type": "error", "error": "Download cancelled"} + assert writer_started.is_set() + assert writer_closed.is_set() + assert publication.context_closed is True + assert publication.published is False + + +@pytest.mark.asyncio +async def test_cancellation_check_after_transfer_prevents_publication( + tmp_path, state_manager, monkeypatch +): + remote_client = FakeRemoteClient( + FakeRemoteStream([b"complete private bytes"], content_length=22) + ) + install_remote_client(monkeypatch, remote_client) + destination = tmp_path / "download.evt" + + def cancelled() -> bool: + return True + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(destination), + cancellation_check=cancelled, + ) + + assert events[-1] == {"type": "error", "error": "Download cancelled"} + assert remote_client.calls[0]["cancellation_check"] is cancelled + assert not destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@pytest.mark.asyncio +async def test_control_character_filename_is_rejected_before_remote_io( + tmp_path, state_manager, monkeypatch +): + destination = tmp_path / "unsafe\nname.evt" + monkeypatch.setattr( + archive_module, + "RemoteSourceClient", + lambda *_args, **_kwargs: pytest.fail("remote I/O must not start"), + ) + + events = await collect_download( + ArchiveService(state_manager), + url=APPROVED_URL, + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + + assert events == [ + {"type": "error", "error": "The download could not be published safely"} + ] + assert not destination.exists() + + +@pytest.mark.asyncio +async def test_non_heasarc_url_is_rejected_without_leaking_query( + tmp_path, state_manager +): + destination = tmp_path / "download.evt" + secret = "TOP-SECRET-QUERY" + + events = await collect_download( + ArchiveService(state_manager), + url=f"https://evil.example/FTP/file.evt?token={secret}", + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + + assert events == [ + { + "type": "error", + "error": "The selected URL is not an approved HEASARC archive download", + } + ] + assert secret not in json.dumps(events) + assert not destination.exists() + + +@pytest.mark.asyncio +async def test_http_error_exposes_only_status_not_remote_path_or_query( + tmp_path, state_manager, monkeypatch +): + secret = "TOP-SECRET-QUERY" + remote_client = FakeRemoteClient( + FakeRemoteStream( + [], + failure=RemoteSourceHTTPError( + 403, + "https://heasarc.gsfc.nasa.gov/FTP/private/file.evt", + ), + ) + ) + + @asynccontextmanager + async def failing_stream(*_args, **_kwargs): + raise RemoteSourceHTTPError( + 403, + "https://heasarc.gsfc.nasa.gov/FTP/private/file.evt", + ) + yield # pragma: no cover + + remote_client.stream = failing_stream + install_remote_client(monkeypatch, remote_client) + destination = tmp_path / "download.evt" + + events = await collect_download( + ArchiveService(state_manager), + url=f"{APPROVED_URL}?token={secret}", + destination_path=str(destination), + destination_grant=write_grant(destination), + ) + + assert events == [ + {"type": "error", "error": "The HEASARC server returned HTTP 403"} + ] + serialized = json.dumps(events) + assert secret not in serialized + assert "private/file" not in serialized + assert str(destination) not in serialized diff --git a/python-backend/tests/test_archive_request_models.py b/python-backend/tests/test_archive_request_models.py new file mode 100644 index 0000000..8b72433 --- /dev/null +++ b/python-backend/tests/test_archive_request_models.py @@ -0,0 +1,167 @@ +"""Strict bounded model tests for archive search requests.""" + +from __future__ import annotations + +from typing import get_args + +import pytest +from pydantic import ValidationError +from routes.archive_routes import ( + ArchiveMission, + SearchByCoordinatesRequest, + SearchByNameRequest, + SearchByObsidRequest, + _iso_dates_to_mjd_range, +) +from services.archive_service import SUPPORTED_CATALOGS + + +def test_strict_mission_literal_matches_supported_catalogs(): + assert set(get_args(ArchiveMission)) == set(SUPPORTED_CATALOGS) + + +def test_valid_archive_search_requests_keep_supported_behavior(): + by_name = SearchByNameRequest.model_validate( + { + "source_name": "Cyg X-1", + "mission": "NICER", + "radius": 0.5, + "max_results": 100, + "min_exposure": 0.0, + "start_date": "2024-02-29", + "end_date": "2024-03-01", + } + ) + by_coordinates = SearchByCoordinatesRequest.model_validate( + { + "ra": 0.0, + "dec": -90.0, + "mission": "NuSTAR", + "radius": 10.0, + "max_results": 1_000, + "min_exposure": 1_000_000_000.0, + } + ) + by_obsid = SearchByObsidRequest.model_validate( + {"obsid": "4010080142-A", "mission": "XMM-Newton"} + ) + + assert by_name.source_name == "Cyg X-1" + assert by_coordinates.ra == 0.0 + assert by_coordinates.dec == -90.0 + assert by_obsid.obsid == "4010080142-A" + assert _iso_dates_to_mjd_range("2024-02-29", "2024-03-01") is not None + + +@pytest.mark.parametrize( + "model,payload", + [ + ( + SearchByNameRequest, + {"source_name": "Crab", "mission": "NICER", "extra": True}, + ), + (SearchByNameRequest, {"source_name": " ", "mission": "NICER"}), + ( + SearchByNameRequest, + {"source_name": "Crab\nsecret", "mission": "NICER"}, + ), + ( + SearchByNameRequest, + {"source_name": "x" * 257, "mission": "NICER"}, + ), + (SearchByNameRequest, {"source_name": "Crab", "mission": "Unknown"}), + ( + SearchByNameRequest, + {"source_name": "Crab", "mission": "NICER", "radius": 0.0}, + ), + ( + SearchByNameRequest, + {"source_name": "Crab", "mission": "NICER", "radius": 10.1}, + ), + ( + SearchByNameRequest, + {"source_name": "Crab", "mission": "NICER", "radius": float("inf")}, + ), + ( + SearchByNameRequest, + {"source_name": "Crab", "mission": "NICER", "max_results": 0}, + ), + ( + SearchByNameRequest, + {"source_name": "Crab", "mission": "NICER", "max_results": 1_001}, + ), + ( + SearchByNameRequest, + {"source_name": "Crab", "mission": "NICER", "max_results": "100"}, + ), + ( + SearchByNameRequest, + {"source_name": "Crab", "mission": "NICER", "min_exposure": -1.0}, + ), + ( + SearchByNameRequest, + { + "source_name": "Crab", + "mission": "NICER", + "start_date": "2024-02-30", + }, + ), + ( + SearchByNameRequest, + { + "source_name": "Crab", + "mission": "NICER", + "end_date": "2024-1-01", + }, + ), + ( + SearchByNameRequest, + { + "source_name": "Crab", + "mission": "NICER", + "start_date": "2024-03-01", + "end_date": "2024-02-29", + }, + ), + ( + SearchByCoordinatesRequest, + {"ra": -0.1, "dec": 0.0, "mission": "NICER"}, + ), + ( + SearchByCoordinatesRequest, + {"ra": 0.0, "dec": 90.1, "mission": "NICER"}, + ), + ( + SearchByCoordinatesRequest, + {"ra": float("nan"), "dec": 0.0, "mission": "NICER"}, + ), + ( + SearchByCoordinatesRequest, + {"ra": 0.0, "dec": 0.0, "mission": "NICER", "radius": "0.5"}, + ), + (SearchByObsidRequest, {"obsid": "../escape", "mission": "NICER"}), + (SearchByObsidRequest, {"obsid": "x" * 129, "mission": "NICER"}), + ( + SearchByObsidRequest, + {"obsid": "4010080142", "mission": "NICER", "extra": True}, + ), + ], +) +def test_archive_search_models_reject_unbounded_or_coercive_values(model, payload): + with pytest.raises(ValidationError): + model.model_validate(payload) + + +@pytest.mark.parametrize( + "start_date,end_date", + [ + ("not-a-date", None), + (None, "2024-02-30"), + ], +) +def test_date_conversion_fails_instead_of_silently_widening_range( + start_date, + end_date, +): + with pytest.raises(ValueError): + _iso_dates_to_mjd_range(start_date, end_date) diff --git a/python-backend/tests/test_archive_search_security.py b/python-backend/tests/test_archive_search_security.py new file mode 100644 index 0000000..06744e0 --- /dev/null +++ b/python-backend/tests/test_archive_search_security.py @@ -0,0 +1,317 @@ +"""Security regressions for bounded off-loop HEASARC searches.""" + +from __future__ import annotations + +import asyncio +import threading +import time +from types import SimpleNamespace + +import pytest +import routes.archive_routes as route_module +import services.archive_service as archive_module +from astropy import units as u +from astropy.coordinates import SkyCoord +from astropy.table import Table +from routes.archive_routes import ( + SearchByCoordinatesRequest, + SearchByNameRequest, + SearchByObsidRequest, + search_by_coordinates, + search_by_name, + search_by_obsid, +) +from services.archive_service import ( + ARCHIVE_SEARCH_TOTAL_SECONDS, + HEASARC_TAP_URL, + MAX_ARCHIVE_OBSID_REMOTE_ROWS, + MAX_ARCHIVE_SEARCH_REMOTE_ROWS, + ArchiveSearchBudget, + ArchiveService, +) + + +def result_envelope(message: str = "finished") -> dict[str, object]: + return { + "success": True, + "data": {"observations": []}, + "message": message, + "error": None, + } + + +class RouteSearchService: + def __init__(self, operation) -> None: + self.operation = operation + self.calls = 0 + + def create_result(self, success, data=None, message="", error=None, **kwargs): + return { + "success": success, + "data": data, + "message": message, + "error": error, + **kwargs, + } + + def search_by_name(self, **_kwargs): + self.calls += 1 + return self.operation() + + def search_by_coordinates(self, **_kwargs): + self.calls += 1 + return self.operation() + + def search_by_obsid(self, **_kwargs): + self.calls += 1 + return self.operation() + + +def coordinate_request() -> SearchByCoordinatesRequest: + return SearchByCoordinatesRequest(ra=83.633, dec=22.0145, mission="NICER") + + +class FakeSecureArchiveClient: + calls: list[dict[str, object]] = [] + sesame_body = b"%J 83.63240000 +22.01740000\n" + tap_body = b"
5001010204
" + + def __init__(self, policy, *, timeouts, max_redirects): + self.policy = policy + self.timeouts = timeouts + self.max_redirects = max_redirects + + async def fetch_text(self, url, *, max_bytes, **_kwargs): + self.calls.append( + { + "kind": "sesame", + "url": url, + "policy": self.policy.name, + "max_bytes": max_bytes, + "timeouts": self.timeouts, + "max_redirects": self.max_redirects, + } + ) + return self.sesame_body.decode(), SimpleNamespace(content_type="text/plain") + + async def post_form_bytes( + self, url, fields, *, max_bytes, max_request_bytes, **_kwargs + ): + self.calls.append( + { + "kind": "tap", + "url": url, + "fields": dict(fields), + "policy": self.policy.name, + "max_bytes": max_bytes, + "max_request_bytes": max_request_bytes, + "timeouts": self.timeouts, + "max_redirects": self.max_redirects, + } + ) + return self.tap_body, SimpleNamespace(content_type="text/xml") + + +def test_archive_searches_use_one_pinned_client_per_hop_and_bound_requests( + state_manager, monkeypatch +): + FakeSecureArchiveClient.calls = [] + monkeypatch.setattr(archive_module, "RemoteSourceClient", FakeSecureArchiveClient) + service = ArchiveService(state_manager) + + name_result = service.search_by_name("Crab", "NICER", max_results=100) + coordinate_result = service.search_by_coordinates(83.633, 22.0145, "NICER") + obsid_result = service.search_by_obsid("4010080142", "NICER") + + assert name_result["success"] is True + assert coordinate_result["success"] is True + assert obsid_result["success"] is True + assert [call["kind"] for call in FakeSecureArchiveClient.calls] == [ + "sesame", + "tap", + "tap", + "tap", + ] + sesame = FakeSecureArchiveClient.calls[0] + assert sesame["max_bytes"] == 64 * 1024 + assert sesame["max_redirects"] == 0 + for call in FakeSecureArchiveClient.calls[1:]: + assert call["url"] == HEASARC_TAP_URL + assert call["max_bytes"] == 32 * 1024**2 + assert call["max_request_bytes"] == 64 * 1024 + assert call["max_redirects"] == 0 + assert call["fields"]["MAXREC"] in { + MAX_ARCHIVE_SEARCH_REMOTE_ROWS, + MAX_ARCHIVE_OBSID_REMOTE_ROWS, + } + + +def test_region_and_obsid_adql_are_local_and_allowlisted(state_manager, monkeypatch): + service = ArchiveService(state_manager) + queries: list[tuple[str, int]] = [] + monkeypatch.setattr( + service, + "_query_tap", + lambda query, maxrec, budget: (queries.append((query, maxrec)) or Table()), + ) + coords = SkyCoord(83.633 * u.deg, 22.0145 * u.deg) + monkeypatch.setattr(service, "_resolve_source_name", lambda _name, _budget: coords) + + service.search_by_name("Crab", "NICER") + service.search_by_coordinates(83.633, 22.0145, "NICER") + service.search_by_obsid("4010080142", "NICER") + + assert queries[0][0] == ( + "SELECT * FROM nicermastr WHERE CONTAINS(" + "POINT('ICRS',83.633,22.0145),CIRCLE('ICRS',83.633,22.0145,0.5))=1" + ) + assert queries[0][1] == MAX_ARCHIVE_SEARCH_REMOTE_ROWS + assert queries[2] == ( + "SELECT * FROM nicermastr WHERE obsid = '4010080142'", + MAX_ARCHIVE_OBSID_REMOTE_ROWS, + ) + + +def test_source_name_parser_is_local_and_does_not_use_astropy_network(monkeypatch): + service = object.__new__(ArchiveService) + monkeypatch.setattr( + archive_module.SkyCoord, + "from_name", + lambda _name: (_ for _ in ()).throw(AssertionError("network resolver used")), + ) + + class SesameClient(FakeSecureArchiveClient): + async def fetch_text(self, url, *, max_bytes, **kwargs): + return "%J 83.6324 +22.0174", SimpleNamespace(content_type="text/plain") + + monkeypatch.setattr(archive_module, "RemoteSourceClient", SesameClient) + coords = service._resolve_source_name("Crab") + assert coords is not None + assert coords.ra.deg == pytest.approx(83.6324) + assert coords.dec.deg == pytest.approx(22.0174) + + +def test_archive_search_budget_is_shared_and_monotonic(): + budget = ArchiveSearchBudget.start() + assert 0 < budget.remaining() <= ARCHIVE_SEARCH_TOTAL_SECONDS + budget.deadline = time.monotonic() - 1 + with pytest.raises(archive_module.RemoteSourceTimeout): + budget.remaining() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "route,payload", + [ + (search_by_name, SearchByNameRequest(source_name="Crab", mission="NICER")), + (search_by_coordinates, coordinate_request()), + (search_by_obsid, SearchByObsidRequest(obsid="4010080142", mission="NICER")), + ], +) +async def test_every_archive_search_route_runs_on_a_worker_thread( + route, payload, monkeypatch +): + monkeypatch.setattr( + route_module, "ARCHIVE_SEARCH_CAPACITY", threading.BoundedSemaphore(2) + ) + event_loop_thread = threading.get_ident() + worker_threads: list[int] = [] + service = RouteSearchService( + lambda: (worker_threads.append(threading.get_ident()) or result_envelope()) + ) + + result = await route(payload, service) + assert result["success"] is True + assert worker_threads and worker_threads[0] != event_loop_thread + + +@pytest.mark.asyncio +async def test_archive_search_route_keeps_event_loop_responsive(monkeypatch): + monkeypatch.setattr( + route_module, "ARCHIVE_SEARCH_CAPACITY", threading.BoundedSemaphore(2) + ) + release = threading.Event() + service = RouteSearchService( + lambda: (release.wait(timeout=0.5) and result_envelope()) or result_envelope() + ) + + task = asyncio.create_task(search_by_coordinates(coordinate_request(), service)) + started_at = time.monotonic() + await asyncio.sleep(0.02) + heartbeat_elapsed = time.monotonic() - started_at + release.set() + result = await task + assert heartbeat_elapsed < 0.2 + assert result["success"] is True + + +@pytest.mark.asyncio +async def test_archive_search_capacity_is_process_global_and_fail_fast(monkeypatch): + capacity = 2 + monkeypatch.setattr( + route_module, "ARCHIVE_SEARCH_CAPACITY", threading.BoundedSemaphore(capacity) + ) + monkeypatch.setattr(route_module, "ARCHIVE_SEARCH_RESPONSE_TIMEOUT_SECONDS", 1.0) + release = threading.Event() + started = [threading.Event() for _index in range(capacity)] + + def blocking_operation(index): + started[index].set() + release.wait(timeout=1.0) + return result_envelope() + + services = [ + RouteSearchService(lambda index=index: blocking_operation(index)) + for index in range(capacity) + ] + active = [ + asyncio.create_task(search_by_coordinates(coordinate_request(), service)) + for service in services + ] + for event in started: + assert await asyncio.to_thread(event.wait, 0.5) + + rejected_service = RouteSearchService(lambda: result_envelope("unexpected")) + rejected = await search_by_coordinates(coordinate_request(), rejected_service) + assert rejected["message"] == "Too many archive searches are already active" + assert rejected_service.calls == 0 + + release.set() + assert all(result["success"] for result in await asyncio.gather(*active)) + + +@pytest.mark.asyncio +async def test_timed_out_search_keeps_capacity_until_worker_finishes(monkeypatch): + monkeypatch.setattr( + route_module, "ARCHIVE_SEARCH_CAPACITY", threading.BoundedSemaphore(1) + ) + monkeypatch.setattr(route_module, "ARCHIVE_SEARCH_RESPONSE_TIMEOUT_SECONDS", 0.02) + release = threading.Event() + started = threading.Event() + + def blocking_operation(): + started.set() + release.wait(timeout=1.0) + return result_envelope() + + timed_out = await search_by_coordinates( + coordinate_request(), RouteSearchService(blocking_operation) + ) + assert started.is_set() + assert timed_out["message"] == "The archive search timed out" + + rejected_service = RouteSearchService(lambda: result_envelope("unexpected")) + rejected = await search_by_coordinates(coordinate_request(), rejected_service) + assert rejected["message"] == "Too many archive searches are already active" + assert rejected_service.calls == 0 + + release.set() + deadline = time.monotonic() + 1.0 + while True: + retry = await search_by_coordinates( + coordinate_request(), RouteSearchService(result_envelope) + ) + if retry["success"]: + break + assert time.monotonic() < deadline + await asyncio.sleep(0.01) diff --git a/python-backend/tests/test_backend_security.py b/python-backend/tests/test_backend_security.py new file mode 100644 index 0000000..0fc365d --- /dev/null +++ b/python-backend/tests/test_backend_security.py @@ -0,0 +1,161 @@ +"""Security-boundary tests for the loopback FastAPI application.""" + +import httpx +import pytest + +from main import BACKEND_SESSION_HEADER, create_app + + +SESSION_SECRET = "a" * 64 +AUTH_HEADERS = {BACKEND_SESSION_HEADER: SESSION_SECRET} +DEV_ORIGIN = "http://localhost:5173" + + +def make_client(*, session_secret: str | None = SESSION_SECRET): + app = create_app(session_secret=session_secret) + return app, httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) + + +@pytest.mark.asyncio +async def test_health_is_public_and_exposes_no_application_state(): + _app, client = make_client() + async with client: + response = await client.get("/health") + + assert response.status_code == 200 + assert response.json() == { + "status": "healthy", + "service": "stingray-explorer-backend", + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("endpoint", "method"), + [ + ("/", "GET"), + ("/api/status", "GET"), + ("/api/shutdown", "POST"), + ("/api/logs/status", "GET"), + ("/api/logs/stream", "GET"), + ("/api/jobs/stream", "GET"), + ], +) +async def test_privileged_routes_reject_missing_session(endpoint, method): + _app, client = make_client() + async with client: + response = await client.request(method, endpoint) + + assert response.status_code == 401 + assert SESSION_SECRET not in response.text + + +@pytest.mark.asyncio +async def test_status_accepts_the_per_launch_session_from_electron_main(): + _app, client = make_client() + async with client: + response = await client.get("/api/status", headers=AUTH_HEADERS) + + assert response.status_code == 200 + assert "backend_resources" in response.json() + assert SESSION_SECRET not in response.text + + +@pytest.mark.asyncio +async def test_wrong_origin_and_wrong_session_fail_before_route_execution(): + app, client = make_client() + executions = 0 + + @app.post("/api/security-test-marker") + async def marker(): + nonlocal executions + executions += 1 + return {"executed": True} + + async with client: + wrong_origin = await client.post( + "/api/security-test-marker", + headers={**AUTH_HEADERS, "Origin": "https://attacker.example"}, + ) + wrong_session = await client.post( + "/api/security-test-marker", + headers={BACKEND_SESSION_HEADER: "b" * 64, "Origin": DEV_ORIGIN}, + ) + + assert wrong_origin.status_code == 403 + assert wrong_session.status_code == 401 + assert executions == 0 + + +@pytest.mark.asyncio +async def test_packaged_null_origin_requires_the_session_credential(): + _app, client = make_client() + async with client: + unauthorized = await client.get("/api/status", headers={"Origin": "null"}) + authorized = await client.get( + "/api/status", headers={**AUTH_HEADERS, "Origin": "null"} + ) + + assert unauthorized.status_code == 401 + assert authorized.status_code == 200 + + +@pytest.mark.asyncio +async def test_missing_backend_session_configuration_fails_closed(): + _app, client = make_client(session_secret="") + async with client: + response = await client.get("/api/status", headers=AUTH_HEADERS) + + assert response.status_code == 503 + assert SESSION_SECRET not in response.text + + +@pytest.mark.asyncio +async def test_allowed_preflight_is_explicit_and_needs_no_secret(): + _app, client = make_client() + async with client: + response = await client.options( + "/api/status", + headers={ + "Origin": DEV_ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": ("content-type,x-stingray-session"), + }, + ) + + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == DEV_ORIGIN + assert "POST" in response.headers["access-control-allow-methods"] + assert ( + "x-stingray-session" in response.headers["access-control-allow-headers"].lower() + ) + assert "access-control-allow-credentials" not in response.headers + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "headers", + [ + { + "Origin": "https://attacker.example", + "Access-Control-Request-Method": "POST", + }, + { + "Origin": DEV_ORIGIN, + "Access-Control-Request-Method": "PUT", + }, + { + "Origin": DEV_ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization", + }, + ], +) +async def test_disallowed_preflights_are_rejected(headers): + _app, client = make_client() + async with client: + response = await client.options("/api/status", headers=headers) + + assert response.status_code == 400 diff --git a/python-backend/tests/test_backend_startup.py b/python-backend/tests/test_backend_startup.py new file mode 100644 index 0000000..8984aec --- /dev/null +++ b/python-backend/tests/test_backend_startup.py @@ -0,0 +1,63 @@ +"""Regression tests for the listener-owned backend startup protocol.""" + +import socket + +import pytest +import uvicorn + +from main import bind_backend_socket, parse_requested_port, run_backend + + +@pytest.mark.parametrize("value", ["0", "65536", "+1", "01", " 8765", "8765"]) +def test_explicit_port_validation_is_canonical(value: str): + with pytest.raises(ValueError): + parse_requested_port(value) + + +@pytest.mark.parametrize( + ("value", "expected"), [("1", 1), ("8765", 8765), ("65535", 65535)] +) +def test_explicit_port_validation_accepts_only_valid_ports(value: str, expected: int): + assert parse_requested_port(value) == expected + + +def test_run_backend_announces_an_owned_listening_socket( + monkeypatch, capsys, unused_tcp_port +): + observed: dict[str, object] = {} + + def fake_run(server, *, sockets=None): + assert sockets is not None and len(sockets) == 1 + listener = sockets[0] + observed["listener"] = listener + observed["port"] = listener.getsockname()[1] + try: + assert listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN) == 1 + except OSError: + # macOS does not expose SO_ACCEPTCONN for AF_INET sockets; the + # competing-bind check below still proves the listener is owned. + pass + with pytest.raises(OSError): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as competing: + competing.bind(("127.0.0.1", listener.getsockname()[1])) + assert server.config.port == listener.getsockname()[1] + assert server.config.workers == 1 + + monkeypatch.setattr(uvicorn.Server, "run", fake_run) + run_backend(str(unused_tcp_port)) + + output = capsys.readouterr().out + assert output == f"BACKEND_PORT:{observed['port']}\n" + assert observed["port"] == unused_tcp_port + assert observed["listener"].fileno() == -1 + + +def test_bind_backend_socket_closes_failed_candidates(monkeypatch, unused_tcp_port): + blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + blocker.bind(("127.0.0.1", unused_tcp_port)) + blocker.listen() + try: + with pytest.raises(RuntimeError): + bind_backend_socket(requested_port=str(unused_tcp_port)) + finally: + blocker.close() diff --git a/python-backend/tests/test_correlation_service.py b/python-backend/tests/test_correlation_service.py new file mode 100644 index 0000000..a8884bf --- /dev/null +++ b/python-backend/tests/test_correlation_service.py @@ -0,0 +1,485 @@ +"""Tests for CorrelationService (auto/cross correlation). + +Every stingray behaviour pinned here was verified against stingray 2.2.10; the +comments record the measured values so a regression is obvious. +""" + +import json + +import httpx +import numpy as np +import pytest +from stingray import EventList +from stingray.crosscorrelation import CrossCorrelation + +from services.correlation_service import ( + MAX_BINS, + CorrelationService, + _shared_grid_lightcurves, +) +from services.state_manager import StateManager +from tests.backend_auth import ( + TEST_BACKEND_AUTH_HEADERS, + TEST_BACKEND_SESSION_SECRET, +) +from utils.performance_monitor import PerformanceMonitor + +DT = 0.05 +LENGTH = 64.0 +# A realistic mission-elapsed time: float64 spacing here is 1.49e-8 s, enough +# to visibly quantise a grid built by np.arange over absolute times. +MET = 8e7 + + +def pulse_times( + seed: int, + n_events: int = 30000, + length: float = LENGTH, + center: float = 30.0, + width: float = 2.0, + amp: float = 6.0, +) -> np.ndarray: + """Event times drawn from a rate with a single Gaussian pulse. + + A non-periodic modulation is used on purpose: a sinusoid makes the + correlation peak ambiguous modulo its period, which would make the + sign-convention and shared-grid assertions below unfalsifiable. + """ + rng = np.random.default_rng(seed) + out: list = [] + while len(out) < n_events: + candidates = rng.uniform(0.0, length, n_events) + rate = 1.0 + amp * np.exp(-((candidates - center) ** 2) / (2 * width**2)) + keep = candidates[rng.uniform(0.0, 1.0 + amp, n_events) < rate] + out.extend(keep.tolist()) + return np.sort(np.asarray(out[:n_events])) + + +@pytest.fixture() +def pulse_state(state_manager: StateManager) -> StateManager: + """A pulsed list, the same list delayed by 0.5 s, and a truncated copy.""" + times = pulse_times(7) + state_manager.add_event_data("ev_pulse", EventList(time=times, gti=[[0.0, LENGTH]])) + state_manager.add_event_data( + "ev_delayed", + EventList(time=times + 0.5, gti=[[0.5, LENGTH + 0.5]]), + ) + # Same photons, but the list only starts at t=16 s: identical signal on a + # different absolute-time footing. + truncated = times[times >= 16.0] + state_manager.add_event_data( + "ev_truncated", EventList(time=truncated, gti=[[16.0, LENGTH]]) + ) + return state_manager + + +# -------------------------------------------------------------------------- +# auto-correlation +# -------------------------------------------------------------------------- + + +def test_auto_correlation_serializes_with_zero_time_shift(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.auto_correlation("ev1", dt=DT) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert data["time_shift"] == 0.0 + assert data["mode"] == "same" + assert data["norm"] == "none" + assert data["dt"] == DT + assert data["n"] == len(data["corr"]) == len(data["time_lags"]) + assert isinstance(data["warnings"], list) + # 64 s / 0.05 s, minus the partial first/last bin the GTI trims. + assert 1275 <= data["n"] <= 1280 + + +def test_auto_correlation_full_mode_doubles_the_lag_axis(loaded_state): + svc = CorrelationService(loaded_state) + same = svc.auto_correlation("ev1", dt=DT, mode="same") + full = svc.auto_correlation("ev1", dt=DT, mode="full") + assert full["success"], full + assert full["data"]["mode"] == "full" + assert full["data"]["n"] == 2 * same["data"]["n"] - 1 + assert len(full["data"]["time_lags"]) == full["data"]["n"] + + +def test_auto_correlation_variance_norm_is_bounded(pulse_state): + # AutoCorrelation() drops `norm`, so the variance path must route through + # CrossCorrelation(lc, lc, norm='variance'). Measured peaks for this + # fixture: norm='none' -> 6.06e5 counts^2, norm='variance' -> 1.057. + svc = CorrelationService(pulse_state) + raw = svc.auto_correlation("ev_pulse", dt=DT, norm="none") + normed = svc.auto_correlation("ev_pulse", dt=DT, norm="variance") + assert normed["success"], normed + json.dumps(normed, allow_nan=False) + assert normed["data"]["norm"] == "variance" + assert normed["data"]["time_shift"] == 0.0 + peak_raw = max(v for v in raw["data"]["corr"] if v is not None) + peak_normed = max(v for v in normed["data"]["corr"] if v is not None) + assert peak_raw > 1000.0 + assert peak_normed < 2.0 + + +def test_auto_correlation_rejects_unknown_mode(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.auto_correlation("ev1", dt=DT, mode="valid") + assert not result["success"] + assert "mode" in result["message"] + assert "same" in result["message"] and "full" in result["message"] + + +def test_auto_correlation_rejects_unknown_norm(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.auto_correlation("ev1", dt=DT, norm="leahy") + assert not result["success"] + assert "norm" in result["message"] + + +def test_auto_correlation_rejects_unknown_event_list(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.auto_correlation("nope", dt=DT) + assert not result["success"] + assert "not found" in result["message"] + assert result["data"] is None + + +def test_auto_correlation_rejects_non_positive_dt(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.auto_correlation("ev1", dt=0.0) + assert not result["success"] + assert "dt" in result["message"] + + +def test_auto_correlation_rejects_dt_larger_than_the_data(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.auto_correlation("ev1", dt=40.0) + assert not result["success"] + assert "at least 3" in result["message"] + + +# -------------------------------------------------------------------------- +# cross-correlation +# -------------------------------------------------------------------------- + + +def test_cross_correlation_sign_convention_for_delayed_second_list(pulse_state): + # ev_delayed = ev_pulse + 0.5 s, so the FIRST list leads and time_shift < 0. + # Documented for the UI as: positive time_shift means the first list lags + # the second. + svc = CorrelationService(pulse_state) + result = svc.cross_correlation("ev_pulse", "ev_delayed", dt=DT) + assert result["success"], result + assert result["data"]["time_shift"] == pytest.approx(-0.5, abs=2 * DT) + # ...and the reversed pair flips the sign. + reversed_result = svc.cross_correlation("ev_delayed", "ev_pulse", dt=DT) + assert reversed_result["data"]["time_shift"] == pytest.approx(0.5, abs=2 * DT) + + +def test_cross_correlation_bins_both_lists_on_one_shared_grid(pulse_state): + # ev_truncated holds the same photons as ev_pulse but only from t=16 s on. + # Binning each list independently (EventList.to_lc) puts identical features + # 320 bins apart, and stingray correlates by POSITION, so the naive answer + # is time_shift = +16.0 s. A shared bin-edge grid gives the true 0.0 s. + ev_pulse = pulse_state.get_event_data("ev_pulse") + ev_truncated = pulse_state.get_event_data("ev_truncated") + naive = CrossCorrelation( + ev_pulse.to_lc(dt=DT), ev_truncated.to_lc(dt=DT), mode="same" + ) + assert abs(float(naive.time_shift)) > 10.0 # measured 16.0 + + svc = CorrelationService(pulse_state) + result = svc.cross_correlation("ev_pulse", "ev_truncated", dt=DT) + assert result["success"], result + assert result["data"]["time_shift"] == pytest.approx(0.0, abs=2 * DT) + # The shared grid spans only the common range (~48 s), not ev_pulse's 64 s. + common = float(ev_truncated.time[-1]) - float(ev_truncated.time[0]) + assert result["data"]["n"] * DT == pytest.approx(common, abs=2 * DT) + assert result["data"]["n"] < 0.8 * (LENGTH / DT) + + +def test_cross_correlation_warns_when_the_common_range_crops_the_data(pulse_state): + svc = CorrelationService(pulse_state) + result = svc.cross_correlation("ev_pulse", "ev_truncated", dt=DT) + assert result["success"], result + assert any("common time range" in w for w in result["data"]["warnings"]) + + +def test_cross_correlation_serializes_for_independent_lists(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.cross_correlation("ev1", "ev2", dt=DT) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert data["dt"] == DT + assert data["mode"] == "same" + assert data["norm"] == "none" + assert data["n"] == len(data["corr"]) == len(data["time_lags"]) + assert isinstance(data["warnings"], list) + + +def test_cross_correlation_variance_nan_nulls_the_time_shift(pulse_state): + # A flat 200-event list has a negative noise-subtracted variance + # (measured -0.89) while the pulsed list is strongly positive (+454), so + # sqrt(var1*var2) is NaN and stingray silently returns an all-NaN corr + # with a bogus time_shift (measured -0.5). + rng = np.random.default_rng(42) + flat = np.sort(rng.uniform(0.0, LENGTH, 200)) + pulse_state.add_event_data("ev_flat", EventList(time=flat, gti=[[0.0, LENGTH]])) + svc = CorrelationService(pulse_state) + result = svc.cross_correlation("ev_flat", "ev_pulse", dt=DT, norm="variance") + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert data["time_shift"] is None + assert all(v is None for v in data["corr"]) + assert any("NaN" in w for w in data["warnings"]) + assert any("negative" in w.lower() for w in data["warnings"]) + + +def test_cross_correlation_rejects_disjoint_event_lists(loaded_state): + rng = np.random.default_rng(8) + far = np.sort(rng.uniform(1000.0, 1064.0, 5000)) + loaded_state.add_event_data("ev_far", EventList(time=far, gti=[[1000.0, 1064.0]])) + svc = CorrelationService(loaded_state) + result = svc.cross_correlation("ev1", "ev_far", dt=DT) + assert not result["success"] + assert "no overlapping time range" in result["message"] + + +def test_cross_correlation_rejects_unknown_event_lists(loaded_state): + svc = CorrelationService(loaded_state) + first = svc.cross_correlation("nope", "ev2", dt=DT) + assert not first["success"] + assert "nope" in first["message"] and "not found" in first["message"] + second = svc.cross_correlation("ev1", "nope", dt=DT) + assert not second["success"] + assert "nope" in second["message"] and "not found" in second["message"] + + +def test_cross_correlation_rejects_unknown_mode(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.cross_correlation("ev1", "ev2", dt=DT, mode="valid") + assert not result["success"] + assert "mode" in result["message"] + + +def test_cross_correlation_rejects_unknown_norm(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.cross_correlation("ev1", "ev2", dt=DT, norm="frac") + assert not result["success"] + assert "norm" in result["message"] + + +def test_cross_correlation_rejects_dt_larger_than_the_overlap(loaded_state): + svc = CorrelationService(loaded_state) + result = svc.cross_correlation("ev1", "ev2", dt=30.0) + assert not result["success"] + assert "at least 3" in result["message"] + + +def test_cross_correlation_full_mode_lengths_agree(pulse_state): + svc = CorrelationService(pulse_state) + result = svc.cross_correlation("ev_pulse", "ev_delayed", dt=DT, mode="full") + assert result["success"], result + data = result["data"] + # stingray's cross= construction path desyncs corr/time_lags in 'full' + # mode; the two-Lightcurve path used here must not. + assert data["n"] == len(data["corr"]) == len(data["time_lags"]) + assert data["time_shift"] == pytest.approx(-0.5, abs=2 * DT) + + +def test_variance_norm_warns_when_noise_subtracted_variance_is_negative(loaded_state): + # Both conftest lists are Poisson-flat (measured noise-subtracted variance + # -3.72 at dt=0.05), so the product stays positive and no NaN appears -- + # but the normalisation is physically meaningless and must be flagged. + svc = CorrelationService(loaded_state) + result = svc.cross_correlation("ev1", "ev2", dt=DT, norm="variance") + assert result["success"], result + assert result["data"]["time_shift"] is not None + assert any("negative" in w.lower() for w in result["data"]["warnings"]) + + +# -------------------------------------------------------------------------- +# grid robustness (unsorted input, bin cap, dt fidelity, float precision) +# -------------------------------------------------------------------------- + + +def test_cross_correlation_uses_the_full_span_of_an_unsorted_event_list(pulse_state): + # EventList.read() does not forward skip_checks in stingray 2.2.10, so real + # unsorted event files reach the service with time[0]/time[-1] pointing at + # interior photons. Deriving the grid from them correlated a sliver of the + # data (measured 26 lags instead of ~1280) or inverted start/stop and faked + # a "no overlapping time range" rejection. + shuffled = np.random.default_rng(99).permutation(pulse_times(7)) + events = EventList(time=shuffled, gti=[[0.0, LENGTH]], skip_checks=True) + assert float(events.time[0]) > float(np.min(events.time)) + assert float(events.time[-1]) < float(np.max(events.time)) + pulse_state.add_event_data("ev_shuffled", events) + + svc = CorrelationService(pulse_state) + ordered = svc.cross_correlation("ev_pulse", "ev_delayed", dt=DT) + unordered = svc.cross_correlation("ev_shuffled", "ev_delayed", dt=DT) + assert unordered["success"], unordered + # np.histogram is order-independent, so the results must be identical. + assert unordered["data"]["n"] == ordered["data"]["n"] + assert unordered["data"]["corr"] == ordered["data"]["corr"] + assert unordered["data"]["time_shift"] == ordered["data"]["time_shift"] + # The grid spans the whole overlap (~63.5 s), not an interior sliver. + assert unordered["data"]["n"] * DT == pytest.approx(LENGTH - 0.5, abs=0.5) + + # The auto path used to derive its span the same way. + assert ( + svc.auto_correlation("ev_shuffled", dt=DT)["data"]["n"] + == svc.auto_correlation("ev_pulse", dt=DT)["data"]["n"] + ) + + +def test_correlation_rejects_a_dt_that_would_blow_up_the_bin_count(loaded_state): + # Nothing else bounds the grid: two 3 ks lists at dt=1e-3 already serialise + # ~93 MB of JSON and add ~750 MB of RSS, and a finer dt OOMs the backend. + svc = CorrelationService(loaded_state) + cases = ( + (svc.auto_correlation("ev1", dt=1e-5), "the span of 'ev1'"), + (svc.cross_correlation("ev1", "ev2", dt=1e-5), "the overlapping time range"), + ) + for result, what in cases: + assert not result["success"], result + assert result["data"] is None + message = result["message"] + assert message.startswith(f"dt (1e-05s) over {what} (63.") + assert "bins;" in message + assert ( + f"increase dt or shorten the range (the cap is {MAX_BINS:,} bins)" + in message + ) + # The reported count is the real one: ~64 s / 1e-5 s. + assert "6,39" in message + + # A large-but-workable grid is still accepted. + ok = svc.cross_correlation("ev1", "ev2", dt=1e-3) + assert ok["success"], ok + assert ok["data"]["n"] < MAX_BINS + + +def test_requested_dt_is_the_dt_used_by_both_endpoints(state_manager): + # EventList.dt is the instrument time resolution (TIMEDEL for real HEASARC + # files) and EventList.to_lc snaps dt to a multiple of it, so the auto page + # rendered half as many lags as the cross page for identical input while + # its message quoted the requested dt (640 lags "at dt=0.05s" over 64 s). + times = pulse_times(7) + for name in ("ev_res", "ev_res_copy"): + state_manager.add_event_data( + name, EventList(time=times, gti=[[0.0, LENGTH]], dt=0.1) + ) + # Pins the upstream behaviour being routed around. + assert float(state_manager.get_event_data("ev_res").to_lc(dt=DT).dt) == 0.1 + + svc = CorrelationService(state_manager) + auto = svc.auto_correlation("ev_res", dt=DT) + cross = svc.cross_correlation("ev_res", "ev_res_copy", dt=DT) + assert auto["success"], auto + assert cross["success"], cross + assert auto["data"]["dt"] == DT + assert cross["data"]["dt"] == DT + assert auto["data"]["n"] == cross["data"]["n"] + # The success message must describe the run that actually happened. + assert f"({auto['data']['n']} lags, dt={DT}s)" in auto["message"] + assert f"({cross['data']['n']} lags, dt={DT}s)" in cross["message"] + # Binning on our own grid bypasses stingray's beat-artefact guard, so the + # mismatch is surfaced instead of silently rewriting the user's dt. + for result in (auto, cross): + assert any("time resolution" in w for w in result["data"]["warnings"]) + + +def test_large_absolute_times_do_not_stretch_the_lag_axis(state_manager): + # np.arange over absolute MET quantises its step to the local float64 + # spacing: at MET 8e7 a requested dt of 0.01 s really stepped 0.010000005 s + # (measured), while stingray kept deriving time_lags from the declared dt. + times = pulse_times(7) + state_manager.add_event_data( + "ev_met", EventList(time=times + MET, gti=[[MET, MET + LENGTH]]) + ) + state_manager.add_event_data( + "ev_met_delayed", + EventList(time=times + MET + 0.5, gti=[[MET + 0.5, MET + LENGTH + 0.5]]), + ) + lc1, lc2, grid_start, grid_stop = _shared_grid_lightcurves( + state_manager.get_event_data("ev_met"), + state_manager.get_event_data("ev_met_delayed"), + 0.01, + ) + assert lc1.n == lc2.n + assert (grid_stop - grid_start) / lc1.n == pytest.approx(0.01, abs=1e-9) + + svc = CorrelationService(state_manager) + result = svc.cross_correlation("ev_met", "ev_met_delayed", dt=DT) + assert result["success"], result + assert result["data"]["time_shift"] == pytest.approx(-0.5, abs=2 * DT) + + +# -------------------------------------------------------------------------- +# routes +# -------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_correlation_routes_are_wired(loaded_state): + from main import create_app + + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + app.state.state_manager = loaded_state + app.state.performance_monitor = PerformanceMonitor() + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + auto = await client.post( + "/api/correlation/auto-correlation", + json={"event_list_name": "ev1", "dt": DT}, + ) + cross = await client.post( + "/api/correlation/cross-correlation", + json={ + "event_list_1_name": "ev1", + "event_list_2_name": "ev2", + "dt": DT, + "mode": "same", + "norm": "none", + }, + ) + + assert auto.status_code == 200 + assert auto.json()["success"], auto.json() + assert auto.json()["data"]["time_shift"] == 0.0 + assert cross.status_code == 200 + assert cross.json()["success"], cross.json() + assert "warnings" in cross.json()["data"] + + +@pytest.mark.asyncio +async def test_correlation_routes_soft_fail_with_http_200(loaded_state): + from main import create_app + + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + app.state.state_manager = loaded_state + app.state.performance_monitor = PerformanceMonitor() + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + response = await client.post( + "/api/correlation/auto-correlation", + json={"event_list_name": "missing", "dt": DT}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is False + assert body["data"] is None diff --git a/python-backend/tests/test_deadtime_service.py b/python-backend/tests/test_deadtime_service.py new file mode 100644 index 0000000..d457de0 --- /dev/null +++ b/python-backend/tests/test_deadtime_service.py @@ -0,0 +1,338 @@ +import json + +import httpx +import numpy as np +import pytest +from stingray import EventList + +from services.deadtime_service import DeadtimeService +from tests.backend_auth import ( + TEST_BACKEND_AUTH_HEADERS, + TEST_BACKEND_SESSION_SECRET, +) +from utils.performance_monitor import PerformanceMonitor + + +@pytest.fixture() +def deadtime_state(loaded_state): + """loaded_state plus a dead-time-affected event list built like report (b). + + 300 c/s incident over 200 s, non-paralyzable dead time 2.5 ms -> ~171 c/s + detected. The uncorrected Leahy power sits well below 2; the model + correction must bring it back to 2. + """ + rng = np.random.default_rng(42) + length = 200.0 + times = np.sort(rng.uniform(0.0, length, 60000)) + ev = EventList(time=times, gti=[[0.0, length]]).apply_deadtime(0.0025) + loaded_state.add_event_data("ev_dead", ev) + return loaded_state + + +def test_pds_correction_recovers_leahy_white_noise(deadtime_state): + svc = DeadtimeService(deadtime_state) + result = svc.calculate_pds_correction( + "ev_dead", dt=0.001, segment_size=20.0, dead_time=0.0025, limit_k=250 + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + + uncorrected = np.asarray(data["power_uncorrected"], dtype=float) + corrected = np.asarray(data["power_corrected"], dtype=float) + assert len(data["freq"]) == len(uncorrected) == len(corrected) + # Dead time suppresses the Leahy white-noise level (measured 1.657)... + assert uncorrected.mean() < 1.9 + # ...and the model correction restores it to 2 (measured 1.9982). + assert abs(corrected.mean() - 2.0) < 0.05 + assert data["n_segments"] == 10 # 200 s / 20 s segments + assert data["rate"] == pytest.approx(171.5, abs=1.0) + assert data["norm"] == "leahy" + assert data["warnings"] == [] + + +def test_pds_correction_missing_event_list_soft_fails(deadtime_state): + svc = DeadtimeService(deadtime_state) + result = svc.calculate_pds_correction( + "nope", dt=0.001, segment_size=20.0, dead_time=0.0025 + ) + assert not result["success"] + assert result["data"] is None + assert "not found" in result["message"] + + +def test_pds_correction_rejects_unphysical_rate_times_dead_time(deadtime_state): + # ev1 is 20000 events over 64 s = 312.5 c/s; 312.5 * 0.01 s = 3.12 >= 1. + svc = DeadtimeService(deadtime_state) + result = svc.calculate_pds_correction( + "ev1", dt=0.01, segment_size=8.0, dead_time=0.01 + ) + assert not result["success"] + assert "must be less than 1" in result["message"] + # The message names the actual numbers so the user can act on it. + assert "312.50" in result["message"] + assert "0.01" in result["message"] + assert "3.12" in result["message"] + + +def test_pds_correction_rejects_tiny_segment_size(deadtime_state): + svc = DeadtimeService(deadtime_state) + result = svc.calculate_pds_correction( + "ev1", dt=0.0625, segment_size=0.125, dead_time=0.0001 + ) + assert not result["success"] + assert "3x dt" in result["message"] + + +def test_pds_correction_rejects_segment_longer_than_exposure(deadtime_state): + svc = DeadtimeService(deadtime_state) + result = svc.calculate_pds_correction( + "ev1", dt=0.01, segment_size=100.0, dead_time=0.0001 + ) + assert not result["success"] + assert "longer than the total good-time exposure" in result["message"] + + +def test_pds_correction_rejects_non_positive_dead_time(deadtime_state): + svc = DeadtimeService(deadtime_state) + result = svc.calculate_pds_correction( + "ev1", dt=0.01, segment_size=8.0, dead_time=0.0 + ) + assert not result["success"] + assert "dead_time must be positive" in result["message"] + + +def test_pds_correction_background_rate_counts_towards_the_physical_limit( + deadtime_state, +): + # 312.5 c/s source is fine at td=1 ms (0.31), but +800 c/s background is not. + svc = DeadtimeService(deadtime_state) + ok = svc.calculate_pds_correction("ev1", dt=0.01, segment_size=8.0, dead_time=0.001) + assert ok["success"], ok + bad = svc.calculate_pds_correction( + "ev1", dt=0.01, segment_size=8.0, dead_time=0.001, background_rate=800.0 + ) + assert not bad["success"] + assert "must be less than 1" in bad["message"] + assert "800.00" in bad["message"] + + +def _register_deadtime_pair(state, seed_a=11, seed_b=12, length=64.0, n=20000): + """Two independent simultaneous streams, each dead-time filtered (report (c)).""" + for name, seed in (("det_a", seed_a), ("det_b", seed_b)): + rng = np.random.default_rng(seed) + times = np.sort(rng.uniform(0.0, length, n)) + ev = EventList(time=times, gti=[[0.0, length]]).apply_deadtime(0.0025) + state.add_event_data(name, ev) + + +def test_fad_correction_returns_finite_serializable_columns(loaded_state): + _register_deadtime_pair(loaded_state) + svc = DeadtimeService(loaded_state) + result = svc.calculate_fad_correction( + "det_a", "det_b", dt=1.0 / 512, segment_size=2.0 + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + + assert data["n_segments"] == 32 # 64 s / 2 s segments + assert data["norm"] == "frac" + n_freq = len(data["freq"]) + assert n_freq > 0 + for column in ("pds1", "pds2", "ptot", "cs"): + values = data[column] + assert len(values) == n_freq + assert all(v is not None for v in values) + assert np.isfinite(np.asarray(values, dtype=float)).all() + # cs is the magnitude of a complex column; the signed cospectrum comes too. + assert len(data["cs_real"]) == n_freq + assert all(v >= 0 for v in data["cs"]) + assert not any("fewer than 30" in w for w in data["warnings"]) + + +def test_fad_correction_warns_below_thirty_segments_but_still_computes(loaded_state): + _register_deadtime_pair(loaded_state) + svc = DeadtimeService(loaded_state) + result = svc.calculate_fad_correction( + "det_a", "det_b", dt=1.0 / 512, segment_size=8.0 + ) + assert result["success"], result + assert result["data"]["n_segments"] == 8 + assert any("fewer than 30" in w for w in result["data"]["warnings"]) + assert len(result["data"]["freq"]) > 0 + + +def test_fad_correction_does_not_mutate_stored_event_lists(loaded_state): + # FAD assigns the GTI intersection onto BOTH inputs; the service must shield + # the objects held in StateManager from that side effect. + rng = np.random.default_rng(21) + a = EventList(time=np.sort(rng.uniform(0.0, 64.0, 20000)), gti=[[0.0, 64.0]]) + b = EventList(time=np.sort(rng.uniform(8.0, 72.0, 20000)), gti=[[8.0, 72.0]]) + loaded_state.add_event_data("gti_a", a) + loaded_state.add_event_data("gti_b", b) + + svc = DeadtimeService(loaded_state) + result = svc.calculate_fad_correction( + "gti_a", "gti_b", dt=1.0 / 512, segment_size=1.0 + ) + assert result["success"], result + assert np.allclose(loaded_state.get_event_data("gti_a").gti, [[0.0, 64.0]]) + assert np.allclose(loaded_state.get_event_data("gti_b").gti, [[8.0, 72.0]]) + + +def test_fad_correction_missing_event_list_soft_fails(loaded_state): + svc = DeadtimeService(loaded_state) + result = svc.calculate_fad_correction("ev1", "nope", dt=0.01, segment_size=8.0) + assert not result["success"] + assert "not found" in result["message"] + + +def test_fad_correction_rejects_disjoint_event_lists(loaded_state): + rng = np.random.default_rng(8) + far = np.sort(rng.uniform(1000.0, 1064.0, 5000)) + loaded_state.add_event_data("ev_far", EventList(time=far, gti=[[1000.0, 1064.0]])) + svc = DeadtimeService(loaded_state) + result = svc.calculate_fad_correction("ev1", "ev_far", dt=0.01, segment_size=8.0) + assert not result["success"] + assert "no overlapping time range" in result["message"] + + +def test_fad_correction_rejects_tiny_segment_size(loaded_state): + svc = DeadtimeService(loaded_state) + result = svc.calculate_fad_correction("ev1", "ev2", dt=0.0625, segment_size=0.125) + assert not result["success"] + assert "3x dt" in result["message"] + + +def test_fad_correction_rejects_empty_event_list(loaded_state): + # `EventList(time=np.array([]))` normalizes `.time` to None, not an empty + # array, so the preflight must not call `len()` on it directly (that + # would raise a raw TypeError instead of a readable rejection). + loaded_state.add_event_data( + "ev_empty", EventList(time=np.array([]), gti=[[0.0, 64.0]]) + ) + svc = DeadtimeService(loaded_state) + + result = svc.calculate_fad_correction("ev1", "ev_empty", dt=0.01, segment_size=8.0) + assert not result["success"] + assert result["data"] is None + assert "contains no events" in result["message"] + + # Same rejection regardless of which argument position is empty. + result_swapped = svc.calculate_fad_correction( + "ev_empty", "ev1", dt=0.01, segment_size=8.0 + ) + assert not result_swapped["success"] + assert "contains no events" in result_swapped["message"] + + +def test_fad_correction_rejects_zero_exposure_event_list(loaded_state): + # A fully-screened observation: real event times, but a GTI array with + # zero rows (all good time removed by screening). Before the preflight + # this reached stingray's FAD() unguarded and raised a bare + # `IndexError: list index out of range`. + rng = np.random.default_rng(9) + times = np.sort(rng.uniform(0.0, 64.0, 500)) + loaded_state.add_event_data( + "ev_no_gti", EventList(time=times, gti=np.zeros((0, 2))) + ) + svc = DeadtimeService(loaded_state) + + result = svc.calculate_fad_correction("ev1", "ev_no_gti", dt=0.01, segment_size=8.0) + assert not result["success"] + assert result["data"] is None + assert "no good-time exposure" in result["message"] + assert "ev_no_gti" in result["message"] + + +def test_fad_correction_handles_nan_fad_delta_without_crashing(loaded_state): + # Force fad_delta to NaN the same way a real user would trigger it: pick + # the same event list for both "detectors" (e.g. testing with one loaded + # file, or accidentally selecting the same detector twice). The smoothed + # Fourier difference between two byte-identical inputs is exactly zero, + # so `average_diff / smooth_real**0.5` is 0/0 -> NaN -> fad_delta is NaN. + # Without a finite-or-None guard, that NaN reaches `json.dumps(..., + # allow_nan=False)` unguarded and turns a success:true result into an + # unhandled 500 at the JSON-encoding layer. + svc = DeadtimeService(loaded_state) + result = svc.calculate_fad_correction("ev1", "ev1", dt=1.0 / 512, segment_size=2.0) + + assert result["success"], result + json.dumps(result, allow_nan=False) # must not raise + data = result["data"] + + assert data["fad_delta"] is None + assert data["n_segments"] == 32 # 64 s / 2 s segments, above MIN_FAD_SEGMENTS + assert any( + "fad_delta" in w and "could not be computed" in w for w in data["warnings"] + ) + + +def _client(state): + """httpx client over the real app, wired to a pre-populated StateManager.""" + from main import create_app + + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + # ASGITransport does not run the lifespan; provide state manually. + app.state.state_manager = state + app.state.performance_monitor = PerformanceMonitor() + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) + + +@pytest.mark.asyncio +async def test_pds_correction_route_matches_the_request_contract(loaded_state): + async with _client(loaded_state) as client: + response = await client.post( + "/api/deadtime/pds-correction", + json={ + "event_list_name": "ev1", + "dt": 0.05, + "segment_size": 8.0, + "dead_time": 0.0001, + "background_rate": 0.0, + "limit_k": 200, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"], body + data = body["data"] + assert set( + [ + "freq", + "power_uncorrected", + "power_corrected", + "rate", + "n_segments", + "warnings", + ] + ) <= set(data) + assert data["n_segments"] == 8 # 64 s / 8 s segments + + +@pytest.mark.asyncio +async def test_fad_correction_route_matches_the_request_contract(loaded_state): + async with _client(loaded_state) as client: + response = await client.post( + "/api/deadtime/fad-correction", + json={ + "event_list_1_name": "ev1", + "event_list_2_name": "ev2", + "dt": 1.0 / 512, + "segment_size": 2.0, + "norm": "frac", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"], body + data = body["data"] + assert set(["freq", "pds1", "pds2", "ptot", "cs", "n_segments", "warnings"]) <= set( + data + ) + assert data["n_segments"] == 32 diff --git a/python-backend/tests/test_event_format_security.py b/python-backend/tests/test_event_format_security.py new file mode 100644 index 0000000..223c032 --- /dev/null +++ b/python-backend/tests/test_event_format_security.py @@ -0,0 +1,522 @@ +"""Regression coverage for the EventList format security boundary.""" + +from collections.abc import Callable +from typing import Any + +import httpx +import pytest +from pydantic import BaseModel, ValidationError + +import services.data_service as data_service_module +import services.job_manager as job_manager_module +from main import create_app +from models.event_formats import ( + CANONICAL_INPUT_EVENT_FORMATS, + INPUT_EVENT_FORMATS, + OUTPUT_EVENT_FORMATS, + require_input_event_format, + require_output_event_format, +) +from routes.data_routes import ( + BatchLoadEventListRequest, + GetFileMetadataRequest, + LoadByEventCountRequest, + LoadByTimeRangeRequest, + LoadEventListFromUrlRequest, + LoadEventListRequest, + SingleFileConfig, +) +from routes.job_routes import ( + FileConfig, + SubmitBatchJobRequest, + SubmitLoadJobRequest, + SubmitUrlJobRequest, +) +from services.data_service import DataService +from services.job_manager import JobManager +from services.state_manager import StateManager +from tests.backend_auth import ( + TEST_BACKEND_AUTH_HEADERS, + TEST_BACKEND_SESSION_SECRET, +) + + +UNSAFE_FORMATS = ("pickle", "unknown-format", "votable", "evt") +TEST_FILE_GRANT = "test-native-file-grant" + +RequestBuilder = Callable[[str], BaseModel] +REQUEST_BUILDERS: tuple[tuple[str, RequestBuilder], ...] = ( + ( + "data-load", + lambda fmt: LoadEventListRequest( + file_path="/selected/events.evt", + file_grant=TEST_FILE_GRANT, + name="events", + fmt=fmt, + ), + ), + ( + "data-load-url", + lambda fmt: LoadEventListFromUrlRequest( + url="https://example.test/events.evt", name="events", fmt=fmt + ), + ), + ( + "data-time-range", + lambda fmt: LoadByTimeRangeRequest( + file_path="/selected/events.evt", + file_grant=TEST_FILE_GRANT, + name="events", + start_time=0.0, + end_time=1.0, + fmt=fmt, + ), + ), + ( + "data-event-count", + lambda fmt: LoadByEventCountRequest( + file_path="/selected/events.evt", + file_grant=TEST_FILE_GRANT, + name="events", + fmt=fmt, + ), + ), + ( + "data-metadata", + lambda fmt: GetFileMetadataRequest( + file_path="/selected/events.evt", + file_grant=TEST_FILE_GRANT, + fmt=fmt, + ), + ), + ( + "data-batch-item", + lambda fmt: SingleFileConfig( + file_path="/selected/events.evt", + file_grant=TEST_FILE_GRANT, + name="events", + fmt=fmt, + ), + ), + ( + "data-batch-shared", + lambda fmt: BatchLoadEventListRequest( + files=[ + { + "file_path": "/selected/events.evt", + "file_grant": TEST_FILE_GRANT, + "name": "events", + } + ], + shared_fmt=fmt, + ), + ), + ( + "data-batch-per-file", + lambda fmt: BatchLoadEventListRequest( + files=[ + { + "file_path": "/selected/events.evt", + "file_grant": TEST_FILE_GRANT, + "name": "events", + "fmt": fmt, + } + ], + use_same_settings=False, + ), + ), + ( + "job-load", + lambda fmt: SubmitLoadJobRequest( + file_path="/selected/events.evt", + file_grant=TEST_FILE_GRANT, + name="events", + fmt=fmt, + ), + ), + ( + "job-batch-item", + lambda fmt: FileConfig( + file_path="/selected/events.evt", + file_grant=TEST_FILE_GRANT, + name="events", + fmt=fmt, + ), + ), + ( + "job-batch-shared", + lambda fmt: SubmitBatchJobRequest( + files=[ + { + "file_path": "/selected/events.evt", + "file_grant": TEST_FILE_GRANT, + "name": "events", + } + ], + shared_fmt=fmt, + ), + ), + ( + "job-batch-per-file", + lambda fmt: SubmitBatchJobRequest( + files=[ + { + "file_path": "/selected/events.evt", + "file_grant": TEST_FILE_GRANT, + "name": "events", + "fmt": fmt, + } + ], + use_same_settings=False, + ), + ), + ( + "job-url", + lambda fmt: SubmitUrlJobRequest( + url="https://example.test/events.evt", name="events", fmt=fmt + ), + ), +) + + +@pytest.mark.parametrize("unsafe_format", UNSAFE_FORMATS) +@pytest.mark.parametrize( + ("_case", "build_request"), + REQUEST_BUILDERS, + ids=[case for case, _builder in REQUEST_BUILDERS], +) +def test_request_models_reject_unsafe_input_formats( + _case: str, build_request: RequestBuilder, unsafe_format: str +) -> None: + with pytest.raises(ValidationError): + build_request(unsafe_format) + + +@pytest.mark.parametrize("fmt", sorted(INPUT_EVENT_FORMATS)) +@pytest.mark.parametrize( + ("_case", "build_request"), + REQUEST_BUILDERS, + ids=[case for case, _builder in REQUEST_BUILDERS], +) +def test_request_models_preserve_supported_input_formats( + _case: str, build_request: RequestBuilder, fmt: str +) -> None: + data = build_request(fmt).model_dump() + if "per-file" in _case: + actual_format = data["files"][0]["fmt"] + else: + actual_format = data["shared_fmt" if "shared" in _case else "fmt"] + assert actual_format == fmt + + +def test_batch_job_file_format_defaults_to_ogip_instead_of_none() -> None: + config = FileConfig( + file_path="/selected/events.evt", + file_grant=TEST_FILE_GRANT, + name="events", + ) + assert config.fmt == "ogip" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "endpoint", ["/api/data/load-url", "/api/data/load-url-stream"] +) +@pytest.mark.parametrize("unsafe_format", UNSAFE_FORMATS) +async def test_url_routes_reject_unsafe_format_before_network( + monkeypatch: pytest.MonkeyPatch, endpoint: str, unsafe_format: str +) -> None: + def forbidden_network(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("network access occurred before format validation") + + monkeypatch.setattr(DataService, "load_event_list_from_url", forbidden_network) + monkeypatch.setattr( + DataService, "load_event_list_from_url_stream", forbidden_network + ) + + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + app.state.state_manager = StateManager() + app.state.performance_monitor = None + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + response = await client.post( + endpoint, + json={ + "url": "https://example.test/events.evt", + "name": "events", + "fmt": unsafe_format, + }, + ) + + assert response.status_code == 422 + + +class _ForbiddenState: + def __getattr__(self, name: str) -> Any: + raise AssertionError(f"state accessed before format validation: {name}") + + +def _forbidden_io(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("filesystem or network access occurred before validation") + + +@pytest.fixture() +def data_service_without_io(monkeypatch: pytest.MonkeyPatch) -> DataService: + monkeypatch.setattr(httpx, "AsyncClient", _forbidden_io) + monkeypatch.setattr( + data_service_module.tempfile, "NamedTemporaryFile", _forbidden_io + ) + monkeypatch.setattr(data_service_module.os.path, "getsize", _forbidden_io) + monkeypatch.setattr(data_service_module.os, "makedirs", _forbidden_io) + monkeypatch.setattr(data_service_module.fits, "open", _forbidden_io) + monkeypatch.setattr(data_service_module, "FITSTimeseriesReader", _forbidden_io) + monkeypatch.setattr(data_service_module.EventList, "read", _forbidden_io) + monkeypatch.setattr(data_service_module, "ThreadPoolExecutor", _forbidden_io) + monkeypatch.setattr(DataService, "_detect_fits_file_type", _forbidden_io) + return DataService(state_manager=_ForbiddenState()) + + +ServiceCall = Callable[[DataService, str], Any] +DIRECT_SERVICE_CALLS: tuple[tuple[str, ServiceCall], ...] = ( + ( + "local-load", + lambda service, fmt: service.load_event_list( + "/selected/events.h5", "events", fmt=fmt + ), + ), + ( + "url-load", + lambda service, fmt: service.load_event_list_from_url( + "https://example.test/events.evt", "events", fmt=fmt + ), + ), + ( + "time-range", + lambda service, fmt: service.load_event_list_by_time_range( + "/selected/events.evt", "events", 0.0, 1.0, fmt=fmt + ), + ), + ( + "event-count", + lambda service, fmt: service.load_event_list_by_event_count( + "/selected/events.evt", "events", fmt=fmt + ), + ), + ( + "metadata", + lambda service, fmt: service.get_file_metadata("/selected/events.evt", fmt=fmt), + ), + ( + "memory-estimate", + lambda service, fmt: service._estimate_memory_usage(100, fmt=fmt), + ), + ( + "memory-safety", + lambda service, fmt: service._can_load_safely("/selected/events.evt", fmt=fmt), + ), + ( + "batch-shared", + lambda service, fmt: service.load_batch_event_lists( + [{"file_path": "/selected/events.evt", "name": "events"}], + shared_fmt=fmt, + ), + ), + ( + "batch-item", + lambda service, fmt: service.load_batch_event_lists( + [ + { + "file_path": "/selected/events.evt", + "name": "events", + "fmt": fmt, + } + ], + use_same_settings=False, + ), + ), + ( + "batch-ignored-item", + lambda service, fmt: service.load_batch_event_lists( + [ + { + "file_path": "/selected/events.evt", + "name": "events", + "fmt": fmt, + } + ], + shared_fmt="ogip", + ), + ), +) + + +@pytest.mark.parametrize("unsafe_format", UNSAFE_FORMATS) +@pytest.mark.parametrize( + ("_case", "invoke"), + DIRECT_SERVICE_CALLS, + ids=[case for case, _invoke in DIRECT_SERVICE_CALLS], +) +def test_direct_data_service_rejects_unsafe_input_before_io( + data_service_without_io: DataService, + _case: str, + invoke: ServiceCall, + unsafe_format: str, +) -> None: + with pytest.raises(ValueError, match="Unsupported input EventList format"): + invoke(data_service_without_io, unsafe_format) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("unsafe_format", UNSAFE_FORMATS) +@pytest.mark.parametrize("stream_kind", ["url", "batch-shared", "batch-item"]) +async def test_direct_streaming_services_reject_unsafe_input_before_io( + data_service_without_io: DataService, + unsafe_format: str, + stream_kind: str, +) -> None: + if stream_kind == "url": + stream = data_service_without_io.load_event_list_from_url_stream( + "https://example.test/events.evt", "events", fmt=unsafe_format + ) + elif stream_kind == "batch-shared": + stream = data_service_without_io.load_batch_event_lists_stream( + [{"file_path": "/selected/events.evt", "name": "events"}], + shared_fmt=unsafe_format, + ) + else: + stream = data_service_without_io.load_batch_event_lists_stream( + [ + { + "file_path": "/selected/events.evt", + "name": "events", + "fmt": unsafe_format, + } + ], + use_same_settings=False, + ) + + with pytest.raises(ValueError, match="Unsupported input EventList format"): + await anext(stream) + + +def test_legacy_data_save_surface_is_retired() -> None: + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + route_paths = { + (method, route.path) + for route in app.routes + for method in getattr(route, "methods", set()) + } + assert ("POST", "/api/data/save") not in route_paths + assert not hasattr(DataService, "save_event_list") + + +class _NoSubmitExecutor: + def __init__(self) -> None: + self.submissions = 0 + + def submit(self, *_args: Any, **_kwargs: Any) -> None: + self.submissions += 1 + raise AssertionError("job scheduled before format validation") + + def shutdown(self, *_args: Any, **_kwargs: Any) -> None: + return None + + +JobCall = Callable[[JobManager, str], Any] +DIRECT_JOB_CALLS: tuple[tuple[str, JobCall], ...] = ( + ( + "load", + lambda manager, fmt: manager.submit_load_job( + "/selected/events.evt", "events", fmt=fmt + ), + ), + ( + "url", + lambda manager, fmt: manager.submit_url_load_job( + "https://example.test/events.evt", "events", fmt=fmt + ), + ), + ( + "batch-shared", + lambda manager, fmt: manager.submit_batch_load_job( + [{"file_path": "/selected/events.evt", "name": "events"}], + shared_fmt=fmt, + ), + ), + ( + "batch-item", + lambda manager, fmt: manager.submit_batch_load_job( + [ + { + "file_path": "/selected/events.evt", + "name": "events", + "fmt": fmt, + } + ], + use_same_settings=False, + ), + ), + ( + "batch-ignored-item", + lambda manager, fmt: manager.submit_batch_load_job( + [ + { + "file_path": "/selected/events.evt", + "name": "events", + "fmt": fmt, + } + ], + shared_fmt="ogip", + ), + ), +) + + +@pytest.mark.parametrize("unsafe_format", UNSAFE_FORMATS) +@pytest.mark.parametrize( + ("_case", "invoke"), + DIRECT_JOB_CALLS, + ids=[case for case, _invoke in DIRECT_JOB_CALLS], +) +def test_job_manager_rejects_unsafe_formats_before_scheduling( + monkeypatch: pytest.MonkeyPatch, + _case: str, + invoke: JobCall, + unsafe_format: str, +) -> None: + executor = _NoSubmitExecutor() + monkeypatch.setattr( + job_manager_module, + "ThreadPoolExecutor", + lambda **_kwargs: executor, + ) + manager = JobManager(state_manager=object(), data_service=object()) + + with pytest.raises(ValueError, match="Unsupported input EventList format"): + invoke(manager, unsafe_format) + + assert executor.submissions == 0 + assert manager.list_jobs() == [] + assert manager._futures == {} + assert manager._update_queue.empty() + + +@pytest.mark.parametrize("fmt", sorted(INPUT_EVENT_FORMATS)) +def test_format_policy_preserves_supported_input_values(fmt: str) -> None: + expected = "ogip" if fmt == "hea" else fmt + assert require_input_event_format(fmt) == expected + + +def test_hea_is_only_a_compatibility_alias() -> None: + assert "hea" in INPUT_EVENT_FORMATS + assert "hea" not in CANONICAL_INPUT_EVENT_FORMATS + assert require_input_event_format("hea") == "ogip" + + +@pytest.mark.parametrize("fmt", sorted(OUTPUT_EVENT_FORMATS)) +def test_format_policy_preserves_supported_output_values(fmt: str) -> None: + assert require_output_event_format(fmt) == fmt diff --git a/python-backend/tests/test_gti_service.py b/python-backend/tests/test_gti_service.py new file mode 100644 index 0000000..b73ffa6 --- /dev/null +++ b/python-backend/tests/test_gti_service.py @@ -0,0 +1,850 @@ +"""Tests for the Utilities GTI service and its asynchronous route boundary.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import httpx +import numpy as np +import pytest +import services.gti_service as gti_module +import stingray +from fastapi import FastAPI +from routes import gti_routes +from services.gti_service import GTIService +from services.state_manager import StateManager +from stingray import EventList +from stingray.gti import ( + append_gtis, + create_gti_mask, + cross_two_gtis, + get_btis, + merge_gtis, + split_gtis_by_exposure, + time_intervals_from_gtis, +) + + +def _result_gtis(result: dict, key: str = "intervals") -> np.ndarray: + rows = result["data"][key] + if not rows: + return np.empty((0, 2)) + return np.asarray([[row["start"], row["stop"]] for row in rows]) + + +def _state_with_events() -> tuple[StateManager, EventList]: + state = StateManager() + events = EventList( + time=np.arange(0.5, 10.0, 1.0), + energy=np.linspace(1.0, 10.0, 10), + pi=np.arange(10), + gti=np.asarray([[0.0, 4.0], [6.0, 10.0]]), + dt=0, + mjdref=59000.0, + ) + state.add_event_data("source", events) + return state, events + + +def test_inspect_reports_all_intervals_and_scientific_summary(): + state, _ = _state_with_events() + + result = GTIService(state).inspect("source") + + assert result["success"] is True + assert result["data"]["gti_status"] == "available" + assert result["data"]["interval_count"] == 2 + assert result["data"]["lengths_s"] == [4.0, 4.0] + assert result["data"]["separations_s"] == [2.0] + assert result["data"]["total_exposure_s"] == 8.0 + assert result["data"]["overall_time_span_s"] == 10.0 + assert result["data"]["duty_cycle"] == pytest.approx(0.8) + assert result["data"]["mjdref"] == 59000.0 + assert result["data"]["provenance"]["stingray_version"] == stingray.__version__ + + +def test_inspect_handles_missing_and_empty_gtis_without_mutating_source(): + state = StateManager() + missing = EventList() + empty = EventList(time=[1.0, 2.0], gti=np.empty((0, 2))) + inferred = EventList(time=[1.0, 2.0, 3.0]) + state.add_event_data("missing", missing) + state.add_event_data("empty", empty) + state.add_event_data("inferred", inferred) + + missing_result = GTIService(state).inspect("missing") + empty_result = GTIService(state).inspect("empty") + inferred_result = GTIService(state).inspect("inferred") + + assert missing_result["data"]["gti_status"] == "missing" + assert empty_result["data"]["gti_status"] == "empty" + assert empty_result["data"]["total_exposure_s"] == 0.0 + assert inferred_result["data"]["gti_status"] == "missing" + assert inferred_result["data"]["intervals"] == [] + assert any( + "no effective GTI" in warning for warning in inferred_result["data"]["warnings"] + ) + # EventList.gti would synthesize [time[0], time[-1]] in Stingray 2.2.10. + # Inspection deliberately avoids that property so missing metadata remains + # explicit and consistent with the renderer's no-synthesis explanation. + assert inferred._gti is None + + +def test_inspect_rejects_missing_name(): + result = GTIService(StateManager()).inspect("unknown") + + assert result["success"] is False + assert "not found" in result["message"] + + +@pytest.mark.parametrize( + ("gtis", "message_fragment"), + [ + ([[0.0]], "exactly [start, stop]"), + ([[0.0, np.nan]], "interval 1 stop must be finite"), + ([[0.0, "1"]], "interval 1 stop must be a finite number"), + ([[False, 1.0]], "interval 1 start must be a finite number"), + ([[1.0, 1.0]], "interval 1 must have positive length"), + ([[2.0, 3.0], [0.0, 1.0]], "interval 2 starts"), + ([[0.0, 2.0], [1.0, 3.0]], "interval 2 starts at 1.0 and overlaps"), + ], +) +def test_validate_rejects_malformed_nonfinite_zero_unsorted_and_overlap( + gtis, message_fragment +): + result = GTIService(StateManager()).validate(gtis) + + assert result["success"] is False + assert message_fragment in result["message"] + + +def test_validate_caps_gti_and_row_generators_before_unbounded_materialization(): + consumed_rows: list[int] = [] + + def oversized_gtis(): + for index in range(100): + consumed_rows.append(index) + yield [float(index * 2), float(index * 2 + 1)] + + array, error = gti_module._validate_gti_array(oversized_gtis(), max_rows=2) + + assert array is None + assert error == "GTIs contain at least 3 rows; the cap is 2" + assert consumed_rows == [0, 1, 2] + + consumed_values: list[int] = [] + + def oversized_row(): + for value in range(100): + consumed_values.append(value) + yield float(value) + + array, error = gti_module._validate_gti_array([oversized_row()]) + + assert array is None + assert error == ( + "GTIs interval 1 has at least 3 value(s); exactly [start, stop] is required" + ) + assert consumed_values == [0, 1, 2] + + +@pytest.mark.parametrize("gtis", ["0,1", {0: [0.0, 1.0]}, True]) +def test_validate_rejects_non_array_gti_containers(gtis): + result = GTIService(StateManager()).validate(gtis) + + assert result["success"] is False + assert "array of [start, stop] rows" in result["message"] + + +@pytest.mark.parametrize( + ("operation", "upstream"), + [ + ("intersection", lambda left, right: cross_two_gtis(left, right)), + ("union", lambda left, right: merge_gtis([left, right], "union")), + ("append", lambda left, right: append_gtis(left, right)), + ], +) +def test_set_operations_match_direct_stingray(operation, upstream): + if operation == "append": + left = np.asarray([[0.0, 1.0], [4.0, 5.0]]) + right = np.asarray([[2.0, 3.0], [6.0, 7.0]]) + else: + left = np.asarray([[0.0, 3.0], [5.0, 8.0]]) + right = np.asarray([[2.0, 6.0], [7.0, 9.0]]) + + result = GTIService(StateManager()).set_operation(left, right, operation) + + assert result["success"] is True + np.testing.assert_allclose(_result_gtis(result), upstream(left, right)) + + +def test_intersection_guards_stingray_empty_shape_and_append_checks_precondition(): + service = GTIService(StateManager()) + + empty = service.set_operation([[0.0, 1.0]], [[2.0, 3.0]], "intersection") + invalid_append = service.set_operation([[0.0, 2.0]], [[1.0, 3.0]], "append") + + assert empty["success"] is True + assert _result_gtis(empty).shape == (0, 2) + assert "no shared" in empty["data"]["warnings"][0] + assert invalid_append["success"] is False + assert "mutually exclusive" in invalid_append["message"] + + +def test_union_cap_is_checked_before_stingray_allocation(monkeypatch): + monkeypatch.setattr(gti_module, "MAX_GTI_ROWS", 2) + + def must_not_run(*_args, **_kwargs): + raise AssertionError("Stingray must not run after the cap fails") + + monkeypatch.setattr(gti_module, "merge_gtis", must_not_run) + result = GTIService(StateManager()).set_operation( + [[0.0, 1.0], [2.0, 3.0]], [[4.0, 5.0]], "union" + ) + + assert result["success"] is False + assert "cap" in result["message"] + + +def test_bad_time_intervals_match_direct_stingray_and_allow_empty_gti(): + service = GTIService(StateManager()) + gtis = np.asarray([[1.0, 2.0], [4.0, 5.0]]) + + result = service.bad_time_intervals(gtis, 0.0, 6.0) + empty = service.bad_time_intervals([], 0.0, 6.0) + + assert result["success"] is True + np.testing.assert_allclose( + _result_gtis(result), get_btis(gtis, start_time=0.0, stop_time=6.0) + ) + np.testing.assert_allclose(_result_gtis(empty), [[0.0, 6.0]]) + + +def test_bad_time_intervals_omit_stingray_zero_duration_touch_boundary(): + result = GTIService(StateManager()).bad_time_intervals( + [[0.0, 1.0], [1.0, 2.0]], 0.0, 2.0 + ) + + assert result["success"] is True + assert result["data"]["intervals"] == [] + assert "zero-duration" in result["data"]["warnings"][0] + + +def test_bad_time_interval_cap_is_checked_before_stingray(monkeypatch): + monkeypatch.setattr(gti_module, "MAX_GTI_ROWS", 2) + + def must_not_run(*_args, **_kwargs): + raise AssertionError("Stingray must not run after the cap fails") + + monkeypatch.setattr(gti_module, "get_btis", must_not_run) + result = GTIService(StateManager()).bad_time_intervals( + [[1.0, 2.0], [3.0, 4.0]], 0.0, 5.0 + ) + + assert result["success"] is False + assert "cap" in result["message"] + + +@pytest.mark.parametrize( + ("start", "stop", "fragment"), + [ + (1.0, 1.0, "greater than"), + (np.nan, 3.0, "start_time must be finite"), + (1.0, np.inf, "stop_time must be finite"), + ], +) +def test_bad_time_intervals_reject_invalid_range(start, stop, fragment): + result = GTIService(StateManager()).bad_time_intervals([], start, stop) + + assert result["success"] is False + assert fragment in result["message"] + + +def test_mask_preview_matches_stingray_and_intersects_source_exposure(): + state, source = _state_with_events() + requested = np.asarray([[1.0, 3.0], [7.0, 12.0]]) + effective = cross_two_gtis(source.gti, requested) + expected_mask = create_gti_mask(source.time, effective, dt=source.dt) + + result = GTIService(state).mask_preview("source", requested) + + assert result["success"] is True + assert result["data"]["retained_event_count"] == int( + np.count_nonzero(expected_mask) + ) + assert result["data"]["rejected_event_count"] == int( + len(source.time) - np.count_nonzero(expected_mask) + ) + assert result["data"]["retained_exposure_s"] == pytest.approx(5.0) + assert result["data"]["time_unit"] == "s" + assert result["data"]["time_reference"] == "absolute_mission_time" + applied_rows = result["data"]["applied_gtis"]["intervals"] + applied = np.asarray([[row["start"], row["stop"]] for row in applied_rows]) + np.testing.assert_allclose(applied, effective) + assert any("clipped" in warning for warning in result["data"]["warnings"]) + + +def test_mask_preview_is_bounded_and_json_safe(monkeypatch): + state = StateManager() + time_values = np.linspace(0.0, 10.0, 6001) + state.add_event_data("large", EventList(time=time_values, gti=[[0.0, 10.0]], dt=0)) + monkeypatch.setattr(gti_module, "MAX_EXACT_OUTPUT", 3) + + result = GTIService(state).mask_preview("large", [[1.0, 9.0]]) + + assert result["success"] is True + assert result["data"]["mask_preview"]["shown"] == 3 + assert result["data"]["mask_preview"]["truncated"] is True + assert len(result["data"]["plot"]["time"]) <= 5000 + json.dumps(result, allow_nan=False) + + +def test_mask_event_cap_prevents_mask_allocation(monkeypatch): + state, _ = _state_with_events() + monkeypatch.setattr(gti_module, "MAX_MASK_EVENTS", 3) + + result = GTIService(state).mask_preview("source", [[0.0, 1.0]]) + + assert result["success"] is False + assert "cap" in result["message"] + + +def test_mask_rejects_event_list_without_explicit_gti_instead_of_synthesizing(): + state = StateManager() + source = EventList(time=[1.0, 2.0, 3.0], dt=0) + state.add_event_data("missing-gti", source) + + preview = GTIService(state).mask_preview("missing-gti", [[1.0, 2.0]]) + saved = GTIService(state).save_masked("missing-gti", [[1.0, 2.0]], "must-not-exist") + + assert preview["success"] is False + assert saved["success"] is False + assert "no effective GTI" in preview["message"] + assert "no effective GTI" in saved["message"] + assert source._gti is None + assert not state.has_event_data("must-not-exist") + + +def test_save_masked_preserves_source_and_owns_all_arrays(): + state, source = _state_with_events() + source_time = source.time.copy() + source_energy = source.energy.copy() + source_pi = source.pi.copy() + source_gti = source.gti.copy() + + service = GTIService(state) + requested = [[1.0, 3.0], [7.0, 9.0]] + preview = service.mask_preview("source", requested) + result = service.save_masked("source", requested, "derived") + derived = state.get_event_data("derived") + + assert result["success"] is True + np.testing.assert_array_equal(source.time, source_time) + np.testing.assert_array_equal(source.energy, source_energy) + np.testing.assert_array_equal(source.pi, source_pi) + np.testing.assert_array_equal(source.gti, source_gti) + np.testing.assert_allclose(derived.gti, [[1.0, 3.0], [7.0, 9.0]]) + preview_time = np.asarray(preview["data"]["mask_preview"]["time"]) + preview_mask = np.asarray(preview["data"]["mask_preview"]["retained"]) + np.testing.assert_allclose(derived.time, preview_time[preview_mask]) + assert not np.shares_memory(source.time, derived.time) + assert not np.shares_memory(source.energy, derived.energy) + assert not np.shares_memory(source.pi, derived.pi) + assert not np.shares_memory(source.gti, derived.gti) + + +def test_save_masked_guards_empty_gti_case(): + state, _ = _state_with_events() + + result = GTIService(state).save_masked("source", [[20.0, 30.0]], "empty") + derived = state.get_event_data("empty") + + assert result["success"] is True + assert len(derived.time) == 0 + assert derived.gti.shape == (0, 2) + assert result["data"]["retained_exposure_s"] == 0.0 + + +def test_save_masked_rejects_duplicate_and_invalid_destination(): + state, _ = _state_with_events() + state.add_event_data("duplicate", EventList(time=[1.0], gti=[[0.0, 2.0]])) + service = GTIService(state) + + duplicate = service.save_masked("source", [[0.0, 1.0]], "duplicate") + invalid = service.save_masked("source", [[0.0, 1.0]], " bad") + + assert duplicate["success"] is False + assert "already exists" in duplicate["message"] + assert invalid["success"] is False + assert "whitespace" in invalid["message"] + + +def test_concurrent_save_is_atomic(monkeypatch): + state, _ = _state_with_events() + original_has_event_data = state.has_event_data + start_barrier = threading.Barrier(2) + + def ignore_preflight_for_destination(name): + if name == "race": + start_barrier.wait(timeout=5) + return False + return original_has_event_data(name) + + monkeypatch.setattr(state, "has_event_data", ignore_preflight_for_destination) + services = [GTIService(state), GTIService(state)] + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit( + service.save_masked, + "source", + [[1.0, 3.0]], + "race", + ) + for service in services + ] + results = [future.result() for future in futures] + + assert sorted(result["success"] for result in results) == [False, True] + assert state.list_event_names().count("race") == 1 + assert any( + "concurrently" in result["message"] + for result in results + if not result["success"] + ) + + +def test_fixed_segments_match_direct_stingray(): + gtis = np.asarray([[0.0, 5.0], [7.0, 12.0]]) + starts, stops = time_intervals_from_gtis(gtis, 2.0) + + result = GTIService(StateManager()).fixed_segments(gtis, 2.0) + + assert result["success"] is True + np.testing.assert_allclose(_result_gtis(result), np.column_stack((starts, stops))) + assert result["data"]["unused_exposure_s"] == pytest.approx(2.0) + + +@pytest.mark.parametrize( + ("gtis", "segment_size", "expected_count"), + [ + ([[0.0, 37.0]], 3.7, 10), + ([[50_000.0, 50_001.8]], 0.3, 6), + ([[0.0, 30.0]], 0.3, 100), + ([[50_000.0, 50_000.1]], 0.1, 1), + ([[50_000.0, 50_000.2]], 0.1, 2), + ([[100_000_000.0, 100_000_000.3]], 0.3, 1), + ([[1_000_000_000_000.0, 1_000_000_000_000.1]], 0.1, 1), + ], +) +def test_fixed_segments_normalize_only_ulp_scale_endpoint_drift( + gtis, segment_size, expected_count +): + result = GTIService(StateManager()).fixed_segments(gtis, segment_size) + + assert result["success"] is True, result + assert result["data"]["interval_count"] == expected_count + intervals = _result_gtis(result) + assert intervals[0, 0] == gtis[0][0] + assert intervals[-1, 1] == gtis[0][1] + assert np.all(intervals[1:, 0] == intervals[:-1, 1]) + assert result["data"]["segmented_exposure_s"] == pytest.approx( + result["data"]["source_exposure_s"] + ) + assert result["data"]["unused_exposure_s"] == pytest.approx(0.0) + + +@pytest.mark.parametrize("segment_size", [0.0, -1.0, np.nan, np.inf]) +def test_fixed_segments_reject_invalid_size(segment_size): + result = GTIService(StateManager()).fixed_segments([[0.0, 5.0]], segment_size) + + assert result["success"] is False + + +def test_fixed_segments_rejects_no_fit_and_allocation_cap(monkeypatch): + service = GTIService(StateManager()) + no_fit = service.fixed_segments([[0.0, 1.0]], 2.0) + monkeypatch.setattr(gti_module, "MAX_GTI_ROWS", 2) + over_cap = service.fixed_segments([[0.0, 10.0]], 1.0) + + assert no_fit["success"] is False + assert "No GTI" in no_fit["message"] + assert over_cap["success"] is False + assert "cap" in over_cap["message"] + + +@pytest.mark.parametrize("segment_size", [1.000001, 1.000005]) +def test_fixed_segments_reject_upstream_epsilon_escape(segment_size): + result = GTIService(StateManager()).fixed_segments([[0.0, 1.0]], segment_size) + + assert result["success"] is False + assert "fully contains" in result["message"] + + +def test_fixed_segments_omit_partial_remainder_that_upstream_extends_past_gti(): + result = GTIService(StateManager()).fixed_segments([[0.0, 1.0]], 0.333334) + + assert result["success"] is True + intervals = _result_gtis(result) + np.testing.assert_allclose(intervals, [[0.0, 0.333334], [0.333334, 0.666668]]) + assert np.all(intervals[:, 0] >= 0.0) + assert np.all(intervals[:, 1] <= 1.0) + assert result["data"]["segmented_exposure_s"] <= result["data"]["source_exposure_s"] + assert result["data"]["unused_exposure_s"] == pytest.approx(0.333332) + assert any( + "exceeded a source GTI boundary" in warning + for warning in result["data"]["warnings"] + ) + + +@pytest.mark.parametrize( + ("gtis", "segment_size"), + [ + ([[1_000_000_000_000_000.0, 1_000_000_000_000_001.0]], 0.1), + ([[100_000_000_000_000.0, 100_000_000_000_001.0]], 0.01), + ], +) +def test_fixed_segments_reject_unrepresentable_absolute_timestamp_step( + gtis, segment_size +): + result = GTIService(StateManager()).fixed_segments(gtis, segment_size) + + assert result["success"] is False + assert result["error"] is None + assert "binary64 timestamp resolution" in result["message"] + assert "relative-second GTIs" in result["message"] + + +def test_pathologically_small_segment_requests_fail_before_upstream(monkeypatch): + def must_not_run(*_args, **_kwargs): + raise AssertionError("Stingray must not run after the cap fails") + + service = GTIService(StateManager()) + monkeypatch.setattr(gti_module, "time_intervals_from_gtis", must_not_run) + fixed = service.fixed_segments([[0.0, 1.0]], np.nextafter(0.0, 1.0)) + monkeypatch.setattr(gti_module, "split_gtis_by_exposure", must_not_run) + split = service.split_by_exposure([[0.0, 1.0]], np.nextafter(0.0, 1.0)) + + assert fixed["success"] is False + assert "cap" in fixed["message"] + assert split["success"] is False + assert "cap" in split["message"] + + +def test_split_by_exposure_matches_direct_stingray_and_preserves_exposure(): + gtis = np.asarray([[0.0, 30.0], [40.0, 70.0], [90.0, 120.0], [130.0, 160.0]]) + expected = split_gtis_by_exposure(gtis, 60.0) + + result = GTIService(StateManager()).split_by_exposure(gtis, 60.0) + + assert result["success"] is True + assert result["data"]["chunk_count"] == len(expected) + for result_chunk, expected_chunk in zip(result["data"]["chunks"], expected): + rows = np.asarray( + [[row["start"], row["stop"]] for row in result_chunk["intervals"]] + ) + np.testing.assert_allclose(rows, expected_chunk) + assert result["data"]["source_exposure_s"] == pytest.approx( + result["data"]["output_exposure_s"] + ) + assert "approximate" in result["data"]["warnings"][0] + + +@pytest.mark.parametrize( + ("gtis", "threshold"), + [ + ([[0.0, 15.0]], 0.5), + ([[0.0, 15.0]], 10.0), + ([[0.0, 5.0], [6.0, 10.0]], 2.0), + ([[0.0, 5.0], [6.0, 10.0]], 10.0), + ], +) +def test_split_by_exposure_treats_threshold_without_qualifying_gap_as_noop( + gtis, threshold +): + ordinary = GTIService(StateManager()).split_by_exposure(gtis, 1.0) + + result = GTIService(StateManager()).split_by_exposure( + gtis, + 1.0, + new_interval_if_gti_sep=threshold, + ) + + assert ordinary["success"] is True, ordinary + assert result["success"] is True, result + assert result["data"]["chunk_count"] == ordinary["data"]["chunk_count"] + assert result["data"]["chunks"] == ordinary["data"]["chunks"] + + +def test_split_by_exposure_preserves_qualifying_gap_threshold_behavior(): + gtis = np.asarray([[0.0, 5.0], [6.0, 10.0]]) + expected = split_gtis_by_exposure( + gtis, + 1.0, + new_interval_if_gti_sep=0.5, + ) + + result = GTIService(StateManager()).split_by_exposure( + gtis, + 1.0, + new_interval_if_gti_sep=0.5, + ) + + assert result["success"] is True, result + assert result["data"]["chunk_count"] == len(expected) + for result_chunk, expected_chunk in zip(result["data"]["chunks"], expected): + rows = np.asarray( + [[row["start"], row["stop"]] for row in result_chunk["intervals"]] + ) + np.testing.assert_allclose(rows, expected_chunk) + + +@pytest.mark.parametrize( + ("gtis", "exposure", "expected_chunks", "expected_rows"), + [ + ([[50_000.0, 50_003.0], [50_003.6, 50_004.5]], 0.3, 13, 13), + ( + [ + [100_000_000.0, 100_000_000.0 + 3 * 3.7], + [100_000_000.0 + 5 * 3.7, 100_000_000.0 + 11 * 3.7], + ], + 3.7, + 8, + 9, + ), + ], +) +def test_split_by_exposure_avoids_tiny_roundoff_rows_across_multiple_gtis( + gtis, exposure, expected_chunks, expected_rows +): + result = GTIService(StateManager()).split_by_exposure(gtis, exposure) + + assert result["success"] is True, result + assert result["data"]["chunk_count"] == expected_chunks + rows = [row for chunk in result["data"]["chunks"] for row in chunk["intervals"]] + assert len(rows) == expected_rows + assert all(row["stop"] > row["start"] for row in rows) + assert result["data"]["source_exposure_s"] == pytest.approx( + result["data"]["output_exposure_s"] + ) + + +@pytest.mark.parametrize( + ("gtis", "exposure"), + [ + ([[0.0, 0.3]], 0.1), + ([[0.0, 0.6]], 0.1), + ([[0.0, 11.1]], 3.7), + ], +) +def test_split_by_exposure_preserves_direct_small_origin_grouping(gtis, exposure): + expected = split_gtis_by_exposure(np.asarray(gtis), exposure) + + result = GTIService(StateManager()).split_by_exposure(gtis, exposure) + + assert result["success"] is True, result + assert result["data"]["chunk_count"] == len(expected) + for result_chunk, expected_chunk in zip(result["data"]["chunks"], expected): + rows = np.asarray( + [[row["start"], row["stop"]] for row in result_chunk["intervals"]] + ) + np.testing.assert_allclose(rows, expected_chunk, rtol=0, atol=0) + + +@pytest.mark.parametrize( + ("gtis", "exposure", "expected_chunks"), + [ + ([[50_000.0, 50_001.8]], 0.3, 6), + ([[100_000_000.0, 100_000_030.0]], 0.3, 100), + ([[50_000.0, 50_000.3]], 0.1, 3), + ([[1_000_000_000_000.0, 1_000_000_000_000.0 + 0.3]], 0.1, 3), + ([[100_000_000.0, 100_000_000.0 + 22.2]], 3.7, 6), + ], +) +def test_split_by_exposure_is_stable_at_large_absolute_epochs( + gtis, exposure, expected_chunks +): + result = GTIService(StateManager()).split_by_exposure(gtis, exposure) + + assert result["success"] is True, result + assert result["data"]["chunk_count"] == expected_chunks + assert result["data"]["source_exposure_s"] == pytest.approx( + result["data"]["output_exposure_s"], rel=1e-12, abs=1e-12 + ) + intervals = np.asarray( + [ + [row["start"], row["stop"]] + for chunk in result["data"]["chunks"] + for row in chunk["intervals"] + ] + ) + assert intervals[0, 0] == gtis[0][0] + assert intervals[-1, 1] == gtis[0][1] + assert np.all(intervals[:, 1] > intervals[:, 0]) + + +@pytest.mark.parametrize("exposure", [0.01, 0.001]) +def test_split_by_exposure_rejects_collapsing_absolute_timestamp_step(exposure): + result = GTIService(StateManager()).split_by_exposure( + [[1_000_000_000_000_000.0, 1_000_000_000_000_001.0]], + exposure, + ) + + assert result["success"] is False + assert result["error"] is None + assert "binary64 timestamp resolution" in result["message"] + assert "relative-second GTIs" in result["message"] + + +@pytest.mark.parametrize( + "gtis", + [ + [[100_000_000.0, 100_000_000.0 + 1.0000000149011612]], + [[1_000_000_000_000.0, 1_000_000_000_000.0 + 1.0001220703125]], + [[-1_000_000_000_000.0, -1_000_000_000_000.0 + 1.0001220703125]], + ], +) +def test_split_by_exposure_preserves_representable_one_ulp_remainder(gtis): + result = GTIService(StateManager()).split_by_exposure(gtis, 0.1) + + assert result["success"] is True, result + assert result["data"]["chunk_count"] == 10 + assert result["data"]["interval_count"] == 11 + assert result["data"]["output_exposure_s"] == result["data"]["source_exposure_s"] + final_row = result["data"]["chunks"][-1]["intervals"][-1] + assert final_row["stop"] > final_row["start"] + + +@pytest.mark.parametrize( + ("gtis", "exposure", "separation"), + [([], 1.0, None), ([[0.0, 1.0]], 0.0, None), ([[0.0, 1.0]], 1.0, 0.0)], +) +def test_split_by_exposure_rejects_empty_and_invalid_parameters( + gtis, exposure, separation +): + result = GTIService(StateManager()).split_by_exposure( + gtis, exposure, new_interval_if_gti_sep=separation + ) + + assert result["success"] is False + + +@pytest.mark.parametrize( + ("gtis", "message_fragment"), + [ + ([[-1e308, 1e308]], "interval 1"), + ([[-1e308, -1e307], [1e307, 1e308]], "Total GTI exposure"), + ], +) +def test_split_by_exposure_rejects_unrepresentable_finite_exposure_before_upstream( + monkeypatch, gtis, message_fragment +): + def must_not_run(*_args, **_kwargs): + raise AssertionError("Stingray must not run after derived exposure validation") + + monkeypatch.setattr(gti_module, "split_gtis_by_exposure", must_not_run) + + result = GTIService(StateManager()).split_by_exposure(gtis, 1e308) + + assert result["success"] is False + assert result["error"] is None + assert message_fragment in result["message"] + assert "finite seconds" in result["message"] + + +def test_validation_rejects_unrepresentable_finite_interval_arithmetic(): + result = GTIService(StateManager()).validate([[-1e308, 1e308]]) + + assert result["success"] is False + assert result["error"] is None + assert "interval 1" in result["message"] + assert "finite seconds" in result["message"] + + +@pytest.mark.parametrize("times", [[5.0], [0.1, 9.9], [1.0, 2.0, 9.0]]) +def test_mask_without_dt_treats_events_as_point_timestamps(state_manager, times): + source = EventList(time=np.asarray(times), gti=np.asarray([[0.0, 10.0]])) + source.dt = None + state_manager.add_event_data("point-events", source) + + result = GTIService(state_manager).mask_preview("point-events", [[0.0, 10.0]]) + + assert result["success"], result + assert result["data"]["retained_event_count"] == len(times) + assert result["data"]["rejected_event_count"] == 0 + assert any("point timestamps" in warning for warning in result["data"]["warnings"]) + + +def _route_app() -> FastAPI: + app = FastAPI() + app.include_router(gti_routes.router, prefix="/api/utilities/gti") + app.state.state_manager = StateManager() + app.state.performance_monitor = None + return app + + +@pytest.mark.asyncio +async def test_routes_return_strict_json_and_reject_nonfinite_payload(): + app = _route_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/utilities/gti/validate", + json={"gtis": [[0.0, 1.0]], "time_reference": "relative_seconds"}, + ) + nonfinite = await client.post( + "/api/utilities/gti/validate", + json={"gtis": [[0.0, "NaN"]]}, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + json.dumps(response.json(), allow_nan=False) + assert nonfinite.status_code == 422 + + +@pytest.mark.asyncio +async def test_inspect_route_offloads_blocking_service(monkeypatch): + def slow_inspect(self, event_list_name): + del self, event_list_name + time.sleep(0.6) + return {"success": True, "data": None, "message": "ok", "error": None} + + monkeypatch.setattr(gti_module.GTIService, "inspect", slow_inspect) + app = _route_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + slow_task = asyncio.create_task( + client.post( + "/api/utilities/gti/inspect", json={"event_list_name": "source"} + ) + ) + start = time.monotonic() + await asyncio.sleep(0) + await asyncio.sleep(0.05) + elapsed = time.monotonic() - start + response = await slow_task + + assert response.status_code == 200 + assert elapsed < 0.4, f"event loop was blocked for {elapsed:.2f}s" + + +def test_every_route_uses_asyncio_to_thread(): + route_functions = [ + gti_routes.inspect, + gti_routes.validate, + gti_routes.set_operation, + gti_routes.bad_time_intervals, + gti_routes.mask_preview, + gti_routes.mask_save, + gti_routes.fixed_segments, + gti_routes.exposure_segments, + ] + + assert all( + "asyncio.to_thread" in inspect.getsource(route) for route in route_functions + ) diff --git a/python-backend/tests/test_hdf5_export.py b/python-backend/tests/test_hdf5_export.py new file mode 100644 index 0000000..459bf39 --- /dev/null +++ b/python-backend/tests/test_hdf5_export.py @@ -0,0 +1,1628 @@ +"""Verified optional HDF5 export coverage for General I/O.""" + +from __future__ import annotations + +import hashlib +import hmac +import io +import os +import time +import warnings +from collections import UserDict +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +from astropy import units as u +from astropy.table import Column, MaskedColumn, Table +from astropy.utils.masked import Masked +from pydantic import ValidationError +from routes.io_utility_routes import ExportObjectRequest +from services import io_utility_service as io_module +from services.io_utility_service import ( + HDF5_MANIFEST_PATH, + HDF5_SCHEMA, + HDF5_TABLE_PATH, + HDF5_UNAVAILABLE_REASON, + HDF5_VERIFICATION_CHECKS, + IOUtilityService, +) +from services.state_manager import StateManager +from services.utility_helpers import FILE_GRANT_VERSION +from stingray import EventList, Lightcurve + +TEST_SECRET = "hdf5-export-test-file-grant-secret" +CORE_FORMATS = ["csv", "ecsv", "json", "fits"] + + +@pytest.fixture(autouse=True) +def file_grant_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STINGRAY_FILE_GRANT_SECRET", TEST_SECRET) + + +def make_write_grant(path: Path, *, expires_delta: int = 300) -> str: + resolved = path.parent.resolve(strict=True) / path.name + expires = int(time.time()) + expires_delta + parent_stat = resolved.parent.stat() + prefix = f"{FILE_GRANT_VERSION}.{expires}.{parent_stat.st_dev}.{parent_stat.st_ino}" + digest = hmac.new( + TEST_SECRET.encode(), + ( + f"{FILE_GRANT_VERSION}\0write\0{expires}\0{resolved}\0" + f"{parent_stat.st_dev}\0{parent_stat.st_ino}" + ).encode(), + hashlib.sha256, + ).hexdigest() + return f"{prefix}.{digest}" + + +def installed_h5py() -> Any: + module, reason = io_module._optional_hdf5_runtime() + if module is None: + pytest.skip(reason) + return module + + +def read_export(path: Path, object_type: str, object_name: str) -> Table: + h5py_module = installed_h5py() + with path.open("rb") as stream: + table, _manifest = io_module._read_hdf5_table( + stream, + h5py_module, + object_type=object_type, + object_name=object_name, + ) + return table + + +def assert_private_failure(path: Path, result: dict[str, Any]) -> None: + assert result["success"] is False, result + assert not path.exists() + assert list(path.parent.glob(".stingray-export-*")) == [] + + +@pytest.fixture +def representative_state() -> StateManager: + state = StateManager() + events = EventList( + time=np.asarray([1.0, 2.0, 3.0], dtype=np.float64), + pi=np.asarray([10, 11, 12], dtype=np.int16), + energy=np.asarray([0.5, 0.75, 1.0], dtype=np.float32), + gti=np.empty((0, 2), dtype=np.float32), + mjdref=58_000.125, + dt=0.125, + ) + events.detector_id = np.asarray([0, 1, 2], dtype=np.uint8) + events.mission = "NICER" + events.instr = "XTI" + events.header = "TELESCOP=NICER;INSTRUME=XTI" + events.rmf_conversion_provenance = { + "operation": "rmf_event_list_pi_to_energy", + "calibrated": True, + } + events.mission_io_conversion_type = "rough_approximate" + events.mission_io_provenance_json = '{"operation":"mission_io.rough_pi_to_energy"}' + state.add_event_data("events complete", events) + + curve = Lightcurve( + time=np.asarray([1.0, 2.0, 3.0]), + counts=np.asarray([10.0, 12.0, 11.0]), + dt=1.0, + gti=np.asarray([[0.5, 3.5]]), + bg_counts=np.asarray([1.0, 1.5, 1.25]), + bg_ratio=np.asarray([2.0, 2.0, 2.0]), + frac_exp=np.asarray([1.0, 0.8, 0.9]), + mjdref=58_000.125, + ) + # These are valid lazy scientific arrays in Stingray 2.2.10. + _ = curve.bin_lo, curve.bin_hi + curve.mission = "NICER" + curve.header = "TELESCOP=NICER" + curve.custom_flux = np.asarray([4.0, 5.0, 6.0]) * (u.erg / u.s) + curve.custom_quality = np.ma.array( + [8.0, 9.0, 10.0], + mask=[True, False, False], + fill_value=-19.0, + ) + curve.custom_masked_quantity = Masked( + np.asarray([3.0, 4.0, 5.0]) * u.s, + mask=[True, False, False], + ) + curve.lightcurve_provenance = {"operation": "background_corrected"} + state.add_lightcurve_data("curve complete", curve) + + analysis = Table() + analysis["nullable"] = MaskedColumn( + np.asarray([1.25, np.nan], dtype=">f8"), + mask=[True, False], + fill_value=-7.25, + unit=u.s, + description="A masked lag and an unmasked NaN", + meta={"role": "lag"}, + ) + analysis["nullable"].format = ".3f" + analysis["label"] = Column(["low", "high"]) + analysis["channel"] = Column(np.asarray([1, 2], dtype=">u2")) + analysis.meta = { + "metadata": { + "method": "cross-spectrum", + "non_column_fields": ["band"], + }, + "band": (0.5, 2.0), + "provenance": { + "operation": "timing_time_lags", + "source": ["a", "b"], + }, + } + state.add_analysis_result("analysis complete", analysis) + return state + + +def test_hdf5_capability_and_request_contract( + representative_state: StateManager, +) -> None: + h5py_module = installed_h5py() + service = IOUtilityService(representative_state) + + result = service.list_exportable_objects() + + assert result["success"], result + data = result["data"] + assert data["format_allowlist"] == [*CORE_FORMATS, "hdf5"] + assert "hdf5" not in data["excluded_formats"] + for object_type, formats in data["capability_matrix"].items(): + capability = formats["hdf5"] + assert capability == { + "supported": True, + "notes": ( + "Versioned Stingray Explorer HDF5 table with a complete semantic " + "reopen comparison before publication." + ), + "reason": None, + "extensions": [".hdf5"], + "dependency": { + "name": "h5py", + "available": True, + "version": h5py_module.__version__, + }, + }, object_type + assert all( + item["formats"] == [*CORE_FORMATS, "hdf5"] and item["format_reasons"] == {} + for item in data["objects"] + ) + request = ExportObjectRequest( + object_type="event_list", + object_name="events complete", + format="hdf5", + destination_path="/selected/events.hdf5", + destination_grant="grant", + ) + assert request.format == "hdf5" + with pytest.raises(ValidationError): + ExportObjectRequest( + object_type="event_list", + object_name="events complete", + format="h5", + destination_path="/selected/events.h5", + destination_grant="grant", + ) + + +@pytest.mark.parametrize( + ("object_class", "relative_path", "object_type", "required_metadata"), + [ + ( + EventList, + "monol_testA.evt", + "event_list", + {"header", "mjdref", "mission", "instr", "t_start", "t_stop"}, + ), + ( + Lightcurve, + "lcurveA.fits", + "lightcurve", + { + "header", + "mjdref", + "high_precision", + "input_counts", + "low_memory", + "tstart", + "tseg", + }, + ), + ], +) +def test_repository_representative_scientific_file_round_trip( + object_class: Any, + relative_path: str, + object_type: str, + required_metadata: set[str], +) -> None: + h5py_module = installed_h5py() + source_path = Path(__file__).parents[2] / "files" / "data" / relative_path + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + source = object_class.read(str(source_path), fmt="hea") + table = io_module._table_for_object( + source, + object_type, + preserve_timing_precision=True, + ) + try: + table, manifest = io_module._prepare_hdf5_table(table) + except ValueError as exception: + if np.dtype(np.longdouble).itemsize > 8: + assert "width is unsupported" in str(exception) + return + raise + stream = io.BytesIO() + + io_module._write_hdf5_table( + stream, + h5py_module, + table, + manifest, + object_type=object_type, + object_name="representative", + ) + stream.seek(0) + reopened, reopened_manifest = io_module._read_hdf5_table( + stream, + h5py_module, + object_type=object_type, + object_name="representative", + ) + + assert required_metadata <= set(reopened.meta) + assert reopened_manifest == manifest + assert io_module._verify_hdf5_table(table, reopened) == HDF5_VERIFICATION_CHECKS + + +def test_hdf5_event_list_round_trip_preserves_all_fields_and_provenance( + representative_state: StateManager, tmp_path: Path +) -> None: + installed_h5py() + service = IOUtilityService(representative_state) + path = tmp_path / "events.hdf5" + + result = service.export_object( + "event_list", + "events complete", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert result["success"], result + assert result["data"]["verification"] == { + "schema": HDF5_SCHEMA, + "table_path": HDF5_TABLE_PATH, + "semantic_round_trip": True, + "checks": HDF5_VERIFICATION_CHECKS, + "h5py_version": installed_h5py().__version__, + } + assert result["data"]["bytes"] == path.stat().st_size > 0 + with installed_h5py().File(path, "r") as handle: + assert HDF5_TABLE_PATH in handle + assert HDF5_MANIFEST_PATH in handle + table = read_export(path, "event_list", "events complete") + expected = io_module._table_for_object( + representative_state.copy_event_data("events complete"), + "event_list", + preserve_timing_precision=True, + ) + expected, _ = io_module._prepare_hdf5_table(expected) + assert table.colnames == expected.colnames + assert set(table.colnames) >= {"time", "pi", "energy", "detector_id"} + assert table["pi"].dtype == np.dtype("int16") + assert table["energy"].dtype == np.dtype("float32") + assert table["detector_id"].dtype == np.dtype("uint8") + assert table.meta["gti_status"] == "present" + assert table.meta["gti"].shape == (0, 2) + assert table.meta["mjdref"] == pytest.approx(58_000.125) + assert table.meta["mission"] == "NICER" + assert table.meta["instr"] == "XTI" + assert table.meta["header"] == "TELESCOP=NICER;INSTRUME=XTI" + assert ( + table.meta["rmf_conversion_provenance"]["operation"] + == "rmf_event_list_pi_to_energy" + ) + assert table.meta["mission_io_conversion_type"] == "rough_approximate" + assert "mission_io.rough_pi_to_energy" in table.meta["mission_io_provenance_json"] + + +def test_hdf5_distinguishes_missing_gti_from_explicit_empty( + representative_state: StateManager, tmp_path: Path +) -> None: + installed_h5py() + no_gti = EventList(time=[1.0, 2.0], pi=[1, 2]) + assert no_gti._gti is None + representative_state.add_event_data("events missing gti", no_gti) + service = IOUtilityService(representative_state) + missing_path = tmp_path / "missing.hdf5" + empty_path = tmp_path / "empty.hdf5" + + missing_result = service.export_object( + "event_list", + "events missing gti", + "hdf5", + str(missing_path), + make_write_grant(missing_path), + ) + empty_result = service.export_object( + "event_list", + "events complete", + "hdf5", + str(empty_path), + make_write_grant(empty_path), + ) + + assert missing_result["success"], missing_result + assert empty_result["success"], empty_result + assert any("no explicit GTI" in warning for warning in missing_result["warnings"]) + missing = read_export(missing_path, "event_list", "events missing gti") + empty = read_export(empty_path, "event_list", "events complete") + assert missing.meta["gti_status"] == "missing" + assert "gti" not in missing.meta + assert empty.meta["gti_status"] == "present" + assert empty.meta["gti"].shape == (0, 2) + assert ( + empty.meta["gti"].dtype + == representative_state.get_event_data("events complete")._gti.dtype + ) + assert representative_state.get_event_data("events missing gti")._gti is None + + +def test_hdf5_lightcurve_round_trip_keeps_scientific_arrays( + representative_state: StateManager, tmp_path: Path +) -> None: + installed_h5py() + service = IOUtilityService(representative_state) + path = tmp_path / "curve.hdf5" + + result = service.export_object( + "lightcurve", + "curve complete", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert result["success"], result + table = read_export(path, "lightcurve", "curve complete") + for name in ( + "time", + "counts", + "bg_counts", + "bg_ratio", + "frac_exp", + "bin_lo", + "bin_hi", + "custom_flux", + "custom_quality", + "custom_masked_quantity", + ): + assert name in table.colnames + np.testing.assert_allclose(table["bg_counts"], [1.0, 1.5, 1.25]) + np.testing.assert_allclose(table["bg_ratio"], [2.0, 2.0, 2.0]) + np.testing.assert_allclose(table["frac_exp"], [1.0, 0.8, 0.9]) + np.testing.assert_allclose(table["custom_flux"], [4.0, 5.0, 6.0]) + assert table["custom_flux"].unit == u.erg / u.s + assert table["custom_quality"].mask.tolist() == [True, False, False] + assert table["custom_quality"].fill_value == pytest.approx(-19.0) + assert table["custom_masked_quantity"].mask.tolist() == [True, False, False] + assert table["custom_masked_quantity"].unit == u.s + assert table["time"].unit == u.s + assert table["counts"].unit == u.ct + assert table["bg_counts"].unit == u.ct + assert table["bin_lo"].unit == u.s + assert table["bin_hi"].unit == u.s + assert table.meta["gti_status"] == "present" + np.testing.assert_allclose(table.meta["gti"], [[0.5, 3.5]]) + assert table.meta["mission"] == "NICER" + assert table.meta["header"] == "TELESCOP=NICER" + assert table.meta["dt"] == pytest.approx(1.0) + assert table.meta["dt_unit"] == "s" + assert table.meta["high_precision"] is False + assert table.meta["input_counts"] is True + assert table.meta["low_memory"] is False + assert table.meta["notes"] == "" + assert table.meta["tstart"] == pytest.approx(0.5) + assert table.meta["tseg"] == pytest.approx(3.0) + assert table.meta["lightcurve_provenance"] == {"operation": "background_corrected"} + + +def test_hdf5_lightcurve_preserves_per_bin_widths( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + curve = Lightcurve( + time=np.asarray([0.5, 2.0, 4.5]), + counts=np.asarray([1.0, 2.0, 3.0]), + dt=np.asarray([1.0, 2.0, 3.0]), + ) + state.add_lightcurve_data("variable bins", curve) + service = IOUtilityService(state) + path = tmp_path / "variable-bins.hdf5" + + result = service.export_object( + "lightcurve", + "variable bins", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert result["success"], result + table = read_export(path, "lightcurve", "variable bins") + np.testing.assert_allclose(table["dt"], [1.0, 2.0, 3.0]) + assert table["dt"].unit == u.s + + +def test_hdf5_analysis_round_trip_preserves_masks_nan_units_order_and_fill( + representative_state: StateManager, tmp_path: Path +) -> None: + h5py_module = installed_h5py() + service = IOUtilityService(representative_state) + path = tmp_path / "analysis.hdf5" + + result = service.export_object( + "analysis_result", + "analysis complete", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert result["success"], result + with h5py_module.File(path, "r") as handle: + manifest_dataset = handle[HDF5_MANIFEST_PATH] + assert manifest_dataset.shape == () + assert manifest_dataset.dtype.kind == "S" + assert not manifest_dataset.dtype.hasobject + table = read_export(path, "analysis_result", "analysis complete") + assert table.colnames == ["nullable", "label", "channel"] + assert table["nullable"].dtype == np.dtype("float64") + assert table["channel"].dtype == np.dtype("uint16") + assert table["label"].dtype.kind == "U" + assert table["nullable"].mask.tolist() == [True, False] + assert np.isnan(table["nullable"].data.data[1]) + assert table["nullable"].fill_value == pytest.approx(-7.25) + assert table["nullable"].unit == u.s + assert table["nullable"].description == "A masked lag and an unmasked NaN" + assert table["nullable"].format == ".3f" + assert table["nullable"].meta == {"role": "lag"} + assert table.meta["metadata"]["method"] == "cross-spectrum" + assert table.meta["band"] == (0.5, 2.0) + assert table.meta["provenance"]["operation"] == "timing_time_lags" + + +def test_nullable_python_none_becomes_mask_but_nan_remains_unmasked( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + state.add_analysis_result( + "nullable", + { + "value": [None, float("nan"), 1.0], + "metadata": {"units": {"value": "s"}}, + }, + ) + service = IOUtilityService(state) + path = tmp_path / "nullable.hdf5" + + result = service.export_object( + "analysis_result", + "nullable", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert result["success"], result + table = read_export(path, "analysis_result", "nullable") + assert isinstance(table["value"], MaskedColumn) + assert table["value"].mask.tolist() == [True, False, False] + assert np.isnan(table["value"].data.data[1]) + assert table["value"].unit == u.s + + +def test_analysis_scalar_quantity_remains_unit_bearing_metadata( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + state.add_analysis_result( + "scalar quantity", + { + "value": [1.0, 2.0], + "exposure": np.asarray(3.5) * u.s, + }, + ) + service = IOUtilityService(state) + path = tmp_path / "scalar-quantity.hdf5" + + result = service.export_object( + "analysis_result", + "scalar quantity", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert result["success"], result + table = read_export(path, "analysis_result", "scalar quantity") + assert table.meta["exposure"] == 3.5 * u.s + + +@pytest.mark.parametrize( + "quantity", + [ + u.Quantity(np.uint64(2**64 - 1), u.ct, dtype=np.uint64), + u.Quantity(np.asarray([1, 2], dtype=np.int16), u.ct, dtype=np.int16), + ], +) +def test_integer_quantity_metadata_is_honestly_unsupported( + quantity: u.Quantity, +) -> None: + installed_h5py() + state = StateManager() + table = Table({"value": [1.0, 2.0]}) + table.meta["quantity"] = quantity + state.add_analysis_result("integer quantity", table) + + listed = IOUtilityService(state).list_exportable_objects() + + assert listed["success"], listed + entry = listed["data"]["objects"][0] + assert entry["formats"] == CORE_FORMATS + assert "integer or boolean Quantity" in entry["format_reasons"]["hdf5"] + + +def test_quantity_subclass_metadata_is_honestly_unsupported() -> None: + installed_h5py() + state = StateManager() + table = Table({"value": [1.0]}) + # Angle carries additional type semantics beyond a base Quantity. + from astropy.coordinates import Angle + + table.meta["angle"] = Angle(45.0, u.deg) + state.add_analysis_result("quantity subclass", table) + + listed = IOUtilityService(state).list_exportable_objects() + + entry = listed["data"]["objects"][0] + assert entry["formats"] == CORE_FORMATS + assert "Quantity subclass 'Angle'" in entry["format_reasons"]["hdf5"] + + +@pytest.mark.parametrize( + ("values", "mask"), + [ + (np.asarray([1.0, 2.0]), np.asarray([False, False])), + (np.asarray([], dtype=np.float64), np.asarray([], dtype=bool)), + ], +) +def test_all_false_and_empty_masked_columns_round_trip( + tmp_path: Path, + values: np.ndarray, + mask: np.ndarray, +) -> None: + installed_h5py() + state = StateManager() + table = Table() + table["value"] = MaskedColumn( + values, + mask=mask, + fill_value=-19.5, + unit=u.s, + ) + state.add_analysis_result("all false mask", table) + service = IOUtilityService(state) + path = tmp_path / f"all-false-{len(values)}.hdf5" + + listed = service.list_exportable_objects() + result = service.export_object( + "analysis_result", + "all false mask", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert "hdf5" in listed["data"]["objects"][0]["formats"] + assert result["success"], result + reopened = read_export(path, "analysis_result", "all false mask") + assert isinstance(reopened["value"], MaskedColumn) + assert reopened["value"].mask.tolist() == mask.tolist() + assert reopened["value"].fill_value == pytest.approx(-19.5) + assert reopened["value"].unit == u.s + + +@pytest.mark.filterwarnings("ignore:overflow encountered in cast:RuntimeWarning") +@pytest.mark.parametrize("byteorder", ["<", ">"]) +@pytest.mark.parametrize( + ("dtype_code", "fill_value"), + [ + ("i1", -7), + ("i2", -257), + ("i4", -65_537), + ("i8", -(2**40)), + ("u1", 7), + ("u2", 257), + ("u4", 65_537), + ("u8", 2**40), + ("f2", np.nan), + ("f4", np.inf), + ("f8", -np.inf), + ], +) +def test_hdf5_numeric_dtype_endian_and_fill_matrix( + byteorder: str, + dtype_code: str, + fill_value: Any, +) -> None: + h5py_module = installed_h5py() + dtype = np.dtype(dtype_code).newbyteorder(byteorder) + if dtype.kind == "i": + info = np.iinfo(dtype) + values = np.asarray([info.min, 0, info.max], dtype=dtype) + mask = [False, True, False] + elif dtype.kind == "u": + info = np.iinfo(dtype) + values = np.asarray([0, 1, info.max], dtype=dtype) + mask = [False, True, False] + else: + values = np.asarray([np.nan, np.inf, -np.inf, -0.0], dtype=dtype) + mask = [False, False, False, True] + source = Table() + with warnings.catch_warnings(): + # Astropy briefly probes its large default floating fill before + # applying our explicit float16 fill, which can emit an irrelevant + # cast-overflow warning. + warnings.simplefilter("ignore", RuntimeWarning) + source["value"] = MaskedColumn( + values, + mask=mask, + fill_value=fill_value, + ) + canonical, manifest = io_module._prepare_hdf5_table(source) + stream = io.BytesIO() + + io_module._write_hdf5_table( + stream, + h5py_module, + canonical, + manifest, + object_type="analysis_result", + object_name="dtype matrix", + ) + stream.seek(0) + reopened, _ = io_module._read_hdf5_table( + stream, + h5py_module, + object_type="analysis_result", + object_name="dtype matrix", + ) + + assert reopened["value"].dtype.kind == dtype.kind + assert reopened["value"].dtype.itemsize == dtype.itemsize + assert io_module._verify_hdf5_table(canonical, reopened) == HDF5_VERIFICATION_CHECKS + + +@pytest.mark.parametrize( + ("values", "fill_value"), + [ + (np.asarray([False, True, False], dtype=bool), True), + (np.asarray(["", "a", "z"], dtype="U1"), "?"), + (np.asarray(["", "soft", "hard"], dtype="U8"), "missing"), + ], +) +def test_hdf5_bool_and_unicode_width_fill_matrix( + values: np.ndarray, + fill_value: Any, +) -> None: + h5py_module = installed_h5py() + source = Table() + source["value"] = MaskedColumn( + values, + mask=[False, True, False], + fill_value=fill_value, + ) + canonical, manifest = io_module._prepare_hdf5_table(source) + stream = io.BytesIO() + + io_module._write_hdf5_table( + stream, + h5py_module, + canonical, + manifest, + object_type="analysis_result", + object_name="dtype matrix", + ) + stream.seek(0) + reopened, _ = io_module._read_hdf5_table( + stream, + h5py_module, + object_type="analysis_result", + object_name="dtype matrix", + ) + + assert reopened["value"].dtype == values.dtype + assert io_module._verify_hdf5_table(canonical, reopened) == HDF5_VERIFICATION_CHECKS + + +def test_longdouble_timing_is_preserved_or_honestly_unsupported( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + events = EventList(time=[1.0, 2.0], pi=[1, 2]) + events.mjdref = np.longdouble("55197.00076601852") + events._gti = np.asarray( + [[np.longdouble("0.123456789012345"), np.longdouble("2.5")]], + dtype=np.longdouble, + ) + state.add_event_data("high precision timing", events) + service = IOUtilityService(state) + path = tmp_path / "high-precision.hdf5" + + listed = service.list_exportable_objects() + + assert listed["success"], listed + entry = listed["data"]["objects"][0] + if np.dtype(np.longdouble).itemsize > 8: + assert entry["formats"] == CORE_FORMATS + assert "width is unsupported" in entry["format_reasons"]["hdf5"] + result = service.export_object( + "event_list", + "high precision timing", + "hdf5", + str(path), + make_write_grant(path), + ) + assert_private_failure(path, result) + return + + assert "hdf5" in entry["formats"] + result = service.export_object( + "event_list", + "high precision timing", + "hdf5", + str(path), + make_write_grant(path), + ) + assert result["success"], result + table = read_export(path, "event_list", "high precision timing") + assert table.meta["mjdref"] == events.mjdref + assert table.meta["gti"].dtype.itemsize == np.dtype(np.longdouble).itemsize + np.testing.assert_array_equal(table.meta["gti"], events._gti) + + +def test_table_backed_nullable_and_premasked_objects_keep_mask_and_fill( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + source = Table() + source["nullable"] = Column( + np.asarray([None, float("nan"), 1.0], dtype=object), + unit=u.s, + ) + source["hidden"] = MaskedColumn( + np.asarray(["not-a-number", 2.5, 3.5], dtype=object), + mask=[True, False, False], + fill_value=-12.5, + unit=u.keV, + description="masked storage is not scientific data", + meta={"role": "energy"}, + ) + source["already_masked"] = MaskedColumn( + [4.0, 5.0, 6.0], + mask=[False, True, False], + fill_value=-44.0, + ) + state.add_analysis_result("table nullable", source) + service = IOUtilityService(state) + path = tmp_path / "table-nullable.hdf5" + + result = service.export_object( + "analysis_result", + "table nullable", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert result["success"], result + table = read_export(path, "analysis_result", "table nullable") + assert table["nullable"].mask.tolist() == [True, False, False] + assert np.isnan(table["nullable"].data.data[1]) + assert table["hidden"].mask.tolist() == [True, False, False] + assert table["hidden"].fill_value == pytest.approx(-12.5) + assert table["hidden"].unit == u.keV + assert table["hidden"].description == "masked storage is not scientific data" + assert table["hidden"].meta == {"role": "energy"} + assert table["already_masked"].mask.tolist() == [False, True, False] + assert table["already_masked"].fill_value == pytest.approx(-44.0) + + +def test_masked_payload_storage_is_not_compared_as_scientific_data() -> None: + expected = Table() + expected["value"] = MaskedColumn([1.0, 2.0], mask=[True, False], fill_value=-9.0) + actual = expected.copy(copy_data=True) + actual["value"].data.data[0] = 999.0 + + checks = io_module._verify_hdf5_table(expected, actual) + + assert checks == HDF5_VERIFICATION_CHECKS + + +def test_noncoercible_source_fill_is_hdf5_specific_incompatibility( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + source = Table() + source["value"] = MaskedColumn( + np.asarray(["hidden", 2.5], dtype=object), + mask=[True, False], + fill_value="missing", + ) + state.add_analysis_result("noncoercible fill", source) + service = IOUtilityService(state) + + listed = service.list_exportable_objects() + + assert listed["success"], listed + entry = listed["data"]["objects"][0] + assert entry["exportable"] is True + assert entry["formats"] == CORE_FORMATS + assert "cannot preserve" in entry["format_reasons"]["hdf5"] + path = tmp_path / "noncoercible-fill.hdf5" + result = service.export_object( + "analysis_result", + "noncoercible fill", + "hdf5", + str(path), + make_write_grant(path), + ) + assert_private_failure(path, result) + assert "custom fill value" in result["message"] + + +def test_callable_column_format_is_not_advertised_for_hdf5() -> None: + installed_h5py() + state = StateManager() + source = Table({"value": [1.0, 2.0]}) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + source["value"].format = lambda value: f"{value:.2f}" + state.add_analysis_result("callable format", source) + + listed = IOUtilityService(state).list_exportable_objects() + + assert listed["success"], listed + entry = listed["data"]["objects"][0] + assert entry["formats"] == CORE_FORMATS + assert "callable or non-text format" in entry["format_reasons"]["hdf5"] + + +def test_numpy_scalar_metadata_is_canonicalized_without_rounding( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + source = Table({"value": [1.0, 2.0]}) + source.meta["threshold"] = np.float32(1.1) + source["value"].meta["floor"] = np.float32(0.1) + state.add_analysis_result("numpy scalars", source) + service = IOUtilityService(state) + path = tmp_path / "numpy-scalars.hdf5" + + result = service.export_object( + "analysis_result", + "numpy scalars", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert result["success"], result + reopened = read_export(path, "analysis_result", "numpy scalars") + assert reopened.meta["threshold"] == float(np.float32(1.1)) + assert reopened["value"].meta["floor"] == float(np.float32(0.1)) + + +def test_unstable_hdf5_metadata_and_names_keep_core_formats() -> None: + installed_h5py() + state = StateManager() + + class ArraySubclass(np.ndarray): + pass + + mapping = Table({"value": [1.0]}) + mapping.meta["mapping"] = UserDict({"threshold": 1}) + state.add_analysis_result("mapping subclass", mapping) + + masked = Table({"value": [1.0]}) + masked.meta["quality"] = Masked( + np.asarray([1.0, 2.0]) * u.s, + mask=[True, False], + ) + state.add_analysis_result("masked metadata", masked) + + array_subclass = Table({"value": [1.0]}) + array_subclass.meta["matrix"] = np.asarray([[1.0]]).view(ArraySubclass) + state.add_analysis_result("ndarray subclass", array_subclass) + + custom_unit = Table({"value": [1.0]}) + custom_unit["value"].unit = u.def_unit("hdf5_unregistered_custom_unit") + state.add_analysis_result("custom unit", custom_unit) + + control_name = Table({"a\0b": [1.0]}) + state.add_analysis_result("control name", control_name) + + listed = IOUtilityService(state).list_exportable_objects() + + assert listed["success"], listed + entries = {entry["name"]: entry for entry in listed["data"]["objects"]} + expected_reasons = { + "mapping subclass": "mapping subclass 'UserDict'", + "masked metadata": "masked metadata", + "ndarray subclass": "ndarray subclass 'ArraySubclass'", + "custom unit": "external custom-unit definition", + "control name": "control characters in column name", + } + for name, reason in expected_reasons.items(): + assert entries[name]["formats"] == CORE_FORMATS + assert reason in entries[name]["format_reasons"]["hdf5"] + + +def test_non_text_analysis_keys_are_rejected_before_collision_and_publication( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + state.add_analysis_result( + "colliding keys", + {1: [1.0, 2.0], "1": [3.0, 4.0]}, + ) + service = IOUtilityService(state) + path = tmp_path / "colliding-keys.hdf5" + + listed = service.list_exportable_objects() + result = service.export_object( + "analysis_result", + "colliding keys", + "hdf5", + str(path), + make_write_grant(path), + ) + + entry = listed["data"]["objects"][0] + assert entry["exportable"] is False + assert entry["formats"] == [] + assert "field names must be text" in entry["reason"] + assert_private_failure(path, result) + assert "field names must be text" in result["message"] + + +def test_nul_object_name_is_hdf5_specific_and_never_publishes( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + name = "analysis\0hidden" + state.add_analysis_result(name, Table({"value": [1.0]})) + service = IOUtilityService(state) + path = tmp_path / "nul-object-name.hdf5" + + listed = service.list_exportable_objects() + result = service.export_object( + "analysis_result", + name, + "hdf5", + str(path), + make_write_grant(path), + ) + + entry = listed["data"]["objects"][0] + assert entry["exportable"] is True + assert entry["formats"] == CORE_FORMATS + assert "NUL characters in object names" in entry["format_reasons"]["hdf5"] + assert_private_failure(path, result) + assert "NUL characters in object names" in result["message"] + + +def test_non_utf8_object_name_is_hdf5_specific_and_never_publishes( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + name = "analysis\ud800" + state.add_analysis_result(name, Table({"value": [1.0]})) + service = IOUtilityService(state) + path = tmp_path / "non-utf8-object-name.hdf5" + + listed = service.list_exportable_objects() + result = service.export_object( + "analysis_result", + name, + "hdf5", + str(path), + make_write_grant(path), + ) + + entry = listed["data"]["objects"][0] + assert entry["formats"] == CORE_FORMATS + assert "valid UTF-8 text" in entry["format_reasons"]["hdf5"] + assert_private_failure(path, result) + assert "valid UTF-8 text" in result["message"] + + +def test_reserved_astropy_metadata_key_is_hdf5_specific_and_never_publishes( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + table = Table({"value": [1.0]}) + table.meta["__serialized_columns__"] = "collision" + state.add_analysis_result("reserved metadata", table) + service = IOUtilityService(state) + path = tmp_path / "reserved-metadata.hdf5" + + listed = service.list_exportable_objects() + result = service.export_object( + "analysis_result", + "reserved metadata", + "hdf5", + str(path), + make_write_grant(path), + ) + + entry = listed["data"]["objects"][0] + assert entry["exportable"] is True + assert entry["formats"] == CORE_FORMATS + assert "reserved top-level metadata key" in entry["format_reasons"]["hdf5"] + assert_private_failure(path, result) + assert "reserved top-level metadata key" in result["message"] + + +def test_missing_h5py_is_honest_and_does_not_disable_other_formats( + representative_state: StateManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_import_module = io_module.importlib.import_module + + def import_without_h5py(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "h5py": + raise ModuleNotFoundError("synthetic missing optional h5py") + return real_import_module(name, *args, **kwargs) + + monkeypatch.setattr(io_module.importlib, "import_module", import_without_h5py) + service = IOUtilityService(representative_state) + + listed = service.list_exportable_objects() + + assert listed["success"], listed + data = listed["data"] + assert data["format_allowlist"] == CORE_FORMATS + assert data["excluded_formats"]["hdf5"] == HDF5_UNAVAILABLE_REASON + for matrix in data["capability_matrix"].values(): + assert matrix["hdf5"] == { + "supported": False, + "notes": "Optional HDF5 runtime dependency is unavailable.", + "reason": HDF5_UNAVAILABLE_REASON, + "extensions": [".hdf5"], + "dependency": { + "name": "h5py", + "available": False, + "version": None, + }, + } + assert all( + item["formats"] == CORE_FORMATS + and item["format_reasons"] == {"hdf5": HDF5_UNAVAILABLE_REASON} + for item in data["objects"] + ) + + path = tmp_path / "unavailable.hdf5" + result = service.export_object( + "event_list", + "events complete", + "hdf5", + str(path), + make_write_grant(path), + ) + assert_private_failure(path, result) + assert HDF5_UNAVAILABLE_REASON in result["message"] + + json_path = tmp_path / "still-available.json" + json_result = service.export_object( + "event_list", + "events complete", + "json", + str(json_path), + make_write_grant(json_path), + ) + assert json_result["success"], json_result + assert json_path.exists() + + +def test_object_specific_hdf5_incompatibility_keeps_core_formats( + tmp_path: Path, +) -> None: + installed_h5py() + state = StateManager() + table = Table({"value": np.asarray([1.0, 2.0])}) + # The pre-existing core formats can serialize or deliberately stringify a + # set. The versioned HDF5 schema rejects it because its exact semantic + # type is outside the explicitly verified metadata subset. + table.meta["selection"] = {"soft", "hard"} + state.add_analysis_result( + "set metadata", + table, + ) + service = IOUtilityService(state) + + listed = service.list_exportable_objects() + + assert listed["success"], listed + entry = listed["data"]["objects"][0] + assert entry["exportable"] is True + assert entry["formats"] == CORE_FORMATS + assert "type set" in entry["format_reasons"]["hdf5"] + path = tmp_path / "set-metadata.hdf5" + exported = service.export_object( + "analysis_result", + "set metadata", + "hdf5", + str(path), + make_write_grant(path), + ) + assert_private_failure(path, exported) + assert "type set" in exported["message"] + + +def test_hdf5_requires_exact_extension_and_refuses_existing_destination( + representative_state: StateManager, tmp_path: Path +) -> None: + installed_h5py() + service = IOUtilityService(representative_state) + wrong_extension = tmp_path / "events.h5" + + wrong = service.export_object( + "event_list", + "events complete", + "hdf5", + str(wrong_extension), + make_write_grant(wrong_extension), + ) + + assert_private_failure(wrong_extension, wrong) + assert "exact '.hdf5'" in wrong["message"] + + existing = tmp_path / "existing.hdf5" + existing.write_bytes(b"sentinel") + existing_result = service.export_object( + "event_list", + "events complete", + "hdf5", + str(existing), + make_write_grant(existing), + ) + assert existing_result["success"] is False + assert existing.read_bytes() == b"sentinel" + + +def test_hdf5_caps_run_before_copy_and_table_construction( + representative_state: StateManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + installed_h5py() + monkeypatch.setattr(io_module, "MAX_EXPORT_ROWS", 1) + table_built = False + + def forbidden_table(*_args: Any, **_kwargs: Any) -> Table: + nonlocal table_built + table_built = True + raise AssertionError("table construction ran before the row cap") + + monkeypatch.setattr(io_module, "_table_for_object", forbidden_table) + service = IOUtilityService(representative_state) + path = tmp_path / "too-large.hdf5" + + result = service.export_object( + "event_list", + "events complete", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert_private_failure(path, result) + assert table_built is False + assert "operation cap is 1" in result["message"] + + +def test_hdf5_column_metadata_cap_runs_before_copy_and_publication( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + installed_h5py() + state = StateManager() + table = Table({"value": [1.0]}) + table["value"].meta["blob"] = "x" * 1_000_000 + state.add_analysis_result("oversized column metadata", table) + monkeypatch.setattr(io_module, "MAX_EXPORT_ESTIMATED_BYTES", 128) + + def forbidden_copy(*_args: Any, **_kwargs: Any) -> Table: + raise AssertionError("column metadata cap must run before table copy") + + monkeypatch.setattr(table, "copy", forbidden_copy) + service = IOUtilityService(state) + path = tmp_path / "oversized-column-metadata.hdf5" + + listed = service.list_exportable_objects() + result = service.export_object( + "analysis_result", + "oversized column metadata", + "hdf5", + str(path), + make_write_grant(path), + ) + + entry = listed["data"]["objects"][0] + assert entry["exportable"] is False + assert "operation size cap" in entry["reason"] + assert_private_failure(path, result) + assert "operation size cap" in result["message"] + + +@pytest.mark.parametrize( + "corruption", + [ + "corrupt_superblock", + "truncated", + "schema_mismatch", + "missing_table", + "softlink_table", + "enum_field", + "padded_compound_layout", + "opposite_endian_member", + ], +) +def test_corrupt_or_incomplete_hdf5_never_publishes( + representative_state: StateManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + corruption: str, +) -> None: + h5py_module = installed_h5py() + original_write = io_module._write_hdf5_table + + def corrupt_after_write(stream: Any, *args: Any, **kwargs: Any) -> None: + original_write(stream, *args, **kwargs) + if corruption == "corrupt_superblock": + stream.seek(0) + stream.write(b"NOTHDF5!") + elif corruption == "truncated": + stream.seek(0, os.SEEK_END) + stream.truncate(max(1, stream.tell() // 2)) + else: + stream.seek(0) + with h5py_module.File(stream, "r+") as handle: + if corruption == "schema_mismatch": + handle[io_module.HDF5_GROUP_PATH].attrs["schema"] = "wrong.v1" + elif corruption == "missing_table": + del handle[HDF5_TABLE_PATH] + elif corruption == "softlink_table": + alternate_path = f"{io_module.HDF5_GROUP_PATH}/alternate_table" + handle.copy(HDF5_TABLE_PATH, alternate_path) + del handle[HDF5_TABLE_PATH] + handle[HDF5_TABLE_PATH] = h5py_module.SoftLink(f"/{alternate_path}") + else: + original = handle[HDF5_TABLE_PATH][()] + old_dtype = original.dtype + if corruption == "enum_field": + enum_dtype = h5py_module.enum_dtype( + {"one": 1, "two": 2}, + basetype=np.dtype("u2"), + ) + replacement_dtype = np.dtype( + [ + ( + name, + enum_dtype + if name == "channel" + else old_dtype.fields[name][0], + ) + for name in old_dtype.names + ] + ) + elif corruption == "padded_compound_layout": + formats = [ + old_dtype.fields[name][0] for name in old_dtype.names + ] + offsets: list[int] = [] + cursor = 8 + for field_dtype in formats: + offsets.append(cursor) + cursor += field_dtype.itemsize + 3 + replacement_dtype = np.dtype( + { + "names": list(old_dtype.names), + "formats": formats, + "offsets": offsets, + "itemsize": cursor + 8, + } + ) + else: + opposite = ">" if np.little_endian else "<" + replacement_dtype = np.dtype( + [ + ( + name, + ( + old_dtype.fields[name][0].newbyteorder(opposite) + if old_dtype.fields[name][0].kind + in {"i", "u", "f"} + else old_dtype.fields[name][0] + ), + ) + for name in old_dtype.names + ] + ) + replacement = np.empty(original.shape, dtype=replacement_dtype) + for name in old_dtype.names: + replacement[name] = original[name] + del handle[HDF5_TABLE_PATH] + handle.create_dataset(HDF5_TABLE_PATH, data=replacement) + handle.flush() + + monkeypatch.setattr(io_module, "_write_hdf5_table", corrupt_after_write) + service = IOUtilityService(representative_state) + path = tmp_path / f"{corruption}.hdf5" + + result = service.export_object( + "analysis_result", + "analysis complete", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert_private_failure(path, result) + + +def test_reduced_hdf5_integer_precision_never_publishes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + h5py_module = installed_h5py() + state = StateManager() + state.add_analysis_result( + "signed integer precision", + Table({"value": np.asarray([1, 2], dtype=np.int16)}), + ) + original_write = io_module._write_hdf5_table + + def reduce_precision_after_write(stream: Any, *args: Any, **kwargs: Any) -> None: + original_write(stream, *args, **kwargs) + stream.seek(0) + with h5py_module.File(stream, "r+") as handle: + original = handle[HDF5_TABLE_PATH][()] + old_dtype = original.dtype + del handle[HDF5_TABLE_PATH] + dataspace = h5py_module.h5s.create_simple(original.shape) + compound_type = h5py_module.h5t.create( + h5py_module.h5t.COMPOUND, + old_dtype.itemsize, + ) + member_types: list[Any] = [] + dataset_id = None + try: + for name in old_dtype.names: + member_type = h5py_module.h5t.py_create( + old_dtype.fields[name][0], + logical=True, + ) + if name == "value": + member_type.set_precision(8) + compound_type.insert( + name.encode("utf-8"), + old_dtype.fields[name][1], + member_type, + ) + member_types.append(member_type) + dataset_id = h5py_module.h5d.create( + handle[io_module.HDF5_GROUP_PATH].id, + b"table", + compound_type, + dataspace, + ) + dataset_id.write( + h5py_module.h5s.ALL, + h5py_module.h5s.ALL, + original, + ) + finally: + if dataset_id is not None: + dataset_id.close() + for member_type in member_types: + member_type.close() + compound_type.close() + dataspace.close() + handle.flush() + + monkeypatch.setattr(io_module, "_write_hdf5_table", reduce_precision_after_write) + service = IOUtilityService(state) + path = tmp_path / "reduced-integer-precision.hdf5" + + result = service.export_object( + "analysis_result", + "signed integer precision", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert_private_failure(path, result) + assert "noncanonical compound layout or member type" in result["message"] + + +@pytest.mark.parametrize( + "corruption", + ["oversized_manifest", "oversized_table_shape", "oversized_astropy_metadata"], +) +def test_oversized_hdf5_storage_is_rejected_before_publication( + representative_state: StateManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + corruption: str, +) -> None: + h5py_module = installed_h5py() + original_write = io_module._write_hdf5_table + + def corrupt_after_write(stream: Any, *args: Any, **kwargs: Any) -> None: + original_write(stream, *args, **kwargs) + stream.seek(0) + with h5py_module.File(stream, "r+") as handle: + if corruption == "oversized_manifest": + del handle[HDF5_MANIFEST_PATH] + handle.create_dataset( + HDF5_MANIFEST_PATH, + shape=(), + dtype=f"S{io_module.HDF5_MANIFEST_MAX_BYTES + 1}", + data=np.bytes_(b"[]"), + ) + elif corruption == "oversized_table_shape": + table_dtype = handle[HDF5_TABLE_PATH].dtype + del handle[HDF5_TABLE_PATH] + handle.create_dataset( + HDF5_TABLE_PATH, + shape=(io_module.MAX_EXPORT_ROWS + 1,), + dtype=table_dtype, + chunks=(1,), + ) + else: + del handle[io_module.HDF5_ASTROPY_METADATA_PATH] + handle.create_dataset( + io_module.HDF5_ASTROPY_METADATA_PATH, + shape=(io_module.MAX_EXPORT_ESTIMATED_BYTES + 1,), + dtype="S1", + chunks=(1_024,), + ) + handle.flush() + + monkeypatch.setattr(io_module, "_write_hdf5_table", corrupt_after_write) + service = IOUtilityService(representative_state) + path = tmp_path / f"{corruption}.hdf5" + + result = service.export_object( + "analysis_result", + "analysis complete", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert_private_failure(path, result) + + +@pytest.mark.parametrize( + "mismatch", + [ + "row_count", + "column_order", + "dtype", + "mask", + "value", + "unit", + "column_metadata", + "fill_value", + "table_metadata", + "gti", + "provenance", + ], +) +def test_each_semantic_reopen_mismatch_fails_before_publication( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mismatch: str, +) -> None: + installed_h5py() + state = StateManager() + table = Table() + table["value"] = MaskedColumn( + [1.0, np.nan], + mask=[True, False], + fill_value=-9.5, + unit=u.s, + meta={"role": "lag"}, + ) + table["index"] = Column(np.asarray([1, 2], dtype=np.int16)) + table.meta = { + "method": "cross-spectrum", + "gti": np.asarray([[0.0, 2.0]]), + "gti_status": "present", + "provenance": {"operation": "timing"}, + } + state.add_analysis_result("semantic", table) + original_read = io_module._read_hdf5_table + + def mismatched_read(*args: Any, **kwargs: Any) -> tuple[Table, Any]: + reopened, manifest = original_read(*args, **kwargs) + if mismatch == "row_count": + reopened = reopened[:-1] + elif mismatch == "column_order": + reopened = reopened[list(reversed(reopened.colnames))] + elif mismatch == "dtype": + replacement = MaskedColumn( + np.asarray(reopened["value"].data.data, dtype=np.float32), + mask=reopened["value"].mask, + fill_value=reopened["value"].fill_value, + unit=reopened["value"].unit, + meta=reopened["value"].meta, + ) + reopened.replace_column("value", replacement) + elif mismatch == "mask": + reopened["value"].mask[0] = False + elif mismatch == "value": + reopened["value"].data.data[1] = 99.0 + elif mismatch == "unit": + reopened["value"].unit = u.ms + elif mismatch == "column_metadata": + reopened["value"].meta["role"] = "changed" + elif mismatch == "fill_value": + reopened["value"].fill_value = -1.0 + elif mismatch == "table_metadata": + reopened.meta["method"] = "changed" + elif mismatch == "gti": + reopened.meta["gti"][0, 1] = 99.0 + elif mismatch == "provenance": + reopened.meta["provenance"]["operation"] = "changed" + return reopened, manifest + + monkeypatch.setattr(io_module, "_read_hdf5_table", mismatched_read) + service = IOUtilityService(state) + path = tmp_path / f"mismatch-{mismatch}.hdf5" + + result = service.export_object( + "analysis_result", + "semantic", + "hdf5", + str(path), + make_write_grant(path), + ) + + assert_private_failure(path, result) diff --git a/python-backend/tests/test_internal_grant_routes.py b/python-backend/tests/test_internal_grant_routes.py new file mode 100644 index 0000000..c78f3f6 --- /dev/null +++ b/python-backend/tests/test_internal_grant_routes.py @@ -0,0 +1,213 @@ +"""Security tests for the Electron-main-only file-grant issuer.""" + +from __future__ import annotations + +import os +import time +from pathlib import Path + +import httpx +import pytest +from main import BACKEND_SESSION_HEADER, create_app +from routes import internal_grant_routes +from routes.internal_grant_routes import GRANT_ISSUER_HEADER +from services.utility_helpers import ( + FileGrantEligibilityError, + FILE_GRANT_SECRET_ENV, + FILE_GRANT_TTL_SECONDS, + FILE_GRANT_VERSION, + verify_file_grant, +) +from services.windows_secure_fs import WINDOWS_FILE_GRANT_VERSION + +SESSION_SECRET = "session-secret-for-route-tests-32-bytes" +ISSUER_SECRET = "issuer-secret-for-route-tests-32-bytes" +SESSION_HEADERS = {BACKEND_SESSION_HEADER: SESSION_SECRET} +ISSUER_HEADERS = { + BACKEND_SESSION_HEADER: SESSION_SECRET, + GRANT_ISSUER_HEADER: ISSUER_SECRET, +} +ISSUE_PATH = "/internal/file-grants/issue" + + +def make_client(*, issuer_secret: str | None = ISSUER_SECRET): + app = create_app( + session_secret=SESSION_SECRET, + file_grant_secret=issuer_secret, + ) + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) + + +@pytest.mark.asyncio +async def test_renderer_session_alone_cannot_issue_file_grants(tmp_path): + selected = tmp_path / "selected.fits" + selected.write_bytes(b"fits") + async with make_client() as client: + missing = await client.post( + ISSUE_PATH, + headers=SESSION_HEADERS, + json={"path": str(selected), "access": "read"}, + ) + wrong = await client.post( + ISSUE_PATH, + headers={**SESSION_HEADERS, GRANT_ISSUER_HEADER: "x" * 64}, + json={"path": str(selected), "access": "read"}, + ) + wrong_session = await client.post( + ISSUE_PATH, + headers={ + BACKEND_SESSION_HEADER: ISSUER_SECRET, + GRANT_ISSUER_HEADER: ISSUER_SECRET, + }, + json={"path": str(selected), "access": "read"}, + ) + + assert missing.status_code == 401 + assert wrong.status_code == 401 + assert wrong_session.status_code == 401 + assert ISSUER_SECRET not in missing.text + wrong.text + wrong_session.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("origin", ["null", "http://localhost:5173"]) +async def test_any_browser_origin_is_rejected_even_with_both_secrets(tmp_path, origin): + selected = tmp_path / "selected.fits" + selected.write_bytes(b"fits") + async with make_client() as client: + response = await client.post( + ISSUE_PATH, + headers={**ISSUER_HEADERS, "Origin": origin}, + json={"path": str(selected), "access": "read"}, + ) + + assert response.status_code == 403 + assert "grant" not in response.json() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("configured_secret", [None, "too-short"]) +async def test_missing_or_weak_issuer_configuration_fails_closed( + tmp_path, configured_secret, monkeypatch +): + selected = tmp_path / "selected.fits" + selected.write_bytes(b"fits") + if configured_secret is None: + monkeypatch.delenv(FILE_GRANT_SECRET_ENV, raising=False) + async with make_client(issuer_secret=configured_secret) as client: + response = await client.post( + ISSUE_PATH, + headers=ISSUER_HEADERS, + json={"path": str(selected), "access": "read"}, + ) + + assert response.status_code == 503 + assert ISSUER_SECRET not in response.text + + +@pytest.mark.asyncio +async def test_ineligible_native_path_returns_bounded_actionable_400( + tmp_path, monkeypatch +): + selected = tmp_path / "selected.fits" + + def reject_ineligible_path(*_args, **_kwargs): + raise FileGrantEligibilityError( + "Windows reparse points, junctions, and symbolic links are not supported" + ) + + monkeypatch.setattr( + internal_grant_routes, + "issue_file_grant", + reject_ineligible_path, + ) + async with make_client() as client: + response = await client.post( + ISSUE_PATH, + headers=ISSUER_HEADERS, + json={"path": str(selected), "access": "read"}, + ) + + assert response.status_code == 400 + detail = response.json()["detail"] + assert detail == ( + "Windows reparse points, junctions, and symbolic links are not supported" + ) + assert len(detail) <= 256 + assert str(selected) not in response.text + + +@pytest.mark.asyncio +async def test_main_can_issue_and_verify_read_and_write_grants(tmp_path, monkeypatch): + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, ISSUER_SECRET) + selected = tmp_path / "selected.fits" + selected.write_bytes(b"fits") + destination = tmp_path / "export.ecsv" + before = int(time.time()) + + async with make_client() as client: + read_response = await client.post( + ISSUE_PATH, + headers=ISSUER_HEADERS, + json={"path": str(selected), "access": "read"}, + ) + write_response = await client.post( + ISSUE_PATH, + headers=ISSUER_HEADERS, + json={"path": str(destination), "access": "write"}, + ) + + assert read_response.status_code == 200 + assert write_response.status_code == 200 + assert read_response.headers["cache-control"] == "no-store" + expected_version = ( + WINDOWS_FILE_GRANT_VERSION if os.name == "nt" else FILE_GRANT_VERSION + ) + for response, path, access, must_exist in ( + (read_response, selected, "read", True), + (write_response, destination, "write", False), + ): + payload = response.json() + assert Path(payload["path"]) == path.resolve() + assert payload["grant"].startswith(f"{expected_version}.") + assert before + FILE_GRANT_TTL_SECONDS <= payload["expires_at"] + assert payload["expires_at"] <= int(time.time()) + FILE_GRANT_TTL_SECONDS + assert ( + verify_file_grant( + payload["path"], + payload["grant"], + access=access, + must_exist=must_exist, + ) + == path.resolve() + ) + + +@pytest.mark.asyncio +async def test_issuer_request_is_strict_bounded_and_hidden_from_openapi(tmp_path): + selected = tmp_path / "selected.fits" + selected.write_bytes(b"fits") + async with make_client() as client: + invalid_access = await client.post( + ISSUE_PATH, + headers=ISSUER_HEADERS, + json={"path": str(selected), "access": "execute"}, + ) + unexpected_field = await client.post( + ISSUE_PATH, + headers=ISSUER_HEADERS, + json={"path": str(selected), "access": "read", "grant": "forged"}, + ) + oversized_path = await client.post( + ISSUE_PATH, + headers=ISSUER_HEADERS, + json={"path": "x" * 4_097, "access": "read"}, + ) + schema = await client.get("/openapi.json", headers=SESSION_HEADERS) + + assert invalid_access.status_code == 422 + assert unexpected_field.status_code == 422 + assert oversized_path.status_code == 422 + assert schema.status_code == 200 + assert ISSUE_PATH not in schema.json()["paths"] diff --git a/python-backend/tests/test_io_utility_service.py b/python-backend/tests/test_io_utility_service.py new file mode 100644 index 0000000..ad7a20b --- /dev/null +++ b/python-backend/tests/test_io_utility_service.py @@ -0,0 +1,2322 @@ +"""General I/O Utilities service and route tests.""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import inspect +import json +import os +import time +import warnings +from pathlib import Path + +import httpx +import numpy as np +import pytest +from astropy import units as u +from astropy.io import fits +from astropy.table import MaskedColumn, Table +from astropy.utils.exceptions import AstropyUserWarning +from fastapi import FastAPI +from pydantic import ValidationError +from routes import io_utility_routes +from routes.io_utility_routes import ( + ConvertEventListRequest, + ConvertPiRequest, + ExportObjectRequest, + GrantedInputRequest, +) +from services.io_utility_service import IOUtilityService +from services.state_manager import StateManager +from services.utility_helpers import FILE_GRANT_VERSION +from stingray import EventList, Lightcurve +from stingray.io import pi_to_energy + +from services.timing_service import TimingService + +TEST_SECRET = "io-utility-test-file-grant-secret" + + +@pytest.fixture(autouse=True) +def file_grant_secret(monkeypatch): + monkeypatch.setenv("STINGRAY_FILE_GRANT_SECRET", TEST_SECRET) + + +def make_grant( + path: Path, + access: str, + *, + expires_delta: int = 300, + signed_path: Path | None = None, +) -> str: + """Issue the same exact-path HMAC token as Electron main.""" + target = signed_path or path + if target.exists(): + resolved = target.resolve(strict=True) + else: + resolved = target.parent.resolve(strict=True) / target.name + expires = int(time.time()) + expires_delta + identity_path = resolved if access == "read" else resolved.parent + selected_stat = identity_path.stat() + prefix = ( + f"{FILE_GRANT_VERSION}.{expires}.{selected_stat.st_dev}.{selected_stat.st_ino}" + ) + digest = hmac.new( + TEST_SECRET.encode(), + ( + f"{FILE_GRANT_VERSION}\0{access}\0{expires}\0{resolved}\0" + f"{selected_stat.st_dev}\0{selected_stat.st_ino}" + ).encode(), + hashlib.sha256, + ).hexdigest() + return f"{prefix}.{digest}" + + +def write_rmf( + path: Path, + *, + channels: tuple[int, ...] = (0, 1, 2), + e_min: tuple[float, ...] = (0.1, 0.2, 0.4), + e_max: tuple[float, ...] = (0.2, 0.4, 0.8), + units: tuple[str | None, str | None] = ("keV", "keV"), + include_ebounds: bool = True, + energy_format: str = "E", + channel_format: str = "J", + channel_bzero: int | None = None, +) -> None: + primary = fits.PrimaryHDU() + primary.header["MJDREFI"] = 59_000 + primary.header["MJDREFF"] = 0.123456789012345 + hdus: list[fits.hdu.base.ExtensionHDU | fits.PrimaryHDU] = [primary] + if include_ebounds: + channel_array = np.asarray( + channels, dtype=np.uint64 if channel_bzero is not None else None + ) + columns = [ + fits.Column( + name="CHANNEL", + format=channel_format, + bzero=channel_bzero, + array=channel_array, + ), + fits.Column( + name="E_MIN", + format=energy_format, + unit=units[0], + array=np.asarray(e_min), + ), + fits.Column( + name="E_MAX", + format=energy_format, + unit=units[1], + array=np.asarray(e_max), + ), + ] + hdus.append(fits.BinTableHDU.from_columns(columns, name="EBOUNDS")) + fits.HDUList(hdus).writeto(path, checksum=True) + + +def write_event_fits(path: Path) -> None: + primary = fits.PrimaryHDU() + events = fits.BinTableHDU.from_columns( + [ + fits.Column(name="TIME", format="D", unit="s", array=[1.0, 2.0]), + fits.Column(name="PI", format="J", array=[0, 1]), + ], + name="EVENTS", + ) + events.header["MJDREFI"] = 58_000 + events.header["MJDREFF"] = "0.123456789012345678" + events.header["TIMESYS"] = "TT" + events.header["TIMEUNIT"] = "s" + events.header["TSTART"] = 1.0 + events.header["TSTOP"] = 2.0 + fits.HDUList([primary, events]).writeto(path, checksum=True) + + +@pytest.fixture() +def rmf_path(tmp_path: Path) -> Path: + path = tmp_path / "calibration.rmf" + write_rmf(path) + return path + + +@pytest.fixture() +def io_state() -> StateManager: + state = StateManager() + event_list = EventList( + time=np.array([1.0, 2.0, 3.0]), + pi=np.array([0, 1, 2]), + energy=np.array([9.0, 9.0, 9.0]), + gti=np.array([[0.5, 3.5]]), + mjdref=58_000.125, + ) + state.add_event_data("events", event_list) + state.add_lightcurve_data( + "curve", + Lightcurve( + time=np.array([1.0, 2.0, 3.0]), + counts=np.array([5.0, 7.0, 6.0]), + dt=1.0, + mjdref=58_000.125, + ), + ) + state.add_analysis_result( + "result", + {"frequency": [1.0, 2.0], "power": [3.0, 4.0], "norm": "leahy"}, + ) + return state + + +@pytest.fixture() +def service(io_state: StateManager) -> IOUtilityService: + return IOUtilityService(io_state) + + +def test_inspect_fits_reports_lazy_hdu_structure_and_exact_mjdref( + service: IOUtilityService, tmp_path: Path +): + path = tmp_path / "events.fits" + write_event_fits(path) + + result = service.inspect_file(str(path), make_grant(path, "read")) + + assert result["success"], result + data = result["data"] + assert data["supported"] is True + assert data["detected_type"] == "fits" + assert data["size_bytes"] == path.stat().st_size + assert len(data["hdus"]) == 2 + events = data["hdus"][1] + assert events["type"] == "binary_table" + assert events["row_count"] == 2 + assert [column["name"] for column in events["columns"]] == ["TIME", "PI"] + assert "header-only inspection" in data["warnings"][0] + timing = events["timing"] + assert timing["status"] == "available" + assert timing["mjdref"]["decimal"] == "58000.123456789012345678" + assert timing["mjdref"]["source_keywords"] == { + "MJDREFI": "58000", + "MJDREFF": "0.123456789012345678", + } + assert timing["keywords"]["TIMESYS"] == "TT" + json.dumps(result, allow_nan=False) + + +def test_inspect_fits_uses_original_numeric_mjd_card_digits(service, tmp_path): + path = tmp_path / "numeric-mjd.fits" + primary = fits.PrimaryHDU() + primary.header["MJDREFI"] = 58_000 + primary.header.append(fits.Card.fromstring("MJDREFF = 0.123456789012345678")) + primary.writeto(path) + + result = service.inspect_file(str(path), make_grant(path, "read")) + + assert result["success"], result + mjdref = result["data"]["hdus"][0]["timing"]["mjdref"] + assert mjdref["decimal"] == "58000.123456789012345678" + assert mjdref["source_keywords"]["MJDREFF"] == "0.123456789012345678" + + +def test_inspect_fits_nulls_and_warns_on_nonfinite_timing_cards(service, tmp_path): + path = tmp_path / "nonfinite-timing.fits" + primary = fits.PrimaryHDU() + primary.header["MJDREF"] = "NaN" + primary.header["TSTART"] = "Infinity" + primary.writeto(path) + + result = service.inspect_file(str(path), make_grant(path, "read")) + + assert result["success"], result + timing = result["data"]["hdus"][0]["timing"] + assert timing["status"] == "invalid" + assert timing["mjdref"] is None + assert timing["keywords"]["MJDREF"] is None + assert timing["keywords"]["TSTART"] is None + assert any("non-finite" in warning for warning in result["warnings"]) + json.dumps(result, allow_nan=False) + + +def test_inspect_unsupported_and_malformed_files_are_clean(service, tmp_path): + text_path = tmp_path / "notes.txt" + text_path.write_text("not scientific data", encoding="utf-8") + unsupported = service.inspect_file(str(text_path), make_grant(text_path, "read")) + assert unsupported["success"] + assert unsupported["data"]["supported"] is False + assert "Data Ingestion" in unsupported["data"]["warnings"][0] + + malformed_path = tmp_path / "broken.fits" + malformed_path.write_bytes(b"SIMPLE = definitely-not-a-valid-FITS") + malformed = service.inspect_file( + str(malformed_path), make_grant(malformed_path, "read") + ) + assert malformed["success"] is False + assert "Inspecting selected file" in malformed["message"] + + +def test_inspect_fits_reports_conflicting_time_references(service, tmp_path): + path = tmp_path / "ambiguous.fits" + primary = fits.PrimaryHDU() + primary.header["MJDREF"] = 58_000.0 + events = fits.BinTableHDU.from_columns( + [fits.Column(name="TIME", format="D", array=[1.0])], name="EVENTS" + ) + events.header["MJDREF"] = 59_000.0 + fits.HDUList([primary, events]).writeto(path) + + result = service.inspect_file(str(path), make_grant(path, "read")) + assert result["success"], result + assert any("ambiguous" in warning for warning in result["data"]["warnings"]) + + +def test_inspect_fits_rejects_incomplete_split_mjdref(service, tmp_path): + path = tmp_path / "incomplete-mjdref.fits" + primary = fits.PrimaryHDU() + primary.header["MJDREFI"] = 58_000 + primary.writeto(path) + + result = service.inspect_file(str(path), make_grant(path, "read")) + + assert result["success"], result + timing = result["data"]["hdus"][0]["timing"] + assert timing["status"] == "invalid" + assert timing["mjdref"] is None + assert "invalid" in timing["note"] + assert any("invalid or non-finite" in warning for warning in result["warnings"]) + + +def test_inspect_fits_warns_on_conflicting_direct_and_split_mjdref(service, tmp_path): + path = tmp_path / "conflicting-cards.fits" + primary = fits.PrimaryHDU() + primary.header["MJDREF"] = 58_000.0 + primary.header["MJDREFI"] = 59_000 + primary.header["MJDREFF"] = 0.125 + primary.writeto(path) + + result = service.inspect_file(str(path), make_grant(path, "read")) + + assert result["success"], result + timing = result["data"]["hdus"][0]["timing"] + assert timing["status"] == "available" + assert timing["mjdref"]["decimal"] == "58000.0" + assert any("conflicting MJDREF" in warning for warning in result["warnings"]) + + +def test_equivalent_mjdref_decimal_spellings_are_not_cross_hdu_ambiguous( + service, tmp_path +): + path = tmp_path / "equivalent-mjdrefs.fits" + primary = fits.PrimaryHDU() + primary.header["MJDREF"] = "58000.1000" + events = fits.BinTableHDU.from_columns( + [fits.Column(name="TIME", format="D", array=[1.0])], name="EVENTS" + ) + events.header["MJDREF"] = "58000.1" + fits.HDUList([primary, events]).writeto(path) + + result = service.inspect_file(str(path), make_grant(path, "read")) + + assert result["success"], result + assert not any("ambiguous" in warning for warning in result["warnings"]) + + +def test_inspect_fits_combines_split_high_precision_timing_keywords(service, tmp_path): + path = tmp_path / "split-timing.fits" + primary = fits.PrimaryHDU() + primary.header["TSTARTI"] = 12_345 + primary.header.append(fits.Card.fromstring("TSTARTF = 0.123456789012345678")) + primary.header["TSTOPI"] = 12_346 + primary.header["TSTOPF"] = 0.25 + primary.header["TIMEZERI"] = 2 + primary.header["TIMEZERF"] = 0.5 + primary.writeto(path) + + result = service.inspect_file(str(path), make_grant(path, "read")) + + assert result["success"], result + timing = result["data"]["hdus"][0]["timing"] + assert timing["keywords"]["TSTART"] == pytest.approx(12_345.123456789011) + assert timing["keywords"]["TSTOP"] == pytest.approx(12_346.25) + assert timing["keywords"]["TIMEZERO"] == pytest.approx(2.5) + assert timing["high_precision_keywords"]["TSTART"]["decimal"] == ( + "12345.123456789012345678" + ) + assert timing["high_precision_keywords"]["TSTART"]["source_keywords"] == { + "TSTARTI": "12345", + "TSTARTF": "0.123456789012345678", + } + json.dumps(result, allow_nan=False) + + +@pytest.mark.parametrize( + "cards,invalid_keyword", + [ + ({"TIMEDEL": -1.0, "TIMEPIXR": 0.5}, "TIMEDEL"), + ({"TIMEDEL": 1.0, "TIMEPIXR": 2.0}, "TIMEPIXR"), + ({"TSTARTI": 10}, "TSTART"), + ], +) +def test_inspect_fits_nulls_invalid_timing_domains_and_incomplete_splits( + service, tmp_path, cards, invalid_keyword +): + path = tmp_path / f"invalid-{invalid_keyword.lower()}.fits" + primary = fits.PrimaryHDU() + for keyword, value in cards.items(): + primary.header[keyword] = value + primary.writeto(path) + + result = service.inspect_file(str(path), make_grant(path, "read")) + + assert result["success"], result + timing = result["data"]["hdus"][0]["timing"] + assert timing["status"] == "invalid" + assert timing["keywords"][invalid_keyword] is None + assert any(invalid_keyword in warning for warning in result["warnings"]) + json.dumps(result, allow_nan=False) + + +def test_inspect_file_size_cap_is_checked_before_fits_open( + service, tmp_path, monkeypatch +): + import services.io_utility_service as module + + path = tmp_path / "large.fits" + path.write_bytes(b"SIMPLE = " + b"x" * 32) + monkeypatch.setattr(module, "MAX_FITS_INSPECT_BYTES", 8) + result = service.inspect_file(str(path), make_grant(path, "read")) + assert result["success"] is False + assert "supported cap" in result["message"] + + +@pytest.mark.parametrize("case", ["malformed", "forged", "expired", "wrong_access"]) +def test_input_file_grants_are_enforced(service, rmf_path, tmp_path, case): + if case == "malformed": + token = "not-a-file-grant" + elif case == "forged": + other = tmp_path / "other.rmf" + write_rmf(other) + token = make_grant(rmf_path, "read", signed_path=other) + elif case == "expired": + token = make_grant(rmf_path, "read", expires_delta=-5) + else: + token = make_grant(rmf_path, "write") + + result = service.inspect_rmf(str(rmf_path), token) + assert result["success"] is False + assert "grant" in result["message"].lower() or "path" in result["message"].lower() + + +def test_rmf_path_swap_after_grant_open_cannot_redirect_public_reads( + service, rmf_path, tmp_path, monkeypatch +): + import services.io_utility_service as module + + replacement = tmp_path / "replacement.rmf" + write_rmf( + replacement, + channels=(10, 11, 12), + e_min=(1.0, 2.0, 3.0), + e_max=(2.0, 3.0, 4.0), + ) + grant = make_grant(rmf_path, "read") + real_load = module._load_valid_rmf + + def swap_then_load(stream, *, require_energy_unit): + os.replace(replacement, rmf_path) + return real_load(stream, require_energy_unit=require_energy_unit) + + monkeypatch.setattr(module, "_load_valid_rmf", swap_then_load) + result = service.inspect_rmf(str(rmf_path), grant) + + assert result["success"], result + assert result["data"]["channel_min"] == 0 + assert result["data"]["channel_max"] == 2 + with fits.open(rmf_path) as hdul: + np.testing.assert_array_equal(hdul["EBOUNDS"].data["CHANNEL"], [10, 11, 12]) + + +def test_inspect_rmf_returns_validated_bounds(service, rmf_path): + result = service.inspect_rmf(str(rmf_path), make_grant(rmf_path, "read")) + assert result["success"], result + data = result["data"] + assert data["channel_count"] == 3 + assert (data["channel_min"], data["channel_max"]) == (0, 2) + assert data["energy_unit"] == "keV" + assert data["conversion_supported"] is True + assert data["preview_rows"][2]["energy_midpoint"] == pytest.approx(0.6) + + +def test_convert_pasted_pi_matches_public_stingray_call(service, rmf_path): + values = [2, 0, 1, 2] + result = service.convert_pi_values( + values, str(rmf_path), make_grant(rmf_path, "read") + ) + assert result["success"], result + expected = pi_to_energy(np.asarray(values), str(rmf_path)) + actual = np.asarray([row["energy"] for row in result["data"]["rows"]]) + np.testing.assert_allclose(actual, expected) + assert result["data"]["energy_unit"] == "keV" + assert result["data"]["provenance"]["calibrated"] is True + + +def test_convert_pi_rejects_missing_channel_before_upstream_zero_mapping( + service, rmf_path +): + result = service.convert_pi_values( + [0, 99], str(rmf_path), make_grant(rmf_path, "read") + ) + assert result["success"] is False + assert "exact RMF EBOUNDS" in result["message"] + assert "99" in result["message"] + + +def test_exports_preserve_known_units_except_explicitly_lossy_csv(service, tmp_path): + ecsv_path = tmp_path / "events.ecsv" + ecsv_result = service.export_object( + "event_list", + "events", + "ecsv", + str(ecsv_path), + make_grant(ecsv_path, "write"), + ) + assert ecsv_result["success"], ecsv_result + ecsv_table = Table.read(ecsv_path, format="ascii.ecsv") + assert str(ecsv_table["time"].unit) == "s" + assert str(ecsv_table["energy"].unit) == "keV" + assert ecsv_table.meta["gti_time_unit"] == "s" + + json_path = tmp_path / "events.json" + json_result = service.export_object( + "event_list", + "events", + "json", + str(json_path), + make_grant(json_path, "write"), + ) + assert json_result["success"], json_result + payload = json.loads(json_path.read_text(encoding="utf-8")) + assert payload["column_units"]["time"] == "s" + assert payload["column_units"]["energy"] == "keV" + assert payload["column_units"]["pi"] is None + assert payload["metadata"]["gti_time_unit"] == "s" + + fits_path = tmp_path / "events.fits" + fits_result = service.export_object( + "event_list", + "events", + "fits", + str(fits_path), + make_grant(fits_path, "write"), + ) + assert fits_result["success"], fits_result + with fits.open(fits_path, checksum=True) as hdul: + assert hdul["EVENTS"].columns["time"].unit == "s" + assert hdul["EVENTS"].columns["energy"].unit == "keV" + assert hdul["EVENTS"].header["TIMEUNIT"] == "s" + assert hdul["GTI"].columns["START"].unit == "s" + assert hdul["GTI"].columns["STOP"].unit == "s" + assert hdul["GTI"].header["TIMEUNIT"] == "s" + assert hdul["GTI"].header["MJDREF"] == pytest.approx(58_000.125) + assert hdul["GTI"].header["MJDREFI"] == 58_000 + assert hdul["GTI"].header["MJDREFF"] == pytest.approx(0.125) + + lightcurve_path = tmp_path / "curve.ecsv" + lightcurve_result = service.export_object( + "lightcurve", + "curve", + "ecsv", + str(lightcurve_path), + make_grant(lightcurve_path, "write"), + ) + assert lightcurve_result["success"], lightcurve_result + lightcurve_table = Table.read(lightcurve_path, format="ascii.ecsv") + assert str(lightcurve_table["time"].unit) == "s" + assert str(lightcurve_table["counts"].unit) == "ct" + assert lightcurve_table.meta["gti_time_unit"] == "s" + + csv_path = tmp_path / "events.csv" + csv_result = service.export_object( + "event_list", + "events", + "csv", + str(csv_path), + make_grant(csv_path, "write"), + ) + assert csv_result["success"], csv_result + assert any("column units" in warning for warning in csv_result["warnings"]) + + +@pytest.mark.parametrize("export_format", ["json", "ecsv", "fits"]) +def test_lightcurve_background_and_exposure_arrays_are_not_dropped( + service, io_state, tmp_path, export_format +): + curve = Lightcurve( + time=np.array([1.0, 2.0, 3.0]), + counts=np.array([10.0, 12.0, 11.0]), + dt=1.0, + bg_counts=np.array([1.0, 1.5, 1.25]), + bg_ratio=np.array([2.0, 2.0, 2.0]), + frac_exp=np.array([1.0, 0.8, 0.9]), + ) + # Installed Stingray only serializes these valid time-boundary arrays after + # the lazy properties have been materialized. + _ = curve.bin_lo, curve.bin_hi + io_state.add_lightcurve_data("background curve", curve) + path = tmp_path / f"background-curve.{export_format}" + + result = service.export_object( + "lightcurve", + "background curve", + export_format, + str(path), + make_grant(path, "write"), + ) + + assert result["success"], result + if export_format == "json": + payload = json.loads(path.read_text(encoding="utf-8")) + columns = payload["columns"] + assert columns["bg_counts"] == pytest.approx([1.0, 1.5, 1.25]) + assert columns["bg_ratio"] == pytest.approx([2.0, 2.0, 2.0]) + assert columns["frac_exp"] == pytest.approx([1.0, 0.8, 0.9]) + assert payload["column_units"]["bg_counts"] == "ct" + assert payload["column_units"]["bg_ratio"] == "" + assert payload["column_units"]["frac_exp"] == "" + assert payload["column_units"]["bin_lo"] == "s" + assert payload["column_units"]["bin_hi"] == "s" + elif export_format == "ecsv": + table = Table.read(path, format="ascii.ecsv") + np.testing.assert_allclose(table["bg_counts"], [1.0, 1.5, 1.25]) + np.testing.assert_allclose(table["bg_ratio"], [2.0, 2.0, 2.0]) + np.testing.assert_allclose(table["frac_exp"], [1.0, 0.8, 0.9]) + assert str(table["bg_counts"].unit) == "ct" + assert str(table["bin_lo"].unit) == "s" + assert str(table["bin_hi"].unit) == "s" + else: + with fits.open(path, checksum=True) as hdul: + np.testing.assert_allclose(hdul["DATA"].data["bg_counts"], [1.0, 1.5, 1.25]) + np.testing.assert_allclose(hdul["DATA"].data["bg_ratio"], [2.0, 2.0, 2.0]) + np.testing.assert_allclose(hdul["DATA"].data["frac_exp"], [1.0, 0.8, 0.9]) + assert u.Unit(hdul["DATA"].columns["bg_counts"].unit) == u.ct + assert hdul["DATA"].columns["bin_lo"].unit == "s" + assert hdul["DATA"].columns["bin_hi"].unit == "s" + + +def test_variable_lightcurve_bin_widths_are_fits_columns_in_seconds( + service, io_state, tmp_path +): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + curve = Lightcurve( + time=np.array([0.5, 2.0, 4.5]), + counts=np.array([1.0, 2.0, 3.0]), + dt=np.array([1.0, 2.0, 3.0]), + ) + io_state.add_lightcurve_data("variable bins", curve) + path = tmp_path / "variable-bins.fits" + + result = service.export_object( + "lightcurve", + "variable bins", + "fits", + str(path), + make_grant(path, "write"), + ) + + assert result["success"], result + with fits.open(path, checksum=True) as hdul: + np.testing.assert_allclose(hdul["DATA"].data["dt"], [1.0, 2.0, 3.0]) + assert hdul["DATA"].columns["dt"].unit == "s" + + +def test_empty_gti_is_written_and_verified_as_zero_row_extension( + service, io_state, tmp_path +): + empty_gti_events = EventList(time=np.array([1.0, 2.0]), pi=np.array([0, 1])) + empty_gti_events.gti = np.empty((0, 2), dtype=float) + io_state.add_event_data("empty gti", empty_gti_events) + path = tmp_path / "empty-gti.fits" + + result = service.export_object( + "event_list", + "empty gti", + "fits", + str(path), + make_grant(path, "write"), + ) + + assert result["success"], result + with fits.open(path, checksum=True) as hdul: + assert "GTI" in hdul + assert len(hdul["GTI"].data) == 0 + assert hdul["GTI"].header["TIMEUNIT"] == "s" + + +def test_complex_analysis_columns_are_rejected_before_any_file_is_created( + service, io_state, tmp_path +): + io_state.add_analysis_result( + "complex result", + {"frequency": [1.0, 2.0], "amplitude": [1.0 + 2.0j, 3.0 + 4.0j]}, + ) + listed = service.list_exportable_objects() + complex_entry = next( + item for item in listed["data"]["objects"] if item["name"] == "complex result" + ) + assert complex_entry["exportable"] is False + assert "complex" in complex_entry["reason"] + + path = tmp_path / "complex.json" + result = service.export_object( + "analysis_result", + "complex result", + "json", + str(path), + make_grant(path, "write"), + ) + assert result["success"] is False + assert "separate real and imaginary columns" in result["message"] + assert not path.exists() + + +def test_nested_analysis_result_is_not_partially_advertised_or_exported( + service, io_state, tmp_path +): + io_state.add_analysis_result( + "power colors", + { + "time": [1.0, 2.0], + "power_colors": {"band_a": [3.0, 4.0]}, + "freq_ranges": [[0.1, 0.2], [0.2, 0.4]], + }, + ) + + listed = service.list_exportable_objects() + entry = next( + item for item in listed["data"]["objects"] if item["name"] == "power colors" + ) + assert entry["exportable"] is False + assert "losslessly represented" in entry["reason"] + assert entry["formats"] == [] + + path = tmp_path / "power-colors.json" + result = service.export_object( + "analysis_result", + "power colors", + "json", + str(path), + make_grant(path, "write"), + ) + assert result["success"] is False + assert "losslessly represented" in result["message"] + assert not path.exists() + + +@pytest.mark.parametrize( + "values", + [ + np.asarray(["2020-01-01"], dtype="datetime64[D]"), + np.asarray([1], dtype="timedelta64[D]"), + ], +) +def test_datetime_analysis_columns_are_not_advertised_as_universally_exportable( + service, io_state, tmp_path, values +): + name = f"unsupported-{values.dtype.kind}" + io_state.add_analysis_result(name, Table({"value": values})) + + listed = service.list_exportable_objects() + entry = next(item for item in listed["data"]["objects"] if item["name"] == name) + assert entry["exportable"] is False + assert "not losslessly supported" in entry["reason"] + + path = tmp_path / f"{name}.json" + result = service.export_object( + "analysis_result", + name, + "json", + str(path), + make_grant(path, "write"), + ) + assert result["success"] is False + assert "not losslessly supported" in result["message"] + assert not path.exists() + + +@pytest.mark.parametrize( + "values,error_text", + [ + ( + np.asarray([(1, 2.0)], dtype=[("index", "i4"), ("value", "f8")]), + "not losslessly supported", + ), + (np.asarray([b"abc"], dtype="S3"), "not losslessly supported"), + (np.asarray(["é"], dtype="U1"), "non-ASCII Unicode"), + ], +) +def test_structured_and_non_ascii_columns_are_not_advertised_for_all_formats( + service, io_state, tmp_path, values, error_text +): + name = f"unsupported-{values.dtype.kind}" + io_state.add_analysis_result(name, Table({"value": values})) + + listed = service.list_exportable_objects() + entry = next(item for item in listed["data"]["objects"] if item["name"] == name) + assert entry["exportable"] is False + assert error_text in entry["reason"] + assert entry["formats"] == [] + + path = tmp_path / f"{name}.json" + result = service.export_object( + "analysis_result", + name, + "json", + str(path), + make_grant(path, "write"), + ) + assert result["success"] is False + assert error_text in result["message"] + assert not path.exists() + + +def test_masked_analysis_json_preserves_missing_values_as_null( + service, io_state, tmp_path +): + table = Table([MaskedColumn([1.0, 2.0], mask=[False, True])], names=["statistic"]) + io_state.add_analysis_result("masked result", table) + path = tmp_path / "masked-result.json" + + result = service.export_object( + "analysis_result", + "masked result", + "json", + str(path), + make_grant(path, "write"), + ) + + assert result["success"], result + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["columns"]["statistic"] == [1.0, None] + assert any("1 masked value" in warning for warning in result["warnings"]) + + +def test_json_export_uses_null_and_warning_for_nonfinite_analysis_values( + service, io_state, tmp_path +): + sample_count = 10_000 + io_state.add_analysis_result( + "nonfinite result", + { + "frequency": np.arange(sample_count, dtype=float), + "statistic": np.full(sample_count, np.nan), + }, + ) + path = tmp_path / "nonfinite.json" + + result = service.export_object( + "analysis_result", + "nonfinite result", + "json", + str(path), + make_grant(path, "write"), + ) + + assert result["success"], result + payload = json.loads(path.read_text(encoding="utf-8")) + assert len(payload["columns"]["statistic"]) == sample_count + assert set(payload["columns"]["statistic"]) == {None} + assert payload["column_units"]["frequency"] is None + assert payload["column_units"]["statistic"] is None + nonfinite_warnings = [ + warning for warning in result["warnings"] if "non-finite" in warning + ] + assert len(nonfinite_warnings) == 1 + assert "10,000" in nonfinite_warnings[0] + json.dumps(result, allow_nan=False) + json.dumps(payload, allow_nan=False) + + +@pytest.mark.parametrize("export_format", ["json", "ecsv", "fits"]) +def test_nullable_numeric_analysis_columns_remain_exportable( + service, io_state, tmp_path, export_format +): + io_state.add_analysis_result( + "nullable lags", + { + "freq": [1.0, 2.0], + "time_lags": [None, 0.25], + "time_lags_err": [None, None], + "metadata": { + "units": { + "freq": "Hz", + "time_lags": "s", + "time_lags_err": "s", + } + }, + }, + ) + listed = service.list_exportable_objects() + entry = next( + item for item in listed["data"]["objects"] if item["name"] == "nullable lags" + ) + assert entry["exportable"] is True, entry + path = tmp_path / f"nullable-lags.{export_format}" + + result = service.export_object( + "analysis_result", + "nullable lags", + export_format, + str(path), + make_grant(path, "write"), + ) + + assert result["success"], result + if export_format == "json": + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["columns"]["time_lags"] == [None, 0.25] + assert payload["columns"]["time_lags_err"] == [None, None] + assert payload["column_units"]["time_lags"] == "s" + assert not any("non-finite" in warning for warning in result["warnings"]) + json.dumps(payload, allow_nan=False) + elif export_format == "ecsv": + table = Table.read(path, format="ascii.ecsv") + assert table["time_lags"].mask.tolist() == [True, False] + assert table["time_lags"][1] == pytest.approx(0.25) + assert str(table["time_lags"].unit) == "s" + else: + with fits.open(path, checksum=True) as hdul: + assert np.isnan(hdul["DATA"].data["time_lags"][0]) + assert hdul["DATA"].data["time_lags"][1] == pytest.approx(0.25) + assert hdul["DATA"].columns["time_lags"].unit == "s" + + +def test_saved_band_lags_export_nullable_values_and_parameter_metadata( + service, io_state, tmp_path +): + times = np.arange(0.0, 64.0, 0.0625) + io_state.add_event_data("constant a", EventList(time=times, gti=[[0.0, 64.0]])) + io_state.add_event_data("constant b", EventList(time=times, gti=[[0.0, 64.0]])) + timing_result = TimingService(io_state).calculate_time_lags( + "constant a", + "constant b", + dt=0.0625, + segment_size=8.0, + freq_range=(0.5, 2.0), + output_name="band lags", + ) + assert timing_result["success"], timing_result + assert timing_result["data"]["metadata"]["non_column_fields"] == ["freq_range"] + assert any(value is None for value in timing_result["data"]["time_lags_err"]) + + listed = service.list_exportable_objects() + entry = next( + item for item in listed["data"]["objects"] if item["name"] == "band lags" + ) + assert entry["exportable"] is True, entry + path = tmp_path / "band-lags.json" + exported = service.export_object( + "analysis_result", + "band lags", + "json", + str(path), + make_grant(path, "write"), + ) + + assert exported["success"], exported + payload = json.loads(path.read_text(encoding="utf-8")) + assert "freq_range" not in payload["columns"] + assert payload["metadata"]["freq_range"] == [0.5, 2.0] + assert payload["metadata"]["provenance"]["operation"] == "timing_time_lags" + assert payload["column_units"]["freq"] == "Hz" + assert payload["column_units"]["time_lags_err"] == "s" + json.dumps(payload, allow_nan=False) + + +def test_saved_nullable_coherence_result_is_exportable(service, io_state, tmp_path): + times = np.arange(0.0, 64.0, 0.0625) + io_state.add_event_data("constant c", EventList(time=times, gti=[[0.0, 64.0]])) + io_state.add_event_data("constant d", EventList(time=times, gti=[[0.0, 64.0]])) + timing_result = TimingService(io_state).calculate_coherence( + "constant c", + "constant d", + dt=0.0625, + segment_size=8.0, + output_name="nullable coherence", + ) + assert timing_result["success"], timing_result + assert all(value is None for value in timing_result["data"]["coherence"]) + + listed = service.list_exportable_objects() + entry = next( + item + for item in listed["data"]["objects"] + if item["name"] == "nullable coherence" + ) + assert entry["exportable"] is True, entry + path = tmp_path / "nullable-coherence.json" + exported = service.export_object( + "analysis_result", + "nullable coherence", + "json", + str(path), + make_grant(path, "write"), + ) + + assert exported["success"], exported + payload = json.loads(path.read_text(encoding="utf-8")) + assert set(payload["columns"]["coherence"]) == {None} + assert set(payload["columns"]["coherence_err"]) == {None} + assert payload["column_units"]["freq"] == "Hz" + assert payload["metadata"]["provenance"]["operation"] == "timing_coherence" + json.dumps(payload, allow_nan=False) + + +@pytest.mark.parametrize("export_format", ["json", "ecsv", "fits"]) +def test_explicitly_missing_event_gti_is_not_synthesized_during_export( + service, io_state, tmp_path, export_format +): + no_gti = EventList(time=np.array([1.0, 2.0, 3.0]), pi=np.array([0, 1, 2])) + assert no_gti._gti is None + io_state.add_event_data("no explicit gti", no_gti) + path = tmp_path / f"no-gti.{export_format}" + + result = service.export_object( + "event_list", + "no explicit gti", + export_format, + str(path), + make_grant(path, "write"), + ) + + assert result["success"], result + assert any("no explicit GTI" in warning for warning in result["warnings"]) + assert io_state.get_event_data("no explicit gti")._gti is None + if export_format == "json": + payload = json.loads(path.read_text(encoding="utf-8")) + assert "gti" not in payload["metadata"] + assert payload["metadata"]["gti_status"] == "missing" + elif export_format == "ecsv": + table = Table.read(path, format="ascii.ecsv") + assert "gti" not in table.meta + assert table.meta["gti_status"] == "missing" + else: + with fits.open(path, checksum=True) as hdul: + assert "GTI" not in hdul + + +@pytest.mark.parametrize( + "values,error_text", + [ + ([0.5], "integer channel"), + ([-1], "non-negative"), + ([float("nan")], "finite"), + ([float("inf")], "finite"), + ([], "at least 1"), + ], +) +def test_convert_pi_rejects_invalid_arrays(service, rmf_path, values, error_text): + result = service.convert_pi_values( + values, str(rmf_path), make_grant(rmf_path, "read") + ) + assert result["success"] is False + assert error_text in result["message"] + + +def test_convert_pi_enforces_output_cap(service, rmf_path, monkeypatch): + import services.io_utility_service as module + + monkeypatch.setattr(module, "MAX_ARRAY_INPUT", 2) + result = service.convert_pi_values( + [0, 1, 2], str(rmf_path), make_grant(rmf_path, "read") + ) + assert result["success"] is False + assert "cap is 2" in result["message"] + + +def test_convert_pi_bounds_generator_consumption_at_cap_plus_one( + service, rmf_path, monkeypatch +): + import services.io_utility_service as module + + consumed: list[int] = [] + + def values(): + for value in range(10): + consumed.append(value) + yield value + + monkeypatch.setattr(module, "MAX_ARRAY_INPUT", 2) + result = service.convert_pi_values( + values(), str(rmf_path), make_grant(rmf_path, "read") + ) + + assert result["success"] is False + assert "at least 3 values; the cap is 2" in result["message"] + assert consumed == [0, 1, 2] + + +@pytest.mark.parametrize( + "builder,error_text", + [ + (lambda path: write_rmf(path, include_ebounds=False), "exactly one EBOUNDS"), + ( + lambda path: write_rmf(path, channels=(0, 0, 2)), + "duplicate value 0", + ), + ( + lambda path: write_rmf(path, e_min=(0.3, 0.2, 0.4)), + "E_MIN < E_MAX", + ), + ], +) +def test_malformed_rmf_is_rejected(service, tmp_path, builder, error_text): + path = tmp_path / "bad.rmf" + builder(path) + result = service.inspect_rmf(str(path), make_grant(path, "read")) + assert result["success"] is False + assert error_text in result["message"] + + +def test_missing_rmf_units_are_inspectable_but_conversion_is_disabled( + service, tmp_path +): + path = tmp_path / "unitless.rmf" + write_rmf(path, units=(None, None)) + token = make_grant(path, "read") + inspected = service.inspect_rmf(str(path), token) + assert inspected["success"] + assert inspected["data"]["conversion_supported"] is False + assert inspected["data"]["energy_unit"] is None + converted = service.convert_pi_values([0], str(path), token) + assert converted["success"] is False + assert "units are missing" in converted["message"] + + +def test_energy_equivalent_rmf_units_are_normalized_to_kev(service, tmp_path): + path = tmp_path / "electron-volts.rmf" + write_rmf( + path, + e_min=(100.0, 200.0, 400.0), + e_max=(200.0, 400.0, 800.0), + units=("eV", "eV"), + ) + token = make_grant(path, "read") + + inspected = service.inspect_rmf(str(path), token) + assert inspected["success"], inspected + assert inspected["data"]["energy_unit"] == "keV" + assert inspected["data"]["energy_min"] == pytest.approx(0.1) + assert inspected["data"]["preview_rows"][0]["energy_midpoint"] == pytest.approx( + 0.15 + ) + + converted = service.convert_pi_values([0, 2], str(path), token) + assert converted["success"], converted + assert converted["data"]["energy_unit"] == "keV" + assert [row["energy"] for row in converted["data"]["rows"]] == pytest.approx( + [0.15, 0.6] + ) + + +def test_mixed_energy_equivalent_bounds_are_independently_normalized(service, tmp_path): + path = tmp_path / "mixed-energy-units.rmf" + write_rmf( + path, + e_min=(100.0, 200.0, 400.0), + e_max=(0.2, 0.4, 0.8), + units=("eV", "keV"), + ) + token = make_grant(path, "read") + + result = service.convert_pi_values([0, 2], str(path), token) + + assert result["success"], result + assert [row["energy"] for row in result["data"]["rows"]] == pytest.approx( + [0.15, 0.6] + ) + assert any("independently normalized" in warning for warning in result["warnings"]) + + +def test_non_energy_rmf_units_are_rejected(service, tmp_path): + path = tmp_path / "time-bounds.rmf" + write_rmf(path, units=("s", "s")) + + result = service.inspect_rmf(str(path), make_grant(path, "read")) + + assert result["success"] is False + assert "not energy-equivalent" in result["message"] + + +def test_non_energy_unit_is_rejected_even_if_other_bound_unit_is_missing( + service, tmp_path +): + path = tmp_path / "partially-unitless-time-bounds.rmf" + write_rmf(path, units=("s", None)) + + result = service.inspect_rmf(str(path), make_grant(path, "read")) + + assert result["success"] is False + assert "not energy-equivalent" in result["message"] + + +@pytest.mark.parametrize( + "e_min,e_max,label", + [ + ((float("nan"),), (1.0,), "E_MIN"), + ((0.0,), (float("inf"),), "E_MAX"), + ], +) +def test_nonfinite_rmf_bounds_are_rejected(service, tmp_path, e_min, e_max, label): + path = tmp_path / f"nonfinite-{label}.rmf" + write_rmf( + path, + channels=(0,), + e_min=e_min, + e_max=e_max, + energy_format="D", + ) + + result = service.inspect_rmf(str(path), make_grant(path, "read")) + + assert result["success"] is False + assert f"RMF {label}[0] must be finite" in result["message"] + + +def test_negative_rmf_photon_energy_bounds_are_rejected(service, tmp_path): + path = tmp_path / "negative-energy.rmf" + write_rmf( + path, + channels=(0,), + e_min=(-2.0,), + e_max=(-1.0,), + energy_format="D", + ) + + result = service.convert_pi_values([0], str(path), make_grant(path, "read")) + + assert result["success"] is False + assert "non-negative photon energies" in result["message"] + + +def test_rmf_and_pi_channels_enforce_exact_json_integer_boundaries( + service, rmf_path, tmp_path +): + maximum_exact = 2**53 - 1 + safe_path = tmp_path / "maximum-exact-channel.rmf" + write_rmf( + safe_path, + channels=(maximum_exact,), + e_min=(1.0,), + e_max=(2.0,), + channel_format="K", + energy_format="D", + ) + safe = service.convert_pi_values( + [maximum_exact], str(safe_path), make_grant(safe_path, "read") + ) + assert safe["success"], safe + assert safe["data"]["rows"][0]["pi"] == maximum_exact + + too_large_path = tmp_path / "inexact-json-channel.rmf" + write_rmf( + too_large_path, + channels=(2**53,), + e_min=(1.0,), + e_max=(2.0,), + channel_format="K", + energy_format="D", + ) + too_large_rmf = service.inspect_rmf( + str(too_large_path), make_grant(too_large_path, "read") + ) + assert too_large_rmf["success"] is False + assert "exact JSON/JavaScript integer cap" in too_large_rmf["message"] + + with warnings.catch_warnings(record=True) as caught: + too_large_pi = service.convert_pi_values( + [2**53], str(rmf_path), make_grant(rmf_path, "read") + ) + assert too_large_pi["success"] is False + assert "exact JSON/JavaScript integer cap" in too_large_pi["message"] + assert caught == [] + + +def test_uint64_channel_cannot_alias_int64_max_without_warning(service, tmp_path): + path = tmp_path / "uint64-channel.rmf" + write_rmf( + path, + channels=(2**63,), + e_min=(1.0,), + e_max=(2.0,), + channel_format="K", + channel_bzero=2**63, + energy_format="D", + ) + + with warnings.catch_warnings(record=True) as caught: + inspected = service.inspect_rmf(str(path), make_grant(path, "read")) + assert inspected["success"] is False + assert "exact JSON/JavaScript integer cap" in inspected["message"] + assert caught == [] + + # The old float64 path rounded both values to 2**63 and could falsely + # report an exact match before an unsafe int64 cast. + with warnings.catch_warnings(record=True) as caught: + converted = service.convert_pi_values( + [2**63 - 1], str(path), make_grant(path, "read") + ) + assert converted["success"] is False + assert "exact JSON/JavaScript integer cap" in converted["message"] + assert caught == [] + + +def test_rmf_unique_pi_work_cap_precedes_public_stingray_call( + service, rmf_path, monkeypatch +): + import services.io_utility_service as module + + monkeypatch.setattr(module, "MAX_RMF_PI_WORK", 5) + + def forbidden_call(*args, **kwargs): + raise AssertionError("pi_to_energy must not run after the work cap fails") + + monkeypatch.setattr(module, "pi_to_energy", forbidden_call) + result = service.convert_pi_values( + [0, 0, 1], str(rmf_path), make_grant(rmf_path, "read") + ) + + assert result["success"] is False + assert "3 RMF channels x 2 unique PI values" in result["message"] + assert "work cap is 5" in result["message"] + + +def test_huge_finite_rmf_bounds_have_strict_json_safe_midpoints(service, tmp_path): + path = tmp_path / "huge-bounds.rmf" + write_rmf( + path, + channels=(0,), + e_min=(1.0e308,), + e_max=(1.5e308,), + units=("keV", "keV"), + energy_format="D", + ) + token = make_grant(path, "read") + + inspected = service.inspect_rmf(str(path), token) + assert inspected["success"], inspected + assert inspected["data"]["preview_rows"][0]["energy_midpoint"] == pytest.approx( + 1.25e308 + ) + json.dumps(inspected, allow_nan=False) + + converted = service.convert_pi_values([0], str(path), token) + assert converted["success"], converted + assert converted["data"]["rows"][0]["energy"] == pytest.approx(1.25e308) + assert any("overflow-safe" in warning for warning in converted["warnings"]) + json.dumps(converted, allow_nan=False) + + +def test_rmf_with_invalid_checksum_is_rejected(service, tmp_path): + path = tmp_path / "corrupt.rmf" + write_rmf(path) + with path.open("r+b") as stream: + stream.seek(-1, os.SEEK_END) + original = stream.read(1) + stream.seek(-1, os.SEEK_END) + stream.write(bytes([original[0] ^ 1])) + + result = service.inspect_rmf(str(path), make_grant(path, "read")) + assert result["success"] is False + assert "checksum or DATASUM" in result["message"] + + +def test_event_list_conversion_preview_and_save_are_immutable( + service, io_state, rmf_path +): + original = io_state.get_event_data("events") + before = { + "time": original.time.copy(), + "pi": original.pi.copy(), + "energy": original.energy.copy(), + "gti": original.gti.copy(), + } + token = make_grant(rmf_path, "read") + + preview = service.convert_event_list("events", str(rmf_path), token) + assert preview["success"], preview + json.dumps(preview, allow_nan=False) + assert preview["data"]["saved"] is False + assert io_state.list_event_names() == ["events"] + + saved = service.convert_event_list( + "events", str(rmf_path), token, save_as="events calibrated" + ) + assert saved["success"], saved + json.dumps(saved, allow_nan=False) + derived = io_state.get_event_data("events calibrated") + np.testing.assert_array_equal(derived.pi, before["pi"]) + assert derived.pi.dtype == before["pi"].dtype + np.testing.assert_allclose( + derived.energy, pi_to_energy(before["pi"], str(rmf_path)) + ) + assert derived.rmf_conversion_provenance["calibrated"] is True + assert derived.rmf_conversion_provenance["parameters"]["rmf_path"] == str( + rmf_path.resolve() + ) + + np.testing.assert_array_equal(original.time, before["time"]) + np.testing.assert_array_equal(original.pi, before["pi"]) + np.testing.assert_array_equal(original.energy, before["energy"]) + np.testing.assert_array_equal(original.gti, before["gti"]) + assert not hasattr(original, "rmf_conversion_provenance") + + +@pytest.mark.parametrize("export_format", ["json", "ecsv", "fits"]) +def test_rmf_conversion_provenance_survives_safe_exports( + service, io_state, rmf_path, tmp_path, export_format +): + converted = service.convert_event_list( + "events", + str(rmf_path), + make_grant(rmf_path, "read"), + save_as="calibrated provenance", + ) + assert converted["success"], converted + path = tmp_path / f"calibrated-provenance.{export_format}" + + exported = service.export_object( + "event_list", + "calibrated provenance", + export_format, + str(path), + make_grant(path, "write"), + ) + + assert exported["success"], exported + if export_format == "json": + payload = json.loads(path.read_text(encoding="utf-8")) + provenance = payload["metadata"]["rmf_conversion_provenance"] + elif export_format == "ecsv": + provenance = Table.read(path, format="ascii.ecsv").meta[ + "rmf_conversion_provenance" + ] + else: + with fits.open(path, checksum=True) as hdul: + encoded = hdul["METADATA"].data["JSON"][0] + if isinstance(encoded, bytes): + encoded = encoded.decode("ascii") + provenance = json.loads(encoded)["rmf_conversion_provenance"] + assert provenance["operation"] == "rmf_event_list_pi_to_energy" + assert provenance["calibrated"] is True + + +def test_event_list_duplicate_derived_name_never_replaces_existing( + service, io_state, rmf_path +): + sentinel = io_state.get_event_data("events") + result = service.convert_event_list( + "events", + str(rmf_path), + make_grant(rmf_path, "read"), + save_as="events", + ) + assert result["success"] is False + assert "already exists" in result["message"] + assert io_state.get_event_data("events") is sentinel + + +def test_event_list_save_as_name_is_validated(service, io_state, rmf_path): + result = service.convert_event_list( + "events", + str(rmf_path), + make_grant(rmf_path, "read"), + save_as="../not-a-name", + ) + assert result["success"] is False + assert "Destination name" in result["message"] + assert io_state.list_event_names() == ["events"] + + +def test_event_list_without_pi_is_rejected(service, io_state, rmf_path): + io_state.add_event_data("no-pi", EventList(time=[1.0, 2.0])) + result = service.convert_event_list( + "no-pi", str(rmf_path), make_grant(rmf_path, "read") + ) + assert result["success"] is False + assert "no PI channel" in result["message"] + + +def test_list_exportable_objects_has_honest_capability_matrix(service): + result = service.list_exportable_objects() + assert result["success"], result + data = result["data"] + hdf5_supported = data["capability_matrix"]["event_list"]["hdf5"]["supported"] + expected_formats = ["csv", "ecsv", "json", "fits"] + if hdf5_supported: + expected_formats.append("hdf5") + assert data["format_allowlist"] == expected_formats + assert set(data["capability_matrix"]) == { + "event_list", + "lightcurve", + "analysis_result", + } + assert all(item["exportable"] for item in data["objects"]) + assert all( + set(item["formats"]) <= set(data["format_allowlist"]) + for item in data["objects"] + ) + assert "pickle" in data["excluded_formats"] + assert ("hdf5" in data["excluded_formats"]) is not hdf5_supported + + +@pytest.mark.parametrize("export_format", ["csv", "ecsv", "json", "fits"]) +def test_event_list_export_is_exclusive_and_reopen_verified( + service, tmp_path, export_format +): + path = tmp_path / f"events.{export_format}" + result = service.export_object( + "event_list", + "events", + export_format, + str(path), + make_grant(path, "write"), + ) + assert result["success"], result + assert ( + result["data"] + | { + "path": str(path.resolve()), + "format": export_format, + "row_count": 3, + "object_type": "event_list", + "object_name": "events", + "verified": True, + } + == result["data"] + ) + assert result["data"]["bytes"] == path.stat().st_size > 0 + + if export_format == "csv": + assert len(Table.read(path, format="ascii.csv")) == 3 + assert result["data"]["warnings"] + elif export_format == "ecsv": + assert len(Table.read(path, format="ascii.ecsv")) == 3 + elif export_format == "fits": + with fits.open(path, checksum=True) as hdul: + assert hdul[1].name == "EVENTS" + assert len(hdul[1].data) == 3 + np.testing.assert_allclose(hdul["GTI"].data["START"], [0.5]) + np.testing.assert_allclose(hdul["GTI"].data["STOP"], [3.5]) + assert hdul[1].header["HDUCLAS1"] == "GENERIC" + # The installed generic Stingray reader can recover event columns only + # when fmt='fits' is explicit. GTI is preserved in our separate FITS + # extension but is not consumed by that generic reader. + with pytest.warns(AstropyUserWarning, match="multiple tables"): + generic = EventList.read(str(path), fmt="fits", hdu=1) + np.testing.assert_allclose(generic.time, [1.0, 2.0, 3.0]) + np.testing.assert_array_equal(generic.pi, [0, 1, 2]) + np.testing.assert_allclose(generic.energy, [9.0, 9.0, 9.0]) + assert generic.mjdref == pytest.approx(58_000.125) + assert 'fmt="ogip"' not in inspect.getsource(service.export_object).lower() + else: + parsed = json.loads(path.read_text(encoding="utf-8")) + assert parsed["schema"] == "stingray-explorer.tabular.v1" + assert parsed["row_count"] == 3 + assert set(parsed["column_units"]) == set(parsed["columns"]) + json.dumps(parsed, allow_nan=False) + + +@pytest.mark.parametrize( + "object_type,object_name,export_format", + [ + ("lightcurve", "curve", "ecsv"), + ("analysis_result", "result", "json"), + ], +) +def test_other_tabular_objects_export( + service, tmp_path, object_type, object_name, export_format +): + path = tmp_path / f"output.{export_format}" + result = service.export_object( + object_type, + object_name, + export_format, + str(path), + make_grant(path, "write"), + ) + assert result["success"], result + assert result["data"]["verified"] is True + + +@pytest.mark.parametrize("export_format", ["json", "ecsv", "fits"]) +def test_analysis_units_metadata_and_provenance_are_preserved( + service, io_state, tmp_path, export_format +): + io_state.add_analysis_result( + "timing lags", + { + "freq": [1.0, 2.0], + "time_lags": [0.1, 0.2], + "metadata": { + "units": {"freq": "Hz", "time_lags": "s"}, + "method": "cross-spectrum", + }, + "provenance": {"operation": "time_lag", "source": ["a", "b"]}, + }, + ) + path = tmp_path / f"timing-lags.{export_format}" + + result = service.export_object( + "analysis_result", + "timing lags", + export_format, + str(path), + make_grant(path, "write"), + ) + + assert result["success"], result + if export_format == "json": + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["column_units"] == {"freq": "Hz", "time_lags": "s"} + assert payload["metadata"]["metadata"]["method"] == "cross-spectrum" + assert payload["metadata"]["provenance"]["operation"] == "time_lag" + elif export_format == "ecsv": + table = Table.read(path, format="ascii.ecsv") + assert str(table["freq"].unit) == "Hz" + assert str(table["time_lags"].unit) == "s" + assert table.meta["metadata"]["method"] == "cross-spectrum" + assert table.meta["provenance"]["operation"] == "time_lag" + else: + with fits.open(path, checksum=True) as hdul: + assert hdul["DATA"].columns["freq"].unit == "Hz" + assert hdul["DATA"].columns["time_lags"].unit == "s" + encoded = hdul["METADATA"].data["JSON"][0] + if isinstance(encoded, bytes): + encoded = encoded.decode("ascii") + metadata = json.loads(encoded) + assert metadata["metadata"]["method"] == "cross-spectrum" + assert metadata["provenance"]["operation"] == "time_lag" + + +def test_analysis_quantity_columns_keep_their_units(service, io_state, tmp_path): + io_state.add_analysis_result( + "quantity lags", + { + "frequency": np.asarray([1.0, 2.0]) * u.Hz, + "lag": np.asarray([0.1, 0.2]) * u.s, + }, + ) + path = tmp_path / "quantity-lags.json" + + result = service.export_object( + "analysis_result", + "quantity lags", + "json", + str(path), + make_grant(path, "write"), + ) + + assert result["success"], result + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["column_units"] == {"frequency": "Hz", "lag": "s"} + + +def test_analysis_catalog_checks_row_cap_before_table_copy( + service, io_state, monkeypatch +): + import services.io_utility_service as module + + monkeypatch.setattr(module, "MAX_EXPORT_ROWS", 2) + oversized = Table({"frequency": [1.0, 2.0, 3.0]}) + io_state.add_analysis_result("oversized table", oversized) + + def forbidden_copy(*args, **kwargs): + raise AssertionError("catalog must not copy an over-cap table") + + monkeypatch.setattr(oversized, "copy", forbidden_copy) + result = service.list_exportable_objects() + + assert result["success"], result + entry = next( + item for item in result["data"]["objects"] if item["name"] == "oversized table" + ) + assert entry["exportable"] is False + assert "export cap is 2" in entry["reason"] + + +@pytest.mark.parametrize( + "table,constant,value,error_text", + [ + ( + Table({"a": [1.0], "b": [2.0], "c": [3.0]}), + "MAX_EXPORT_COLUMNS", + 2, + "column cap is 2", + ), + ( + Table({"a": [1.0, 2.0], "b": [3.0, 4.0]}), + "MAX_EXPORT_CELLS", + 3, + "cell cap is 3", + ), + ( + Table({"label": ["x" * 200]}), + "MAX_EXPORT_ESTIMATED_BYTES", + 100, + "estimated-size cap", + ), + ], +) +def test_analysis_catalog_checks_shape_and_size_caps_before_table_copy( + service, io_state, monkeypatch, table, constant, value, error_text +): + import services.io_utility_service as module + + monkeypatch.setattr(module, constant, value) + name = f"capped-{constant}" + io_state.add_analysis_result(name, table) + + def forbidden_copy(*args, **kwargs): + raise AssertionError("catalog must reject before copying the table") + + monkeypatch.setattr(table, "copy", forbidden_copy) + result = service.list_exportable_objects() + + assert result["success"], result + entry = next(item for item in result["data"]["objects"] if item["name"] == name) + assert entry["exportable"] is False + assert error_text in entry["reason"] + + +def test_analysis_size_cap_counts_shared_metadata_each_time( + service, io_state, monkeypatch +): + import services.io_utility_service as module + + shared = np.zeros(8, dtype=np.float64) + io_state.add_analysis_result( + "shared metadata", + {"value": [1.0], "metadata": {"first": shared, "second": shared}}, + ) + monkeypatch.setattr(module, "MAX_EXPORT_ESTIMATED_BYTES", 100) + + listed = service.list_exportable_objects() + + entry = next( + item for item in listed["data"]["objects"] if item["name"] == "shared metadata" + ) + assert entry["exportable"] is False + assert "estimated-size cap" in entry["reason"] + + +def test_analysis_catalog_rejects_cyclic_metadata(service, io_state): + metadata = {} + metadata["self"] = metadata + io_state.add_analysis_result( + "cyclic metadata", {"value": [1.0], "metadata": metadata} + ) + + listed = service.list_exportable_objects() + + entry = next( + item for item in listed["data"]["objects"] if item["name"] == "cyclic metadata" + ) + assert entry["exportable"] is False + assert "cycle" in entry["reason"] + + +def test_nested_analysis_metadata_size_cap_precedes_deepcopy( + service, io_state, tmp_path, monkeypatch +): + import services.io_utility_service as io_module + import services.state_manager as state_module + + io_state.add_analysis_result( + "large metadata", + {"value": [1.0], "metadata": {"blob": np.zeros(64, dtype=np.float64)}}, + ) + monkeypatch.setattr(io_module, "MAX_EXPORT_ESTIMATED_BYTES", 100) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("metadata size preflight must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + path = tmp_path / "large-metadata.json" + result = service.export_object( + "analysis_result", + "large metadata", + "json", + str(path), + make_grant(path, "write"), + ) + + assert result["success"] is False + assert "operation size cap" in result["message"] + assert not path.exists() + + +def test_analysis_row_cap_runs_before_sequence_array_conversion_or_deepcopy( + service, io_state, tmp_path, monkeypatch +): + import services.io_utility_service as io_module + import services.state_manager as state_module + + oversized = [1.0, 2.0, 3.0] + io_state.add_analysis_result("oversized sequence", {"value": oversized}) + monkeypatch.setattr(io_module, "MAX_EXPORT_ROWS", 2) + real_asarray = state_module.np.asarray + + def guarded_asarray(value, *args, **kwargs): + if value is oversized: + raise AssertionError( + "row preflight must not materialize the Python sequence" + ) + return real_asarray(value, *args, **kwargs) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("row preflight must run before deepcopy") + + monkeypatch.setattr(state_module.np, "asarray", guarded_asarray) + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + path = tmp_path / "oversized-sequence.json" + + result = service.export_object( + "analysis_result", + "oversized sequence", + "json", + str(path), + make_grant(path, "write"), + ) + + assert result["success"] is False + assert "operation cap is 2" in result["message"] + assert not path.exists() + + +@pytest.mark.parametrize("object_type", ["event_list", "lightcurve"]) +def test_loaded_object_cell_cap_precedes_deepcopy_and_catalog_table_build( + service, io_state, tmp_path, monkeypatch, object_type +): + import services.io_utility_service as io_module + import services.state_manager as state_module + + if object_type == "event_list": + obj = EventList(time=np.asarray([1.0])) + io_state.add_event_data("wide events", obj) + else: + obj = Lightcurve(time=np.asarray([1.0]), counts=np.asarray([2.0]), dt=1.0) + io_state.add_lightcurve_data("wide curve", obj) + for index in range(8): + setattr(obj, f"extra_{index}", np.asarray([float(index)])) + monkeypatch.setattr(io_module, "MAX_EXPORT_CELLS", 5) + + listed = service.list_exportable_objects() + name = "wide events" if object_type == "event_list" else "wide curve" + entry = next(item for item in listed["data"]["objects"] if item["name"] == name) + assert entry["exportable"] is False + assert "cell cap is 5" in entry["reason"] + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("cell preflight must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + path = tmp_path / f"{object_type}.json" + exported = service.export_object( + object_type, + name, + "json", + str(path), + make_grant(path, "write"), + ) + assert exported["success"] is False + assert "operation cell cap is 5" in exported["message"] + assert not path.exists() + + +def test_loaded_object_column_cap_precedes_deepcopy( + service, io_state, tmp_path, monkeypatch +): + import services.io_utility_service as io_module + import services.state_manager as state_module + + events = EventList(time=np.asarray([1.0])) + for index in range(4): + setattr(events, f"custom_{index}", np.asarray([float(index)])) + io_state.add_event_data("many columns", events) + monkeypatch.setattr(io_module, "MAX_EXPORT_COLUMNS", 2) + + listed = service.list_exportable_objects() + entry = next( + item for item in listed["data"]["objects"] if item["name"] == "many columns" + ) + assert entry["exportable"] is False + assert "column cap is 2" in entry["reason"] + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("column preflight must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + path = tmp_path / "many-columns.json" + exported = service.export_object( + "event_list", + "many columns", + "json", + str(path), + make_grant(path, "write"), + ) + assert exported["success"] is False + assert "operation column cap is 2" in exported["message"] + assert not path.exists() + + +def test_loaded_time_row_cap_precedes_array_materialization( + service, io_state, monkeypatch +): + import services.io_utility_service as module + + oversized_time = [1.0, 2.0, 3.0] + events = EventList(time=np.asarray([1.0])) + events._time = oversized_time + io_state.add_event_data("list-backed time", events) + monkeypatch.setattr(module, "MAX_EXPORT_ROWS", 2) + real_asarray = module.np.asarray + + def guarded_asarray(value, *args, **kwargs): + if value is oversized_time: + raise AssertionError("row preflight must precede time-array conversion") + return real_asarray(value, *args, **kwargs) + + monkeypatch.setattr(module.np, "asarray", guarded_asarray) + + listed = service.list_exportable_objects() + + entry = next( + item for item in listed["data"]["objects"] if item["name"] == "list-backed time" + ) + assert entry["exportable"] is False + assert "operation cap is 2" in entry["reason"] + + +def test_export_refuses_existing_destination_without_changing_it(service, tmp_path): + path = tmp_path / "existing.json" + sentinel = b"user-owned sentinel" + path.write_bytes(sentinel) + result = service.export_object( + "event_list", + "events", + "json", + str(path), + make_grant(path, "write"), + ) + assert result["success"] is False + assert "already exists" in result["message"] + assert path.read_bytes() == sentinel + + +def test_export_requires_exact_extension_and_exact_write_grant(service, tmp_path): + wrong_extension = tmp_path / "events.txt" + extension_result = service.export_object( + "event_list", + "events", + "json", + str(wrong_extension), + make_grant(wrong_extension, "write"), + ) + assert extension_result["success"] is False + assert "exact '.json'" in extension_result["message"] + assert not wrong_extension.exists() + + path = tmp_path / "events.json" + other = tmp_path / "other.json" + grant = make_grant(path, "write", signed_path=other) + grant_result = service.export_object( + "event_list", "events", "json", str(path), grant + ) + assert grant_result["success"] is False + assert not path.exists() + + unsupported = tmp_path / "events.pickle" + unsupported_result = service.export_object( + "event_list", + "events", + "pickle", + str(unsupported), + make_grant(unsupported, "write"), + ) + assert unsupported_result["success"] is False + assert "format must be one of" in unsupported_result["message"] + assert not unsupported.exists() + + +def test_export_row_cap_and_failure_cleanup(service, tmp_path, monkeypatch): + import services.io_utility_service as module + + monkeypatch.setattr(module, "MAX_EXPORT_ROWS", 2) + capped = tmp_path / "capped.csv" + result = service.export_object( + "event_list", + "events", + "csv", + str(capped), + make_grant(capped, "write"), + ) + assert result["success"] is False + assert "operation cap is 2" in result["message"] + assert not capped.exists() + + monkeypatch.setattr(module, "MAX_EXPORT_ROWS", 10) + failed = tmp_path / "failed.csv" + + def fail_write(*args, **kwargs): + raise OSError("synthetic writer failure") + + monkeypatch.setattr(Table, "write", fail_write) + failed_result = service.export_object( + "event_list", + "events", + "csv", + str(failed), + make_grant(failed, "write"), + ) + assert failed_result["success"] is False + assert "synthetic writer failure" in failed_result["message"] + assert not failed.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +def test_export_detects_destination_replacement_and_does_not_delete_replacement( + service, tmp_path, monkeypatch +): + destination = tmp_path / "events.json" + attacker_file = tmp_path / "replacement.json" + replacement_bytes = b'{"user":"replacement"}' + attacker_file.write_bytes(replacement_bytes) + + real_link = os.link + + def replace_before_publish(source, target, **kwargs): + os.replace(attacker_file, destination) + return real_link(source, target, **kwargs) + + monkeypatch.setattr(os, "link", replace_before_publish) + result = service.export_object( + "event_list", + "events", + "json", + str(destination), + make_grant(destination, "write"), + ) + assert result["success"] is False + assert "already exists" in result["message"] + # Atomic publication refuses the replacement, and cleanup touches only the + # private staging artifact rather than this user-visible destination. + assert destination.read_bytes() == replacement_bytes + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +def test_export_parent_swap_cannot_redirect_staging_or_cleanup( + service, tmp_path, monkeypatch +): + import services.io_utility_service as module + + selected_parent = tmp_path / "selected-parent" + selected_parent.mkdir() + moved_parent = tmp_path / "moved-parent" + destination = selected_parent / "events.json" + grant = make_grant(destination, "write") + real_mkdir = os.mkdir + swapped = False + + def swap_before_staging(name, mode=0o777, *, dir_fd=None): + nonlocal swapped + if not swapped and str(name).startswith(".stingray-export-"): + swapped = True + os.rename(selected_parent, moved_parent) + real_mkdir(selected_parent, 0o755) + (selected_parent / "sentinel").write_bytes(b"replacement directory") + return real_mkdir(name, mode, dir_fd=dir_fd) + + monkeypatch.setattr(module.os, "mkdir", swap_before_staging) + result = service.export_object( + "event_list", + "events", + "json", + str(destination), + grant, + ) + + assert result["success"] is False + assert (selected_parent / "sentinel").read_bytes() == b"replacement directory" + assert not destination.exists() + assert not (moved_parent / destination.name).exists() + assert list(moved_parent.glob(".stingray-export-*")) == [] + + +def test_export_cleanup_uses_pinned_directories_after_parent_swap( + service, tmp_path, monkeypatch +): + import services.io_utility_service as module + + selected_parent = tmp_path / "selected-parent" + selected_parent.mkdir() + moved_parent = tmp_path / "moved-parent" + destination = selected_parent / "events.json" + grant = make_grant(destination, "write") + replacement_bytes = b"replacement staging artifact" + real_mkdir = os.mkdir + real_unlink = os.unlink + replacement_artifact: Path | None = None + + def swap_before_cleanup(name, *, dir_fd=None): + nonlocal replacement_artifact + if replacement_artifact is None and str(name).startswith("artifact"): + os.rename(selected_parent, moved_parent) + real_mkdir(selected_parent, 0o755) + staging_name = next(moved_parent.glob(".stingray-export-*")).name + replacement_staging = selected_parent / staging_name + real_mkdir(replacement_staging, 0o700) + replacement_artifact = replacement_staging / str(name) + replacement_artifact.write_bytes(replacement_bytes) + return real_unlink(name, dir_fd=dir_fd) + + monkeypatch.setattr(module.os, "unlink", swap_before_cleanup) + result = service.export_object( + "event_list", + "events", + "json", + str(destination), + grant, + ) + + assert result["success"], result + assert replacement_artifact is not None + assert replacement_artifact.read_bytes() == replacement_bytes + assert (moved_parent / destination.name).is_file() + assert not destination.exists() + assert list(moved_parent.glob(".stingray-export-*")) == [] + + +def test_export_cleanup_retains_replacement_staging_directory( + service, tmp_path, monkeypatch +): + import services.io_utility_service as module + + destination = tmp_path / "events.json" + real_open = os.open + real_close = os.close + real_mkdir = os.mkdir + real_rename = os.rename + parent_descriptor: int | None = None + staging_descriptor: int | None = None + staging_name: str | None = None + replacement_created = False + + def tracked_open(name, flags, *args, **kwargs): + nonlocal parent_descriptor, staging_descriptor, staging_name + descriptor = real_open(name, flags, *args, **kwargs) + if Path(name) == tmp_path.resolve(): + parent_descriptor = descriptor + elif str(name).startswith(".stingray-export-"): + staging_descriptor = descriptor + staging_name = str(name) + return descriptor + + def swap_before_staging_close(descriptor): + nonlocal replacement_created + if descriptor == staging_descriptor and not replacement_created: + assert parent_descriptor is not None + assert staging_name is not None + real_rename( + staging_name, + f"{staging_name}.original", + src_dir_fd=parent_descriptor, + dst_dir_fd=parent_descriptor, + ) + real_mkdir(staging_name, 0o700, dir_fd=parent_descriptor) + replacement_created = True + return real_close(descriptor) + + monkeypatch.setattr(module.os, "open", tracked_open) + monkeypatch.setattr(module.os, "close", swap_before_staging_close) + result = service.export_object( + "event_list", + "events", + "json", + str(destination), + make_grant(destination, "write"), + ) + + assert result["success"], result + assert replacement_created + assert staging_name is not None + assert (tmp_path / staging_name).is_dir() + assert (tmp_path / f"{staging_name}.original").is_dir() + assert any("identity changed" in warning for warning in result["warnings"]) + + +@pytest.mark.parametrize( + ("failure_kind", "failure_role"), + [ + ("fstat", "write"), + ("stream", "write"), + ("fstat", "read"), + ("stream", "read"), + ], +) +def test_export_closes_raw_descriptors_on_setup_failure( + service, tmp_path, monkeypatch, failure_kind, failure_role +): + import services.io_utility_service as module + import services.secure_publication as publication_module + + destination = tmp_path / "events.json" + real_open = os.open + real_fstat = os.fstat + real_file_io = publication_module.io.FileIO + captured: dict[str, int] = {} + + def tracked_open(name, flags, *args, **kwargs): + descriptor = real_open(name, flags, *args, **kwargs) + if str(name).startswith("artifact"): + access_mode = flags & os.O_ACCMODE + role = "write" if access_mode in {os.O_WRONLY, os.O_RDWR} else "read" + captured[role] = descriptor + return descriptor + + def injected_fstat(descriptor): + if failure_kind == "fstat" and descriptor == captured.get(failure_role): + raise OSError(f"synthetic {failure_role} fstat failure") + return real_fstat(descriptor) + + def injected_file_io(descriptor, *args, **kwargs): + if failure_kind == "stream" and descriptor == captured.get(failure_role): + raise OSError(f"synthetic {failure_role} stream failure") + return real_file_io(descriptor, *args, **kwargs) + + monkeypatch.setattr(module.os, "open", tracked_open) + monkeypatch.setattr(module.os, "fstat", injected_fstat) + monkeypatch.setattr(publication_module.io, "FileIO", injected_file_io) + result = service.export_object( + "event_list", + "events", + "json", + str(destination), + make_grant(destination, "write"), + ) + + assert result["success"] is False + assert f"synthetic {failure_role} {failure_kind} failure" in result["message"] + assert not destination.exists() + assert failure_role in captured + for descriptor in set(captured.values()): + with pytest.raises(OSError): + real_fstat(descriptor) + staging_entries = list(tmp_path.glob(".stingray-export-*")) + if (failure_kind, failure_role) == ("fstat", "write"): + # The artifact identity could not be authenticated, so fail closed and + # retain the private entry rather than risk deleting a replacement. + assert len(staging_entries) == 1 + else: + assert staging_entries == [] + + +def test_success_payloads_are_strict_json_serializable(service, rmf_path): + results = [ + service.inspect_rmf(str(rmf_path), make_grant(rmf_path, "read")), + service.convert_pi_values([0, 1], str(rmf_path), make_grant(rmf_path, "read")), + service.list_exportable_objects(), + ] + for result in results: + assert result["success"], result + json.dumps(result, allow_nan=False) + + +def test_request_models_forbid_extras_and_nonfinite_values(): + with pytest.raises(ValidationError): + GrantedInputRequest(file_path="/x", file_grant="token", surprise=True) + with pytest.raises(ValidationError): + ConvertPiRequest(rmf_path="/x", rmf_grant="token", pi_values=[float("nan")]) + + +def test_request_models_accept_existing_source_names_from_ingestion(): + long_source_name = "loaded-" + "x" * 300 + + conversion = ConvertEventListRequest( + rmf_path="/selected.rmf", + rmf_grant="token", + event_list_name=long_source_name, + ) + export = ExportObjectRequest( + object_type="event_list", + object_name=long_source_name, + format="json", + destination_path="/selected.json", + destination_grant="token", + ) + + assert conversion.event_list_name == long_source_name + assert export.object_name == long_source_name + + +def test_all_routes_are_registered_and_offload_to_threads(): + paths = {route.path for route in io_utility_routes.router.routes} + assert paths == { + "/inspect-file", + "/inspect-rmf", + "/convert-pi", + "/convert-event-list", + "/exportable-objects", + "/export", + } + for route in io_utility_routes.router.routes: + assert "asyncio.to_thread" in inspect.getsource(route.endpoint), route.path + + +@pytest.mark.asyncio +async def test_io_route_remains_responsive_while_service_runs(monkeypatch): + def slow_list(self): + time.sleep(0.6) + return {"success": True, "data": {}, "message": "ok", "error": None} + + monkeypatch.setattr(IOUtilityService, "list_exportable_objects", slow_list) + app = FastAPI() + app.state.state_manager = StateManager() + app.state.performance_monitor = None + app.include_router(io_utility_routes.router, prefix="/api/utilities/io") + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + slow_task = asyncio.create_task( + client.get("/api/utilities/io/exportable-objects") + ) + started = time.monotonic() + await asyncio.sleep(0) + await asyncio.sleep(0.05) + elapsed = time.monotonic() - started + response = await slow_task + assert response.status_code == 200 + assert elapsed < 0.4, f"event loop was blocked for {elapsed:.2f}s" + + +@pytest.mark.asyncio +async def test_convert_pi_route_end_to_end(rmf_path): + app = FastAPI() + app.state.state_manager = StateManager() + app.state.performance_monitor = None + app.include_router(io_utility_routes.router, prefix="/api/utilities/io") + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/utilities/io/convert-pi", + json={ + "pi_values": [0, 2], + "rmf_path": str(rmf_path), + "rmf_grant": make_grant(rmf_path, "read"), + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"], body + assert [row["pi"] for row in body["data"]["rows"]] == [0, 2] diff --git a/python-backend/tests/test_lightcurve_service.py b/python-backend/tests/test_lightcurve_service.py new file mode 100644 index 0000000..311741c --- /dev/null +++ b/python-backend/tests/test_lightcurve_service.py @@ -0,0 +1,65 @@ +import json + +import pytest + +from services.lightcurve_service import LightcurveService + + +def test_decimation_caps_returned_points(loaded_state): + svc = LightcurveService(loaded_state) + # 64 s span at dt=0.001 -> 64000 bins; cap at 5000 plot points. + result = svc.create_lightcurve_from_event_list( + "ev1", dt=0.001, output_name="lc_fine", max_points=5000 + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert data["n_bins"] == 64000 # true resolution is reported + assert len(data["time"]) <= 5000 # transferred arrays are capped + assert data["plot_stride"] == 13 # ceil(64000 / 5000) + assert len(data["time"]) == len(data["counts"]) + + +def test_no_decimation_below_cap(loaded_state): + svc = LightcurveService(loaded_state) + result = svc.create_lightcurve_from_event_list( + "ev1", dt=1.0, output_name="lc_coarse" + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert data["plot_stride"] == 1 + assert len(data["time"]) == data["n_bins"] + + +def test_get_lightcurve_data_decimates(loaded_state): + svc = LightcurveService(loaded_state) + svc.create_lightcurve_from_event_list("ev1", dt=0.001, output_name="lc_fine2") + result = svc.get_lightcurve_data("lc_fine2", max_points=1000) + assert result["success"], result + json.dumps(result, allow_nan=False) + assert len(result["data"]["time"]) <= 1000 + assert result["data"]["plot_stride"] == 64 + + +def test_stats_use_full_resolution_and_zero_disables_decimation(loaded_state): + svc = LightcurveService(loaded_state) + decimated = svc.create_lightcurve_from_event_list( + "ev1", dt=0.001, output_name="lc_stats_a", max_points=1000 + ) + full = svc.get_lightcurve_data("lc_stats_a", max_points=0) + assert full["data"]["plot_stride"] == 1 + assert len(full["data"]["time"]) == full["data"]["n_bins"] + # count_rate_mean must come from the FULL arrays, not the decimated payload + assert decimated["data"]["count_rate_mean"] == ( + sum(full["data"]["counts"]) / (full["data"]["n_bins"] * full["data"]["dt"]) + ) + + +def test_rebin_scales_dt_by_factor(loaded_state): + svc = LightcurveService(loaded_state) + svc.create_lightcurve_from_event_list("ev1", dt=0.5, output_name="lc_base") + result = svc.rebin_lightcurve("lc_base", rebin_factor=2.0, output_name="lc_base_r2") + assert result["success"], result + assert result["data"]["dt"] == pytest.approx(1.0) # 2 x 0.5, factor semantics + assert "count_rate_mean" in result["data"] diff --git a/python-backend/tests/test_misc_service.py b/python-backend/tests/test_misc_service.py new file mode 100644 index 0000000..e0f9039 --- /dev/null +++ b/python-backend/tests/test_misc_service.py @@ -0,0 +1,838 @@ +"""Tests for the curated Miscellaneous Utilities service and route contract.""" + +from __future__ import annotations + +import inspect +import json +import warnings + +import numpy as np +import pytest +from fastapi.routing import APIRoute +from pydantic import ValidationError +from stingray import EventList +from stingray.utils import ( + baseline_als, + create_window, + equal_count_energy_ranges, + fix_segment_size_to_integer_samples, + nearest_power_of_two, + optimal_bin_time, + poisson_symmetrical_errors, + rebin_data, + rebin_data_log, + standard_error, +) + +import routes.misc_routes as misc_routes +import services.misc_service as misc_module +from routes.misc_routes import LinearRebinRequest, PoissonErrorRequest, WindowRequest +from services.misc_service import ( + LINEAR_REBIN_NEEDS_VARIANCE_INPUT, + MAX_BASELINE_ITERATIONS, + MAX_FFT_SAMPLES, + MAX_POISSON_LOOKUP_COUNT, + MiscService, + SUPPORTED_WINDOWS, +) +from services.utility_helpers import MAX_ARRAY_INPUT, MAX_EXACT_OUTPUT, MAX_MATRIX_CELLS + + +@pytest.fixture() +def service(state_manager): + return MiscService(state_manager) + + +def assert_json_safe(result): + """The production JSON encoder rejects NaN/Infinity, so tests do too.""" + + json.dumps(result, allow_nan=False) + + +def test_capabilities_are_derived_from_installed_stingray(service): + result = service.capabilities() + assert result["success"], result + data = result["data"] + assert data["window_types"] == [ + "uniform", + "parzen", + "hamming", + "hanning", + "triangular", + "welch", + "blackmann", + "flat-top", + ] + assert tuple(data["window_types"]) == SUPPORTED_WINDOWS + assert data["rebin"]["linear_uncertainty_workaround_required"] is True + assert data["limits"]["max_array_values"] == MAX_ARRAY_INPUT + assert data["limits"]["max_matrix_cells"] == MAX_MATRIX_CELLS + assert data["provenance"]["stingray_version"] == "2.2.10" + assert_json_safe(result) + + +def test_window_capabilities_remain_derivable_without_installed_source(monkeypatch): + def unavailable_source(_function): + raise OSError("frozen module has no source file") + + monkeypatch.setattr(misc_module.inspect, "getsource", unavailable_source) + + assert misc_module._derive_supported_windows() == SUPPORTED_WINDOWS + + +def test_numeric_success_payloads_state_units(service): + linear = service.linear_rebin([0.5, 1.5], [1.0, 2.0], 2.0, dx=1.0) + logarithmic = service.logarithmic_rebin( + [1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 4.0], 0.1, dx=1.0 + ) + baseline = service.estimate_baseline(np.arange(8.0), np.linspace(1.0, 2.0, 8)) + window = service.generate_window(4, "uniform") + poisson = service.poisson_errors([0, 4]) + standard = service.standard_error([[1.0, 2.0], [3.0, 4.0]]) + + for result in (linear, logarithmic, baseline, window, poisson, standard): + assert result["success"], result + assert result["data"]["units"] + assert all( + isinstance(value, str) and value + for value in result["data"]["units"].values() + ) + + +@pytest.mark.parametrize( + "method,analytic_error", + [("sum", 2.0 * np.sqrt(2.0)), ("mean", np.sqrt(2.0))], +) +def test_linear_rebin_matches_stingray_bins_and_analytic_uncertainty( + service, method, analytic_error +): + x = np.arange(0.5, 8.5, 1.0) + y = np.arange(1.0, 9.0) + sigma = np.full(x.size, 2.0) + + result = service.linear_rebin( + x, + y, + 2.0, + y_error=sigma, + method=method, + dx=1.0, + ) + assert result["success"], result + data = result["data"] + + # 2.2.10 expects variance input due to the bug fixed by upstream PR #953. + expected = rebin_data(x, y, 2.0, yerr=sigma**2, method=method, dx=1.0) + np.testing.assert_allclose(data["rebinned"]["x"], expected[0]) + np.testing.assert_allclose(data["rebinned"]["y"], expected[1]) + np.testing.assert_allclose(data["rebinned"]["y_error"], expected[2]) + np.testing.assert_allclose(data["rebinned"]["y_error"], analytic_error) + assert data["provenance"]["uncertainty_compatibility"]["workaround_applied"] + assert any("PR #953" in warning for warning in data["warnings"]) + assert LINEAR_REBIN_NEEDS_VARIANCE_INPUT + assert_json_safe(result) + + +def test_linear_rebin_rejects_fractional_overlap_uncertainties(service): + result = service.linear_rebin( + np.arange(0.5, 8.5), + np.ones(8), + 2.5, + y_error=np.full(8, 2.0), + dx=1.0, + ) + assert not result["success"] + assert "integer dx_new / dx" in result["message"] + assert "fractional-overlap" in result["message"] + + +def test_linear_rebin_rejects_uncertainties_for_nonuniform_x(service): + result = service.linear_rebin( + [0.5, 1.5, 3.0, 4.0], + [1.0, 1.0, 1.0, 1.0], + 3.0, + y_error=[1.0, 1.0, 1.0, 1.0], + ) + assert not result["success"] + assert "uniformly spaced" in result["message"] + + +@pytest.mark.parametrize("supplied_dx", [0.1, None]) +def test_linear_rebin_accepts_uniform_grid_quantized_by_large_offset( + service, supplied_dx +): + origin = 1e12 + dx = 0.1 + x = origin + np.arange(20) * dx + y = np.arange(1.0, 21.0) + sigma = np.full(x.size, 0.1) + + # At this absolute scale, one ULP is larger than the apparent variation + # between the two adjacent spacings, despite the scientifically uniform grid. + assert np.ptp(np.diff(x)) > 1e-4 + assert np.spacing(origin) >= np.ptp(np.diff(x)) + + result = service.linear_rebin( + x, + y, + 0.2, + y_error=sigma, + method="sum", + dx=supplied_dx, + ) + assert result["success"], result + + canonical_x = np.arange(x.size, dtype=float) * dx + expected = rebin_data( + canonical_x, + y, + 0.2, + yerr=sigma**2, + method="sum", + dx=dx, + ) + data = result["data"] + np.testing.assert_allclose(data["rebinned"]["x"], expected[0] + origin) + np.testing.assert_allclose(data["rebinned"]["y"], expected[1]) + np.testing.assert_allclose(data["rebinned"]["y_error"], expected[2]) + assert data["provenance"]["coordinate_processing"] == { + "origin_relative_stingray_input": True, + "uniform_grid_reexpressed": True, + "ulp_accommodation_used": True, + } + assert any("floating-point ULPs" in warning for warning in data["warnings"]) + assert_json_safe(result) + + +def test_linear_rebin_still_rejects_real_jitter_on_large_offset(service): + x = 1e12 + np.arange(20) * 0.1 + x[10] += 0.01 + result = service.linear_rebin( + x, + np.ones(x.size), + 0.2, + y_error=np.full(x.size, 0.1), + dx=0.1, + ) + assert not result["success"] + assert "uniformly spaced" in result["message"] + + +@pytest.mark.parametrize("sample_count", [6, 12]) +def test_linear_rebin_preserves_every_complete_uniform_bin(service, sample_count): + x = np.arange(sample_count, dtype=float) * 0.1 + y = np.arange(sample_count, dtype=float) + + result = service.linear_rebin(x, y, 0.2, method="sum", dx=0.1) + + assert result["success"], result + data = result["data"] + expected = rebin_data( + np.arange(sample_count, dtype=float), + y, + 2.0, + method="sum", + dx=1.0, + ) + np.testing.assert_allclose(data["rebinned"]["y"], expected[1]) + np.testing.assert_allclose(data["rebinned"]["y"], y.reshape(-1, 2).sum(axis=1)) + assert sum(data["rebinned"]["y"]) == pytest.approx(sum(y)) + assert len(data["rebinned"]["y"]) == sample_count // 2 + assert any("float-modulo" in warning for warning in data["warnings"]) + + +def test_linear_rebin_without_uncertainties_allows_fractional_bins(service): + x = np.arange(0.5, 8.5) + y = np.arange(8.0) + result = service.linear_rebin(x, y, 2.5, method="mean", dx=1.0) + expected = rebin_data(x, y, 2.5, method="mean", dx=1.0) + assert result["success"], result + assert result["data"]["rebinned"]["y_error"] is None + np.testing.assert_allclose(result["data"]["rebinned"]["x"], expected[0]) + np.testing.assert_allclose(result["data"]["rebinned"]["y"], expected[1]) + + +def test_linear_rebin_scales_extreme_finite_values_without_overflow_or_underflow( + service, +): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = service.linear_rebin( + np.arange(4.0), + np.full(4, 1e308), + 2.0, + method="mean", + dx=1.0, + y_error=np.full(4, 1e-200), + ) + + assert result["success"], result + np.testing.assert_allclose(result["data"]["rebinned"]["y"], 1e308) + np.testing.assert_allclose( + result["data"]["rebinned"]["y_error"], + np.sqrt(2.0) / 2.0 * 1e-200, + rtol=1e-12, + atol=0.0, + ) + assert not [item for item in caught if issubclass(item.category, RuntimeWarning)] + assert_json_safe(result) + + +def test_linear_rebin_scales_large_single_sample_uncertainty(service): + result = service.linear_rebin( + [0.0, 1.0], + [1.0, 2.0], + 1.0, + method="mean", + dx=1.0, + y_error=[1e308, 1e308], + ) + + assert result["success"], result + np.testing.assert_allclose(result["data"]["rebinned"]["y_error"], 1e308) + assert_json_safe(result) + + +def test_linear_rebin_fails_closed_when_true_sum_exceeds_float_range(service): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = service.linear_rebin( + [0.0, 1.0], + [1e308, 1e308], + 2.0, + method="sum", + dx=1.0, + ) + + assert result["success"] is False + assert result["error"] is None + assert "outside the finite float range" in result["message"] + assert not [item for item in caught if issubclass(item.category, RuntimeWarning)] + + +@pytest.mark.parametrize("dx_new", [0.3, 1e308]) +def test_linear_rebin_rejects_target_wider_than_covered_input(service, dx_new): + result = service.linear_rebin( + [0.0, 0.1], + [1.0, 2.0], + dx_new, + method="sum", + dx=0.1, + ) + + assert result["success"] is False + assert result["error"] is None + assert "no complete output bin fits" in result["message"] + + +def test_logarithmic_rebin_matches_stingray_and_labels_errors_as_mean(service): + x = np.arange(1.0, 101.0) + y = np.linspace(2.0, 5.0, x.size) + sigma = np.full(x.size, 2.0) + result = service.logarithmic_rebin( + x, + y, + 0.1, + y_error=sigma, + dx=1.0, + ) + assert result["success"], result + expected = rebin_data_log(x, y, 0.1, y_err=sigma, dx=1.0) + data = result["data"] + np.testing.assert_allclose(data["rebinned"]["x"], expected[0]) + np.testing.assert_allclose(data["rebinned"]["y"], expected[1]) + np.testing.assert_allclose(data["rebinned"]["y_error"], expected[2]) + np.testing.assert_array_equal(data["rebinned"]["samples_per_bin"], expected[3]) + assert data["method"] == "mean" + assert "sqrt(sum(sigma_i^2)) / N" in data["error_semantics"] + assert_json_safe(result) + + +@pytest.mark.parametrize("uncertainty", [1e-200, 1e308]) +def test_logarithmic_rebin_scales_extreme_finite_values(service, uncertainty): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = service.logarithmic_rebin( + [1.0, 2.0], + [1e308, 1e308], + 0.1, + y_error=[uncertainty, uncertainty], + dx=1.0, + ) + + assert result["success"], result + np.testing.assert_allclose(result["data"]["rebinned"]["y"], 1e308) + np.testing.assert_allclose( + result["data"]["rebinned"]["y_error"], + uncertainty, + rtol=1e-12, + atol=0.0, + ) + assert not [item for item in caught if issubclass(item.category, RuntimeWarning)] + assert_json_safe(result) + + +def test_logarithmic_rebin_rejects_non_growing_edge_before_public_call( + service, monkeypatch +): + origin = 1e8 + dx = 2.0 * np.spacing(origin) + x = origin + np.arange(10) * dx + + def must_not_run(*args, **kwargs): + raise AssertionError("non-progressing public rebin must be rejected first") + + monkeypatch.setattr(misc_module, "rebin_data_log", must_not_run) + result = service.logarithmic_rebin(x, np.arange(10.0), 0.1, dx=dx) + + assert result["success"] is False + assert result["error"] is None + assert "cannot grow" in result["message"] + assert "non-progressing edge loop" in result["message"] + + +def test_baseline_matches_public_stingray_api(service): + x = np.linspace(0.0, 10.0, 101) + y = 4.0 + 0.2 * x + np.exp(-((x - 5.0) ** 2) / 0.2) + result = service.estimate_baseline( + x, + y, + lam=1e5, + asymmetry=0.01, + iterations=12, + ) + assert result["success"], result + expected_corrected, expected_baseline = baseline_als( + x, + y, + lam=1e5, + p=0.01, + niter=12, + return_baseline=True, + ) + np.testing.assert_allclose(result["data"]["baseline"], expected_baseline) + np.testing.assert_allclose(result["data"]["corrected"], expected_corrected) + assert_json_safe(result) + + +def test_baseline_scales_large_finite_constant_without_nulls(service): + result = service.estimate_baseline( + np.arange(8.0), + np.full(8, 1e308), + lam=1e5, + asymmetry=0.01, + iterations=12, + ) + + assert result["success"], result + data = result["data"] + assert np.all(np.isfinite(data["baseline"])) + assert np.all(np.isfinite(data["corrected"])) + expected_corrected, expected_baseline = baseline_als( + np.arange(8.0), + np.ones(8), + lam=1e5, + p=0.01, + niter=12, + return_baseline=True, + ) + np.testing.assert_allclose(data["baseline"], expected_baseline * 1e308) + np.testing.assert_allclose(data["corrected"], expected_corrected * 1e308) + assert_json_safe(result) + + +@pytest.mark.parametrize("window_type", SUPPORTED_WINDOWS) +def test_every_reported_window_matches_stingray(service, window_type): + result = service.generate_window(32, window_type) + assert result["success"], result + np.testing.assert_allclose(result["data"]["window"], create_window(32, window_type)) + assert result["data"]["summary"]["energy"] >= 0 + assert_json_safe(result) + + +def test_short_zero_sum_window_is_json_safe_and_explained(service): + result = service.generate_window(2, "hanning") + assert result["success"], result + assert result["data"]["summary"]["equivalent_noise_bandwidth_bins"] is None + assert any("undefined" in warning for warning in result["data"]["warnings"]) + assert_json_safe(result) + + +def test_nonfinite_stingray_output_becomes_null_with_warning(service, monkeypatch): + monkeypatch.setattr( + misc_module, + "create_window", + lambda count, window_type: np.asarray([1.0, np.nan]), + ) + result = service.generate_window(2, "uniform") + assert result["success"], result + assert result["data"]["window"] == [1.0, None] + assert any("non-finite" in warning for warning in result["data"]["warnings"]) + assert_json_safe(result) + + +def test_sampling_helpers_match_public_stingray_functions(service): + bin_result = service.calculate_optimal_bin_time(10.0, 0.3) + assert bin_result["success"], bin_result + expected_bin = optimal_bin_time(10.0, 0.3) + assert bin_result["data"]["adjusted_bin_time"] == pytest.approx(expected_bin) + assert bin_result["data"]["sample_count"] == 64 + assert bin_result["data"]["changed"] + + power_result = service.calculate_nearest_power_of_two(65) + assert power_result["success"], power_result + assert power_result["data"]["nearest_power_of_two"] == nearest_power_of_two(65) + assert power_result["data"]["delta"] == -1 + assert power_result["data"]["units"] == "dimensionless" + + segment_result = service.adjust_segment_size(0.999, 0.1, tolerance=0.01) + assert segment_result["success"], segment_result + expected_segment, expected_samples = fix_segment_size_to_integer_samples( + 0.999, 0.1, tolerance=0.01 + ) + assert segment_result["data"]["adjusted_segment_size"] == expected_segment + assert segment_result["data"]["sample_count"] == expected_samples + assert segment_result["data"]["changed"] + + for result in (bin_result, power_result, segment_result): + assert_json_safe(result) + + +@pytest.mark.parametrize( + "fft_length,proposed_bin_time", + [(1e-320, 1e-322), (5e-323, 1e-323)], +) +def test_optimal_bin_time_fails_closed_for_subnormal_non_power_of_two_count( + service, fft_length, proposed_bin_time +): + result = service.calculate_optimal_bin_time(fft_length, proposed_bin_time) + + assert result["success"] is False + assert result["error"] is None + assert "power-of-two FFT sample count" in result["message"] + + +def test_segment_size_fails_closed_when_upward_rounding_overflows(service): + maximum = np.finfo(float).max + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = service.adjust_segment_size( + maximum, + maximum / 1.995, + tolerance=0.01, + ) + + assert result["success"] is False + assert result["error"] is None + assert "non-finite or invalid adjusted segment size" in result["message"] + assert not [item for item in caught if issubclass(item.category, RuntimeWarning)] + + +def test_poisson_errors_match_public_stingray_api(service): + counts = np.asarray([0, 1, 2, 10, 100]) + result = service.poisson_errors(counts) + assert result["success"], result + np.testing.assert_allclose( + result["data"]["symmetric_error"], poisson_symmetrical_errors(counts) + ) + assert "frequentist-confidence" in result["data"]["assumptions"] + assert_json_safe(result) + + +def test_standard_error_matches_stingray_and_analytic_sem(service): + samples = np.asarray([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) + mean = np.mean(samples, axis=0) + result = service.standard_error(samples) + assert result["success"], result + expected = standard_error(samples, mean) + np.testing.assert_allclose(result["data"]["standard_error"], expected) + np.testing.assert_allclose( + result["data"]["standard_error"], np.full(2, np.sqrt(4.0 / 3.0)) + ) + np.testing.assert_allclose(result["data"]["mean"], mean) + assert result["data"]["mean_source"] == "calculated_arithmetic_mean" + assert_json_safe(result) + + +def test_standard_error_rejects_non_mean_reference(service): + result = service.standard_error([[1.0, 2.0], [3.0, 4.0]], mean=[0.0, 0.0]) + assert not result["success"] + assert "arithmetic sample mean" in result["message"] + + +def test_standard_error_scales_large_identical_samples(service): + result = service.standard_error([[1e308], [1e308]]) + + assert result["success"], result + np.testing.assert_allclose(result["data"]["mean"], [1e308]) + np.testing.assert_array_equal(result["data"]["standard_error"], [0.0]) + assert_json_safe(result) + + +def test_standard_error_scales_tiny_nonzero_samples(service): + result = service.standard_error([[1e-200], [-1e-200]]) + + assert result["success"], result + np.testing.assert_array_equal(result["data"]["mean"], [0.0]) + np.testing.assert_allclose( + result["data"]["standard_error"], + [1e-200], + rtol=1e-12, + atol=0.0, + ) + assert_json_safe(result) + + +def test_equal_count_pasted_energy_matches_stingray(service): + energies = np.linspace(0.5, 10.0, 100) + result = service.equal_count_ranges(n_ranges=5, energies=energies) + assert result["success"], result + data = result["data"] + expected = equal_count_energy_ranges(energies, 5) + np.testing.assert_allclose(data["bin_edges"], expected) + expected_counts, _ = np.histogram(energies, bins=expected) + np.testing.assert_array_equal(data["counts"], expected_counts) + assert sum(data["counts"]) == 100 + assert data["energy_unit"] == "keV" + assert data["provenance"]["input_source"] == {"kind": "pasted_values"} + assert_json_safe(result) + + +def test_equal_count_event_energy_uses_snapshot_and_does_not_mutate_source( + state_manager, +): + times = np.arange(20.0) + energies = np.linspace(1.0, 8.0, 20) + events = EventList(time=times, energy=energies, gti=[[0.0, 20.0]]) + state_manager.add_event_data("science", events) + stored = state_manager.get_event_data("science") + time_before = stored.time.copy() + energy_before = stored.energy.copy() + time_identity = id(stored.time) + energy_identity = id(stored.energy) + + result = MiscService(state_manager).equal_count_ranges( + n_ranges=4, + event_list_name="science", + ) + assert result["success"], result + assert result["data"]["energy_unit"] == "keV" + assert result["data"]["provenance"]["source_snapshot"] is True + np.testing.assert_array_equal(stored.time, time_before) + np.testing.assert_array_equal(stored.energy, energy_before) + assert id(stored.time) == time_identity + assert id(stored.energy) == energy_identity + + +def test_equal_count_event_without_energy_is_rejected(state_manager): + state_manager.add_event_data( + "no-energy", + EventList(time=np.arange(5.0), gti=[[0.0, 5.0]]), + ) + result = MiscService(state_manager).equal_count_ranges( + n_ranges=2, + event_list_name="no-energy", + ) + assert not result["success"] + assert "no energy data" in result["message"] + + +def test_equal_count_missing_event_list_is_rejected(service): + result = service.equal_count_ranges(n_ranges=2, event_list_name="missing") + assert not result["success"] + assert "was not found" in result["message"] + + +def test_event_energy_unit_cannot_be_silently_reinterpreted(state_manager): + state_manager.add_event_data( + "science", + EventList( + time=np.arange(5.0), + energy=np.linspace(1.0, 5.0, 5), + gti=[[0.0, 5.0]], + ), + ) + result = MiscService(state_manager).equal_count_ranges( + n_ranges=2, + event_list_name="science", + energy_unit="MeV", + ) + assert not result["success"] + assert "must be 'keV'" in result["message"] + + +@pytest.mark.parametrize( + "call,expected", + [ + (lambda svc: svc.linear_rebin([0, 1], [1], 2), "same length"), + (lambda svc: svc.linear_rebin([0, np.nan], [1, 2], 2), "x[1]"), + (lambda svc: svc.linear_rebin([0, 0], [1, 2], 2), "strictly increasing"), + ( + lambda svc: svc.linear_rebin([0, 1], [1, 2], 2, y_error=[1, -1]), + "non-negative", + ), + (lambda svc: svc.logarithmic_rebin([0, 1], [1, 2], 0.1), "positive"), + (lambda svc: svc.logarithmic_rebin([1, 2], [1, 2], 0), "factor"), + (lambda svc: svc.estimate_baseline([0, 1], [1, 2]), "at least 3"), + ( + lambda svc: svc.estimate_baseline([0, 1, 2], [1, 2, 3], asymmetry=1), + "asymmetry", + ), + ( + lambda svc: svc.estimate_baseline( + [0, 1, 2], + [1, 2, 3], + iterations=MAX_BASELINE_ITERATIONS + 1, + ), + "iterations", + ), + (lambda svc: svc.generate_window(1, "uniform"), "n_samples"), + (lambda svc: svc.generate_window(8, "blackman"), "window_type"), + (lambda svc: svc.calculate_optimal_bin_time(1, 2), "must not exceed"), + (lambda svc: svc.calculate_nearest_power_of_two(1), "between 2"), + (lambda svc: svc.calculate_nearest_power_of_two(3.5), "integer"), + ( + lambda svc: svc.calculate_nearest_power_of_two(70), + "result is withheld", + ), + (lambda svc: svc.adjust_segment_size(0.05, 0.1), "at least one dt"), + (lambda svc: svc.poisson_errors([1, 2.5]), "integer Poisson"), + (lambda svc: svc.poisson_errors([1, -1]), "non-negative"), + (lambda svc: svc.standard_error([[1, 2]]), "at least two rows"), + ( + lambda svc: svc.standard_error([[1, 2], [3, np.inf]]), + "samples[1][1]", + ), + ( + lambda svc: svc.equal_count_ranges(n_ranges=2), + "exactly one energy source", + ), + ( + lambda svc: svc.equal_count_ranges( + n_ranges=2, energies=[1, 2], event_list_name="also" + ), + "exactly one energy source", + ), + ( + lambda svc: svc.equal_count_ranges(n_ranges=3, energies=[1, 2]), + "at least 3", + ), + ( + lambda svc: svc.equal_count_ranges(n_ranges=2, energies=[1, 1, 1, 1]), + "energy_min", + ), + ], +) +def test_invalid_domains_are_actionable(service, call, expected): + result = call(service) + assert not result["success"], result + assert expected in result["message"] + + +def test_explicit_allocation_caps_reject_before_large_work(service): + too_many = np.zeros(MAX_ARRAY_INPUT + 1) + array_result = service.poisson_errors(too_many) + assert not array_result["success"] + assert "cap" in array_result["message"] + + lookup_result = service.poisson_errors([MAX_POISSON_LOOKUP_COUNT + 1]) + assert not lookup_result["success"] + assert "lookup allocation" in lookup_result["message"] + + matrix = np.zeros((500, MAX_MATRIX_CELLS // 500 + 1)) + matrix_result = service.standard_error(matrix) + assert not matrix_result["success"] + assert "cells" in matrix_result["message"] + + fft_result = service.calculate_optimal_bin_time(1.0, 1.0 / (MAX_FFT_SAMPLES + 1)) + assert not fft_result["success"] + assert "FFT" in fft_result["message"] and "cap" in fft_result["message"] + + rebin_result = service.linear_rebin( + [0.0, 1.0], + [1.0, 2.0], + 1e-9, + dx=1e-9, + ) + assert not rebin_result["success"] + assert "allocate" in rebin_result["message"] + + +def test_exact_arrays_are_preserved_while_plot_preview_is_decimated(service): + x = np.arange(0.5, 6001.5) + y = np.sin(x) + result = service.linear_rebin(x, y, 2.0, dx=1.0) + assert result["success"], result + original = result["data"]["original"] + preview = result["data"]["plot_preview"]["original"] + assert len(original["x"]) == 6001 + assert preview["source_points"] == 6001 + assert preview["stride"] == 2 + assert len(preview["values"]["x"]) == 3001 + assert_json_safe(result) + + +def test_route_models_reject_coercion_and_unknown_fields(): + with pytest.raises(ValidationError): + LinearRebinRequest( + x=[True, 1.0], + y=[1.0, 2.0], + dx_new=2.0, + ) + with pytest.raises(ValidationError): + WindowRequest(n_samples=8, window_type="uniform", arbitrary="nope") + with pytest.raises(ValidationError): + LinearRebinRequest( + x=[0.0, np.nan], + y=[1.0, 2.0], + dx_new=2.0, + ) + with pytest.raises(ValidationError): + PoissonErrorRequest(counts=[0.0] * (MAX_ARRAY_INPUT + 1)) + + +def test_every_misc_route_structurally_offloads_to_thread(): + api_routes = [ + route for route in misc_routes.router.routes if isinstance(route, APIRoute) + ] + assert {route.path for route in api_routes} == { + "/capabilities", + "/rebin/linear", + "/rebin/logarithmic", + "/baseline", + "/window", + "/sampling/optimal-bin-time", + "/sampling/nearest-power-of-two", + "/sampling/segment-size", + "/errors/poisson", + "/errors/standard", + "/energy-ranges", + } + for route in api_routes: + source = inspect.getsource(route.endpoint) + assert "asyncio.to_thread" in source, f"{route.path} blocks the event loop" + + +@pytest.mark.asyncio +async def test_window_route_dispatches_through_to_thread(service, monkeypatch): + calls = [] + + async def fake_to_thread(function, *args, **kwargs): + calls.append((function, args, kwargs)) + return function(*args, **kwargs) + + monkeypatch.setattr(misc_routes.asyncio, "to_thread", fake_to_thread) + result = await misc_routes.generate_window( + WindowRequest(n_samples=8, window_type="hamming"), + service, + ) + assert result["success"], result + assert len(calls) == 1 + assert calls[0][0] == service.generate_window + + +def test_limits_exported_by_capabilities_match_service_constants(service): + limits = service.capabilities()["data"]["limits"] + assert limits["max_exact_output_values"] == MAX_EXACT_OUTPUT + assert limits["max_fft_samples"] == MAX_FFT_SAMPLES + assert limits["max_poisson_count"] == MAX_POISSON_LOOKUP_COUNT diff --git a/python-backend/tests/test_mission_io_service.py b/python-backend/tests/test_mission_io_service.py new file mode 100644 index 0000000..6938bb5 --- /dev/null +++ b/python-backend/tests/test_mission_io_service.py @@ -0,0 +1,1283 @@ +"""Tests for Mission-Specific I/O service and route contracts.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from pathlib import Path + +import numpy as np +import pytest +import routes.mission_io_routes as mission_routes +import services.mission_io_service as mission_module +from astropy.io import fits +from pydantic import ValidationError +from routes.mission_io_routes import MissionIdentifyRequest, RoughPiConversionRequest +from services.mission_io_service import MissionIOService +from services.utility_helpers import FILE_GRANT_SECRET_ENV, FILE_GRANT_VERSION +from stingray import EventList +from stingray.mission_support import ( + get_rough_conversion_function, + mission_specific_event_interpretation, + read_mission_info, +) + + +def _grant(secret: str, path: Path, access: str = "read") -> str: + expires = int(time.time()) + 60 + resolved = path.resolve() + identity_path = resolved if access == "read" else resolved.parent + selected_stat = identity_path.stat() + prefix = ( + f"{FILE_GRANT_VERSION}.{expires}.{selected_stat.st_dev}.{selected_stat.st_ino}" + ) + payload = ( + f"{FILE_GRANT_VERSION}\0{access}\0{expires}\0{resolved}\0" + f"{selected_stat.st_dev}\0{selected_stat.st_ino}" + ).encode() + digest = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + return f"{prefix}.{digest}" + + +def _write_identification_fits( + path: Path, + *, + mission: str = "NICER", + instrument: str = "XTI", + mode: str = "PHOTON", +) -> None: + primary = fits.PrimaryHDU() + primary.header["TELESCOP"] = mission + primary.header["INSTRUME"] = instrument + primary.header["DATAMODE"] = mode + primary.header["MJDREFI"] = 56658 + primary.header["MJDREFF"] = 0.000777592592592593 + events = fits.BinTableHDU.from_columns( + [fits.Column(name="TIME", format="D", array=np.array([0.0, 1.0]))], + name="EVENTS", + ) + fits.HDUList([primary, events]).writeto(path) + + +def _write_xte_science_fits(path: Path) -> None: + primary = fits.PrimaryHDU() + primary.header["TELESCOP"] = "XTE" + primary.header["INSTRUME"] = "PCA" + primary.header["DATAMODE"] = "E_125US_64M_0_1S" + events = fits.BinTableHDU.from_columns( + [ + fits.Column( + name="PHA", + format="B", + array=np.array([0, 1, 2], dtype=np.uint8), + ) + ], + name="XTE_SE", + ) + events.header["TELESCOP"] = "XTE" + events.header["INSTRUME"] = "PCA" + events.header["DATAMODE"] = "E_125US_64M_0_1S" + events.header["TEVTB2"] = "(M[1]{1},C[0~4,5~6,7]{2})" + fits.HDUList([primary, events]).writeto(path) + + +@pytest.fixture() +def service(state_manager) -> MissionIOService: + return MissionIOService(state_manager) + + +def test_runtime_capability_list_uses_database_and_separates_support(service): + result = service.list_capabilities() + + assert result["success"] is True + raw_database = read_mission_info() + assert result["data"]["raw_database_entry_count"] == len(raw_database) + assert result["data"]["mission_count"] == len( + {name.casefold() for name in raw_database} + ) + rows = {row["mission"].casefold(): row for row in result["data"]["missions"]} + assert rows["nicer"]["rough_pi_to_energy"]["status"] == "supported" + assert rows["swift"]["rough_pi_to_energy"]["status"] == "unsupported" + assert rows["xte"]["rough_pi_to_energy"]["status"] == "conditional" + assert rows["xte"]["rough_pi_to_energy"]["epoch_mjd_domain"] == { + "minimum_exclusive": 50_081.0, + "maximum_inclusive": 55_931.0, + } + assert rows["xte"]["mapping"]["detector_column"] == "PCUID" + assert rows["xte"]["modes"]["PCA"] == read_mission_info("XTE")["PCA"]["modes"] + assert rows["xte"]["modes"]["HEXTE"] == read_mission_info("XTE")["HEXTE"]["modes"] + assert set(rows["astrosat"]["modes"]["SXT"]) == {"BM", "CM", "FW", "PC", "PW"} + assert rows["xte"]["specialized_interpretation"]["supported"] is True + assert ( + sum( + row["specialized_interpretation"]["supported"] + for row in result["data"]["missions"] + ) + == 1 + ) + assert "do not imply" in result["data"]["support_note"] + assert result["data"]["provenance"]["operation"] == ("mission_io.list_capabilities") + assert result["data"]["provenance"]["read_only"] is True + json.dumps(result, allow_nan=False) + + +def test_selected_mission_mapping_honors_instrument_and_mode(service): + result = service.get_mission_info("XTE", instrument="PCA", mode="E_125US_64M_0_1S") + + assert result["success"] is True + assert result["data"]["mapping"]["event_hdu"] == "XTE_SE" + assert result["data"]["mapping"]["energy_or_channel_column"] == "PHA" + assert result["data"]["mapping"]["detector_column"] == "PCUID" + assert result["data"]["available_modes"] == read_mission_info("XTE")["PCA"]["modes"] + assert result["data"]["capabilities"]["rough_pi_to_energy"] == { + "status": "conditional", + "approximate": True, + "dependencies": ["instrument=PCA", "epoch_mjd", "detector_id"], + "epoch_mjd_domain": { + "minimum_exclusive": 50_081.0, + "maximum_inclusive": 55_931.0, + }, + } + assert result["data"]["provenance"]["operation"] == ("mission_io.get_mission_info") + assert result["data"]["provenance"]["parameters"] == { + "requested_mission": "XTE", + "resolved_mission": "XTE", + "instrument": "PCA", + "mode": "E_125US_64M_0_1S", + } + json.dumps(result, allow_nan=False) + + unsupported_instrument = service.get_mission_info("XTE", instrument="HEXTE") + assert unsupported_instrument["success"] is True + assert ( + unsupported_instrument["data"]["capabilities"]["specialized_interpretation"][ + "supported" + ] + is False + ) + assert unsupported_instrument["data"]["capabilities"]["rough_pi_to_energy"] == { + "status": "unsupported", + "approximate": False, + "dependencies": [], + "epoch_mjd_domain": None, + } + assert any( + "limited to PCA" in warning for warning in unsupported_instrument["warnings"] + ) + + +@pytest.mark.parametrize( + ("instrument", "mode", "message"), + [ + ("NOT_AN_INSTRUMENT", None, "not defined for XTE"), + ("PCA", "NOT_A_MODE", "not defined for XTE/PCA"), + (None, "NOT_A_MODE", "requires an instrument selection for XTE"), + ], +) +def test_selected_mission_mapping_rejects_unknown_runtime_choices( + service, instrument, mode, message +): + result = service.get_mission_info("XTE", instrument=instrument, mode=mode) + + assert result["success"] is False + assert result["error"] is None + assert message in result["message"] + + +def test_selected_mission_mapping_accepts_nested_runtime_mode(service): + result = service.get_mission_info("ASTROSAT", instrument="SXT", mode="BM") + + assert result["success"] is True + assert result["data"]["instrument"] == "SXT" + assert result["data"]["mode"] == "BM" + assert set(result["data"]["available_modes"]) == {"BM", "CM", "FW", "PC", "PW"} + + +def test_identify_loaded_event_list_reports_each_source(service, state_manager): + event_list = EventList( + time=[1.0, 2.0], + pi=[10, 20], + mission="nicer", + instr="XTI", + mjdref=56658.0, + ) + event_list.mode = "PHOTON" + state_manager.add_event_data("events", event_list) + + result = service.identify_source(event_list_name="events") + + assert result["success"] is True + assert result["data"]["mission"] == { + "value": "NICER", + "raw_value": "nicer", + "source": "EventList.mission", + "source_type": "event_list_attribute", + "inferred": False, + "override": False, + "database_supported": True, + } + assert result["data"]["instrument"]["source"] == "EventList.instr" + assert result["data"]["mode"]["source"] == "EventList.mode" + assert result["data"]["mapping"]["energy_or_channel_column"] == "PI" + assert result["data"]["timing_metadata"]["mjdref"]["source"] == "EventList.mjdref" + assert result["data"]["provenance"]["operation"] == "mission_io.identify_source" + assert result["data"]["provenance"]["input_source"]["name"] == "events" + json.dumps(result, allow_nan=False) + + +def test_identification_does_not_apply_generic_mapping_to_unknown_runtime_mode( + service, state_manager +): + source = EventList( + time=[1.0, 2.0], + pi=[10, 20], + mission="XTE", + instr="PCA", + ) + source.mode = "NOT_A_MODE" + state_manager.add_event_data("unknown-xte-mode", source) + + identified = service.identify_source(event_list_name="unknown-xte-mode") + + assert identified["success"] is True + assert identified["data"]["mapping"] is None + assert "not defined for XTE/PCA" in identified["data"]["mapping_validation_error"] + assert any("no generic fallback" in item for item in identified["warnings"]) + + converted = service.convert_pi_to_energy( + event_list_name="unknown-xte-mode", + epoch_mjd=55_930.0, + detector_ids=[0], + ) + assert converted["success"] is False + assert "not defined for XTE/PCA" in converted["message"] + + +def test_missing_metadata_is_explicit_and_missing_only_overrides_are_labelled( + service, state_manager +): + state_manager.add_event_data("unknown", EventList(time=[1.0, 2.0], pi=[1, 2])) + + missing = service.identify_source(event_list_name="unknown") + assert missing["success"] is True + assert missing["data"]["mission"]["source_type"] == "missing" + assert any("Mission metadata is missing" in item for item in missing["warnings"]) + assert missing["data"]["warnings"] == missing["warnings"] + + overridden = service.identify_source( + event_list_name="unknown", + mission_override="nicer", + instrument_override="XTI", + mode_override="PHOTON", + ) + assert overridden["success"] is True + for key in ("mission", "instrument", "mode"): + assert overridden["data"][key]["source_type"] == "override" + assert overridden["data"][key]["override"] is True + assert overridden["data"]["mission"]["value"] == "NICER" + + state_manager.add_event_data( + "known", EventList(time=[1.0], pi=[1], mission="NICER", instr="XTI") + ) + conflict = service.identify_source(event_list_name="known", mission_override="XMM") + assert conflict["success"] is False + assert "only when" in conflict["message"] + assert conflict["error"] is None + + +def test_unknown_mission_is_distinct_from_missing_or_operation_unsupported( + service, state_manager +): + info = service.get_mission_info("NOT-A-REAL-MISSION") + assert info["success"] is False + assert "not present" in info["message"] + + state_manager.add_event_data( + "unknown-mission", + EventList( + time=np.array([0.0]), + pi=np.array([1]), + mission="NOT-A-REAL-MISSION", + instr="UNKNOWN-INSTRUMENT", + ), + ) + identified = service.identify_source(event_list_name="unknown-mission") + + assert identified["success"] is True + assert identified["data"]["mission"]["database_supported"] is False + assert identified["data"]["mapping"] is None + assert any("not present" in warning for warning in identified["warnings"]) + json.dumps(identified, allow_nan=False) + + +@pytest.mark.parametrize( + ("mission", "pi_values"), + [ + ("NUSTAR", [0, 10, 100]), + ("XMM", [0, 10, 100]), + ("NICER", [0, 10, 100]), + ("IXPE", [0, 10, 100]), + ("AXAF", [1, 10, 100]), + ], +) +def test_simple_rough_conversions_match_public_stingray(service, mission, pi_values): + result = service.convert_pi_to_energy( + pi_values=pi_values, + mission_override=mission, + ) + + assert result["success"] is True + expected = get_rough_conversion_function(mission)(np.asarray(pi_values)) + actual = np.array([row["energy_kev"] for row in result["data"]["rows"]]) + np.testing.assert_allclose(actual, expected) + assert result["data"]["approximate"] is True + assert result["data"]["conversion_type"] == "rough_approximate" + assert result["data"]["precise_calibration"]["location"] == "General I/O" + assert "APPROXIMATE" in result["data"]["label"] + assert "RMF" in result["warnings"][0] + assert result["data"]["warnings"] == result["warnings"] + + +def test_xte_conversion_matches_public_function_and_requires_dependencies(service): + pi_values = np.array([10, 11, 20]) + detectors = np.array([0, 3, 4]) + result = service.convert_pi_to_energy( + pi_values=pi_values.tolist(), + mission_override="XTE", + instrument_override="PCA", + epoch_mjd=55930.0, + detector_ids=detectors.tolist(), + ) + + expected = get_rough_conversion_function("XTE", "PCA", 55930.0)( + pi_values, detector_id=detectors + ) + assert result["success"] is True + np.testing.assert_allclose( + [row["energy_kev"] for row in result["data"]["rows"]], expected + ) + assert result["data"]["dependencies"]["epoch_mjd"]["source"] == "request.epoch_mjd" + assert ( + result["data"]["dependencies"]["detector_id"]["source"] + == "request.detector_ids" + ) + + missing_epoch = service.convert_pi_to_energy( + pi_values=[10], + mission_override="XTE", + instrument_override="PCA", + detector_ids=[0], + ) + assert missing_epoch["success"] is False + assert "epoch" in missing_epoch["message"].lower() + + missing_detector = service.convert_pi_to_energy( + pi_values=[10], + mission_override="XTE", + instrument_override="PCA", + epoch_mjd=55930, + ) + assert missing_detector["success"] is False + assert "detector" in missing_detector["message"].lower() + + +@pytest.mark.parametrize( + ("epoch_mjd", "success"), + [ + (50_081.0, False), + (50_081.000001, True), + (55_931.0, True), + (55_931.000001, False), + ], +) +def test_xte_conversion_enforces_installed_calibration_epoch_domain( + service, epoch_mjd, success +): + result = service.convert_pi_to_energy( + pi_values=[10], + mission_override="XTE", + instrument_override="PCA", + epoch_mjd=epoch_mjd, + detector_ids=[0], + ) + + assert result["success"] is success + if not success: + assert "50081 < MJD <= 55931" in result["message"] + assert result["error"] is None + + +def test_xte_non_pca_and_database_only_mission_are_actionably_unsupported(service): + hexte = service.convert_pi_to_energy( + pi_values=[10], + mission_override="XTE", + instrument_override="HEXTE", + epoch_mjd=55930, + detector_ids=[0], + ) + assert hexte["success"] is False + assert "PCA only" in hexte["message"] + + swift = service.convert_pi_to_energy(pi_values=[10], mission_override="SWIFT") + assert swift["success"] is False + assert "No public rough" in swift["message"] + + +def test_conversion_validates_channels_and_caps_before_work(service, monkeypatch): + assert ( + service.convert_pi_to_energy( + pi_values=[1, float("nan")], mission_override="NICER" + )["success"] + is False + ) + fractional = service.convert_pi_to_energy(pi_values=[1.5], mission_override="NICER") + assert fractional["success"] is False + assert "integer channel" in fractional["message"] + negative = service.convert_pi_to_energy(pi_values=[-1], mission_override="NICER") + assert negative["success"] is False + assert "non-negative" in negative["message"] + + monkeypatch.setattr(mission_module, "MAX_ARRAY_INPUT", 2) + capped = service.convert_pi_to_energy(pi_values=[1, 2, 3], mission_override="NICER") + assert capped["success"] is False + assert "cap is 2" in capped["message"] + + +def test_non_finite_upstream_energies_become_null_with_warning(service, monkeypatch): + monkeypatch.setattr( + mission_module, + "get_rough_conversion_function", + lambda *args, **kwargs: lambda values: np.array([np.nan, np.inf, 3.5]), + ) + + result = service.convert_pi_to_energy(pi_values=[1, 2, 3], mission_override="NICER") + + assert result["success"] is True + assert [row["energy_kev"] for row in result["data"]["rows"]] == [ + None, + None, + 3.5, + ] + assert any( + "represent" in warning and "null" in warning for warning in result["warnings"] + ) + json.dumps(result, allow_nan=False) + + +def test_axaf_rejects_channel_zero_and_negative_approximate_energy(service): + result = service.convert_pi_to_energy(pi_values=[0, 1], mission_override="AXAF") + + assert result["success"] is False + assert result["error"] is None + assert "must be at least 1" in result["message"] + assert "non-negative" in result["message"] + + +def test_epoch_is_explicitly_unused_for_epoch_independent_conversion(service): + result = service.convert_pi_to_energy( + pi_values=[1, 2], mission_override="NICER", epoch_mjd=55_555.0 + ) + + assert result["success"] is True + dependency = result["data"]["dependencies"]["epoch_mjd"] + assert dependency["used"] is False + assert dependency["value"] is None + assert dependency["requested_value"] == 55_555.0 + assert any("not used" in warning for warning in result["warnings"]) + assert result["data"]["provenance"]["parameters"]["epoch_mjd"] is None + assert result["data"]["provenance"]["parameters"]["requested_epoch_mjd"] == 55_555.0 + + +def test_loaded_event_snapshot_caps_and_channel_alignment( + service, state_manager, monkeypatch +): + oversized = EventList( + time=np.array([0.0, 1.0, 2.0]), + pi=np.array([1, 2, 3]), + mission="NICER", + instr="XTI", + ) + state_manager.add_event_data("oversized", oversized) + monkeypatch.setattr(mission_module, "MAX_ARRAY_INPUT", 2) + + capped = service.convert_pi_to_energy(event_list_name="oversized") + + assert capped["success"] is False + assert "operation cap is 2" in capped["message"] + + monkeypatch.setattr(mission_module, "MAX_ARRAY_INPUT", 100_000) + misaligned = EventList( + time=np.array([0.0, 1.0]), + pi=np.array([1, 2]), + mission="NICER", + instr="XTI", + ) + misaligned.pi = np.array([1]) + state_manager.add_event_data("misaligned", misaligned) + + rejected = service.convert_pi_to_energy( + event_list_name="misaligned", save_as="must-not-exist" + ) + + assert rejected["success"] is False + assert "2 time value(s) but 1 PI/channel value(s)" in rejected["message"] + assert state_manager.get_event_data("must-not-exist") is None + + +def test_identification_snapshot_is_bounded(service, state_manager, monkeypatch): + state_manager.add_event_data( + "large-identification", + EventList( + time=np.array([0.0, 1.0, 2.0]), + pi=np.array([1, 2, 3]), + mission="NICER", + ), + ) + monkeypatch.setattr(mission_module, "MAX_EXPORT_ROWS", 2) + + result = service.identify_source(event_list_name="large-identification") + + assert result["success"] is False + assert "operation cap is 2" in result["message"] + + +def test_derived_event_is_atomic_immutable_and_carries_serializable_provenance( + service, state_manager +): + source = EventList( + time=np.array([0.0, 1.0, 2.0]), + pi=np.array([0, 100, 200]), + gti=[[0.0, 2.0]], + mission="NICER", + instr="XTI", + notes="original note", + ) + state_manager.add_event_data("source", source) + source_time = source.time.copy() + source_pi = source.pi.copy() + + result = service.convert_pi_to_energy(event_list_name="source", save_as="derived") + + assert result["success"] is True + assert result["data"]["saved_event_list"] == "derived" + np.testing.assert_array_equal(source.time, source_time) + np.testing.assert_array_equal(source.pi, source_pi) + assert source.energy is None + assert source.notes == "original note" + + derived = state_manager.get_event_data("derived") + np.testing.assert_array_equal(derived.pi, source_pi) + np.testing.assert_allclose(derived.energy, [0.0, 1.0, 2.0]) + assert not np.shares_memory(derived.pi, source.pi) + assert derived.mission_io_conversion_type == "rough_approximate" + attached = json.loads(derived.mission_io_provenance_json) + assert attached["input_source"] == {"type": "loaded_event_list", "name": "source"} + assert attached["conversion_type"] == "rough_approximate" + assert "APPROXIMATE" in derived.notes + json.dumps(result, allow_nan=False) + + duplicate = service.convert_pi_to_energy( + event_list_name="source", save_as="derived" + ) + assert duplicate["success"] is False + assert "already exists" in duplicate["message"] + assert state_manager.get_event_data("derived") is derived + + +def test_loaded_xte_derives_epoch_and_preserves_detector_ids(service, state_manager): + source = EventList( + time=np.array([442_845_936.0, 442_845_937.0]), + pi=np.array([10, 11]), + mission="XTE", + instr="PCA", + mjdref=49353.000696574074, + detector_id=np.array([0, 3]), + ) + state_manager.add_event_data("xte", source) + + result = service.convert_pi_to_energy(event_list_name="xte") + + assert result["success"] is True + derived_epoch = 49353.000696574074 + 442_845_936.0 / 86400.0 + expected = get_rough_conversion_function("XTE", "PCA", derived_epoch)( + source.pi, detector_id=source.detector_id + ) + np.testing.assert_allclose( + [row["energy_kev"] for row in result["data"]["rows"]], expected + ) + assert "EventList.mjdref" in result["data"]["dependencies"]["epoch_mjd"]["source"] + + +def test_xte_requested_detector_ids_are_saved_and_reproducibly_provenanced( + service, state_manager +): + source = EventList( + time=np.array([0.0, 1.0]), + pi=np.array([10, 20]), + mission="XTE", + instr="PCA", + ) + state_manager.add_event_data("xte-no-detectors", source) + + result = service.convert_pi_to_energy( + event_list_name="xte-no-detectors", + epoch_mjd=55_930.0, + detector_ids=[0, 3], + save_as="xte-with-detectors", + ) + + assert result["success"] is True + derived = state_manager.get_event_data("xte-with-detectors") + np.testing.assert_array_equal(derived.detector_id, [0, 3]) + assert source.detector_id is None + parameters = json.loads(derived.mission_io_provenance_json)["parameters"] + detector_provenance = parameters["detector_ids"] + assert detector_provenance["source"] == "request.detector_ids" + assert detector_provenance["count"] == 2 + assert detector_provenance["values"] == [0, 3] + assert detector_provenance["preview_truncated"] is False + expected_digest = hashlib.sha256( + np.asarray([0, 3], dtype=" 1: + assert any("TIMEPIXR" in warning for warning in result["warnings"]) + + +def test_identification_omits_nonfinite_timing_cards_with_warnings( + service, state_manager +): + source = EventList(time=[1.0, 2.0], pi=[1, 2], mission="NICER", instr="XTI") + header = fits.Header() + header["MJD-OBS"] = "NaN" + header["TSTART"] = "Infinity" + header["TSTOP"] = True + header["TIMEZERO"] = "NaN" + header["TIMEDEL"] = 1.0 + header["TIMEPIXR"] = 0.5 + source.header = header + state_manager.add_event_data("nonfinite-timing", source) + + result = service.identify_source(event_list_name="nonfinite-timing") + + assert result["success"] is True + timing = result["data"]["timing_metadata"] + assert "mjd_observation" not in timing + assert "tstart" not in timing + assert "tstop" not in timing + assert "timezero" not in timing + assert timing["timedel"]["value"] == 1.0 + assert timing["timepixr"]["value"] == 0.5 + assert any("MJD-OBS" in warning for warning in result["warnings"]) + assert any("TSTART" in warning for warning in result["warnings"]) + assert any("TSTOP" in warning for warning in result["warnings"]) + assert any("TIMEZERO" in warning for warning in result["warnings"]) + json.dumps(result, allow_nan=False) + + +def test_loaded_xte_rejects_malformed_mjdreff_for_epoch_derivation( + service, state_manager +): + source = EventList( + time=np.array([0.0, 1.0]), + pi=np.array([10, 11]), + mission="XTE", + instr="PCA", + detector_id=np.array([0, 1]), + ) + header = fits.Header() + header["MJDREFI"] = 50_000 + header["MJDREFF"] = "not-a-number" + header["TSTART"] = 0.0 + header["TIMEUNIT"] = "s" + source.header = header + state_manager.add_event_data("xte-bad-mjdreff", source) + + result = service.convert_pi_to_energy(event_list_name="xte-bad-mjdreff") + + assert result["success"] is False + assert "requires the observation epoch" in result["message"] + assert any("not substituted with zero" in warning for warning in result["warnings"]) + + +def test_loaded_xte_rejects_incomplete_split_mjdref(service, state_manager): + source = EventList( + time=np.array([0.0, 1.0]), + pi=np.array([10, 11]), + mission="XTE", + instr="PCA", + detector_id=np.array([0, 1]), + ) + header = fits.Header() + header["MJDREFI"] = 50_163 + header["TSTART"] = 0.0 + header["TIMEUNIT"] = "d" + source.header = header + state_manager.add_event_data("xte-incomplete-mjdref", source) + + result = service.convert_pi_to_energy(event_list_name="xte-incomplete-mjdref") + + assert result["success"] is False + assert "requires the observation epoch" in result["message"] + assert any("both MJDREFI and MJDREFF" in item for item in result["warnings"]) + + +def test_identification_omits_overflowed_split_mjdref(service, state_manager): + source = EventList( + time=np.array([0.0, 1.0]), + pi=np.array([1, 2]), + mission="NICER", + instr="XTI", + ) + header = fits.Header() + header["MJDREFI"] = 1e308 + header["MJDREFF"] = 1e308 + source.header = header + state_manager.add_event_data("overflow-mjdref", source) + + result = service.identify_source(event_list_name="overflow-mjdref") + + assert result["success"] is True + assert "mjdref" not in result["data"]["timing_metadata"] + assert any( + "not representable as a finite value" in item for item in result["warnings"] + ) + json.dumps(result, allow_nan=False) + + +def test_fits_identification_requires_exact_read_grant(service, tmp_path, monkeypatch): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "selected.fits" + adjacent = tmp_path / "adjacent.fits" + _write_identification_fits(selected) + _write_identification_fits(adjacent, mission="XMM", instrument="EPN") + grant = _grant(secret, selected) + + result = service.identify_source(file_path=str(selected), file_grant=grant) + assert result["success"] is True + assert result["data"]["mission"]["value"] == "NICER" + assert result["data"]["mission"]["source_type"] == "fits_header" + assert result["data"]["source"]["path"] == str(selected.resolve()) + assert result["data"]["timing_metadata"]["mjdref"]["value"] == pytest.approx( + 56658.00077759259 + ) + mjdref = result["data"]["timing_metadata"]["mjdref"] + assert mjdref["decimal"] == "56658.000777592592592593" + assert mjdref["components"]["integer"]["value"] == "56658" + assert mjdref["components"]["fraction"]["value"] == "0.000777592592592593" + + substituted = service.identify_source(file_path=str(adjacent), file_grant=grant) + assert substituted["success"] is False + assert "does not match" in substituted["message"] + + no_grant = service.identify_source(file_path=str(selected)) + assert no_grant["success"] is False + assert "grant" in no_grant["message"] + + +def test_fits_identification_preserves_raw_mjdref_card_precision( + service, tmp_path, monkeypatch +): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "exact-mjdref.fits" + primary = fits.PrimaryHDU() + primary.header["TELESCOP"] = "NICER" + primary.header["INSTRUME"] = "XTI" + primary.header.append( + fits.Card.fromstring("MJDREF = 58000.123456789012345".ljust(80)) + ) + events = fits.BinTableHDU.from_columns( + [fits.Column(name="TIME", format="D", array=np.array([0.0, 1.0]))], + name="EVENTS", + ) + fits.HDUList([primary, events]).writeto(selected) + + result = service.identify_source( + file_path=str(selected), file_grant=_grant(secret, selected) + ) + + assert result["success"] is True + mjdref = result["data"]["timing_metadata"]["mjdref"] + assert mjdref["decimal"] == "58000.123456789012345" + assert mjdref["value"] == pytest.approx(58_000.12345678901) + + +def test_malformed_fits_returns_clean_identification_and_interpretation_failures( + service, tmp_path, monkeypatch +): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "malformed.fits" + selected.write_bytes(b"this is not a FITS file") + grant = _grant(secret, selected) + + identified = service.identify_source(file_path=str(selected), file_grant=grant) + interpreted = service.interpret_selected_fits( + file_path=str(selected), file_grant=grant + ) + + assert identified["success"] is False + assert interpreted["success"] is False + assert "Could not inspect" in identified["message"] + assert "Could not inspect" in interpreted["message"] + assert identified["error"] is None + assert interpreted["error"] is None + json.dumps(identified, allow_nan=False) + json.dumps(interpreted, allow_nan=False) + + +def test_malformed_mjdreff_is_not_substituted_with_zero(service, tmp_path, monkeypatch): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "malformed-mjdreff.fits" + _write_identification_fits(selected) + with fits.open(selected, mode="update") as hdulist: + hdulist[0].header["MJDREFF"] = "not-a-number" + hdulist.flush() + + result = service.identify_source( + file_path=str(selected), file_grant=_grant(secret, selected) + ) + + assert result["success"] is True + assert "mjdref" not in result["data"]["timing_metadata"] + assert any( + "MJDREFF" in warning and "not substituted with zero" in warning + for warning in result["warnings"] + ) + + +def test_mission_fits_hdu_count_is_capped_before_header_iteration( + service, tmp_path, monkeypatch +): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "too-many-hdus.fits" + hdus = [fits.PrimaryHDU()] + hdus.extend(fits.ImageHDU() for _ in range(mission_module.MAX_FITS_HDUS)) + fits.HDUList(hdus).writeto(selected) + grant = _grant(secret, selected) + + identified = service.identify_source(file_path=str(selected), file_grant=grant) + interpreted = service.interpret_selected_fits( + file_path=str(selected), file_grant=grant + ) + + assert identified["success"] is False + assert interpreted["success"] is False + assert "513 HDUs" in identified["message"] + assert "cap is 512" in identified["message"] + assert "513 HDUs" in interpreted["message"] + assert "cap is 512" in interpreted["message"] + + +def test_mission_header_precedes_telescope_header(service, tmp_path, monkeypatch): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "precedence.fits" + _write_identification_fits(selected, mission="NICER", instrument="XTI") + with fits.open(selected, mode="update") as hdulist: + hdulist[0].header["MISSION"] = "XMM" + hdulist.flush() + + result = service.identify_source( + file_path=str(selected), file_grant=_grant(secret, selected) + ) + + assert result["success"] is True + assert result["data"]["mission"]["value"] == "XMM" + assert result["data"]["mission"]["source"].endswith(".MISSION") + + +def test_xte_interpretation_matches_public_api_and_does_not_change_file( + service, tmp_path, monkeypatch +): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "xte-science.fits" + _write_xte_science_fits(selected) + grant = _grant(secret, selected) + + with fits.open(selected) as hdulist: + direct_hdu = hdulist["XTE_SE"].copy() + expected_hdu = mission_specific_event_interpretation("XTE")(direct_hdu) + expected = np.asarray(expected_hdu.data["PHA"]).copy() + + result = service.interpret_selected_fits(file_path=str(selected), file_grant=grant) + + assert result["success"] is True + actual = np.array([row["interpreted_pha"] for row in result["data"]["rows"]]) + np.testing.assert_array_equal(actual, expected) + assert result["data"]["read_only"] is True + assert result["data"]["source_modified"] is False + assert result["data"]["changed_count"] == 3 + assert "does not calibrate" in result["warnings"][-1] + with fits.open(selected) as unchanged: + np.testing.assert_array_equal(unchanged["XTE_SE"].data["PHA"], [0, 1, 2]) + json.dumps(result, allow_nan=False) + + +def test_xte_interpretation_rejects_missing_hdu_pha_and_oversized_table( + service, tmp_path, monkeypatch +): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + + missing_hdu = tmp_path / "xte-missing-hdu.fits" + _write_identification_fits(missing_hdu, mission="XTE", instrument="PCA") + no_hdu = service.interpret_selected_fits( + file_path=str(missing_hdu), + file_grant=_grant(secret, missing_hdu), + ) + assert no_hdu["success"] is False + assert "no XTE_SE extension" in no_hdu["message"] + + missing_pha = tmp_path / "xte-missing-pha.fits" + primary = fits.PrimaryHDU() + primary.header["TELESCOP"] = "XTE" + primary.header["INSTRUME"] = "PCA" + events = fits.BinTableHDU.from_columns( + [fits.Column(name="TIME", format="D", array=np.array([0.0, 1.0]))], + name="XTE_SE", + ) + fits.HDUList([primary, events]).writeto(missing_pha) + no_pha = service.interpret_selected_fits( + file_path=str(missing_pha), + file_grant=_grant(secret, missing_pha), + ) + assert no_pha["success"] is False + assert "no PHA column" in no_pha["message"] + + oversized = tmp_path / "xte-oversized.fits" + _write_xte_science_fits(oversized) + monkeypatch.setattr(mission_module, "MAX_ARRAY_INPUT", 2) + too_many_rows = service.interpret_selected_fits( + file_path=str(oversized), + file_grant=_grant(secret, oversized), + ) + assert too_many_rows["success"] is False + assert "contains 3 rows" in too_many_rows["message"] + assert "cap is 2" in too_many_rows["message"] + + +def test_specialized_interpretation_is_xte_only(service, tmp_path, monkeypatch): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "nicer.fits" + _write_identification_fits(selected) + + result = service.interpret_selected_fits( + file_path=str(selected), file_grant=_grant(secret, selected) + ) + + assert result["success"] is False + assert "Only XTE" in result["message"] + + +@pytest.mark.parametrize("instrument", [None, "HEXTE"]) +def test_xte_specialized_interpretation_requires_pca( + service, tmp_path, monkeypatch, instrument +): + secret = "mission-io-test-file-grant-secret" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / f"xte-{instrument or 'missing'}.fits" + _write_xte_science_fits(selected) + with fits.open(selected, mode="update") as hdulist: + for hdu in hdulist: + if instrument is None: + if "INSTRUME" in hdu.header: + del hdu.header["INSTRUME"] + else: + hdu.header["INSTRUME"] = instrument + hdulist.flush() + + result = service.interpret_selected_fits( + file_path=str(selected), file_grant=_grant(secret, selected) + ) + + assert result["success"] is False + assert "only PCA science-event FITS is supported" in result["message"] + + +def test_request_models_enforce_exact_sources_and_file_grants(): + with pytest.raises(ValidationError, match="exactly one"): + MissionIdentifyRequest() + with pytest.raises(ValidationError, match="file_grant"): + MissionIdentifyRequest(file_path="/selected/file.fits") + with pytest.raises(ValidationError, match="exactly one"): + RoughPiConversionRequest(pi_values=[1], event_list_name="events") + with pytest.raises(ValidationError, match="save_as"): + RoughPiConversionRequest(pi_values=[1], save_as="derived") + with pytest.raises(ValidationError, match="finite number"): + RoughPiConversionRequest(pi_values=[float("nan")], mission_override="NICER") + with pytest.raises(ValidationError, match="finite number"): + RoughPiConversionRequest( + pi_values=[1], mission_override="XTE", epoch_mjd=float("inf") + ) + + +def test_request_models_accept_existing_source_names_from_ingestion(): + long_source_name = "loaded-" + "x" * 300 + + identification = MissionIdentifyRequest(event_list_name=long_source_name) + conversion = RoughPiConversionRequest( + event_list_name=long_source_name, + mission_override="NICER", + ) + + assert identification.event_list_name == long_source_name + assert conversion.event_list_name == long_source_name + + +@pytest.mark.asyncio +async def test_conversion_route_offloads_blocking_service_call(monkeypatch): + calls = [] + + class StubService: + def convert_pi_to_energy(self, **kwargs): + calls.append(kwargs) + return {"success": True} + + async def fake_to_thread(function, *args, **kwargs): + calls.append("offloaded") + return function(*args, **kwargs) + + monkeypatch.setattr(mission_routes.asyncio, "to_thread", fake_to_thread) + request = RoughPiConversionRequest(pi_values=[1, 2], mission_override="NICER") + + result = await mission_routes.convert_pi_to_energy(request, StubService()) + + assert result == {"success": True} + assert calls[0] == "offloaded" + assert calls[1]["pi_values"] == [1.0, 2.0] + assert calls[1]["mission_override"] == "NICER" diff --git a/python-backend/tests/test_remote_source.py b/python-backend/tests/test_remote_source.py new file mode 100644 index 0000000..187927a --- /dev/null +++ b/python-backend/tests/test_remote_source.py @@ -0,0 +1,880 @@ +"""Focused, network-free tests for the remote-source security boundary.""" + +from __future__ import annotations + +import ipaddress +from collections.abc import AsyncIterator, Sequence + +import httpx +import pytest +import services.remote_source as remote_source_module +from services.remote_source import ( + CDS_SESAME_POLICY, + GENERAL_HTTPS_POLICY, + HEASARC_ARCHIVE_POLICY, + HEASARC_TAP_POLICY, + RemoteSourceCancelled, + RemoteSourceClient, + RemoteSourceError, + RemoteSourceHTTPError, + RemoteSourcePeerError, + RemoteSourcePolicyError, + RemoteSourceRedirectError, + RemoteSourceResolutionError, + RemoteSourceSizeError, + RemoteSourceTimeout, + RemoteTimeouts, + redact_remote_url, +) + +PUBLIC_IP = "93.184.216.34" +SECOND_PUBLIC_IP = "1.1.1.1" + + +class StaticResolver: + def __init__(self, addresses: Sequence[str] = (PUBLIC_IP,)) -> None: + self.addresses = addresses + self.calls: list[tuple[str, int]] = [] + + async def __call__(self, host: str, port: int) -> Sequence[str]: + self.calls.append((host, port)) + return self.addresses + + +class HostResolver: + def __init__(self, addresses: dict[str, Sequence[str]]) -> None: + self.addresses = addresses + self.calls: list[tuple[str, int]] = [] + + async def __call__(self, host: str, port: int) -> Sequence[str]: + self.calls.append((host, port)) + return self.addresses[host] + + +class PeerStream: + def __init__(self, address: str | None, port: int = 443) -> None: + self.address = address + self.port = port + + def get_extra_info(self, info: str): + if info == "server_addr" and self.address is not None: + return (self.address, self.port) + return None + + +class ChunkStream(httpx.AsyncByteStream): + def __init__(self, chunks: Sequence[bytes], on_chunk=None) -> None: + self.chunks = chunks + self.on_chunk = on_chunk + + async def __aiter__(self) -> AsyncIterator[bytes]: + for index, chunk in enumerate(self.chunks): + if self.on_chunk is not None: + self.on_chunk(index) + yield chunk + + +class FailingReadStream(httpx.AsyncByteStream): + async def __aiter__(self) -> AsyncIterator[bytes]: + raise httpx.ReadTimeout( + "read secret", + request=httpx.Request( + "GET", "https://example.com/data?secret=must-not-leak" + ), + ) + yield b"" # pragma: no cover - keeps this an async generator + + +def response( + status: int = 200, + *, + body: bytes | httpx.AsyncByteStream = b"ok", + headers: dict[str, str] | None = None, + peer: str | None = PUBLIC_IP, + peer_port: int = 443, +) -> httpx.Response: + extensions = {} + if peer is not None: + extensions["network_stream"] = PeerStream(peer, peer_port) + if isinstance(body, bytes): + body = ChunkStream((body,)) + return httpx.Response(status, stream=body, headers=headers, extensions=extensions) + + +def client_for( + handler, + *, + resolver=None, + policy=GENERAL_HTTPS_POLICY, + **kwargs, +) -> RemoteSourceClient: + return RemoteSourceClient( + policy, + resolver=resolver or StaticResolver(), + transport=httpx.MockTransport(handler), + **kwargs, + ) + + +@pytest.mark.parametrize( + "url", + [ + "http://example.com/file.fits", + "ftp://example.com/file.fits", + "https://user:secret@example.com/file.fits", + "https://example.com/file.fits#section", + "https://example.com/file.fits#", + "https://example.com:444/file.fits", + "https://example.com:0443/file.fits", + "https://example.com:/file.fits", + "https://example.com\\@evil.test/file.fits", + "https://example.com/%0d%0aX-Test:yes", + " https://example.com/file.fits", + "https://example.com/%zz", + "https://example.com./file.fits", + ], +) +@pytest.mark.asyncio +async def test_invalid_urls_are_rejected_before_transport(url: str) -> None: + called = False + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal called + called = True + return response() + + source = client_for(handler) + with pytest.raises(RemoteSourcePolicyError): + await source.fetch_bytes(url, max_bytes=100) + assert called is False + + +@pytest.mark.asyncio +async def test_remote_url_length_is_bounded_before_resolution_or_transport() -> None: + resolver = StaticResolver() + called = False + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal called + called = True + return response() + + source = client_for(handler, resolver=resolver) + with pytest.raises(RemoteSourcePolicyError, match="canonical string"): + await source.fetch_bytes(f"https://example.com/{'a' * 4_096}", max_bytes=100) + + assert resolver.calls == [] + assert called is False + + +@pytest.mark.asyncio +async def test_canonical_explicit_https_port_is_allowed_and_redacted() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return response(headers={"Content-Length": "2"}) + + source = client_for(handler) + body, info = await source.fetch_bytes( + "https://example.com:443/file.fits?token=super-secret", max_bytes=2 + ) + + assert body == b"ok" + assert info.display_url == "https://example.com:443/file.fits" + assert "super-secret" not in repr(info) + assert seen[0].url.host == PUBLIC_IP + assert seen[0].headers["host"] == "example.com" + assert seen[0].headers["accept-encoding"] == "identity" + assert seen[0].extensions["sni_hostname"] == "example.com" + assert seen[0].extensions["timeout"] == { + "connect": 10.0, + "read": 30.0, + "write": 10.0, + "pool": 5.0, + } + + +@pytest.mark.parametrize( + "url", + [ + "https://heasarc.gsfc.nasa.gov/xamin/vo/tap/sync/extra", + "https://heasarc.gsfc.nasa.gov/xamin/vo/tap/%73ync", + "https://attacker.example/xamin/vo/tap/sync", + ], +) +@pytest.mark.asyncio +async def test_archive_search_policies_require_exact_host_and_path(url: str) -> None: + called = False + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal called + called = True + return response() + + source = client_for(handler, policy=HEASARC_TAP_POLICY) + with pytest.raises(RemoteSourcePolicyError): + await source.post_form_bytes(url, {"QUERY": "SELECT 1"}, max_bytes=100) + assert called is False + + cds = client_for(handler, policy=CDS_SESAME_POLICY) + with pytest.raises(RemoteSourcePolicyError): + await cds.fetch_text( + "https://cds.unistra.fr/cgi-bin/nph-sesame/SNV/extra?Crab", + max_bytes=100, + ) + + +@pytest.mark.asyncio +async def test_form_post_is_bounded_pinned_and_does_not_follow_redirects() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return response( + 302, + headers={"Location": "https://heasarc.gsfc.nasa.gov/xamin/vo/tap/sync"}, + peer=PUBLIC_IP, + ) + + source = client_for(handler, policy=HEASARC_TAP_POLICY) + with pytest.raises(RemoteSourceRedirectError, match="not allowed"): + await source.post_form_bytes( + "https://heasarc.gsfc.nasa.gov/xamin/vo/tap/sync", + {"REQUEST": "doQuery", "LANG": "ADQL", "MAXREC": 10, "QUERY": "SELECT 1"}, + max_bytes=100, + ) + + assert len(seen) == 1 + assert seen[0].method == "POST" + assert seen[0].url.host == PUBLIC_IP + assert seen[0].headers["host"] == "heasarc.gsfc.nasa.gov" + assert seen[0].headers["content-type"] == "application/x-www-form-urlencoded" + assert seen[0].content == b"REQUEST=doQuery&LANG=ADQL&MAXREC=10&QUERY=SELECT+1" + + +@pytest.mark.asyncio +async def test_form_request_size_is_checked_before_dns_or_transport() -> None: + resolver = StaticResolver() + called = False + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal called + called = True + return response() + + source = client_for(handler, policy=HEASARC_TAP_POLICY, resolver=resolver) + with pytest.raises(RemoteSourceSizeError): + await source.post_form_bytes( + "https://heasarc.gsfc.nasa.gov/xamin/vo/tap/sync", + {"QUERY": "x" * 100}, + max_bytes=100, + max_request_bytes=10, + ) + assert resolver.calls == [] + assert called is False + + +@pytest.mark.parametrize( + "address", + [ + "127.0.0.1", + "10.0.0.1", + "169.254.169.254", + "224.0.0.1", + "0.0.0.0", + "192.0.2.1", + "::1", + "fc00::1", + "fe80::1", + "ff02::1", + "::", + "2001:db8::1", + "::ffff:127.0.0.1", + ], +) +@pytest.mark.asyncio +async def test_dns_rejects_every_non_public_address_class(address: str) -> None: + called = False + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal called + called = True + return response() + + source = client_for(handler, resolver=StaticResolver((address,))) + with pytest.raises(RemoteSourceResolutionError, match="non-public"): + await source.fetch_bytes("https://example.com/data", max_bytes=100) + assert called is False + + +@pytest.mark.asyncio +async def test_dns_rejects_mixed_public_and_private_results() -> None: + source = client_for( + lambda request: response(), + resolver=StaticResolver((PUBLIC_IP, "127.0.0.1")), + ) + with pytest.raises(RemoteSourceResolutionError, match="non-public"): + await source.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_empty_and_invalid_dns_results_fail_closed() -> None: + empty = client_for(lambda request: response(), resolver=StaticResolver(())) + with pytest.raises(RemoteSourceResolutionError, match="no addresses"): + await empty.fetch_bytes("https://example.com/data", max_bytes=100) + + invalid = client_for( + lambda request: response(), resolver=StaticResolver(("not-an-ip",)) + ) + with pytest.raises(RemoteSourceResolutionError, match="invalid address"): + await invalid.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_public_ip_literal_is_validated_without_dns_lookup() -> None: + resolver = StaticResolver(("127.0.0.1",)) + source = client_for(lambda request: response(), resolver=resolver) + body, _ = await source.fetch_bytes(f"https://{PUBLIC_IP}/data", max_bytes=100) + assert body == b"ok" + assert resolver.calls == [] + + +@pytest.mark.asyncio +async def test_private_ip_literal_is_rejected() -> None: + source = client_for(lambda request: response()) + with pytest.raises(RemoteSourceResolutionError, match="non-public"): + await source.fetch_bytes("https://127.0.0.1/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_actual_peer_must_be_public_and_match_validated_dns() -> None: + private_peer = client_for( + lambda request: response(peer="127.0.0.1"), + resolver=StaticResolver((PUBLIC_IP,)), + ) + with pytest.raises(RemoteSourcePeerError, match="non-public"): + await private_peer.fetch_bytes("https://example.com/data", max_bytes=100) + + changed_peer = client_for( + lambda request: response(peer=SECOND_PUBLIC_IP), + resolver=StaticResolver((PUBLIC_IP,)), + ) + with pytest.raises(RemoteSourcePeerError, match="did not match"): + await changed_peer.fetch_bytes("https://example.com/data", max_bytes=100) + + wrong_port = client_for( + lambda request: response(peer=PUBLIC_IP, peer_port=8443), + resolver=StaticResolver((PUBLIC_IP,)), + ) + with pytest.raises(RemoteSourcePeerError, match="unexpected port"): + await wrong_port.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_dns_rebinding_cannot_change_numeric_connection_target() -> None: + attempted_peers: list[str] = [] + + def rebinding_transport(request: httpx.Request) -> httpx.Response: + # Model a second DNS lookup that has been poisoned after validation. + # A hostname request would be sent to loopback; a numeric request cannot + # be rebound and is sent to the already approved address. + peer = "127.0.0.1" if request.url.host == "example.com" else request.url.host + attempted_peers.append(peer) + return response(peer=peer) + + source = client_for( + rebinding_transport, + resolver=StaticResolver((PUBLIC_IP,)), + ) + body, _ = await source.fetch_bytes("https://example.com/data", max_bytes=100) + + assert body == b"ok" + assert attempted_peers == [PUBLIC_IP] + + +@pytest.mark.asyncio +async def test_approved_addresses_are_tried_by_numeric_ip_without_new_dns() -> None: + attempted_urls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + attempted_urls.append(request.url.host) + if request.url.host == SECOND_PUBLIC_IP: + raise httpx.ConnectError("unavailable", request=request) + return response(peer=PUBLIC_IP) + + source = client_for( + handler, + resolver=StaticResolver((PUBLIC_IP, SECOND_PUBLIC_IP)), + ) + body, _ = await source.fetch_bytes("https://example.com/data", max_bytes=100) + + assert body == b"ok" + assert attempted_urls == [SECOND_PUBLIC_IP, PUBLIC_IP] + + +@pytest.mark.asyncio +async def test_missing_peer_extension_fails_closed() -> None: + source = client_for(lambda request: response(peer=None)) + with pytest.raises(RemoteSourcePeerError, match="identity was unavailable"): + await source.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_peer_extension_without_server_address_fails_closed() -> None: + def handler(request: httpx.Request) -> httpx.Response: + result = response(peer=None) + result.extensions["network_stream"] = PeerStream(None) + return result + + source = client_for(handler) + with pytest.raises(RemoteSourcePeerError, match="identity was unavailable"): + await source.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.parametrize( + "url", + [ + "https://example.com/FTP/file.fits", + "https://heasarc.gsfc.nasa.gov/ftp/file.fits", + "https://heasarc.gsfc.nasa.gov/FTP", + "https://heasarc.gsfc.nasa.gov/FTP/../cgi-bin/query", + "https://heasarc.gsfc.nasa.gov/FTP/%2e%2e/cgi-bin/query", + "https://heasarc.gsfc.nasa.gov/FTP%2ffile.fits", + "https://heasarc.gsfc.nasa.gov.evil.test/FTP/file.fits", + "https://heasarc.gsfc.nasa.gov./FTP/file.fits", + ], +) +@pytest.mark.asyncio +async def test_archive_policy_has_exact_host_port_and_path_boundary(url: str) -> None: + source = client_for(lambda request: response(), policy=HEASARC_ARCHIVE_POLICY) + with pytest.raises(RemoteSourcePolicyError): + await source.fetch_bytes(url, max_bytes=100) + + +@pytest.mark.asyncio +async def test_archive_policy_accepts_implicit_or_explicit_default_port() -> None: + resolver = StaticResolver() + source = client_for( + lambda request: response(), + resolver=resolver, + policy=HEASARC_ARCHIVE_POLICY, + ) + for url in ( + "https://heasarc.gsfc.nasa.gov/FTP/file.fits", + "https://heasarc.gsfc.nasa.gov:443/FTP/file.fits", + ): + body, _ = await source.fetch_bytes(url, max_bytes=100) + assert body == b"ok" + assert resolver.calls == [ + ("heasarc.gsfc.nasa.gov", 443), + ("heasarc.gsfc.nasa.gov", 443), + ] + + +@pytest.mark.asyncio +async def test_archive_redirect_cannot_escape_exact_boundary() -> None: + source = client_for( + lambda request: response( + 302, + headers={"Location": "https://heasarc.gsfc.nasa.gov/cgi-bin/private"}, + ), + policy=HEASARC_ARCHIVE_POLICY, + ) + with pytest.raises(RemoteSourcePolicyError, match="path boundary"): + await source.fetch_bytes( + "https://heasarc.gsfc.nasa.gov/FTP/start", max_bytes=100 + ) + + +@pytest.mark.asyncio +async def test_every_redirect_is_re_resolved_and_peer_validated() -> None: + resolver = HostResolver( + {"first.example": (PUBLIC_IP,), "second.example": (SECOND_PUBLIC_IP,)} + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.headers["host"] == "first.example": + return response( + 302, + headers={"Location": "https://second.example/final?credential=hidden"}, + peer=PUBLIC_IP, + ) + return response(body=b"final", peer=SECOND_PUBLIC_IP) + + source = client_for(handler, resolver=resolver) + body, info = await source.fetch_bytes( + "https://first.example/start?secret=one", max_bytes=100 + ) + + assert body == b"final" + assert info.redirect_count == 1 + assert info.display_url == "https://second.example/final" + assert resolver.calls == [("first.example", 443), ("second.example", 443)] + + +@pytest.mark.asyncio +async def test_redirect_to_private_dns_target_is_rejected_before_second_request() -> ( + None +): + resolver = HostResolver( + {"first.example": (PUBLIC_IP,), "internal.example": ("127.0.0.1",)} + ) + requests: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request.headers["host"]) + return response( + 302, + headers={"Location": "https://internal.example/admin"}, + peer=PUBLIC_IP, + ) + + source = client_for(handler, resolver=resolver) + with pytest.raises(RemoteSourceResolutionError, match="non-public"): + await source.fetch_bytes("https://first.example/start", max_bytes=100) + assert requests == ["first.example"] + + +@pytest.mark.asyncio +async def test_redirect_cannot_downgrade_to_http() -> None: + source = client_for( + lambda request: response( + 302, headers={"Location": "http://example.com/plaintext"} + ) + ) + with pytest.raises(RemoteSourcePolicyError, match="HTTPS"): + await source.fetch_bytes("https://example.com/start", max_bytes=100) + + +@pytest.mark.asyncio +async def test_redirect_limit_and_missing_location_are_bounded() -> None: + source = client_for( + lambda request: response(302, headers={"Location": "/again"}), + max_redirects=2, + ) + with pytest.raises(RemoteSourceRedirectError, match="limit"): + await source.fetch_bytes("https://example.com/start", max_bytes=100) + + missing = client_for(lambda request: response(302)) + with pytest.raises(RemoteSourceRedirectError, match="missing Location"): + await missing.fetch_bytes("https://example.com/start", max_bytes=100) + + +@pytest.mark.asyncio +async def test_content_length_is_checked_before_body_iteration() -> None: + iterated = False + + def mark_iteration(index: int) -> None: + nonlocal iterated + iterated = True + + source = client_for( + lambda request: response( + body=ChunkStream((b"never",), mark_iteration), + headers={"Content-Length": "101"}, + ) + ) + with pytest.raises(RemoteSourceSizeError, match="100-byte"): + await source.fetch_bytes("https://example.com/large", max_bytes=100) + assert iterated is False + + +@pytest.mark.parametrize( + "content_length", + ["-1", "not-a-number", "1, 2"], +) +@pytest.mark.asyncio +async def test_invalid_or_conflicting_content_lengths_are_rejected( + content_length: str, +) -> None: + source = client_for( + lambda request: response(headers={"Content-Length": content_length}) + ) + with pytest.raises(RemoteSourceSizeError, match="Content-Length"): + await source.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_huge_numeric_content_length_fails_as_size_error() -> None: + source = client_for( + lambda request: response(headers={"Content-Length": "9" * 5000}) + ) + with pytest.raises(RemoteSourceSizeError, match="100-byte"): + await source.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_incremental_cap_applies_without_content_length() -> None: + source = client_for( + lambda request: response(body=ChunkStream((b"1234", b"5678"))), + chunk_size=4, + ) + with pytest.raises(RemoteSourceSizeError, match="7-byte"): + await source.fetch_bytes("https://example.com/data", max_bytes=7) + + +@pytest.mark.asyncio +async def test_non_identity_content_encoding_is_rejected() -> None: + source = client_for(lambda request: response(headers={"Content-Encoding": "gzip"})) + with pytest.raises(RemoteSourceError, match="identity encoding"): + await source.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_stream_yields_raw_chunks_and_safe_metadata() -> None: + source = client_for( + lambda request: response( + body=ChunkStream((b"one", b"two")), + headers={"Content-Length": "6", "Content-Type": "application/fits"}, + ), + chunk_size=3, + ) + async with source.stream( + "https://example.com/data?api_key=never-display", max_bytes=6 + ) as stream: + chunks = [chunk async for chunk in stream.aiter_bytes()] + assert stream.bytes_read == 6 + assert stream.info.content_length == 6 + assert stream.info.content_type == "application/fits" + with pytest.raises(RuntimeError, match="only be consumed once"): + await anext(stream.aiter_bytes()) + assert chunks == [b"one", b"two"] + assert "api_key" not in repr(stream.info) + + +@pytest.mark.asyncio +async def test_cancellation_is_checked_for_each_chunk() -> None: + checks = 0 + + def cancellation_check() -> bool: + nonlocal checks + checks += 1 + return checks >= 4 + + source = client_for( + lambda request: response(body=ChunkStream((b"first", b"second"))), + chunk_size=5, + ) + yielded: list[bytes] = [] + with pytest.raises(RemoteSourceCancelled): + async with source.stream( + "https://example.com/data", + max_bytes=100, + cancellation_check=cancellation_check, + ) as stream: + async for chunk in stream.aiter_bytes(): + yielded.append(chunk) + assert yielded == [b"first"] + + +@pytest.mark.asyncio +async def test_async_cancellation_hook_is_supported() -> None: + async def cancelled() -> bool: + return True + + source = client_for(lambda request: response()) + with pytest.raises(RemoteSourceCancelled): + await source.fetch_bytes( + "https://example.com/data", max_bytes=100, cancellation_check=cancelled + ) + + +class MutableClock: + def __init__(self) -> None: + self.value = 0.0 + + def __call__(self) -> float: + return self.value + + +@pytest.mark.asyncio +async def test_monotonic_total_deadline_covers_dns() -> None: + clock = MutableClock() + + async def resolver(host: str, port: int) -> Sequence[str]: + clock.value = 2.0 + return (PUBLIC_IP,) + + source = client_for( + lambda request: response(), + resolver=resolver, + clock=clock, + timeouts=RemoteTimeouts(total=1.0), + ) + with pytest.raises(RemoteSourceTimeout, match="deadline"): + await source.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_monotonic_total_deadline_covers_body_iteration() -> None: + clock = MutableClock() + + def advance_clock(index: int) -> None: + clock.value = 2.0 + + source = client_for( + lambda request: response( + body=ChunkStream((b"too-late",), on_chunk=advance_clock) + ), + clock=clock, + timeouts=RemoteTimeouts(total=1.0), + ) + with pytest.raises(RemoteSourceTimeout, match="deadline"): + await source.fetch_bytes("https://example.com/data", max_bytes=100) + + +@pytest.mark.asyncio +async def test_httpx_phase_timeout_is_sanitized() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectTimeout("secret=must-not-leak", request=request) + + source = client_for(handler) + with pytest.raises(RemoteSourceTimeout) as exc_info: + await source.fetch_bytes( + "https://example.com/data?secret=must-not-leak", max_bytes=100 + ) + assert "must-not-leak" not in str(exc_info.value) + assert exc_info.value.__cause__ is None + + +@pytest.mark.asyncio +async def test_httpx_read_timeout_is_sanitized() -> None: + source = client_for(lambda request: response(body=FailingReadStream())) + with pytest.raises(RemoteSourceTimeout) as exc_info: + await source.fetch_bytes( + "https://example.com/data?secret=must-not-leak", max_bytes=100 + ) + assert "must-not-leak" not in str(exc_info.value) + assert "read secret" not in str(exc_info.value) + assert exc_info.value.__cause__ is None + + +@pytest.mark.asyncio +async def test_dns_failure_is_sanitized_and_query_is_redacted() -> None: + async def resolver(host: str, port: int) -> Sequence[str]: + raise OSError("resolver secret") + + source = client_for(lambda request: response(), resolver=resolver) + with pytest.raises(RemoteSourceResolutionError) as exc_info: + await source.fetch_bytes( + "https://example.com/data?credential=never-show", max_bytes=100 + ) + assert "credential" not in str(exc_info.value) + assert "never-show" not in str(exc_info.value) + assert "resolver secret" not in str(exc_info.value) + assert exc_info.value.__cause__ is None + + +@pytest.mark.asyncio +async def test_http_status_error_and_metadata_never_expose_query() -> None: + source = client_for(lambda request: response(404)) + with pytest.raises(RemoteSourceHTTPError) as exc_info: + await source.fetch_bytes( + "https://example.com/missing?token=top-secret", max_bytes=100 + ) + assert exc_info.value.status_code == 404 + assert "top-secret" not in str(exc_info.value) + assert "token" not in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_bounded_text_helper_decodes_listing() -> None: + source = client_for( + lambda request: response( + body=b"file", + headers={"Content-Length": "28"}, + ) + ) + text, info = await source.fetch_text("https://example.com/listing", max_bytes=64) + assert text == "file" + assert info.content_length == 28 + + +@pytest.mark.asyncio +async def test_bounded_text_helper_rejects_invalid_encoding() -> None: + source = client_for(lambda request: response(body=b"\xff")) + with pytest.raises(RemoteSourceError, match="not valid utf-8"): + await source.fetch_text("https://example.com/listing", max_bytes=1) + + +def test_redaction_never_includes_userinfo_query_or_fragment() -> None: + display = redact_remote_url( + "https://name:password@example.com:443/path?api_key=secret#fragment" + ) + assert display == "https://example.com:443/path" + assert "name" not in display + assert "password" not in display + assert "api_key" not in display + assert "secret" not in display + assert "fragment" not in display + + +@pytest.mark.asyncio +async def test_unicode_url_is_canonicalized_without_treating_utf8_as_controls() -> None: + resolver = StaticResolver() + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return response() + + source = client_for(handler, resolver=resolver) + body, info = await source.fetch_bytes( + "https://b\N{LATIN SMALL LETTER U WITH DIAERESIS}cher.example/\N{LATIN SMALL LETTER U WITH DIAERESIS}ber", + max_bytes=100, + ) + assert body == b"ok" + assert resolver.calls == [("xn--bcher-kva.example", 443)] + assert seen[0].url.host == PUBLIC_IP + assert seen[0].headers["host"] == "xn--bcher-kva.example" + assert seen[0].extensions["sni_hostname"] == "xn--bcher-kva.example" + assert info.display_url.endswith("/\N{LATIN SMALL LETTER U WITH DIAERESIS}ber") + + +@pytest.mark.asyncio +async def test_client_disables_environment_proxies_and_automatic_redirects( + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_client = httpx.AsyncClient + constructor_options: list[dict[str, object]] = [] + + class RecordingClient(real_client): + def __init__(self, *args, **kwargs) -> None: + constructor_options.append(dict(kwargs)) + super().__init__(*args, **kwargs) + + monkeypatch.setattr(remote_source_module.httpx, "AsyncClient", RecordingClient) + source = client_for(lambda request: response()) + body, _ = await source.fetch_bytes("https://example.com/data", max_bytes=100) + assert body == b"ok" + assert constructor_options[0]["trust_env"] is False + assert constructor_options[0]["follow_redirects"] is False + assert constructor_options[0]["limits"].max_keepalive_connections == 0 + + +def test_timeout_and_client_bounds_validate_configuration() -> None: + with pytest.raises(ValueError, match="total timeout"): + RemoteTimeouts(total=0) + with pytest.raises(ValueError, match="total timeout"): + RemoteTimeouts(total=float("inf")) + with pytest.raises(ValueError, match="read timeout"): + RemoteTimeouts(read=float("nan")) + with pytest.raises(ValueError, match="connect timeout"): + RemoteTimeouts(connect=True) + with pytest.raises(ValueError, match="max_redirects"): + RemoteSourceClient(max_redirects=-1) + with pytest.raises(ValueError, match="max_redirects"): + RemoteSourceClient(max_redirects=True) + with pytest.raises(ValueError, match="max_redirects"): + RemoteSourceClient(max_redirects=11) + with pytest.raises(ValueError, match="chunk_size"): + RemoteSourceClient(chunk_size=0) + with pytest.raises(ValueError, match="chunk_size"): + RemoteSourceClient(chunk_size=True) + with pytest.raises(ValueError, match="chunk_size"): + RemoteSourceClient(chunk_size=1024 * 1024 + 1) + + +def test_public_address_fixture_is_really_global() -> None: + assert ipaddress.ip_address(PUBLIC_IP).is_global diff --git a/python-backend/tests/test_retired_legacy_surfaces.py b/python-backend/tests/test_retired_legacy_surfaces.py new file mode 100644 index 0000000..18b02d0 --- /dev/null +++ b/python-backend/tests/test_retired_legacy_surfaces.py @@ -0,0 +1,57 @@ +"""Structural regressions for intentionally retired unsafe legacy runtimes.""" + +from pathlib import Path + +from main import create_app + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SESSION_SECRET = "retired-surface-test-session-secret" * 2 + + +def test_duplicate_raw_path_export_api_is_absent_from_the_application(): + app = create_app(session_secret=SESSION_SECRET) + route_paths = {route.path for route in app.routes} + + assert not {path for path in route_paths if path.startswith("/api/export")} + assert "/api/utilities/io/export" in route_paths + + +def test_duplicate_export_implementations_and_renderer_client_are_removed(): + retired_files = ( + "python-backend/routes/export_routes.py", + "python-backend/services/export_service.py", + "src/api/exportApi.ts", + ) + + assert all( + not (PROJECT_ROOT / relative_path).exists() for relative_path in retired_files + ) + + renderer_api_source = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted((PROJECT_ROOT / "src/api").glob("*.ts")) + ) + assert "/api/export" not in renderer_api_source + + +def test_legacy_panel_server_has_no_executable_or_deployment_entrypoint(): + assert not (PROJECT_ROOT / "explorer.py").exists() + assert not (PROJECT_ROOT / "Dockerfile").exists() + + operational_sources = [ + PROJECT_ROOT / "package.json", + PROJECT_ROOT / "pixi.toml", + *(PROJECT_ROOT / ".github/workflows").glob("*.yml"), + *(PROJECT_ROOT / ".github/workflows").glob("*.yaml"), + *(PROJECT_ROOT / "scripts").glob("*"), + ] + launch_configuration = "\n".join( + path.read_text(encoding="utf-8") + for path in operational_sources + if path.is_file() + ).lower() + + assert "panel serve" not in launch_configuration + assert "allow-websocket-origin" not in launch_configuration + assert "huggingface.co/spaces" not in launch_configuration diff --git a/python-backend/tests/test_route_concurrency.py b/python-backend/tests/test_route_concurrency.py new file mode 100644 index 0000000..5003010 --- /dev/null +++ b/python-backend/tests/test_route_concurrency.py @@ -0,0 +1,71 @@ +"""Verify analysis routes run blocking work off the event loop. + +A handler that calls the synchronous service directly blocks the loop, so a +concurrent asyncio.sleep cannot complete on time. With asyncio.to_thread, the +sleep returns at the expected time while the slow computation runs in a thread. +""" + +import asyncio +import time + +import httpx +import pytest + +from services.state_manager import StateManager +from tests.backend_auth import ( + TEST_BACKEND_AUTH_HEADERS, + TEST_BACKEND_SESSION_SECRET, +) +from utils.performance_monitor import PerformanceMonitor + + +@pytest.mark.asyncio +async def test_lightcurve_create_does_not_block_event_loop(monkeypatch): + import services.lightcurve_service as lcs_mod + from main import create_app + + def slow_create(self, **kwargs): + time.sleep(0.6) + return {"success": True, "data": None, "message": "ok", "error": None} + + monkeypatch.setattr( + lcs_mod.LightcurveService, "create_lightcurve_from_event_list", slow_create + ) + + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + # ASGITransport does not run the lifespan; provide state manually. + app.state.state_manager = StateManager() + app.state.performance_monitor = PerformanceMonitor() + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + slow_task = asyncio.create_task( + client.post( + "/api/lightcurve/from-event-list", + json={"event_list_name": "x", "dt": 0.1, "output_name": "y"}, + ) + ) + # Start the clock before the first yield so the measurement captures + # the block wherever the first scheduler checkpoint lands. + t0 = time.monotonic() + # Yield control so the slow task can start executing. + await asyncio.sleep(0) + + # Measure how long the yield plus a 0.05s sleep actually take. + # If the event loop is blocked by the sync service call, control + # cannot return until the blocking work finishes (~0.6s later), so + # the measured duration will be ~0.65s instead of ~0.05s. + await asyncio.sleep(0.05) + elapsed = time.monotonic() - t0 + + probe = await client.get("/") + slow_response = await slow_task + + assert probe.status_code == 200 + assert slow_response.status_code == 200 + # Without to_thread the sleep is delayed ~0.55s by the blocked loop. + assert elapsed < 0.4, f"event loop was blocked for {elapsed:.2f}s" diff --git a/python-backend/tests/test_secure_data_boundary.py b/python-backend/tests/test_secure_data_boundary.py new file mode 100644 index 0000000..d14e1c6 --- /dev/null +++ b/python-backend/tests/test_secure_data_boundary.py @@ -0,0 +1,1355 @@ +"""Adversarial coverage for native-grant data and private job resources.""" + +from __future__ import annotations + +import asyncio +import gzip +import logging +import shutil +import threading +import time +from concurrent.futures import Future +from contextlib import asynccontextmanager, contextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import h5py +import numpy as np +import pytest +from astropy.io import fits +from pydantic import ValidationError +from stingray import EventList + +import services.data_service as data_service_module +import services.job_manager as job_manager_module +from main import create_app +from models.job import Job +from routes.data_routes import ( + BatchFileSizeRequest, + BatchLoadEventListRequest, + LoadEventListRequest, + load_event_list as load_event_list_route, +) +from routes.job_routes import ( + SubmitBatchJobRequest, + SubmitLoadJobRequest, + submit_load_job, +) +from services.data_service import DataService +from services.job_manager import JobManager +from services.remote_source import GENERAL_HTTPS_POLICY, RemoteSourceError +from services.state_manager import StateManager +from services.utility_helpers import GrantedReadFile, issue_file_grant +from tests.backend_auth import TEST_BACKEND_SESSION_SECRET + + +SECRET = "secure-data-boundary-secret-at-least-32-bytes" + + +@pytest.fixture(autouse=True) +def file_grant_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STINGRAY_FILE_GRANT_SECRET", SECRET) + + +def _grant(path: Path) -> str: + return issue_file_grant(str(path), access="read").grant + + +def _write_hdf5(path: Path, times: list[float]) -> None: + EventList( + time=np.asarray(times, dtype=float), + gti=np.asarray([[min(times), max(times)]], dtype=float), + ).write(str(path), fmt="hdf5") + + +def _write_event_fits(path: Path) -> None: + primary = fits.PrimaryHDU() + primary.header["TELESCOP"] = "NICER" + primary.header["INSTRUME"] = "XTI" + events = fits.BinTableHDU.from_columns( + [ + fits.Column(name="TIME", format="D", unit="s", array=[1.0, 2.0]), + fits.Column(name="PI", format="J", array=[0, 1]), + ], + name="EVENTS", + ) + events.header["MJDREFI"] = 58_000 + events.header["MJDREFF"] = 0.0 + events.header["TIMESYS"] = "TT" + events.header["TIMEUNIT"] = "s" + events.header["TSTART"] = 1.0 + events.header["TSTOP"] = 2.0 + fits.HDUList([primary, events]).writeto(path, checksum=True) + + +def _write_rmf(path: Path, energies: tuple[float, float]) -> None: + ebounds = fits.BinTableHDU.from_columns( + [ + fits.Column(name="CHANNEL", format="J", array=[0, 1]), + fits.Column( + name="E_MIN", + format="D", + unit="keV", + array=[energies[0], energies[1]], + ), + fits.Column( + name="E_MAX", + format="D", + unit="keV", + array=[energies[0], energies[1]], + ), + ], + name="EBOUNDS", + ) + fits.HDUList([fits.PrimaryHDU(), ebounds]).writeto(path, checksum=True) + + +def test_request_models_require_exact_grants_pairs_bounds_and_no_extras() -> None: + with pytest.raises(ValidationError, match="file_grant"): + LoadEventListRequest(file_path="/selected/events.evt", name="events") + with pytest.raises(ValidationError, match="rmf_file and rmf_grant"): + LoadEventListRequest( + file_path="/selected/events.evt", + file_grant="grant", + name="events", + rmf_file="/selected/cal.rmf", + ) + with pytest.raises(ValidationError, match="extra_forbidden"): + LoadEventListRequest( + file_path="/selected/events.evt", + file_grant="grant", + name="events", + surprise=True, + ) + with pytest.raises(ValidationError, match="extra_forbidden"): + LoadEventListRequest( + file_path="/selected/events.evt", + file_grant="grant", + name="events", + _file_source="forged-internal-capability", + ) + with pytest.raises(ValidationError): + LoadEventListRequest( + file_path="/selected/events.evt", + file_grant="grant", + name="events", + high_precision="false", + ) + with pytest.raises(ValidationError): + LoadEventListRequest( + file_path="/selected/events.evt", + file_grant="grant", + name="/private/events", + ) + with pytest.raises(ValidationError): + BatchLoadEventListRequest( + files=[ + { + "file_path": f"/selected/{index}.evt", + "file_grant": "grant", + "name": f"events-{index}", + } + for index in range(33) + ] + ) + BatchFileSizeRequest( + files=[{"file_path": "/selected/events.evt", "file_grant": "grant"}] + ) + with pytest.raises(ValidationError, match="shared_rmf"): + SubmitBatchJobRequest( + files=[ + { + "file_path": "/selected/events.evt", + "file_grant": "grant", + "name": "events", + } + ], + shared_rmf_file="/selected/cal.rmf", + ) + + +def test_every_local_route_is_grant_shaped_and_legacy_save_is_absent() -> None: + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + route_paths = { + (method, route.path) + for route in app.routes + for method in getattr(route, "methods", set()) + } + assert ("POST", "/api/data/save") not in route_paths + assert not hasattr(DataService, "save_event_list") + assert {"file_path", "file_grant"} <= set(LoadEventListRequest.model_fields) + assert {"files"} <= set(BatchFileSizeRequest.model_fields) + assert {"file_path", "file_grant"} <= set(SubmitLoadJobRequest.model_fields) + + +def test_hdf5_load_uses_h5py_over_anonymous_spool_not_selected_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + selected = tmp_path / "events.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + seen: list[Any] = [] + original_h5py_file = h5py.File + + class TrackedH5pyFile(original_h5py_file): + def __init__(self, source, *args, **kwargs): + seen.append(source) + assert not isinstance(source, (str, bytes, Path)) + super().__init__(source, *args, **kwargs) + + monkeypatch.setattr(data_service_module.h5py, "File", TrackedH5pyFile) + result = DataService(StateManager()).load_event_list( + str(selected), + "events", + fmt="hdf5", + file_grant=_grant(selected), + ) + + assert result["success"] is True + assert seen + + +def test_path_swap_after_grant_open_cannot_redirect_hdf5_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + selected = tmp_path / "selected.hdf5" + original = tmp_path / "original.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + grant = _grant(selected) + original_spool = data_service_module._spooled_copy + swapped = False + + @contextmanager + def swap_then_spool(source, *, max_bytes, cancellation_check=None): + nonlocal swapped + if not swapped: + swapped = True + selected.rename(original) + _write_hdf5(selected, [90.0, 91.0]) + with original_spool( + source, + max_bytes=max_bytes, + cancellation_check=cancellation_check, + ) as stream: + yield stream + + monkeypatch.setattr(data_service_module, "_spooled_copy", swap_then_spool) + state = StateManager() + result = DataService(state).load_event_list( + str(selected), "events", fmt="hdf5", file_grant=grant + ) + + assert result["success"] is True + assert state.get_event_data("events").time.tolist() == [1.0, 2.0] + + +def test_native_open_failure_does_not_expose_selected_path(tmp_path: Path) -> None: + selected = tmp_path / "private-selected-events.hdf5" + moved = tmp_path / "moved.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + grant = _grant(selected) + selected.rename(moved) + + with pytest.raises(PermissionError) as raised: + DataService(StateManager()).load_event_list( + str(selected), "events", fmt="hdf5", file_grant=grant + ) + + assert str(selected) not in str(raised.value) + + +def test_rmf_swap_after_grant_open_cannot_redirect_calibration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + events = tmp_path / "events.evt" + rmf = tmp_path / "selected.rmf" + original_rmf = tmp_path / "original.rmf" + _write_event_fits(events) + _write_rmf(rmf, (1.0, 2.0)) + event_grant = _grant(events) + rmf_grant = _grant(rmf) + original_spool = data_service_module._spooled_copy + swapped = False + + @contextmanager + def swap_rmf_then_spool(source, *, max_bytes, cancellation_check=None): + nonlocal swapped + if source.path == rmf.resolve() and not swapped: + swapped = True + rmf.rename(original_rmf) + _write_rmf(rmf, (100.0, 200.0)) + with original_spool( + source, + max_bytes=max_bytes, + cancellation_check=cancellation_check, + ) as stream: + yield stream + + monkeypatch.setattr(data_service_module, "_spooled_copy", swap_rmf_then_spool) + state = StateManager() + result = DataService(state).load_event_list( + str(events), + "events", + fmt="ogip", + rmf_file=str(rmf), + file_grant=event_grant, + rmf_grant=rmf_grant, + ) + + assert result["success"] is True + assert state.get_event_data("events").energy.tolist() == [1.0, 2.0] + + +def test_gzip_ogip_load_is_preserved_without_reopening_selected_path( + tmp_path: Path, +) -> None: + uncompressed = tmp_path / "events.evt" + selected = tmp_path / "events.evt.gz" + _write_event_fits(uncompressed) + with uncompressed.open("rb") as source, gzip.open(selected, "wb") as target: + shutil.copyfileobj(source, target) + + result = DataService(StateManager()).load_event_list( + str(selected), "events", fmt="ogip", file_grant=_grant(selected) + ) + + assert result["success"] is True + assert result["data"]["n_events"] == 2 + assert "stingray-input-" not in repr(result) + + +def test_batch_rejects_one_bad_grant_before_any_load( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first = tmp_path / "first.hdf5" + second = tmp_path / "second.hdf5" + _write_hdf5(first, [1.0, 2.0]) + _write_hdf5(second, [3.0, 4.0]) + service = DataService(StateManager()) + monkeypatch.setattr( + service, + "load_event_list", + lambda *args, **kwargs: pytest.fail("load started before batch pinning"), + ) + + with pytest.raises(PermissionError): + service.load_batch_event_lists( + [ + { + "file_path": str(first), + "file_grant": _grant(first), + "name": "first", + }, + { + "file_path": str(second), + "file_grant": "forged", + "name": "second", + }, + ] + ) + + +def test_shared_rmf_batch_is_serialized_to_avoid_seek_races( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + files = [] + for index in range(3): + path = tmp_path / f"events-{index}.evt" + path.write_bytes(b"placeholder") + files.append( + { + "file_path": str(path), + "file_grant": _grant(path), + "name": f"events-{index}", + } + ) + rmf = tmp_path / "shared.rmf" + rmf.write_bytes(b"placeholder") + lock = threading.Lock() + active = 0 + maximum_active = 0 + + def fake_load(*args, **kwargs): + nonlocal active, maximum_active + with lock: + active += 1 + maximum_active = max(maximum_active, active) + time.sleep(0.02) + with lock: + active -= 1 + return { + "success": True, + "data": {"n_events": 1}, + "message": "loaded", + "error": None, + } + + service = DataService(StateManager()) + monkeypatch.setattr(service, "load_event_list", fake_load) + result = service.load_batch_event_lists( + files, + shared_rmf_file=str(rmf), + shared_rmf_grant=_grant(rmf), + max_workers=3, + ) + + assert result["success"] is True + assert result["data"]["summary"]["workers_used"] == 1 + assert maximum_active == 1 + + +class _CapturingExecutor: + def __init__(self) -> None: + self.calls: list[tuple[Any, tuple[Any, ...]]] = [] + self.futures: list[Future] = [] + + def submit(self, target, *args) -> Future: + future = Future() + self.calls.append((target, args)) + self.futures.append(future) + return future + + def shutdown(self, *args, **kwargs) -> None: + if kwargs.get("cancel_futures"): + for future in self.futures: + future.cancel() + return None + + +class _GatedRunningExecutor: + def __init__(self) -> None: + self.started = threading.Event() + self.release = threading.Event() + self.future = Future() + self.job = None + + def submit(self, target, job) -> Future: + self.job = job + assert self.future.set_running_or_notify_cancel() is True + self.started.set() + assert self.release.wait(2.0) + return self.future + + def shutdown(self, *args, **kwargs) -> None: + return None + + +class _RejectingExecutor: + def __init__(self) -> None: + self.calls = 0 + + def submit(self, target, job) -> Future: + self.calls += 1 + raise RuntimeError("executor rejected submission") + + def shutdown(self, *args, **kwargs) -> None: + return None + + +def _manager_with_captured_executor( + state: StateManager, +) -> tuple[JobManager, _CapturingExecutor]: + manager = JobManager(state, DataService(state), max_workers=1) + manager._executor.shutdown(wait=False, cancel_futures=True) + executor = _CapturingExecutor() + manager._executor = executor + return manager, executor + + +def test_schedule_keeps_running_cancelled_job_owned_until_callback( + tmp_path: Path, +) -> None: + selected = tmp_path / "selected.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + manager = JobManager(StateManager(), DataService(StateManager()), max_workers=1) + manager._executor.shutdown(wait=False, cancel_futures=True) + executor = _GatedRunningExecutor() + manager._executor = executor + + submitted: list[Job] = [] + + def submit() -> None: + submitted.append( + manager.submit_load_job( + str(selected), + "events", + fmt="hdf5", + file_grant=_grant(selected), + ) + ) + + thread = threading.Thread(target=submit) + thread.start() + assert executor.started.wait(2.0) + job = executor.job + assert job is not None + source = manager._resources[job.id].private["file_source"] + assert manager._futures[job.id] is job_manager_module._SUBMITTING + assert job.status.value == "pending" + + assert manager.cancel_job(job.id) is True + assert manager.clear_completed_jobs() == 0 + assert manager.get_job(job.id) is job + assert source.stream.closed is False + + executor.release.set() + thread.join(timeout=2.0) + assert not thread.is_alive() + assert submitted == [job] + assert job.status.value == "cancelled" + assert executor.future.cancel() is False + executor.future.set_result(None) + + assert source.stream.closed is True + assert job.id not in manager._resources + assert job.id not in manager._futures + assert manager._reserved_jobs == 0 + assert manager._retained_capabilities == 0 + event_types = [update["type"] for update in manager._update_queue.queue] + assert event_types.count("job_created") == 1 + assert "job_completed" not in event_types + assert "job_failed" not in event_types + + +def test_schedule_rolls_back_resources_when_executor_submit_fails( + tmp_path: Path, +) -> None: + selected = tmp_path / "selected.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + state = StateManager() + manager = JobManager(state, DataService(state), max_workers=1) + manager._executor.shutdown(wait=False, cancel_futures=True) + executor = _RejectingExecutor() + manager._executor = executor + + with pytest.raises(RuntimeError, match="rejected"): + manager.submit_load_job( + str(selected), + "events", + fmt="hdf5", + file_grant=_grant(selected), + ) + + assert executor.calls == 1 + assert manager._jobs == {} + assert manager._resources == {} + assert manager._futures == {} + assert manager._created_job_ids == set() + assert manager._reserved_jobs == 0 + assert manager._retained_capabilities == 0 + assert manager._update_queue.empty() + + +def test_queued_job_pins_before_return_redacts_and_cleans_after_success( + tmp_path: Path, +) -> None: + selected = tmp_path / "selected.hdf5" + original = tmp_path / "original.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + state = StateManager() + manager, executor = _manager_with_captured_executor(state) + job = manager.submit_load_job( + str(selected), + "events", + fmt="hdf5", + file_grant=_grant(selected), + ) + public = job.to_dict() + assert "params" not in public + assert str(selected) not in repr(public) + assert job.id in manager._resources + + selected.rename(original) + _write_hdf5(selected, [90.0, 91.0]) + target, args = executor.calls[0] + target(*args) + executor.futures[0].set_result(None) + + assert job.status.value == "completed" + assert state.get_event_data("events").time.tolist() == [1.0, 2.0] + assert job.id not in manager._resources + assert str(selected) not in repr(job.to_dict()) + + +def test_pending_job_cancel_closes_private_descriptor_and_redacts_updates( + tmp_path: Path, +) -> None: + selected = tmp_path / "selected.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + manager, _executor = _manager_with_captured_executor(StateManager()) + job = manager.submit_load_job( + str(selected), + "events", + fmt="hdf5", + file_grant=_grant(selected), + ) + source = manager._resources[job.id].private["file_source"] + + assert manager.cancel_job(job.id) is True + assert source.stream.closed is True + assert job.id not in manager._resources + updates = list(manager._update_queue.queue) + assert "job_failed" not in {update["type"] for update in updates} + assert str(selected) not in repr(updates) + assert "file_grant" not in repr(updates) + + +def test_running_job_cancel_defers_close_until_worker_exits(tmp_path: Path) -> None: + selected = tmp_path / "selected.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + manager, executor = _manager_with_captured_executor(StateManager()) + job = manager.submit_load_job( + str(selected), + "events", + fmt="hdf5", + file_grant=_grant(selected), + ) + source = manager._resources[job.id].private["file_source"] + assert executor.futures[0].set_running_or_notify_cancel() is True + job.start() + + assert manager.cancel_job(job.id) is True + assert source.stream.closed is False + assert manager.clear_completed_jobs() == 0 + assert manager.get_job(job.id) is job + assert source.stream.closed is False + target, args = executor.calls[0] + target(*args) + executor.futures[0].set_result(None) + + assert job.status.value == "cancelled" + assert source.stream.closed is True + assert job.id not in manager._resources + + +def test_url_job_failure_redacts_url_and_cleans_pinned_rmf( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + rmf = tmp_path / "private-calibration.rmf" + rmf.write_bytes(b"calibration") + state = StateManager() + manager, executor = _manager_with_captured_executor(state) + + def fail_remote_load(*args, **kwargs): + raise RuntimeError( + "do not expose https://user:password@example.test/file?token=secret" + ) + + monkeypatch.setattr( + manager._data_service, "load_event_list_from_url", fail_remote_load + ) + supplied_url = "https://example.test/events.evt?private=query" + job = manager.submit_url_load_job( + supplied_url, + "events", + rmf_file=str(rmf), + rmf_grant=_grant(rmf), + ) + source = manager._resources[job.id].private["rmf_source"] + assert supplied_url not in repr(job.to_dict()) + + target, args = executor.calls[0] + target(*args) + executor.futures[0].set_result(None) + + public = job.to_dict() + assert job.status.value == "failed" + assert public["error"] == "The background job could not be completed" + assert supplied_url not in repr(public) + assert str(rmf) not in repr(public) + assert source.stream.closed is True + assert job.id not in manager._resources + + +def test_batch_job_pins_all_inputs_before_return_and_cleans_on_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + paths = [tmp_path / "first.hdf5", tmp_path / "second.hdf5"] + for index, path in enumerate(paths): + _write_hdf5(path, [float(index + 1), float(index + 2)]) + state = StateManager() + manager, executor = _manager_with_captured_executor(state) + + def fail_load(*args, **kwargs): + raise RuntimeError(f"do not expose {paths[0]}") + + monkeypatch.setattr(manager._data_service, "load_event_list", fail_load) + job = manager.submit_batch_load_job( + [ + { + "file_path": str(path), + "file_grant": _grant(path), + "name": f"events-{index}", + } + for index, path in enumerate(paths) + ] + ) + sources = list(manager._resources[job.id].private["file_sources"]) + assert all(not source.stream.closed for source in sources) + assert all(str(path) not in repr(job.to_dict()) for path in paths) + + target, args = executor.calls[0] + target(*args) + executor.futures[0].set_result(None) + + assert job.status.value == "failed" + assert all(source.stream.closed for source in sources) + assert job.id not in manager._resources + assert all(str(path) not in repr(job.to_dict()) for path in paths) + + +def test_shutdown_cancels_pending_job_and_closes_capabilities(tmp_path: Path) -> None: + selected = tmp_path / "selected.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + manager, _executor = _manager_with_captured_executor(StateManager()) + job = manager.submit_load_job( + str(selected), + "events", + fmt="hdf5", + file_grant=_grant(selected), + ) + source = manager._resources[job.id].private["file_source"] + + manager.shutdown() + + assert job.status.value == "cancelled" + assert "job_failed" not in { + update["type"] for update in manager._update_queue.queue + } + assert source.stream.closed is True + assert job.id not in manager._resources + + +def test_shutdown_marks_running_job_cancelled_and_cleans_after_worker_exit( + tmp_path: Path, +) -> None: + selected = tmp_path / "selected.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + manager, executor = _manager_with_captured_executor(StateManager()) + job = manager.submit_load_job( + str(selected), + "events", + fmt="hdf5", + file_grant=_grant(selected), + ) + source = manager._resources[job.id].private["file_source"] + assert executor.futures[0].set_running_or_notify_cancel() is True + + manager.shutdown() + + assert job.status.value == "cancelled" + assert source.stream.closed is False + target, args = executor.calls[0] + target(*args) + executor.futures[0].set_result(None) + assert source.stream.closed is True + assert job.id not in manager._resources + + +def test_job_public_result_recursively_drops_capability_fields() -> None: + job = Job( + result={ + "n_events": 2, + "time_range": [1.0, 2.0], + "notes": "opened /private/source.evt with token=secret", + "message": "https://user:secret@example.test/file?token=x", + "stingray_warnings": ["unclosed /private/source.evt"], + "nested": { + "source_url": "https://user:secret@example.test/file?token=x", + "file_path": "/private/file", + }, + } + ) + job.fail("do not expose /private/file or https://example.test/?token=x") + + public = job.to_dict() + assert "params" not in public + assert public["result"] == { + "event_count": 2, + "time_start": 1.0, + "time_end": 2.0, + "warnings": ["The scientific reader reported warnings"], + } + assert public["error"] == "The background job could not be completed" + assert public["progress_message"] == "Failed" + assert "/private/file" not in repr(public) + assert "token=secret" not in repr(public) + + +def test_scientific_failures_do_not_expose_native_or_private_paths( + tmp_path: Path, +) -> None: + selected = tmp_path / "private-secret-events.hdf5" + selected.write_bytes(b"not hdf5") + result = DataService(StateManager()).load_event_list( + str(selected), "events", fmt="hdf5", file_grant=_grant(selected) + ) + + assert result["success"] is False + assert result["error"] == "event_read_failed" + assert str(selected) not in repr(result) + assert "stingray-input-" not in repr(result) + + +def test_remote_loader_uses_general_policy_cap_and_returns_no_url( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "events.hdf5" + _write_hdf5(source, [1.0, 2.0]) + body = source.read_bytes() + observed: dict[str, Any] = {} + + class FakeRemoteStream: + def __init__(self) -> None: + self.info = SimpleNamespace(content_length=len(body)) + self.bytes_read = 0 + + async def aiter_bytes(self): + self.bytes_read = len(body) + yield body + + class FakeRemoteClient: + def __init__(self, policy) -> None: + observed["policy"] = policy + + @asynccontextmanager + async def stream(self, url, *, max_bytes, cancellation_check=None): + observed["url"] = url + observed["max_bytes"] = max_bytes + yield FakeRemoteStream() + + monkeypatch.setattr(data_service_module, "RemoteSourceClient", FakeRemoteClient) + supplied = "https://example.test/events.hdf5?private=query" + result = DataService(StateManager()).load_event_list_from_url( + supplied, "events", fmt="hdf5" + ) + + assert result["success"] is True + assert observed["policy"] is GENERAL_HTTPS_POLICY + assert observed["max_bytes"] == data_service_module.REMOTE_EVENT_LIMIT + assert supplied not in repr(result) + + +def test_direct_remote_load_pins_optional_rmf_before_network( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + events = tmp_path / "events.evt" + selected_rmf = tmp_path / "selected.rmf" + original_rmf = tmp_path / "original.rmf" + _write_event_fits(events) + _write_rmf(selected_rmf, (1.0, 2.0)) + body = events.read_bytes() + + class FakeRemoteStream: + info = SimpleNamespace(content_length=len(body)) + bytes_read = 0 + + async def aiter_bytes(self): + self.bytes_read = len(body) + yield body + + class SwappingRemoteClient: + def __init__(self, _policy) -> None: + pass + + @asynccontextmanager + async def stream(self, _url, *, max_bytes, cancellation_check=None): + selected_rmf.rename(original_rmf) + _write_rmf(selected_rmf, (100.0, 200.0)) + yield FakeRemoteStream() + + monkeypatch.setattr(data_service_module, "RemoteSourceClient", SwappingRemoteClient) + state = StateManager() + result = DataService(state).load_event_list_from_url( + "https://example.test/events.evt", + "events", + fmt="ogip", + rmf_file=str(selected_rmf), + rmf_grant=_grant(selected_rmf), + ) + + assert result["success"] is True + assert state.get_event_data("events").energy.tolist() == [1.0, 2.0] + + +def test_streaming_remote_load_pins_optional_rmf_before_network( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + events = tmp_path / "events.evt" + selected_rmf = tmp_path / "selected.rmf" + original_rmf = tmp_path / "original.rmf" + _write_event_fits(events) + _write_rmf(selected_rmf, (1.0, 2.0)) + body = events.read_bytes() + + class FakeRemoteStream: + info = SimpleNamespace(content_length=len(body)) + bytes_read = 0 + + async def aiter_bytes(self): + self.bytes_read = len(body) + yield body + + class SwappingRemoteClient: + def __init__(self, _policy) -> None: + pass + + @asynccontextmanager + async def stream(self, _url, *, max_bytes, cancellation_check=None): + selected_rmf.rename(original_rmf) + _write_rmf(selected_rmf, (100.0, 200.0)) + yield FakeRemoteStream() + + monkeypatch.setattr(data_service_module, "RemoteSourceClient", SwappingRemoteClient) + state = StateManager() + service = DataService(state) + + async def collect_events(): + return [ + event + async for event in service.load_event_list_from_url_stream( + "https://example.test/events.evt", + "events", + fmt="ogip", + rmf_file=str(selected_rmf), + rmf_grant=_grant(selected_rmf), + ) + ] + + streamed = asyncio.run(collect_events()) + + assert streamed[-1]["type"] == "complete" + assert state.get_event_data("events").energy.tolist() == [1.0, 2.0] + + +def test_remote_policy_failures_do_not_expose_any_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + supplied = "https://example.test/private/events.evt?token=secret" + + class RejectingRemoteClient: + def __init__(self, _policy) -> None: + pass + + @asynccontextmanager + async def stream(self, url, *, max_bytes, cancellation_check=None): + raise RemoteSourceError(f"rejected {url}") + yield # pragma: no cover + + monkeypatch.setattr( + data_service_module, "RemoteSourceClient", RejectingRemoteClient + ) + service = DataService(StateManager()) + result = service.load_event_list_from_url(supplied, "events") + + async def collect_events(): + return [ + event + async for event in service.load_event_list_from_url_stream( + supplied, "streamed" + ) + ] + + streamed = asyncio.run(collect_events()) + + assert result["success"] is False + assert "https://" not in repr(result) + assert supplied not in repr(result) + assert "https://" not in repr(streamed) + assert supplied not in repr(streamed) + + +def test_job_submission_route_redacts_native_failure( + caplog: pytest.LogCaptureFixture, +) -> None: + selected = "/private/selected/events.hdf5" + grant = "private-grant-token" + + class RejectingManager: + def submit_load_job(self, **_kwargs): + raise OSError(f"could not open {selected} using {grant}") + + request = SimpleNamespace( + app=SimpleNamespace(state=SimpleNamespace(job_manager=RejectingManager())) + ) + body = SubmitLoadJobRequest( + file_path=selected, + file_grant=grant, + name="events", + fmt="hdf5", + ) + with caplog.at_level(logging.ERROR): + response = asyncio.run(submit_load_job(request, body)) + + assert response.success is False + assert response.error == "job_submission_rejected" + assert selected not in repr(response) + assert grant not in repr(response) + assert selected not in caplog.text + assert grant not in caplog.text + + +def test_data_route_maps_native_failure_to_sanitized_envelope( + caplog: pytest.LogCaptureFixture, +) -> None: + selected = "/private/selected/events.hdf5" + grant = "private-grant-token" + + class RejectingService: + def load_event_list(self, **_kwargs): + raise OSError(f"could not open {selected} using {grant}") + + body = LoadEventListRequest( + file_path=selected, + file_grant=grant, + name="events", + fmt="hdf5", + ) + with caplog.at_level(logging.ERROR): + response = asyncio.run(load_event_list_route(body, RejectingService())) + + assert response["error"] == "data_input_rejected" + assert selected not in repr(response) + assert grant not in repr(response) + assert selected not in caplog.text + assert grant not in caplog.text + + +def test_stream_disconnect_signals_and_drains_worker_before_closing_resources( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + rmf = tmp_path / "selected.rmf" + rmf.write_bytes(b"calibration") + started = threading.Event() + cancellation_seen = threading.Event() + allow_worker_exit = threading.Event() + observed: dict[str, Any] = {} + + class FakeRemoteStream: + info = SimpleNamespace(content_length=4) + bytes_read = 0 + + async def aiter_bytes(self): + self.bytes_read = 4 + yield b"data" + + class FakeRemoteClient: + def __init__(self, _policy) -> None: + pass + + @asynccontextmanager + async def stream(self, _url, *, max_bytes, cancellation_check=None): + yield FakeRemoteStream() + + service = DataService(StateManager()) + + def wait_for_cancel( + event_stream, + _name, + _fmt, + _rmf_file, + _rmf_grant, + _columns, + _high_precision, + _skip_checks, + _notes, + rmf_source, + cancellation_check, + ): + observed["rmf_source"] = rmf_source + started.set() + while not cancellation_check(): + time.sleep(0.001) + cancellation_seen.set() + assert allow_worker_exit.wait(2.0) + observed["event_closed_during_worker"] = event_stream.closed + observed["rmf_closed_during_worker"] = rmf_source.stream.closed + return {"success": False, "message": "cancelled"} + + monkeypatch.setattr(data_service_module, "RemoteSourceClient", FakeRemoteClient) + monkeypatch.setattr(service, "_load_remote_stream", wait_for_cancel) + + async def cancel_consumer() -> None: + async def consume() -> None: + async for _event in service.load_event_list_from_url_stream( + "https://example.test/events.evt", + "events", + rmf_file=str(rmf), + rmf_grant=_grant(rmf), + ): + pass + + task = asyncio.create_task(consume()) + assert await asyncio.to_thread(started.wait, 2.0) + task.cancel() + assert await asyncio.to_thread(cancellation_seen.wait, 2.0) + task.cancel() + await asyncio.sleep(0) + assert observed["rmf_source"].stream.closed is False + allow_worker_exit.set() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(cancel_consumer()) + + assert observed["event_closed_during_worker"] is False + assert observed["rmf_closed_during_worker"] is False + assert observed["rmf_source"].stream.closed is True + + +def test_batch_stream_maps_bad_grant_to_sanitized_error(tmp_path: Path) -> None: + selected = tmp_path / "private-events.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + service = DataService(StateManager()) + + async def collect_events(): + return [ + event + async for event in service.load_batch_event_lists_stream( + [ + { + "file_path": str(selected), + "file_grant": "forged-private-grant", + "name": "events", + } + ] + ) + ] + + events = asyncio.run(collect_events()) + + assert events == [ + { + "type": "error", + "error": "The selected batch could not be admitted or loaded", + } + ] + assert str(selected) not in repr(events) + assert "forged-private-grant" not in repr(events) + + +def test_batch_stream_emits_each_file_before_the_slowest_worker_finishes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + paths = [tmp_path / "a.evt", tmp_path / "b.evt"] + for index, path in enumerate(paths): + _write_event_fits(path) + service = DataService(StateManager()) + a_finished = threading.Event() + b_started = threading.Event() + release_b = threading.Event() + observed_sources: dict[str, GrantedReadFile] = {} + + def fake_load(*args, **kwargs): + name = args[1] + observed_sources[name] = kwargs["_file_source"] + if name == "a": + a_finished.set() + return {"success": True, "data": {"n_events": 1}, "message": "loaded"} + b_started.set() + assert release_b.wait(2.0) + return {"success": True, "data": {"n_events": 2}, "message": "loaded"} + + monkeypatch.setattr(service, "load_event_list", fake_load) + + async def consume() -> list[dict[str, Any]]: + stream = service.load_batch_event_lists_stream( + [ + { + "file_path": str(paths[0]), + "file_grant": _grant(paths[0]), + "name": "a", + }, + { + "file_path": str(paths[1]), + "file_grant": _grant(paths[1]), + "name": "b", + }, + ], + max_workers=2, + ) + first = await anext(stream) + assert await asyncio.to_thread(a_finished.wait, 2.0) + assert await asyncio.to_thread(b_started.wait, 2.0) + assert first == { + "type": "file_complete", + "name": "a", + "success": True, + "completed": 1, + "total": 2, + "data": {"n_events": 1}, + } + assert observed_sources["a"].stream.closed is False + assert observed_sources["b"].stream.closed is False + release_b.set() + remaining = [event async for event in stream] + return [first, *remaining] + + events = asyncio.run(consume()) + assert [event["type"] for event in events] == [ + "file_complete", + "file_complete", + "complete", + ] + assert events[1]["name"] == "b" + assert events[-1]["total_events"] == 3 + assert observed_sources["a"].stream.closed is True + assert observed_sources["b"].stream.closed is True + + +def test_batch_stream_cancellation_drains_worker_before_closing_sources( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + paths = [tmp_path / "a.evt", tmp_path / "b.evt"] + for path in paths: + _write_event_fits(path) + service = DataService(StateManager()) + b_started = threading.Event() + cancellation_seen = threading.Event() + observed_sources: dict[str, GrantedReadFile] = {} + + def fake_load(*args, **kwargs): + name = args[1] + observed_sources[name] = kwargs["_file_source"] + if name == "a": + return {"success": True, "data": {"n_events": 1}} + b_started.set() + while not kwargs["_cancellation_check"](): + time.sleep(0.005) + cancellation_seen.set() + return {"success": False, "message": "cancelled"} + + monkeypatch.setattr(service, "load_event_list", fake_load) + + async def cancel_consumer() -> None: + stream = service.load_batch_event_lists_stream( + [ + { + "file_path": str(paths[0]), + "file_grant": _grant(paths[0]), + "name": "a", + }, + { + "file_path": str(paths[1]), + "file_grant": _grant(paths[1]), + "name": "b", + }, + ], + max_workers=2, + ) + await anext(stream) + pending = asyncio.create_task(anext(stream)) + assert await asyncio.to_thread(b_started.wait, 2.0) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + assert await asyncio.to_thread(cancellation_seen.wait, 2.0) + + asyncio.run(cancel_consumer()) + assert observed_sources["a"].stream.closed is True + assert observed_sources["b"].stream.closed is True + + +def test_job_capability_budget_rejects_before_pinning_and_releases( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first = tmp_path / "first.hdf5" + second = tmp_path / "second.hdf5" + _write_hdf5(first, [1.0, 2.0]) + _write_hdf5(second, [3.0, 4.0]) + monkeypatch.setattr(job_manager_module, "MAX_RETAINED_CAPABILITIES", 1) + manager, _executor = _manager_with_captured_executor(StateManager()) + first_job = manager.submit_load_job( + str(first), "first", fmt="hdf5", file_grant=_grant(first) + ) + + with pytest.raises(RuntimeError, match="capacity"): + manager.submit_load_job( + str(second), "second", fmt="hdf5", file_grant=_grant(second) + ) + + assert manager._reserved_jobs == 1 + assert manager._retained_capabilities == 1 + assert manager.cancel_job(first_job.id) is True + assert manager._reserved_jobs == 0 + assert manager._retained_capabilities == 0 + + +def test_job_reservation_released_when_grant_pin_fails(tmp_path: Path) -> None: + selected = tmp_path / "selected.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + manager, _executor = _manager_with_captured_executor(StateManager()) + + with pytest.raises(PermissionError): + manager.submit_load_job( + str(selected), "events", fmt="hdf5", file_grant="forged" + ) + + assert manager._reserved_jobs == 0 + assert manager._retained_capabilities == 0 + + +def test_cancel_wins_against_inflight_completion(tmp_path: Path) -> None: + selected = tmp_path / "selected.hdf5" + _write_hdf5(selected, [1.0, 2.0]) + manager, executor = _manager_with_captured_executor(StateManager()) + started = threading.Event() + finish = threading.Event() + + def delayed_success(*_args, **_kwargs): + started.set() + assert finish.wait(2.0) + return {"success": True, "data": {"n_events": 2}} + + manager._data_service.load_event_list = delayed_success + job = manager.submit_load_job( + str(selected), "events", fmt="hdf5", file_grant=_grant(selected) + ) + source = manager._resources[job.id].private["file_source"] + assert executor.futures[0].set_running_or_notify_cancel() is True + target, args = executor.calls[0] + worker = threading.Thread(target=target, args=args) + worker.start() + assert started.wait(2.0) + + assert manager.cancel_job(job.id) is True + finish.set() + worker.join(timeout=2.0) + assert not worker.is_alive() + executor.futures[0].set_result(None) + + assert job.status.value == "cancelled" + assert source.stream.closed is True + event_types = [update["type"] for update in manager._update_queue.queue] + assert "job_completed" not in event_types + + +def test_remote_scientific_failure_redacts_url_and_reader_details( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class BadRemoteStream: + info = SimpleNamespace(content_length=9) + bytes_read = 0 + + async def aiter_bytes(self): + self.bytes_read = 9 + yield b"not-hdf5" + + class BadRemoteClient: + def __init__(self, _policy) -> None: + pass + + @asynccontextmanager + async def stream(self, _url, *, max_bytes, cancellation_check=None): + yield BadRemoteStream() + + monkeypatch.setattr(data_service_module, "RemoteSourceClient", BadRemoteClient) + supplied = "https://example.test/events.hdf5?token=private" + result = DataService(StateManager()).load_event_list_from_url( + supplied, "events", fmt="hdf5" + ) + + assert result["success"] is False + assert result["error"] == "event_read_failed" + assert supplied not in repr(result) + assert "token=private" not in repr(result) diff --git a/python-backend/tests/test_secure_publication.py b/python-backend/tests/test_secure_publication.py new file mode 100644 index 0000000..c0c5b92 --- /dev/null +++ b/python-backend/tests/test_secure_publication.py @@ -0,0 +1,307 @@ +"""Contract tests for format-independent secure publication.""" + +from __future__ import annotations + +import os +import time + +import pytest + +import services.secure_publication as publication_module +import services.utility_helpers as utility_helpers +from services.secure_publication import open_secure_publication +from services.utility_helpers import ( + FILE_GRANT_SECRET_ENV, + FILE_GRANT_TTL_SECONDS, + SECURE_DIR_FD_OPERATIONS_SUPPORTED, + issue_file_grant, +) + +TEST_SECRET = "secure-publication-test-secret-at-least-32-bytes" + +requires_posix_publication = pytest.mark.skipif( + os.name != "posix" or not SECURE_DIR_FD_OPERATIONS_SUPPORTED, + reason="POSIX descriptor-relative publication primitives are required", +) + + +@pytest.fixture(autouse=True) +def file_grant_secret(monkeypatch): + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, TEST_SECRET) + + +def _write_grant(path) -> str: + return issue_file_grant(str(path), access="write").grant + + +@requires_posix_publication +def test_posix_publication_contract_writes_reopens_and_publishes(tmp_path, monkeypatch): + destination = tmp_path / "artifact.bin" + real_fsync = os.fsync + flushed_descriptors: list[int] = [] + + def tracked_fsync(descriptor: int) -> None: + flushed_descriptors.append(descriptor) + real_fsync(descriptor) + + monkeypatch.setattr(publication_module.os, "fsync", tracked_fsync) + + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + assert publication.path == destination.resolve() + assert publication.filename == destination.name + publication.revalidate("destination changed") + publication.assert_destination_available() + publication.reserve_staging(".bin") + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"scientifically verified bytes") + with pytest.raises(RuntimeError, match="writer is already active"): + with publication.open_writer("wb", encoding=None): + pass + assert len(flushed_descriptors) == 1 + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"scientifically verified bytes" + with pytest.raises(RuntimeError, match="reader is already active"): + with publication.open_reader("rb", encoding=None): + pass + assert publication.verified_size() == len(b"scientifically verified bytes") + assert publication.publish() == [] + + assert destination.read_bytes() == b"scientifically verified bytes" + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@requires_posix_publication +def test_posix_publication_writer_supports_seekable_read_write_stream(tmp_path): + destination = tmp_path / "artifact.hdf5" + + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.reserve_staging(".hdf5") + with publication.open_writer("w+b", encoding=None) as stream: + assert stream.seekable() is True + assert stream.readable() is True + assert stream.writable() is True + stream.write(b"HDF5-compatible stream") + stream.seek(0) + assert stream.read() == b"HDF5-compatible stream" + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"HDF5-compatible stream" + publication.verified_size() + publication.publish() + + assert destination.read_bytes() == b"HDF5-compatible stream" + + +@requires_posix_publication +def test_posix_publication_uses_fixed_grant_admission_time(tmp_path, monkeypatch): + destination = tmp_path / "long-running.bin" + grant = _write_grant(destination) + future_time = int(time.time()) + FILE_GRANT_TTL_SECONDS + 30 + + with open_secure_publication(str(destination), grant) as publication: + publication.reserve_staging(".bin") + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"long-running export") + monkeypatch.setattr(utility_helpers.time, "time", lambda: future_time) + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"long-running export" + publication.verified_size() + publication.publish() + + assert destination.read_bytes() == b"long-running export" + + +@requires_posix_publication +def test_posix_publication_rejects_grant_expired_before_admission( + tmp_path, monkeypatch +): + destination = tmp_path / "expired.bin" + admission_time = int(time.time()) + monkeypatch.setattr( + utility_helpers.time, + "time", + lambda: admission_time - FILE_GRANT_TTL_SECONDS - 1, + ) + expired_grant = _write_grant(destination) + monkeypatch.setattr(utility_helpers.time, "time", lambda: admission_time) + + with pytest.raises(PermissionError, match="expired"): + with open_secure_publication(str(destination), expired_grant): + pytest.fail("An expired grant must not be admitted") + + +@requires_posix_publication +def test_posix_publication_refuses_a_late_existing_target(tmp_path): + destination = tmp_path / "artifact.bin" + sentinel = b"user-owned target" + + with pytest.raises(FileExistsError, match="already exists"): + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.assert_destination_available() + publication.reserve_staging(".bin") + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"new bytes") + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"new bytes" + publication.verified_size() + destination.write_bytes(sentinel) + publication.publish() + + assert destination.read_bytes() == sentinel + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@requires_posix_publication +def test_posix_publication_cleans_owned_staging_after_writer_failure(tmp_path): + destination = tmp_path / "artifact.bin" + + with pytest.raises(OSError, match="synthetic writer failure"): + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.assert_destination_available() + publication.reserve_staging(".bin") + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"partial private bytes") + raise OSError("synthetic writer failure") + + assert not destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@requires_posix_publication +def test_posix_publication_enforces_verified_lifecycle(tmp_path): + destination = tmp_path / "artifact.bin" + + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + with pytest.raises(RuntimeError, match="has not completed verification"): + publication.publish() + + publication.reserve_staging(".bin") + with pytest.raises(RuntimeError, match="has not completed"): + with publication.open_reader("rb", encoding=None): + pass + with pytest.raises(RuntimeError, match="has not completed verification"): + publication.publish() + + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"verified bytes") + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"verified bytes" + with pytest.raises(RuntimeError, match="reader already completed"): + with publication.open_reader("rb", encoding=None): + pass + publication.verified_size() + publication.publish() + with pytest.raises(RuntimeError, match="already published"): + publication.publish() + + assert destination.read_bytes() == b"verified bytes" + + +@requires_posix_publication +def test_posix_writer_stream_construction_closes_descriptor_once(tmp_path, monkeypatch): + destination = tmp_path / "artifact.bin" + + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.reserve_staging(".bin") + writer_descriptor = publication._writer_descriptor + real_close = publication_module.os.close + closed: list[int] = [] + + def tracked_close(descriptor: int) -> None: + closed.append(descriptor) + real_close(descriptor) + + def fail_file_io(*_args, **_kwargs): + raise OSError("synthetic stream construction failure") + + monkeypatch.setattr(publication_module.os, "close", tracked_close) + monkeypatch.setattr(publication_module.io, "FileIO", fail_file_io) + + with pytest.raises(OSError, match="synthetic stream construction failure"): + with publication.open_writer("wb", encoding=None): + pass + + assert closed.count(writer_descriptor) == 1 + + assert not destination.exists() + + +@requires_posix_publication +def test_posix_invalid_stream_modes_close_transferred_descriptors( + tmp_path, monkeypatch +): + writer_destination = tmp_path / "invalid-writer.bin" + with open_secure_publication( + str(writer_destination), + _write_grant(writer_destination), + ) as publication: + publication.reserve_staging(".bin") + writer_descriptor = publication._writer_descriptor + real_close = publication_module.os.close + closed: list[int] = [] + + def tracked_close(descriptor: int) -> None: + closed.append(descriptor) + real_close(descriptor) + + monkeypatch.setattr(publication_module.os, "close", tracked_close) + with pytest.raises(ValueError, match="Unsupported secure publication stream"): + with publication.open_writer("invalid", encoding=None): + pass + assert closed.count(writer_descriptor) == 1 + + reader_destination = tmp_path / "invalid-reader.bin" + with open_secure_publication( + str(reader_destination), + _write_grant(reader_destination), + ) as publication: + publication.reserve_staging(".bin") + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"private bytes") + closed.clear() + opened: list[int] = [] + real_open = publication_module.os.open + + def tracked_open(*args, **kwargs): + descriptor = real_open(*args, **kwargs) + opened.append(descriptor) + return descriptor + + monkeypatch.setattr(publication_module.os, "open", tracked_open) + with pytest.raises(ValueError, match="Unsupported secure publication stream"): + with publication.open_reader("invalid", encoding=None): + pass + assert opened + assert closed.count(opened[-1]) == 1 + + assert not writer_destination.exists() + assert not reader_destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +def test_secure_publication_fails_closed_without_a_platform_adapter( + tmp_path, monkeypatch +): + destination = tmp_path / "artifact.bin" + monkeypatch.setattr(publication_module, "_platform_name", lambda: "unsupported") + + with pytest.raises(NotImplementedError, match="not supported"): + with open_secure_publication(str(destination), "unused"): + pytest.fail("An unsupported platform must not yield a publication") diff --git a/python-backend/tests/test_smoke.py b/python-backend/tests/test_smoke.py new file mode 100644 index 0000000..4b7fd23 --- /dev/null +++ b/python-backend/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_services_import_and_state_works(loaded_state): + assert loaded_state.has_event_data("ev1") + assert loaded_state.has_event_data("ev2") + assert len(loaded_state.get_event_data("ev1").time) == 20000 diff --git a/python-backend/tests/test_spectrum_service.py b/python-backend/tests/test_spectrum_service.py new file mode 100644 index 0000000..6e4b359 --- /dev/null +++ b/python-backend/tests/test_spectrum_service.py @@ -0,0 +1,113 @@ +import json + +import numpy as np +import pytest + +from services.spectrum_service import SpectrumService, _finite_list + + +def test_cross_spectrum_is_strict_json_serializable(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_cross_spectrum("ev1", "ev2", dt=0.0625) + assert result["success"], result + json.dumps(result, allow_nan=False) # complex or NaN values raise here + data = result["data"] + assert all(isinstance(p, float) for p in data["power"][:10]) + assert data["power_phase"] is not None + assert len(data["power_phase"]) == len(data["power"]) + + +def test_averaged_cross_spectrum_is_strict_json_serializable(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_averaged_cross_spectrum( + "ev1", "ev2", dt=0.0625, segment_size=8.0 + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + assert result["data"]["power_phase"] is not None + assert result["data"]["n_segments"] == 8 # 64 s fixture / 8 s segments + + +def test_power_spectrum_has_null_phase(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_power_spectrum("ev1", dt=0.0625) + assert result["success"], result + json.dumps(result, allow_nan=False) + assert result["data"]["power_phase"] is None + + +def test_rebin_of_stored_cross_spectrum_serializes(loaded_state): + svc = SpectrumService(loaded_state) + created = svc.create_cross_spectrum("ev1", "ev2", dt=0.0625, output_name="cs1") + assert created["success"], created + rebinned = svc.rebin_spectrum("cs1", rebin_factor=0.1, log=True) + assert rebinned["success"], rebinned + json.dumps(rebinned, allow_nan=False) + + +def test_finite_list_maps_nonfinite_to_none(): + assert _finite_list(np.array([1.0, np.nan, np.inf, -np.inf])) == [ + 1.0, + None, + None, + None, + ] + + +def test_cross_spectrum_power_is_magnitude(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_cross_spectrum("ev1", "ev2", dt=0.0625, output_name="cs_mag") + assert result["success"], result + cs = loaded_state.get_spectrum_data("cs_mag") + expected_mag = np.abs(np.asarray(cs.power)) + expected_phase = np.angle(np.asarray(cs.power)) + assert np.allclose(result["data"]["power"], expected_mag) + assert np.allclose(result["data"]["power_phase"], expected_phase) + + +def test_linear_rebin_scales_df_by_factor(loaded_state): + svc = SpectrumService(loaded_state) + created = svc.create_power_spectrum("ev1", dt=0.0625, output_name="ps_lin") + assert created["success"], created + base_df = created["data"]["df"] + result = svc.rebin_spectrum("ps_lin", rebin_factor=2.0, log=False) + assert result["success"], result + freq = result["data"]["freq"] + new_df = freq[1] - freq[0] + assert new_df == pytest.approx(2.0 * base_df, rel=1e-6) + assert result["data"]["norm"] == created["data"]["norm"] + + +def test_dynamical_power_spectrum_serializes(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_dynamical_power_spectrum("ev1", dt=0.0625, segment_size=8.0) + assert result["success"], result + json.dumps(result, allow_nan=False) + + +def test_averaged_power_spectrum_serializes_with_n_segments(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_averaged_power_spectrum("ev1", dt=0.0625, segment_size=8.0) + assert result["success"], result + json.dumps(result, allow_nan=False) + assert result["data"]["n_segments"] == 8 + + +def test_tiny_segment_size_rejected_with_readable_message(loaded_state): + svc = SpectrumService(loaded_state) + result = svc.create_averaged_power_spectrum("ev1", dt=0.0625, segment_size=0.125) + assert not result["success"] + assert "3x dt" in result["message"] + + +def test_disjoint_event_lists_rejected_readably(loaded_state): + import numpy as np + from stingray import EventList + + rng = np.random.default_rng(7) + far = np.sort(rng.uniform(1000.0, 1064.0, 5000)) + loaded_state.add_event_data("ev_far", EventList(time=far, gti=[[1000.0, 1064.0]])) + svc = SpectrumService(loaded_state) + result = svc.create_cross_spectrum("ev1", "ev_far", dt=0.0625) + assert not result["success"] + assert "no overlapping time range" in result["message"] diff --git a/python-backend/tests/test_statistics_service.py b/python-backend/tests/test_statistics_service.py new file mode 100644 index 0000000..f42150a --- /dev/null +++ b/python-backend/tests/test_statistics_service.py @@ -0,0 +1,561 @@ +"""Scientific, serialization, validation, and concurrency tests for statistics.""" + +from __future__ import annotations + +import inspect +import json + +import numpy as np +import pytest +from pydantic import ValidationError +from routes import statistics_routes +from routes.statistics_routes import ( + GaussianRequest, + PdmEvaluateRequest, + PdsEvaluateRequest, + TrialRequest, +) +from services.statistics_service import MAX_COUNT_PARAMETER, StatisticsService +from stingray import stats as stingray_stats + + +@pytest.fixture() +def service(state_manager) -> StatisticsService: + return StatisticsService(state_manager) + + +def _assert_success(result): + assert result["success"], result + assert set(result) == {"success", "data", "message", "error"} + assert result["error"] is None + assert isinstance(result["data"]["warnings"], list) + assert result["data"]["provenance"]["stingray_version"] == "2.2.10" + json.dumps(result, allow_nan=False) + return result["data"] + + +def test_gaussian_probability_uses_stingray_one_sided_upper_tail(service): + probability = 0.0013498980316301035 + data = _assert_success( + service.gaussian_significance( + probability=probability, + sidedness="one-sided", + ) + ) + + expected = float(stingray_stats.equivalent_gaussian_Nsigma(probability)) + assert data["sigma"] == pytest.approx(expected) + assert data["sigma"] == pytest.approx(3.0) + assert data["effective_one_sided_probability"] == probability + assert data["effective_one_sided_log_probability"] == pytest.approx( + np.log(probability) + ) + assert data["sidedness"] == "one-sided" + assert data["tail"] == "upper" + assert data["direction"] == "probability_to_gaussian_sigma" + assert data["provenance"]["public_api_calls"] == [ + "stingray.stats.equivalent_gaussian_Nsigma" + ] + + +def test_gaussian_two_sided_probability_splits_the_total_between_tails(service): + total_probability = 0.002699796063260207 + data = _assert_success( + service.gaussian_significance( + probability=total_probability, + sidedness="two-sided", + ) + ) + + one_tail = total_probability / 2.0 + expected = float(stingray_stats.equivalent_gaussian_Nsigma(one_tail)) + assert data["effective_one_sided_probability"] == pytest.approx(one_tail) + assert data["effective_one_sided_log_probability"] == pytest.approx( + np.log(total_probability) - np.log(2.0) + ) + assert data["sigma"] == pytest.approx(expected) + assert data["sigma"] == pytest.approx(3.0) + + +def test_gaussian_log_probability_supports_extreme_significance(service): + log_probability = -1000.0 + data = _assert_success( + service.gaussian_significance( + log_probability=log_probability, + sidedness="one-sided", + ) + ) + + expected = float( + stingray_stats.equivalent_gaussian_Nsigma_from_logp(log_probability) + ) + assert data["sigma"] == pytest.approx(expected) + assert data["sigma"] == pytest.approx(44.6159802496772) + assert data["effective_one_sided_probability"] == 0.0 + assert any("underflowed to 0.0" in warning for warning in data["warnings"]) + assert data["provenance"]["public_api_calls"] == [ + "stingray.stats.equivalent_gaussian_Nsigma_from_logp" + ] + + +def test_gaussian_two_sided_log_probability_uses_half_each_tail(service): + total_log_probability = float(np.log(0.002699796063260207)) + data = _assert_success( + service.gaussian_significance( + log_probability=total_log_probability, + sidedness="two-sided", + ) + ) + + effective_log_probability = total_log_probability - float(np.log(2.0)) + expected = stingray_stats.equivalent_gaussian_Nsigma_from_logp( + effective_log_probability + ) + assert data["effective_one_sided_log_probability"] == pytest.approx( + effective_log_probability + ) + assert data["sigma"] == pytest.approx(float(expected)) + assert data["sigma"] == pytest.approx(3.0) + + +@pytest.mark.parametrize( + ("direction", "probability", "n_trials", "upstream"), + [ + ( + "single-to-multi", + 0.0001, + 37, + stingray_stats.p_multitrial_from_single_trial, + ), + ( + "multi-to-single", + 0.01, + 37, + stingray_stats.p_single_trial_from_p_multitrial, + ), + ], +) +def test_trial_conversions_match_stingray( + service, direction, probability, n_trials, upstream +): + data = _assert_success( + service.convert_trials( + direction=direction, + probability=probability, + n_trials=n_trials, + ) + ) + + expected = float(upstream(probability, n_trials)) + assert data["output_probability"] == pytest.approx(expected) + assert data["direction"] == direction + assert data["n_trials"] == n_trials + assert "independent" in data["independence_assumption"] + + +@pytest.mark.parametrize( + ("direction", "probability"), + [ + ("single-to-multi", 0.0), + ("single-to-multi", 1.0), + ("multi-to-single", 0.0), + ], +) +def test_trial_conversion_supported_boundaries_match_stingray( + service, direction, probability +): + data = _assert_success( + service.convert_trials( + direction=direction, + probability=probability, + n_trials=7, + ) + ) + with np.errstate(divide="ignore"): + if direction == "single-to-multi": + expected = stingray_stats.p_multitrial_from_single_trial(probability, 7) + else: + expected = stingray_stats.p_single_trial_from_p_multitrial(probability, 7) + assert data["output_probability"] == float(expected) + if probability == 0.0: + assert any("divide by zero" in warning for warning in data["warnings"]) + + +@pytest.mark.parametrize( + ("method", "kwargs", "probability_call", "log_probability_call"), + [ + ( + "evaluate_pds", + {"power": 10.0, "n_trials": 3, "n_summed_spectra": 2, "n_rebin": 4}, + lambda values: stingray_stats.pds_probability( + values["power"], + ntrial=values["n_trials"], + n_summed_spectra=values["n_summed_spectra"], + n_rebin=values["n_rebin"], + ), + lambda values: stingray_stats.pds_logprobability( + values["power"], + ntrial=values["n_trials"], + n_summed_spectra=values["n_summed_spectra"], + n_rebin=values["n_rebin"], + ), + ), + ( + "evaluate_z2", + {"z2": 20.0, "harmonics": 3, "n_trials": 7, "n_summed_spectra": 2}, + lambda values: stingray_stats.z2_n_probability( + values["z2"], + values["harmonics"], + ntrial=values["n_trials"], + n_summed_spectra=values["n_summed_spectra"], + ), + lambda values: stingray_stats.z2_n_logprobability( + values["z2"], + values["harmonics"], + ntrial=values["n_trials"], + n_summed_spectra=values["n_summed_spectra"], + ), + ), + ( + "evaluate_fold", + {"statistic": 30.0, "n_phase_bins": 16, "n_trials": 7}, + lambda values: stingray_stats.fold_profile_probability( + values["statistic"], + values["n_phase_bins"], + ntrial=values["n_trials"], + ), + lambda values: stingray_stats.fold_profile_logprobability( + values["statistic"], + values["n_phase_bins"], + ntrial=values["n_trials"], + ), + ), + ( + "evaluate_pdm", + { + "statistic": 0.8, + "n_samples": 1000, + "n_phase_bins": 16, + "n_trials": 7, + }, + lambda values: stingray_stats.phase_dispersion_probability( + values["statistic"], + values["n_samples"], + values["n_phase_bins"], + ntrial=values["n_trials"], + ), + lambda values: stingray_stats.phase_dispersion_logprobability( + values["statistic"], + values["n_samples"], + values["n_phase_bins"], + ntrial=values["n_trials"], + ), + ), + ], +) +def test_statistic_evaluations_match_both_public_stingray_calls( + service, + method, + kwargs, + probability_call, + log_probability_call, +): + data = _assert_success(getattr(service, method)(**kwargs)) + + assert data["probability"] == pytest.approx(float(probability_call(kwargs))) + assert data["log_probability"] == pytest.approx(float(log_probability_call(kwargs))) + assert data["probability"] == pytest.approx(np.exp(data["log_probability"])) + assert data["calculation"] == "probability" + assert data["direction"] == "observed_statistic_to_false_alarm_probability" + assert data["probability_scope"] == "overall_post_trial" + assert data["more_significant_when"] == ( + "smaller" if method == "evaluate_pdm" else "larger" + ) + + +@pytest.mark.parametrize( + ("method", "kwargs", "upstream", "upstream_args"), + [ + ( + "detect_pds", + { + "false_alarm_probability": 0.01, + "n_trials": 37, + "n_summed_spectra": 2, + "n_rebin": 4, + }, + stingray_stats.pds_detection_level, + { + "epsilon": 0.01, + "ntrial": 37, + "n_summed_spectra": 2, + "n_rebin": 4, + }, + ), + ( + "detect_z2", + { + "false_alarm_probability": 0.01, + "harmonics": 3, + "n_trials": 7, + "n_summed_spectra": 2, + }, + stingray_stats.z2_n_detection_level, + {"n": 3, "epsilon": 0.01, "ntrial": 7, "n_summed_spectra": 2}, + ), + ( + "detect_fold", + {"false_alarm_probability": 0.01, "n_phase_bins": 16, "n_trials": 7}, + stingray_stats.fold_detection_level, + {"nbin": 16, "epsilon": 0.01, "ntrial": 7}, + ), + ( + "detect_pdm", + { + "false_alarm_probability": 0.01, + "n_samples": 1000, + "n_phase_bins": 16, + "n_trials": 7, + }, + stingray_stats.phase_dispersion_detection_level, + {"nsamples": 1000, "nbin": 16, "epsilon": 0.01, "ntrial": 7}, + ), + ], +) +def test_detection_levels_match_public_stingray_calls( + service, method, kwargs, upstream, upstream_args +): + data = _assert_success(getattr(service, method)(**kwargs)) + + assert data["detection_level"] == pytest.approx(float(upstream(**upstream_args))) + assert data["false_alarm_probability"] == kwargs["false_alarm_probability"] + assert data["false_alarm_probability_scope"] == "overall_post_trial" + assert data["direction"] == "false_alarm_probability_to_detection_level" + expected_operator = "<=" if method == "detect_pdm" else ">=" + assert data["decision_rule"] == ( + f"observed_statistic {expected_operator} detection_level" + ) + + +def test_detection_level_roundtrips_to_requested_overall_probability(service): + detection = _assert_success( + service.detect_pds( + false_alarm_probability=0.01, + n_trials=37, + n_summed_spectra=2, + n_rebin=4, + ) + ) + evaluated = _assert_success( + service.evaluate_pds( + power=detection["detection_level"], + n_trials=37, + n_summed_spectra=2, + n_rebin=4, + ) + ) + assert evaluated["probability"] == pytest.approx(0.01) + + +@pytest.mark.parametrize( + ("method", "kwargs", "message"), + [ + ( + "gaussian_significance", + {"probability": np.nan, "sidedness": "one-sided"}, + "must be finite", + ), + ( + "gaussian_significance", + {"probability": 0.1, "log_probability": -2.0}, + "exactly one", + ), + ( + "convert_trials", + {"direction": "single-to-multi", "probability": 0.01, "n_trials": True}, + "must be an integer", + ), + ( + "convert_trials", + {"direction": "single-to-multi", "probability": 0.01, "n_trials": 1.0}, + "must be an integer", + ), + ( + "convert_trials", + { + "direction": "single-to-multi", + "probability": 0.01, + "n_trials": MAX_COUNT_PARAMETER + 1, + }, + "must not exceed", + ), + ("evaluate_pds", {"power": -1.0}, "must be at least 0"), + ("evaluate_pds", {"power": 10**1000}, "must be a finite number"), + ("evaluate_z2", {"z2": np.inf}, "must be finite"), + ( + "evaluate_fold", + {"statistic": 10.0, "n_phase_bins": 2}, + "must be at least 3", + ), + ( + "evaluate_pdm", + {"statistic": 1.1, "n_samples": 100, "n_phase_bins": 10}, + "must be at most 1", + ), + ( + "evaluate_pdm", + {"statistic": 0.8, "n_samples": 10, "n_phase_bins": 10}, + "must be greater than", + ), + ( + "detect_pds", + {"false_alarm_probability": 1.0}, + "must be less than 1", + ), + ( + "convert_trials", + {"direction": "multi-to-single", "probability": 1.0, "n_trials": 10}, + "must be less than 1", + ), + ], +) +def test_service_rejects_invalid_and_nonfinite_domains( + service, method, kwargs, message +): + result = getattr(service, method)(**kwargs) + + assert not result["success"] + assert result["data"] is None + assert result["error"] is None + assert message in result["message"] + json.dumps(result, allow_nan=False) + + +def test_probability_underflow_preserves_zero_and_finite_log_probability(service): + data = _assert_success(service.evaluate_pds(power=1_000_000.0)) + + assert data["probability"] == 0.0 + assert np.isfinite(data["log_probability"]) + assert data["log_probability"] == pytest.approx( + float(stingray_stats.pds_logprobability(1_000_000.0)) + ) + assert any("underflowed to 0.0" in warning for warning in data["warnings"]) + + +def test_nonfinite_upstream_log_output_becomes_null_with_warning(service): + data = _assert_success( + service.evaluate_pdm( + statistic=0.0, + n_samples=1000, + n_phase_bins=16, + ) + ) + + assert data["probability"] == 0.0 + assert data["log_probability"] is None + assert any( + "non-finite" in warning and "represented as null" in warning + for warning in data["warnings"] + ) + + +def test_numpy_scalar_inputs_are_normalized_in_provenance(service): + result = service.evaluate_pds( + power=np.float32(10.0), + n_trials=np.int64(3), + n_summed_spectra=np.int64(2), + n_rebin=np.int64(4), + ) + data = _assert_success(result) + + parameters = data["provenance"]["parameters"] + assert parameters == { + "power": 10.0, + "n_trials": 3, + "n_summed_spectra": 2, + "n_rebin": 4, + } + + +def test_ill_conditioned_inverse_trial_result_is_null_and_warnings_are_captured( + service, +): + data = _assert_success( + service.convert_trials( + direction="multi-to-single", + probability=float(np.nextafter(1.0, 0.0)), + n_trials=1000, + ) + ) + + assert data["output_probability"] is None + assert any("very close to 1" in warning for warning in data["warnings"]) + assert any("ill-conditioned" in warning for warning in data["warnings"]) + assert any("represented as null" in warning for warning in data["warnings"]) + + +def test_request_models_are_strict_finite_and_forbid_extra_fields(): + with pytest.raises(ValidationError): + PdsEvaluateRequest(power=np.nan) + with pytest.raises(ValidationError): + PdsEvaluateRequest(power=10.0, n_trials=1.0) + with pytest.raises(ValidationError): + PdsEvaluateRequest(power=10.0, surprise=True) + with pytest.raises(ValidationError): + GaussianRequest(probability=0.1, log_probability=-2.0) + with pytest.raises(ValidationError): + GaussianRequest() + assert ( + TrialRequest( + direction="single-to-multi", probability=0.0, n_trials=1 + ).probability + == 0.0 + ) + assert ( + TrialRequest( + direction="single-to-multi", probability=1.0, n_trials=1 + ).probability + == 1.0 + ) + assert ( + TrialRequest( + direction="multi-to-single", probability=0.0, n_trials=1 + ).probability + == 0.0 + ) + with pytest.raises(ValidationError): + TrialRequest(direction="multi-to-single", probability=1.0, n_trials=1) + with pytest.raises(ValidationError): + PdmEvaluateRequest( + statistic=0.8, + n_samples=10, + n_phase_bins=10, + ) + + +def test_router_exposes_only_the_explicit_statistics_operations(): + actual = { + (route.path, frozenset(route.methods)) + for route in statistics_routes.router.routes + } + expected_paths = { + "/gaussian", + "/trials", + "/pds/evaluate", + "/pds/detection", + "/z2/evaluate", + "/z2/detection", + "/fold/evaluate", + "/fold/detection", + "/pdm/evaluate", + "/pdm/detection", + } + + assert actual == {(path, frozenset({"POST"})) for path in expected_paths} + + +def test_every_statistics_route_offloads_its_service_operation_to_a_thread(): + assert statistics_routes.router.routes + for route in statistics_routes.router.routes: + source = inspect.getsource(route.endpoint) + assert "return await asyncio.to_thread(" in source, route.path diff --git a/python-backend/tests/test_timing_service.py b/python-backend/tests/test_timing_service.py new file mode 100644 index 0000000..badc97a --- /dev/null +++ b/python-backend/tests/test_timing_service.py @@ -0,0 +1,174 @@ +import json + +import numpy as np + +from services.timing_service import TimingService + + +def test_coherence_of_identical_signals_is_one(loaded_state): + svc = TimingService(loaded_state) + # An event list crossed with itself has coherence == 1 at all frequencies. + result = svc.calculate_coherence("ev1", "ev1", dt=0.0625, segment_size=8.0) + assert result["success"], result + json.dumps(result, allow_nan=False) + coh = np.asarray(result["data"]["coherence"], dtype=float) + # Identical inputs give raw coherence == 1 exactly; the Ingram-2019 noise-bias + # correction can push individual noise-dominated bins above 1 without bound + # (~(N/P)^2 / n_bin), so only a coarse cap discriminates against the old + # |unnorm_power|^2 bug, whose values were ~1e8. + assert np.all(coh < 2.0) + assert np.median(coh) > 0.9 # measured 0.993 for this fixture + assert result["data"]["metadata"]["units"]["freq"] == "Hz" + assert result["data"]["metadata"]["units"]["coherence"] == "1" + assert result["data"]["provenance"]["operation"] == "timing_coherence" + + +def test_coherence_includes_uncertainty(loaded_state): + svc = TimingService(loaded_state) + result = svc.calculate_coherence("ev1", "ev2", dt=0.0625, segment_size=8.0) + assert result["success"], result + data = result["data"] + assert "coherence_err" in data + assert data["coherence_err"] is not None + assert len(data["coherence_err"]) == len(data["coherence"]) + + +def test_time_lags_include_errors_and_serialize(loaded_state): + svc = TimingService(loaded_state) + result = svc.calculate_time_lags("ev1", "ev2", dt=0.0625, segment_size=8.0) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert "time_lags_err" in data + assert len(data["freq"]) == len(data["time_lags"]) + assert data["time_lags_err"] is not None + assert len(data["time_lags_err"]) == len(data["time_lags"]) + assert data["metadata"]["units"] == { + "freq": "Hz", + "time_lags": "s", + "time_lags_err": "s", + } + assert data["provenance"]["operation"] == "timing_time_lags" + + +def test_time_lags_freq_range_filters_all_arrays(loaded_state): + svc = TimingService(loaded_state) + full = svc.calculate_time_lags("ev1", "ev2", dt=0.0625, segment_size=8.0) + sub = svc.calculate_time_lags( + "ev1", "ev2", dt=0.0625, segment_size=8.0, freq_range=(0.5, 2.0) + ) + assert sub["success"], sub + freqs = np.asarray(sub["data"]["freq"], dtype=float) + assert freqs.min() >= 0.5 + assert freqs.max() <= 2.0 + assert len(sub["data"]["freq"]) < len(full["data"]["freq"]) + assert len(sub["data"]["time_lags"]) == len(sub["data"]["freq"]) + if sub["data"]["time_lags_err"] is not None: + assert len(sub["data"]["time_lags_err"]) == len(sub["data"]["freq"]) + assert sub["data"]["metadata"]["non_column_fields"] == ["freq_range"] + + +def test_power_colors_serializes(loaded_state): + svc = TimingService(loaded_state) + result = svc.calculate_power_colors( + "ev1", + dt=0.0625, + segment_size=8.0, + freq_ranges={ + "A": (0.125, 0.5), + "B": (0.5, 1.0), + "C": (1.0, 2.0), + "D": (2.0, 4.0), + }, + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert len(data["time"]) == 8 # 64 s / 8 s segments + for band in data["power_colors"].values(): + assert len(band) == len(data["time"]) + + +def test_bispectrum_serializes(loaded_state): + svc = TimingService(loaded_state) + result = svc.create_bispectrum("ev1", dt=0.25, maxlag=10) + assert result["success"], result + json.dumps(result, allow_nan=False) + assert "cum3" not in result["data"] + + +def test_time_lag_of_identical_signals_is_zero(loaded_state): + svc = TimingService(loaded_state) + result = svc.calculate_time_lags("ev1", "ev1", dt=0.0625, segment_size=8.0) + assert result["success"], result + lags = np.asarray([v for v in result["data"]["time_lags"] if v is not None]) + assert np.max(np.abs(lags)) < 1e-10 + + +def test_tiny_segment_size_rejected_for_coherence(loaded_state): + svc = TimingService(loaded_state) + result = svc.calculate_coherence("ev1", "ev2", dt=0.0625, segment_size=0.125) + assert not result["success"] + assert "3x dt" in result["message"] + + +def test_coherence_of_independent_signals_is_low(loaded_state): + svc = TimingService(loaded_state) + result = svc.calculate_coherence("ev1", "ev2", dt=0.0625, segment_size=8.0) + assert result["success"], result + coh = np.asarray( + [v for v in result["data"]["coherence"] if v is not None], dtype=float + ) + assert np.median(coh) < 0.5 # measured ~0.1 for independent fixtures + + +def test_disjoint_event_lists_rejected_for_coherence(loaded_state): + rng = np.random.default_rng(8) + far = np.sort(rng.uniform(1000.0, 1064.0, 5000)) + from stingray import EventList + + loaded_state.add_event_data("ev_far", EventList(time=far, gti=[[1000.0, 1064.0]])) + svc = TimingService(loaded_state) + result = svc.calculate_coherence("ev1", "ev_far", dt=0.0625, segment_size=8.0) + assert not result["success"] + assert "no overlapping time range" in result["message"] + + +def test_time_lag_sign_convention_for_shifted_signal(loaded_state): + # Pin the sign convention the UI will document: ev_shifted = ev1 delayed by 0.1 s. + from stingray import EventList + + ev1 = loaded_state.get_event_data("ev1") + # Keep the same GTI as ev1 so both light curves share one bin grid; with + # gti=[[0.1, 64.1]] the GTI intersection misaligns the grids (0.1 is not a + # multiple of dt) and the effective shift becomes 2 bins = 0.125 s. + shifted_times = ev1.time + 0.1 + shifted_times = shifted_times[shifted_times < 64.0] + shifted = EventList(time=np.sort(shifted_times), gti=[[0.0, 64.0]]) + loaded_state.add_event_data("ev_shifted", shifted) + svc = TimingService(loaded_state) + result = svc.calculate_time_lags( + "ev1", "ev_shifted", dt=0.0625, segment_size=8.0, freq_range=(0.25, 2.0) + ) + assert result["success"], result + lags = np.asarray([v for v in result["data"]["time_lags"] if v is not None]) + median_lag = float(np.median(lags)) + # Magnitude must recover the 0.1 s shift well below the phase-wrap limit (5 Hz). + assert abs(abs(median_lag) - 0.1) < 0.02 + # Observed: median_lag = -0.095 for channel 2 delayed by 0.1 s → stingray + # convention: positive lag means channel 2 (second list) leads channel 1; + # a delayed second channel yields negative lags. + + +def test_short_overlap_rejected_for_coherence(loaded_state): + # ev1 covers 0–64 s; ev_partial covers 56–120 s → 8 s overlap < 16 s segment. + from stingray import EventList + + rng = np.random.default_rng(99) + partial_times = np.sort(rng.uniform(56.0, 120.0, 5000)) + ev_partial = EventList(time=partial_times, gti=[[56.0, 120.0]]) + loaded_state.add_event_data("ev_partial", ev_partial) + svc = TimingService(loaded_state) + result = svc.calculate_coherence("ev1", "ev_partial", dt=0.0625, segment_size=16.0) + assert not result["success"] + assert "shorter than the segment size" in result["message"] diff --git a/python-backend/tests/test_utility_app_integration.py b/python-backend/tests/test_utility_app_integration.py new file mode 100644 index 0000000..2907ea7 --- /dev/null +++ b/python-backend/tests/test_utility_app_integration.py @@ -0,0 +1,261 @@ +"""Registered-app smoke coverage for every Utilities category.""" + +import json +import math + +import httpx +import pytest +from fastapi.responses import StreamingResponse + +from main import ( + MAX_REQUEST_BODY_BYTES, + MAX_SERIALIZED_VALIDATION_ERRORS, + RequestBodyLimitMiddleware, + create_app, +) +from services.state_manager import StateManager +from tests.backend_auth import ( + TEST_BACKEND_AUTH_HEADERS, + TEST_BACKEND_SESSION_SECRET, +) + + +class _ChunkedBody(httpx.AsyncByteStream): + """Stream a body without giving HTTPX a Content-Length value.""" + + def __init__(self, size: int, chunk_size: int = 64 * 1024) -> None: + self.size = size + self.chunk_size = chunk_size + + async def __aiter__(self): + remaining = self.size + while remaining: + size = min(remaining, self.chunk_size) + yield b"x" * size + remaining -= size + + +def _strict_json(response: httpx.Response): + def reject_constant(value: str): + raise AssertionError(f"Non-standard JSON constant in response: {value}") + + return json.loads(response.content, parse_constant=reject_constant) + + +@pytest.mark.asyncio +async def test_registered_utility_routes_execute_representative_operations(): + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + app.state.state_manager = StateManager() + app.state.performance_monitor = None + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + responses = { + "statistics": await client.post( + "/api/utilities/statistics/gaussian", + json={ + "probability": 0.15865525393145707, + "sidedness": "one-sided", + }, + ), + "gti": await client.post( + "/api/utilities/gti/validate", + json={ + "gtis": [[0.0, 2.0], [3.0, 5.0]], + "time_reference": "relative_seconds", + }, + ), + "io": await client.get("/api/utilities/io/exportable-objects"), + "mission_io": await client.get("/api/utilities/mission-io/capabilities"), + "misc": await client.post( + "/api/utilities/misc/window", + json={"n_samples": 8, "window_type": "hamming"}, + ), + } + + for category, response in responses.items(): + assert response.status_code == 200, (category, response.text) + payload = response.json() + assert set(("success", "data", "message", "error")) <= payload.keys() + assert payload["success"] is True, (category, payload) + + assert math.isclose(responses["statistics"].json()["data"]["sigma"], 1.0) + assert responses["gti"].json()["data"]["interval_count"] == 2 + assert responses["io"].json()["data"]["objects"] == [] + assert responses["mission_io"].json()["data"]["mission_count"] > 0 + assert len(responses["misc"].json()["data"]["window"]) == 8 + + +@pytest.mark.asyncio +async def test_nonfinite_request_validation_is_itself_strict_json(): + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + app.state.state_manager = StateManager() + app.state.performance_monitor = None + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + response = await client.post( + "/api/utilities/statistics/gaussian", + content='{"probability":NaN,"sidedness":"one-sided"}', + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 422 + payload = _strict_json(response) + assert payload["detail"][0]["input"] is None + assert "finite number" in payload["detail"][0]["msg"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("length_header", ["honest", "misleading", "missing"]) +async def test_request_body_limit_rejects_declared_and_streamed_oversize_bodies( + length_header, +): + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + transport = httpx.ASGITransport(app=app) + oversized = MAX_REQUEST_BODY_BYTES + 1 + headers = {"content-type": "application/json"} + + if length_header == "missing": + content = _ChunkedBody(oversized) + else: + content = b"x" * oversized + if length_header == "misleading": + headers["content-length"] = "1" + + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + response = await client.post( + "/api/utilities/statistics/gaussian", + content=content, + headers=headers, + ) + + assert response.status_code == 413 + assert _strict_json(response) == {"detail": "Request body exceeds the 8 MiB limit"} + + +@pytest.mark.asyncio +async def test_request_body_limit_preserves_streaming_responses(): + async def streaming_app(scope, receive, send): + async def chunks(): + yield b"first\n" + yield b"second\n" + + response = StreamingResponse(chunks(), media_type="text/plain") + await response(scope, receive, send) + + app = RequestBodyLimitMiddleware(streaming_app, MAX_REQUEST_BODY_BYTES) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/") + + assert response.status_code == 200 + assert response.text == "first\nsecond\n" + + +@pytest.mark.asyncio +async def test_validation_response_does_not_echo_large_rejected_input(): + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + app.state.state_manager = StateManager() + app.state.performance_monitor = None + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + response = await client.post( + "/api/utilities/statistics/gaussian", + json={"probability": 0.5, "sidedness": "x" * 1_000_000}, + ) + + assert response.status_code == 422 + assert len(response.content) < 4_096 + payload = _strict_json(response) + assert payload["detail"][0]["input"] is None + assert "x" * 1_000 not in response.text + + +@pytest.mark.asyncio +async def test_validation_response_caps_the_number_of_serialized_errors(): + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + app.state.state_manager = StateManager() + app.state.performance_monitor = None + transport = httpx.ASGITransport(app=app) + invalid_values = [False] * (MAX_SERIALIZED_VALIDATION_ERRORS + 25) + + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + response = await client.post( + "/api/utilities/misc/rebin/linear", + json={"x": invalid_values, "y": invalid_values, "dx_new": 1.0}, + ) + + assert response.status_code == 422 + payload = _strict_json(response) + assert len(payload["detail"]) == MAX_SERIALIZED_VALIDATION_ERRORS + 1 + assert payload["detail"][-1]["type"] == "validation_errors_omitted" + assert all(error["input"] is None for error in payload["detail"]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("path", "payload"), + [ + ( + "/api/utilities/gti/validate", + {"gtis": [[False, 2.0]], "time_reference": "relative_seconds"}, + ), + ( + "/api/utilities/gti/validate", + {"gtis": [["0", 2.0]], "time_reference": "relative_seconds"}, + ), + ( + "/api/utilities/io/convert-pi", + {"rmf_path": "/tmp/test.rmf", "rmf_grant": "grant", "pi_values": [True]}, + ), + ( + "/api/utilities/io/convert-pi", + {"rmf_path": "/tmp/test.rmf", "rmf_grant": "grant", "pi_values": ["2"]}, + ), + ( + "/api/utilities/mission-io/convert-pi", + {"pi_values": [True], "mission_override": "NICER"}, + ), + ( + "/api/utilities/mission-io/convert-pi", + {"pi_values": [2.0], "mission_override": "NICER", "epoch_mjd": "50000"}, + ), + ], +) +async def test_utility_request_models_reject_coercion_dependent_values(path, payload): + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + app.state.state_manager = StateManager() + app.state.performance_monitor = None + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + response = await client.post(path, json=payload) + + assert response.status_code == 422 diff --git a/python-backend/tests/test_utility_helpers.py b/python-backend/tests/test_utility_helpers.py new file mode 100644 index 0000000..cd6ca76 --- /dev/null +++ b/python-backend/tests/test_utility_helpers.py @@ -0,0 +1,572 @@ +"""Shared Utility safety helpers and state semantics.""" + +import hashlib +import hmac +import json +import os +import time +from concurrent.futures import ThreadPoolExecutor +from decimal import Decimal +from pathlib import Path + +import numpy as np +import pytest +from astropy import units as u +from astropy.table import MaskedColumn, Table +from astropy.utils.masked import Masked +from services.state_manager import StateManager +from services.utility_helpers import ( + FILE_GRANT_MAX_FUTURE_SECONDS, + FILE_GRANT_SECRET_ENV, + FILE_GRANT_TTL_SECONDS, + FILE_GRANT_VERSION, + issue_file_grant, + json_safe, + open_verified_read_grant, + open_verified_write_grant, + validate_derived_name, + validate_finite_array, + verify_file_grant, +) +from stingray import EventList + +from services import utility_helpers + + +def _grant(secret: str, path, access: str, expires: int | None = None) -> str: + expires = int(time.time()) + 60 if expires is None else expires + resolved = path.resolve() + identity_path = resolved if access == "read" else resolved.parent + selected_stat = identity_path.stat() + prefix = ( + f"{FILE_GRANT_VERSION}.{expires}.{selected_stat.st_dev}.{selected_stat.st_ino}" + ) + payload = ( + f"{FILE_GRANT_VERSION}\0{access}\0{expires}\0{resolved}\0" + f"{selected_stat.st_dev}\0{selected_stat.st_ino}" + ).encode() + digest = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + return f"{prefix}.{digest}" + + +def test_file_grant_is_bound_to_exact_path_and_access(tmp_path, monkeypatch): + secret = "test-only-file-grant-secret-32-bytes" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "selected.fits" + selected.write_bytes(b"fits") + adjacent = tmp_path / "adjacent.fits" + adjacent.write_bytes(b"other") + token = _grant(secret, selected, "read") + + assert ( + verify_file_grant(str(selected), token, access="read", must_exist=True) + == selected.resolve() + ) + with pytest.raises(PermissionError, match="does not match"): + verify_file_grant(str(adjacent), token, access="read", must_exist=True) + with pytest.raises(PermissionError, match="does not match"): + verify_file_grant(str(selected), token, access="write", must_exist=False) + + +def test_file_grant_rejects_expired_or_missing_secret(tmp_path, monkeypatch): + selected = tmp_path / "selected.fits" + selected.write_bytes(b"fits") + secret = "test-only-file-grant-secret-32-bytes" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + expired = _grant(secret, selected, "read", int(time.time()) - 1) + with pytest.raises(PermissionError, match="expired"): + verify_file_grant(str(selected), expired, access="read", must_exist=True) + monkeypatch.delenv(FILE_GRANT_SECRET_ENV) + with pytest.raises(PermissionError, match="not launched by Electron"): + verify_file_grant(str(selected), expired, access="read", must_exist=True) + + +def test_python_issuer_creates_canonical_v2_grants_with_fixed_ttl( + tmp_path, monkeypatch +): + secret = "test-only-file-grant-secret-32-bytes" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + monkeypatch.setattr(utility_helpers.time, "time", lambda: 1_900_000_000) + selected = tmp_path / "selected.fits" + selected.write_bytes(b"fits") + + issued = issue_file_grant(str(selected), access="read") + + assert issued.path == selected.resolve() + assert issued.expires_at == 1_900_000_000 + FILE_GRANT_TTL_SECONDS + assert issued.grant.startswith(f"{FILE_GRANT_VERSION}.{issued.expires_at}.") + assert ( + verify_file_grant( + str(issued.path), issued.grant, access="read", must_exist=True + ) + == issued.path + ) + + +def test_file_grant_rejects_malformed_future_and_weak_secret(tmp_path, monkeypatch): + secret = "test-only-file-grant-secret-32-bytes" + selected = tmp_path / "selected.fits" + selected.write_bytes(b"fits") + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + + malformed_grants = ( + "v1.1.2.3." + "0" * 64, + "v2." + "9" * 10_000 + ".2.3." + "0" * 64, + "v2." + "9" * 21 + ".2.3." + "0" * 64, + "v2.123.\u0661.3." + "0" * 64, + ) + for malformed in malformed_grants: + with pytest.raises(PermissionError, match="malformed"): + verify_file_grant(str(selected), malformed, access="read", must_exist=True) + + future = _grant( + secret, + selected, + "read", + int(time.time()) + FILE_GRANT_MAX_FUTURE_SECONDS + 1, + ) + with pytest.raises(PermissionError, match="expiry is invalid"): + verify_file_grant(str(selected), future, access="read", must_exist=True) + + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, "too-short") + with pytest.raises(PermissionError, match="strong per-launch secret"): + verify_file_grant(str(selected), future, access="read", must_exist=True) + + +def test_read_grant_pins_identity_and_open_descriptor(tmp_path, monkeypatch): + secret = "test-only-file-grant-secret-32-bytes" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "selected.fits" + selected.write_bytes(b"original") + token = _grant(secret, selected, "read") + + replacement = tmp_path / "replacement.fits" + replacement.write_bytes(b"replacement") + os.replace(replacement, selected) + with pytest.raises(PermissionError, match="identity changed"): + verify_file_grant(str(selected), token, access="read", must_exist=True) + + selected.write_bytes(b"second original") + token = _grant(secret, selected, "read") + replacement.write_bytes(b"second replacement") + with open_verified_read_grant(str(selected), token) as granted: + os.replace(replacement, selected) + assert granted.stream.read() == b"second original" + assert granted.size_bytes == len(b"second original") + + +def test_read_grant_rejects_swap_between_path_check_and_descriptor_open( + tmp_path, monkeypatch +): + secret = "test-only-file-grant-secret-32-bytes" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected = tmp_path / "selected.fits" + selected.write_bytes(b"original") + token = _grant(secret, selected, "read") + replacement = tmp_path / "replacement.fits" + replacement.write_bytes(b"replacement") + real_open = os.open + swapped = False + + def swap_before_open(file_path, flags, *args, **kwargs): + nonlocal swapped + if not swapped and Path(file_path) == selected.resolve(): + swapped = True + os.replace(replacement, selected) + return real_open(file_path, flags, *args, **kwargs) + + monkeypatch.setattr(os, "open", swap_before_open) + with ( + pytest.raises(PermissionError, match="identity changed"), + open_verified_read_grant(str(selected), token), + ): + pass + + +def test_write_grant_is_bound_to_selected_parent_directory(tmp_path, monkeypatch): + secret = "test-only-file-grant-secret-32-bytes" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected_parent = tmp_path / "selected-parent" + selected_parent.mkdir() + destination = selected_parent / "export.json" + token = _grant(secret, destination, "write") + + original_parent = tmp_path / "original-parent" + selected_parent.rename(original_parent) + selected_parent.mkdir() + + with pytest.raises(PermissionError, match="directory identity changed"): + verify_file_grant(str(destination), token, access="write", must_exist=False) + + +def test_write_grant_rejects_swap_between_path_check_and_parent_open( + tmp_path, monkeypatch +): + secret = "test-only-file-grant-secret-32-bytes" + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, secret) + selected_parent = tmp_path / "selected-parent" + selected_parent.mkdir() + selected_parent_path = selected_parent.resolve() + destination = selected_parent / "export.json" + token = _grant(secret, destination, "write") + moved_parent = tmp_path / "moved-parent" + real_open = os.open + real_fstat = os.fstat + opened_descriptor: int | None = None + + def swap_before_open(file_path, flags, *args, **kwargs): + nonlocal opened_descriptor + if Path(file_path) == selected_parent_path: + selected_parent.rename(moved_parent) + selected_parent.mkdir() + opened_descriptor = real_open(file_path, flags, *args, **kwargs) + return opened_descriptor + + monkeypatch.setattr(os, "open", swap_before_open) + with ( + pytest.raises(PermissionError, match="directory identity changed"), + open_verified_write_grant(str(destination), token), + ): + pass + + assert opened_descriptor is not None + with pytest.raises(OSError): + real_fstat(opened_descriptor) + + +def test_state_snapshot_is_detached_and_add_if_absent_is_atomic(): + state = StateManager() + source = EventList( + time=np.array([1.0, 2.0, 3.0]), + energy=np.array([2.0, 3.0, 4.0]), + gti=[[0.5, 3.5]], + ) + state.add_event_data("source", source) + snapshot = state.copy_event_data("source") + snapshot.time[0] = 99.0 + snapshot.gti[0, 0] = -1.0 + assert source.time[0] == 1.0 + assert source.gti[0, 0] == 0.5 + assert not np.shares_memory(source.time, snapshot.time) + + def add(index: int) -> bool: + return state.add_event_data_if_absent( + "derived", EventList(time=[float(index), float(index + 1)]) + ) + + with ThreadPoolExecutor(max_workers=8) as pool: + outcomes = list(pool.map(add, range(32))) + assert outcomes.count(True) == 1 + assert outcomes.count(False) == 31 + + +def test_state_row_cap_rejects_sized_sequence_before_iteration(): + class GuardedList(list): + def __iter__(self): + raise AssertionError("oversized state must not be iterated") + + state = StateManager() + state.add_analysis_result("oversized", {"value": GuardedList([1, 2, 3])}) + + with pytest.raises(ValueError, match="has 3 rows; the operation cap is 2"): + state.copy_analysis_result( + "oversized", max_rows=2, max_cells=100, max_bytes=10_000 + ) + + +def test_state_byte_cap_rejects_string_without_full_utf8_encoding(): + class GuardedString(str): + def encode(self, *args, **kwargs): + raise AssertionError("oversized state must not be encoded in full") + + state = StateManager() + state.add_analysis_result( + "oversized metadata", {"metadata": {"header": GuardedString("x" * 1_000)}} + ) + + with pytest.raises(ValueError, match="operation size cap"): + state.copy_analysis_result( + "oversized metadata", max_rows=10, max_cells=100, max_bytes=64 + ) + + +def test_state_byte_cap_counts_column_metadata_before_deepcopy(monkeypatch): + from services import state_manager as state_module + + table = Table({"value": [1.0]}) + table["value"].meta["blob"] = "x" * 1_000_000 + state = StateManager() + state.add_analysis_result("column metadata", table) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("column metadata cap must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + with pytest.raises(ValueError, match="operation size cap"): + state.copy_analysis_result( + "column metadata", max_rows=10, max_cells=100, max_bytes=128 + ) + + +def test_state_byte_cap_counts_mask_storage_before_deepcopy(monkeypatch): + from services import state_manager as state_module + + table = Table() + table["value"] = MaskedColumn( + np.zeros(1_024, dtype=np.uint8), + mask=np.ones(1_024, dtype=bool), + fill_value=255, + ) + state = StateManager() + state.add_analysis_result("masked column", table) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("column mask cap must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + with pytest.raises(ValueError, match="operation size cap"): + state.copy_analysis_result( + "masked column", max_rows=2_000, max_cells=2_000, max_bytes=1_500 + ) + + +@pytest.mark.parametrize("field", ["description", "format", "unit", "fill_value"]) +def test_state_byte_cap_counts_all_column_semantics_before_deepcopy(monkeypatch, field): + from services import state_manager as state_module + + if field == "fill_value": + column = MaskedColumn( + np.asarray([object()], dtype=object), + mask=[True], + fill_value="x" * 512, + ) + table = Table([column], names=["value"]) + else: + table = Table({"value": [1.0]}) + if field == "description": + table["value"].description = "x" * 512 + elif field == "format": + table["value"].format = "%0.2f" + (" " * 512) + else: + table["value"].unit = u.def_unit("state_cap_" + ("u" * 512)) + state = StateManager() + state.add_analysis_result(f"column {field}", table) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError(f"column {field} cap must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + with pytest.raises(ValueError, match="operation size cap"): + state.copy_analysis_result( + f"column {field}", max_rows=10, max_cells=100, max_bytes=128 + ) + + +def test_state_byte_cap_counts_quantity_unit_before_deepcopy(monkeypatch): + from services import state_manager as state_module + + table = Table({"value": [1.0]}) + table.meta["exposure"] = u.Quantity( + 1.0, + u.def_unit("state_quantity_" + ("u" * 10_000)), + ) + state = StateManager() + state.add_analysis_result("quantity unit", table) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("Quantity unit cap must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + with pytest.raises(ValueError, match="operation size cap"): + state.copy_analysis_result( + "quantity unit", max_rows=10, max_cells=100, max_bytes=1_000 + ) + + +def test_state_byte_cap_counts_ndarray_subclass_state_before_deepcopy(monkeypatch): + from services import state_manager as state_module + + class ArraySubclass(np.ndarray): + pass + + value = np.asarray([1.0]).view(ArraySubclass) + value.blob = "x" * 10_000 + state = StateManager() + state.add_analysis_result("array subclass", {"value": value}) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("ndarray subclass cap must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + with pytest.raises(ValueError, match="operation size cap"): + state.copy_analysis_result( + "array subclass", max_rows=10, max_cells=100, max_bytes=1_000 + ) + + +def test_state_byte_cap_counts_plain_masked_array_storage_before_deepcopy( + monkeypatch, +): + from services import state_manager as state_module + + value = np.ma.array( + np.zeros(1_024, dtype=np.uint8), + mask=np.ones(1_024, dtype=bool), + fill_value=255, + ) + state = StateManager() + state.add_analysis_result("plain masked array", {"value": value}) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("masked array cap must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + with pytest.raises(ValueError, match="operation size cap"): + state.copy_analysis_result( + "plain masked array", max_rows=2_000, max_cells=2_000, max_bytes=1_500 + ) + + +def test_state_byte_cap_counts_large_python_int_before_deepcopy(monkeypatch): + from services import state_manager as state_module + + state = StateManager() + state.add_analysis_result( + "large integer", + {"value": [1.0], "metadata": {"large": 10**4_000}}, + ) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("large integer cap must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + with pytest.raises(ValueError, match="operation size cap"): + state.copy_analysis_result( + "large integer", max_rows=10, max_cells=100, max_bytes=200 + ) + + +def test_state_byte_cap_counts_masked_object_payload_before_deepcopy(monkeypatch): + from services import state_manager as state_module + + class LargePayload: + def __init__(self): + self.blob = "x" * 1_000 + + value = Masked( + np.asarray([LargePayload()], dtype=object), + mask=[True], + ) + state = StateManager() + state.add_analysis_result("masked object payload", {"value": value}) + + def forbidden_deepcopy(*args, **kwargs): + raise AssertionError("masked object payload cap must run before deepcopy") + + monkeypatch.setattr(state_module.copy, "deepcopy", forbidden_deepcopy) + with pytest.raises(ValueError, match="operation size cap"): + state.copy_analysis_result( + "masked object payload", max_rows=10, max_cells=100, max_bytes=128 + ) + + +def test_state_rejects_structured_object_payload_before_deepcopy(): + class EmbeddedPayload: + def __deepcopy__(self, memo): + raise AssertionError("structured object payload must not be deep-copied") + + values = np.empty(1, dtype=[("x", object)]) + values["x"][0] = EmbeddedPayload() + table = Table() + table["structured"] = values + state = StateManager() + state.add_analysis_result("structured object", table) + + with pytest.raises(ValueError, match="structured dtype with object references"): + state.copy_analysis_result( + "structured object", max_rows=10, max_cells=100, max_bytes=1_000 + ) + + +def test_common_validation_and_json_sanitizing(): + assert validate_derived_name("derived-events") is None + assert validate_derived_name(" ../unsafe") is not None + array, error = validate_finite_array([1, 2, 3], label="values") + assert error is None and np.array_equal(array, [1, 2, 3]) + assert "values[1]" in validate_finite_array([1, np.nan], label="values")[1] + assert "booleans" in validate_finite_array([1, True], label="values")[1] + assert "complex" in validate_finite_array([1, 2 + 1j], label="values")[1] + assert "text values" in validate_finite_array([1, "2"], label="values")[1] + assert "text" in validate_finite_array("12", label="values")[1] + assert "mapping" in validate_finite_array({1: 2}, label="values")[1] + assert "booleans" in validate_finite_array(True, label="values")[1] + + warnings: list[str] = [] + payload = json_safe( + { + "finite": np.float64(1.0), + "scalar_array": np.asarray(2.0), + "bad": [np.inf, np.nan], + "complex": 1 + 2j, + "bad_decimal": Decimal("Infinity"), + }, + warnings, + ) + assert payload == { + "finite": 1.0, + "scalar_array": 2.0, + "bad": [None, None], + "complex": None, + "bad_decimal": None, + } + assert warnings + json.dumps(payload, allow_nan=False) + + +def test_finite_array_caps_generators_before_unbounded_materialization(): + consumed: list[int] = [] + + def oversized_values(): + for value in range(100): + consumed.append(value) + yield value + + array, error = validate_finite_array(oversized_values(), label="values", max_size=2) + + assert array is None + assert error == "values contains at least 3 values; the cap is 2" + assert consumed == [0, 1, 2] + + valid, valid_error = validate_finite_array( + (value for value in [1.0, 2.0]), label="values", max_size=2 + ) + assert valid_error is None + np.testing.assert_array_equal(valid, [1.0, 2.0]) + + +def test_finite_array_uses_sized_preflight_before_iteration(): + class OversizedValues: + def __len__(self): + return 4 + + def __iter__(self): + raise AssertionError("oversized sized input must not be iterated") + + array, error = validate_finite_array(OversizedValues(), label="values", max_size=3) + + assert array is None + assert error == "values contains 4 values; the cap is 3" + + +def test_json_sanitizing_aggregates_large_nonfinite_array_warnings(): + warnings: list[str] = [] + payload = json_safe(np.full(10_000, np.nan), warnings, "large_array") + + assert payload == [None] * 10_000 + assert warnings == [ + ( + "large_array contains 10,000 non-finite values represented as null; " + "first at large_array[0]" + ) + ] + json.dumps(payload, allow_nan=False) diff --git a/python-backend/tests/test_varenergy_service.py b/python-backend/tests/test_varenergy_service.py new file mode 100644 index 0000000..7766a69 --- /dev/null +++ b/python-backend/tests/test_varenergy_service.py @@ -0,0 +1,953 @@ +"""Tests for the var-energy spectrum service and routes. + +Covariance/rms spectra are all-NaN on pure Poisson data (no excess variance in +the reference band), so every "should produce numbers" test uses an event list +whose arrival times are drawn from a sinusoidally modulated rate. Energies are +assigned independently of time, so every energy band shares the same +variability — exactly the correlated-variability case these spectra measure. +""" + +import json + +import numpy as np +import pytest +from stingray import EventList + +from services.varenergy_service import VarEnergyService +from tests.backend_auth import ( + TEST_BACKEND_AUTH_HEADERS, + TEST_BACKEND_SESSION_SECRET, +) + +ESPEC = dict(energy_min=0.5, energy_max=10.0, n_bands=5) +FREQ = dict(freq_min=0.1, freq_max=1.0) + + +def modulated_event_list( + seed: int = 17, + n_events: int = 120000, + length: float = 64.0, + mod_freq: float = 0.5, + amplitude: float = 0.6, +) -> EventList: + """Event list with a shared sinusoidal rate modulation across all energies.""" + rng = np.random.default_rng(seed) + kept = [] + total = 0 + while total < n_events: + candidates = rng.uniform(0.0, length, n_events) + accept = rng.uniform(0.0, 1.0, n_events) < ( + 1 + amplitude * np.sin(2 * np.pi * mod_freq * candidates) + ) / (1 + amplitude) + kept.append(candidates[accept]) + total += int(accept.sum()) + times = np.sort(np.concatenate(kept)[:n_events]) + energy = rng.uniform(0.5, 10.0, n_events) + return EventList(time=times, energy=energy, gti=[[0.0, length]]) + + +def sparse_event_list( + seed: int = 3, n_events: int = 400, length: float = 64.0 +) -> EventList: + """Pure-Poisson, low-count list: the legitimate all-NaN / low-count path.""" + rng = np.random.default_rng(seed) + times = np.sort(rng.uniform(0.0, length, n_events)) + energy = rng.uniform(0.5, 10.0, n_events) + return EventList(time=times, energy=energy, gti=[[0.0, length]]) + + +def two_gti_constant_event_list( + seed: int = 11, n_per_gti: int = 20000, gti=((0.0, 100.0), (900.0, 1000.0)) +) -> EventList: + """Constant-rate Poisson source seen in two GTIs across a long slew gap. + + There is no intrinsic variability whatsoever; the only structure a light + curve spanning gti[0][0]..gti[-1][-1] can show is the 800 s of dead time + between the two intervals. + """ + rng = np.random.default_rng(seed) + times = np.concatenate( + [np.sort(rng.uniform(start, stop, n_per_gti)) for start, stop in gti] + ) + energy = rng.uniform(0.5, 10.0, times.size) + return EventList(time=times, energy=energy, gti=[list(g) for g in gti]) + + +def gappy_modulated_event_list(gti=((0.0, 28.0), (36.0, 64.0)), **kwargs) -> EventList: + """`modulated_event_list` with the events outside `gti` screened out.""" + events = modulated_event_list(**kwargs) + inside = np.zeros(events.time.size, dtype=bool) + for start, stop in gti: + inside |= (events.time >= start) & (events.time < stop) + return EventList( + time=events.time[inside], + energy=events.energy[inside], + gti=[list(g) for g in gti], + ) + + +@pytest.fixture() +def modulated_state(state_manager): + state_manager.add_event_data("ev_mod", modulated_event_list()) + return state_manager + + +def finite_values(values): + return np.asarray([v for v in values if v is not None], dtype=float) + + +# -------------------------------------------------------------------------- +# rms-spectrum +# -------------------------------------------------------------------------- + + +def test_rms_spectrum_returns_finite_spectrum_and_serializes(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, norm="frac", **FREQ, **ESPEC + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert len(data["energy"]) == 5 + assert len(data["spectrum"]) == 5 + assert len(data["spectrum_error"]) == 5 + assert all(v is not None for v in data["spectrum"]) + # 60% sinusoidal modulation -> fractional rms of order 0.4 in every band. + rms = finite_values(data["spectrum"]) + assert np.all(rms > 0.2) and np.all(rms < 0.8) + assert data["freq_range"] == [0.1, 1.0] + assert data["norm"] == "frac" + assert data["n_segments_hint"] == 8 # 64 s of GTI / 8 s segments + assert data["warnings"] == [] or isinstance(data["warnings"], list) + + +def test_rms_spectrum_abs_norm_differs_from_frac(modulated_state): + svc = VarEnergyService(modulated_state) + frac = svc.rms_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, norm="frac", **FREQ, **ESPEC + ) + absolute = svc.rms_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, norm="abs", **FREQ, **ESPEC + ) + assert absolute["success"], absolute + assert ( + finite_values(absolute["data"]["spectrum"])[0] + > 10 * finite_values(frac["data"]["spectrum"])[0] + ) + assert absolute["data"]["norm"] == "abs" + + +def test_rms_spectrum_rejects_unknown_norm(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, norm="leahy", **FREQ, **ESPEC + ) + assert not result["success"] + assert "norm" in result["message"] + + +def test_rms_spectrum_missing_event_list(state_manager): + svc = VarEnergyService(state_manager) + result = svc.rms_spectrum( + "nope", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert not result["success"] + assert "not found" in result["message"] + + +def test_rms_spectrum_rejects_tiny_segment_size(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", bin_time=0.0625, segment_size=0.125, **FREQ, **ESPEC + ) + assert not result["success"] + assert "3x dt" in result["message"] + + +def test_rms_spectrum_rejects_segment_longer_than_gti(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", bin_time=0.0625, segment_size=200.0, **FREQ, **ESPEC + ) + assert not result["success"] + assert "good-time interval" in result["message"] + assert "64" in result["message"] + + +def test_rms_spectrum_rejects_freq_max_above_nyquist(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + freq_min=0.1, + freq_max=20.0, + **ESPEC, + ) + assert not result["success"] + assert "Nyquist" in result["message"] + + +def test_rms_spectrum_rejects_inverted_freq_range(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, freq_min=1.0, freq_max=0.1, **ESPEC + ) + assert not result["success"] + assert "freq_min" in result["message"] + + +def test_rms_spectrum_rejects_bad_energy_range(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + energy_min=10.0, + energy_max=0.5, + n_bands=5, + **FREQ, + ) + assert not result["success"] + assert "energy_min" in result["message"] + + +def test_rms_spectrum_rejects_single_band(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + energy_min=0.5, + energy_max=10.0, + n_bands=1, + **FREQ, + ) + assert not result["success"] + assert "n_bands" in result["message"] + + +def test_log_bands_rejected_for_zero_energy_min(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + energy_min=0.0, + energy_max=10.0, + n_bands=5, + log_bands=True, + **FREQ, + ) + assert not result["success"] + assert "log" in result["message"] + + +def test_log_bands_produce_log_spaced_energies(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + log_bands=True, + **FREQ, + **ESPEC, + ) + assert result["success"], result + energies = np.asarray(result["data"]["energy"], dtype=float) + # Log-spaced edges give increasing bin widths -> increasing centre spacing. + spacing = np.diff(energies) + assert np.all(np.diff(spacing) > 0) + + +def test_event_list_without_energy_is_rejected(state_manager): + rng = np.random.default_rng(4) + times = np.sort(rng.uniform(0.0, 64.0, 5000)) + state_manager.add_event_data("ev_noe", EventList(time=times, gti=[[0.0, 64.0]])) + svc = VarEnergyService(state_manager) + result = svc.rms_spectrum( + "ev_noe", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert not result["success"] + assert "energy" in result["message"] + + +def test_low_count_data_yields_nulls_and_warnings(state_manager): + state_manager.add_event_data("ev_few", sparse_event_list()) + svc = VarEnergyService(state_manager) + result = svc.rms_spectrum( + "ev_few", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert any(v is None for v in data["spectrum"]) + assert data["warnings"], "stingray's low-count advisory must reach the payload" + assert any("Low count rate" in w for w in data["warnings"]) + + +# -------------------------------------------------------------------------- +# lag-spectrum +# -------------------------------------------------------------------------- + + +def test_lag_spectrum_returns_seconds_and_serializes(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.lag_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert len(data["energy"]) == 5 + assert all(v is not None for v in data["spectrum"]) + # No injected energy-dependent delay -> lags consistent with zero (<0.1 s). + assert np.all(np.abs(finite_values(data["spectrum"])) < 0.1) + assert data["ref_band"] is None + assert data["freq_range"] == [0.1, 1.0] + assert data["n_segments_hint"] == 8 + + +def test_lag_spectrum_with_reference_band(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.lag_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + ref_min=8.0, + ref_max=10.0, + **FREQ, + **ESPEC, + ) + assert result["success"], result + assert result["data"]["ref_band"] == [8.0, 10.0] + full = svc.lag_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert result["data"]["spectrum"] != full["data"]["spectrum"] + # A narrow reference band makes stingray's error formula take the sqrt of a + # negative number; the bare numpy text must not reach the UI unexplained. + for warning in result["data"]["warnings"]: + assert not warning.startswith("invalid value encountered"), warning + + +def test_bare_numpy_warnings_are_wrapped_in_an_explanation(): + from services.varenergy_service import _humanize_warnings + + readable = _humanize_warnings( + [ + "invalid value encountered in sqrt", + "invalid value encountered in sqrt", + "Low count rate in the 0.5-2.4 subject band: 6 ct/segment (<10). Skipping.", + ] + ) + assert len(readable) == 2, "duplicates must collapse" + assert readable[0].startswith("undefined maths") + assert "invalid value encountered in sqrt" in readable[0] + assert readable[1].startswith("Low count rate") + + +@pytest.mark.parametrize("ref_min,ref_max", [(8.0, None), (None, 10.0)]) +def test_lag_spectrum_rejects_half_a_reference_band(modulated_state, ref_min, ref_max): + svc = VarEnergyService(modulated_state) + result = svc.lag_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + ref_min=ref_min, + ref_max=ref_max, + **FREQ, + **ESPEC, + ) + assert not result["success"] + assert "ref_min" in result["message"] and "ref_max" in result["message"] + + +def test_lag_spectrum_rejects_inverted_reference_band(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.lag_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + ref_min=10.0, + ref_max=8.0, + **FREQ, + **ESPEC, + ) + assert not result["success"] + assert "ref_min" in result["message"] + + +# -------------------------------------------------------------------------- +# excess-variance +# -------------------------------------------------------------------------- + + +def test_excess_variance_workaround_returns_finite_values(modulated_state): + # Pins the stingray 2.2.10 bug workaround: the constructed object's + # .spectrum is always all-NaN, so the service must use the arrays returned + # by _spectrum_function(). + from stingray.varenergyspectrum import ExcessVarianceSpectrum + + raw = ExcessVarianceSpectrum( + events=modulated_state.get_event_data("ev_mod"), + freq_interval=[0.1, 1.0], + energy_spec=(0.5, 10.0, 5, "lin"), + bin_time=0.0625, + ) + assert np.all(np.isnan(raw.spectrum)), ( + "upstream bug disappeared; drop the workaround" + ) + + svc = VarEnergyService(modulated_state) + result = svc.excess_variance_spectrum("ev_mod", bin_time=0.0625, **ESPEC) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert len(data["energy"]) == 5 + assert all(v is not None for v in data["spectrum"]) + assert all(v is not None for v in data["spectrum_error"]) + fvar = finite_values(data["spectrum"]) + # F_var recovers the injected ~0.42 fractional variability in every band. + assert np.all(fvar > 0.2) and np.all(fvar < 0.8) + assert data["normalization"] == "fvar" + + +def test_excess_variance_normalization_none_is_unnormalized(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.excess_variance_spectrum( + "ev_mod", bin_time=0.0625, normalization="none", **ESPEC + ) + assert result["success"], result + assert result["data"]["normalization"] == "none" + # Unnormalized excess variance is in counts^2, orders of magnitude above F_var. + assert np.all(finite_values(result["data"]["spectrum"]) > 1.0) + + +def test_excess_variance_rejects_unknown_normalization(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.excess_variance_spectrum( + "ev_mod", bin_time=0.0625, normalization="norm_xs", **ESPEC + ) + assert not result["success"] + assert "normalization" in result["message"] + + +def test_excess_variance_is_not_invented_by_inter_gti_gaps(state_manager): + # stingray's ExcessVarianceSpectrum builds ONE light curve from gti[0, 0] + # to gti[-1, -1], so every bin in the 800 s gap is a real 0-count bin and + # np.var(lc.counts) measures the gap, not the source. The service must + # restrict the statistic to bins that are actually inside a GTI. + from stingray.varenergyspectrum import ExcessVarianceSpectrum + + events = two_gti_constant_event_list() + state_manager.add_event_data("ev_gappy", events) + + raw, _ = ExcessVarianceSpectrum( + events=events, + freq_interval=[0.0, 0.5], + energy_spec=(0.5, 10.0, 4, "lin"), + bin_time=1.0, + )._spectrum_function() + assert np.all(raw > 1.5), ( + "upstream stopped counting gap bins as data; revisit the workaround", + raw, + ) + + svc = VarEnergyService(state_manager) + result = svc.excess_variance_spectrum( + "ev_gappy", bin_time=1.0, energy_min=0.5, energy_max=10.0, n_bands=4 + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + measured = [ + (value, error) + for value, error in zip(data["spectrum"], data["spectrum_error"]) + if value is not None + ] + # A constant Poisson source has no excess variance: every band that comes + # out finite must sit within a few sigma of zero, nowhere near F_var ~ 2. + for value, error in measured: + assert value < 0.2, measured + assert value < 5 * error, measured + + +def test_excess_variance_still_measures_real_variability_across_gtis(state_manager): + # Masking the gaps must not cost sensitivity: the same 60% modulation is + # recovered whether or not the observation is interrupted. + state_manager.add_event_data("ev_mod", modulated_event_list()) + state_manager.add_event_data("ev_mod_gaps", gappy_modulated_event_list()) + svc = VarEnergyService(state_manager) + + whole = svc.excess_variance_spectrum("ev_mod", bin_time=0.0625, **ESPEC) + gappy = svc.excess_variance_spectrum("ev_mod_gaps", bin_time=0.0625, **ESPEC) + assert whole["success"] and gappy["success"], (whole, gappy) + whole_fvar = finite_values(whole["data"]["spectrum"]) + gappy_fvar = finite_values(gappy["data"]["spectrum"]) + assert len(gappy_fvar) == 5 + assert np.all(gappy_fvar > 0.2) and np.all(gappy_fvar < 0.8) + assert np.all(np.abs(gappy_fvar - whole_fvar) < 0.1) + + +def test_excess_variance_builds_each_light_curve_once(modulated_state, monkeypatch): + # VarEnergySpectrum.__init__ runs _spectrum_function() and discards the + # result; the service used to run it a second time to recover the numbers, + # doubling the heaviest allocation in this module. + from stingray import Lightcurve + + original = Lightcurve.make_lightcurve + calls = [] + + def counting_make_lightcurve(*args, **kwargs): + calls.append(1) + return original(*args, **kwargs) + + monkeypatch.setattr( + Lightcurve, "make_lightcurve", staticmethod(counting_make_lightcurve) + ) + svc = VarEnergyService(modulated_state) + result = svc.excess_variance_spectrum( + "ev_mod", bin_time=0.0625, energy_min=0.5, energy_max=10.0, n_bands=3 + ) + assert result["success"], result + assert len(calls) == 3, "one light curve per energy band, not two" + + +def test_nan_advice_on_the_excess_variance_page_names_only_its_own_controls( + state_manager, +): + # ExcessVarianceSpectrum has no reference band and the endpoint exposes no + # segment_size, so the shared advisory sent users hunting for controls that + # do not exist on that page. + state_manager.add_event_data("ev_few", sparse_event_list()) + svc = VarEnergyService(state_manager) + result = svc.excess_variance_spectrum("ev_few", bin_time=0.0625, **ESPEC) + assert result["success"], result + assert all(v is None for v in result["data"]["spectrum"]) + advice = [w for w in result["data"]["warnings"] if "could not be computed" in w] + assert advice, result["data"]["warnings"] + assert "segment_size" not in advice[0] + assert "reference band" not in advice[0] + assert "bin_time" in advice[0] and "energy bands" in advice[0] + + # The segmented spectra do have both controls, and keep naming them. + covariance = svc.avg_covariance_spectrum( + "ev_few", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + segmented_advice = [ + w for w in covariance["data"]["warnings"] if "could not be computed" in w + ] + assert segmented_advice, covariance["data"]["warnings"] + assert "segment_size" in segmented_advice[0] + assert "reference band" in segmented_advice[0] + + +# -------------------------------------------------------------------------- +# variable-energy-spectrum +# -------------------------------------------------------------------------- + + +def test_variable_energy_spectrum_returns_three_panels(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.variable_energy_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert len(data["energy"]) == 5 + for key in ("counts", "rms", "lag"): + assert set(data[key]) == {"spectrum", "error"} + assert len(data[key]["spectrum"]) == 5 + assert len(data[key]["error"]) == 5 + assert np.all(finite_values(data["counts"]["spectrum"]) > 0) + assert np.all(finite_values(data["rms"]["spectrum"]) > 0.2) + assert np.all(np.abs(finite_values(data["lag"]["spectrum"])) < 0.1) + assert data["freq_range"] == [0.1, 1.0] + assert data["ref_band"] is None + assert data["n_segments_hint"] == 8 + + +def test_variable_energy_spectrum_honours_reference_band(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.variable_energy_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + ref_min=8.0, + ref_max=10.0, + **FREQ, + **ESPEC, + ) + assert result["success"], result + assert result["data"]["ref_band"] == [8.0, 10.0] + + +def test_variable_energy_spectrum_rejects_half_a_reference_band(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.variable_energy_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, ref_min=8.0, **FREQ, **ESPEC + ) + assert not result["success"] + assert "ref_min" in result["message"] + + +# -------------------------------------------------------------------------- +# covariance-spectrum / avg-covariance-spectrum +# -------------------------------------------------------------------------- + + +def test_covariance_spectrum_uses_the_whole_gti_as_one_segment(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.covariance_spectrum("ev_mod", bin_time=0.0625, **FREQ, **ESPEC) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert data["segment_size"] == 64.0 + assert data["n_segments_hint"] == 1 + assert data["norm"] == "abs" + assert data["ref_band"] is None + assert all(v is not None for v in data["spectrum"]) + assert np.all(finite_values(data["spectrum"]) > 0) + + +def test_covariance_spectrum_frac_norm(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.covariance_spectrum( + "ev_mod", bin_time=0.0625, norm="frac", **FREQ, **ESPEC + ) + assert result["success"], result + assert result["data"]["norm"] == "frac" + assert np.all(finite_values(result["data"]["spectrum"]) < 1.0) + + +def test_avg_covariance_spectrum_averages_segments(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.avg_covariance_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert data["segment_size"] == 8.0 + assert data["n_segments_hint"] == 8 + assert all(v is not None for v in data["spectrum"]) + assert np.all(finite_values(data["spectrum"]) > 0) + # Averaging 8 segments must not change the covariance level materially. + single = svc.covariance_spectrum("ev_mod", bin_time=0.0625, **FREQ, **ESPEC) + ratio = finite_values(data["spectrum"]) / finite_values(single["data"]["spectrum"]) + assert np.all(np.abs(ratio - 1.0) < 0.2) + + +def test_avg_covariance_spectrum_rejects_segment_longer_than_gti(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.avg_covariance_spectrum( + "ev_mod", bin_time=0.0625, segment_size=100.0, **FREQ, **ESPEC + ) + assert not result["success"] + assert "good-time interval" in result["message"] + + +def test_avg_covariance_spectrum_with_reference_band(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.avg_covariance_spectrum( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + ref_min=8.0, + ref_max=10.0, + **FREQ, + **ESPEC, + ) + assert result["success"], result + assert result["data"]["ref_band"] == [8.0, 10.0] + + +def test_covariance_spectrum_reports_the_gtis_it_dropped(state_manager): + # segment_size is derived as the longest GTI, and stingray's + # time_intervals_from_gtis skips every shorter one outright, so "1 segment + # (full GTI)" is only true for a single-GTI observation. The payload has to + # say how much exposure actually contributed. + state_manager.add_event_data( + "ev_gaps", gappy_modulated_event_list(gti=((0.0, 24.0), (28.0, 64.0))) + ) + svc = VarEnergyService(state_manager) + result = svc.covariance_spectrum("ev_gaps", bin_time=0.0625, **FREQ, **ESPEC) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert data["segment_size"] == 36.0 + assert data["n_segments_hint"] == 1 + assert data["n_gtis_total"] == 2 + assert data["n_gtis_used"] == 1 + assert data["exposure_total"] == 60.0 + assert data["exposure_used"] == 36.0 + dropped = [w for w in data["warnings"] if "skipped entirely" in w] + assert dropped, data["warnings"] + assert "36s of the 60s" in dropped[0] + assert "60%" in dropped[0] + assert "1 of 2 good-time intervals" in result["message"] + + +def test_single_gti_covariance_says_nothing_about_dropped_exposure(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.covariance_spectrum("ev_mod", bin_time=0.0625, **FREQ, **ESPEC) + assert result["success"], result + data = result["data"] + assert data["n_gtis_total"] == data["n_gtis_used"] == 1 + assert data["exposure_used"] == data["exposure_total"] == 64.0 + assert not [w for w in data["warnings"] if "skipped entirely" in w] + assert result["message"] == "Computed covariance spectrum in 5 energy bands" + + +def test_segmented_endpoints_warn_when_a_gti_is_too_short_for_the_segment( + state_manager, +): + state_manager.add_event_data( + "ev_gaps", gappy_modulated_event_list(gti=((0.0, 6.0), (8.0, 64.0))) + ) + svc = VarEnergyService(state_manager) + result = svc.rms_spectrum( + "ev_gaps", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert result["success"], result + skipped = [w for w in result["data"]["warnings"] if "skips entirely" in w] + assert skipped, result["data"]["warnings"] + assert "1 of the 2 good-time intervals" in skipped[0] + + +def test_covariance_on_sparse_poisson_data_is_null_with_warnings(state_manager): + # Pure Poisson noise has no excess variance in the reference band, so the + # covariance is the sqrt of a negative number: legitimately all-NaN. + state_manager.add_event_data("ev_few", sparse_event_list()) + svc = VarEnergyService(state_manager) + result = svc.avg_covariance_spectrum( + "ev_few", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert result["success"], result + json.dumps(result, allow_nan=False) + data = result["data"] + assert all(v is None for v in data["spectrum"]) + assert data["warnings"], "the all-NaN result must come with an explanation" + assert any("could not be computed" in w for w in data["warnings"]) + + +# -------------------------------------------------------------------------- +# segment_size / bin_time compatibility +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "bin_time,segment_size,expected", + [(0.03, 8.0, 8.01), (0.07, 10.0, 10.01), (0.05, 8.03, 8.05), (0.09, 32.0, 32.04)], +) +def test_segment_size_is_snapped_to_a_whole_number_of_bins( + modulated_state, bin_time, segment_size, expected +): + # stingray masks frequencies on rint(segment_size/bin_time) bins but sizes + # the FFT with floor(); when they disagree sub_power[good] raised + # "IndexError: boolean index did not match indexed array", and when they + # happened to match in length the mask was applied to the wrong grid. + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", bin_time=bin_time, segment_size=segment_size, **FREQ, **ESPEC + ) + assert result["success"], result + assert "IndexError" not in (result["message"] or "") + note = [w for w in result["data"]["warnings"] if "segment_size was adjusted" in w] + assert note, result["data"]["warnings"] + assert f"to {expected:g}s" in note[0], note + + +@pytest.mark.parametrize( + "bin_time,segment_size", [(0.03, 8.0), (0.07, 10.0), (0.05, 8.03), (0.09, 32.0)] +) +def test_adjusted_segment_puts_stingrays_two_grids_on_one_bin_count( + modulated_state, bin_time, segment_size +): + from stingray.utils import fix_segment_size_to_integer_samples + + svc = VarEnergyService(modulated_state) + adjusted, note = svc._fit_segment_to_bins(segment_size, bin_time, None) + assert note + _, fft_bins = fix_segment_size_to_integer_samples(adjusted, bin_time) + assert fft_bins == int(np.rint(adjusted / bin_time)) + + +def test_variable_energy_spectrum_survives_a_non_integer_segment_ratio(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.variable_energy_spectrum( + "ev_mod", bin_time=0.03, segment_size=8.0, **FREQ, **ESPEC + ) + assert result["success"], result + assert all(v is not None for v in result["data"]["rms"]["spectrum"]) + + +def test_segment_size_that_already_fits_is_left_alone(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", bin_time=0.0625, segment_size=8.0, **FREQ, **ESPEC + ) + assert result["success"], result + assert not [ + w for w in result["data"]["warnings"] if "segment_size was adjusted" in w + ] + + +# -------------------------------------------------------------------------- +# frequency window vs frequency resolution +# -------------------------------------------------------------------------- + + +def test_frequency_window_with_no_fourier_bin_is_rejected(modulated_state): + # segment_size 8 s -> the lowest sampled frequency is 1/8 = 0.125 Hz, so + # 0.001-0.05 Hz selects nothing and every band used to come back null + # behind a bare "Mean of empty slice." warning. + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", + bin_time=0.05, + segment_size=8.0, + freq_min=0.001, + freq_max=0.05, + **ESPEC, + ) + assert not result["success"] + assert "1/segment_size" in result["message"] + assert "0.125 Hz" in result["message"] + + +def test_frequency_window_floor_applies_to_the_covariance_endpoints(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.avg_covariance_spectrum( + "ev_mod", + bin_time=0.05, + segment_size=8.0, + freq_min=0.001, + freq_max=0.05, + **ESPEC, + ) + assert not result["success"] + assert "1/segment_size" in result["message"] + + +def test_frequency_window_wider_than_one_bin_is_accepted(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.rms_spectrum( + "ev_mod", bin_time=0.05, segment_size=8.0, freq_min=0.001, freq_max=0.5, **ESPEC + ) + assert result["success"], result + + +# -------------------------------------------------------------------------- +# reference bands with no events +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "method", ["lag_spectrum", "variable_energy_spectrum", "avg_covariance_spectrum"] +) +def test_empty_reference_band_is_rejected_readably(modulated_state, method): + svc = VarEnergyService(modulated_state) + result = getattr(svc, method)( + "ev_mod", + bin_time=0.0625, + segment_size=8.0, + ref_min=50.0, + ref_max=100.0, + **FREQ, + **ESPEC, + ) + assert not result["success"] + assert "reference band" in result["message"] + assert "no events" in result["message"] + assert "NoneType" not in result["message"] + + +def test_empty_reference_band_is_rejected_by_covariance_spectrum(modulated_state): + svc = VarEnergyService(modulated_state) + result = svc.covariance_spectrum( + "ev_mod", bin_time=0.0625, ref_min=50.0, ref_max=100.0, **FREQ, **ESPEC + ) + assert not result["success"] + assert "reference band" in result["message"] + assert "NoneType" not in result["message"] + + +# -------------------------------------------------------------------------- +# routes +# -------------------------------------------------------------------------- + + +def test_all_six_routes_are_registered(): + from routes import varenergy_routes + + paths = {route.path for route in varenergy_routes.router.routes} + assert paths == { + "/rms-spectrum", + "/lag-spectrum", + "/excess-variance", + "/variable-energy-spectrum", + "/covariance-spectrum", + "/avg-covariance-spectrum", + } + + +def test_rms_request_model_has_no_reference_band_field(): + # ref_band is silently inert for RmsSpectrum in stingray 2.2.10, so the + # endpoint must not offer a control that does nothing. + from routes.varenergy_routes import RmsSpectrumRequest + + fields = set(RmsSpectrumRequest.model_fields) + assert not fields & {"ref_band", "ref_min", "ref_max"} + + +def test_route_bodies_offload_to_a_thread(): + import inspect + + from routes import varenergy_routes + + for route in varenergy_routes.router.routes: + source = inspect.getsource(route.endpoint) + assert "asyncio.to_thread" in source, route.path + + +@pytest.mark.asyncio +async def test_rms_spectrum_endpoint_end_to_end(): + import httpx + + from main import create_app + from services.state_manager import StateManager + from utils.performance_monitor import PerformanceMonitor + + app = create_app(session_secret=TEST_BACKEND_SESSION_SECRET) + app.state.state_manager = StateManager() + app.state.performance_monitor = PerformanceMonitor() + app.state.state_manager.add_event_data("ev_mod", modulated_event_list()) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers=TEST_BACKEND_AUTH_HEADERS, + ) as client: + response = await client.post( + "/api/varenergy/rms-spectrum", + json={ + "event_list_name": "ev_mod", + "bin_time": 0.0625, + "segment_size": 8.0, + "freq_min": 0.1, + "freq_max": 1.0, + "energy_min": 0.5, + "energy_max": 10.0, + "n_bands": 5, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"], body + assert len(body["data"]["energy"]) == 5 + assert body["data"]["norm"] == "frac" diff --git a/python-backend/tests/test_windows_secure_publication.py b/python-backend/tests/test_windows_secure_publication.py new file mode 100644 index 0000000..cd0a4a9 --- /dev/null +++ b/python-backend/tests/test_windows_secure_publication.py @@ -0,0 +1,539 @@ +"""Real-Windows coverage for FILE_ID_INFO grants and NTFS publication.""" + +from __future__ import annotations + +import ctypes +import os +import subprocess +import time +from contextlib import contextmanager +from pathlib import Path + +import pytest + +import services.utility_helpers as utility_helpers +from services.secure_publication import open_secure_publication +from services.utility_helpers import ( + FILE_GRANT_MAX_FUTURE_SECONDS, + FILE_GRANT_SECRET_ENV, + FILE_GRANT_TTL_SECONDS, + FileGrantEligibilityError, + issue_file_grant, + open_verified_read_grant, + verify_file_grant, +) +from services.windows_secure_fs import ( + FILE_RENAME_INFORMATION_CLASS, + WINDOWS_FILE_GRANT_VERSION, + WindowsFileIdentity, + WindowsNativeApi, + _build_file_rename_information, + _FILE_RENAME_INFORMATION, + _FILE_RENAME_OPERATION, + canonicalize_windows_path, + pin_windows_path, + validate_windows_path_text, +) + +TEST_SECRET = "windows-secure-publication-test-secret-32-bytes" +WINDOWS_REQUIRED_ENV = "STINGRAY_REQUIRE_WINDOWS_SECURE_EXPORT" + +requires_windows = pytest.mark.skipif( + os.name != "nt", + reason="Real Windows NTFS handles are required", +) + + +@pytest.fixture(autouse=True) +def file_grant_secret(monkeypatch): + monkeypatch.setenv(FILE_GRANT_SECRET_ENV, TEST_SECRET) + + +def _write_grant(path: Path) -> str: + return issue_file_grant(str(path), access="write").grant + + +def test_windows_ci_capability_gate_cannot_pass_via_skips(tmp_path): + """The dedicated workflow sets the gate, making Windows capability required.""" + if os.environ.get(WINDOWS_REQUIRED_ENV) != "1": + return + assert os.name == "nt" + assert WindowsNativeApi().kernel32 is not None + issued = issue_file_grant(str(tmp_path / "capability.bin"), access="write") + assert issued.grant.startswith(f"{WINDOWS_FILE_GRANT_VERSION}.") + + +def test_windows_path_canonicalizes_drive_case_and_separators(): + canonical = canonicalize_windows_path("c:/Science/Events.fits") + assert str(canonical) == r"C:\Science\Events.fits" + assert str(canonicalize_windows_path("d:/artifact.bin")) == r"D:\artifact.bin" + assert str(canonicalize_windows_path("e:/")) == "E:\\" + + +def test_windows_native_rename_buffer_matches_file_rename_information_abi(): + pointer_size = ctypes.sizeof(ctypes.c_void_p) + assert ctypes.sizeof(_FILE_RENAME_OPERATION) == 4 + assert _FILE_RENAME_INFORMATION.RootDirectory.offset == ( + 8 if pointer_size == 8 else 4 + ) + assert _FILE_RENAME_INFORMATION.FileNameLength.offset == ( + 16 if pointer_size == 8 else 8 + ) + assert _FILE_RENAME_INFORMATION.FileName.offset == (20 if pointer_size == 8 else 12) + assert ctypes.sizeof(_FILE_RENAME_INFORMATION) == (24 if pointer_size == 8 else 16) + + parent_handle = 0x01020304 if pointer_size == 4 else 0x0102030405060708 + filename = "artifact.bin" + encoded_name = filename.encode("utf-16-le") + buffer, buffer_size = _build_file_rename_information(parent_handle, filename) + raw = bytes(buffer) + + assert FILE_RENAME_INFORMATION_CLASS == 10 + assert buffer_size == ctypes.sizeof(_FILE_RENAME_INFORMATION) + len(encoded_name) + assert raw[:4] == b"\0" * 4 + root_offset = _FILE_RENAME_INFORMATION.RootDirectory.offset + assert int.from_bytes(raw[root_offset : root_offset + pointer_size], "little") == ( + parent_handle + ) + length_offset = _FILE_RENAME_INFORMATION.FileNameLength.offset + assert int.from_bytes(raw[length_offset : length_offset + 4], "little") == len( + encoded_name + ) + name_offset = _FILE_RENAME_INFORMATION.FileName.offset + assert raw[name_offset : name_offset + len(encoded_name)] == encoded_name + + +def test_windows_native_rename_uses_nt_class_10_and_relative_parent_handle(): + captured: dict[str, object] = {} + + class FakeNtdll: + def NtSetInformationFile( + self, + handle, + io_status, + buffer, + buffer_size, + information_class, + ): + captured["handle"] = handle.value + captured["io_status"] = io_status + captured["buffer"] = bytes(buffer) + captured["buffer_size"] = buffer_size + captured["information_class"] = information_class + return 0 + + def RtlNtStatusToDosError(self, status): + raise AssertionError( + f"Successful NT status was unexpectedly mapped: {status}" + ) + + api = object.__new__(WindowsNativeApi) + api.ntdll = FakeNtdll() + parent_handle = 0x1234 + filename = "artifact.bin" + api.rename_no_replace(0x5678, parent_handle, filename) + + assert captured["handle"] == 0x5678 + assert captured["information_class"] == FILE_RENAME_INFORMATION_CLASS + assert captured["buffer_size"] == len(captured["buffer"]) + raw = captured["buffer"] + assert isinstance(raw, bytes) + pointer_size = ctypes.sizeof(ctypes.c_void_p) + root_offset = _FILE_RENAME_INFORMATION.RootDirectory.offset + assert int.from_bytes(raw[root_offset : root_offset + pointer_size], "little") == ( + parent_handle + ) + + +@pytest.mark.parametrize( + "unsafe_path, expected", + [ + (r"\\server\share\events.fits", "UNC"), + (r"\\?\C:\science\events.fits", "UNC"), + (r"C:\science\events.fits:stream", "alternate data streams"), + (r"C:\science\CON.fits", "reserved device"), + (r"C:\science\COM1 .fits", "reserved device"), + (r"C:\science\event.fits. ", "space or period"), + (r"C:\science\..\event.fits", "dot components"), + ], +) +def test_windows_path_policy_rejects_ambiguous_names(unsafe_path, expected): + with pytest.raises(ValueError, match=expected): + validate_windows_path_text(unsafe_path) + + +@requires_windows +def test_windows_drive_root_is_really_pinned(tmp_path): + drive_root = Path(tmp_path.anchor) + with pin_windows_path(drive_root, directory=True) as pinned: + assert pinned.path == drive_root + assert pinned.directory is True + assert len(pinned.identity.file_id) == 16 + + +@requires_windows +def test_windows_v3_grant_pins_read_identity_and_closes_handles(tmp_path): + selected = tmp_path / "events.fits" + selected.write_bytes(b"selected scientific bytes") + replacement = tmp_path / "replacement.fits" + replacement.write_bytes(b"replacement") + + issued = issue_file_grant(str(selected), access="read") + parts = issued.grant.split(".") + assert parts[0] == WINDOWS_FILE_GRANT_VERSION + assert len(parts[2]) == 16 + assert len(parts[3]) == 32 + assert ( + verify_file_grant( + str(selected), + issued.grant, + access="read", + must_exist=True, + ) + == selected + ) + adjacent = tmp_path / "adjacent.fits" + adjacent.write_bytes(b"adjacent") + with pytest.raises(PermissionError, match="does not match"): + verify_file_grant(str(adjacent), issued.grant, access="read", must_exist=True) + with pytest.raises(PermissionError, match="does not match"): + verify_file_grant(str(selected), issued.grant, access="write", must_exist=False) + + with open_verified_read_grant(str(selected), issued.grant) as granted: + with pytest.raises(OSError): + os.replace(replacement, selected) + assert granted.stream.read() == b"selected scientific bytes" + assert granted.size_bytes == len(b"selected scientific bytes") + + # All retained prefix/file handles are gone after the grant context. + os.replace(replacement, selected) + assert selected.read_bytes() == b"replacement" + + +@requires_windows +def test_windows_v3_grants_reject_stale_file_and_parent_identities(tmp_path): + selected = tmp_path / "events.fits" + selected.write_bytes(b"original") + stale_read_grant = issue_file_grant(str(selected), access="read").grant + replacement = tmp_path / "replacement.fits" + replacement.write_bytes(b"replacement") + os.replace(replacement, selected) + with pytest.raises(PermissionError, match="identity changed"): + verify_file_grant( + str(selected), stale_read_grant, access="read", must_exist=True + ) + + parent = tmp_path / "selected-parent" + parent.mkdir() + destination = parent / "artifact.bin" + stale_write_grant = issue_file_grant(str(destination), access="write").grant + moved_parent = tmp_path / "original-parent" + parent.rename(moved_parent) + parent.mkdir() + with pytest.raises(PermissionError, match="directory identity changed"): + with open_secure_publication(str(destination), stale_write_grant): + pytest.fail("A stale Windows parent identity must never be yielded") + + +@requires_windows +def test_windows_v3_grant_rejects_malformed_and_future_tokens(tmp_path, monkeypatch): + selected = tmp_path / "events.fits" + selected.write_bytes(b"events") + issued = issue_file_grant(str(selected), access="read") + malformed = [ + issued.grant.replace("v3.", "v2.", 1), + "v3.123.bad.00000000000000000000000000000000." + "0" * 64, + "v3.123.0000000000000000." + "f" * 31 + "." + "0" * 64, + ] + for token in malformed: + with pytest.raises(PermissionError): + verify_file_grant(str(selected), token, access="read", must_exist=True) + + now = int(time.time()) + monkeypatch.setattr("services.utility_helpers.time.time", lambda: now) + future = issue_file_grant(str(selected), access="read") + monkeypatch.setattr( + "services.utility_helpers.time.time", + lambda: now - FILE_GRANT_MAX_FUTURE_SECONDS, + ) + with pytest.raises(PermissionError, match="expiry is invalid"): + verify_file_grant(str(selected), future.grant, access="read", must_exist=True) + + +@requires_windows +def test_windows_publication_is_seekable_verified_and_handle_clean( + tmp_path, monkeypatch +): + parent = tmp_path / "selected-parent" + parent.mkdir() + destination = parent / "artifact.hdf5" + moved_parent = tmp_path / "moved-parent" + + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + future_time = int(time.time()) + FILE_GRANT_TTL_SECONDS + 30 + reservation = publication._reservation + real_private_descriptor = reservation.api.private_security_descriptor + real_flush = reservation.api.flush + private_descriptors: list[object] = [] + flushed_handles: list[int] = [] + + @contextmanager + def tracked_private_descriptor(): + with real_private_descriptor() as descriptor: + private_descriptors.append(descriptor) + yield descriptor + + def tracked_flush(handle): + flushed_handles.append(handle) + real_flush(handle) + + monkeypatch.setattr( + reservation.api, + "private_security_descriptor", + tracked_private_descriptor, + ) + monkeypatch.setattr(reservation.api, "flush", tracked_flush) + publication.assert_destination_available() + publication.reserve_staging(".hdf5") + with pytest.raises(OSError): + parent.rename(moved_parent) + with publication.open_writer("w+b", encoding=None) as stream: + assert stream.seekable() and stream.readable() and stream.writable() + stream.write(b"verified HDF5-compatible bytes") + stream.seek(0) + assert stream.read() == b"verified HDF5-compatible bytes" + with pytest.raises(RuntimeError, match="writer is already active"): + with publication.open_writer("w+b", encoding=None): + pass + monkeypatch.setattr(utility_helpers.time, "time", lambda: future_time) + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"verified HDF5-compatible bytes" + with pytest.raises(RuntimeError, match="reader is already active"): + with publication.open_reader("rb", encoding=None): + pass + assert publication.verified_size() == len(b"verified HDF5-compatible bytes") + assert publication.publish() == [] + + assert len(private_descriptors) == 1 + assert len(flushed_handles) == 1 + assert destination.read_bytes() == b"verified HDF5-compatible bytes" + assert list(parent.glob(".stingray-export-*")) == [] + parent.rename(moved_parent) + moved_parent.rename(parent) + + +@requires_windows +def test_windows_publication_rejects_grant_expired_before_admission( + tmp_path, monkeypatch +): + destination = tmp_path / "expired.bin" + admission_time = int(time.time()) + monkeypatch.setattr( + utility_helpers.time, + "time", + lambda: admission_time - FILE_GRANT_TTL_SECONDS - 1, + ) + expired_grant = _write_grant(destination) + monkeypatch.setattr(utility_helpers.time, "time", lambda: admission_time) + + with pytest.raises(PermissionError, match="expired"): + with open_secure_publication(str(destination), expired_grant): + pytest.fail("An expired Windows grant must not be admitted") + + +@requires_windows +def test_windows_publication_roundtrips_real_hdf5_file_object_driver(tmp_path): + import h5py + + destination = tmp_path / "real-science.hdf5" + expected = [1.25, 2.5, 5.0] + + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.reserve_staging(".hdf5") + with publication.open_writer("w+b", encoding=None) as stream: + with h5py.File(stream, "w") as handle: + handle.create_dataset("events/time", data=expected) + handle.attrs["schema"] = "stingray-explorer.hdf5.v1" + with publication.open_reader("rb", encoding=None) as stream: + with h5py.File(stream, "r") as handle: + assert handle["events/time"][:].tolist() == expected + assert handle.attrs["schema"] == "stingray-explorer.hdf5.v1" + publication.verified_size() + publication.publish() + + with h5py.File(destination, "r") as handle: + assert handle["events/time"][:].tolist() == expected + + +@requires_windows +def test_windows_publication_enforces_verified_lifecycle_and_nonempty_output( + tmp_path, +): + empty_destination = tmp_path / "empty.bin" + with open_secure_publication( + str(empty_destination), + _write_grant(empty_destination), + ) as publication: + with pytest.raises(RuntimeError, match="has not completed verification"): + publication.publish() + publication.reserve_staging(".bin") + with pytest.raises(RuntimeError, match="has not completed"): + with publication.open_reader("rb", encoding=None): + pass + with publication.open_writer("wb", encoding=None): + pass + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"" + with pytest.raises(ValueError, match="empty"): + publication.verified_size() + + assert not empty_destination.exists() + assert list(tmp_path.glob(".stingray-export-*")) == [] + + destination = tmp_path / "verified.bin" + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.reserve_staging(".bin") + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"verified") + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"verified" + publication.verified_size() + publication.publish() + with pytest.raises(RuntimeError, match="already published"): + publication.publish() + + +@requires_windows +def test_windows_publication_refuses_late_target_race_without_replacement(tmp_path): + destination = tmp_path / "artifact.bin" + sentinel = b"user-owned target" + + with pytest.raises(FileExistsError, match="already exists"): + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.assert_destination_available() + publication.reserve_staging(".bin") + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"new bytes") + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"new bytes" + publication.verified_size() + destination.write_bytes(sentinel) + publication.publish() + + assert destination.read_bytes() == sentinel + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@requires_windows +def test_windows_publication_cleans_owned_handles_after_writer_failure(tmp_path): + parent = tmp_path / "selected-parent" + parent.mkdir() + destination = parent / "artifact.bin" + + with pytest.raises(OSError, match="synthetic writer failure"): + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.reserve_staging(".bin") + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"partial") + raise OSError("synthetic writer failure") + + assert not destination.exists() + assert list(parent.glob(".stingray-export-*")) == [] + moved_parent = tmp_path / "moved-parent" + parent.rename(moved_parent) + + +@requires_windows +@pytest.mark.parametrize("invalid_phase", ["writer", "reader"]) +def test_windows_invalid_stream_mode_closes_every_owned_handle(tmp_path, invalid_phase): + parent = tmp_path / f"{invalid_phase}-parent" + parent.mkdir() + destination = parent / "artifact.bin" + + with pytest.raises(ValueError, match="Unsupported secure publication stream"): + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.reserve_staging(".bin") + if invalid_phase == "reader": + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"private bytes") + with publication.open_reader("invalid", encoding=None): + pass + else: + with publication.open_writer("invalid", encoding=None): + pass + + assert not destination.exists() + assert list(parent.glob(".stingray-export-*")) == [] + parent.rename(tmp_path / f"moved-{invalid_phase}-parent") + + +@requires_windows +def test_windows_publication_checks_final_reopen_identity(tmp_path, monkeypatch): + destination = tmp_path / "artifact.bin" + + with pytest.raises(PermissionError, match="changed during publication"): + with open_secure_publication( + str(destination), + _write_grant(destination), + ) as publication: + publication.reserve_staging(".bin") + with publication.open_writer("wb", encoding=None) as stream: + stream.write(b"published bytes") + with publication.open_reader("rb", encoding=None) as stream: + assert stream.read() == b"published bytes" + publication.verified_size() + + reservation = publication._reservation + real_identity = reservation.api.identity + + def mismatched_final_identity(handle): + identity = real_identity(handle) + if handle != reservation.artifact_handle: + return WindowsFileIdentity( + volume_serial=identity.volume_serial, + file_id=b"\xff" * 16, + ) + return identity + + monkeypatch.setattr(reservation.api, "identity", mismatched_final_identity) + publication.publish() + + # Rename already occurred before the final reopen check. Failures never + # trigger a destructive retry against the published destination. + assert destination.read_bytes() == b"published bytes" + assert list(tmp_path.glob(".stingray-export-*")) == [] + + +@requires_windows +def test_windows_grants_reject_junction_prefixes(tmp_path): + real_parent = tmp_path / "real-parent" + real_parent.mkdir() + junction = tmp_path / "junction-parent" + result = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(junction), str(real_parent)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + + with pytest.raises(FileGrantEligibilityError, match="reparse points"): + issue_file_grant(str(junction / "artifact.bin"), access="write") diff --git a/python-backend/utils/__init__.py b/python-backend/utils/__init__.py new file mode 100644 index 0000000..fe2a948 --- /dev/null +++ b/python-backend/utils/__init__.py @@ -0,0 +1,6 @@ +"""Utilities for Stingray Explorer backend.""" + +from .performance_monitor import PerformanceMonitor +from .error_handler import ErrorHandler + +__all__ = ["PerformanceMonitor", "ErrorHandler"] diff --git a/python-backend/utils/error_handler.py b/python-backend/utils/error_handler.py new file mode 100644 index 0000000..da34a6d --- /dev/null +++ b/python-backend/utils/error_handler.py @@ -0,0 +1,140 @@ +""" +Error handling utilities for Stingray Explorer. +""" + +import traceback +from typing import Any, Dict, Optional, Tuple + + +class ErrorHandler: + """ + Centralized error handling for Stingray Explorer backend. + + Provides consistent error formatting and user-friendly messages. + """ + + # Mapping of common exception types to user-friendly messages + ERROR_MESSAGES: Dict[type, str] = { + FileNotFoundError: "The specified file could not be found", + PermissionError: "Permission denied when accessing the file", + ValueError: "Invalid value provided", + TypeError: "Invalid data type", + MemoryError: "Operation requires more memory than available", + IOError: "Error reading or writing data", + KeyError: "Required data field not found", + } + + @classmethod + def handle_error( + cls, + exception: Exception, + context: str = "", + include_traceback: bool = False, + **context_data: Any, + ) -> Tuple[str, str]: + """ + Handle an exception and return user-friendly and technical messages. + + Args: + exception: The exception that occurred + context: Description of the operation that failed + include_traceback: Whether to include full traceback in technical message + **context_data: Additional context data to include + + Returns: + Tuple of (user_friendly_message, technical_message) + + Example: + >>> try: + ... data = load_file(path) + ... except Exception as e: + ... user_msg, tech_msg = ErrorHandler.handle_error( + ... e, context="Loading file", file_path=path + ... ) + """ + # Get exception type and message + exc_type = type(exception) + exc_message = str(exception) + + # Create user-friendly message + base_message = cls.ERROR_MESSAGES.get( + exc_type, f"An error occurred: {exc_type.__name__}" + ) + + if context: + user_message = f"{context}: {base_message}" + else: + user_message = base_message + + if exc_message and exc_message != str(exc_type): + user_message = f"{user_message}. {exc_message}" + + # Create technical message + tech_parts = [ + f"Exception: {exc_type.__name__}", + f"Message: {exc_message}", + ] + + if context: + tech_parts.append(f"Context: {context}") + + if context_data: + context_str = ", ".join(f"{k}={v}" for k, v in context_data.items()) + tech_parts.append(f"Data: {context_str}") + + if include_traceback: + tech_parts.append(f"Traceback:\n{traceback.format_exc()}") + + technical_message = " | ".join(tech_parts) + + return user_message, technical_message + + @classmethod + def format_validation_error( + cls, field: str, expected: str, received: Any + ) -> Tuple[str, str]: + """ + Format a validation error message. + + Args: + field: The field that failed validation + expected: Description of expected value + received: The actual value received + + Returns: + Tuple of (user_friendly_message, technical_message) + """ + user_message = f"Invalid {field}: expected {expected}, got {type(received).__name__}" + technical_message = f"Validation error: {field}={received!r}, expected {expected}" + + return user_message, technical_message + + @classmethod + def create_error_response( + cls, + exception: Exception, + context: str = "", + **context_data: Any, + ) -> Dict[str, Any]: + """ + Create a standardized error response dictionary. + + Args: + exception: The exception that occurred + context: Description of the operation that failed + **context_data: Additional context data + + Returns: + Error response dictionary + """ + user_msg, tech_msg = cls.handle_error( + exception, context=context, **context_data + ) + + return { + "success": False, + "data": None, + "message": user_msg, + "error": tech_msg, + "error_type": type(exception).__name__, + } diff --git a/python-backend/utils/log_stream.py b/python-backend/utils/log_stream.py new file mode 100644 index 0000000..9229df8 --- /dev/null +++ b/python-backend/utils/log_stream.py @@ -0,0 +1,313 @@ +""" +Log streaming utilities for real-time log delivery via SSE. + +This module captures Python logging output and warnings, then streams them +to connected frontend clients through Server-Sent Events (SSE). +""" + +import asyncio +import logging +import queue +import warnings +from collections import deque +from datetime import datetime, timezone +from typing import Any, AsyncGenerator, Callable, Optional + +# Loggers to skip (reduce noise) +SKIP_LOGGERS = frozenset({ + "uvicorn.access", + "uvicorn.error", +}) + + +class StreamingLogHandler(logging.Handler): + """ + Custom logging handler that pushes log records to a queue for SSE streaming. + + Maps Python log levels to frontend-compatible levels: + - DEBUG -> debug + - INFO -> info + - WARNING -> warn + - ERROR/CRITICAL -> error + """ + + LEVEL_MAP = { + logging.DEBUG: "debug", + logging.INFO: "info", + logging.WARNING: "warn", + logging.ERROR: "error", + logging.CRITICAL: "error", + } + + def __init__( + self, + log_queue: "queue.Queue[dict[str, Any]]", + manager: "LogStreamManager", + ) -> None: + """ + Initialize the handler with a queue for log entries. + + Args: + log_queue: Thread-safe queue to push log entries to + manager: The LogStreamManager instance for history storage + """ + super().__init__() + self._queue = log_queue + self._manager = manager + + def emit(self, record: logging.LogRecord) -> None: + """ + Emit a log record by pushing it to the queue and storing in history. + + Args: + record: The log record to emit + """ + # Skip noisy loggers + if record.name in SKIP_LOGGERS: + return + + try: + # Map level to frontend-compatible string + level = self.LEVEL_MAP.get(record.levelno, "info") + + # Format the message + message = self.format(record) + + # Create log entry + log_entry = { + "type": "log", + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": level, + "source": "python", + "logger": record.name, + "message": message, + } + + # Store in history for replay to new SSE clients + self._manager._add_to_history(log_entry) + + # Non-blocking put (drop if queue is full) + try: + self._queue.put_nowait(log_entry) + except queue.Full: + # Queue full, drop oldest entry and try again + try: + self._queue.get_nowait() + self._queue.put_nowait(log_entry) + except queue.Empty: + pass + + except Exception: + # Don't let logging errors crash the app + self.handleError(record) + + +class LogStreamManager: + """ + Manages log streaming infrastructure for SSE delivery. + + Captures Python logging output and warnings, buffering them in a queue + for delivery to connected SSE clients. Supports multiple concurrent + connections and handles cleanup on shutdown. + """ + + def __init__(self, max_queue_size: int = 1000, max_history: int = 100) -> None: + """ + Initialize the log stream manager. + + Args: + max_queue_size: Maximum number of log entries to buffer + max_history: Maximum number of log entries to keep in history for replay + """ + self._queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=max_queue_size) + self._history: deque[dict[str, Any]] = deque(maxlen=max_history) + self._handler: Optional[StreamingLogHandler] = None + self._original_showwarning: Optional[Callable[..., None]] = None + self._installed = False + self._active_connections = 0 + + def install(self, log_level: int = logging.DEBUG) -> None: + """ + Install the log handler and warning capture. + + Args: + log_level: Minimum log level to capture (default: DEBUG) + """ + if self._installed: + return + + # Create and configure handler + self._handler = StreamingLogHandler(self._queue, self) + self._handler.setLevel(log_level) + + # Set formatter + formatter = logging.Formatter("%(message)s") + self._handler.setFormatter(formatter) + + # Add to root logger + root_logger = logging.getLogger() + root_logger.addHandler(self._handler) + + # Capture warnings + self._original_showwarning = warnings.showwarning + warnings.showwarning = self._capture_warning + + self._installed = True + logging.getLogger(__name__).info("Log streaming installed") + + def uninstall(self) -> None: + """Remove the log handler and restore original warning handling.""" + if not self._installed: + return + + # Remove handler from root logger + if self._handler: + root_logger = logging.getLogger() + root_logger.removeHandler(self._handler) + self._handler = None + + # Restore original showwarning + if self._original_showwarning: + warnings.showwarning = self._original_showwarning + self._original_showwarning = None + + # Clear the queue + while not self._queue.empty(): + try: + self._queue.get_nowait() + except queue.Empty: + break + + self._installed = False + logging.getLogger(__name__).info("Log streaming uninstalled") + + def _add_to_history(self, log_entry: dict[str, Any]) -> None: + """ + Add a log entry to the history buffer for replay to new SSE clients. + + Args: + log_entry: The log entry to store + """ + self._history.append(log_entry) + + def _capture_warning( + self, + message: Warning | str, + category: type[Warning], + filename: str, + lineno: int, + file: Any = None, + line: str | None = None, + ) -> None: + """ + Capture warnings and route them to the log stream. + + Args: + message: The warning message + category: The warning category class + filename: The file where the warning occurred + lineno: The line number + file: File to write to (ignored, we capture it) + line: Source code line (optional) + """ + # Format warning message + warning_msg = f"{category.__name__}: {message}" + if filename and lineno: + warning_msg = f"{filename}:{lineno}: {warning_msg}" + + # Create log entry + log_entry = { + "type": "log", + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "warn", + "source": "python", + "logger": f"warnings.{category.__name__}", + "message": warning_msg, + } + + # Store in history for replay to new SSE clients + self._add_to_history(log_entry) + + # Push to queue + try: + self._queue.put_nowait(log_entry) + except queue.Full: + try: + self._queue.get_nowait() + self._queue.put_nowait(log_entry) + except queue.Empty: + pass + + # Also call original handler if it exists (for console output) + if self._original_showwarning: + self._original_showwarning(message, category, filename, lineno, file, line) + + async def stream_logs( + self, + heartbeat_interval: float = 30.0, + ) -> AsyncGenerator[dict[str, Any], None]: + """ + Async generator that yields log entries for SSE streaming. + + Replays history to new connections, then continues with live stream. + Includes periodic heartbeat events to keep the connection alive. + + Args: + heartbeat_interval: Seconds between heartbeat events (default: 30) + + Yields: + Log entry dictionaries ready for JSON serialization + """ + # Clear history if this is a new session (no active connections) + # This prevents stale logs from previous Electron sessions being replayed + if self._active_connections == 0: + self._history.clear() + + self._active_connections += 1 + last_heartbeat = asyncio.get_event_loop().time() + + try: + # Replay history for new connections + for log_entry in list(self._history): + yield log_entry + + while True: + # Check for log entries in the queue + try: + log_entry = self._queue.get_nowait() + yield log_entry + last_heartbeat = asyncio.get_event_loop().time() + except queue.Empty: + # No logs available, check if we need a heartbeat + current_time = asyncio.get_event_loop().time() + if current_time - last_heartbeat >= heartbeat_interval: + yield { + "type": "heartbeat", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + last_heartbeat = current_time + + # Small sleep to yield control and not spin + await asyncio.sleep(0.1) + + finally: + self._active_connections -= 1 + + @property + def is_installed(self) -> bool: + """Check if log streaming is currently installed.""" + return self._installed + + @property + def active_connections(self) -> int: + """Get the number of active SSE connections.""" + return self._active_connections + + @property + def queue_size(self) -> int: + """Get the current queue size.""" + return self._queue.qsize() + + +# Global singleton instance +log_stream_manager = LogStreamManager() diff --git a/python-backend/utils/performance_monitor.py b/python-backend/utils/performance_monitor.py new file mode 100644 index 0000000..845a7db --- /dev/null +++ b/python-backend/utils/performance_monitor.py @@ -0,0 +1,180 @@ +""" +Performance monitoring utilities for Stingray Explorer. +""" + +import time +import psutil +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Any, Dict, Generator, List, Optional + + +@dataclass +class OperationMetrics: + """Metrics for a single operation.""" + + name: str + duration_ms: float + memory_mb: float + success: bool + context: Dict[str, Any] = field(default_factory=dict) + timestamp: float = field(default_factory=time.time) + + +class PerformanceMonitor: + """ + Monitor performance of operations in the Stingray Explorer backend. + + Tracks execution time, memory usage, and operation success rates. + """ + + def __init__(self, max_history: int = 100): + """ + Initialize the performance monitor. + + Args: + max_history: Maximum number of operations to keep in history + """ + self.max_history = max_history + self.history: List[OperationMetrics] = [] + self._process = psutil.Process() + + @contextmanager + def track_operation( + self, name: str, **context: Any + ) -> Generator[None, None, None]: + """ + Context manager to track an operation's performance. + + Args: + name: Name of the operation + **context: Additional context to log with the metrics + + Example: + >>> with monitor.track_operation("load_file", file_path="/data/obs.evt"): + ... data = load_file("/data/obs.evt") + """ + start_time = time.perf_counter() + start_memory = self._get_memory_mb() + success = True + + try: + yield + except Exception: + success = False + raise + finally: + end_time = time.perf_counter() + end_memory = self._get_memory_mb() + + metrics = OperationMetrics( + name=name, + duration_ms=(end_time - start_time) * 1000, + memory_mb=end_memory - start_memory, + success=success, + context=context, + ) + + self._add_to_history(metrics) + + def _get_memory_mb(self) -> float: + """Get current memory usage in megabytes.""" + try: + return self._process.memory_info().rss / (1024 * 1024) + except Exception: + return 0.0 + + def _add_to_history(self, metrics: OperationMetrics) -> None: + """Add metrics to history, trimming if necessary.""" + self.history.append(metrics) + if len(self.history) > self.max_history: + self.history = self.history[-self.max_history :] + + def get_memory_usage(self) -> Dict[str, float]: + """ + Get current memory usage information. + + Returns: + Dictionary with memory usage metrics + """ + try: + memory_info = self._process.memory_info() + virtual_memory = psutil.virtual_memory() + + return { + "process_mb": memory_info.rss / (1024 * 1024), + "process_percent": self._process.memory_percent(), + "system_total_gb": virtual_memory.total / (1024**3), + "system_available_gb": virtual_memory.available / (1024**3), + "system_percent": virtual_memory.percent, + } + except Exception: + return {} + + def get_cpu_usage(self) -> Dict[str, float]: + """ + Get current CPU usage information. + + Returns: + Dictionary with CPU usage metrics + + Note: + Using interval=0.1 for cpu_percent() to get accurate readings. + Without an interval, the first call returns 0.0 (psutil quirk). + """ + try: + return { + "process_percent": self._process.cpu_percent(interval=0.1), + "system_percent": psutil.cpu_percent(interval=None), + "cpu_count": psutil.cpu_count(), + } + except Exception: + return {} + + def get_recent_operations(self, count: int = 10) -> List[Dict[str, Any]]: + """ + Get recent operation metrics. + + Args: + count: Number of recent operations to return + + Returns: + List of operation metrics dictionaries + """ + recent = self.history[-count:] if self.history else [] + return [ + { + "name": op.name, + "duration_ms": op.duration_ms, + "memory_mb": op.memory_mb, + "success": op.success, + "context": op.context, + "timestamp": op.timestamp, + } + for op in recent + ] + + def get_statistics(self) -> Dict[str, Any]: + """ + Get aggregate statistics from operation history. + + Returns: + Dictionary with aggregate statistics + """ + if not self.history: + return {"total_operations": 0} + + durations = [op.duration_ms for op in self.history] + successes = sum(1 for op in self.history if op.success) + + return { + "total_operations": len(self.history), + "success_rate": successes / len(self.history), + "avg_duration_ms": sum(durations) / len(durations), + "max_duration_ms": max(durations), + "min_duration_ms": min(durations), + } + + def clear_history(self) -> None: + """Clear operation history.""" + self.history.clear() diff --git a/resources/icon.ico b/resources/icon.ico new file mode 100644 index 0000000..858841c Binary files /dev/null and b/resources/icon.ico differ diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000..61a4499 Binary files /dev/null and b/resources/icon.png differ diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..452c573 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Build the Stingray Explorer application + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +echo "Building Stingray Explorer..." + +cd "$PROJECT_ROOT" + +# Install npm dependencies if needed +if [ ! -d "node_modules" ]; then + echo "Installing npm dependencies..." + npm install +fi + +# Build the application +echo "Building Electron app..." +npm run build + +echo "Build complete!" +echo "Output is in the dist/ directory." diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..2293f71 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Start the full development environment +# +# Note: The Python backend is spawned and managed by Electron, not this script. +# This ensures Electron can capture all backend stdout/stderr for the log panel. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +echo "Starting Stingray Explorer development environment..." + +# Cleanup function - kills any orphaned backend processes on exit +cleanup() { + echo "" + echo "Cleaning up..." + # Kill any orphaned python backend processes (safety measure) + pkill -9 -f "${PROJECT_ROOT}/python-backend/main.py" 2>/dev/null || true + echo "Cleanup complete." + exit 0 +} + +# Set up traps for various signals +trap cleanup EXIT +trap cleanup SIGINT +trap cleanup SIGTERM +trap cleanup SIGHUP + +# Start the Electron app (it will spawn and manage the Python backend) +echo "Starting Electron app..." +echo "Note: Python backend will be started by Electron for proper log capture." +cd "$PROJECT_ROOT" + +# Use dev:linux on Linux to disable sandbox (avoids permission issues) +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + npm run dev:linux +else + npm run dev +fi + +# Cleanup will be called automatically via trap diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..917e48f --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Package the Stingray Explorer application for distribution + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +BACKEND_DIR="$PROJECT_ROOT/python-backend" +DIST_DIR="$PROJECT_ROOT/dist" + +echo "Packaging Stingray Explorer..." + +cd "$PROJECT_ROOT" + +# Build the application first +"$SCRIPT_DIR/build.sh" + +# Create a distribution package with Python backend +echo "Creating distribution package..." + +# Create resources directory for Python backend +RESOURCES_DIR="$DIST_DIR/resources/python-backend" +mkdir -p "$RESOURCES_DIR" + +# Copy Python backend files +cp -r "$BACKEND_DIR"/* "$RESOURCES_DIR/" + +echo "Package created in $DIST_DIR" +echo "" +echo "Note: For production distribution, you'll need to:" +echo "1. Bundle Python with the application or require it as a dependency" +echo "2. Use electron-builder to create platform-specific installers" +echo "3. Sign the application for macOS and Windows" diff --git a/scripts/setup-python.sh b/scripts/setup-python.sh new file mode 100755 index 0000000..04aa9a4 --- /dev/null +++ b/scripts/setup-python.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Setup Python environment for Stingray Explorer backend + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +BACKEND_DIR="$PROJECT_ROOT/python-backend" +VENV_DIR="$PROJECT_ROOT/.venv" + +echo "Setting up Python environment for Stingray Explorer..." + +# Check if Python 3 is available +if ! command -v python3 &> /dev/null; then + echo "Error: Python 3 is required but not installed." + exit 1 +fi + +# Create virtual environment if it doesn't exist +if [ ! -d "$VENV_DIR" ]; then + echo "Creating virtual environment..." + python3 -m venv "$VENV_DIR" +fi + +# Activate virtual environment +source "$VENV_DIR/bin/activate" + +# Upgrade pip +echo "Upgrading pip..." +pip install --upgrade pip + +# Install requirements +echo "Installing Python dependencies..." +pip install -r "$BACKEND_DIR/requirements.txt" + +echo "Python environment setup complete!" +echo "" +echo "To activate the virtual environment, run:" +echo " source $VENV_DIR/bin/activate" diff --git a/scripts/start-backend.sh b/scripts/start-backend.sh new file mode 100755 index 0000000..52f3e65 --- /dev/null +++ b/scripts/start-backend.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Start the Python backend server using Pixi +pixi run start-backend diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..d7bb986 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,727 @@ +import React, { useState, createContext, useContext, useMemo } from 'react'; +import { RouterProvider, createHashRouter } from 'react-router-dom'; +import { ThemeProvider as MuiThemeProvider, createTheme, Theme } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// MUI Palette augmentation for stingrayGreen +declare module '@mui/material/styles' { + interface Palette { + stingrayGreen: Palette['primary']; + } + interface PaletteOptions { + stingrayGreen?: PaletteOptions['primary']; + } +} + +// Layout +import MainLayout from '@/components/layout/MainLayout'; + +// Pages +import HomePage from '@/pages/Home'; +import DataIngestionPage from '@/pages/DataIngestion'; +import NotFoundPage from '@/pages/NotFound'; + +// QuickLook Pages +import EventListPage from '@/pages/QuickLook/EventList'; +import LightCurvePage from '@/pages/QuickLook/LightCurve'; +import PowerSpectrumPage from '@/pages/QuickLook/PowerSpectrum'; +import AvgPowerSpectrumPage from '@/pages/QuickLook/AvgPowerSpectrum'; +import CrossSpectrumPage from '@/pages/QuickLook/CrossSpectrum'; +import AvgCrossSpectrumPage from '@/pages/QuickLook/AvgCrossSpectrum'; +import DynamicalPowerSpectrumPage from '@/pages/QuickLook/DynamicalPowerSpectrum'; +import CoherencePage from '@/pages/QuickLook/Coherence'; +import TimeLagsPage from '@/pages/QuickLook/TimeLags'; +import CrossCorrelationPage from '@/pages/QuickLook/CrossCorrelation'; +import AutoCorrelationPage from '@/pages/QuickLook/AutoCorrelation'; +import DeadTimeCorrectionsPage from '@/pages/QuickLook/DeadTimeCorrections'; +import BispectrumPage from '@/pages/QuickLook/Bispectrum'; +import PowerColorsPage from '@/pages/QuickLook/PowerColors'; +import CovarianceSpectrumPage from '@/pages/QuickLook/CovarianceSpectrum'; +import AvgCovarianceSpectrumPage from '@/pages/QuickLook/AvgCovarianceSpectrum'; +import VariableEnergySpectrumPage from '@/pages/QuickLook/VariableEnergySpectrum'; +import RmsEnergySpectrumPage from '@/pages/QuickLook/RmsEnergySpectrum'; +import LagEnergySpectrumPage from '@/pages/QuickLook/LagEnergySpectrum'; +import ExcessVarianceSpectrumPage from '@/pages/QuickLook/ExcessVarianceSpectrum'; + +// Utilities Pages +import StatisticalFunctionsPage from '@/pages/Utilities/StatisticalFunctions'; +import GTIPage from '@/pages/Utilities/GTI'; +import IOPage from '@/pages/Utilities/IO'; +import MissionIOPage from '@/pages/Utilities/MissionIO'; +import MiscPage from '@/pages/Utilities/Misc'; + +// Modeling Pages +import ModelBuilderPage from '@/pages/Modeling/ModelBuilder'; +import MLEFittingPage from '@/pages/Modeling/MLEFitting'; +import MCMCFittingPage from '@/pages/Modeling/MCMCFitting'; + +// Pulsar Pages +import PeriodSearchPage from '@/pages/Pulsar/PeriodSearch'; +import PhaseFoldingPage from '@/pages/Pulsar/PhaseFolding'; +import PhaseogramPage from '@/pages/Pulsar/Phaseogram'; + +// Simulator Page +import SimulatorPage from '@/pages/Simulator'; + +// Hooks +import { useJobStream } from '@/hooks/useJobStream'; +import { BackendStatusProvider } from '@/context/BackendContext'; + +// Theme Context +interface ThemeContextType { + darkMode: boolean; + toggleDarkMode: () => void; +} + +export const ThemeContext = createContext({ + darkMode: false, + toggleDarkMode: () => {}, +}); + +export const useThemeContext = (): ThemeContextType => useContext(ThemeContext); + +// Create React Query client +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 1, + }, + }, +}); + +// ─── Shared typography ──────────────────────────────────────────────────────── +const fontDisplay = '"JetBrains Mono", "Fira Code", "Source Code Pro", monospace'; +const fontBody = '"IBM Plex Sans", "Source Sans 3", -apple-system, sans-serif'; + +const sharedTypography = { + fontFamily: fontBody, + h1: { fontFamily: fontDisplay, fontWeight: 700, letterSpacing: '-0.02em' }, + h2: { fontFamily: fontDisplay, fontWeight: 700, letterSpacing: '-0.01em' }, + h3: { fontFamily: fontDisplay, fontWeight: 600, letterSpacing: '-0.01em' }, + h4: { fontFamily: fontDisplay, fontWeight: 600, letterSpacing: '0' }, + h5: { fontFamily: fontDisplay, fontWeight: 500, letterSpacing: '0' }, + h6: { fontFamily: fontDisplay, fontWeight: 500, letterSpacing: '0.01em' }, + subtitle1: { fontFamily: fontBody, fontWeight: 500 }, + subtitle2: { fontFamily: fontBody, fontWeight: 500 }, + body1: { fontFamily: fontBody, fontWeight: 400, lineHeight: 1.6 }, + body2: { fontFamily: fontBody, fontWeight: 400, lineHeight: 1.5 }, + button: { fontFamily: fontBody, fontWeight: 600, letterSpacing: '0.02em', textTransform: 'none' as const }, + caption: { fontFamily: fontBody, fontWeight: 400 }, + overline: { fontFamily: fontDisplay, fontWeight: 500, letterSpacing: '0.1em', textTransform: 'uppercase' as const }, +}; + +const sharedShape = { borderRadius: 8 }; + +// ─── Dark theme (primary / default) ────────────────────────────────────────── +const createDarkTheme = (): Theme => + createTheme({ + palette: { + mode: 'dark', + primary: { main: '#00d4aa', light: '#33e0be', dark: '#00a885', contrastText: '#0a0e1a' }, + secondary: { main: '#3b82f6', light: '#60a5fa', dark: '#2563eb', contrastText: '#ffffff' }, + stingrayGreen: { main: '#5ead61', light: '#8edf91', dark: '#3d7a40', contrastText: '#ffffff' }, + background: { default: '#0a0e1a', paper: '#121829' }, + text: { primary: '#e2e8f0', secondary: '#94a3b8', disabled: '#475569' }, + divider: 'rgba(148, 163, 184, 0.12)', + success: { main: '#22c55e', light: '#4ade80', dark: '#16a34a' }, + warning: { main: '#f59e0b', light: '#fbbf24', dark: '#d97706' }, + error: { main: '#ef4444', light: '#f87171', dark: '#dc2626' }, + info: { main: '#3b82f6', light: '#60a5fa', dark: '#2563eb' }, + }, + typography: sharedTypography, + shape: sharedShape, + transitions: { + easing: { easeInOut: 'cubic-bezier(0.22, 0.61, 0.36, 1)' }, + }, + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + transition: 'background-color 0.3s cubic-bezier(0.22, 0.61, 0.36, 1)', + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', + transition: 'background-color 0.2s ease, box-shadow 0.2s ease', + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + background: 'rgba(18, 24, 41, 0.6)', + backdropFilter: 'blur(12px) saturate(150%)', + WebkitBackdropFilter: 'blur(12px) saturate(150%)', + border: '1px solid rgba(148, 163, 184, 0.12)', + transition: 'border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1)', + '&:hover': { + borderColor: 'rgba(0, 212, 170, 0.3)', + boxShadow: '0 0 20px rgba(0, 212, 170, 0.1), 0 8px 32px rgba(0, 0, 0, 0.3)', + }, + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + borderRadius: 8, + transition: 'all 0.2s ease', + }, + contained: { + boxShadow: '0 2px 8px rgba(0, 212, 170, 0.15)', + '&:hover': { + boxShadow: '0 4px 20px rgba(0, 212, 170, 0.25), 0 0 40px rgba(0, 212, 170, 0.1)', + }, + }, + outlined: { + borderColor: 'rgba(148, 163, 184, 0.2)', + '&:hover': { + borderColor: '#00d4aa', + boxShadow: '0 0 12px rgba(0, 212, 170, 0.15)', + }, + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: 'rgba(0, 212, 170, 0.08)', + boxShadow: '0 0 12px rgba(0, 212, 170, 0.12)', + }, + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + fontFamily: fontBody, + fontWeight: 500, + }, + outlined: { + borderColor: 'rgba(148, 163, 184, 0.2)', + }, + filled: { + backgroundColor: 'rgba(0, 212, 170, 0.12)', + }, + }, + }, + MuiTextField: { + styleOverrides: { + root: { + '& .MuiOutlinedInput-root': { + transition: 'box-shadow 0.2s ease', + '&.Mui-focused': { + boxShadow: '0 0 0 3px rgba(0, 212, 170, 0.12)', + }, + }, + }, + }, + }, + MuiAppBar: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + backgroundImage: 'none', + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + background: 'rgba(18, 24, 41, 0.85)', + backdropFilter: 'blur(16px) saturate(150%)', + WebkitBackdropFilter: 'blur(16px) saturate(150%)', + border: '1px solid rgba(148, 163, 184, 0.12)', + }, + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + fontFamily: fontBody, + fontSize: '0.75rem', + backgroundColor: 'rgba(18, 24, 41, 0.9)', + backdropFilter: 'blur(8px)', + border: '1px solid rgba(148, 163, 184, 0.12)', + }, + }, + }, + MuiAlert: { + styleOverrides: { + root: { + backdropFilter: 'blur(8px)', + border: '1px solid', + }, + standardSuccess: { + backgroundColor: 'rgba(34, 197, 94, 0.1)', + borderColor: 'rgba(34, 197, 94, 0.2)', + }, + standardWarning: { + backgroundColor: 'rgba(245, 158, 11, 0.1)', + borderColor: 'rgba(245, 158, 11, 0.2)', + }, + standardError: { + backgroundColor: 'rgba(239, 68, 68, 0.1)', + borderColor: 'rgba(239, 68, 68, 0.2)', + }, + standardInfo: { + backgroundColor: 'rgba(59, 130, 246, 0.1)', + borderColor: 'rgba(59, 130, 246, 0.2)', + }, + }, + }, + MuiDivider: { + styleOverrides: { + root: { + borderColor: 'rgba(148, 163, 184, 0.08)', + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + borderRadius: 6, + margin: '1px 6px', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: 'rgba(0, 212, 170, 0.06)', + }, + '&.Mui-selected': { + backgroundColor: 'rgba(0, 212, 170, 0.1)', + '&:hover': { + backgroundColor: 'rgba(0, 212, 170, 0.14)', + }, + }, + }, + }, + }, + MuiTab: { + styleOverrides: { + root: { + fontFamily: fontBody, + fontWeight: 500, + textTransform: 'none', + }, + }, + }, + MuiTableHead: { + styleOverrides: { + root: { + '& .MuiTableCell-head': { + fontFamily: fontDisplay, + fontWeight: 500, + fontSize: '0.75rem', + letterSpacing: '0.05em', + textTransform: 'uppercase', + backgroundColor: 'rgba(0, 212, 170, 0.04)', + borderBottom: '1px solid rgba(148, 163, 184, 0.12)', + }, + }, + }, + }, + MuiTableCell: { + styleOverrides: { + root: { + borderBottom: '1px solid rgba(148, 163, 184, 0.06)', + }, + }, + }, + MuiLinearProgress: { + styleOverrides: { + root: { + borderRadius: 4, + backgroundColor: 'rgba(0, 212, 170, 0.08)', + }, + }, + }, + MuiMenu: { + styleOverrides: { + paper: { + background: 'rgba(18, 24, 41, 0.9)', + backdropFilter: 'blur(12px) saturate(150%)', + WebkitBackdropFilter: 'blur(12px) saturate(150%)', + border: '1px solid rgba(148, 163, 184, 0.12)', + }, + }, + }, + MuiPopover: { + styleOverrides: { + paper: { + background: 'rgba(18, 24, 41, 0.9)', + backdropFilter: 'blur(12px) saturate(150%)', + WebkitBackdropFilter: 'blur(12px) saturate(150%)', + border: '1px solid rgba(148, 163, 184, 0.12)', + }, + }, + }, + }, + }); + +// ─── Light theme (refined Observatory Light) ───────────────────────────────── +const createLightTheme = (): Theme => + createTheme({ + palette: { + mode: 'light', + primary: { main: '#0d9b7a', light: '#00d4aa', dark: '#087a60', contrastText: '#ffffff' }, + secondary: { main: '#2563eb', light: '#3b82f6', dark: '#1d4ed8', contrastText: '#ffffff' }, + stingrayGreen: { main: '#4a9a4d', light: '#5ead61', dark: '#2e7d32', contrastText: '#ffffff' }, + background: { default: '#f0f2f5', paper: '#ffffff' }, + text: { primary: '#1e293b', secondary: '#64748b', disabled: '#94a3b8' }, + divider: 'rgba(30, 41, 59, 0.12)', + success: { main: '#16a34a', light: '#22c55e', dark: '#15803d' }, + warning: { main: '#d97706', light: '#f59e0b', dark: '#b45309' }, + error: { main: '#dc2626', light: '#ef4444', dark: '#b91c1c' }, + info: { main: '#2563eb', light: '#3b82f6', dark: '#1d4ed8' }, + }, + typography: sharedTypography, + shape: sharedShape, + transitions: { + easing: { easeInOut: 'cubic-bezier(0.22, 0.61, 0.36, 1)' }, + }, + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + transition: 'background-color 0.3s cubic-bezier(0.22, 0.61, 0.36, 1)', + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', + transition: 'background-color 0.2s ease, box-shadow 0.2s ease', + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + background: 'rgba(255, 255, 255, 0.7)', + backdropFilter: 'blur(12px) saturate(150%)', + WebkitBackdropFilter: 'blur(12px) saturate(150%)', + border: '1px solid rgba(30, 41, 59, 0.08)', + transition: 'border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1)', + '&:hover': { + borderColor: 'rgba(13, 155, 122, 0.3)', + boxShadow: '0 4px 24px rgba(13, 155, 122, 0.08), 0 8px 32px rgba(0, 0, 0, 0.06)', + }, + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + borderRadius: 8, + transition: 'all 0.2s ease', + }, + contained: { + boxShadow: '0 2px 8px rgba(13, 155, 122, 0.15)', + '&:hover': { + boxShadow: '0 4px 20px rgba(13, 155, 122, 0.2)', + }, + }, + outlined: { + borderColor: 'rgba(30, 41, 59, 0.2)', + '&:hover': { + borderColor: '#0d9b7a', + boxShadow: '0 0 12px rgba(13, 155, 122, 0.1)', + }, + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: 'rgba(13, 155, 122, 0.08)', + }, + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + fontFamily: fontBody, + fontWeight: 500, + }, + outlined: { + borderColor: 'rgba(30, 41, 59, 0.2)', + }, + filled: { + backgroundColor: 'rgba(13, 155, 122, 0.1)', + }, + }, + }, + MuiTextField: { + styleOverrides: { + root: { + '& .MuiOutlinedInput-root': { + transition: 'box-shadow 0.2s ease', + '&.Mui-focused': { + boxShadow: '0 0 0 3px rgba(13, 155, 122, 0.1)', + }, + }, + }, + }, + }, + MuiAppBar: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + backgroundImage: 'none', + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + background: 'rgba(255, 255, 255, 0.9)', + backdropFilter: 'blur(16px) saturate(150%)', + WebkitBackdropFilter: 'blur(16px) saturate(150%)', + border: '1px solid rgba(30, 41, 59, 0.08)', + }, + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + fontFamily: fontBody, + fontSize: '0.75rem', + backgroundColor: 'rgba(30, 41, 59, 0.9)', + border: '1px solid rgba(30, 41, 59, 0.12)', + }, + }, + }, + MuiAlert: { + styleOverrides: { + root: { + border: '1px solid', + }, + standardSuccess: { + backgroundColor: 'rgba(22, 163, 74, 0.08)', + borderColor: 'rgba(22, 163, 74, 0.2)', + }, + standardWarning: { + backgroundColor: 'rgba(217, 119, 6, 0.08)', + borderColor: 'rgba(217, 119, 6, 0.2)', + }, + standardError: { + backgroundColor: 'rgba(220, 38, 38, 0.08)', + borderColor: 'rgba(220, 38, 38, 0.2)', + }, + standardInfo: { + backgroundColor: 'rgba(37, 99, 235, 0.08)', + borderColor: 'rgba(37, 99, 235, 0.2)', + }, + }, + }, + MuiDivider: { + styleOverrides: { + root: { + borderColor: 'rgba(30, 41, 59, 0.08)', + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + borderRadius: 6, + margin: '1px 6px', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: 'rgba(13, 155, 122, 0.06)', + }, + '&.Mui-selected': { + backgroundColor: 'rgba(13, 155, 122, 0.1)', + '&:hover': { + backgroundColor: 'rgba(13, 155, 122, 0.14)', + }, + }, + }, + }, + }, + MuiTab: { + styleOverrides: { + root: { + fontFamily: fontBody, + fontWeight: 500, + textTransform: 'none', + }, + }, + }, + MuiTableHead: { + styleOverrides: { + root: { + '& .MuiTableCell-head': { + fontFamily: fontDisplay, + fontWeight: 500, + fontSize: '0.75rem', + letterSpacing: '0.05em', + textTransform: 'uppercase', + backgroundColor: 'rgba(13, 155, 122, 0.04)', + borderBottom: '1px solid rgba(30, 41, 59, 0.12)', + }, + }, + }, + }, + MuiTableCell: { + styleOverrides: { + root: { + borderBottom: '1px solid rgba(30, 41, 59, 0.06)', + }, + }, + }, + MuiLinearProgress: { + styleOverrides: { + root: { + borderRadius: 4, + backgroundColor: 'rgba(13, 155, 122, 0.08)', + }, + }, + }, + MuiMenu: { + styleOverrides: { + paper: { + background: 'rgba(255, 255, 255, 0.95)', + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + border: '1px solid rgba(30, 41, 59, 0.08)', + }, + }, + }, + MuiPopover: { + styleOverrides: { + paper: { + background: 'rgba(255, 255, 255, 0.95)', + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + border: '1px solid rgba(30, 41, 59, 0.08)', + }, + }, + }, + }, + }); + +// Router configuration +const router = createHashRouter([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'data-ingestion', element: }, + + // QuickLook routes + { path: 'quicklook/event-list', element: }, + { path: 'quicklook/light-curve', element: }, + { path: 'quicklook/power-spectrum', element: }, + { path: 'quicklook/avg-power-spectrum', element: }, + { path: 'quicklook/cross-spectrum', element: }, + { path: 'quicklook/avg-cross-spectrum', element: }, + { path: 'quicklook/dynamical-power-spectrum', element: }, + { path: 'quicklook/coherence', element: }, + { path: 'quicklook/time-lags', element: }, + { path: 'quicklook/cross-correlation', element: }, + { path: 'quicklook/auto-correlation', element: }, + { path: 'quicklook/dead-time-corrections', element: }, + { path: 'quicklook/bispectrum', element: }, + { path: 'quicklook/power-colors', element: }, + { path: 'quicklook/covariance-spectrum', element: }, + { path: 'quicklook/avg-covariance-spectrum', element: }, + { path: 'quicklook/variable-energy-spectrum', element: }, + { path: 'quicklook/rms-energy-spectrum', element: }, + { path: 'quicklook/lag-energy-spectrum', element: }, + { path: 'quicklook/excess-variance-spectrum', element: }, + + // Utilities routes + { path: 'utilities/statistical-functions', element: }, + { path: 'utilities/gti', element: }, + { path: 'utilities/io', element: }, + { path: 'utilities/mission-io', element: }, + { path: 'utilities/misc', element: }, + + // Modeling routes + { path: 'modeling/builder', element: }, + { path: 'modeling/mle', element: }, + { path: 'modeling/mcmc', element: }, + + // Pulsar routes + { path: 'pulsar/search', element: }, + { path: 'pulsar/folding', element: }, + { path: 'pulsar/phaseogram', element: }, + + // Simulator + { path: 'simulator', element: }, + + // 404 + { path: '*', element: }, + ], + }, +]); + +/** + * Component that initializes the job stream SSE connection. + * Must be inside BackendContext.Provider to access backend state. + */ +const JobStreamInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => { + useJobStream(); + return <>{children}; +}; + +// Main App Component +const App: React.FC = () => { + const [darkMode, setDarkMode] = useState(() => { + const saved = localStorage.getItem('darkMode'); + return saved !== null ? JSON.parse(saved) : true; + }); + + // Toggle dark mode + const toggleDarkMode = (): void => { + setDarkMode((prev) => { + const newValue = !prev; + localStorage.setItem('darkMode', JSON.stringify(newValue)); + return newValue; + }); + }; + + // Theme memoization + const theme = useMemo(() => (darkMode ? createDarkTheme() : createLightTheme()), [darkMode]); + + return ( + + + + + + + + + + + + + ); +}; + +export default App; diff --git a/src/api/archiveApi.test.ts b/src/api/archiveApi.test.ts new file mode 100644 index 0000000..b6b5e6b --- /dev/null +++ b/src/api/archiveApi.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { archiveApi } from './archiveApi'; + +function sseResponse(events: string[]) { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${event}\n\n`)); + } + controller.close(); + }, + }); + return { + ok: true, + status: 200, + statusText: 'OK', + body: stream, + }; +} + +describe('archiveApi secure download stream', () => { + beforeEach(() => { + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { getBackendPort: vi.fn().mockResolvedValue(8765) }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('posts the exact destination grant and forwards cancellation', async () => { + const fetchMock = vi.fn().mockResolvedValue( + sseResponse([ + JSON.stringify({ + type: 'complete', + file_name: 'download.evt', + size_bytes: 12, + sha256: 'a'.repeat(64), + warnings: [], + file_path: '/must/not/survive/parsing', + destination_grant: 'must-not-survive-parsing', + }), + ]) + ); + vi.stubGlobal('fetch', fetchMock); + const abortController = new AbortController(); + + const events = []; + for await (const event of archiveApi.downloadToDiskSSE({ + url: 'https://heasarc.gsfc.nasa.gov/FTP/nicer/download.evt', + destination_path: '/native/download.evt', + destination_grant: 'write-grant', + signal: abortController.signal, + })) { + events.push(event); + } + + expect(events).toEqual([ + { + type: 'complete', + file_name: 'download.evt', + size_bytes: 12, + sha256: 'a'.repeat(64), + warnings: [], + }, + ]); + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:8765/api/archive/download-to-disk', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: abortController.signal, + }) + ); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ + url: 'https://heasarc.gsfc.nasa.gov/FTP/nicer/download.evt', + destination_path: '/native/download.evt', + destination_grant: 'write-grant', + }); + }); + + it('rejects malformed completion data instead of treating a path as a filename', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + sseResponse([ + JSON.stringify({ + type: 'complete', + file_name: '/unverified/private/file.evt', + size_bytes: 12, + sha256: 'a'.repeat(64), + warnings: [], + }), + ]) + ) + ); + + const consume = async () => { + for await (const _event of archiveApi.downloadToDiskSSE({ + url: 'https://heasarc.gsfc.nasa.gov/FTP/nicer/download.evt', + destination_path: '/native/download.evt', + destination_grant: 'write-grant', + })) { + // Exhaust the stream. + } + }; + + await expect(consume()).rejects.toThrow('Malformed download progress response'); + }); +}); diff --git a/src/api/archiveApi.ts b/src/api/archiveApi.ts new file mode 100644 index 0000000..3c03428 --- /dev/null +++ b/src/api/archiveApi.ts @@ -0,0 +1,393 @@ +/** + * API functions for HEASARC archive operations + */ + +import { apiClient, ApiResponse } from './client'; + +// Types + +/** Supported HEASARC catalog information */ +export interface HeasarcCatalog { + id: string; + catalog: string; + display_name: string; + description: string; +} + +/** HEASARC observation result */ +export interface HeasarcObservation { + obsid: string; + name: string; + ra: number | null; + dec: number | null; + exposure: number | null; + time: string; + catalog: string; + // Mission-specific fields + prnb?: string; // RXTE proposal number + // Swift instrument-specific exposures + xrt_exposure?: number | null; + bat_exposure?: number | null; + uvot_exposure?: number | null; + // IXPE per-detector-unit exposures + exposure_du1?: number | null; + exposure_du2?: number | null; + exposure_du3?: number | null; + // NuSTAR-specific fields + exposure_b?: number | null; // FPMB exposure (seconds) + observation_mode?: string; // "SCIENCE" or "SLEW" + issue_flag?: number | null; // 0=OK, 1=known issues + // NICER-specific fields + processing_status?: string; + num_fpm?: number | null; + // XMM-Newton-specific fields (per-instrument data only via ObsID/ADQL search) + pn_time?: number | null; // EPIC-PN exposure (seconds) + pn_mode?: string; // EPIC-PN observation mode + mos1_time?: number | null; // EPIC-MOS1 exposure (seconds) + mos1_mode?: string; // EPIC-MOS1 observation mode + mos2_time?: number | null; // EPIC-MOS2 exposure (seconds) + mos2_mode?: string; // EPIC-MOS2 observation mode + xmm_status?: string; // "archived" or "scheduled" + data_in_heasarc?: string; // "Y" or "N" + // Chandra-specific fields + detector?: string; // "ACIS-I", "ACIS-S", "HRC-I", "HRC-S" + grating?: string; // "NONE", "HETG", "LETG" + chandra_status?: string; // "archived", "observed", "scheduled", etc. +} + +/** Search result from HEASARC */ +export interface SearchResult { + observations: HeasarcObservation[]; + count: number; + mission: string; + radius?: number; + // For name search + source_name?: string; + resolved_ra?: number; + resolved_dec?: number; + // For coordinate search + ra?: number; + dec?: number; + // For obsid search + obsid?: string; +} + +/** Download URLs for an observation */ +export interface ObservationUrls { + urls: Record; + mission: string; + obsid: string; +} + +// File Browser types +export type FileType = 'event' | 'calibration' | 'auxiliary' | 'log' | 'directory' | 'other'; + +export interface FileEntry { + path: string; + name: string; + is_directory: boolean; + file_type: FileType; + size_bytes: number | null; + size_display: string; + full_url: string; + children?: FileEntry[]; +} + +export interface ListFilesResponse { + base_url: string; + files: FileEntry[]; + mission: string; + obsid: string; + total_files: number; +} + +// Download to disk SSE event types +export interface DownloadToDiskProgressEvent { + type: 'progress'; + bytes_downloaded: number; + total_bytes: number; + percent: number; +} + +export interface DownloadToDiskCompleteEvent { + type: 'complete'; + file_name: string; + size_bytes: number; + sha256: string; + warnings: string[]; +} + +export interface DownloadToDiskErrorEvent { + type: 'error'; + error: string; +} + +export type DownloadToDiskEvent = + | DownloadToDiskProgressEvent + | DownloadToDiskCompleteEvent + | DownloadToDiskErrorEvent; + +const isFiniteNonNegativeNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= 0; + +const parseDownloadEvent = (value: unknown): DownloadToDiskEvent => { + if (!value || typeof value !== 'object') { + throw new Error('Malformed download progress response'); + } + const event = value as Record; + if (event.type === 'progress') { + if ( + !isFiniteNonNegativeNumber(event.bytes_downloaded) || + !isFiniteNonNegativeNumber(event.total_bytes) || + !isFiniteNonNegativeNumber(event.percent) || + event.percent > 100 + ) { + throw new Error('Malformed download progress response'); + } + return { + type: 'progress', + bytes_downloaded: event.bytes_downloaded, + total_bytes: event.total_bytes, + percent: event.percent, + }; + } + if (event.type === 'complete') { + if ( + typeof event.file_name !== 'string' || + event.file_name.length < 1 || + event.file_name.length > 512 || + event.file_name === '.' || + event.file_name === '..' || + event.file_name.includes('/') || + event.file_name.includes('\\') || + Array.from(event.file_name).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 0x20 || codePoint === 0x7f; + }) || + !isFiniteNonNegativeNumber(event.size_bytes) || + typeof event.sha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(event.sha256) || + !Array.isArray(event.warnings) || + event.warnings.some( + (warning) => typeof warning !== 'string' || warning.length > 1_024 + ) + ) { + throw new Error('Malformed download progress response'); + } + return { + type: 'complete', + file_name: event.file_name, + size_bytes: event.size_bytes, + sha256: event.sha256, + warnings: [...(event.warnings as string[])], + }; + } + if ( + event.type === 'error' && + typeof event.error === 'string' && + event.error.length > 0 && + event.error.length <= 1_024 + ) { + return { type: 'error', error: event.error }; + } + throw new Error('Malformed download progress response'); +}; + +// API functions +export const archiveApi = { + /** + * Get list of supported HEASARC catalogs + */ + async getCatalogs(): Promise> { + return apiClient.get('/api/archive/catalogs'); + }, + + /** + * Search HEASARC by source name + */ + async searchByName(params: { + source_name: string; + mission: string; + radius?: number; + max_results?: number; + min_exposure?: number; + start_date?: string; // ISO "YYYY-MM-DD" + end_date?: string; // ISO "YYYY-MM-DD" + }): Promise> { + return apiClient.post('/api/archive/search/name', { + source_name: params.source_name, + mission: params.mission, + radius: params.radius ?? 0.5, + max_results: params.max_results ?? 100, + min_exposure: params.min_exposure, + start_date: params.start_date, + end_date: params.end_date, + }); + }, + + /** + * Search HEASARC by coordinates + */ + async searchByCoordinates(params: { + ra: number; + dec: number; + mission: string; + radius?: number; + max_results?: number; + min_exposure?: number; + start_date?: string; // ISO "YYYY-MM-DD" + end_date?: string; // ISO "YYYY-MM-DD" + }): Promise> { + return apiClient.post('/api/archive/search/coordinates', { + ra: params.ra, + dec: params.dec, + mission: params.mission, + radius: params.radius ?? 0.5, + max_results: params.max_results ?? 100, + min_exposure: params.min_exposure, + start_date: params.start_date, + end_date: params.end_date, + }); + }, + + /** + * Search HEASARC by Observation ID + */ + async searchByObsid(params: { + obsid: string; + mission: string; + }): Promise> { + return apiClient.post('/api/archive/search/obsid', { + obsid: params.obsid, + mission: params.mission, + }); + }, + + /** + * Get download URLs for an observation + */ + async getObservationUrls( + mission: string, + obsid: string + ): Promise> { + return apiClient.get(`/api/archive/observation/${mission}/${obsid}`); + }, + + /** + * List all files in an observation directory + * + * Returns a tree structure of files with metadata including sizes, + * file type classification, and download URLs. + * + * @param params.obs_data - Additional observation data for directory lookup: + * - ra/dec: Coordinates for locate_data query (helps find directory) + * - prnb: RXTE proposal number (required for RXTE) + */ + async listObservationFiles(params: { + mission: string; + obsid: string; + obs_time?: string; + obs_data?: { + ra?: number | null; + dec?: number | null; + prnb?: string; + }; + recursive?: boolean; + max_depth?: number; + }): Promise> { + return apiClient.post('/api/archive/list-files', { + mission: params.mission, + obsid: params.obsid, + obs_time: params.obs_time, + obs_data: params.obs_data, + recursive: params.recursive ?? true, + max_depth: params.max_depth ?? 3, + }); + }, + + /** + * Download a file from URL to local disk with SSE progress streaming. + * + * This function routes the download through the authenticated backend so its + * HEASARC source policy, bounds, and secure publication rules are enforced. + * Progress is streamed as SSE events. + * + * @param params.url - URL to download from (e.g., HEASARC HTTPS URL) + * @param params.destination_path - Exact native-selected destination + * @param params.destination_grant - Short-lived backend-verifiable write grant + * @yields DownloadToDiskEvent - Progress, complete, or error events + */ + async *downloadToDiskSSE(params: { + url: string; + destination_path: string; + destination_grant: string; + signal?: AbortSignal; + }): AsyncGenerator { + const port = await apiClient.getPort(); + const endpointUrl = `http://127.0.0.1:${port}/api/archive/download-to-disk`; + + const response = await fetch(endpointUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: params.url, + destination_path: params.destination_path, + destination_grant: params.destination_grant, + }), + signal: params.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body available for streaming'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE format: "data: {...}\n\n" + const lines = buffer.split('\n\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6); + try { + yield parseDownloadEvent(JSON.parse(jsonStr)); + } catch { + throw new Error('Malformed download progress response'); + } + } + } + } + + // Process remaining buffer + if (buffer.trim() && buffer.startsWith('data: ')) { + const jsonStr = buffer.slice(6).trim(); + if (jsonStr) { + try { + yield parseDownloadEvent(JSON.parse(jsonStr)); + } catch { + throw new Error('Malformed download progress response'); + } + } + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + }, +}; + +export default archiveApi; diff --git a/src/api/backendSessionPolicy.test.ts b/src/api/backendSessionPolicy.test.ts new file mode 100644 index 0000000..7cb2131 --- /dev/null +++ b/src/api/backendSessionPolicy.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + BACKEND_SESSION_HEADER, + isTrustedRendererLocation, + shouldAuthenticateBackendRequest, + withBackendSessionHeader, +} from '../../electron/backendSessionPolicy'; + +const trustedRequest = { + requestUrl: 'http://127.0.0.1:8765/api/status', + method: 'GET', + backendPort: 8765, + requestWebContentsId: 7, + trustedWebContentsId: 7, + isMainFrame: true, + frameUrl: 'http://localhost:5173/#/data-ingestion', + rendererEntryUrl: 'http://localhost:5173/', +}; + +describe('Electron backend session policy', () => { + it('authenticates only exact backend requests from the trusted top-level renderer', () => { + expect(shouldAuthenticateBackendRequest(trustedRequest)).toBe(true); + expect( + shouldAuthenticateBackendRequest({ + ...trustedRequest, + requestUrl: 'http://127.0.0.1:8766/api/status', + }) + ).toBe(false); + expect( + shouldAuthenticateBackendRequest({ + ...trustedRequest, + requestUrl: 'http://localhost:8765/api/status', + }) + ).toBe(false); + expect( + shouldAuthenticateBackendRequest({ ...trustedRequest, requestWebContentsId: 8 }) + ).toBe(false); + expect( + shouldAuthenticateBackendRequest({ ...trustedRequest, isMainFrame: false }) + ).toBe(false); + expect( + shouldAuthenticateBackendRequest({ + ...trustedRequest, + frameUrl: 'https://attacker.example/', + }) + ).toBe(false); + expect( + shouldAuthenticateBackendRequest({ ...trustedRequest, method: 'OPTIONS' }) + ).toBe(false); + }); + + it.each([ + [ + 'macOS', + 'file:///Applications/Stingray%20Explorer/dist/index.html', + 'file://attacker.example/Applications/Stingray%20Explorer/dist/index.html', + ], + [ + 'Windows', + 'file:///C:/Program%20Files/Stingray%20Explorer/dist/index.html', + 'file://attacker.example/C:/Program%20Files/Stingray%20Explorer/dist/index.html', + ], + ])('accepts only the exact %s packaged file entry', (_platform, entry, foreignAuthority) => { + expect(isTrustedRendererLocation(`${entry}#/utilities/io`, entry)).toBe(true); + expect(isTrustedRendererLocation(foreignAuthority, entry)).toBe(false); + expect(isTrustedRendererLocation(`${entry}?source=attacker#/utilities/io`, entry)).toBe( + false + ); + expect( + isTrustedRendererLocation( + entry.replace('index.html', 'other.html'), + entry + ) + ).toBe(false); + }); + + it('removes renderer-supplied session headers before main installs its secret', () => { + const headers = withBackendSessionHeader( + { Accept: 'application/json', 'x-stingray-session': 'attacker-value' }, + 'main-process-secret' + ); + + expect(headers).toEqual({ + Accept: 'application/json', + [BACKEND_SESSION_HEADER]: 'main-process-secret', + }); + expect(withBackendSessionHeader(headers)).toEqual({ Accept: 'application/json' }); + }); +}); diff --git a/src/api/backendStatusStore.test.ts b/src/api/backendStatusStore.test.ts new file mode 100644 index 0000000..82b91bb --- /dev/null +++ b/src/api/backendStatusStore.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { BackendStatusStore } from '../../electron/backendStatus'; + +describe('BackendStatusStore', () => { + it('owns an atomic, monotonically revisioned lifecycle snapshot', () => { + const notify = vi.fn(); + const store = new BackendStatusStore(notify); + + expect(store.getSnapshot()).toEqual({ + revision: 0, + phase: 'stopped', + port: null, + error: null, + }); + + expect(store.publish({ phase: 'starting' })).toEqual({ + revision: 1, + phase: 'starting', + port: null, + error: null, + }); + expect(store.publish({ phase: 'ready', port: 54321 })).toEqual({ + revision: 2, + phase: 'ready', + port: 54321, + error: null, + }); + expect(store.publish({ phase: 'error', error: 'backend failed' })).toEqual({ + revision: 3, + phase: 'error', + port: null, + error: 'backend failed', + }); + + expect(notify).toHaveBeenCalledTimes(3); + expect(notify).toHaveBeenLastCalledWith(store.getSnapshot()); + }); + + it('does not expose its stored snapshot for external mutation', () => { + const store = new BackendStatusStore(() => undefined); + const snapshot = store.publish({ phase: 'ready', port: 49152 }); + snapshot.phase = 'stopped'; + snapshot.port = null; + + expect(store.getSnapshot()).toEqual({ + revision: 1, + phase: 'ready', + port: 49152, + error: null, + }); + }); +}); diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..085bc8c --- /dev/null +++ b/src/api/client.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { apiClient } from './client'; + +describe('apiClient validation errors', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('surfaces actionable FastAPI field details instead of a generic status', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: vi.fn().mockResolvedValue({ + detail: [ + { + loc: ['body', 'probability'], + msg: 'Input should be less than 1', + }, + ], + }), + }) + ); + + const result = await apiClient.post('/api/utilities/statistics/gaussian', { + probability: 2, + }); + + expect(result.success).toBe(false); + expect(result.message).toBe('probability: Input should be less than 1'); + expect(result.error).toBe('probability: Input should be less than 1'); + }); + + it('preserves scientific warnings returned with a non-2xx response', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: vi.fn().mockResolvedValue({ + message: 'RXTE conversion could not run', + error: 'Observation epoch is required', + warnings: [ + 'Approximate conversion was not performed.', + 42, + ], + }), + }) + ); + + const result = await apiClient.post('/api/utilities/mission-io/convert-pi', {}); + + expect(result.success).toBe(false); + expect(result.error).toBe('Observation epoch is required'); + expect(result.warnings).toEqual(['Approximate conversion was not performed.']); + }); + + it('parses authenticated fetch streams without placing credentials in the URL', async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"first"}\n\n')); + controller.enqueue(encoder.encode('data: {"type":"second"}\r\n\r\n')); + controller.close(); + }, + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body: stream, + }); + vi.stubGlobal('fetch', fetchMock); + + const events = []; + for await (const event of apiClient.stream<{ type: string }>('/api/jobs/stream')) { + events.push(event); + } + + expect(events).toEqual([{ type: 'first' }, { type: 'second' }]); + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:8765/api/jobs/stream', + expect.objectContaining({ + method: 'GET', + headers: { Accept: 'text/event-stream' }, + }) + ); + expect(String(fetchMock.mock.calls[0][0])).not.toContain('session'); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..e274b62 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,178 @@ +/** + * API client for communicating with the Python backend + */ + +export interface ApiResponse { + success: boolean; + data: T | null; + message: string; + error: string | null; + /** Non-fatal scientific advisories, including warnings returned with failures. */ + warnings?: string[]; +} + +class ApiClient { + private baseUrl: string = 'http://127.0.0.1:8765'; + private port: number = 8765; + + async setPort(port: number): Promise { + this.port = port; + this.baseUrl = `http://127.0.0.1:${port}`; + } + + async getPort(): Promise { + // Try to get port from Electron IPC + if (window.electronAPI?.getBackendPort) { + try { + const port = await window.electronAPI.getBackendPort(); + if (port && port !== this.port) { + console.log(`[ApiClient] Port updated: ${this.port} -> ${port}`); + await this.setPort(port); + } + } catch (error) { + console.warn('[ApiClient] Failed to get backend port from Electron:', error); + } + } + return this.port; + } + + async request( + endpoint: string, + options: RequestInit = {} + ): Promise> { + // Always sync port before request + await this.getPort(); + const url = `${this.baseUrl}${endpoint}`; + + console.log(`[ApiClient] ${options.method || 'GET'} ${url}`); + + const defaultHeaders: Record = { + 'Content-Type': 'application/json', + }; + + const config: RequestInit = { + ...options, + headers: { + ...defaultHeaders, + ...options.headers, + }, + }; + + try { + const response = await fetch(url, config); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error(`[ApiClient] HTTP ${response.status}:`, errorData); + const validationMessage = Array.isArray(errorData.detail) + ? errorData.detail + .map((issue: { loc?: Array; msg?: string }) => { + const field = issue.loc?.slice(1).join('.') || 'request'; + return `${field}: ${issue.msg || 'invalid value'}`; + }) + .join('; ') + : typeof errorData.detail === 'string' + ? errorData.detail + : null; + return { + success: false, + data: null, + message: errorData.message || validationMessage || `HTTP error: ${response.status}`, + error: errorData.error || validationMessage || response.statusText, + warnings: Array.isArray(errorData.warnings) + ? errorData.warnings.filter( + (warning: unknown): warning is string => typeof warning === 'string' + ) + : undefined, + }; + } + + const data = await response.json(); + console.log(`[ApiClient] Response:`, data.success ? 'success' : 'failed'); + return data; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[ApiClient] Fetch error:`, error); + return { + success: false, + data: null, + message: `Request failed: ${errorMessage}`, + error: errorMessage, + }; + } + } + + async get(endpoint: string): Promise> { + return this.request(endpoint, { method: 'GET' }); + } + + async post(endpoint: string, data?: unknown): Promise> { + return this.request(endpoint, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async delete(endpoint: string): Promise> { + return this.request(endpoint, { method: 'DELETE' }); + } + + /** Stream JSON SSE events through Electron's authenticated network session. */ + async *stream(endpoint: string, signal?: AbortSignal): AsyncGenerator { + await this.getPort(); + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response body available for streaming'); + + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + const events = buffer.split(/\r?\n\r?\n/); + buffer = events.pop() ?? ''; + for (const event of events) { + const data = event + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n'); + if (data) yield JSON.parse(data) as T; + } + if (done) break; + } + + const finalData = buffer + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n'); + if (finalData) yield JSON.parse(finalData) as T; + } finally { + reader.releaseLock(); + } + } + + async healthCheck(): Promise { + try { + const response = await this.get<{ status: string }>('/health'); + return response.success && response.data?.status === 'healthy'; + } catch { + return false; + } + } +} + +// Export singleton instance +export const apiClient = new ApiClient(); + +export default apiClient; diff --git a/src/api/correlationApi.ts b/src/api/correlationApi.ts new file mode 100644 index 0000000..6c810f7 --- /dev/null +++ b/src/api/correlationApi.ts @@ -0,0 +1,73 @@ +/** + * API functions for correlation analysis operations (auto-correlation / cross-correlation) + */ + +import { apiClient, ApiResponse } from './client'; + +// Types + +/** + * Shared response shape for both /api/correlation/auto-correlation and + * /api/correlation/cross-correlation. + * + * Sign convention (cross-correlation only): time_shift > 0 means the first + * event list lags the second; time_shift < 0 means the first list leads. + * For auto-correlation the backend returns 0.0 (the peak is at zero lag by + * construction) -- not null. time_shift is null only when corr contains NaN, + * which can happen on either endpoint with norm='variance'. + * + * dt is the bin size actually used, which is always the dt requested: both + * endpoints bin onto their own grid rather than going through + * EventList.to_lc, which would snap dt to the instrument time resolution. + */ +export interface CorrelationData { + time_lags: Array; + corr: Array; + time_shift: number | null; + dt: number; + n: number; + mode: string; + norm: string; + warnings: string[]; +} + +// API functions +export const correlationApi = { + /** + * Compute the auto-correlation of a single event list. + */ + async autoCorrelation(params: { + event_list_name: string; + dt: number; + mode?: string; + norm?: string; + }): Promise> { + return apiClient.post('/api/correlation/auto-correlation', { + event_list_name: params.event_list_name, + dt: params.dt, + mode: params.mode ?? 'same', + norm: params.norm ?? 'none', + }); + }, + + /** + * Compute the cross-correlation between two event lists. + */ + async crossCorrelation(params: { + event_list_1_name: string; + event_list_2_name: string; + dt: number; + mode?: string; + norm?: string; + }): Promise> { + return apiClient.post('/api/correlation/cross-correlation', { + event_list_1_name: params.event_list_1_name, + event_list_2_name: params.event_list_2_name, + dt: params.dt, + mode: params.mode ?? 'same', + norm: params.norm ?? 'none', + }); + }, +}; + +export default correlationApi; diff --git a/src/api/dataApi.ts b/src/api/dataApi.ts new file mode 100644 index 0000000..6133d08 --- /dev/null +++ b/src/api/dataApi.ts @@ -0,0 +1,758 @@ +/** + * API functions for EventList data operations + */ + +import { apiClient, ApiResponse } from './client'; + +// Types + +/** Validation check result from data quality checks */ +export interface ValidationIssue { + type: string; + name?: string; + description?: string; + status: 'pass' | 'fail' | 'skip'; + severity: 'error' | 'warning' | 'pass' | 'skip'; + message: string; + count?: number; + total?: number; +} + +/** Per-GTI rate information */ +export interface PerGtiRate { + start: number; + stop: number; + events: number; + duration: number; + rate: number; +} + +/** FITS header information extracted from the event file */ +export interface FitsHeaderInfo { + object?: string; + obs_id?: string; + ra_nom?: number; + dec_nom?: number; + ra_obj?: number; + dec_obj?: number; + exposure?: number; + ontime?: number; + livetime?: number; + date_obs?: string; + date_end?: string; + tstart?: number; + tstop?: number; + creator?: string; + telescop?: string; + instrume?: string; + datamode?: string; + observer?: string; + raw_header?: Record; +} + +export interface EventListSummary { + name: string; + n_events: number; + time_range: [number, number]; + has_energy?: boolean; + has_pi?: boolean; + gti_count?: number; + gti_warnings?: string[] | null; + stingray_warnings?: string[] | null; + validation_issues?: ValidationIssue[] | null; + notes?: string | null; +} + +export interface EventListInfo extends EventListSummary { + duration: number; + mjdref: number | null; + // GTI details + gti_list?: [number, number][]; + total_gti_time?: number; + // Energy/PI range + energy_range?: [number, number]; + pi_range?: [number, number]; + // Mission metadata + mission?: string; + instrument?: string; + // Time statistics + mean_count_rate?: number; + min_time_diff?: number; + max_time_diff?: number; + mean_time_diff?: number; + median_time_diff?: number; + std_time_diff?: number; + // Per-GTI rates + per_gti_rates?: PerGtiRate[]; +} + +export interface MemoryInfo { + total_mb: number; + available_mb: number; + used_mb: number; + percent: number; + process_mb: number; +} + +export interface FileSizeInfo { + file_size_bytes: number; + file_size_mb: number; + file_size_gb: number; + risk_level: 'safe' | 'caution' | 'risky' | 'critical'; + recommend_lazy: boolean; + estimated_memory_mb?: number; + ram_usage_percent?: number; + memory_info?: MemoryInfo; +} + +// Lazy loading types +export interface LazyLoadingInfo { + method: 'time_range' | 'event_count'; + // For time_range method + requested_range?: [number, number]; + actual_range?: [number, number]; + loaded_duration?: number; + // For event_count method + start_index?: number; + end_index?: number; + events_requested?: number; + events_loaded?: number; + // Common fields + total_file_duration: number; + total_file_events: number; + events_loaded_percent: number; +} + +export interface EventListLazyLoadedSummary extends EventListSummary { + lazy_loading_info?: LazyLoadingInfo; + // These are inherited from EventListSummary but explicitly listed for clarity: + // validation_issues?: ValidationIssue[] | null; + // notes?: string | null; +} + +export interface LoadingRecommendation { + can_load_full: boolean; + recommend_lazy: boolean; + suggested_chunk_size: number | null; + suggested_time_chunk: number | null; + strategy: 'full' | 'time_range' | 'event_count'; +} + +export interface FileMetadata { + file_size_mb: number; + file_size_gb: number; + risk_level: 'safe' | 'caution' | 'risky' | 'critical'; + total_events: number; + time_range: [number | null, number | null]; + duration: number; + gti_count: number; + total_gti_time: number | null; + gti_list: [number, number][] | null; + mjdref: number | null; + mission: string | null; + instrument: string | null; + available_columns: string[]; + recommended_loading: LoadingRecommendation; +} + +export type EventInputFormat = 'ogip' | 'fits' | 'hdf5' | 'ascii.ecsv'; + +export interface GrantedEventFile { + file_path: string; + file_grant: string; +} + +type OptionalRmfGrant = + | { rmf_file: string; rmf_grant: string } + | { rmf_file?: never; rmf_grant?: never }; + +type OptionalSharedRmfGrant = + | { shared_rmf_file: string; shared_rmf_grant: string } + | { shared_rmf_file?: never; shared_rmf_grant?: never }; + +// Batch loading types +export type SingleFileConfig = GrantedEventFile & OptionalRmfGrant & { + name: string; + fmt?: EventInputFormat; + additional_columns?: string[]; + high_precision?: boolean; + skip_checks?: boolean; + use_partial_loading?: boolean; + partial_mode?: 'time_range' | 'event_count'; + time_range_start?: number; + time_range_end?: number; + event_start_index?: number; + event_count?: number; + notes?: string; +}; + +export type BatchLoadRequest = OptionalSharedRmfGrant & { + files: SingleFileConfig[]; + use_same_settings: boolean; + // Shared settings (used when use_same_settings=true) + shared_fmt?: EventInputFormat; + shared_additional_columns?: string[]; + shared_high_precision?: boolean; + shared_skip_checks?: boolean; + shared_use_partial_loading?: boolean; + shared_partial_mode?: 'time_range' | 'event_count'; + shared_time_range_start?: number; + shared_time_range_end?: number; + shared_event_start_index?: number; + shared_event_count?: number; +}; + +export type LoadEventListParams = GrantedEventFile & + OptionalRmfGrant & { + name: string; + fmt?: EventInputFormat; + additional_columns?: string[]; + high_precision?: boolean; + skip_checks?: boolean; + notes?: string; + }; + +export type LoadEventListFromUrlParams = OptionalRmfGrant & { + url: string; + name: string; + fmt?: EventInputFormat; + additional_columns?: string[]; + high_precision?: boolean; + skip_checks?: boolean; + notes?: string; +}; + +export interface BatchLoadSuccessItem { + name: string; + data: EventListSummary | null; + message?: string; +} + +export interface BatchLoadFailedItem { + name: string; + error: string; +} + +export interface BatchLoadSummary { + total_files: number; + success_count: number; + failure_count: number; + total_events_loaded: number; + total_time_ms: number; + workers_used: number; +} + +export interface BatchLoadResult { + successful: BatchLoadSuccessItem[]; + failed: BatchLoadFailedItem[]; + summary: BatchLoadSummary; +} + +export interface BatchFileSizeInfo { + file_name: string; + size_mb: number; + estimated_ram_mb: number; + ram_percent: number; + risk_level: 'safe' | 'caution' | 'risky' | 'critical'; + error?: string; +} + +export interface BatchSizeTotals { + size_mb: number; + estimated_ram_mb: number; + ram_percent: number; + risk_level: 'safe' | 'caution' | 'risky' | 'critical'; +} + +export interface BatchSizeResult { + files: BatchFileSizeInfo[]; + total: BatchSizeTotals; + available_ram_mb: number; + file_count: number; + recommend_partial_loading: boolean; +} + +// SSE Streaming types for batch loading +export interface BatchStreamEventFileComplete { + type: 'file_complete'; + name: string; + success: boolean; + completed: number; + total: number; + data?: EventListSummary; + error?: string; +} + +export interface BatchStreamEventComplete { + type: 'complete'; + total_time_ms: number; + success_count: number; + failure_count: number; + total_events: number; + workers_used: number; +} + +export interface BatchStreamEventError { + type: 'error'; + error: string; +} + +export type BatchStreamEvent = + | BatchStreamEventFileComplete + | BatchStreamEventComplete + | BatchStreamEventError; + +// URL Download SSE Streaming types +export interface UrlDownloadProgressEvent { + type: 'progress'; + bytes_downloaded: number; + total_bytes: number; + percent: number; +} + +export interface UrlDownloadProcessingEvent { + type: 'processing'; + message: string; +} + +export interface UrlDownloadCompleteEvent { + type: 'complete'; + data: EventListSummary; + message: string; +} + +export interface UrlDownloadErrorEvent { + type: 'error'; + error: string; +} + +export type UrlDownloadStreamEvent = + | UrlDownloadProgressEvent + | UrlDownloadProcessingEvent + | UrlDownloadCompleteEvent + | UrlDownloadErrorEvent; + +export interface EventListFullPreview { + name: string; + // Core data + times_preview: number[]; + n_events: number; + time_range: [number, number]; + duration: number; + // Energy data + has_energy: boolean; + energy_preview: number[] | null; + energy_range: [number, number] | null; + // PI data + has_pi: boolean; + pi_preview: number[] | null; + pi_range: [number, number] | null; + // GTI data + gti_count: number; + gti_list: [number, number][] | null; + total_gti_time: number | null; + // Reference time + mjdref: number | null; + // Metadata + mission: string | null; + instrument: string | null; + detector_id: string | null; + ephem: string | null; + timeref: string | null; + timesys: string | null; + // Statistics + mean_count_rate: number | null; + min_time_diff: number | null; + max_time_diff: number | null; + mean_time_diff?: number | null; + // Enhanced time statistics + median_time_diff?: number | null; + std_time_diff?: number | null; + // Per-GTI rates + per_gti_rates?: PerGtiRate[] | null; + // Additional columns + additional_columns: string[]; + // User notes + notes?: string | null; + // Data validation + validation_issues?: ValidationIssue[] | null; + // FITS header information + header_info?: FitsHeaderInfo | null; +} + +// API functions +export const dataApi = { + /** + * Load an EventList from a file + */ + async loadEventList(params: LoadEventListParams): Promise> { + return apiClient.post('/api/data/load', { + file_path: params.file_path, + file_grant: params.file_grant, + name: params.name, + fmt: params.fmt || 'ogip', + rmf_file: params.rmf_file, + rmf_grant: params.rmf_grant, + additional_columns: params.additional_columns, + high_precision: params.high_precision || false, + skip_checks: params.skip_checks || false, + notes: params.notes, + }); + }, + + /** + * Load an EventList from a URL + */ + async loadEventListFromUrl( + params: LoadEventListFromUrlParams + ): Promise> { + return apiClient.post('/api/data/load-url', { + url: params.url, + name: params.name, + fmt: params.fmt || 'ogip', + rmf_file: params.rmf_file, + rmf_grant: params.rmf_grant, + additional_columns: params.additional_columns, + high_precision: params.high_precision || false, + skip_checks: params.skip_checks || false, + notes: params.notes, + }); + }, + + /** + * Load an EventList from a URL with SSE streaming for progress updates. + * + * This function returns an async generator that yields UrlDownloadStreamEvent + * objects as the download progresses. This allows the UI to show real-time + * download progress. + * + * @param params - URL loading parameters + * @yields UrlDownloadStreamEvent - Progress, processing, complete, or error events + */ + async *loadEventListFromUrlSSE( + params: LoadEventListFromUrlParams, + signal?: AbortSignal + ): AsyncGenerator { + const port = await apiClient.getPort(); + const url = `http://127.0.0.1:${port}/api/data/load-url-stream`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: params.url, + name: params.name, + fmt: params.fmt || 'ogip', + rmf_file: params.rmf_file, + rmf_grant: params.rmf_grant, + additional_columns: params.additional_columns, + high_precision: params.high_precision || false, + skip_checks: params.skip_checks || false, + notes: params.notes, + }), + signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body available for streaming'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE format: "data: {...}\n\n" + const lines = buffer.split('\n\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6); + try { + const event = JSON.parse(jsonStr) as UrlDownloadStreamEvent; + yield event; + } catch (parseError) { + console.error('Failed to parse SSE event:', parseError, jsonStr); + } + } + } + } + + // Process remaining buffer + if (buffer.trim() && buffer.startsWith('data: ')) { + const jsonStr = buffer.slice(6).trim(); + if (jsonStr) { + try { + const event = JSON.parse(jsonStr) as UrlDownloadStreamEvent; + yield event; + } catch (parseError) { + console.error('Failed to parse final SSE event:', parseError, jsonStr); + } + } + } + } finally { + reader.releaseLock(); + } + }, + + /** + * Delete an EventList from state + */ + async deleteEventList(name: string): Promise> { + return apiClient.delete(`/api/data/${encodeURIComponent(name)}`); + }, + + /** + * Get information about an EventList + */ + async getEventListInfo(name: string): Promise> { + return apiClient.get(`/api/data/${encodeURIComponent(name)}`); + }, + + /** + * List all loaded EventLists + */ + async listEventLists(): Promise> { + return apiClient.get('/api/data/'); + }, + + /** + * Check file size and get loading recommendations + */ + async checkFileSize(file: GrantedEventFile): Promise> { + return apiClient.post('/api/data/check-size', file); + }, + + /** + * Clear all loaded EventLists from memory + */ + async clearAllEventLists(): Promise> { + return apiClient.delete('/api/data/'); + }, + + /** + * Get full preview of an EventList with all attributes + */ + async getEventListFullPreview( + name: string, + timeLimit?: number + ): Promise> { + const params = timeLimit ? `?time_limit=${timeLimit}` : ''; + return apiClient.get(`/api/data/${encodeURIComponent(name)}/full-preview${params}`); + }, + + // ========================================================================= + // TRUE LAZY LOADING API FUNCTIONS + // These use FITSTimeseriesReader for genuine lazy/streaming I/O + // ========================================================================= + + /** + * Load events within a specific time range using true lazy loading. + * Only reads the events within the time window from disk. + */ + async loadEventListByTimeRange(params: { + file_path: string; + file_grant: string; + name: string; + start_time: number; + end_time: number; + fmt?: EventInputFormat; + notes?: string; + }): Promise> { + return apiClient.post('/api/data/load-by-time-range', { + file_path: params.file_path, + file_grant: params.file_grant, + name: params.name, + start_time: params.start_time, + end_time: params.end_time, + fmt: params.fmt || 'ogip', + notes: params.notes, + }); + }, + + /** + * Load a specific number of events using true lazy loading. + * Only reads the requested event range from disk. + */ + async loadEventListByEventCount(params: { + file_path: string; + file_grant: string; + name: string; + start_index?: number; + count?: number; + fmt?: EventInputFormat; + notes?: string; + }): Promise> { + return apiClient.post('/api/data/load-by-event-count', { + file_path: params.file_path, + file_grant: params.file_grant, + name: params.name, + start_index: params.start_index ?? 0, + count: params.count ?? 10000, + fmt: params.fmt || 'ogip', + notes: params.notes, + }); + }, + + /** + * Get file metadata without loading the full data. + * Returns event count, time range, GTI, and loading recommendations. + */ + async getFileMetadata(params: { + file_path: string; + file_grant: string; + fmt?: EventInputFormat; + }): Promise> { + return apiClient.post('/api/data/metadata', { + file_path: params.file_path, + file_grant: params.file_grant, + fmt: params.fmt || 'ogip', + }); + }, + + // ========================================================================= + // BATCH LOADING API FUNCTIONS + // Load multiple files in parallel + // ========================================================================= + + /** + * Check sizes of multiple files and estimate total memory usage. + * Returns per-file and total memory estimates with risk levels. + */ + async checkBatchFileSize(files: GrantedEventFile[]): Promise> { + return apiClient.post('/api/data/check-batch-size', { files }); + }, + + /** + * Load multiple EventLists in parallel using threads. + * + * @param params.files - Array of file configurations + * @param params.use_same_settings - If true, use shared_* settings for all files + * @param params.shared_* - Shared settings applied when use_same_settings=true + */ + async loadBatchEventLists( + params: BatchLoadRequest + ): Promise> { + return apiClient.post('/api/data/load-batch', { + files: params.files, + use_same_settings: params.use_same_settings, + shared_fmt: params.shared_fmt || 'ogip', + shared_rmf_file: params.shared_rmf_file, + shared_rmf_grant: params.shared_rmf_grant, + shared_additional_columns: params.shared_additional_columns, + shared_high_precision: params.shared_high_precision || false, + shared_skip_checks: params.shared_skip_checks || false, + shared_use_partial_loading: params.shared_use_partial_loading || false, + shared_partial_mode: params.shared_partial_mode || 'time_range', + shared_time_range_start: params.shared_time_range_start, + shared_time_range_end: params.shared_time_range_end, + shared_event_start_index: params.shared_event_start_index, + shared_event_count: params.shared_event_count, + }); + }, + + /** + * Load multiple EventLists with SSE streaming for real-time progress. + * + * This function returns an async generator that yields BatchStreamEvent + * objects as each file completes loading. This allows the UI to update + * immediately when each file finishes rather than waiting for all files. + * + * @param params - Same parameters as loadBatchEventLists + * @yields BatchStreamEvent - Events for each file completion and final summary + */ + async *loadBatchEventListsSSE( + params: BatchLoadRequest, + signal?: AbortSignal + ): AsyncGenerator { + const port = await apiClient.getPort(); + const url = `http://127.0.0.1:${port}/api/data/load-batch-stream`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + files: params.files, + use_same_settings: params.use_same_settings, + shared_fmt: params.shared_fmt || 'ogip', + shared_rmf_file: params.shared_rmf_file, + shared_rmf_grant: params.shared_rmf_grant, + shared_additional_columns: params.shared_additional_columns, + shared_high_precision: params.shared_high_precision || false, + shared_skip_checks: params.shared_skip_checks || false, + shared_use_partial_loading: params.shared_use_partial_loading || false, + shared_partial_mode: params.shared_partial_mode || 'time_range', + shared_time_range_start: params.shared_time_range_start, + shared_time_range_end: params.shared_time_range_end, + shared_event_start_index: params.shared_event_start_index, + shared_event_count: params.shared_event_count, + }), + signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body available for streaming'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE format: "data: {...}\n\n" + const lines = buffer.split('\n\n'); + buffer = lines.pop() || ''; // Keep incomplete chunk + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6); + try { + const event = JSON.parse(jsonStr) as BatchStreamEvent; + yield event; + } catch (parseError) { + console.error('Failed to parse SSE event:', parseError, jsonStr); + } + } + } + } + + // Process any remaining data in the buffer + if (buffer.trim() && buffer.startsWith('data: ')) { + const jsonStr = buffer.slice(6).trim(); + if (jsonStr) { + try { + const event = JSON.parse(jsonStr) as BatchStreamEvent; + yield event; + } catch (parseError) { + console.error('Failed to parse final SSE event:', parseError, jsonStr); + } + } + } + } finally { + reader.releaseLock(); + } + }, +}; + +export default dataApi; diff --git a/src/api/deadtimeApi.ts b/src/api/deadtimeApi.ts new file mode 100644 index 0000000..dcb6def --- /dev/null +++ b/src/api/deadtimeApi.ts @@ -0,0 +1,93 @@ +/** + * API functions for dead-time correction operations (model-based PDS + * correction and two-detector FAD correction). + */ + +import { apiClient, ApiResponse } from './client'; + +// Types + +export interface PdsCorrectionData { + freq: number[]; + power_uncorrected: Array; + power_corrected: Array; + rate: number; + n_events: number; + exposure: number; + n_segments: number; + dt: number; + segment_size: number; + dead_time: number; + background_rate: number; + limit_k: number; + norm: string; + warnings: string[]; +} + +export interface FadCorrectionData { + freq: number[]; + pds1: Array; + pds2: Array; + ptot: Array; + /** Magnitude of the complex corrected cross spectrum. */ + cs: Array; + /** Signed cospectrum (real part of the complex cross spectrum). */ + cs_real: Array; + n_segments: number; + dt: number; + segment_size: number; + norm: string; + smoothing_length: number; + is_compliant: boolean; + fad_delta: number; + warnings: string[]; +} + +// API functions +export const deadtimeApi = { + /** + * Model-based dead-time correction of an averaged power spectrum + * (Zhang+95 correction). Normalization is hard-locked to Leahy server-side. + */ + async pdsCorrection(params: { + event_list_name: string; + dt: number; + segment_size: number; + dead_time: number; + background_rate?: number; + limit_k?: number; + }): Promise> { + return apiClient.post('/api/deadtime/pds-correction', { + event_list_name: params.event_list_name, + dt: params.dt, + segment_size: params.segment_size, + dead_time: params.dead_time, + background_rate: params.background_rate ?? 0, + limit_k: params.limit_k ?? 200, + }); + }, + + /** + * Frequency-Amplitude-Determined (FAD) dead-time correction between two + * independent, simultaneous event lists (different detectors). + */ + async fadCorrection(params: { + event_list_1_name: string; + event_list_2_name: string; + dt: number; + segment_size: number; + norm?: string; + smoothing_length?: number | null; + }): Promise> { + return apiClient.post('/api/deadtime/fad-correction', { + event_list_1_name: params.event_list_1_name, + event_list_2_name: params.event_list_2_name, + dt: params.dt, + segment_size: params.segment_size, + norm: params.norm ?? 'frac', + smoothing_length: params.smoothing_length ?? null, + }); + }, +}; + +export default deadtimeApi; diff --git a/src/api/fileGrantResponse.test.ts b/src/api/fileGrantResponse.test.ts new file mode 100644 index 0000000..c325562 --- /dev/null +++ b/src/api/fileGrantResponse.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_FILE_GRANT_RESPONSE_BYTES, + parseFileGrantResponse, +} from '../../electron/fileGrantResponse'; + +describe('native file grant response policy', () => { + it('returns only the validated path and grant', () => { + expect( + parseFileGrantResponse( + JSON.stringify({ + path: '/data/selected.fits', + grant: 'v2.123.4.5.signature', + ignored: 'not forwarded', + }) + ) + ).toEqual({ path: '/data/selected.fits', grant: 'v2.123.4.5.signature' }); + }); + + it.each([ + ['not JSON', '{'], + ['an array', '[]'], + ['a missing path', JSON.stringify({ grant: 'v2.token' })], + ['an empty grant', JSON.stringify({ path: '/data/file.fits', grant: '' })], + [ + 'a control character in the path', + JSON.stringify({ path: '/data/file.fits\nsecond', grant: 'v2.token' }), + ], + [ + 'a control character in the grant', + JSON.stringify({ path: '/data/file.fits', grant: 'v2.token\tmore' }), + ], + ])('rejects %s', (_description, body) => { + expect(() => parseFileGrantResponse(body)).toThrow(); + }); + + it('rejects an oversized body before parsing it', () => { + expect(() => parseFileGrantResponse('x'.repeat(MAX_FILE_GRANT_RESPONSE_BYTES + 1))).toThrow( + /too large/ + ); + }); +}); diff --git a/src/api/gtiApi.ts b/src/api/gtiApi.ts new file mode 100644 index 0000000..cc16249 --- /dev/null +++ b/src/api/gtiApi.ts @@ -0,0 +1,202 @@ +/** Typed API boundary for the Utilities GTI workbench. */ + +import { apiClient } from './client'; +import type { ApiResponse } from './client'; + +export type GtiTimeReference = 'absolute_mission_time' | 'relative_seconds'; +export type GtiSetOperation = 'intersection' | 'union' | 'append'; + +export interface GtiIntervalRow { + index: number; + start: number; + stop: number; + length_s: number | null; +} + +export interface GtiIntervalPlot { + starts: number[]; + stops: number[]; + interval_indices: number[]; + stride: number; + source_points: number; +} + +export interface GtiIntervalPayload { + intervals: GtiIntervalRow[]; + interval_count: number; + lengths_s: Array; + separations_s: Array; + total_exposure_s: number | null; + overall_time_span_s: number | null; + duty_cycle: number | null; + plot: GtiIntervalPlot; +} + +interface GtiResultBase { + warnings: string[]; + provenance: Record; +} + +interface TimedGtiResult extends GtiResultBase { + time_unit: 's'; + time_reference: GtiTimeReference; +} + +export interface GtiInspectionData extends GtiIntervalPayload, TimedGtiResult { + event_list_name: string; + event_count: number; + gti_status: 'available' | 'missing' | 'empty'; + gti_origin: 'effective_event_list_gti'; + mjdref: number | null; +} + +export interface GtiValidationData extends GtiIntervalPayload, TimedGtiResult { + valid: true; +} + +export interface GtiSetOperationData extends GtiIntervalPayload, TimedGtiResult { + operation: GtiSetOperation; + merge_strategy: string; +} + +export interface GtiBadTimeData extends GtiIntervalPayload, TimedGtiResult { + observation_start: number; + observation_stop: number; + good_exposure_s: number | null; + bad_exposure_s: number | null; +} + +export interface GtiMaskPreviewData extends GtiResultBase { + event_list_name: string; + source_event_count: number; + retained_event_count: number; + rejected_event_count: number; + retained_exposure_s: number | null; + time_unit: 's'; + time_reference: 'absolute_mission_time'; + applied_gtis: GtiIntervalPayload; + mask_preview: { + time: number[]; + retained: boolean[]; + shown: number; + total: number; + truncated: boolean; + }; + plot: { + time: number[]; + retained: Array; + stride: number; + source_points: number; + }; +} + +export interface GtiMaskSaveData extends TimedGtiResult { + source_event_list_name: string; + destination_name: string; + source_event_count: number; + retained_event_count: number; + rejected_event_count: number; + retained_exposure_s: number | null; + applied_gtis: GtiIntervalPayload; +} + +export interface GtiFixedSegmentsData extends GtiIntervalPayload, TimedGtiResult { + segment_size_s: number; + source_exposure_s: number | null; + segmented_exposure_s: number | null; + unused_exposure_s: number | null; +} + +export interface GtiExposureChunk extends GtiIntervalPayload { + chunk_index: number; +} + +export interface GtiExposureSegmentsData extends TimedGtiResult { + exposure_per_chunk_s: number; + new_interval_if_gti_sep_s: number | null; + source_exposure_s: number | null; + output_exposure_s: number | null; + chunk_count: number; + interval_count: number; + chunks: GtiExposureChunk[]; + plot: { + starts: number[]; + stops: number[]; + chunk_indices: number[]; + stride: number; + source_points: number; + }; +} + +export const gtiApi = { + inspect(params: { event_list_name: string }): Promise> { + return apiClient.post('/api/utilities/gti/inspect', params); + }, + + validate(params: { + gtis: [number, number][]; + time_reference: GtiTimeReference; + }): Promise> { + return apiClient.post('/api/utilities/gti/validate', params); + }, + + setOperation(params: { + left_gtis: [number, number][]; + right_gtis: [number, number][]; + operation: GtiSetOperation; + time_reference: GtiTimeReference; + }): Promise> { + return apiClient.post('/api/utilities/gti/set-operation', params); + }, + + badTimeIntervals(params: { + gtis: [number, number][]; + start_time: number; + stop_time: number; + time_reference: GtiTimeReference; + }): Promise> { + return apiClient.post('/api/utilities/gti/bad-time-intervals', params); + }, + + previewMask(params: { + event_list_name: string; + gtis: [number, number][]; + }): Promise> { + return apiClient.post('/api/utilities/gti/mask/preview', params); + }, + + saveMask(params: { + event_list_name: string; + gtis: [number, number][]; + destination_name: string; + }): Promise> { + return apiClient.post('/api/utilities/gti/mask/save', params); + }, + + fixedSegments(params: { + gtis: [number, number][]; + segment_size: number; + time_reference: GtiTimeReference; + }): Promise> { + return apiClient.post('/api/utilities/gti/segment/fixed', params); + }, + + exposureSegments(params: { + gtis: [number, number][]; + exposure_per_chunk: number; + new_interval_if_gti_sep?: number; + time_reference: GtiTimeReference; + }): Promise> { + const payload = { + gtis: params.gtis, + exposure_per_chunk: params.exposure_per_chunk, + time_reference: params.time_reference, + ...(params.new_interval_if_gti_sep === undefined + ? {} + : { new_interval_if_gti_sep: params.new_interval_if_gti_sep }), + }; + return apiClient.post('/api/utilities/gti/segment/exposure', payload); + }, +}; + +export default gtiApi; diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..8204459 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,41 @@ +/** + * API module exports + */ + +export { apiClient, type ApiResponse } from './client'; +export { dataApi, type EventListSummary, type EventListInfo, type FileSizeInfo } from './dataApi'; +export { lightcurveApi, type LightcurveData, type LightcurveSummary } from './lightcurveApi'; +export { + spectrumApi, + type PowerSpectrumData, + type DynamicalPowerSpectrumData, + type SpectrumSummary, +} from './spectrumApi'; +export { + timingApi, + type BispectrumData, + type PowerColorsData, + type TimeLagsData, + type CoherenceData, +} from './timingApi'; +export { correlationApi, type CorrelationData } from './correlationApi'; +export { + varenergyApi, + type RmsSpectrumData, + type LagSpectrumData, + type ExcessVarianceData, + type VarEnergyBand, + type VariableEnergySpectrumData, + type CovarianceSpectrumData, +} from './varenergyApi'; +export { deadtimeApi, type PdsCorrectionData, type FadCorrectionData } from './deadtimeApi'; +export { statisticsApi } from './statisticsApi'; +export { gtiApi } from './gtiApi'; +export { + ioApi, + type ExportableObjectType, + type UtilityExportFormat, + type ExportResult as UtilityIoExportResult, +} from './ioApi'; +export { missionIoApi } from './missionIoApi'; +export { miscApi } from './miscApi'; diff --git a/src/api/ioApi.ts b/src/api/ioApi.ts new file mode 100644 index 0000000..684b84d --- /dev/null +++ b/src/api/ioApi.ts @@ -0,0 +1,209 @@ +import { apiClient, ApiResponse } from './client'; + +const IO_PREFIX = '/api/utilities/io'; + +export interface UtilityMetadata { + warnings: string[]; + provenance: Record; +} + +export interface GrantedPathRequest { + file_path: string; + file_grant: string; +} + +export interface FitsColumnSummary { + name: string; + format: string; + unit: string | null; +} + +export interface FitsHighPrecisionValue { + decimal: string; + stingray_value: string; + source_keywords: Record; +} + +export type FitsMjdReference = FitsHighPrecisionValue; + +export interface FitsTimingSummary { + mjdref: FitsMjdReference | null; + status: 'available' | 'missing' | 'invalid'; + note: string; + keywords: Record; + high_precision_keywords: Record; +} + +export interface FitsHduSummary { + index: number; + name: string; + type: string; + row_count: number | null; + dimensions: number[]; + columns: FitsColumnSummary[]; + timing: FitsTimingSummary; +} + +export interface FileInspectionResult extends UtilityMetadata { + path: string; + filename: string; + extension: string | null; + size_bytes: number; + supported: boolean; + detected_type: 'fits' | 'rmf' | 'unknown'; + hdus: FitsHduSummary[]; +} + +export interface RmfChannelBound { + channel: number; + energy_min: number; + energy_max: number; + energy_midpoint: number; +} + +export interface RmfInspectionResult extends UtilityMetadata { + path: string; + filename: string; + size_bytes: number; + channel_count: number; + channel_min: number; + channel_max: number; + energy_min: number; + energy_max: number; + energy_unit: string | null; + conversion_supported: boolean; + contiguous_channels: boolean; + preview_rows: RmfChannelBound[]; + preview_truncated: boolean; +} + +export interface PiEnergyRow { + index: number; + pi: number; + energy: number; +} + +export interface IoPlotPreview { + arrays: [number[], number[]]; + stride: number; + source_points: number; +} + +export interface PiConversionResult extends UtilityMetadata { + rows: PiEnergyRow[]; + count: number; + energy_unit: string; + plot: IoPlotPreview; +} + +export interface EventListConversionResult extends UtilityMetadata { + source_name: string; + saved: boolean; + saved_name: string | null; + event_count: number; + energy_unit: string; + preview_rows: PiEnergyRow[]; + preview_truncated: boolean; + plot: IoPlotPreview; + pi_preserved: boolean; +} + +export type ExportableObjectType = 'event_list' | 'lightcurve' | 'analysis_result'; +export type UtilityExportFormat = 'fits' | 'csv' | 'ecsv' | 'json' | 'hdf5'; + +export interface ExportableObject { + object_type: ExportableObjectType; + name: string; + row_count: number | null; + exportable: boolean; + formats: UtilityExportFormat[]; + format_reasons?: Partial>; + reason: string | null; +} + +export interface FormatCapability { + supported: boolean; + notes: string; + reason?: string | null; + extensions?: string[]; + dependency?: { + name: string; + available: boolean; + version: string | null; + }; +} + +export interface ExportableObjectsResult { + objects: ExportableObject[]; + capability_matrix: Record< + ExportableObjectType, + Record + >; + format_allowlist: UtilityExportFormat[]; + excluded_formats: Record; + row_cap: number; + provenance: Record; +} + +export interface ExportResult extends UtilityMetadata { + path: string; + bytes: number; + format: UtilityExportFormat; + row_count: number; + object_type: ExportableObjectType; + object_name: string; + verified: boolean; + verification?: { + schema: string; + table_path: string; + semantic_round_trip: boolean; + checks: string[]; + h5py_version: string; + } | null; +} + +export const ioApi = { + inspectFile(params: GrantedPathRequest): Promise> { + return apiClient.post(`${IO_PREFIX}/inspect-file`, params); + }, + + inspectRmf(params: { + rmf_path: string; + rmf_grant: string; + }): Promise> { + return apiClient.post(`${IO_PREFIX}/inspect-rmf`, params); + }, + + convertPi(params: { + pi_values: number[]; + rmf_path: string; + rmf_grant: string; + }): Promise> { + return apiClient.post(`${IO_PREFIX}/convert-pi`, params); + }, + + convertEventList(params: { + event_list_name: string; + rmf_path: string; + rmf_grant: string; + save_as?: string | null; + }): Promise> { + return apiClient.post(`${IO_PREFIX}/convert-event-list`, params); + }, + + listExportableObjects(): Promise> { + return apiClient.get(`${IO_PREFIX}/exportable-objects`); + }, + + exportObject(params: { + object_type: ExportableObjectType; + object_name: string; + format: UtilityExportFormat; + destination_path: string; + destination_grant: string; + }): Promise> { + return apiClient.post(`${IO_PREFIX}/export`, params); + }, +}; + +export default ioApi; diff --git a/src/api/jobApi.ts b/src/api/jobApi.ts new file mode 100644 index 0000000..46d36b9 --- /dev/null +++ b/src/api/jobApi.ts @@ -0,0 +1,153 @@ +/** + * API functions for background job operations. + */ + +import { apiClient, ApiResponse } from './client'; +import type { + Job, + JobStreamEvent, + NameConflictResult, + SubmitLoadJobParams, + SubmitBatchJobParams, + SubmitUrlJobParams, +} from '@/types/job'; + +export const jobApi = { + /** + * Submit a single file load job. + * + * Returns immediately with the job ID. The actual loading happens + * asynchronously in a background thread. + */ + async submitLoadJob(params: SubmitLoadJobParams): Promise> { + return apiClient.post('/api/jobs/submit-load', { + file_path: params.file_path, + file_grant: params.file_grant, + name: params.name, + fmt: params.fmt || 'ogip', + rmf_file: params.rmf_file, + rmf_grant: params.rmf_grant, + additional_columns: params.additional_columns, + high_precision: params.high_precision || false, + skip_checks: params.skip_checks || false, + notes: params.notes, + use_partial_loading: params.use_partial_loading || false, + partial_mode: params.partial_mode || 'time_range', + time_range_start: params.time_range_start, + time_range_end: params.time_range_end, + event_start_index: params.event_start_index, + event_count: params.event_count, + }); + }, + + /** + * Submit a batch load job for multiple files. + * + * Returns immediately with the job ID. The actual loading happens + * asynchronously in a background thread. + */ + async submitBatchJob(params: SubmitBatchJobParams): Promise> { + return apiClient.post('/api/jobs/submit-batch', { + files: params.files, + use_same_settings: params.use_same_settings ?? true, + shared_fmt: params.shared_fmt || 'ogip', + shared_rmf_file: params.shared_rmf_file, + shared_rmf_grant: params.shared_rmf_grant, + shared_additional_columns: params.shared_additional_columns, + shared_high_precision: params.shared_high_precision || false, + shared_skip_checks: params.shared_skip_checks || false, + shared_use_partial_loading: params.shared_use_partial_loading || false, + shared_partial_mode: params.shared_partial_mode || 'time_range', + shared_time_range_start: params.shared_time_range_start, + shared_time_range_end: params.shared_time_range_end, + shared_event_start_index: params.shared_event_start_index, + shared_event_count: params.shared_event_count, + }); + }, + + /** + * Submit a URL download and load job. + * + * Returns immediately with the job ID. The actual download and loading + * happens asynchronously in a background thread. + */ + async submitUrlJob(params: SubmitUrlJobParams): Promise> { + return apiClient.post('/api/jobs/submit-url', { + url: params.url, + name: params.name, + fmt: params.fmt || 'ogip', + rmf_file: params.rmf_file, + rmf_grant: params.rmf_grant, + additional_columns: params.additional_columns, + high_precision: params.high_precision || false, + skip_checks: params.skip_checks || false, + notes: params.notes, + }); + }, + + /** + * List all jobs. + * + * @param includeCompleted - Include completed/failed/cancelled jobs + * @param limit - Maximum number of jobs to return + */ + async listJobs( + includeCompleted: boolean = true, + limit: number = 50 + ): Promise> { + return apiClient.get( + `/api/jobs/?include_completed=${includeCompleted}&limit=${limit}` + ); + }, + + /** + * Get all active (pending or running) jobs. + */ + async getActiveJobs(): Promise> { + return apiClient.get('/api/jobs/active'); + }, + + /** + * Get a specific job by ID. + */ + async getJob(jobId: string): Promise> { + return apiClient.get(`/api/jobs/${jobId}`); + }, + + /** + * Cancel a pending job. + * + * Only pending jobs can be cancelled. Running jobs cannot be interrupted. + */ + async cancelJob(jobId: string): Promise> { + return apiClient.post(`/api/jobs/${jobId}/cancel`); + }, + + /** + * Check if a name conflicts with existing data or pending jobs. + */ + async checkNameConflict(name: string): Promise> { + return apiClient.post('/api/jobs/check-name', { name }); + }, + + /** + * Clear all completed/failed/cancelled jobs. + */ + async clearCompletedJobs(): Promise> { + return apiClient.delete('/api/jobs/completed'); + }, + + /** + * Create an SSE stream for job updates. + * + * This function returns an async generator that yields JobStreamEvent + * objects as jobs are created, updated, and completed. + * + * @yields JobStreamEvent - Events for job updates + */ + async *streamJobUpdates(signal?: AbortSignal): AsyncGenerator { + yield* apiClient.stream('/api/jobs/stream', signal); + }, +}; + +export default jobApi; diff --git a/src/api/legacyBridgeRetirement.test.ts b/src/api/legacyBridgeRetirement.test.ts new file mode 100644 index 0000000..8e8965f --- /dev/null +++ b/src/api/legacyBridgeRetirement.test.ts @@ -0,0 +1,60 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = process.cwd(); + +function productionTypeScriptFiles(directory: string): string[] { + return readdirSync(directory).flatMap((entry) => { + const path = join(directory, entry); + if (statSync(path).isDirectory()) return productionTypeScriptFiles(path); + if (!/\.tsx?$/.test(path) || /\.test\.tsx?$/.test(path)) return []; + return [path]; + }); +} + +describe('raw Electron filesystem bridge retirement', () => { + it('exposes only grant-backed native file selection methods', () => { + const preload = readFileSync(join(repositoryRoot, 'electron/preload.ts'), 'utf8'); + const handlers = readFileSync(join(repositoryRoot, 'electron/ipcHandlers.ts'), 'utf8'); + const declarations = readFileSync( + join(repositoryRoot, 'src/types/electron.d.ts'), + 'utf8' + ); + + for (const rawMethod of [ + 'openFile', + 'saveFile', + 'fileExists', + 'openDirectory', + 'showItemInFolder', + ]) { + expect(preload).not.toMatch(new RegExp(`\\b${rawMethod}\\s*:`)); + expect(declarations).not.toMatch(new RegExp(`\\b${rawMethod}\\s*:`)); + } + + for (const rawChannel of [ + 'dialog:openFile', + 'dialog:saveFile', + 'dialog:openDirectory', + 'file:exists', + 'shell:showItemInFolder', + ]) { + expect(handlers).not.toContain(`'${rawChannel}'`); + expect(preload).not.toContain(`'${rawChannel}'`); + } + + expect(preload).toMatch(/\bopenGrantedFile\s*:/); + expect(preload).toMatch(/\bsaveGrantedFile\s*:/); + }); + + it('has no production renderer call site for a retired raw method', () => { + const rendererSources = productionTypeScriptFiles(join(repositoryRoot, 'src')); + const rawCall = /electronAPI\.(?:openFile|saveFile|fileExists|openDirectory|showItemInFolder)\b/; + + const offenders = rendererSources.filter((source) => + rawCall.test(readFileSync(source, 'utf8')) + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/api/legacyGrantContracts.test.ts b/src/api/legacyGrantContracts.test.ts new file mode 100644 index 0000000..68cc6b5 --- /dev/null +++ b/src/api/legacyGrantContracts.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { + dataApi, + type BatchFileSizeInfo, + type BatchLoadFailedItem, + type BatchLoadSuccessItem, + type BatchStreamEventFileComplete, + type FileMetadata, +} from './dataApi'; +import { jobApi } from './jobApi'; +import type { Job, SubmitBatchJobParams, SubmitLoadJobParams } from '@/types/job'; + +const clientMocks = vi.hoisted(() => ({ + post: vi.fn(), + getPort: vi.fn().mockResolvedValue(8765), +})); + +vi.mock('./client', () => ({ + apiClient: { + post: clientMocks.post, + getPort: clientMocks.getPort, + }, +})); + +const response = { success: true, data: {}, message: '', error: null }; + +describe('legacy native-file grant request contracts', () => { + beforeEach(() => { + clientMocks.post.mockReset().mockResolvedValue(response); + }); + + it('sends a distinct grant for a local source and its optional RMF', async () => { + await dataApi.loadEventList({ + file_path: '/science/events.fits', + file_grant: 'event-grant', + name: 'events', + fmt: 'ogip', + rmf_file: '/calibration/response.rmf', + rmf_grant: 'rmf-grant', + }); + + expect(clientMocks.post).toHaveBeenCalledWith( + '/api/data/load', + expect.objectContaining({ + file_path: '/science/events.fits', + file_grant: 'event-grant', + rmf_file: '/calibration/response.rmf', + rmf_grant: 'rmf-grant', + }) + ); + + await dataApi.loadEventListFromUrl({ + url: 'https://example.test/events.fits', + name: 'remote-events', + rmf_file: '/calibration/response.rmf', + rmf_grant: 'remote-rmf-grant', + }); + expect(clientMocks.post).toHaveBeenLastCalledWith( + '/api/data/load-url', + expect.objectContaining({ + rmf_file: '/calibration/response.rmf', + rmf_grant: 'remote-rmf-grant', + }) + ); + }); + + it('grants size, metadata, partial, and every batch source independently', async () => { + const first = { file_path: '/science/a.fits', file_grant: 'grant-a' }; + const second = { file_path: '/science/b.hdf5', file_grant: 'grant-b' }; + + await dataApi.checkFileSize(first); + await dataApi.getFileMetadata({ ...first, fmt: 'ogip' }); + await dataApi.loadEventListByTimeRange({ + ...first, + name: 'a', + start_time: 0, + end_time: 10, + fmt: 'ogip', + }); + await dataApi.loadEventListByEventCount({ + ...first, + name: 'a-partial', + start_index: 0, + count: 100, + fmt: 'ogip', + }); + await dataApi.checkBatchFileSize([first, second]); + await dataApi.loadBatchEventLists({ + files: [ + { ...first, name: 'a', fmt: 'ogip' }, + { ...second, name: 'b', fmt: 'hdf5' }, + ], + use_same_settings: true, + shared_rmf_file: '/calibration/shared.rmf', + shared_rmf_grant: 'shared-rmf-grant', + }); + + expect(clientMocks.post).toHaveBeenNthCalledWith(1, '/api/data/check-size', first); + expect(clientMocks.post).toHaveBeenNthCalledWith( + 2, + '/api/data/metadata', + expect.objectContaining(first) + ); + expect(clientMocks.post).toHaveBeenNthCalledWith( + 3, + '/api/data/load-by-time-range', + expect.objectContaining(first) + ); + expect(clientMocks.post).toHaveBeenNthCalledWith( + 4, + '/api/data/load-by-event-count', + expect.objectContaining(first) + ); + expect(clientMocks.post).toHaveBeenNthCalledWith(5, '/api/data/check-batch-size', { + files: [first, second], + }); + expect(clientMocks.post).toHaveBeenNthCalledWith( + 6, + '/api/data/load-batch', + expect.objectContaining({ + files: [expect.objectContaining(first), expect.objectContaining(second)], + shared_rmf_grant: 'shared-rmf-grant', + }) + ); + }); + + it('includes source and RMF grants in single, batch, and URL job submissions', async () => { + const single: SubmitLoadJobParams = { + file_path: '/science/events.fits', + file_grant: 'event-grant', + name: 'events', + rmf_file: '/calibration/response.rmf', + rmf_grant: 'rmf-grant', + }; + const batch: SubmitBatchJobParams = { + files: [ + { + file_path: '/science/events.fits', + file_grant: 'event-grant', + name: 'events', + }, + ], + use_same_settings: true, + shared_rmf_file: '/calibration/response.rmf', + shared_rmf_grant: 'shared-rmf-grant', + }; + + await jobApi.submitLoadJob(single); + await jobApi.submitBatchJob(batch); + await jobApi.submitUrlJob({ + url: 'https://example.test/events.fits', + name: 'remote', + rmf_file: '/calibration/response.rmf', + rmf_grant: 'url-rmf-grant', + }); + + expect(clientMocks.post).toHaveBeenNthCalledWith( + 1, + '/api/jobs/submit-load', + expect.objectContaining({ file_grant: 'event-grant', rmf_grant: 'rmf-grant' }) + ); + expect(clientMocks.post).toHaveBeenNthCalledWith( + 2, + '/api/jobs/submit-batch', + expect.objectContaining({ + files: [expect.objectContaining({ file_grant: 'event-grant' })], + shared_rmf_grant: 'shared-rmf-grant', + }) + ); + expect(clientMocks.post).toHaveBeenNthCalledWith( + 3, + '/api/jobs/submit-url', + expect.objectContaining({ rmf_grant: 'url-rmf-grant' }) + ); + }); + + it('models metadata, batch results, and batch SSE as redacted response DTOs', () => { + expectTypeOf().not.toHaveProperty('file_path'); + expectTypeOf().not.toHaveProperty('file_path'); + expectTypeOf().not.toHaveProperty('file_path'); + expectTypeOf().not.toHaveProperty('file_path'); + expectTypeOf().not.toHaveProperty('file_path'); + + const orderedSize: BatchFileSizeInfo = { + file_name: 'shared.fits', + size_mb: 1, + estimated_ram_mb: 2, + ram_percent: 3, + risk_level: 'safe', + }; + const completeEvent: BatchStreamEventFileComplete = { + type: 'file_complete', + name: 'events', + success: true, + completed: 1, + total: 1, + }; + + expect(orderedSize).toEqual(expect.objectContaining({ file_name: 'shared.fits' })); + expect(completeEvent).not.toHaveProperty('file_path'); + }); + + it('models public jobs without request parameters or native authority', () => { + const job: Job = { + id: 'job-id', + type: 'load_batch', + status: 'completed', + progress: 1, + progress_message: 'Complete', + total_items: 1, + completed_items: 1, + created_at: '2026-08-11T00:00:00Z', + started_at: '2026-08-11T00:00:01Z', + completed_at: '2026-08-11T00:00:02Z', + result: { success_count: 1, total_files: 1 }, + error: null, + display_name: 'Batch load', + }; + + expect(job).not.toHaveProperty('params'); + + const assertInvalidContracts = (): void => { + // @ts-expect-error Local jobs require a source grant. + void jobApi.submitLoadJob({ file_path: '/science/a.fits', name: 'a' }); + // @ts-expect-error RMF paths and grants must be supplied together. + void jobApi.submitLoadJob({ + file_path: '/science/a.fits', + file_grant: 'grant-a', + name: 'a', + rmf_file: '/calibration/a.rmf', + }); + void jobApi.submitLoadJob({ + file_path: '/science/a.pkl', + file_grant: 'grant-a', + name: 'a', + // @ts-expect-error Pickle is not an accepted event input format. + fmt: 'pickle', + }); + // @ts-expect-error Public job DTOs never expose submission parameters. + const leakedJob: Job = { ...job, params: { file_grant: 'secret' } }; + void leakedJob; + }; + void assertInvalidContracts; + }); +}); diff --git a/src/api/lightcurveApi.ts b/src/api/lightcurveApi.ts new file mode 100644 index 0000000..0aca4e8 --- /dev/null +++ b/src/api/lightcurveApi.ts @@ -0,0 +1,108 @@ +/** + * API functions for Lightcurve operations + */ + +import { apiClient, ApiResponse } from './client'; + +// Types +export interface LightcurveData { + name: string; + time: number[]; + counts: number[]; + dt: number; + n_bins: number; + plot_stride?: number; + time_range?: [number, number]; + count_rate_mean?: number; + count_stats?: { + mean: number; + std: number; + min: number; + max: number; + }; +} + +export interface LightcurveSummary { + name: string; + n_bins: number; + dt: number; + time_range: [number, number]; +} + +// API functions +export const lightcurveApi = { + /** + * Create a Lightcurve from an EventList + */ + async createFromEventList(params: { + event_list_name: string; + dt: number; + output_name: string; + gti?: number[][]; + max_points?: number; + }): Promise> { + return apiClient.post('/api/lightcurve/from-event-list', { + event_list_name: params.event_list_name, + dt: params.dt, + output_name: params.output_name, + gti: params.gti, + max_points: params.max_points, + }); + }, + + /** + * Create a Lightcurve from arrays + */ + async createFromArrays(params: { + times: number[]; + counts: number[]; + dt: number; + output_name: string; + }): Promise> { + return apiClient.post('/api/lightcurve/from-arrays', params); + }, + + /** + * Rebin a lightcurve + */ + async rebin(params: { + name: string; + rebin_factor: number; + output_name: string; + max_points?: number; + }): Promise> { + return apiClient.post('/api/lightcurve/rebin', { + name: params.name, + rebin_factor: params.rebin_factor, + output_name: params.output_name, + max_points: params.max_points, + }); + }, + + /** + * Get lightcurve data for plotting + */ + async getLightcurveData( + name: string, + maxPoints?: number + ): Promise> { + const query = maxPoints ? `?max_points=${maxPoints}` : ''; + return apiClient.get(`/api/lightcurve/${encodeURIComponent(name)}${query}`); + }, + + /** + * List all loaded lightcurves + */ + async listLightcurves(): Promise> { + return apiClient.get('/api/lightcurve/'); + }, + + /** + * Delete a lightcurve from state + */ + async deleteLightcurve(name: string): Promise> { + return apiClient.delete(`/api/lightcurve/${encodeURIComponent(name)}`); + }, +}; + +export default lightcurveApi; diff --git a/src/api/logApi.ts b/src/api/logApi.ts new file mode 100644 index 0000000..ca94aa8 --- /dev/null +++ b/src/api/logApi.ts @@ -0,0 +1,181 @@ +/** + * Log streaming API client using Server-Sent Events (SSE). + * + * Connects to the backend log stream and pushes log entries to the logStore. + */ + +import { useLogStore } from '@/store/logStore'; +import { apiClient } from './client'; + +/** + * Log entry received from the backend SSE stream. + */ +interface StreamLogEntry { + type: 'log' | 'heartbeat'; + timestamp: string; + level?: 'info' | 'warn' | 'error' | 'debug'; + source?: 'python'; + logger?: string; + message?: string; +} + +/** + * SSE client for real-time log streaming from the backend. + * + * Features: + * - Automatic reconnection with exponential backoff + * - Heartbeat handling to detect stale connections + * - Integration with Zustand logStore + */ +class LogStreamClient { + private abortController: AbortController | null = null; + private connected: boolean = false; + private reconnectAttempts: number = 0; + private maxReconnectAttempts: number = 10; + private reconnectTimer: ReturnType | null = null; + private isConnecting: boolean = false; + + /** + * Connect to the log stream SSE endpoint. + * + * Will automatically reconnect on connection loss with exponential backoff. + */ + async connect(): Promise { + // Avoid duplicate connections + if (this.abortController || this.isConnecting) { + console.log('[LogStreamClient] Already connected or connecting'); + return; + } + + this.isConnecting = true; + const controller = new AbortController(); + this.abortController = controller; + + try { + console.log('[LogStreamClient] Connecting to authenticated log stream'); + let opened = false; + for await (const data of apiClient.stream( + '/api/logs/stream', + controller.signal + )) { + if (!opened) { + opened = true; + this.connected = true; + this.reconnectAttempts = 0; + this.isConnecting = false; + useLogStore.getState().addLog({ + level: 'info', + source: 'frontend', + message: 'Connected to Python log stream', + }); + } + if (data.type === 'log' && data.level && data.message) { + useLogStore.getState().addLog({ + level: data.level, + source: data.source || 'python', + message: data.message, + }); + } + } + if (!controller.signal.aborted) { + console.warn('[LogStreamClient] Stream ended, will attempt reconnect'); + this.handleDisconnect(); + } + } catch (error) { + if (controller.signal.aborted) return; + console.error('[LogStreamClient] Failed to connect:', error); + this.isConnecting = false; + this.handleDisconnect(); + } + } + + /** + * Disconnect from the log stream. + */ + disconnect(): void { + console.log('[LogStreamClient] Disconnecting'); + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + this.abortController?.abort(); + this.abortController = null; + this.connected = false; + + this.reconnectAttempts = 0; + this.isConnecting = false; + } + + /** + * Check if currently connected to the log stream. + */ + isConnected(): boolean { + return this.connected; + } + + /** + * Handle disconnection and schedule reconnect. + */ + private handleDisconnect(): void { + this.abortController?.abort(); + this.abortController = null; + this.connected = false; + + this.scheduleReconnect(); + } + + /** + * Schedule a reconnection attempt with exponential backoff. + */ + private scheduleReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('[LogStreamClient] Max reconnect attempts reached, giving up'); + useLogStore.getState().addLog({ + level: 'error', + source: 'frontend', + message: 'Log stream connection failed after maximum retries', + }); + return; + } + + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s (capped at 32s) + const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 32000); + this.reconnectAttempts++; + + console.log( + `[LogStreamClient] Scheduling reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms` + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } +} + +// Export singleton instance +export const logStreamClient = new LogStreamClient(); + +/** + * Log API namespace for direct function access. + */ +export const logApi = { + /** + * Connect to the log stream. + */ + connect: (): Promise => logStreamClient.connect(), + + /** + * Disconnect from the log stream. + */ + disconnect: (): void => logStreamClient.disconnect(), + + /** + * Check if connected to the log stream. + */ + isConnected: (): boolean => logStreamClient.isConnected(), +}; + +export default logApi; diff --git a/src/api/miscApi.ts b/src/api/miscApi.ts new file mode 100644 index 0000000..f95b776 --- /dev/null +++ b/src/api/miscApi.ts @@ -0,0 +1,307 @@ +/** Typed API surface for the curated public `stingray.utils` workbench. */ + +import { apiClient, type ApiResponse } from './client'; + +export type NullableNumber = number | null; + +export interface UtilityProvenance { + operation: string; + input_source: { + kind: 'installed_runtime' | 'pasted_values' | 'parameters' | 'pasted_matrix' | 'event_list'; + name?: string; + }; + parameters: Record; + stingray_version: string; + window_allowlist_source?: string; + supported_window_types?: string[]; + source_snapshot?: boolean; + uncertainty_compatibility?: { + workaround_applied: boolean; + reference: string; + input_to_stingray: 'variance' | 'standard_uncertainty' | null; + }; +} + +export interface UtilityData { + warnings: string[]; + provenance: UtilityProvenance; +} + +export interface NamedPlotPreview { + values: Record; + stride: number; + source_points: number; +} + +export interface MiscCapabilities extends UtilityData { + window_types: string[]; + rebin: { + modes: Array<'linear' | 'logarithmic'>; + linear_methods: Array<'sum' | 'mean'>; + logarithmic_method: 'mean'; + linear_uncertainty_workaround_required: boolean; + linear_uncertainty_workaround_reference: string; + linear_uncertainty_support: string; + }; + baseline_defaults: { + lambda: number; + asymmetry: number; + iterations: number; + offset_correction: boolean; + }; + runtime_advisories: { + nearest_power_of_two: string; + }; + limits: { + max_array_values: number; + max_exact_output_values: number; + max_matrix_cells: number; + max_baseline_iterations: number; + max_fft_samples: number; + max_poisson_count: number; + max_energy_ranges: number; + }; +} + +export interface RebinSeries { + x: NullableNumber[]; + y: NullableNumber[]; + y_error: NullableNumber[] | null; +} + +export interface RebinnedSeries extends RebinSeries { + samples_per_bin: NullableNumber[] | number | null; +} + +export interface RebinData extends UtilityData { + mode: 'linear' | 'logarithmic'; + method: 'sum' | 'mean'; + original: RebinSeries; + rebinned: RebinnedSeries; + error_semantics: string | null; + units: Record; + plot_preview: { + original: NamedPlotPreview; + rebinned: NamedPlotPreview; + }; +} + +export interface BaselineData extends UtilityData { + x: NullableNumber[]; + original: NullableNumber[]; + baseline: NullableNumber[]; + corrected: NullableNumber[]; + units: Record; + plot_preview: NamedPlotPreview; +} + +export interface WindowSummary { + minimum: NullableNumber; + maximum: NullableNumber; + sum: NullableNumber; + mean: NullableNumber; + rms: NullableNumber; + energy: NullableNumber; + coherent_gain: NullableNumber; + equivalent_noise_bandwidth_bins: NullableNumber; +} + +export interface WindowData extends UtilityData { + window_type: string; + n_samples: number; + sample_index: NullableNumber[]; + window: NullableNumber[]; + units: Record; + summary: WindowSummary; + plot_preview: NamedPlotPreview; +} + +export interface OptimalBinTimeData extends UtilityData { + requested_bin_time: number; + adjusted_bin_time: number; + sample_count: number; + delta: number; + fractional_change: number; + changed: boolean; + units: string; +} + +export interface NearestPowerOfTwoData extends UtilityData { + requested_value: number; + nearest_power_of_two: number; + delta: number; + fractional_change: number; + changed: boolean; + units: string; +} + +export interface SegmentSizeData extends UtilityData { + requested_segment_size: number; + adjusted_segment_size: number; + sample_count: number; + delta: number; + fractional_change: number; + changed: boolean; + units: string; +} + +export interface PoissonErrorData extends UtilityData { + counts: NullableNumber[]; + symmetric_error: NullableNumber[]; + confidence_sigma: number; + units: Record; + assumptions: string; + plot_preview: NamedPlotPreview; +} + +export interface StandardErrorData extends UtilityData { + mean: NullableNumber[]; + calculated_sample_mean: NullableNumber[]; + standard_error: NullableNumber[]; + sample_count: number; + column_count: number; + mean_source: 'calculated_arithmetic_mean' | 'provided'; + units: Record; + assumptions: string; + plot_preview: NamedPlotPreview; +} + +export interface EnergyRangesData extends UtilityData { + bin_edges: NullableNumber[]; + counts: NullableNumber[]; + n_ranges: number; + selected_count: number; + excluded_count: number; + energy_min: number; + energy_max: number; + energy_unit: string; + plot_preview: NamedPlotPreview; +} + +export interface LinearRebinParams { + x: number[]; + y: number[]; + dx_new: number; + y_error?: number[] | null; + method: 'sum' | 'mean'; + dx?: number | null; +} + +export interface LogarithmicRebinParams { + x: number[]; + y: number[]; + factor: number; + y_error?: number[] | null; + dx?: number | null; +} + +export interface BaselineParams { + x: number[]; + y: number[]; + lam: number; + asymmetry: number; + iterations: number; + offset_correction: boolean; +} + +interface EnergyRangesCommonParams { + n_ranges: number; + energy_min?: number | null; + energy_max?: number | null; + energy_unit: string; +} + +export type EnergyRangesParams = EnergyRangesCommonParams & ( + | { energies: number[]; event_list_name?: never } + | { energies?: never; event_list_name: string } +); + +export const miscApi = { + async capabilities(): Promise> { + return apiClient.get('/api/utilities/misc/capabilities'); + }, + + async linearRebin(params: LinearRebinParams): Promise> { + return apiClient.post('/api/utilities/misc/rebin/linear', { + x: params.x, + y: params.y, + dx_new: params.dx_new, + y_error: params.y_error ?? null, + method: params.method, + dx: params.dx ?? null, + }); + }, + + async logarithmicRebin(params: LogarithmicRebinParams): Promise> { + return apiClient.post('/api/utilities/misc/rebin/logarithmic', { + x: params.x, + y: params.y, + factor: params.factor, + y_error: params.y_error ?? null, + dx: params.dx ?? null, + }); + }, + + async estimateBaseline(params: BaselineParams): Promise> { + return apiClient.post('/api/utilities/misc/baseline', params); + }, + + async generateWindow(params: { + n_samples: number; + window_type: string; + }): Promise> { + return apiClient.post('/api/utilities/misc/window', params); + }, + + async optimalBinTime(params: { + fft_length: number; + proposed_bin_time: number; + }): Promise> { + return apiClient.post('/api/utilities/misc/sampling/optimal-bin-time', params); + }, + + async nearestPowerOfTwo(params: { + value: number; + }): Promise> { + return apiClient.post('/api/utilities/misc/sampling/nearest-power-of-two', params); + }, + + async adjustSegmentSize(params: { + segment_size: number; + dt: number; + tolerance: number; + }): Promise> { + return apiClient.post('/api/utilities/misc/sampling/segment-size', params); + }, + + async poissonErrors(params: { + counts: number[]; + }): Promise> { + return apiClient.post('/api/utilities/misc/errors/poisson', params); + }, + + async standardError(params: { + samples: number[][]; + mean?: number[] | null; + }): Promise> { + return apiClient.post('/api/utilities/misc/errors/standard', { + samples: params.samples, + mean: params.mean ?? null, + }); + }, + + async equalCountEnergyRanges( + params: EnergyRangesParams + ): Promise> { + return apiClient.post('/api/utilities/misc/energy-ranges', { + n_ranges: params.n_ranges, + energies: 'energies' in params ? params.energies : null, + event_list_name: 'event_list_name' in params ? params.event_list_name : null, + energy_min: params.energy_min ?? null, + energy_max: params.energy_max ?? null, + energy_unit: params.energy_unit, + }); + }, +}; + +export default miscApi; diff --git a/src/api/missionIoApi.ts b/src/api/missionIoApi.ts new file mode 100644 index 0000000..3781b19 --- /dev/null +++ b/src/api/missionIoApi.ts @@ -0,0 +1,261 @@ +import { apiClient, type ApiResponse } from './client'; + +export interface MissionSourceField { + value: string | null; + raw_value: string | null; + source: string | null; + source_type: 'event_list_attribute' | 'fits_header' | 'override' | 'missing'; + inferred: boolean; + override: boolean; + database_supported?: boolean; +} + +export interface MissionMapping { + event_hdu: unknown; + gti_hdu: unknown; + time_column: unknown; + energy_or_channel_column: unknown; + detector_column: unknown; + instrument_keyword: unknown; + mode_keyword: unknown; +} + +export interface PreciseCalibration { + method: string; + location: string; +} + +export interface RoughConversionCapability { + status: 'supported' | 'conditional' | 'unsupported'; + approximate: boolean; + dependencies: string[]; + message?: string; + epoch_mjd_domain?: { + minimum_exclusive: number; + maximum_inclusive: number; + } | null; +} + +export interface SpecializedInterpretationCapability { + supported: boolean; + scope: string | null; +} + +export interface MissionCapabilityRow { + mission: string; + mapping: MissionMapping; + instruments: unknown; + modes: unknown; + rough_pi_to_energy: RoughConversionCapability; + specialized_interpretation: SpecializedInterpretationCapability; +} + +export interface MissionCapabilitiesData { + missions: MissionCapabilityRow[]; + mission_count: number; + raw_database_entry_count: number; + database_source: string; + support_note: string; + precise_calibration: PreciseCalibration; + provenance: Record; + warnings: string[]; +} + +export interface MissionInfoData { + mission: string; + requested_mission: string; + mission_name_inferred: boolean; + instrument: string | null; + mode: string | null; + mapping: MissionMapping; + available_instruments: unknown; + available_modes: unknown; + capabilities: { + rough_pi_to_energy: RoughConversionCapability; + specialized_interpretation: SpecializedInterpretationCapability; + }; + precise_calibration: PreciseCalibration; + provenance: Record; + warnings: string[]; +} + +export interface MissionIdentificationData { + source: Record; + mission: MissionSourceField; + instrument: MissionSourceField; + mode: MissionSourceField; + mapping: MissionMapping | null; + timing_metadata?: Record & { + mjdref?: MissionMjdReferenceEntry; + }; + hdus?: Array<{ + index: number; + name: string; + type: string; + rows: number | null; + }>; + provenance: Record; + warnings: string[]; +} + +export interface MissionTimingMetadataEntry { + value: unknown; + source: string | null; +} + +export interface MissionMjdReferenceEntry extends MissionTimingMetadataEntry { + value: number | null; + decimal: string; + components: { + integer: { value: string; source: string | null }; + fraction: { value: string; source: string | null }; + } | null; +} + +export interface ConversionDependency { + required: boolean; + used?: boolean; + value?: string | number | null; + requested_value?: string | number | null; + source?: string | null; +} + +export interface ApproximateConversionData { + label: string; + conversion_type: 'rough_approximate'; + approximate: true; + energy_unit: 'keV'; + mission: MissionSourceField; + instrument: MissionSourceField; + mode: MissionSourceField; + dependencies: Record; + count: number; + rows: Array<{ + index: number; + pi: number; + energy_kev: number | null; + detector_id?: number; + }>; + preview_count: number; + preview_truncated: boolean; + saved_event_list: string | null; + precise_calibration: PreciseCalibration; + provenance: Record; + warnings: string[]; +} + +export interface MissionInterpretationData { + label: string; + mission: MissionSourceField; + instrument: MissionSourceField; + mode: MissionSourceField; + supported_scope: string; + read_only: true; + source_modified: false; + hdu: string; + event_count: number; + changed_count: number; + original_pha_range: [number, number] | null; + interpreted_pha_range: [number, number] | null; + rows: Array<{ + index: number; + original_pha: number; + interpreted_pha: number; + changed: boolean; + }>; + preview_count: number; + preview_truncated: boolean; + provenance: Record; + warnings: string[]; +} + +interface MissionOverrideParams { + mission_override?: string; + instrument_override?: string; + mode_override?: string; +} + +export type IdentifyMissionParams = MissionOverrideParams & ( + | { event_list_name: string; file_path?: never; file_grant?: never } + | { event_list_name?: never; file_path: string; file_grant: string } +); + +interface ConvertPiCommonParams extends MissionOverrideParams { + epoch_mjd?: number; + detector_ids?: number[]; +} + +export type ConvertPiParams = ConvertPiCommonParams & ( + | { pi_values: number[]; event_list_name?: never; save_as?: never } + | { pi_values?: never; event_list_name: string; save_as?: string } +); + +export interface InterpretMissionParams { + file_path: string; + file_grant: string; + mission_override?: string; + instrument_override?: string; + mode_override?: string; +} + +type EnvelopeWithWarnings = ApiResponse & { warnings?: string[] }; + +/** + * Utility services return warnings beside the standard envelope. Fold those + * warnings into data so useAnalysisRunner can preserve and render them with + * the last successful result. + */ +async function includeWarnings( + request: Promise> +): Promise> { + const response = await request; + if (response.data === null) return response as ApiResponse; + const existing = 'warnings' in response.data && Array.isArray(response.data.warnings) + ? response.data.warnings as string[] + : []; + return { + ...response, + data: { + ...response.data, + warnings: existing.length > 0 ? existing : response.warnings ?? [], + }, + }; +} + +export const missionIoApi = { + async getCapabilities(): Promise> { + return includeWarnings( + apiClient.get('/api/utilities/mission-io/capabilities') + ); + }, + + async getMissionInfo(params: { + mission: string; + instrument?: string; + mode?: string; + }): Promise> { + return includeWarnings( + apiClient.post('/api/utilities/mission-io/info', params) + ); + }, + + async identify(params: IdentifyMissionParams): Promise> { + return includeWarnings( + apiClient.post('/api/utilities/mission-io/identify', params) + ); + }, + + async convertPi(params: ConvertPiParams): Promise> { + return includeWarnings( + apiClient.post('/api/utilities/mission-io/convert-pi', params) + ); + }, + + async interpret(params: InterpretMissionParams): Promise> { + return includeWarnings( + apiClient.post('/api/utilities/mission-io/interpret', params) + ); + }, +}; + +export default missionIoApi; diff --git a/src/api/pythonManager.test.ts b/src/api/pythonManager.test.ts new file mode 100644 index 0000000..9aacce3 --- /dev/null +++ b/src/api/pythonManager.test.ts @@ -0,0 +1,253 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + request: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/test-app', + }, +})); +vi.mock('child_process', () => ({ + default: { spawn: mocks.spawn }, + spawn: mocks.spawn, +})); +vi.mock('http', () => ({ + default: { request: mocks.request }, + request: mocks.request, +})); + +import { + DEFAULT_BACKEND_PORT, + parseBackendPortAnnouncement, + PythonManager, +} from '../../electron/pythonManager'; + +class FakeChild extends EventEmitter { + readonly stdout = new EventEmitter(); + readonly stderr = new EventEmitter(); + readonly kill = vi.fn((signal?: string) => { + queueMicrotask(() => this.emit('exit', null, signal ?? 'SIGTERM')); + return true; + }); +} + +function installHttpResponses(statusFor: (options: { port?: number; path?: string }) => number) { + mocks.request.mockImplementation((options: { port?: number; path?: string }, callback) => { + const request = new EventEmitter() as EventEmitter & { + end: () => void; + destroy: () => void; + }; + request.end = () => { + queueMicrotask(() => { + const response = new EventEmitter() as EventEmitter & { + statusCode: number; + resume: () => void; + }; + response.statusCode = statusFor(options); + response.resume = () => undefined; + callback(response); + }); + }; + request.destroy = () => undefined; + return request; + }); +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('PythonManager backend announcement protocol', () => { + it.each([ + ['BACKEND_PORT:1', 1], + ['BACKEND_PORT:8765', 8765], + ['BACKEND_PORT:65535', 65535], + ])('accepts canonical port line %s', (line, expected) => { + expect(parseBackendPortAnnouncement(line)).toBe(expected); + }); + + it.each([ + 'BACKEND_PORT:0', + 'BACKEND_PORT:65536', + 'BACKEND_PORT:+1', + 'BACKEND_PORT:01', + 'BACKEND_PORT:8765 ', + 'BACKEND_PORT:8765', + 'BACKEND_PORT:8765:extra', + ])('rejects malformed port line %s', (line) => { + expect(parseBackendPortAnnouncement(line)).toBeNull(); + }); + + it('does not send credentials to the hostile default port before announcement', async () => { + const child = new FakeChild(); + installHttpResponses((options) => { + if (options.path === '/health') return 404; + if (options.path === '/api/status' && options.port === 54321) return 200; + throw new Error(`unexpected request ${options.path}:${options.port}`); + }); + mocks.spawn.mockImplementation(() => { + setTimeout(() => { + child.stdout.emit('data', Buffer.from('BACKEND_')); + child.stdout.emit('data', Buffer.from('PORT:54321\r\n')); + }, 10); + return child; + }); + + const manager = new PythonManager(); + const start = manager.start(); + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(manager.getPort()).toBe(DEFAULT_BACKEND_PORT); + expect(() => manager.getBackendSessionSecret()).toThrow(); + await start; + + const calls = mocks.request.mock.calls.map(([options]) => options as { + port?: number; + path?: string; + headers?: Record; + }); + expect(calls[0]).toMatchObject({ port: DEFAULT_BACKEND_PORT, path: '/health' }); + expect(calls[0].headers).toBeUndefined(); + expect(calls.filter((options) => options.path === '/health')).toHaveLength(1); + const authenticated = calls.find((options) => options.path === '/api/status'); + expect(authenticated).toMatchObject({ port: 54321 }); + expect(authenticated?.headers).toHaveProperty('X-Stingray-Session'); + expect(manager.getPort()).toBe(54321); + expect(manager.getIsRunning()).toBe(true); + }); + + it('rejects an external backend on the default port without spawning', async () => { + installHttpResponses((options) => (options.path === '/health' ? 200 : 500)); + const manager = new PythonManager(); + + await expect(manager.start()).rejects.toThrow( + 'Electron did not launch it and cannot authenticate it' + ); + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(() => manager.getBackendSessionSecret()).toThrow(); + }); + + it.each(['BACKEND_PORT:0\n', 'BACKEND_PORT:8765\nBACKEND_PORT:8766\n'])( + 'terminates startup for invalid or duplicate announcements', + async (output) => { + const child = new FakeChild(); + installHttpResponses((options) => (options.path === '/health' ? 404 : 500)); + mocks.spawn.mockImplementation(() => { + setTimeout(() => child.stdout.emit('data', Buffer.from(output)), 0); + return child; + }); + + const manager = new PythonManager(); + await expect(manager.start()).rejects.toThrow(); + expect(child.kill).toHaveBeenCalled(); + expect(manager.getPort()).toBe(DEFAULT_BACKEND_PORT); + expect(manager.getIsRunning()).toBe(false); + expect(() => manager.getBackendSessionSecret()).toThrow(); + } + ); + + it('reports an unexpected child exit after authenticated readiness', async () => { + const child = new FakeChild(); + const unexpectedExit = vi.fn(); + installHttpResponses((options) => { + if (options.path === '/health') return 404; + if (options.path === '/api/status' && options.port === 54321) return 200; + return 500; + }); + mocks.spawn.mockImplementation(() => { + setTimeout(() => child.stdout.emit('data', Buffer.from('BACKEND_PORT:54321\n')), 0); + return child; + }); + + const manager = new PythonManager(); + manager.setUnexpectedExitCallback(unexpectedExit); + await manager.start(); + + child.emit('exit', 137, null); + + expect(unexpectedExit).toHaveBeenCalledOnce(); + expect(unexpectedExit).toHaveBeenCalledWith({ code: 137, signal: null }); + expect(manager.getIsRunning()).toBe(false); + expect(manager.getPort()).toBe(DEFAULT_BACKEND_PORT); + }); + + it('does not report a deliberate stop as an unexpected exit', async () => { + const child = new FakeChild(); + const unexpectedExit = vi.fn(); + installHttpResponses((options) => { + if (options.path === '/health') return 404; + if (options.path === '/api/status' && options.port === 54321) return 200; + return 500; + }); + mocks.spawn.mockImplementation(() => { + setTimeout(() => child.stdout.emit('data', Buffer.from('BACKEND_PORT:54321\n')), 0); + return child; + }); + + const manager = new PythonManager(); + manager.setUnexpectedExitCallback(unexpectedExit); + await manager.start(); + await manager.stop(); + + expect(unexpectedExit).not.toHaveBeenCalled(); + expect(manager.getIsRunning()).toBe(false); + }); + + it('does not report the expected stop inside restart and reaches ready again', async () => { + const firstChild = new FakeChild(); + const secondChild = new FakeChild(); + const unexpectedExit = vi.fn(); + installHttpResponses((options) => { + if (options.path === '/health') return 404; + if (options.path === '/api/status') return 200; + return 500; + }); + mocks.spawn + .mockImplementationOnce(() => { + setTimeout( + () => firstChild.stdout.emit('data', Buffer.from('BACKEND_PORT:54321\n')), + 0 + ); + return firstChild; + }) + .mockImplementationOnce(() => { + setTimeout( + () => secondChild.stdout.emit('data', Buffer.from('BACKEND_PORT:54322\n')), + 0 + ); + return secondChild; + }); + + const manager = new PythonManager(); + manager.setUnexpectedExitCallback(unexpectedExit); + await manager.start(); + await manager.restart(); + + expect(unexpectedExit).not.toHaveBeenCalled(); + expect(manager.getIsRunning()).toBe(true); + expect(manager.getPort()).toBe(54322); + await manager.stop(); + }); + + it('leaves pre-readiness startup exits to the start rejection path', async () => { + const child = new FakeChild(); + const unexpectedExit = vi.fn(); + installHttpResponses((options) => (options.path === '/health' ? 404 : 500)); + mocks.spawn.mockImplementation(() => { + setTimeout(() => child.emit('exit', 1, null), 0); + return child; + }); + + const manager = new PythonManager(); + manager.setUnexpectedExitCallback(unexpectedExit); + + await expect(manager.start()).rejects.toThrow( + 'Python backend process exited before becoming ready' + ); + expect(unexpectedExit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/requestContracts.test.ts b/src/api/requestContracts.test.ts new file mode 100644 index 0000000..598f12b --- /dev/null +++ b/src/api/requestContracts.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import type { GaussianRequest } from './statisticsApi'; +import type { ConvertPiParams, IdentifyMissionParams } from './missionIoApi'; +import type { EnergyRangesParams } from './miscApi'; + +describe('utility request type contracts', () => { + it('encode exact-one source and input invariants', () => { + const validRequests = [ + { probability: 0.1, sidedness: 'one-sided' } satisfies GaussianRequest, + { log_probability: -10, sidedness: 'two-sided' } satisfies GaussianRequest, + { event_list_name: 'events' } satisfies IdentifyMissionParams, + { file_path: '/science/events.fits', file_grant: 'grant' } satisfies IdentifyMissionParams, + { pi_values: [1, 2], mission_override: 'nicer' } satisfies ConvertPiParams, + { event_list_name: 'events', save_as: 'derived' } satisfies ConvertPiParams, + { n_ranges: 2, energies: [1, 2], energy_unit: 'keV' } satisfies EnergyRangesParams, + { n_ranges: 2, event_list_name: 'events', energy_unit: 'keV' } satisfies EnergyRangesParams, + ]; + + // @ts-expect-error Gaussian requests require exactly one probability representation. + const missingGaussianInput: GaussianRequest = { sidedness: 'one-sided' }; + // @ts-expect-error Gaussian requests cannot include both probability representations. + const duplicateGaussianInput: GaussianRequest = { + probability: 0.1, + log_probability: -2, + sidedness: 'one-sided', + }; + // @ts-expect-error Mission identification accepts exactly one source. + const duplicateIdentifySource: IdentifyMissionParams = { + event_list_name: 'events', + file_path: '/science/events.fits', + file_grant: 'grant', + }; + // @ts-expect-error Pasted PI conversion cannot save a derived EventList. + const pastedSave: ConvertPiParams = { pi_values: [1], save_as: 'derived' }; + // @ts-expect-error Energy ranges accept exactly one energy source. + const duplicateEnergySource: EnergyRangesParams = { + n_ranges: 2, + energies: [1, 2], + event_list_name: 'events', + energy_unit: 'keV', + }; + + expect(validRequests).toHaveLength(8); + void [ + missingGaussianInput, + duplicateGaussianInput, + duplicateIdentifySource, + pastedSave, + duplicateEnergySource, + ]; + }); +}); diff --git a/src/api/spectrumApi.ts b/src/api/spectrumApi.ts new file mode 100644 index 0000000..75576e8 --- /dev/null +++ b/src/api/spectrumApi.ts @@ -0,0 +1,166 @@ +/** + * API functions for spectrum operations + */ + +import { apiClient, ApiResponse } from './client'; + +// Types +export interface PowerSpectrumData { + name: string | null; + freq: number[]; + power: Array; + power_phase?: Array | null; + norm?: string; + n_freq: number; + df?: number; + freq_range?: [number, number]; + segment_size?: number; + n_segments?: number; +} + +export interface DynamicalPowerSpectrumData { + name: string | null; + freq: number[]; + time: number[]; + dyn_ps: Array>; + norm: string; + segment_size: number; + shape: [number, number]; +} + +export interface SpectrumSummary { + name: string; + type: string; + n_freq: number | null; +} + +// API functions +export const spectrumApi = { + /** + * Create a power spectrum from an EventList + */ + async createPowerSpectrum(params: { + event_list_name: string; + dt: number; + norm?: string; + output_name?: string; + }): Promise> { + return apiClient.post('/api/spectrum/power-spectrum', { + event_list_name: params.event_list_name, + dt: params.dt, + norm: params.norm || 'leahy', + output_name: params.output_name, + }); + }, + + /** + * Create an averaged power spectrum from an EventList + */ + async createAveragedPowerSpectrum(params: { + event_list_name: string; + dt: number; + segment_size: number; + norm?: string; + output_name?: string; + }): Promise> { + return apiClient.post('/api/spectrum/averaged-power-spectrum', { + event_list_name: params.event_list_name, + dt: params.dt, + segment_size: params.segment_size, + norm: params.norm || 'leahy', + output_name: params.output_name, + }); + }, + + /** + * Create a cross spectrum from two EventLists + */ + async createCrossSpectrum(params: { + event_list_1_name: string; + event_list_2_name: string; + dt: number; + norm?: string; + output_name?: string; + }): Promise> { + return apiClient.post('/api/spectrum/cross-spectrum', { + event_list_1_name: params.event_list_1_name, + event_list_2_name: params.event_list_2_name, + dt: params.dt, + norm: params.norm || 'leahy', + output_name: params.output_name, + }); + }, + + /** + * Create an averaged cross spectrum from two EventLists + */ + async createAveragedCrossSpectrum(params: { + event_list_1_name: string; + event_list_2_name: string; + dt: number; + segment_size: number; + norm?: string; + output_name?: string; + }): Promise> { + return apiClient.post('/api/spectrum/averaged-cross-spectrum', { + event_list_1_name: params.event_list_1_name, + event_list_2_name: params.event_list_2_name, + dt: params.dt, + segment_size: params.segment_size, + norm: params.norm || 'leahy', + output_name: params.output_name, + }); + }, + + /** + * Create a dynamical power spectrum from an EventList + */ + async createDynamicalPowerSpectrum(params: { + event_list_name: string; + dt: number; + segment_size: number; + norm?: string; + output_name?: string; + }): Promise> { + return apiClient.post('/api/spectrum/dynamical-power-spectrum', { + event_list_name: params.event_list_name, + dt: params.dt, + segment_size: params.segment_size, + norm: params.norm || 'leahy', + output_name: params.output_name, + }); + }, + + /** + * Rebin a spectrum + */ + async rebinSpectrum(params: { + name: string; + rebin_factor: number; + log?: boolean; + output_name?: string; + }): Promise> { + return apiClient.post('/api/spectrum/rebin', { + name: params.name, + rebin_factor: params.rebin_factor, + log: params.log || false, + output_name: params.output_name, + }); + }, + + /** + * List all loaded spectra + */ + async listSpectra(): Promise> { + return apiClient.get('/api/spectrum/'); + }, + + /** + * Delete a spectrum from state + */ + async deleteSpectrum(name: string): Promise> { + return apiClient.delete(`/api/spectrum/${encodeURIComponent(name)}`); + }, +}; + +export default spectrumApi; diff --git a/src/api/statisticsApi.ts b/src/api/statisticsApi.ts new file mode 100644 index 0000000..7227c86 --- /dev/null +++ b/src/api/statisticsApi.ts @@ -0,0 +1,224 @@ +/** + * Typed API boundary for the explicit statistical utilities exposed by the + * FastAPI backend. Each method maps to one supported Stingray operation; + * there is deliberately no generic function-execution endpoint. + */ + +import { apiClient, ApiResponse } from './client'; + +export type StatisticalSidedness = 'one-sided' | 'two-sided'; +export type TrialDirection = 'single-to-multi' | 'multi-to-single'; + +export interface StatisticsResultBase { + warnings: string[]; + provenance: Record; + units: Record; +} + +export interface GaussianResult extends StatisticsResultBase { + calculation: 'gaussian_significance'; + input_mode: 'probability' | 'log_probability'; + input_probability: number | null; + input_log_probability: number | null; + effective_one_sided_probability: number | null; + effective_one_sided_log_probability: number | null; + sigma: number | null; + sidedness: StatisticalSidedness; + tail: 'upper'; + direction: 'probability_to_gaussian_sigma'; +} + +export interface TrialCorrectionResult extends StatisticsResultBase { + calculation: 'trial_correction'; + direction: TrialDirection; + input_probability: number; + output_probability: number | null; + n_trials: number; + independence_assumption: string; +} + +export interface StatisticEvaluationResult extends StatisticsResultBase { + family: 'pds' | 'z2_n' | 'epoch_folding' | 'phase_dispersion'; + calculation: 'probability'; + observed_statistic: number; + probability: number | null; + log_probability: number | null; + probability_scope: 'overall_post_trial'; + tail: 'upper' | 'lower'; + more_significant_when: 'larger' | 'smaller'; + direction: 'observed_statistic_to_false_alarm_probability'; + n_trials: number; + n_summed_spectra?: number; + n_rebin?: number; + harmonics?: number; + n_phase_bins?: number; + n_samples?: number; +} + +export interface StatisticDetectionResult extends StatisticsResultBase { + family: 'pds' | 'z2_n' | 'epoch_folding' | 'phase_dispersion'; + calculation: 'detection_level'; + detection_level: number | null; + false_alarm_probability: number; + false_alarm_probability_scope: 'overall_post_trial'; + n_trials: number; + tail: 'upper' | 'lower'; + decision_rule: string; + direction: 'false_alarm_probability_to_detection_level'; + n_summed_spectra?: number; + n_rebin?: number; + harmonics?: number; + n_phase_bins?: number; + n_samples?: number; +} + +export type GaussianRequest = { + sidedness: StatisticalSidedness; +} & ( + | { probability: number; log_probability?: never } + | { probability?: never; log_probability: number } +); + +export interface TrialCorrectionRequest { + direction: TrialDirection; + probability: number; + n_trials: number; +} + +export interface PdsEvaluateRequest { + power: number; + n_trials?: number; + n_summed_spectra?: number; + n_rebin?: number; +} + +export interface PdsDetectionRequest { + false_alarm_probability: number; + n_trials?: number; + n_summed_spectra?: number; + n_rebin?: number; +} + +export interface Z2EvaluateRequest { + z2: number; + harmonics?: number; + n_trials?: number; + n_summed_spectra?: number; +} + +export interface Z2DetectionRequest { + false_alarm_probability: number; + harmonics?: number; + n_trials?: number; + n_summed_spectra?: number; +} + +export interface FoldEvaluateRequest { + statistic: number; + n_phase_bins: number; + n_trials?: number; +} + +export interface FoldDetectionRequest { + false_alarm_probability: number; + n_phase_bins: number; + n_trials?: number; +} + +export interface PdmEvaluateRequest { + statistic: number; + n_samples: number; + n_phase_bins: number; + n_trials?: number; +} + +export interface PdmDetectionRequest { + false_alarm_probability: number; + n_samples: number; + n_phase_bins: number; + n_trials?: number; +} + +const PREFIX = '/api/utilities/statistics'; + +export const statisticsApi = { + gaussian(params: GaussianRequest): Promise> { + return apiClient.post(`${PREFIX}/gaussian`, params); + }, + + trials(params: TrialCorrectionRequest): Promise> { + return apiClient.post(`${PREFIX}/trials`, params); + }, + + evaluatePds(params: PdsEvaluateRequest): Promise> { + return apiClient.post(`${PREFIX}/pds/evaluate`, { + power: params.power, + n_trials: params.n_trials ?? 1, + n_summed_spectra: params.n_summed_spectra ?? 1, + n_rebin: params.n_rebin ?? 1, + }); + }, + + detectPds(params: PdsDetectionRequest): Promise> { + return apiClient.post(`${PREFIX}/pds/detection`, { + false_alarm_probability: params.false_alarm_probability, + n_trials: params.n_trials ?? 1, + n_summed_spectra: params.n_summed_spectra ?? 1, + n_rebin: params.n_rebin ?? 1, + }); + }, + + evaluateZ2(params: Z2EvaluateRequest): Promise> { + return apiClient.post(`${PREFIX}/z2/evaluate`, { + z2: params.z2, + harmonics: params.harmonics ?? 2, + n_trials: params.n_trials ?? 1, + n_summed_spectra: params.n_summed_spectra ?? 1, + }); + }, + + detectZ2(params: Z2DetectionRequest): Promise> { + return apiClient.post(`${PREFIX}/z2/detection`, { + false_alarm_probability: params.false_alarm_probability, + harmonics: params.harmonics ?? 2, + n_trials: params.n_trials ?? 1, + n_summed_spectra: params.n_summed_spectra ?? 1, + }); + }, + + evaluateFold(params: FoldEvaluateRequest): Promise> { + return apiClient.post(`${PREFIX}/fold/evaluate`, { + statistic: params.statistic, + n_phase_bins: params.n_phase_bins, + n_trials: params.n_trials ?? 1, + }); + }, + + detectFold(params: FoldDetectionRequest): Promise> { + return apiClient.post(`${PREFIX}/fold/detection`, { + false_alarm_probability: params.false_alarm_probability, + n_phase_bins: params.n_phase_bins, + n_trials: params.n_trials ?? 1, + }); + }, + + evaluatePdm(params: PdmEvaluateRequest): Promise> { + return apiClient.post(`${PREFIX}/pdm/evaluate`, { + statistic: params.statistic, + n_samples: params.n_samples, + n_phase_bins: params.n_phase_bins, + n_trials: params.n_trials ?? 1, + }); + }, + + detectPdm(params: PdmDetectionRequest): Promise> { + return apiClient.post(`${PREFIX}/pdm/detection`, { + false_alarm_probability: params.false_alarm_probability, + n_samples: params.n_samples, + n_phase_bins: params.n_phase_bins, + n_trials: params.n_trials ?? 1, + }); + }, +}; + +export default statisticsApi; diff --git a/src/api/timingApi.ts b/src/api/timingApi.ts new file mode 100644 index 0000000..89422b3 --- /dev/null +++ b/src/api/timingApi.ts @@ -0,0 +1,126 @@ +/** + * API functions for timing analysis operations + */ + +import { apiClient, ApiResponse } from './client'; + +// Types +export interface BispectrumData { + name: string | null; + freq: number[]; + lags: number[]; + bispec_mag: number[][]; + bispec_phase: number[][]; + maxlag: number; + scale: string; + window: string; +} + +export interface PowerColorsData { + name: string | null; + power_colors: Record>; + time: number[]; + freq_ranges: Record; +} + +export interface TimeLagsData { + name: string | null; + freq: number[]; + time_lags: Array; + time_lags_err?: Array | null; + freq_range: [number, number] | null; +} + +export interface CoherenceData { + name: string | null; + freq: number[]; + coherence: Array; + coherence_err?: Array | null; + segment_size?: number; + n_segments?: number | null; +} + +// API functions +export const timingApi = { + /** + * Create a bispectrum from an EventList + */ + async createBispectrum(params: { + event_list_name: string; + dt: number; + maxlag?: number; + scale?: string; + window?: string; + output_name?: string; + }): Promise> { + return apiClient.post('/api/timing/bispectrum', { + event_list_name: params.event_list_name, + dt: params.dt, + maxlag: params.maxlag || 25, + scale: params.scale || 'unbiased', + window: params.window || 'uniform', + output_name: params.output_name, + }); + }, + + /** + * Calculate power colors from frequency bands + */ + async calculatePowerColors(params: { + event_list_name: string; + dt: number; + segment_size: number; + freq_ranges: Record; + output_name?: string; + }): Promise> { + return apiClient.post('/api/timing/power-colors', { + event_list_name: params.event_list_name, + dt: params.dt, + segment_size: params.segment_size, + freq_ranges: params.freq_ranges, + output_name: params.output_name, + }); + }, + + /** + * Calculate time lags between two event lists + */ + async calculateTimeLags(params: { + event_list_1_name: string; + event_list_2_name: string; + dt: number; + segment_size: number; + freq_range?: [number, number]; + output_name?: string; + }): Promise> { + return apiClient.post('/api/timing/time-lags', { + event_list_1_name: params.event_list_1_name, + event_list_2_name: params.event_list_2_name, + dt: params.dt, + segment_size: params.segment_size, + freq_range: params.freq_range, + output_name: params.output_name, + }); + }, + + /** + * Calculate coherence between two event lists + */ + async calculateCoherence(params: { + event_list_1_name: string; + event_list_2_name: string; + dt: number; + segment_size: number; + output_name?: string; + }): Promise> { + return apiClient.post('/api/timing/coherence', { + event_list_1_name: params.event_list_1_name, + event_list_2_name: params.event_list_2_name, + dt: params.dt, + segment_size: params.segment_size, + output_name: params.output_name, + }); + }, +}; + +export default timingApi; diff --git a/src/api/varenergyApi.ts b/src/api/varenergyApi.ts new file mode 100644 index 0000000..a82defe --- /dev/null +++ b/src/api/varenergyApi.ts @@ -0,0 +1,270 @@ +/** + * API functions for variability-vs-energy spectra (rms, lag, excess variance, + * variable-energy composite, covariance / averaged covariance). + */ + +import { apiClient, ApiResponse } from './client'; + +// Types + +export interface RmsSpectrumData { + energy: number[]; + spectrum: Array; + spectrum_error: Array; + freq_range: [number, number]; + norm: string; + n_segments_hint: number; + warnings: string[]; +} + +export interface LagSpectrumData { + energy: number[]; + spectrum: Array; + spectrum_error: Array; + freq_range: [number, number]; + ref_band: [number, number] | null; + n_segments_hint: number; + warnings: string[]; +} + +export interface ExcessVarianceData { + energy: number[]; + spectrum: Array; + spectrum_error: Array; + normalization: string; + warnings: string[]; +} + +/** One panel (counts / rms / lag) of the VariableEnergySpectrum composite. */ +export interface VarEnergyBand { + spectrum: Array; + error: Array; +} + +export interface VariableEnergySpectrumData { + energy: number[]; + counts: VarEnergyBand; + rms: VarEnergyBand; + lag: VarEnergyBand; + freq_range: [number, number]; + ref_band: [number, number] | null; + norm: string; + n_segments_hint: number; + warnings: string[]; +} + +/** + * Shared shape for both /covariance-spectrum (n_segments_hint always 1, one + * segment spanning the longest GTI) and /avg-covariance-spectrum + * (segment_size is user-chosen; n_segments_hint = whole segments fitting the + * GTIs). + */ +export interface CovarianceSpectrumData { + energy: number[]; + spectrum: Array; + spectrum_error: Array; + freq_range: [number, number]; + ref_band: [number, number] | null; + norm: string; + segment_size: number; + n_segments_hint: number; + // GTI accounting: segments start at GTI boundaries, so GTIs shorter than + // segment_size contribute nothing; when n_gtis_used < n_gtis_total the + // backend also appends a warning naming the skipped exposure. + n_gtis_total?: number; + n_gtis_used?: number; + exposure_total?: number; + exposure_used?: number; + warnings: string[]; +} + +// API functions +export const varenergyApi = { + /** + * Fractional/absolute rms vs energy. No ref_band field: it is silently + * inert for RmsSpectrum on a single EventList. + */ + async rmsSpectrum(params: { + event_list_name: string; + bin_time: number; + segment_size: number; + freq_min: number; + freq_max: number; + energy_min: number; + energy_max: number; + n_bands?: number; + log_bands?: boolean; + norm?: string; + }): Promise> { + return apiClient.post('/api/varenergy/rms-spectrum', { + event_list_name: params.event_list_name, + bin_time: params.bin_time, + segment_size: params.segment_size, + freq_min: params.freq_min, + freq_max: params.freq_max, + energy_min: params.energy_min, + energy_max: params.energy_max, + n_bands: params.n_bands ?? 5, + log_bands: params.log_bands ?? false, + norm: params.norm ?? 'frac', + }); + }, + + /** + * Lag (seconds) vs energy, optionally against an explicit reference band. + * ref_min/ref_max must be given together or not at all. + */ + async lagSpectrum(params: { + event_list_name: string; + bin_time: number; + segment_size: number; + freq_min: number; + freq_max: number; + energy_min: number; + energy_max: number; + n_bands?: number; + log_bands?: boolean; + ref_min?: number | null; + ref_max?: number | null; + }): Promise> { + return apiClient.post('/api/varenergy/lag-spectrum', { + event_list_name: params.event_list_name, + bin_time: params.bin_time, + segment_size: params.segment_size, + freq_min: params.freq_min, + freq_max: params.freq_max, + energy_min: params.energy_min, + energy_max: params.energy_max, + n_bands: params.n_bands ?? 5, + log_bands: params.log_bands ?? false, + ref_min: params.ref_min ?? null, + ref_max: params.ref_max ?? null, + }); + }, + + /** + * Excess variance vs energy. No freq_min/freq_max/segment_size: stingray's + * ExcessVarianceSpectrum ignores all three (see backend contract notes). + */ + async excessVariance(params: { + event_list_name: string; + bin_time: number; + energy_min: number; + energy_max: number; + n_bands?: number; + log_bands?: boolean; + normalization?: string; + }): Promise> { + return apiClient.post('/api/varenergy/excess-variance', { + event_list_name: params.event_list_name, + bin_time: params.bin_time, + energy_min: params.energy_min, + energy_max: params.energy_max, + n_bands: params.n_bands ?? 5, + log_bands: params.log_bands ?? false, + normalization: params.normalization ?? 'fvar', + }); + }, + + /** + * Composite endpoint: counts + rms + lag panels vs energy from shared + * parameters. No norm field: rms is always fractional. ref_band affects + * the lag panel only. + */ + async variableEnergySpectrum(params: { + event_list_name: string; + bin_time: number; + segment_size: number; + freq_min: number; + freq_max: number; + energy_min: number; + energy_max: number; + n_bands?: number; + log_bands?: boolean; + ref_min?: number | null; + ref_max?: number | null; + }): Promise> { + return apiClient.post('/api/varenergy/variable-energy-spectrum', { + event_list_name: params.event_list_name, + bin_time: params.bin_time, + segment_size: params.segment_size, + freq_min: params.freq_min, + freq_max: params.freq_max, + energy_min: params.energy_min, + energy_max: params.energy_max, + n_bands: params.n_bands ?? 5, + log_bands: params.log_bands ?? false, + ref_min: params.ref_min ?? null, + ref_max: params.ref_max ?? null, + }); + }, + + /** + * Unsegmented covariance spectrum: one segment spanning the longest GTI + * (n_segments_hint is always 1). No segment_size field — it is derived + * server-side. + */ + async covarianceSpectrum(params: { + event_list_name: string; + bin_time: number; + freq_min: number; + freq_max: number; + energy_min: number; + energy_max: number; + n_bands?: number; + log_bands?: boolean; + ref_min?: number | null; + ref_max?: number | null; + norm?: string; + }): Promise> { + return apiClient.post('/api/varenergy/covariance-spectrum', { + event_list_name: params.event_list_name, + bin_time: params.bin_time, + freq_min: params.freq_min, + freq_max: params.freq_max, + energy_min: params.energy_min, + energy_max: params.energy_max, + n_bands: params.n_bands ?? 5, + log_bands: params.log_bands ?? false, + ref_min: params.ref_min ?? null, + ref_max: params.ref_max ?? null, + norm: params.norm ?? 'abs', + }); + }, + + /** + * Segmented (averaged) covariance spectrum: identical contract to + * covarianceSpectrum plus a required segment_size. + */ + async avgCovarianceSpectrum(params: { + event_list_name: string; + bin_time: number; + segment_size: number; + freq_min: number; + freq_max: number; + energy_min: number; + energy_max: number; + n_bands?: number; + log_bands?: boolean; + ref_min?: number | null; + ref_max?: number | null; + norm?: string; + }): Promise> { + return apiClient.post('/api/varenergy/avg-covariance-spectrum', { + event_list_name: params.event_list_name, + bin_time: params.bin_time, + segment_size: params.segment_size, + freq_min: params.freq_min, + freq_max: params.freq_max, + energy_min: params.energy_min, + energy_max: params.energy_max, + n_bands: params.n_bands ?? 5, + log_bands: params.log_bands ?? false, + ref_min: params.ref_min ?? null, + ref_max: params.ref_max ?? null, + norm: params.norm ?? 'abs', + }); + }, +}; + +export default varenergyApi; diff --git a/src/components/analysis/EventListSelector.test.tsx b/src/components/analysis/EventListSelector.test.tsx new file mode 100644 index 0000000..ef2b516 --- /dev/null +++ b/src/components/analysis/EventListSelector.test.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...args: unknown[]) => listEventLists(...args) }, +})); + +import EventListSelector from './EventListSelector'; + +const Harness: React.FC = () => { + const [value, setValue] = useState(''); + return ; +}; + +const DualHarness: React.FC = () => { + const [value1, setValue1] = useState(''); + const [value2, setValue2] = useState(''); + return ( + <> + + + + ); +}; + +describe('EventListSelector', () => { + beforeEach(() => listEventLists.mockReset()); + + it('lists loaded event lists and selects one', async () => { + listEventLists.mockResolvedValue({ + success: true, + data: [ + { name: 'obs1', n_events: 1000, time_range: [0, 10] }, + { name: 'obs2', n_events: 2000, time_range: [0, 20] }, + ], + message: '', + error: null, + }); + renderWithProviders(); + const select = await screen.findByLabelText('Event list'); + await userEvent.click(select); + await userEvent.click(await screen.findByText(/obs2/)); + await waitFor(() => expect(screen.getByLabelText('Event list')).toHaveTextContent('obs2')); + }); + + it('shows an empty-state prompt linking to data ingestion', async () => { + listEventLists.mockResolvedValue({ success: true, data: [], message: '', error: null }); + renderWithProviders(); + expect(await screen.findByText(/No event lists loaded/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Load data/ })).toHaveAttribute( + 'href', + '/data-ingestion' + ); + }); + + it('shares one query across two instances and selections stay independent', async () => { + listEventLists.mockResolvedValue({ + success: true, + data: [ + { name: 'obs1', n_events: 1000, time_range: [0, 10] }, + { name: 'obs2', n_events: 2000, time_range: [0, 20] }, + ], + message: '', + error: null, + }); + renderWithProviders(); + await screen.findByLabelText('Event list 1'); + const select2 = await screen.findByLabelText('Event list 2'); + expect(listEventLists).toHaveBeenCalledTimes(1); + await userEvent.click(select2); + await userEvent.click(await screen.findByText(/obs1/)); + await waitFor(() => expect(screen.getByLabelText('Event list 2')).toHaveTextContent('obs1')); + expect(screen.getByLabelText('Event list 1')).not.toHaveTextContent('obs1'); + }); + + it('refreshes from empty to populated via the refresh button', async () => { + listEventLists.mockResolvedValueOnce({ success: true, data: [], message: '', error: null }); + renderWithProviders(); + expect(await screen.findByText(/No event lists loaded/)).toBeInTheDocument(); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 1000, time_range: [0, 10] }], + message: '', + error: null, + }); + await userEvent.click(screen.getByRole('button', { name: 'Refresh event lists' })); + const select = await screen.findByLabelText('Event list'); + await userEvent.click(select); + expect(await screen.findByText(/obs1/)).toBeInTheDocument(); + }); + + it('shows an error alert with a refresh affordance when listing fails', async () => { + listEventLists.mockResolvedValue({ success: false, data: null, message: 'boom', error: 'boom' }); + renderWithProviders(); + expect(await screen.findByText(/Failed to load event lists/)).toBeInTheDocument(); + expect(screen.getByText(/boom/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Refresh event lists' })).toBeInTheDocument(); + }); + + it('disables EventLists that lack a required scientific data capability', async () => { + listEventLists.mockResolvedValue({ + success: true, + data: [ + { name: 'with-pi', n_events: 10, time_range: [0, 1], has_pi: true }, + { name: 'without-pi', n_events: 10, time_range: [0, 1], has_pi: false }, + ], + message: '', + error: null, + }); + renderWithProviders( + undefined} + requiredCapability="pi" + /> + ); + + await userEvent.click(await screen.findByLabelText('PI EventList')); + expect(screen.getByRole('option', { name: /without-pi.*no PI\/channel data/ })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + expect(screen.getByRole('option', { name: /with-pi/ })).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); +}); diff --git a/src/components/analysis/EventListSelector.tsx b/src/components/analysis/EventListSelector.tsx new file mode 100644 index 0000000..55be46f --- /dev/null +++ b/src/components/analysis/EventListSelector.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { + Alert, + Box, + CircularProgress, + FormControl, + IconButton, + InputLabel, + Link, + MenuItem, + Select, + Tooltip, +} from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { Link as RouterLink } from 'react-router-dom'; +import { useEventLists } from '@/hooks/useEventLists'; +import type { EventListSummary } from '@/api/dataApi'; + +interface EventListSelectorProps { + label: string; + value: string; + onChange: (name: string) => void; + requiredCapability?: 'pi' | 'energy'; + disabled?: boolean; +} + +function supportsRequiredCapability( + eventList: EventListSummary, + requiredCapability: EventListSelectorProps['requiredCapability'] +): boolean { + if (requiredCapability === 'pi') return eventList.has_pi === true; + if (requiredCapability === 'energy') return eventList.has_energy === true; + return true; +} + +/** Dropdown of event lists currently loaded in the backend. */ +const EventListSelector: React.FC = ({ + label, + value, + onChange, + requiredCapability, + disabled = false, +}) => { + const { data, isLoading, isError, error, refetch, isFetching } = useEventLists(); + const labelId = `event-list-selector-${React.useId()}`; + const capabilityLabel = requiredCapability === 'pi' ? 'PI/channel' : 'energy'; + + // Auto-clear a selection that no longer exists in the list (deleted + // elsewhere or backend restart) so consumers never submit stale names. + React.useEffect(() => { + if ( + !isLoading && + !isFetching && + value !== '' && + data !== undefined && + !data.some( + (ev) => ev.name === value && supportsRequiredCapability(ev, requiredCapability) + ) + ) { + onChange(''); + } + }, [data, isLoading, isFetching, value, onChange, requiredCapability]); + + const refreshButton = ( + + + refetch()} + disabled={disabled || isFetching} + > + {isFetching ? : } + + + + ); + + // Only take over the UI with an error when there is no usable (stale) data; + // a failed background refetch keeps the populated dropdown rendered. + if (isError && data === undefined) { + return ( + + Failed to load event lists: {error instanceof Error ? error.message : 'unknown error'} + + ); + } + + if (!isLoading && (data?.length ?? 0) === 0) { + return ( + + No event lists loaded.{' '} + + Load data + {' '} + first. + + ); + } + + if ( + !isLoading && + requiredCapability !== undefined && + data !== undefined && + !data.some((eventList) => supportsRequiredCapability(eventList, requiredCapability)) + ) { + return ( + + No EventLists with {capabilityLabel} data are loaded.{' '} + + Load compatible data + {' '} + first. + + ); + } + + return ( + + + {label} + + + {refreshButton} + + ); +}; + +export default EventListSelector; diff --git a/src/components/common/LogPanel.tsx b/src/components/common/LogPanel.tsx new file mode 100644 index 0000000..8dfd454 --- /dev/null +++ b/src/components/common/LogPanel.tsx @@ -0,0 +1,491 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Box, + Collapse, + IconButton, + Typography, + TextField, + Chip, + Tooltip, + Paper, + Stack, + Divider, + InputAdornment, +} from '@mui/material'; +import { + ExpandLess, + ExpandMore, + Delete, + ContentCopy, + Search, + Terminal, + Error as ErrorIcon, + Warning as WarningIcon, + Info as InfoIcon, + BugReport as DebugIcon, + Code as RawIcon, + FormatListBulleted as FormattedIcon, +} from '@mui/icons-material'; +import { useLogStore, selectFilteredLogs, LogEntry } from '@/store/logStore'; +import { useBackendContext } from '@/context/BackendContext'; +import { logStreamClient } from '@/api/logApi'; + +interface LogPanelProps { + /** Left offset to avoid overlapping left sidebar */ + leftOffset?: number; + /** Right offset to avoid overlapping right toolbar */ + rightOffset?: number; +} + +/** + * Format timestamp for display + */ +const formatTimestamp = (date: Date): string => { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +}; + +/** + * Get icon for log level + */ +const getLevelIcon = (level: LogEntry['level']): React.ReactNode => { + switch (level) { + case 'error': + return ; + case 'warn': + return ; + case 'info': + return ; + case 'debug': + return ; + default: + return null; + } +}; + +/** + * Get color for log level + */ +const getLevelColor = (level: LogEntry['level']): string => { + switch (level) { + case 'error': + return 'error.main'; + case 'warn': + return 'warning.main'; + case 'info': + return 'info.main'; + case 'debug': + return 'text.secondary'; + default: + return 'text.primary'; + } +}; + +/** + * Get color for source + */ +const getSourceColor = (source: LogEntry['source']): 'primary' | 'secondary' | 'default' => { + switch (source) { + case 'python': + return 'primary'; + case 'electron': + return 'secondary'; + case 'frontend': + return 'default'; + default: + return 'default'; + } +}; + +/** + * Log entry row component (formatted view) + */ +const LogRow: React.FC<{ log: LogEntry }> = ({ log }) => { + return ( + + + {formatTimestamp(log.timestamp)} + + {getLevelIcon(log.level)} + + + {log.message} + + + ); +}; + +/** + * Format log entry as raw terminal output + */ +const formatRawLog = (log: LogEntry): string => { + const timestamp = formatTimestamp(log.timestamp); + const level = log.level.toUpperCase().padEnd(5); + const source = `[${log.source}]`.padEnd(10); + return `${timestamp} ${level} ${source} ${log.message}`; +}; + +/** + * Raw log view component - shows logs exactly like terminal output + */ +const RawLogView: React.FC<{ logs: LogEntry[] }> = ({ logs }) => { + return ( + + {logs.map((log) => ( + + {formatRawLog(log)} + + ))} + + ); +}; + +/** + * LogPanel component - displays logs in a collapsible panel + */ +const LogPanel: React.FC = ({ leftOffset = 0, rightOffset = 0 }) => { + const { + isOpen, + togglePanel, + clearLogs, + filter, + toggleLevelFilter, + toggleSourceFilter, + setSearchFilter, + } = useLogStore(); + + const logs = useLogStore(selectFilteredLogs); + const logsEndRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + const [rawView, setRawView] = useState(false); + const { isReady: backendReady } = useBackendContext(); + + // Listen for logs from Electron main process + useEffect(() => { + if (typeof window !== 'undefined' && window.electronAPI) { + // Set up the log listener first + const unsubscribe = window.electronAPI.onLog((log) => { + useLogStore.getState().addLog(log); + }); + + // Signal that we're ready to receive logs (triggers flush of buffered logs) + window.electronAPI.signalLogReady(); + + return unsubscribe; + } + }, []); + + // Connect to Python backend log stream via SSE when backend is ready + useEffect(() => { + if (backendReady) { + // Connect immediately - history replay ensures we don't miss startup logs + logStreamClient.connect(); + + return () => { + logStreamClient.disconnect(); + }; + } + }, [backendReady]); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (autoScroll && logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [logs, autoScroll]); + + // Handle scroll to detect if user scrolled up + const handleScroll = (e: React.UIEvent): void => { + const element = e.currentTarget; + const isAtBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 50; + setAutoScroll(isAtBottom); + }; + + // Copy all visible logs to clipboard + const handleCopyLogs = (): void => { + const logText = logs + .map((log) => `[${formatTimestamp(log.timestamp)}] [${log.level.toUpperCase()}] [${log.source}] ${log.message}`) + .join('\n'); + navigator.clipboard.writeText(logText); + }; + + const levelFilters: LogEntry['level'][] = ['info', 'warn', 'error', 'debug']; + const sourceFilters: LogEntry['source'][] = ['python', 'electron', 'frontend']; + + return ( + + theme.palette.mode === 'dark' + ? 'linear-gradient(to right, #00d4aa, #3b82f6, transparent)' + : 'linear-gradient(to right, #0d9b7a, #2563eb, transparent)', + opacity: 0.4, + zIndex: 1, + }, + }} + > + {/* Header */} + + + + Console + + + { e.stopPropagation(); togglePanel(); }}> + {isOpen ? : } + + + + {/* Content */} + + + {/* Toolbar */} + + {/* Search */} + setSearchFilter(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + sx: { fontSize: '0.8rem', height: 32 }, + }} + sx={{ width: 200 }} + /> + + + + {/* Level filters */} + + {levelFilters.map((level) => ( + toggleLevelFilter(level)} + sx={{ + height: 24, + fontSize: '0.7rem', + textTransform: 'capitalize', + }} + /> + ))} + + + + + {/* Source filters */} + + {sourceFilters.map((source) => ( + toggleSourceFilter(source)} + sx={{ + height: 24, + fontSize: '0.7rem', + textTransform: 'capitalize', + }} + /> + ))} + + + + + {/* Actions */} + + setRawView(!rawView)}> + {rawView ? : } + + + + + + + + + + + + + + + {/* Log entries */} + + theme.palette.mode === 'dark' ? '#080c16' : '#fafbfc', + '&::-webkit-scrollbar': { + width: 8, + }, + '&::-webkit-scrollbar-track': { + backgroundColor: 'background.paper', + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: 'action.disabled', + borderRadius: 4, + }, + }} + > + {logs.length === 0 ? ( + + No logs to display + + ) : rawView ? ( + <> + +
+ + ) : ( + <> + {logs.map((log) => ( + + ))} +
+ + )} + + + + + ); +}; + +export default LogPanel; diff --git a/src/components/common/NotificationToast.tsx b/src/components/common/NotificationToast.tsx new file mode 100644 index 0000000..70e78c6 --- /dev/null +++ b/src/components/common/NotificationToast.tsx @@ -0,0 +1,153 @@ +/** + * NotificationToast - Auto-popup toast notifications + * + * Displays a Snackbar toast whenever a new notification is added to the store. + * Notifications slide in from the right side and auto-dismiss after a few seconds. + */ + +import React, { useEffect, useState } from 'react'; +import { + Snackbar, + Alert, + AlertTitle, + IconButton, + Typography, + Slide, + SlideProps, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { useUIStore, Notification } from '@/store/uiStore'; + +// Slide transition from right +function SlideTransition(props: SlideProps) { + return ; +} + +// Auto-hide duration based on notification type (ms) +const AUTO_HIDE_DURATION: Record = { + info: 4000, + success: 3000, + warning: 5000, + error: 6000, +}; + +const NotificationToast: React.FC = () => { + const { notifications, markNotificationRead } = useUIStore(); + const [open, setOpen] = useState(false); + const [currentNotification, setCurrentNotification] = useState(null); + const [lastNotificationId, setLastNotificationId] = useState(null); + + // Watch for new notifications + useEffect(() => { + if (notifications.length > 0) { + const latestNotification = notifications[0]; + + // Only show toast for new, unread notifications + if (latestNotification.id !== lastNotificationId && !latestNotification.read) { + setCurrentNotification(latestNotification); + setLastNotificationId(latestNotification.id); + setOpen(true); + } + } + }, [notifications, lastNotificationId]); + + const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string): void => { + // Don't close on clickaway - only on explicit close or timeout + if (reason === 'clickaway') { + return; + } + setOpen(false); + }; + + const handleExited = (): void => { + // Mark as read when the toast closes + if (currentNotification) { + markNotificationRead(currentNotification.id); + } + }; + + if (!currentNotification) { + return null; + } + + return ( + + { + const colors: Record = { + success: 'rgba(34, 197, 94, 0.3)', + error: 'rgba(239, 68, 68, 0.3)', + warning: 'rgba(245, 158, 11, 0.3)', + info: 'rgba(59, 130, 246, 0.3)', + }; + return colors[currentNotification.type] || 'rgba(148, 163, 184, 0.12)'; + }, + boxShadow: () => { + const glows: Record = { + success: '0 4px 24px rgba(34, 197, 94, 0.15)', + error: '0 4px 24px rgba(239, 68, 68, 0.15)', + warning: '0 4px 24px rgba(245, 158, 11, 0.15)', + info: '0 4px 24px rgba(59, 130, 246, 0.15)', + }; + return glows[currentNotification.type] || '0 4px 24px rgba(0,0,0,0.1)'; + }, + '& .MuiAlert-message': { + width: '100%', + }, + }} + action={ + + + + } + > + + {currentNotification.title} + + + {currentNotification.message} + + + + ); +}; + +export default NotificationToast; diff --git a/src/components/common/PageTemplate.tsx b/src/components/common/PageTemplate.tsx new file mode 100644 index 0000000..a7eacb1 --- /dev/null +++ b/src/components/common/PageTemplate.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Box, Typography, Paper, Chip, Alert } from '@mui/material'; +import ConstructionIcon from '@mui/icons-material/Construction'; + +interface PageTemplateProps { + title: string; + description?: string; + category?: string; + status?: 'ready' | 'coming-soon' | 'in-development'; + children?: React.ReactNode; +} + +/** + * Reusable page template component + */ +const PageTemplate: React.FC = ({ + title, + description, + category, + status = 'coming-soon', + children, +}) => { + return ( + + {/* Header */} + + {category && ( + + )} + + theme.palette.mode === 'dark' + ? '0 0 30px rgba(0, 212, 170, 0.08)' + : 'none', + }} + > + {title} + + {description && ( + + {description} + + )} + + + {/* Status indicator for pages under development */} + {status !== 'ready' && ( + } + sx={{ mb: 3 }} + > + {status === 'in-development' + ? 'This feature is currently under development.' + : 'This feature is coming soon in a future release.'} + + )} + + {/* Page content */} + {children || ( + + theme.palette.mode === 'dark' + ? 'rgba(18, 24, 41, 0.4)' + : 'rgba(255, 255, 255, 0.6)', + backdropFilter: 'blur(8px)', + border: '1px solid', + borderColor: 'divider', + }} + > + + + {title} + + + This analysis module will be implemented soon. + + + )} + + ); +}; + +export default PageTemplate; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx new file mode 100644 index 0000000..24b8a91 --- /dev/null +++ b/src/components/layout/Header.tsx @@ -0,0 +1,328 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { + AppBar, + Toolbar, + Typography, + IconButton, + Box, + Tooltip, + Chip, + InputBase, + Paper, + Fade, + ClickAwayListener, +} from '@mui/material'; +import MenuIcon from '@mui/icons-material/Menu'; +import MenuOpenIcon from '@mui/icons-material/MenuOpen'; +import Brightness4Icon from '@mui/icons-material/Brightness4'; +import Brightness7Icon from '@mui/icons-material/Brightness7'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import SettingsIcon from '@mui/icons-material/Settings'; +import SearchIcon from '@mui/icons-material/Search'; +import CloseIcon from '@mui/icons-material/Close'; +import CircleIcon from '@mui/icons-material/Circle'; +import ViewSidebarIcon from '@mui/icons-material/ViewSidebar'; +import { ThemeContext } from '../../App'; +import { useBackendContext } from '@/context/BackendContext'; +import { useUIStore } from '@/store/uiStore'; + +interface HeaderProps { + onToggleSidebar: () => void; + sidebarOpen: boolean; + onToggleRightToolbar: () => void; + rightToolbarOpen: boolean; +} + +/** + * Application header with navigation controls and status indicators + */ +const Header: React.FC = ({ + onToggleSidebar, + sidebarOpen, + onToggleRightToolbar, + rightToolbarOpen, +}) => { + const { darkMode, toggleDarkMode } = useContext(ThemeContext); + const { isReady, error } = useBackendContext(); + const { searchOpen, searchQuery, setSearchOpen, setSearchQuery } = useUIStore(); + + const [localSearchQuery, setLocalSearchQuery] = useState(''); + + // With titleBarStyle: 'hiddenInset' macOS draws the traffic lights over the + // web content, so the toolbar must reserve space for them on darwin only. + const [isMac, setIsMac] = useState(false); + useEffect(() => { + let mounted = true; + void window.electronAPI?.getPlatform().then((platform) => { + if (mounted) setIsMac(platform === 'darwin'); + }); + return () => { + mounted = false; + }; + }, []); + + const handleRestartBackend = async (): Promise => { + if (window.electronAPI) { + await window.electronAPI.restartPython(); + } + }; + + const handleSearchOpen = (): void => { + setSearchOpen(true); + setLocalSearchQuery(searchQuery); + }; + + const handleSearchClose = (): void => { + setSearchOpen(false); + setLocalSearchQuery(''); + }; + + const handleSearchSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + setSearchQuery(localSearchQuery); + // TODO: Implement actual search functionality + console.log('Search query:', localSearchQuery); + }; + + return ( + theme.zIndex.drawer + 1, + backgroundColor: (theme) => + theme.palette.mode === 'dark' + ? 'rgba(10, 14, 26, 0.8)' + : 'rgba(255, 255, 255, 0.8)', + backdropFilter: 'blur(12px) saturate(150%)', + WebkitBackdropFilter: 'blur(12px) saturate(150%)', + borderBottom: '1px solid', + borderColor: 'divider', + '&::after': { + content: '""', + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: '1px', + background: (theme) => + theme.palette.mode === 'dark' + ? 'linear-gradient(to right, #00d4aa, #3b82f6, transparent)' + : 'linear-gradient(to right, #0d9b7a, #2563eb, transparent)', + opacity: 0.4, + }, + }} + > + {/* The whole bar is a window drag region; interactive children opt out. */} + + {/* Spacer clearing the macOS traffic lights. Sized from the Window + Controls Overlay env var (enabled via titleBarOverlay in main.ts), + which the compositor recomputes on page zoom and fullscreen — + fixed pixel offsets drift against the OS-drawn buttons when the + user zooms. 16px breathing room after the safe-area edge; 80px + fallback if the env var is ever unavailable. A real element, not + Toolbar padding: sx padding loses the cascade against + MuiToolbar-gutters' media rule. */} + {isMac && ( + + )} + {/* Left sidebar toggle */} + + {sidebarOpen ? : } + + + {/* Logo and title */} + + Stingray Explorer + + theme.palette.mode === 'dark' + ? '0 0 20px rgba(0, 212, 170, 0.15)' + : 'none', + }} + > + Stingray Explorer + + + + {/* Subtitle */} + + Next-Generation Spectral Timing Made Easy + + + {/* Spacer */} + + + {/* Search bar */} + {searchOpen ? ( + + + + theme.palette.mode === 'dark' + ? 'rgba(18, 24, 41, 0.6)' + : 'rgba(240, 242, 245, 0.8)', + backdropFilter: 'blur(8px)', + border: '1px solid', + borderColor: 'divider', + transition: 'border-color 0.2s ease, box-shadow 0.2s ease', + '&:focus-within': { + borderColor: 'primary.main', + boxShadow: (theme) => + `0 0 0 3px ${theme.palette.mode === 'dark' ? 'rgba(0, 212, 170, 0.12)' : 'rgba(13, 155, 122, 0.1)'}`, + }, + }} + elevation={0} + > + + setLocalSearchQuery(e.target.value)} + autoFocus + sx={{ flex: 1, fontSize: '0.8rem', fontFamily: '"IBM Plex Sans", sans-serif' }} + /> + + + + + + + ) : ( + + + + + + )} + + {/* Backend status indicator */} + + + } + label={isReady ? 'Backend Ready' : error ? 'Error' : 'Starting...'} + size="small" + variant="outlined" + color={isReady ? 'success' : error ? 'error' : 'warning'} + sx={{ + mr: 2, + fontFamily: '"IBM Plex Sans", sans-serif', + fontSize: '0.7rem', + fontWeight: 500, + backgroundColor: (theme) => + theme.palette.mode === 'dark' + ? 'rgba(18, 24, 41, 0.5)' + : 'rgba(240, 242, 245, 0.5)', + backdropFilter: 'blur(4px)', + }} + /> + + + {/* Action buttons */} + + {/* Restart backend */} + + + + + + + {/* Dark mode toggle */} + + + {darkMode ? : } + + + + {/* Settings */} + + + + + + + + {/* Right toolbar toggle - rightmost */} + + + + + + + + ); +}; + +export default Header; diff --git a/src/components/layout/JobStatusPanel.tsx b/src/components/layout/JobStatusPanel.tsx new file mode 100644 index 0000000..5cddbe0 --- /dev/null +++ b/src/components/layout/JobStatusPanel.tsx @@ -0,0 +1,316 @@ +/** + * Job Status Panel for the sidebar. + * + * Displays active and recently completed background jobs with + * real-time progress updates. Persists across page navigation. + */ + +import React, { useState, useContext } from 'react'; +import { + Box, + Typography, + IconButton, + Collapse, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Badge, + Tooltip, + Divider, + Button, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import CancelIcon from '@mui/icons-material/Cancel'; +import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; +import SyncIcon from '@mui/icons-material/Sync'; +import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; +import CloseIcon from '@mui/icons-material/Close'; +import WifiIcon from '@mui/icons-material/Wifi'; +import WifiOffIcon from '@mui/icons-material/WifiOff'; +import { useJobStore } from '@/store/jobStore'; +import { jobApi } from '@/api/jobApi'; +import { ThemeContext } from '@/App'; +import type { Job, JobStatus } from '@/types/job'; + +interface JobStatusPanelProps { + /** Whether the sidebar is expanded */ + sidebarOpen: boolean; +} + +/** + * Get icon for job status. + */ +const getStatusIcon = (status: JobStatus): React.ReactNode => { + switch (status) { + case 'pending': + return ; + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + default: + return null; + } +}; + +/** + * Format relative time (e.g., "2m ago"). + */ +const formatRelativeTime = (isoString: string): string => { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return 'just now'; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + return `${Math.floor(diffSec / 86400)}d ago`; +}; + +/** + * Single job item component. + */ +const JobItem: React.FC<{ job: Job; onCancel: (id: string) => void }> = ({ job, onCancel }) => { + const isActive = job.status === 'pending' || job.status === 'running'; + + return ( + + + {getStatusIcon(job.status)} + + {isActive && job.status === 'pending' && ( + + + onCancel(job.id)}> + + + + + )} + + + {job.status === 'running' && job.total_items > 1 && ( + + {job.completed_items}/{job.total_items} files + + )} + + {!isActive && ( + + {formatRelativeTime(job.completed_at || job.created_at)} + + )} + + ); +}; + +/** + * Job Status Panel component. + */ +const JobStatusPanel: React.FC = ({ sidebarOpen }) => { + const { darkMode } = useContext(ThemeContext); + const [expanded, setExpanded] = useState(true); + const [showCompleted, setShowCompleted] = useState(false); + + const { + isConnected, + getActiveJobs, + getCompletedJobs, + getActiveJobCount, + clearCompletedJobs, + } = useJobStore(); + + const activeJobs = getActiveJobs(); + const completedJobs = getCompletedJobs(); + const activeCount = getActiveJobCount(); + + const handleCancel = async (jobId: string): Promise => { + try { + await jobApi.cancelJob(jobId); + } catch (error) { + console.error('Failed to cancel job:', error); + } + }; + + const handleClearCompleted = (): void => { + clearCompletedJobs(); + // Also clear on backend + jobApi.clearCompletedJobs().catch(console.error); + }; + + // Don't render if sidebar is collapsed + if (!sidebarOpen) { + return null; + } + + const hasJobs = activeJobs.length > 0 || completedJobs.length > 0; + + return ( + + {/* Header */} + setExpanded(!expanded)} + > + + + + Jobs + + + + {isConnected ? ( + + ) : ( + + )} + + + + {expanded ? : } + + + + {/* Content */} + + + {/* Active Jobs */} + {activeJobs.length > 0 && ( + + {activeJobs.map((job) => ( + + ))} + + )} + + {/* Completed Jobs Toggle */} + {completedJobs.length > 0 && ( + <> + + + + {showCompleted && ( + + + + + + )} + + + + + {completedJobs.slice(0, 10).map((job) => ( + + ))} + {completedJobs.length > 10 && ( + + +{completedJobs.length - 10} more + + )} + + + + )} + + {/* Empty state - no jobs at all */} + {!hasJobs && ( + + No jobs running or completed + + )} + + {/* Empty state for active (when there are completed jobs) */} + {activeJobs.length === 0 && completedJobs.length > 0 && !showCompleted && ( + + No active jobs + + )} + + + + + ); +}; + +export default JobStatusPanel; diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx new file mode 100644 index 0000000..f099db9 --- /dev/null +++ b/src/components/layout/MainLayout.tsx @@ -0,0 +1,120 @@ +import React, { useState, useCallback } from 'react'; +import { Outlet } from 'react-router-dom'; +import { Box, Toolbar } from '@mui/material'; +import Sidebar from './Sidebar'; +import Header from './Header'; +import RightToolbar, { TOOLBAR_WIDTH } from './RightToolbar'; +import LogPanel from '@/components/common/LogPanel'; +import NotificationToast from '@/components/common/NotificationToast'; +import { useUIStore } from '@/store/uiStore'; + +// Sidebar widths +const MAIN_DRAWER_WIDTH = 240; +const SUB_DRAWER_WIDTH = 240; + +// LogPanel collapsed header height +const LOG_PANEL_HEADER_HEIGHT = 40; + +/** + * Main layout component that wraps all pages + * Includes sidebar, header, right toolbar, and main content area + * (Footer functionality has been consolidated into RightToolbar) + */ +const MainLayout: React.FC = () => { + const [sidebarOpen, setSidebarOpen] = useState(true); + const [submenuOpen, setSubmenuOpen] = useState(false); + const { rightToolbarCollapsed, toggleRightToolbar } = useUIStore(); + + const handleToggleSidebar = useCallback((): void => { + setSidebarOpen((prev) => !prev); + }, []); + + const handleSubmenuStateChange = useCallback((isOpen: boolean): void => { + setSubmenuOpen(isOpen); + }, []); + + // Calculate main content offset based on sidebar state + const leftOffset = sidebarOpen + ? submenuOpen + ? MAIN_DRAWER_WIDTH + SUB_DRAWER_WIDTH + : MAIN_DRAWER_WIDTH + : 0; + + // Calculate right offset based on right toolbar state + const rightOffset = rightToolbarCollapsed ? 0 : TOOLBAR_WIDTH; + + return ( + + {/* Header */} +
+ + {/* Toolbar spacer to account for fixed header */} + + + {/* Main content area with sidebars */} + + {/* Left Sidebar */} + + + {/* Main content */} + + theme.transitions.create(['margin-left', 'margin-right'], { + easing: theme.transitions.easing.easeInOut, + duration: theme.transitions.duration.standard, + }), + marginLeft: `${leftOffset}px`, + marginRight: `${rightOffset}px`, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + backgroundColor: 'background.default', + // Subtle inset shadow where content meets sidebars + boxShadow: (theme) => + theme.palette.mode === 'dark' + ? 'inset 4px 0 12px -4px rgba(0,0,0,0.3), inset -4px 0 12px -4px rgba(0,0,0,0.3)' + : 'inset 4px 0 8px -4px rgba(0,0,0,0.04), inset -4px 0 8px -4px rgba(0,0,0,0.04)', + }} + > + {/* Page content */} + + + + + + {/* Right Toolbar */} + + + + {/* Log Panel - at bottom of main content area (avoids sidebars) */} + + + {/* Toast notifications - auto-popup for new notifications */} + + + ); +}; + +export default MainLayout; diff --git a/src/components/layout/RightToolbar.tsx b/src/components/layout/RightToolbar.tsx new file mode 100644 index 0000000..e40d18e --- /dev/null +++ b/src/components/layout/RightToolbar.tsx @@ -0,0 +1,809 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + IconButton, + Tooltip, + Divider, + Badge, + CircularProgress, + Menu, + MenuItem, + ListItemIcon, + ListItemText, + Typography, + List, + ListItem, + ListItemButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Popover, + Chip, + Link, +} from '@mui/material'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import BugReportIcon from '@mui/icons-material/BugReport'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import LogoutIcon from '@mui/icons-material/Logout'; +import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import WarningIcon from '@mui/icons-material/Warning'; +import InfoIcon from '@mui/icons-material/Info'; +import DeveloperModeIcon from '@mui/icons-material/DeveloperMode'; +import MemoryIcon from '@mui/icons-material/Memory'; +import StorageIcon from '@mui/icons-material/Storage'; +import GitHubIcon from '@mui/icons-material/GitHub'; +import MenuBookIcon from '@mui/icons-material/MenuBook'; +import ForumIcon from '@mui/icons-material/Forum'; +import { useUIStore, Notification, AppResources } from '@/store/uiStore'; +import { useLogStore } from '@/store/logStore'; +import { useBackendContext } from '@/context/BackendContext'; + +const TOOLBAR_WIDTH = 52; +const HEADER_HEIGHT = 64; + +interface RightToolbarProps { + visible?: boolean; +} + +/** + * Right toolbar with quick actions, status indicators, and system info + * (Consolidated from previous Footer + RightToolbar) + */ +const RightToolbar: React.FC = ({ visible = true }) => { + const { + isProcessing, + processingMessage, + processingProgress, + notifications, + unreadNotificationCount, + markNotificationRead, + clearNotifications, + addNotification, + appResources, + setAppResources, + } = useUIStore(); + + const { togglePanel: toggleLogPanel, isOpen: logPanelOpen } = useLogStore(); + const { isReady: backendReady, port: backendPort } = useBackendContext(); + + // App version + const [version, setVersion] = useState(''); + + // Menu/popover states + const [notificationsAnchor, setNotificationsAnchor] = useState(null); + const [debugAnchor, setDebugAnchor] = useState(null); + const [helpAnchor, setHelpAnchor] = useState(null); + const [resourcesAnchor, setResourcesAnchor] = useState(null); + + // Dialog states + const [selectedNotification, setSelectedNotification] = useState(null); + const [aboutOpen, setAboutOpen] = useState(false); + + // Resource monitoring state + const [monitoringActive, setMonitoringActive] = useState(false); + + // Get app version on mount + useEffect(() => { + const getVersion = async (): Promise => { + if (window.electronAPI) { + const appVersion = await window.electronAPI.getAppVersion(); + setVersion(appVersion); + } + }; + getVersion(); + }, []); + + // Fetch app-specific resources from backend AND Electron + const fetchResources = async (): Promise => { + try { + // Fetch backend resources + let backendRes = null; + let systemMemoryTotalMb = 16 * 1024; // Default 16GB + let systemMemoryAvailableMb = 8 * 1024; // Default 8GB + let systemCpuCount = 1; // Default to 1 core + + if (backendReady && backendPort) { + try { + const response = await fetch(`http://127.0.0.1:${backendPort}/api/status`); + if (response.ok) { + const data = await response.json(); + if (data.backend_resources) { + backendRes = { + memoryMb: data.backend_resources.memory_mb || 0, + cpuPercent: data.backend_resources.cpu_percent || 0, + }; + systemMemoryTotalMb = data.backend_resources.system_memory_total_mb || systemMemoryTotalMb; + systemMemoryAvailableMb = data.backend_resources.system_memory_available_mb || systemMemoryAvailableMb; + systemCpuCount = data.backend_resources.system_cpu_count || systemCpuCount; + } + } + } catch { + // Backend might not be ready + } + } + + // Fetch Electron resources + let electronMainRes = null; + let electronRendererRes = null; + + if (window.electronAPI) { + try { + const electronRes = await window.electronAPI.getElectronResources(); + if (electronRes.main) { + electronMainRes = { + memoryMb: electronRes.main.memory_mb || 0, + cpuPercent: electronRes.main.cpu_percent || 0, + }; + } + if (electronRes.renderer) { + electronRendererRes = { + memoryMb: electronRes.renderer.memory_mb || 0, + cpuPercent: electronRes.renderer.cpu_percent || 0, + }; + } + } catch { + // Electron API might not be available + } + } + + // Calculate totals + const totalMemoryMb = + (backendRes?.memoryMb || 0) + + (electronMainRes?.memoryMb || 0) + + (electronRendererRes?.memoryMb || 0); + + // Raw CPU sum (can exceed 100% on multi-core systems) + const totalCpuPercent = + (backendRes?.cpuPercent || 0) + + (electronMainRes?.cpuPercent || 0) + + (electronRendererRes?.cpuPercent || 0); + + // Calculate app percentage of system memory + const appMemoryPercent = systemMemoryTotalMb > 0 + ? (totalMemoryMb / systemMemoryTotalMb) * 100 + : 0; + + // Normalize CPU to total system capacity (0-100%) + // e.g., 200% on 8 cores = 25% of total system CPU + const appCpuPercent = systemCpuCount > 0 + ? totalCpuPercent / systemCpuCount + : 0; + + const combined: AppResources = { + backend: backendRes, + electronMain: electronMainRes, + electronRenderer: electronRendererRes, + totalMemoryMb, + totalCpuPercent, + systemMemoryTotalMb, + systemMemoryAvailableMb, + systemCpuCount, + appMemoryPercent, + appCpuPercent, + }; + + setAppResources(combined); + } catch { + // Error fetching resources + } + }; + + // Poll resources when monitoring is active + useEffect(() => { + if (!monitoringActive) return; + + fetchResources(); + const interval = setInterval(fetchResources, 2000); // Poll every 2 seconds + return () => clearInterval(interval); + }, [monitoringActive, backendReady, backendPort]); + + // Toggle resource monitoring + const handleToggleMonitoring = (): void => { + if (!monitoringActive) { + fetchResources(); + } + setMonitoringActive((prev) => !prev); + }; + + const handleOpenExternal = async (url: string): Promise => { + if (window.electronAPI) { + await window.electronAPI.openExternal(url); + } + }; + + const handleOpenDevTools = (): void => { + if (window.electronAPI) { + window.electronAPI.openDevTools(); + } + }; + + const handleToggleTerminal = (): void => { + toggleLogPanel(); + }; + + const handleQuitApp = (): void => { + if (window.electronAPI) { + window.electronAPI.closeWindow(); + } + }; + + const handleNotificationClick = (notification: Notification): void => { + markNotificationRead(notification.id); + setSelectedNotification(notification); + }; + + const handleCloseNotificationDetail = (): void => { + setSelectedNotification(null); + }; + + const getNotificationIcon = (type: Notification['type']): React.ReactNode => { + switch (type) { + case 'success': + return ; + case 'error': + return ; + case 'warning': + return ; + default: + return ; + } + }; + + const getResourceColor = (): 'success' | 'warning' | 'error' | 'default' => { + if (!appResources || !monitoringActive) return 'default'; + // Use app's percentage of system memory and CPU (whichever is higher) + const memPercent = appResources.appMemoryPercent; + const cpuPercent = appResources.appCpuPercent; + const maxPercent = Math.max(memPercent, cpuPercent); + if (maxPercent > 50) return 'error'; // App using >50% of system resources + if (maxPercent > 25) return 'warning'; // App using >25% of system resources + return 'success'; + }; + + if (!visible) { + return null; + } + + return ( + + theme.palette.mode === 'dark' ? '#0d1220' : '#f8f9fb', + borderLeft: '1px solid', + borderColor: 'divider', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + py: 1, + gap: 0.5, + position: 'fixed', + top: HEADER_HEIGHT, + right: 0, + zIndex: 1100, + overflowY: 'auto', + overflowX: 'hidden', + }} + > + {/* === Status Section === */} + + {/* Processing indicator + Backend status */} + + + {isProcessing ? processingMessage || 'Processing...' : 'System idle'} + + + Backend: {backendReady ? `Connected (port ${backendPort})` : 'Disconnected'} + + + } + placement="left" + > + + + {isProcessing ? ( + processingProgress !== null ? ( + + ) : ( + + ) + ) : ( + + )} + + {/* Backend status dot */} + + + + + {/* Resource Monitor */} + + setResourcesAnchor(e.currentTarget)} + color={getResourceColor()} + sx={{ + animation: monitoringActive ? 'pulse-slow 3s infinite' : 'none', + '@keyframes pulse-slow': { + '0%, 100%': { opacity: 1 }, + '50%': { opacity: 0.7 }, + }, + }} + > + + + + + {/* Terminal / Log Panel */} + + + + + + + theme.palette.mode === 'dark' ? 'linear-gradient(to right, transparent, rgba(0, 212, 170, 0.2), transparent) 1' : 'none' }} /> + + {/* === Actions Section === */} + + {/* Notifications */} + + setNotificationsAnchor(e.currentTarget)} + size="small" + > + + + + + + + {/* Help & Support */} + + setHelpAnchor(e.currentTarget)} + size="small" + > + + + + + {/* Debug Tools */} + + setDebugAnchor(e.currentTarget)} + size="small" + > + + + + + {/* Spacer */} + + + theme.palette.mode === 'dark' ? 'linear-gradient(to right, transparent, rgba(0, 212, 170, 0.2), transparent) 1' : 'none' }} /> + + {/* === Bottom Section === */} + + {/* About */} + + setAboutOpen(true)} + size="small" + > + + + + + {/* Quit */} + + + + + + + {/* === Menus & Dialogs === */} + + {/* Resources Popover */} + setResourcesAnchor(null)} + anchorOrigin={{ vertical: 'center', horizontal: 'left' }} + transformOrigin={{ vertical: 'center', horizontal: 'right' }} + PaperProps={{ sx: { width: 340, p: 2 } }} + > + + App Resources + + + + {monitoringActive && appResources ? ( + + {/* Total App Usage */} + theme.palette.mode === 'dark' ? 'rgba(0, 212, 170, 0.06)' : 'rgba(13, 155, 122, 0.05)', borderRadius: 1, mb: 1.5, border: '1px solid', borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0, 212, 170, 0.15)' : 'rgba(13, 155, 122, 0.15)' }}> + + TOTAL APP USAGE + + + Memory + + {appResources.totalMemoryMb >= 1024 + ? `${(appResources.totalMemoryMb / 1024).toFixed(2)} GB` + : `${appResources.totalMemoryMb.toFixed(0)} MB`} + + ({appResources.appMemoryPercent.toFixed(1)}% of system) + + + + + CPU + + {appResources.appCpuPercent.toFixed(1)}% + + ({appResources.systemCpuCount} cores) + + + + + + {/* Breakdown by Process */} + + Breakdown by Process + + + {/* Python Backend */} + + Python Backend + + {appResources.backend + ? `${appResources.backend.memoryMb.toFixed(0)} MB | ${(appResources.backend.cpuPercent / appResources.systemCpuCount).toFixed(1)}%` + : 'N/A'} + + + {/* Electron Main */} + + Electron Main + + {appResources.electronMain + ? `${appResources.electronMain.memoryMb.toFixed(0)} MB | ${(appResources.electronMain.cpuPercent / appResources.systemCpuCount).toFixed(1)}%` + : 'N/A'} + + + {/* Electron Renderer */} + + Electron Renderer + + {appResources.electronRenderer + ? `${appResources.electronRenderer.memoryMb.toFixed(0)} MB | ${(appResources.electronRenderer.cpuPercent / appResources.systemCpuCount).toFixed(1)}%` + : 'N/A'} + + + + + {/* System Info */} + + System: {(appResources.systemMemoryTotalMb / 1024).toFixed(0)} GB total |{' '} + {(appResources.systemMemoryAvailableMb / 1024).toFixed(1)} GB available + + + ) : ( + + + {monitoringActive ? 'Loading...' : 'Click "Paused" to start monitoring'} + + + )} + + + + {/* Notifications Menu */} + setNotificationsAnchor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'left' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + PaperProps={{ sx: { width: 320, maxHeight: 400 } }} + > + + Notifications + {notifications.length > 0 && ( + + )} + + + {notifications.length === 0 ? ( + + + No notifications + + + ) : ( + + {notifications.slice(0, 10).map((notification) => ( + + handleNotificationClick(notification)} + sx={{ + opacity: notification.read ? 0.6 : 1, + bgcolor: notification.read ? 'transparent' : 'action.hover', + }} + > + + {getNotificationIcon(notification.type)} + + + + + ))} + + )} + + + {/* Help Menu */} + setHelpAnchor(null)} + anchorOrigin={{ vertical: 'center', horizontal: 'left' }} + transformOrigin={{ vertical: 'center', horizontal: 'right' }} + > + { handleOpenExternal('https://docs.stingray.science/'); setHelpAnchor(null); }}> + + + + Documentation + + { handleOpenExternal('https://github.com/kartikmandar-GSOC24/StingrayExplorer/issues'); setHelpAnchor(null); }}> + + + + Report Issue + + { handleOpenExternal('https://github.com/StingraySoftware/stingray/discussions'); setHelpAnchor(null); }}> + + + + Community + + + { handleOpenExternal('https://github.com/StingraySoftware/stingray'); setHelpAnchor(null); }}> + + + + Stingray GitHub + + { handleOpenExternal('https://github.com/kartikmandar-GSOC24/StingrayExplorer'); setHelpAnchor(null); }}> + + + + Explorer GitHub + + + + {/* Debug Menu */} + setDebugAnchor(null)} + anchorOrigin={{ vertical: 'center', horizontal: 'left' }} + transformOrigin={{ vertical: 'center', horizontal: 'right' }} + > + { handleOpenDevTools(); setDebugAnchor(null); }}> + + + + Open DevTools + + { + addNotification({ type: 'success', title: 'Test', message: 'Test notification sent!' }); + setDebugAnchor(null); + }}> + + + + Test Notification + + + + + + + + + + + {/* About Dialog */} + setAboutOpen(false)} + maxWidth="sm" + fullWidth + > + + + Stingray Explorer { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + + Stingray Explorer + + {version ? `Version ${version}` : 'Desktop Application'} | © {new Date().getFullYear()} Kartik Mandar + + + + + + + Stingray Explorer is a comprehensive data analysis and visualization dashboard for X-ray astronomy + time series data. Built on top of the{' '} + handleOpenExternal('https://github.com/StingraySoftware/stingray')}> + Stingray + {' '} + Python library for spectral-timing analysis. + + + + Core Dependencies + + +
    +
  • + handleOpenExternal('https://stingray.readthedocs.io/')}> + Stingray + {' '} + - Spectral-timing software +
  • +
  • + handleOpenExternal('https://www.astropy.org/')}> + Astropy + {' '} + - Core astronomy library +
  • +
  • + handleOpenExternal('https://numpy.org/')}> + NumPy + {' '} + & SciPy - Scientific computing +
  • +
  • + handleOpenExternal('https://fastapi.tiangolo.com/')}> + FastAPI + {' '} + - Backend framework +
  • +
  • + handleOpenExternal('https://www.electronjs.org/')}> + Electron + {' '} + - Desktop framework +
  • +
  • + handleOpenExternal('https://react.dev/')}> + React + {' '} + & Material UI - Frontend +
  • +
+
+ + + Acknowledgements + + + This project was developed as part of Google Summer of Code 2024 under the OpenAstronomy + organization. Special thanks to the Stingray team and mentors for their guidance and support. + + + + Contact + + + Kartik Mandar -{' '} + handleOpenExternal('https://github.com/kartikmandar')}> + @kartikmandar + + +
+ + + + +
+ + {/* Notification Detail Dialog */} + + {selectedNotification && ( + <> + + {getNotificationIcon(selectedNotification.type)} + + {selectedNotification.title} + + + + + {selectedNotification.message} + + + {new Date(selectedNotification.timestamp).toLocaleString()} + + + + + + + )} + +
+ ); +}; + +export default RightToolbar; +export { TOOLBAR_WIDTH }; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..ffbc64c --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,478 @@ +import React, { useState, useEffect, useContext } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { + Box, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Typography, + IconButton, +} from '@mui/material'; +import HomeIcon from '@mui/icons-material/Home'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import AnalyticsIcon from '@mui/icons-material/Analytics'; +import BuildIcon from '@mui/icons-material/Build'; +import ModelTrainingIcon from '@mui/icons-material/ModelTraining'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import ScienceIcon from '@mui/icons-material/Science'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import CloseIcon from '@mui/icons-material/Close'; +import { ThemeContext } from '../../App'; +import JobStatusPanel from './JobStatusPanel'; + +// Sidebar widths +const MAIN_DRAWER_WIDTH = 240; +const SUB_DRAWER_WIDTH = 240; + +interface SidebarProps { + open: boolean; + onSubmenuStateChange: (isOpen: boolean) => void; +} + +interface SubmenuItem { + text: string; + path: string; +} + +interface MenuItem { + text: string; + icon: React.ReactNode; + path: string; + hasSubmenu?: boolean; + submenuItems?: SubmenuItem[]; +} + +/** + * Sidebar navigation component with VAST-style categorized submenu + */ +const Sidebar: React.FC = ({ open, onSubmenuStateChange }) => { + const navigate = useNavigate(); + const location = useLocation(); + const { darkMode } = useContext(ThemeContext); + const [activeSubmenu, setActiveSubmenu] = useState(null); + const [showSubmenu, setShowSubmenu] = useState(true); + + // Notify parent of submenu state changes + useEffect(() => { + onSubmenuStateChange(!!activeSubmenu && showSubmenu); + }, [activeSubmenu, showSubmenu, onSubmenuStateChange]); + + // Navigation items configuration + const menuItems: MenuItem[] = [ + { + text: 'Home', + icon: , + path: '/', + }, + { + text: 'Data Ingestion', + icon: , + path: '/data-ingestion', + }, + { + text: 'QuickLook Analysis', + icon: , + path: '/quicklook', + hasSubmenu: true, + submenuItems: [ + { text: 'Event List', path: '/quicklook/event-list' }, + { text: 'Light Curve', path: '/quicklook/light-curve' }, + { text: 'Power Spectrum', path: '/quicklook/power-spectrum' }, + { text: 'Avg Power Spectrum', path: '/quicklook/avg-power-spectrum' }, + { text: 'Cross Spectrum', path: '/quicklook/cross-spectrum' }, + { text: 'Avg Cross Spectrum', path: '/quicklook/avg-cross-spectrum' }, + { text: 'Dynamical Power Spectrum', path: '/quicklook/dynamical-power-spectrum' }, + { text: 'Coherence', path: '/quicklook/coherence' }, + { text: 'Time Lags', path: '/quicklook/time-lags' }, + { text: 'Cross Correlation', path: '/quicklook/cross-correlation' }, + { text: 'Auto Correlation', path: '/quicklook/auto-correlation' }, + { text: 'Dead Time Corrections', path: '/quicklook/dead-time-corrections' }, + { text: 'Bispectrum', path: '/quicklook/bispectrum' }, + { text: 'Power Colors', path: '/quicklook/power-colors' }, + { text: 'Covariance Spectrum', path: '/quicklook/covariance-spectrum' }, + { text: 'Avg Covariance Spectrum', path: '/quicklook/avg-covariance-spectrum' }, + { text: 'Variable Energy Spectrum', path: '/quicklook/variable-energy-spectrum' }, + { text: 'RMS Energy Spectrum', path: '/quicklook/rms-energy-spectrum' }, + { text: 'Lag Energy Spectrum', path: '/quicklook/lag-energy-spectrum' }, + { text: 'Excess Variance Spectrum', path: '/quicklook/excess-variance-spectrum' }, + ], + }, + { + text: 'Utilities', + icon: , + path: '/utilities', + hasSubmenu: true, + submenuItems: [ + { text: 'Statistical Functions', path: '/utilities/statistical-functions' }, + { text: 'GTI Functionality', path: '/utilities/gti' }, + { text: 'I/O Functionality', path: '/utilities/io' }, + { text: 'Mission Specific I/O', path: '/utilities/mission-io' }, + { text: 'Misc', path: '/utilities/misc' }, + ], + }, + { + text: 'Modeling', + icon: , + path: '/modeling', + hasSubmenu: true, + submenuItems: [ + { text: 'Model Builder', path: '/modeling/builder' }, + { text: 'MLE Fitting', path: '/modeling/mle' }, + { text: 'MCMC Fitting', path: '/modeling/mcmc' }, + ], + }, + { + text: 'Pulsar', + icon: , + path: '/pulsar', + hasSubmenu: true, + submenuItems: [ + { text: 'Period Search', path: '/pulsar/search' }, + { text: 'Phase Folding', path: '/pulsar/folding' }, + { text: 'Phaseogram', path: '/pulsar/phaseogram' }, + ], + }, + { + text: 'Simulator', + icon: , + path: '/simulator', + }, + ]; + + const getActiveMenuItem = (): MenuItem | undefined => { + return menuItems.find((item) => item.text === activeSubmenu); + }; + + const handleMenuClick = (item: MenuItem): void => { + if (item.hasSubmenu) { + setActiveSubmenu(item.text); + setShowSubmenu(true); + } else { + setActiveSubmenu(null); + setShowSubmenu(false); + navigate(item.path); + } + }; + + // Categorization for QuickLook submenu + const quicklookCategories: Record = { + 'Time Domain': ['Event List', 'Light Curve'], + 'Frequency Domain': [ + 'Power Spectrum', + 'Avg Power Spectrum', + 'Cross Spectrum', + 'Avg Cross Spectrum', + 'Dynamical Power Spectrum', + 'Coherence', + 'Time Lags', + ], + 'Correlation Analysis': ['Cross Correlation', 'Auto Correlation'], + 'Advanced Analysis': [ + 'Dead Time Corrections', + 'Bispectrum', + 'Power Colors', + 'Covariance Spectrum', + 'Avg Covariance Spectrum', + ], + 'Energy Dependent Analysis': [ + 'Variable Energy Spectrum', + 'RMS Energy Spectrum', + 'Lag Energy Spectrum', + 'Excess Variance Spectrum', + ], + }; + + // Active item styling + const getItemSx = (isActive: boolean) => ({ + pl: 2, + position: 'relative' as const, + '&::before': isActive + ? { + content: '""', + position: 'absolute', + left: 0, + top: '20%', + bottom: '20%', + width: '3px', + borderRadius: '0 2px 2px 0', + backgroundColor: 'primary.main', + boxShadow: darkMode + ? '0 0 8px rgba(0, 212, 170, 0.4), 0 0 16px rgba(0, 212, 170, 0.15)' + : '0 0 6px rgba(13, 155, 122, 0.3)', + } + : {}, + backgroundColor: isActive + ? darkMode + ? 'rgba(0, 212, 170, 0.08)' + : 'rgba(13, 155, 122, 0.08)' + : 'transparent', + '&:hover': { + backgroundColor: darkMode + ? 'rgba(0, 212, 170, 0.06)' + : 'rgba(13, 155, 122, 0.06)', + }, + }); + + const renderMainMenu = (): React.ReactNode => ( + + {menuItems.map((item) => { + const isActive = !item.hasSubmenu && location.pathname === item.path; + return ( + + handleMenuClick(item)} + selected={isActive} + sx={getItemSx(isActive)} + > + + {item.icon} + + + {item.hasSubmenu && ( + + )} + + + ); + })} + + ); + + const renderSubmenu = (): React.ReactNode => { + const activeItem = getActiveMenuItem(); + if (!activeItem?.submenuItems) return null; + + // Determine if we should use categories (only for QuickLook) + const useCategories = activeItem.text === 'QuickLook Analysis'; + const categories = useCategories ? quicklookCategories : null; + + return ( + <> + {/* Submenu header */} + + + {activeItem.text} + + setShowSubmenu(false)} sx={{ ml: 1 }}> + + + + + {/* Categorized or flat list */} + {categories ? ( + // Categorized rendering for QuickLook + Object.entries(categories).map(([category, items]) => ( + + + {category} + + + {activeItem.submenuItems + ?.filter((subItem) => items.includes(subItem.text)) + .map((subItem) => { + const isActive = location.pathname === subItem.path; + return ( + + navigate(subItem.path)} + selected={isActive} + sx={getItemSx(isActive)} + > + + + + ); + })} + + + )) + ) : ( + // Flat list for other menus + + {activeItem.submenuItems?.map((subItem) => { + const isActive = location.pathname === subItem.path; + return ( + + navigate(subItem.path)} + selected={isActive} + sx={getItemSx(isActive)} + > + + + + ); + })} + + )} + + ); + }; + + // Scrollbar styles + const scrollbarStyles = { + overflow: 'auto', + height: '100%', + '&::-webkit-scrollbar': { + width: '4px', + backgroundColor: 'transparent', + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: darkMode ? 'rgba(0, 212, 170, 0.2)' : 'rgba(13, 155, 122, 0.15)', + borderRadius: '4px', + '&:hover': { + backgroundColor: darkMode ? 'rgba(0, 212, 170, 0.35)' : 'rgba(13, 155, 122, 0.3)', + }, + }, + '&::-webkit-scrollbar-track': { + backgroundColor: 'transparent', + }, + }; + + // Sidebar background — slightly darker than paper for depth + const sidebarBg = darkMode ? '#0d1220' : '#f8f9fb'; + + return ( + <> + {/* Main Sidebar */} + theme.zIndex.drawer, + }} + > + {/* Menu items - scrollable */} + + {renderMainMenu()} + + + {/* Job Status Panel - fixed at bottom */} + + + + {/* Submenu Sidebar */} + theme.zIndex.drawer, + }} + > + + {renderSubmenu()} + + + + ); +}; + +export default Sidebar; diff --git a/src/components/plots/PlotlyChart.test.tsx b/src/components/plots/PlotlyChart.test.tsx new file mode 100644 index 0000000..c4ecf7d --- /dev/null +++ b/src/components/plots/PlotlyChart.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +vi.mock('react-plotly.js', () => ({ + default: ({ data, layout }: { data: unknown[]; layout: Record }) => ( +
).map((t) => t.type).join(',')} + data-xtype={(layout.xaxis as { type?: string })?.type ?? 'linear'} + data-ytype={(layout.yaxis as { type?: string })?.type ?? 'linear'} + data-xgrid={(layout.xaxis as { gridcolor?: string })?.gridcolor ?? ''} + /> + ), +})); + +import PlotlyChart from './PlotlyChart'; + +describe('PlotlyChart', () => { + it('renders traces and merges page layout over theme defaults', async () => { + render( + + ); + await waitFor(() => expect(screen.getByTestId('plotly-mock')).toBeInTheDocument()); + expect(screen.getByTestId('plotly-mock').dataset.traces).toBe('1'); + expect(screen.getByTestId('plotly-mock').dataset.xtype).toBe('log'); + expect(screen.getByTestId('plotly-mock').dataset.ytype).toBe('log'); + expect(screen.getByTestId('plotly-mock').dataset.xgrid).not.toBe(''); + }); + + it('falls back from scattergl to scatter when WebGL is unavailable', async () => { + // jsdom canvases have no WebGL: getContext('webgl') returns null, which is + // exactly the environment the fallback must handle (plotly would otherwise + // render "WebGL is not supported by your browser" into the plot div). + render( + + ); + await waitFor(() => expect(screen.getByTestId('plotly-mock')).toBeInTheDocument()); + expect(screen.getByTestId('plotly-mock').dataset.tracetypes).toBe('scatter,heatmap'); + }); +}); diff --git a/src/components/plots/PlotlyChart.tsx b/src/components/plots/PlotlyChart.tsx new file mode 100644 index 0000000..6b226b1 --- /dev/null +++ b/src/components/plots/PlotlyChart.tsx @@ -0,0 +1,88 @@ +import React, { Suspense, useMemo } from 'react'; +import { Box, CircularProgress, useTheme } from '@mui/material'; +import type { Config, Data, Layout } from 'plotly.js'; + +// plotly.js is ~3 MB; load it only when a page actually renders a chart. +const Plot = React.lazy(() => import('react-plotly.js')); + +export interface PlotlyChartProps { + data: Data[]; + layout?: Partial; + height?: number | string; +} + +const PLOT_CONFIG: Partial = { + responsive: true, + displaylogo: false, + modeBarButtonsToRemove: ['lasso2d', 'select2d', 'autoScale2d'], +}; + +// Plotly's gl traces request their context with failIfMajorPerformanceCaveat, +// so a renderer stuck in software compositing (e.g. after a GPU-process +// failure) refuses them and plotly renders "WebGL is not supported by your +// browser" into the plot div. Probe with the same flag once per session and +// transparently fall back to SVG scatter when gl isn't genuinely available. +let webglSupport: boolean | null = null; + +function webglAvailable(): boolean { + if (webglSupport === null) { + try { + const canvas = document.createElement('canvas'); + webglSupport = !!canvas.getContext('webgl', { failIfMajorPerformanceCaveat: true }); + } catch { + webglSupport = false; + } + } + return webglSupport; +} + +const PlotlyChart: React.FC = ({ data, layout = {}, height = 440 }) => { + const displayData = useMemo(() => { + if (webglAvailable()) return data; + return data.map((trace) => + (trace as { type?: string }).type === 'scattergl' + ? ({ ...trace, type: 'scatter' } as Data) + : trace + ); + }, [data]); + + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const gridColor = isDark ? 'rgba(148, 163, 184, 0.12)' : 'rgba(100, 116, 139, 0.2)'; + + const mergedLayout: Partial = { + autosize: true, + paper_bgcolor: 'rgba(0,0,0,0)', + plot_bgcolor: 'rgba(0,0,0,0)', + font: { + family: '"IBM Plex Sans", sans-serif', + size: 12, + color: theme.palette.text.primary, + }, + margin: { l: 64, r: 24, t: 24, b: 52 }, + showlegend: false, + ...layout, + xaxis: { gridcolor: gridColor, zeroline: false, ...layout.xaxis }, + yaxis: { gridcolor: gridColor, zeroline: false, ...layout.yaxis }, + }; + + return ( + + + + } + > + + + ); +}; + +export default PlotlyChart; diff --git a/src/components/utilities/GrantedFileField.test.tsx b/src/components/utilities/GrantedFileField.test.tsx new file mode 100644 index 0000000..5d2ceeb --- /dev/null +++ b/src/components/utilities/GrantedFileField.test.tsx @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import GrantedFileField, { GrantedFileSelection } from './GrantedFileField'; + +describe('GrantedFileField', () => { + const originalElectronApi = window.electronAPI; + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: originalElectronApi, + }); + }); + + it('returns a native-dialog selection and never exposes a typed path', async () => { + const selected = { path: '/data/response.rmf', grant: 'token' }; + const openGrantedFile = vi.fn().mockResolvedValue([selected]); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { openGrantedFile }, + }); + const onChange = vi.fn(); + render(); + expect(screen.getByLabelText('RMF file')).toHaveAttribute('readonly'); + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + expect(onChange).toHaveBeenCalledWith(selected); + }); + + it('preserves the current explicit selection when the dialog is cancelled', async () => { + const current: GrantedFileSelection = { path: '/data/current.fits', grant: 'old' }; + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { openGrantedFile: vi.fn().mockResolvedValue(null) }, + }); + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByLabelText('FITS file')).toHaveValue('/data/current.fits'); + }); + + it('shows native dialog failures without replacing the current selection', async () => { + const current: GrantedFileSelection = { path: '/data/current.fits', grant: 'old' }; + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { openGrantedFile: vi.fn().mockRejectedValue(new Error('dialog process failed')) }, + }); + const onChange = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + + expect(await screen.findByText(/dialog process failed/)).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByLabelText('FITS file')).toHaveValue('/data/current.fits'); + }); +}); diff --git a/src/components/utilities/GrantedFileField.tsx b/src/components/utilities/GrantedFileField.tsx new file mode 100644 index 0000000..87fec40 --- /dev/null +++ b/src/components/utilities/GrantedFileField.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Alert, Button, Stack, TextField } from '@mui/material'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; + +export interface GrantedFileSelection { + path: string; + grant: string; +} + +interface GrantedFileFieldProps { + label: string; + value: GrantedFileSelection | null; + onChange: (selection: GrantedFileSelection | null) => void; + filters?: { name: string; extensions: string[] }[]; + disabled?: boolean; +} + +/** Native file picker whose result is cryptographically bound to the backend path. */ +const GrantedFileField: React.FC = ({ + label, + value, + onChange, + filters, + disabled = false, +}) => { + const [dialogError, setDialogError] = React.useState(null); + + const choose = async (): Promise => { + setDialogError(null); + if (!window.electronAPI?.openGrantedFile) { + setDialogError('The native file dialog is unavailable.'); + return; + } + try { + const selected = await window.electronAPI.openGrantedFile({ title: label, filters }); + // Cancellation intentionally preserves the previous explicit selection. + if (selected?.[0]) onChange(selected[0]); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + setDialogError(`Could not open the native file dialog: ${detail}`); + } + }; + + return ( + + + + + + {dialogError ? {dialogError} : null} + + ); +}; + +export default GrantedFileField; diff --git a/src/components/utilities/UtilityResult.test.tsx b/src/components/utilities/UtilityResult.test.tsx new file mode 100644 index 0000000..98e0242 --- /dev/null +++ b/src/components/utilities/UtilityResult.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NumericResultTable } from './UtilityResult'; + +describe('NumericResultTable', () => { + const originalElectronApi = window.electronAPI; + + afterEach(() => { + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: originalElectronApi, + }); + }); + + it('renders and copies round-trippable numeric values without artificial truncation', async () => { + const copyToClipboard = vi.fn(); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { copyToClipboard }, + }); + render( + + ); + + expect(screen.getByText('0.12345678901234568')).toBeInTheDocument(); + expect(screen.getByText('1e-10')).toBeInTheDocument(); + expect(screen.getByText('-0')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Copy' })); + expect(copyToClipboard).toHaveBeenCalledWith( + 'Value (keV)\n0.12345678901234568\n1e-10\n-0' + ); + }); +}); diff --git a/src/components/utilities/UtilityResult.tsx b/src/components/utilities/UtilityResult.tsx new file mode 100644 index 0000000..5378590 --- /dev/null +++ b/src/components/utilities/UtilityResult.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { + Alert, + Box, + Button, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + Typography, +} from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; + +export interface ResultColumn { + key: string; + label: string; + unit?: string; +} + +export type ResultCell = number | string | boolean | null | undefined; + +function formatCell(value: ResultCell): string { + if (value == null) return 'null'; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return 'null'; + // Number#toString returns the shortest decimal representation that + // round-trips to the exact IEEE-754 value. Fixed significant-digit or + // exponential formatting would silently discard meaningful precision in + // both the rendered table and copied TSV output. + return Object.is(value, -0) ? '-0' : value.toString(); + } + return String(value); +} + +function formatColumnHeader(column: ResultColumn): string { + return `${column.label}${column.unit ? ` (${column.unit})` : ''}`; +} + +export const UtilityWarnings: React.FC<{ warnings?: string[] }> = ({ warnings = [] }) => + warnings.length > 0 ? ( + + + {warnings.map((warning, index) => ( + + {warning} + + ))} + + + ) : null; + +export const ProvenancePanel: React.FC<{ provenance?: Record }> = ({ provenance }) => + provenance ? ( + + + Provenance + + + {JSON.stringify(provenance, null, 2)} + + + ) : null; + +interface NumericResultTableProps { + title?: string; + columns: ResultColumn[]; + rows: Array>; + pageSize?: number; +} + +/** Paginated exact-value table shared by all Utility workbenches. */ +export const NumericResultTable: React.FC = ({ + title, + columns, + rows, + pageSize = 25, +}) => { + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(pageSize); + React.useEffect(() => setPage(0), [rows]); + const visible = rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); + const copy = (): void => { + const header = columns.map(formatColumnHeader).join('\t'); + const body = rows + .map((row) => columns.map((column) => formatCell(row[column.key])).join('\t')) + .join('\n'); + const text = `${header}\n${body}`; + if (window.electronAPI?.copyToClipboard) window.electronAPI.copyToClipboard(text); + else void navigator.clipboard?.writeText(text); + }; + + return ( + + + {title ?? 'Exact results'} + + + + + + + {columns.map((column) => ( + + {formatColumnHeader(column)} + + ))} + + + + {visible.map((row, rowIndex) => ( + + {columns.map((column) => ( + + {formatCell(row[column.key])} + + ))} + + ))} + +
+
+ setPage(next)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(event) => { + setRowsPerPage(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[10, 25, 50, 100]} + /> +
+ ); +}; diff --git a/src/context/BackendContext.test.tsx b/src/context/BackendContext.test.tsx new file mode 100644 index 0000000..96b4f6c --- /dev/null +++ b/src/context/BackendContext.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { BackendStatusProvider, useBackendContext } from './BackendContext'; +import type { BackendStatusSource } from '@/types/backendStatus'; + +const StatusConsumer: React.FC = () => { + const { isReady } = useBackendContext(); + return {isReady ? 'Backend Ready' : 'Starting...'}; +}; + +describe('BackendStatusProvider', () => { + it('reconciles its consumer to Backend Ready', async () => { + const source: BackendStatusSource = { + getBackendStatus: async () => ({ + revision: 6, + phase: 'ready', + port: 50017, + error: null, + }), + onBackendStatus: () => () => undefined, + }; + + render( + + + + ); + + expect(await screen.findByText('Backend Ready')).toBeInTheDocument(); + }); +}); diff --git a/src/context/BackendContext.tsx b/src/context/BackendContext.tsx new file mode 100644 index 0000000..b00d1a2 --- /dev/null +++ b/src/context/BackendContext.tsx @@ -0,0 +1,24 @@ +import React, { createContext, useContext } from 'react'; +import { useBackendStatus, type BackendState } from '@/hooks/useBackendStatus'; +import type { BackendStatusSource } from '@/types/backendStatus'; + +export const BackendContext = createContext({ + port: null, + isReady: false, + error: null, +}); + +export const useBackendContext = (): BackendState => useContext(BackendContext); + +interface BackendStatusProviderProps { + children: React.ReactNode; + source?: BackendStatusSource; +} + +export const BackendStatusProvider: React.FC = ({ + children, + source, +}) => { + const backendState = useBackendStatus(source); + return {children}; +}; diff --git a/src/hooks/useAnalysisRunner.test.tsx b/src/hooks/useAnalysisRunner.test.tsx new file mode 100644 index 0000000..882c34d --- /dev/null +++ b/src/hooks/useAnalysisRunner.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useAnalysisRunner } from './useAnalysisRunner'; +import { useUIStore } from '@/store/uiStore'; + +describe('useAnalysisRunner', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + }); + + it('stores the result and pushes a success notification', async () => { + const { result } = renderHook(() => useAnalysisRunner<{ v: number }>('Test Op')); + await act(async () => { + await result.current.run(async () => ({ + success: true, + data: { v: 42 }, + message: 'computed', + error: null, + })); + }); + expect(result.current.result?.v).toBe(42); + expect(result.current.running).toBe(false); + expect(result.current.error).toBeNull(); + const notes = useUIStore.getState().notifications; + expect(notes[0].type).toBe('success'); + expect(notes[0].title).toBe('Test Op'); + }); + + it('captures success:false as an error and keeps the previous result', async () => { + const { result } = renderHook(() => useAnalysisRunner<{ v: number }>('Test Op')); + await act(async () => { + await result.current.run(async () => ({ + success: true, + data: { v: 1 }, + message: '', + error: null, + })); + }); + await act(async () => { + await result.current.run(async () => ({ + success: false, + data: null, + message: 'bad dt', + error: 'bad dt', + warnings: ['Approximate conversion was not performed.'], + })); + }); + expect(result.current.error).toBe('bad dt'); + expect(result.current.result?.v).toBe(1); + expect(result.current.warnings).toEqual(['Approximate conversion was not performed.']); + expect(useUIStore.getState().notifications[0].type).toBe('error'); + }); + + it('reset clears result, error, and running', async () => { + const { result } = renderHook(() => useAnalysisRunner<{ v: number }>('Test Op')); + await act(async () => { + await result.current.run(async () => ({ + success: true, + data: { v: 7 }, + message: '', + error: null, + })); + }); + act(() => { + result.current.reset(); + }); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.running).toBe(false); + expect(result.current.warnings).toEqual([]); + }); + + it('captures thrown errors (network failures)', async () => { + const { result } = renderHook(() => useAnalysisRunner('Test Op')); + await act(async () => { + await result.current.run(async () => { + throw new Error('connection refused'); + }); + }); + expect(result.current.error).toBe('connection refused'); + }); +}); diff --git a/src/hooks/useAnalysisRunner.ts b/src/hooks/useAnalysisRunner.ts new file mode 100644 index 0000000..5987ab3 --- /dev/null +++ b/src/hooks/useAnalysisRunner.ts @@ -0,0 +1,74 @@ +import { useCallback, useState } from 'react'; +import { ApiResponse } from '@/api/client'; +import { useUIStore } from '@/store/uiStore'; + +interface AnalysisRunnerState { + result: T | null; + running: boolean; + error: string | null; + warnings: string[]; +} + +function dataWarnings(value: unknown): string[] { + if (typeof value !== 'object' || value === null || !('warnings' in value)) return []; + const warnings = (value as { warnings?: unknown }).warnings; + return Array.isArray(warnings) + ? warnings.filter((warning): warning is string => typeof warning === 'string') + : []; +} + +/** + * Owns the lifecycle of a single analysis request: running flag, last + * successful result, last error, and success/error notifications. + * On failure the previous result is kept so the plot doesn't vanish. + */ +export function useAnalysisRunner(label: string) { + const addNotification = useUIStore((s) => s.addNotification); + const [state, setState] = useState>({ + result: null, + running: false, + error: null, + warnings: [], + }); + + const run = useCallback( + async (call: () => Promise>): Promise => { + setState((s) => ({ ...s, running: true, error: null, warnings: [] })); + try { + const res = await call(); + if (res.success && res.data != null) { + const embeddedWarnings = dataWarnings(res.data); + setState({ + result: res.data, + running: false, + error: null, + warnings: (res.warnings ?? []).filter( + (warning) => !embeddedWarnings.includes(warning) + ), + }); + addNotification({ type: 'success', title: label, message: res.message || 'Done' }); + } else { + const msg = res.error || res.message || 'Operation failed'; + setState((s) => ({ + ...s, + running: false, + error: msg, + warnings: res.warnings ?? [], + })); + addNotification({ type: 'error', title: label, message: msg }); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setState((s) => ({ ...s, running: false, error: msg, warnings: [] })); + addNotification({ type: 'error', title: label, message: msg }); + } + }, + [label, addNotification] + ); + + const reset = useCallback((): void => { + setState({ result: null, running: false, error: null, warnings: [] }); + }, []); + + return { ...state, run, reset }; +} diff --git a/src/hooks/useBackendStatus.test.tsx b/src/hooks/useBackendStatus.test.tsx new file mode 100644 index 0000000..7ebe2b7 --- /dev/null +++ b/src/hooks/useBackendStatus.test.tsx @@ -0,0 +1,193 @@ +import { StrictMode } from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useBackendStatus } from './useBackendStatus'; +import type { BackendStatus, BackendStatusSource } from '@/types/backendStatus'; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +class FakeBackendStatusSource implements BackendStatusSource { + readonly listeners = new Set<(status: BackendStatus) => void>(); + readonly getBackendStatus: BackendStatusSource['getBackendStatus']; + readonly onBackendStatus = vi.fn((callback: (status: BackendStatus) => void) => { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + }); + + constructor(getBackendStatus: BackendStatusSource['getBackendStatus']) { + this.getBackendStatus = vi.fn(getBackendStatus); + } + + emit(status: BackendStatus): void { + for (const listener of this.listeners) listener(status); + } +} + +describe('useBackendStatus', () => { + it('recovers ready state from the snapshot when the ready event already occurred', async () => { + const source = new FakeBackendStatusSource(async () => ({ + revision: 4, + phase: 'ready', + port: 54321, + error: null, + })); + + const { result } = renderHook(() => useBackendStatus(source)); + + await waitFor(() => { + expect(result.current).toEqual({ port: 54321, isReady: true, error: null }); + }); + expect(source.onBackendStatus.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(source.getBackendStatus).mock.invocationCallOrder[0] + ); + }); + + it('does not let a delayed older snapshot overwrite a newer ready event', async () => { + const snapshot = deferred(); + const source = new FakeBackendStatusSource(() => snapshot.promise); + const { result } = renderHook(() => useBackendStatus(source)); + + act(() => { + source.emit({ revision: 8, phase: 'ready', port: 52123, error: null }); + }); + expect(result.current).toEqual({ port: 52123, isReady: true, error: null }); + + await act(async () => { + snapshot.resolve({ revision: 7, phase: 'starting', port: null, error: null }); + await snapshot.promise; + }); + expect(result.current).toEqual({ port: 52123, isReady: true, error: null }); + }); + + it('recovers from an exit error through restart starting and ready updates', async () => { + const source = new FakeBackendStatusSource(async () => ({ + revision: 1, + phase: 'error', + port: null, + error: 'The Python backend exited unexpectedly.', + })); + const { result } = renderHook(() => useBackendStatus(source)); + await waitFor(() => { + expect(result.current.error).toBe('The Python backend exited unexpectedly.'); + }); + + act(() => { + source.emit({ revision: 2, phase: 'starting', port: null, error: null }); + }); + expect(result.current).toEqual({ port: null, isReady: false, error: null }); + + act(() => { + source.emit({ revision: 3, phase: 'ready', port: 52001, error: null }); + }); + expect(result.current).toEqual({ port: 52001, isReady: true, error: null }); + }); + + it('restores an unexpected-exit error from the atomic snapshot after reload', async () => { + const source = new FakeBackendStatusSource(async () => ({ + revision: 12, + phase: 'error', + port: null, + error: 'The Python backend exited unexpectedly (signal SIGKILL).', + })); + + const { result } = renderHook(() => useBackendStatus(source)); + + await waitFor(() => { + expect(result.current).toEqual({ + port: null, + isReady: false, + error: 'The Python backend exited unexpectedly (signal SIGKILL).', + }); + }); + }); + + it('maps backend errors to a not-ready state with the supplied error', async () => { + const source = new FakeBackendStatusSource(async () => ({ + revision: 1, + phase: 'starting', + port: null, + error: null, + })); + const { result } = renderHook(() => useBackendStatus(source)); + await waitFor(() => expect(source.getBackendStatus).toHaveBeenCalledOnce()); + + act(() => { + source.emit({ + revision: 2, + phase: 'error', + port: null, + error: 'Authenticated readiness failed', + }); + }); + expect(result.current).toEqual({ + port: null, + isReady: false, + error: 'Authenticated readiness failed', + }); + }); + + it('ignores an abandoned Strict Mode snapshot and removes both listeners', async () => { + const firstSnapshot = deferred(); + const secondSnapshot = deferred(); + let snapshotCalls = 0; + const source = new FakeBackendStatusSource(() => { + snapshotCalls += 1; + return snapshotCalls === 1 ? firstSnapshot.promise : secondSnapshot.promise; + }); + + const { result, unmount } = renderHook(() => useBackendStatus(source), { + wrapper: StrictMode, + }); + await waitFor(() => expect(source.onBackendStatus).toHaveBeenCalledTimes(2)); + expect(source.listeners.size).toBe(1); + + await act(async () => { + secondSnapshot.resolve({ revision: 5, phase: 'ready', port: 53001, error: null }); + await secondSnapshot.promise; + }); + expect(result.current).toEqual({ port: 53001, isReady: true, error: null }); + + await act(async () => { + firstSnapshot.resolve({ + revision: 99, + phase: 'error', + port: null, + error: 'abandoned effect', + }); + await firstSnapshot.promise; + }); + expect(result.current).toEqual({ port: 53001, isReady: true, error: null }); + + unmount(); + expect(source.listeners.size).toBe(0); + expect(source.onBackendStatus.mock.results).toHaveLength(2); + }); + + it('handles a rejected snapshot without an unhandled rejection', async () => { + const unhandledRejection = vi.fn(); + window.addEventListener('unhandledrejection', unhandledRejection); + const source = new FakeBackendStatusSource(() => + Promise.reject(new Error('temporary IPC failure')) + ); + + const { result } = renderHook(() => useBackendStatus(source)); + await waitFor(() => expect(source.getBackendStatus).toHaveBeenCalledOnce()); + await act(async () => Promise.resolve()); + + expect(result.current).toEqual({ port: null, isReady: false, error: null }); + expect(unhandledRejection).not.toHaveBeenCalled(); + window.removeEventListener('unhandledrejection', unhandledRejection); + }); +}); diff --git a/src/hooks/useBackendStatus.ts b/src/hooks/useBackendStatus.ts new file mode 100644 index 0000000..4ea4773 --- /dev/null +++ b/src/hooks/useBackendStatus.ts @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react'; +import type { BackendStatus, BackendStatusSource } from '@/types/backendStatus'; + +export interface BackendState { + port: number | null; + isReady: boolean; + error: string | null; +} + +export const initialBackendState: BackendState = { + port: null, + isReady: false, + error: null, +}; + +export function backendStateFromStatus(status: BackendStatus): BackendState { + switch (status.phase) { + case 'ready': + if (status.port === null) { + return { + port: null, + isReady: false, + error: 'The backend reported readiness without an available port.', + }; + } + return { port: status.port, isReady: true, error: null }; + case 'error': + return { + port: null, + isReady: false, + error: status.error ?? 'The Python backend failed to start.', + }; + case 'starting': + case 'stopped': + return initialBackendState; + } +} + +function getDefaultSource(): BackendStatusSource | null { + if (typeof window === 'undefined' || !window.electronAPI) return null; + return window.electronAPI; +} + +export function useBackendStatus(source?: BackendStatusSource): BackendState { + const [backendState, setBackendState] = useState(initialBackendState); + const statusSource = source ?? getDefaultSource(); + + useEffect(() => { + if (!statusSource) return; + + let active = true; + let greatestRevision = -1; + let unsubscribe = (): void => undefined; + + const applyStatus = (status: BackendStatus): void => { + if (!active || status.revision <= greatestRevision) return; + greatestRevision = status.revision; + setBackendState(backendStateFromStatus(status)); + }; + + // Subscribe before reading the snapshot so a transition that occurs during + // the IPC round trip cannot be lost. Revisions keep a delayed snapshot from + // overwriting a newer event. + try { + unsubscribe = statusSource.onBackendStatus(applyStatus); + } catch { + // The atomic snapshot can still reconcile a recoverable subscription + // failure. A later mount or renderer reload will try the handshake again. + } + + try { + void statusSource.getBackendStatus().then(applyStatus, () => undefined); + } catch { + // Treat a synchronous bridge failure like a rejected IPC request. Keeping + // the last known state is safer than manufacturing a backend error. + } + + return () => { + active = false; + unsubscribe(); + }; + }, [statusSource]); + + return backendState; +} diff --git a/src/hooks/useEventLists.test.tsx b/src/hooks/useEventLists.test.tsx new file mode 100644 index 0000000..21c0364 --- /dev/null +++ b/src/hooks/useEventLists.test.tsx @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...args: unknown[]) => listEventLists(...args) }, +})); + +import { useEventLists } from './useEventLists'; + +/** Mint a fresh QueryClient per test, outside the wrapper render path. */ +const createWrapper = (): React.FC<{ children: React.ReactNode }> => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} + ); + return Wrapper; +}; + +describe('useEventLists', () => { + beforeEach(() => listEventLists.mockReset()); + + it('returns event list summaries on success', async () => { + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'ev1', n_events: 10, time_range: [0, 1] }], + message: '', + error: null, + }); + const { result } = renderHook(() => useEventLists(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.[0].name).toBe('ev1'); + }); + + it('surfaces a success:false response as a query error', async () => { + listEventLists.mockResolvedValue({ success: false, data: null, message: 'boom', error: 'boom' }); + const { result } = renderHook(() => useEventLists(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect((result.current.error as Error).message).toBe('boom'); + }); +}); diff --git a/src/hooks/useEventLists.ts b/src/hooks/useEventLists.ts new file mode 100644 index 0000000..621a360 --- /dev/null +++ b/src/hooks/useEventLists.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; +import { dataApi, EventListSummary } from '@/api/dataApi'; + +export const EVENT_LISTS_QUERY_KEY = ['eventLists'] as const; + +/** + * Loaded event lists from the backend. The ApiClient re-resolves the backend + * port on every request, so this works without gating on backend readiness; + * failures surface as query errors with a retry affordance in the UI. + */ +export function useEventLists() { + return useQuery({ + queryKey: EVENT_LISTS_QUERY_KEY, + queryFn: async (): Promise => { + const res = await dataApi.listEventLists(); + if (!res.success) { + throw new Error(res.error || res.message || 'Failed to list event lists'); + } + return res.data ?? []; + }, + staleTime: 5_000, + }); +} diff --git a/src/hooks/useJobStream.test.ts b/src/hooks/useJobStream.test.ts new file mode 100644 index 0000000..cb0d082 --- /dev/null +++ b/src/hooks/useJobStream.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildCompletionMessage, handleBatchWarnings } from './useJobStream'; +import type { Job } from '@/types/job'; + +const baseJob: Job = { + id: 'job-id', + type: 'load_event_list', + status: 'completed', + progress: 1, + progress_message: 'Completed', + total_items: 1, + completed_items: 1, + created_at: '2026-08-11T00:00:00Z', + started_at: '2026-08-11T00:00:01Z', + completed_at: '2026-08-11T00:00:02Z', + result: null, + error: null, + display_name: 'Load events', +}; + +describe('redacted job stream presentation', () => { + it('builds completion text from the allowlisted single-result summary', () => { + const message = buildCompletionMessage({ + ...baseJob, + result: { + event_count: 12_345, + time_start: 10, + time_end: 15.5, + warnings: ['GTI validation reported warnings'], + }, + }); + + expect(message).toBe('Load events (12,345 events, 5.50s duration, 1 warning)'); + }); + + it('uses redacted batch names and generic errors without requiring a path', () => { + const addNotification = vi.fn(); + const job: Job = { + ...baseJob, + type: 'load_batch', + display_name: 'Batch load', + total_items: 2, + result: { + successful: [{ name: 'first' }], + failed: [ + { name: 'second', error: 'The selected file could not be loaded' }, + ], + success_count: 1, + failure_count: 1, + total_files: 2, + }, + }; + + expect(buildCompletionMessage(job)).toBe('Batch load (1/2 files loaded)'); + handleBatchWarnings(job, addNotification); + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + title: 'Failed: second', + message: 'The selected file could not be loaded', + }); + expect(JSON.stringify(job)).not.toMatch(/file_path|grant|url|params/); + }); +}); diff --git a/src/hooks/useJobStream.ts b/src/hooks/useJobStream.ts new file mode 100644 index 0000000..218d51a --- /dev/null +++ b/src/hooks/useJobStream.ts @@ -0,0 +1,289 @@ +/** + * React hook for managing the SSE connection to the job stream. + * + * This hook establishes and maintains a persistent SSE connection to + * receive real-time job updates. It handles connection lifecycle, + * automatic reconnection on disconnect, and updates the job store. + * It also triggers detailed notifications when jobs complete or fail, + * including individual error notifications for batch job failures. + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useJobStore } from '@/store/jobStore'; +import { useUIStore, NotificationType } from '@/store/uiStore'; +import { useBackendContext } from '@/context/BackendContext'; +import { jobApi } from '@/api/jobApi'; +import { EVENT_LISTS_QUERY_KEY } from '@/hooks/useEventLists'; +import type { Job, JobStreamEvent } from '@/types/job'; + +/** Reconnection delay in milliseconds */ +const RECONNECT_DELAY = 5000; + +/** Maximum reconnection attempts before giving up */ +const MAX_RECONNECT_ATTEMPTS = 10; + +/** + * Build a detailed notification message from a completed job. + */ +export function buildCompletionMessage(job: Job): string { + const result = job.result || {}; + const parts: string[] = []; + + // Add event count if available + if (typeof result.event_count === 'number') { + parts.push(`${result.event_count.toLocaleString()} events`); + } + + // Add time range if available + if (typeof result.time_start === 'number' && typeof result.time_end === 'number') { + const duration = (result.time_end as number) - (result.time_start as number); + parts.push(`${duration.toFixed(2)}s duration`); + } + + // Add warnings count if any + if (Array.isArray(result.warnings) && result.warnings.length > 0) { + parts.push(`${result.warnings.length} warning${result.warnings.length > 1 ? 's' : ''}`); + } + + // For batch jobs, show success/total counts + if (job.type === 'load_batch' && typeof result.success_count === 'number') { + const total = result.total_files || job.total_items; + parts.push(`${result.success_count}/${total} files loaded`); + } + + if (parts.length > 0) { + return `${job.display_name} (${parts.join(', ')})`; + } + + return job.display_name; +} + +/** + * Handle batch job warnings and failures by creating individual notifications. + * + * For batch jobs that complete with partial failures: + * - Creates an error notification for each failed file with its specific error + * - Optionally could surface GTI/stingray warnings from successful files + */ +export function handleBatchWarnings( + job: Job, + addNotification: (notification: { type: NotificationType; title: string; message: string }) => void +): void { + const result = job.result; + + if (!result) { + return; + } + + const failed = result.failed; + + // Create individual error notifications for each failed file + if (Array.isArray(failed) && failed.length > 0) { + for (const failedFile of failed) { + addNotification({ + type: 'error', + title: `Failed: ${failedFile.name}`, + message: failedFile.error || 'Unknown error occurred', + }); + } + } + +} + +/** + * Hook to manage the job stream SSE connection. + * + * Should be called once at the app root level to establish and + * maintain the connection throughout the app lifecycle. + */ +export function useJobStream(): void { + const { isReady: backendReady, port } = useBackendContext(); + const { + setConnected, + handleEvent, + incrementReconnectAttempts, + resetReconnectAttempts, + reconnectAttempts, + } = useJobStore(); + const addNotification = useUIStore((state) => state.addNotification); + const queryClient = useQueryClient(); + + // Track if we should be connected + const shouldConnectRef = useRef(false); + + // Track the abort controller for cleanup + const abortControllerRef = useRef(null); + + // Track reconnection timeout + const reconnectTimeoutRef = useRef | null>(null); + + // Track which jobs we've already notified to avoid duplicates on reconnection + const notifiedJobsRef = useRef>(new Set()); + + /** + * Handle job completion/failure notifications. + * Only triggers notifications for jobs we haven't already notified about. + */ + const handleJobNotification = useCallback((event: JobStreamEvent): void => { + // Only handle completion and failure events + if (event.type !== 'job_completed' && event.type !== 'job_failed') { + return; + } + + const job = event.job; + + // Skip if we've already notified about this job + if (notifiedJobsRef.current.has(job.id)) { + return; + } + + // Mark as notified + notifiedJobsRef.current.add(job.id); + + // Clean up old entries (keep last 100 to avoid memory leak) + if (notifiedJobsRef.current.size > 100) { + const entries = Array.from(notifiedJobsRef.current); + notifiedJobsRef.current = new Set(entries.slice(-100)); + } + + if (event.type === 'job_completed') { + // All job types load event lists, so refresh any mounted event list + // queries (e.g. analysis page selectors). Kept inside the + // notifiedJobsRef dedup guard so SSE reconnect replays don't + // re-invalidate for jobs we've already processed. + void queryClient.invalidateQueries({ queryKey: EVENT_LISTS_QUERY_KEY }); + + const message = buildCompletionMessage(job); + addNotification({ + type: 'success', + title: 'Data Loaded', + message, + }); + + // For batch jobs, show individual error notifications for failed files + if (job.type === 'load_batch') { + handleBatchWarnings(job, addNotification); + } + } else if (event.type === 'job_failed') { + addNotification({ + type: 'error', + title: 'Load Failed', + message: job.error || `Failed to load: ${job.display_name}`, + }); + } + }, [addNotification, queryClient]); + + const connect = useCallback(async () => { + if (!backendReady || !port) { + console.log('[JobStream] Backend not ready, waiting...'); + return; + } + + if (!shouldConnectRef.current) { + console.log('[JobStream] Connection not requested'); + return; + } + + // Cancel any pending reconnection + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + // Create new abort controller + abortControllerRef.current = new AbortController(); + + console.log('[JobStream] Connecting to job stream...'); + + try { + // Process events from the stream + for await (const event of jobApi.streamJobUpdates(abortControllerRef.current.signal)) { + // Check if we should stop + if (abortControllerRef.current?.signal.aborted) { + console.log('[JobStream] Connection aborted'); + break; + } + + // On first event, mark as connected + setConnected(true, null); + resetReconnectAttempts(); + + // Handle the event (update job store) + handleEvent(event); + + // Trigger notifications for completed/failed jobs + handleJobNotification(event); + + // Log non-heartbeat events for debugging + if (event.type !== 'heartbeat') { + console.log('[JobStream] Event:', event.type); + } + } + + // Stream ended normally + console.log('[JobStream] Stream ended'); + setConnected(false, null); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('[JobStream] Connection error:', errorMessage); + setConnected(false, errorMessage); + } + + // Schedule reconnection if we should still be connected + if (shouldConnectRef.current && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + incrementReconnectAttempts(); + const delay = RECONNECT_DELAY * Math.min(reconnectAttempts + 1, 3); // Exponential backoff up to 3x + console.log(`[JobStream] Reconnecting in ${delay}ms (attempt ${reconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`); + reconnectTimeoutRef.current = setTimeout(connect, delay); + } else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + console.error('[JobStream] Max reconnection attempts reached, giving up'); + setConnected(false, 'Max reconnection attempts reached'); + } + }, [ + backendReady, + port, + setConnected, + handleEvent, + handleJobNotification, + incrementReconnectAttempts, + resetReconnectAttempts, + reconnectAttempts, + ]); + + const disconnect = useCallback(() => { + shouldConnectRef.current = false; + + // Cancel pending reconnection + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + // Abort current connection + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + + setConnected(false, null); + console.log('[JobStream] Disconnected'); + }, [setConnected]); + + // Connect when backend becomes ready + useEffect(() => { + if (backendReady && port) { + shouldConnectRef.current = true; + connect(); + } + + return () => { + disconnect(); + }; + }, [backendReady, port, connect, disconnect]); + + // Reconnect when reconnectAttempts changes (for reconnection loop) + // This effect is intentionally left with an empty dep array to avoid loops +} + +export default useJobStream; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..b94ab1e --- /dev/null +++ b/src/index.css @@ -0,0 +1,287 @@ +/* ========================================================================== + Stingray Explorer — Observatory Dark Global Styles + ========================================================================== */ + +/* Font Imports (self-hosted via @fontsource) */ +@import '@fontsource/jetbrains-mono/400.css'; +@import '@fontsource/jetbrains-mono/500.css'; +@import '@fontsource/jetbrains-mono/700.css'; +@import '@fontsource/ibm-plex-sans/400.css'; +@import '@fontsource/ibm-plex-sans/400-italic.css'; +@import '@fontsource/ibm-plex-sans/500.css'; +@import '@fontsource/ibm-plex-sans/600.css'; +@import '@fontsource/ibm-plex-sans/700.css'; +@import '@fontsource/ibm-plex-mono/400.css'; +@import '@fontsource/ibm-plex-mono/700.css'; + +/* -------------------------------------------------------------------------- + CSS Custom Properties + -------------------------------------------------------------------------- */ +:root { + /* Glass effects */ + --glass-blur: 12px; + --glass-saturation: 150%; + --glass-bg-dark: rgba(18, 24, 41, 0.6); + --glass-bg-light: rgba(255, 255, 255, 0.7); + --glass-border: rgba(148, 163, 184, 0.12); + + /* Glow effects */ + --glow-primary: rgba(0, 212, 170, 0.15); + --glow-secondary: rgba(59, 130, 246, 0.15); + --glow-stingray: rgba(94, 173, 97, 0.12); + + /* Grain */ + --grain-opacity: 0.03; + + /* Transitions */ + --transition-smooth: cubic-bezier(0.22, 0.61, 0.36, 1); + --transition-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + + /* Font stacks */ + --font-display: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + --font-body: 'IBM Plex Sans', 'Source Sans 3', -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'Fira Code', monospace; +} + +/* -------------------------------------------------------------------------- + Reset & Base + -------------------------------------------------------------------------- */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + width: 100%; +} + +body { + font-family: var(--font-body); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; +} + +/* -------------------------------------------------------------------------- + Grain Texture Overlay (atmospheric depth) + -------------------------------------------------------------------------- */ +body::after { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; + opacity: var(--grain-opacity); + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 256px 256px; +} + +/* -------------------------------------------------------------------------- + Scrollbar — Teal-tinted, thin + -------------------------------------------------------------------------- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: rgba(0, 212, 170, 0.2); + border-radius: 3px; + transition: background-color 0.2s ease; +} + +::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 212, 170, 0.4); +} + +/* Light mode scrollbar */ +[data-theme='light'] ::-webkit-scrollbar-thumb { + background-color: rgba(13, 155, 122, 0.2); +} + +[data-theme='light'] ::-webkit-scrollbar-thumb:hover { + background-color: rgba(13, 155, 122, 0.35); +} + +/* -------------------------------------------------------------------------- + Selection & Focus + -------------------------------------------------------------------------- */ +::selection { + background-color: rgba(0, 212, 170, 0.3); + color: #e2e8f0; +} + +:focus-visible { + outline: 2px solid rgba(0, 212, 170, 0.6); + outline-offset: 2px; +} + +/* -------------------------------------------------------------------------- + Utility Classes + -------------------------------------------------------------------------- */ + +/* Prevent text selection on UI elements */ +.no-select { + user-select: none; + -webkit-user-select: none; +} + +/* Drag region for window (macOS) */ +.titlebar-drag-region { + -webkit-app-region: drag; +} + +.titlebar-no-drag { + -webkit-app-region: no-drag; +} + +/* Glass surface utility */ +.glass-surface { + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid var(--glass-border); +} + +/* Glow hover utility */ +.glow-hover { + transition: box-shadow 0.3s var(--transition-smooth), + border-color 0.3s var(--transition-smooth); +} + +.glow-hover:hover { + box-shadow: 0 0 20px var(--glow-primary), 0 0 40px rgba(0, 212, 170, 0.05); + border-color: rgba(0, 212, 170, 0.3); +} + +/* Stagger reveal — children animate in sequence */ +.stagger-reveal > * { + opacity: 0; + animation: fadeInUp 0.5s var(--transition-smooth) forwards; +} + +.stagger-reveal > *:nth-child(1) { animation-delay: 0ms; } +.stagger-reveal > *:nth-child(2) { animation-delay: 80ms; } +.stagger-reveal > *:nth-child(3) { animation-delay: 160ms; } +.stagger-reveal > *:nth-child(4) { animation-delay: 240ms; } +.stagger-reveal > *:nth-child(5) { animation-delay: 320ms; } +.stagger-reveal > *:nth-child(6) { animation-delay: 400ms; } +.stagger-reveal > *:nth-child(7) { animation-delay: 480ms; } +.stagger-reveal > *:nth-child(8) { animation-delay: 560ms; } + +/* Gradient border bottom */ +.gradient-border-bottom { + border-image: linear-gradient(to right, #00d4aa, #3b82f6, transparent) 1; +} + +/* Force display font */ +.text-display { + font-family: var(--font-display) !important; +} + +/* Force mono font */ +.text-mono { + font-family: var(--font-mono) !important; +} + +/* -------------------------------------------------------------------------- + Plotly Chart Container + -------------------------------------------------------------------------- + Note: never add a global .js-plotly-plot width/height !important rule here. + It overrides PlotlyChart's explicit inline sizing and collapses every chart + to zero height inside auto-height containers (the plot renders but occupies + no space until a window resize forces a re-measure). */ + +/* -------------------------------------------------------------------------- + Keyframe Animations + -------------------------------------------------------------------------- */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + transform: translateX(-20px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes glowPulse { + 0%, 100% { box-shadow: 0 0 8px var(--glow-primary); } + 50% { box-shadow: 0 0 20px var(--glow-primary); } +} + +@keyframes borderGlow { + 0%, 100% { border-color: rgba(0, 212, 170, 0.2); } + 50% { border-color: rgba(0, 212, 170, 0.5); } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes statusPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes starTwinkle { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 1; } +} + +/* Animation utility classes */ +.animate-fade-in { + animation: fadeIn 0.2s ease-in-out; +} + +.animate-slide-in { + animation: slideIn 0.3s ease-out; +} + +.animate-fade-in-up { + animation: fadeInUp 0.5s var(--transition-smooth); +} + +/* Loading spinner */ +.loading-spinner { + display: inline-block; + width: 24px; + height: 24px; + border: 3px solid rgba(0, 212, 170, 0.1); + border-radius: 50%; + border-top-color: #00d4aa; + animation: spin 1s ease-in-out infinite; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..ea3ecf9 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +// Render the application +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/src/pages/DataIngestion/FileBrowserDialog.test.tsx b/src/pages/DataIngestion/FileBrowserDialog.test.tsx new file mode 100644 index 0000000..a4f6e4f --- /dev/null +++ b/src/pages/DataIngestion/FileBrowserDialog.test.tsx @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderWithProviders } from '@/test/testUtils'; + +const listObservationFiles = vi.fn(); +const downloadToDiskSSE = vi.fn(); +const addNotification = vi.fn(); + +vi.mock('@/api/archiveApi', () => ({ + archiveApi: { + listObservationFiles: (...args: unknown[]) => listObservationFiles(...args), + downloadToDiskSSE: (...args: unknown[]) => downloadToDiskSSE(...args), + }, +})); + +vi.mock('@/store/uiStore', () => ({ + useUIStore: () => ({ addNotification }), +})); + +import FileBrowserDialog from './FileBrowserDialog'; + +const OBS_DATA = { ra: 83.63, dec: 22.01 }; +const FILE_TREE = { + success: true, + data: { + base_url: 'https://heasarc.gsfc.nasa.gov/FTP/nicer/data/obs/', + mission: 'NICER', + obsid: '1234', + total_files: 1, + files: [ + { + path: 'event.evt', + name: 'event.evt', + is_directory: false, + file_type: 'event', + size_bytes: 12, + size_display: '12 B', + full_url: 'https://heasarc.gsfc.nasa.gov/FTP/nicer/data/obs/event.evt', + }, + ], + }, + message: 'Found one file', + error: null, +}; + +function renderDialog(onDownloadComplete = vi.fn()) { + renderWithProviders( + + ); + return onDownloadComplete; +} + +describe('FileBrowserDialog secure archive download', () => { + const saveGrantedFile = vi.fn(); + const rawSaveFile = vi.fn(); + const rawShowItemInFolder = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + listObservationFiles.mockResolvedValue(FILE_TREE); + saveGrantedFile.mockResolvedValue({ + path: '/verified/download.evt', + grant: 'write-grant-secret', + }); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + saveGrantedFile, + saveFile: rawSaveFile, + showItemInFolder: rawShowItemInFolder, + }, + }); + }); + + it('uses only a native write grant and renders no destination path or grant', async () => { + downloadToDiskSSE.mockImplementation(async function* () { + yield { + type: 'progress', + bytes_downloaded: 12, + total_bytes: 12, + percent: 100, + }; + yield { + type: 'complete', + file_name: 'download.evt', + size_bytes: 12, + sha256: 'a'.repeat(64), + warnings: [], + }; + yield { type: 'error', error: 'must not replace confirmed completion' }; + }); + const onDownloadComplete = renderDialog(); + + expect(await screen.findByText('event.evt')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + + await waitFor(() => { + expect(downloadToDiskSSE).toHaveBeenCalledTimes(1); + }); + expect(saveGrantedFile).toHaveBeenCalledWith({ + title: 'Save Downloaded File', + defaultPath: 'event.evt', + filters: [ + { name: 'FITS Files', extensions: ['fits', 'fits.gz', 'evt', 'evt.gz'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + expect(downloadToDiskSSE).toHaveBeenCalledWith( + expect.objectContaining({ + url: FILE_TREE.data.files[0].full_url, + destination_path: '/verified/download.evt', + destination_grant: 'write-grant-secret', + signal: expect.any(AbortSignal), + }) + ); + expect(await screen.findByText('Downloaded download.evt (12 B)')).toBeInTheDocument(); + expect(screen.queryByText('/verified/download.evt')).not.toBeInTheDocument(); + expect(screen.queryByText('write-grant-secret')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Show in Folder' })).not.toBeInTheDocument(); + expect(rawSaveFile).not.toHaveBeenCalled(); + expect(rawShowItemInFolder).not.toHaveBeenCalled(); + expect(onDownloadComplete).toHaveBeenCalledWith(); + expect(screen.queryByText('must not replace confirmed completion')).not.toBeInTheDocument(); + }); + + it('reports native grant issuance failure without starting a download', async () => { + saveGrantedFile.mockRejectedValue( + new Error('sensitive backend failure at /private/destination') + ); + renderDialog(); + + expect(await screen.findByText('event.evt')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + + await waitFor(() => { + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + title: 'Secure Save Failed', + message: 'Could not authorize the selected destination. Please try again.', + }); + }); + expect(downloadToDiskSSE).not.toHaveBeenCalled(); + expect(screen.queryByText('/private/destination')).not.toBeInTheDocument(); + }); + + it('passes an AbortSignal and reports cancellation without publishing success', async () => { + let observedSignal: AbortSignal | undefined; + downloadToDiskSSE.mockImplementation(async function* (params: { + signal: AbortSignal; + }) { + observedSignal = params.signal; + yield { + type: 'progress', + bytes_downloaded: 4, + total_bytes: 12, + percent: 33.3, + }; + await new Promise((_resolve, reject) => { + params.signal.addEventListener( + 'abort', + () => reject(new DOMException('aborted', 'AbortError')), + { once: true } + ); + }); + }); + const onDownloadComplete = renderDialog(); + + expect(await screen.findByText('event.evt')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(await screen.findByRole('button', { name: 'Cancel Download' })).toBeEnabled(); + await userEvent.click(screen.getByRole('button', { name: 'Cancel Download' })); + + expect(observedSignal?.aborted).toBe(true); + expect(await screen.findByText('Download cancelled')).toBeInTheDocument(); + expect(onDownloadComplete).not.toHaveBeenCalled(); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Download Failed', message: 'Download cancelled' }) + ); + expect(rawSaveFile).not.toHaveBeenCalled(); + expect(rawShowItemInFolder).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/DataIngestion/FileBrowserDialog.tsx b/src/pages/DataIngestion/FileBrowserDialog.tsx new file mode 100644 index 0000000..b5d5905 --- /dev/null +++ b/src/pages/DataIngestion/FileBrowserDialog.tsx @@ -0,0 +1,699 @@ +/** + * File Browser Dialog for HEASARC observations + * + * Shows a tree view of files in an observation directory, + * allowing users to select and download files to their local disk. + */ + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + CircularProgress, + Alert, + Checkbox, + IconButton, + Collapse, + LinearProgress, + Chip, +} from '@mui/material'; +import FolderIcon from '@mui/icons-material/Folder'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; +import StarIcon from '@mui/icons-material/Star'; +import SettingsIcon from '@mui/icons-material/Settings'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import DescriptionIcon from '@mui/icons-material/Description'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import SaveAltIcon from '@mui/icons-material/SaveAlt'; +import { + archiveApi, + FileEntry, + FileType, +} from '@/api/archiveApi'; +import { useUIStore } from '@/store/uiStore'; + +interface ObsData { + ra?: number | null; + dec?: number | null; + prnb?: string; +} + +interface FileBrowserDialogProps { + open: boolean; + onClose: () => void; + mission: string; + obsid: string; + obsTime: string; + targetName: string; + obsData?: ObsData; + onDownloadComplete?: () => void; +} + +interface DownloadState { + status: 'idle' | 'downloading' | 'complete' | 'error'; + message: string; + percent: number; + error?: string; +} + +// File type to icon mapping +const getFileIcon = (fileType: FileType): React.ReactNode => { + switch (fileType) { + case 'event': + return ; + case 'calibration': + return ; + case 'auxiliary': + return ; + case 'log': + return ; + default: + return ; + } +}; + +interface FileTreeItemProps { + entry: FileEntry; + depth: number; + selectedFiles: Set; + onToggleSelect: (entry: FileEntry) => void; + expandedDirs: Set; + onToggleExpand: (path: string) => void; +} + +const FileTreeItem: React.FC = ({ + entry, + depth, + selectedFiles, + onToggleSelect, + expandedDirs, + onToggleExpand, +}) => { + const isExpanded = expandedDirs.has(entry.full_url); + const isSelected = selectedFiles.has(entry.full_url); + + return ( + + entry.is_directory && onToggleExpand(entry.full_url)} + > + {/* Expand/collapse button for directories */} + {entry.is_directory ? ( + + {isExpanded ? : } + + ) : ( + + )} + + {/* Checkbox for files only */} + {!entry.is_directory && ( + onToggleSelect(entry)} + onClick={(e) => e.stopPropagation()} + sx={{ p: 0.25 }} + /> + )} + + {/* Icon */} + + {entry.is_directory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + getFileIcon(entry.file_type) + )} + + + {/* File name */} + + {entry.name} + + + {/* File type chip for event files */} + {entry.file_type === 'event' && ( + + )} + + {/* File size */} + {!entry.is_directory && entry.size_display && ( + + {entry.size_display} + + )} + + + {/* Children */} + {entry.is_directory && entry.children && ( + + {entry.children.map((child) => ( + + ))} + + )} + + ); +}; + +const FileBrowserDialog: React.FC = ({ + open, + onClose, + mission, + obsid, + obsTime, + targetName, + obsData, + onDownloadComplete, +}) => { + const { addNotification } = useUIStore(); + + // Loading and data state + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [files, setFiles] = useState([]); + const [baseUrl, setBaseUrl] = useState(''); + + // Selection state + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + + // Download state + const [downloadState, setDownloadState] = useState({ + status: 'idle', + message: '', + percent: 0, + }); + const downloadAbortController = useRef(null); + const downloadOperationId = useRef(0); + + useEffect( + () => () => { + downloadOperationId.current += 1; + downloadAbortController.current?.abort(); + }, + [] + ); + + const loadFiles = useCallback(async (): Promise => { + downloadOperationId.current += 1; + downloadAbortController.current?.abort(); + downloadAbortController.current = null; + setLoading(true); + setError(null); + setSelectedFiles(new Set()); + setExpandedDirs(new Set()); + setDownloadState({ status: 'idle', message: '', percent: 0 }); + + try { + const response = await archiveApi.listObservationFiles({ + mission, + obsid, + obs_time: obsTime, + obs_data: obsData, + recursive: true, + max_depth: 3, + }); + + if (response.success && response.data) { + setFiles(response.data.files); + setBaseUrl(response.data.base_url); + + // Auto-expand directories with event files and auto-select first event file + const dirsToExpand = new Set(); + let firstEventFileUrl: string | null = null; + + const findEventFiles = (entries: FileEntry[], parentUrl: string): void => { + for (const entry of entries) { + if (entry.is_directory && entry.children) { + const hasEventFile = entry.children.some( + (c) => c.file_type === 'event' || c.is_directory + ); + if (hasEventFile) { + dirsToExpand.add(entry.full_url); + } + findEventFiles(entry.children, entry.full_url); + } else if (entry.file_type === 'event' && !firstEventFileUrl) { + firstEventFileUrl = entry.full_url; + dirsToExpand.add(parentUrl); + } + } + }; + + findEventFiles(response.data.files, response.data.base_url); + setExpandedDirs(dirsToExpand); + + if (firstEventFileUrl) { + setSelectedFiles(new Set([firstEventFileUrl])); + } + } else { + setError(response.message || 'Failed to list files'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to list files'); + } finally { + setLoading(false); + } + }, [mission, obsid, obsTime, obsData]); + + // Load files when dialog opens + useEffect(() => { + if (open) { + void loadFiles(); + } else { + downloadOperationId.current += 1; + downloadAbortController.current?.abort(); + } + }, [open, loadFiles]); + + const handleToggleSelect = useCallback((entry: FileEntry): void => { + setSelectedFiles((prev) => { + const next = new Set(prev); + if (next.has(entry.full_url)) { + next.delete(entry.full_url); + } else { + next.add(entry.full_url); + } + return next; + }); + }, []); + + const handleToggleExpand = useCallback((path: string): void => { + setExpandedDirs((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + // Get selected file info + const getSelectedFileInfo = (): { url: string; name: string; size: number } | null => { + const findFile = (entries: FileEntry[]): FileEntry | null => { + for (const entry of entries) { + if (selectedFiles.has(entry.full_url) && !entry.is_directory) { + return entry; + } + if (entry.is_directory && entry.children) { + const found = findFile(entry.children); + if (found) return found; + } + } + return null; + }; + + const file = findFile(files); + if (file) { + return { + url: file.full_url, + name: file.name, + size: file.size_bytes || 0, + }; + } + return null; + }; + + // Calculate selected files info for display + const getSelectedFilesInfo = (): { count: number; totalSize: number; hasEventFile: boolean } => { + let count = 0; + let totalSize = 0; + let hasEventFile = false; + + const checkFiles = (entries: FileEntry[]): void => { + for (const entry of entries) { + if (entry.is_directory && entry.children) { + checkFiles(entry.children); + } else if (selectedFiles.has(entry.full_url)) { + count++; + if (entry.size_bytes) totalSize += entry.size_bytes; + if (entry.file_type === 'event') hasEventFile = true; + } + } + }; + + checkFiles(files); + return { count, totalSize, hasEventFile }; + }; + + const selectedInfo = getSelectedFilesInfo(); + + const formatTotalSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + }; + + // Handle an authenticated, policy-bounded backend download to a granted destination. + const handleDownload = async (): Promise => { + const fileInfo = getSelectedFileInfo(); + if (!fileInfo) { + addNotification({ + type: 'warning', + title: 'No File Selected', + message: 'Please select a file to download', + }); + return; + } + + // Ask user where to save the file + if (!window.electronAPI?.saveGrantedFile) { + addNotification({ + type: 'error', + title: 'Secure Save Unavailable', + message: 'Restart the desktop application before downloading archive files.', + }); + return; + } + const selectionOperationId = downloadOperationId.current; + let destination: { path: string; grant: string } | null; + try { + destination = await window.electronAPI.saveGrantedFile({ + title: 'Save Downloaded File', + defaultPath: fileInfo.name, + filters: [ + { name: 'FITS Files', extensions: ['fits', 'fits.gz', 'evt', 'evt.gz'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + } catch { + if (downloadOperationId.current !== selectionOperationId) { + return; + } + addNotification({ + type: 'error', + title: 'Secure Save Failed', + message: 'Could not authorize the selected destination. Please try again.', + }); + return; + } + + if ( + !destination || + !open || + downloadOperationId.current !== selectionOperationId + ) { + return; // User cancelled + } + + setDownloadState({ + status: 'downloading', + message: 'Starting download...', + percent: 0, + }); + + const abortController = new AbortController(); + const operationId = downloadOperationId.current + 1; + downloadOperationId.current = operationId; + downloadAbortController.current = abortController; + + try { + let receivedTerminalEvent = false; + for await (const event of archiveApi.downloadToDiskSSE({ + url: fileInfo.url, + destination_path: destination.path, + destination_grant: destination.grant, + signal: abortController.signal, + })) { + if (downloadOperationId.current !== operationId) { + break; + } + if (event.type === 'progress') { + const { bytes_downloaded, total_bytes, percent } = event; + if (total_bytes > 0) { + setDownloadState({ + status: 'downloading', + message: `Downloading: ${percent.toFixed(1)}% (${formatTotalSize(bytes_downloaded)} / ${formatTotalSize(total_bytes)})`, + percent, + }); + } else { + setDownloadState({ + status: 'downloading', + message: `Downloading: ${formatTotalSize(bytes_downloaded)}`, + percent: 0, + }); + } + } else if (event.type === 'complete') { + receivedTerminalEvent = true; + setDownloadState({ + status: 'complete', + message: `Downloaded ${event.file_name} (${formatTotalSize(event.size_bytes)})`, + percent: 100, + }); + + addNotification({ + type: 'success', + title: 'Download Complete', + message: 'The file was securely saved. Use "Load from Local" to open it.', + }); + + if (event.warnings.length > 0) { + addNotification({ + type: 'warning', + title: 'Cleanup Warning', + message: event.warnings.join(' '), + }); + } + onDownloadComplete?.(); + break; + } else if (event.type === 'error') { + receivedTerminalEvent = true; + throw new Error(event.error); + } + } + if (downloadOperationId.current !== operationId) { + return; + } + if (!receivedTerminalEvent) { + throw new Error('Download ended before completion was confirmed'); + } + } catch (err) { + if (downloadOperationId.current !== operationId) { + return; + } + const errorMsg = + abortController.signal.aborted || (err instanceof DOMException && err.name === 'AbortError') + ? 'Download cancelled' + : err instanceof Error + ? err.message + : 'Download failed'; + setDownloadState({ + status: 'error', + message: errorMsg, + percent: 0, + error: errorMsg, + }); + addNotification({ + type: 'error', + title: 'Download Failed', + message: errorMsg, + }); + } finally { + if (downloadAbortController.current === abortController) { + downloadAbortController.current = null; + } + } + }; + + const handleCloseOrCancel = (): void => { + if (downloadState.status === 'downloading') { + downloadAbortController.current?.abort(); + setDownloadState((current) => ({ + ...current, + message: 'Cancelling download...', + })); + return; + } + onClose(); + }; + + const isDownloading = downloadState.status === 'downloading'; + + return ( + + + + + + + Browse Files: {mission} Observation {obsid} + + + Target: {targetName} + + + + + + + {loading ? ( + + + + Loading file list... + + + ) : error ? ( + + {error} + + ) : ( + <> + {/* Base URL display */} + + {baseUrl} + + + {/* File tree */} + + {files.length === 0 ? ( + + No files found in this observation directory. + + ) : ( + files.map((entry) => ( + + )) + )} + + + {/* Selection info */} + + + Selected: {selectedInfo.count} file{selectedInfo.count !== 1 ? 's' : ''} + {selectedInfo.totalSize > 0 && ` (${formatTotalSize(selectedInfo.totalSize)})`} + + {selectedInfo.hasEventFile && ( + } + label="Event file" + size="small" + color="warning" + /> + )} + + + {/* Download progress/status */} + {downloadState.status !== 'idle' && ( + + {downloadState.status === 'error' ? ( + {downloadState.message} + ) : downloadState.status === 'complete' ? ( + {downloadState.message} + ) : ( + <> + + + {downloadState.message} + + 0 ? 'determinate' : 'indeterminate'} + value={downloadState.percent} + sx={{ height: 8, borderRadius: 1 }} + /> + + )} + + )} + + {/* Hint */} + + Select a file and click "Download" to save it locally. + Then use the "Load from Local" tab to load it into the application. + + + )} + + + + + + + + ); +}; + +export default FileBrowserDialog; diff --git a/src/pages/DataIngestion/HeasarcBrowserPanel.tsx b/src/pages/DataIngestion/HeasarcBrowserPanel.tsx new file mode 100644 index 0000000..aea0094 --- /dev/null +++ b/src/pages/DataIngestion/HeasarcBrowserPanel.tsx @@ -0,0 +1,1100 @@ +/** + * HEASARC Browser Panel + * + * Allows users to search and download X-ray observation data + * from NASA's HEASARC archive. + */ + +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + CircularProgress, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Chip, + ToggleButton, + ToggleButtonGroup, + Tooltip, + IconButton, + Collapse, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import PublicIcon from '@mui/icons-material/Public'; +import MyLocationIcon from '@mui/icons-material/MyLocation'; +import TextFieldsIcon from '@mui/icons-material/TextFields'; +import TagIcon from '@mui/icons-material/Tag'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { + archiveApi, + HeasarcCatalog, + HeasarcObservation, +} from '@/api/archiveApi'; +import { useUIStore } from '@/store/uiStore'; +import FileBrowserDialog from './FileBrowserDialog'; + +interface HeasarcBrowserPanelProps { + onDataLoaded?: () => void; +} + +type SearchMode = 'name' | 'coordinates' | 'obsid'; +type SortOrder = 'none' | 'asc' | 'desc'; + +interface ObsData { + ra?: number | null; + dec?: number | null; + prnb?: string; +} + +interface FileBrowserState { + open: boolean; + mission: string; + obsid: string; + obsTime: string; + targetName: string; + obsData?: ObsData; +} + +const HeasarcBrowserPanel: React.FC = ({ onDataLoaded }) => { + const { addNotification } = useUIStore(); + + // Catalogs state + const [catalogs, setCatalogs] = useState([]); + const [loadingCatalogs, setLoadingCatalogs] = useState(true); + + // Search form state + const [searchMode, setSearchMode] = useState('name'); + const [selectedMission, setSelectedMission] = useState('NICER'); + const [sourceName, setSourceName] = useState(''); + const [ra, setRa] = useState(''); + const [dec, setDec] = useState(''); + const [searchRadius, setSearchRadius] = useState(0.5); + const [obsidInput, setObsidInput] = useState(''); + const [maxResults, setMaxResults] = useState(100); + + // Filter state + const [showFilters, setShowFilters] = useState(false); + const [minExposure, setMinExposure] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + // Search results state + const [isSearching, setIsSearching] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [searchMessage, setSearchMessage] = useState(''); + + // File browser dialog state + const [fileBrowser, setFileBrowser] = useState({ + open: false, + mission: '', + obsid: '', + obsTime: '', + targetName: '', + }); + + // Sort state for Date/Time column + const [dateSortOrder, setDateSortOrder] = useState('none'); + + // Mission-specific display flags + const isNicer = selectedMission === 'NICER'; + const isNuSTAR = selectedMission === 'NuSTAR'; + const isXMM = selectedMission === 'XMM-Newton'; + const isChandra = selectedMission === 'Chandra'; + + // XMM-Newton: per-instrument data only available via ADQL (ObsID search) + // Show Instruments column only when at least one result has the data + const hasXmmInstrumentData = isXMM && searchResults.some((obs) => obs.pn_time != null); + + // Chandra: show Detector column only when at least one result has detector info + const hasDetectorData = isChandra && searchResults.some((obs) => obs.detector); + + // Fetch supported catalogs on mount + useEffect(() => { + const fetchCatalogs = async (): Promise => { + try { + const response = await archiveApi.getCatalogs(); + if (response.success && response.data) { + setCatalogs(response.data.catalogs); + } + } catch (error) { + console.error('Failed to fetch catalogs:', error); + addNotification({ + type: 'error', + title: 'Error', + message: 'Failed to fetch HEASARC catalogs', + }); + } finally { + setLoadingCatalogs(false); + } + }; + fetchCatalogs(); + }, [addNotification]); + + /** Build filter params for name/coordinate searches */ + const getFilterParams = (): { + min_exposure?: number; + start_date?: string; + end_date?: string; + } => { + const params: { min_exposure?: number; start_date?: string; end_date?: string } = {}; + const minExpVal = parseFloat(minExposure); + if (!isNaN(minExpVal) && minExpVal > 0) { + params.min_exposure = minExpVal; + } + if (startDate) { + params.start_date = startDate; + } + if (endDate) { + params.end_date = endDate; + } + return params; + }; + + // Handle search + const handleSearch = async (): Promise => { + setIsSearching(true); + setSearchResults([]); + setSearchMessage(''); + setDateSortOrder('none'); // Reset sort order on new search + + try { + if (searchMode === 'obsid') { + // ObsID search + if (!obsidInput.trim()) { + addNotification({ + type: 'warning', + title: 'Missing Input', + message: 'Please enter an Observation ID', + }); + setIsSearching(false); + return; + } + + const response = await archiveApi.searchByObsid({ + obsid: obsidInput.trim(), + mission: selectedMission, + }); + + if (response.success && response.data) { + setSearchResults(response.data.observations); + setSearchMessage(response.message); + } else { + addNotification({ + type: 'error', + title: 'Search Failed', + message: response.message || 'Search failed', + }); + } + } else if (searchMode === 'name') { + if (!sourceName.trim()) { + addNotification({ + type: 'warning', + title: 'Missing Input', + message: 'Please enter a source name', + }); + setIsSearching(false); + return; + } + + const response = await archiveApi.searchByName({ + source_name: sourceName.trim(), + mission: selectedMission, + radius: searchRadius, + max_results: maxResults, + ...getFilterParams(), + }); + + if (response.success && response.data) { + setSearchResults(response.data.observations); + setSearchMessage(response.message); + if (response.data.resolved_ra !== undefined && response.data.resolved_dec !== undefined) { + addNotification({ + type: 'info', + title: 'Source Resolved', + message: `Source resolved to: RA = ${response.data.resolved_ra.toFixed(4)}, Dec = ${response.data.resolved_dec.toFixed(4)}`, + }); + } + } else { + addNotification({ + type: 'error', + title: 'Search Failed', + message: response.message || 'Search failed', + }); + } + } else { + // Coordinate search + const raNum = parseFloat(ra); + const decNum = parseFloat(dec); + + if (isNaN(raNum) || isNaN(decNum)) { + addNotification({ + type: 'warning', + title: 'Invalid Coordinates', + message: 'Please enter valid RA and Dec values in degrees', + }); + setIsSearching(false); + return; + } + + const response = await archiveApi.searchByCoordinates({ + ra: raNum, + dec: decNum, + mission: selectedMission, + radius: searchRadius, + max_results: maxResults, + ...getFilterParams(), + }); + + if (response.success && response.data) { + setSearchResults(response.data.observations); + setSearchMessage(response.message); + } else { + addNotification({ + type: 'error', + title: 'Search Failed', + message: response.message || 'Search failed', + }); + } + } + } catch (error) { + console.error('Search error:', error); + addNotification({ + type: 'error', + title: 'Error', + message: `Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + } finally { + setIsSearching(false); + } + }; + + // Handle opening the file browser for an observation + const handleBrowseFiles = (obs: HeasarcObservation): void => { + setFileBrowser({ + open: true, + mission: selectedMission, + obsid: obs.obsid, + obsTime: obs.time, + targetName: obs.name, + obsData: { + ra: obs.ra, + dec: obs.dec, + prnb: obs.prnb, + }, + }); + }; + + // Handle closing the file browser + const handleCloseFileBrowser = (): void => { + setFileBrowser((prev) => ({ ...prev, open: false })); + }; + + // Refresh loaded data after a backend-verified archive download completes. + const handleDownloadComplete = (): void => { + onDataLoaded?.(); + }; + + // Handle fallback to browse page + const handleBrowseFallback = async (obs: HeasarcObservation): Promise => { + const response = await archiveApi.getObservationUrls(selectedMission, obs.obsid); + if (response.success && response.data?.urls?.browse) { + window.open(response.data.urls.browse, '_blank'); + addNotification({ + type: 'info', + title: 'HEASARC Browser', + message: `Opening HEASARC browse page for ${obs.obsid}. Download the event file manually.`, + }); + } + }; + + // Format exposure time + const formatExposure = (seconds: number | null): string => { + if (seconds === null || seconds === undefined) return 'N/A'; + if (seconds < 60) return `${seconds.toFixed(0)}s`; + if (seconds < 3600) return `${(seconds / 60).toFixed(1)} min`; + return `${(seconds / 3600).toFixed(2)} hr`; + }; + + /** + * Format exposure for display, with Swift instrument awareness. + * For Swift observations where XRT exposure is 0 but BAT has data, + * shows BAT exposure with an instrument label. + */ + const formatObservationExposure = (obs: HeasarcObservation): { label: string; instrument: string | null } => { + // For Swift, check instrument-specific exposures + if (selectedMission === 'Swift' && obs.xrt_exposure !== undefined) { + const xrt = obs.xrt_exposure ?? 0; + const bat = obs.bat_exposure ?? 0; + const uvot = obs.uvot_exposure ?? 0; + + if (xrt > 0) { + return { label: formatExposure(xrt), instrument: 'XRT' }; + } + if (bat > 0) { + return { label: formatExposure(bat), instrument: 'BAT' }; + } + if (uvot > 0) { + return { label: formatExposure(uvot), instrument: 'UVOT' }; + } + return { label: '0s', instrument: null }; + } + + // For NuSTAR: only show FPMA label when FPMB data is also available (ADQL/ObsID search) + // query_region() doesn't return exposure_b, so don't mislead with "FPMA" label + if (selectedMission === 'NuSTAR') { + const instrument = obs.exposure_b != null ? 'FPMA' : null; + return { label: formatExposure(obs.exposure), instrument }; + } + + // For IXPE, show main exposure (per-DU breakdown available in tooltip) + if (selectedMission === 'IXPE' && obs.exposure_du1 !== undefined) { + return { label: formatExposure(obs.exposure), instrument: null }; + } + + // For Chandra, show exposure with detector context + if (selectedMission === 'Chandra') { + return { label: formatExposure(obs.exposure), instrument: null }; + } + + // For XMM-Newton, show duration as main exposure + if (selectedMission === 'XMM-Newton') { + return { label: formatExposure(obs.exposure), instrument: null }; + } + + return { label: formatExposure(obs.exposure), instrument: null }; + }; + + // Format coordinates + const formatCoord = (value: number | null, decimals: number = 4): string => { + if (value === null || value === undefined) return 'N/A'; + return value.toFixed(decimals); + }; + + /** + * Convert Modified Julian Date (MJD) to human-readable date+time string. + * MJD is days since midnight on November 17, 1858. + * The fractional part represents the time of day. + */ + const formatMjdToDateTime = (mjdString: string): string => { + if (!mjdString || mjdString === 'N/A') return 'N/A'; + + try { + const mjd = parseFloat(mjdString); + if (isNaN(mjd)) return 'N/A'; + + // MJD epoch: November 17, 1858 00:00:00 UTC + // Convert MJD to JavaScript Date + // JD = MJD + 2400000.5 + // Unix epoch (Jan 1, 1970) = JD 2440587.5 + // So: Unix days = MJD - 40587 + const unixDays = mjd - 40587; + const unixMs = unixDays * 24 * 60 * 60 * 1000; + const date = new Date(unixMs); + + // Format as DD-MM-YYYY HH:MM:SS + const day = date.getUTCDate().toString().padStart(2, '0'); + const month = (date.getUTCMonth() + 1).toString().padStart(2, '0'); + const year = date.getUTCFullYear(); + const hours = date.getUTCHours().toString().padStart(2, '0'); + const minutes = date.getUTCMinutes().toString().padStart(2, '0'); + const seconds = date.getUTCSeconds().toString().padStart(2, '0'); + + return `${day}-${month}-${year} ${hours}:${minutes}:${seconds}`; + } catch { + return 'N/A'; + } + }; + + // Handle Date/Time column header click for sorting + const handleDateSortClick = (): void => { + setDateSortOrder((prev) => { + if (prev === 'none') return 'asc'; + if (prev === 'asc') return 'desc'; + return 'none'; + }); + }; + + // Get sorted results based on current sort order + const getSortedResults = (): HeasarcObservation[] => { + if (dateSortOrder === 'none') { + return searchResults; // Original order from API + } + + return [...searchResults].sort((a, b) => { + const mjdA = parseFloat(a.time) || 0; + const mjdB = parseFloat(b.time) || 0; + + if (dateSortOrder === 'asc') { + return mjdA - mjdB; // Oldest first + } else { + return mjdB - mjdA; // Newest first + } + }); + }; + + /** Get processing status color for NICER Chip */ + const getStatusColor = (status: string | undefined): 'success' | 'warning' | 'default' => { + if (!status) return 'default'; + const upper = status.toUpperCase(); + if (upper === 'VALIDATED') return 'success'; + if (upper === 'PROCESSED') return 'warning'; + return 'default'; + }; + + /** Get processing status tooltip description */ + const getStatusTooltip = (status: string | undefined): string => { + if (!status) return ''; + const upper = status.toUpperCase(); + if (upper === 'VALIDATED') return 'Data is fully processed, quality-checked, and available in the archive'; + if (upper === 'PROCESSED') return 'Data has been processed but not yet validated by the NICER team'; + if (upper === 'NOTPROCESSED') return 'Data has not been processed yet'; + return status; + }; + + /** Get XMM-Newton status display label (capitalize raw HEASARC value) */ + const getXmmStatusLabel = (status: string | undefined): string => { + if (!status) return ''; + const upper = status.toUpperCase(); + if (upper === 'ARCHIVED') return 'Archived'; + if (upper === 'SCHEDULED') return 'Scheduled'; + return status; + }; + + /** Get XMM-Newton status chip color */ + const getXmmStatusColor = (status: string | undefined): 'success' | 'warning' | 'default' => { + if (!status) return 'default'; + const upper = status.toUpperCase(); + if (upper === 'ARCHIVED') return 'success'; + if (upper === 'SCHEDULED') return 'warning'; + return 'default'; + }; + + /** Get XMM-Newton status tooltip description */ + const getXmmStatusTooltip = (status: string | undefined, dataInHeasarc: string | undefined): string => { + if (!status) return ''; + const upper = status.toUpperCase(); + const dataAvail = dataInHeasarc === 'Y' + ? 'Data files available in HEASARC' + : dataInHeasarc === 'N' + ? 'Data files not yet available in HEASARC' + : ''; + if (upper === 'ARCHIVED') return `Observation completed and archived. ${dataAvail}`; + if (upper === 'SCHEDULED') return `Observation scheduled but not yet executed. ${dataAvail}`; + return `${status}. ${dataAvail}`; + }; + + /** Get Chandra status display label */ + const getChandraStatusLabel = (status: string | undefined): string => { + if (!status) return ''; + const upper = status.toUpperCase(); + if (upper === 'ARCHIVED') return 'Archived'; + if (upper === 'OBSERVED') return 'Observed'; + if (upper === 'SCHEDULED') return 'Scheduled'; + return status; + }; + + /** Get Chandra status chip color */ + const getChandraStatusColor = (status: string | undefined): 'success' | 'primary' | 'default' => { + if (!status) return 'default'; + const upper = status.toUpperCase(); + if (upper === 'ARCHIVED') return 'success'; + if (upper === 'OBSERVED') return 'primary'; + return 'default'; + }; + + /** Get Chandra status tooltip description */ + const getChandraStatusTooltip = (status: string | undefined): string => { + if (!status) return ''; + const upper = status.toUpperCase(); + if (upper === 'ARCHIVED') return 'Observation data processed and available for download'; + if (upper === 'OBSERVED') return 'Observation completed, data processing in progress'; + if (upper === 'SCHEDULED') return 'Observation scheduled but not yet executed'; + return status; + }; + + /** Format Chandra detector + grating display */ + const formatChandraDetector = (obs: HeasarcObservation): { label: string; tooltip: string } => { + const detector = obs.detector || ''; + const grating = obs.grating || ''; + if (!detector) return { label: '', tooltip: '' }; + + const hasGrating = grating && grating.toUpperCase() !== 'NONE'; + const label = hasGrating ? `${detector} / ${grating}` : detector; + + let tooltip = detector; + if (detector.toUpperCase() === 'ACIS-S') { + tooltip = hasGrating + ? `ACIS-S with ${grating} grating — spectroscopy mode` + : 'ACIS-S — CCD array, supports CC mode (2.85ms timing)'; + } else if (detector.toUpperCase() === 'ACIS-I') { + tooltip = hasGrating + ? `ACIS-I with ${grating} grating` + : 'ACIS-I — imaging array, 3.3s standard frame time'; + } else if (detector.toUpperCase() === 'HRC-I') { + tooltip = 'HRC-I — microchannel plate, 16\u03BCs time resolution'; + } else if (detector.toUpperCase() === 'HRC-S') { + tooltip = hasGrating + ? `HRC-S with ${grating} grating — high-resolution spectroscopy` + : 'HRC-S — microchannel plate, LETG readout optimized'; + } + + return { label, tooltip }; + }; + + if (loadingCatalogs) { + return ( + + + + ); + } + + return ( + + {/* Header */} + + + Browse HEASARC Archive + + + + Search NASA's HEASARC archive for X-ray observations by source name, coordinates, or ObsID + + + {/* Mission Selector */} + + Mission + + + + {/* Search Mode Toggle */} + + + Search by: + + value && setSearchMode(value)} + size="small" + fullWidth + > + + + Source Name + + + + Coordinates + + + + ObsID + + + + + {/* Search Inputs */} + {searchMode === 'name' && ( + setSourceName(e.target.value)} + fullWidth + size="small" + sx={{ mb: 2 }} + placeholder="e.g., Crab, Cyg X-1, NGC 3783, GRS 1915+105" + helperText="Enter an astronomical source name (resolved via SIMBAD/NED)" + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + )} + + {searchMode === 'coordinates' && ( + + setRa(e.target.value)} + size="small" + sx={{ flex: 1 }} + placeholder="e.g., 83.6287" + helperText="Right Ascension" + type="number" + inputProps={{ step: 0.0001 }} + /> + setDec(e.target.value)} + size="small" + sx={{ flex: 1 }} + placeholder="e.g., 22.0145" + helperText="Declination" + type="number" + inputProps={{ step: 0.0001 }} + /> + + )} + + {searchMode === 'obsid' && ( + setObsidInput(e.target.value)} + fullWidth + size="small" + sx={{ mb: 2 }} + placeholder="e.g., 4010080142" + helperText="Enter an exact Observation ID to look up" + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + )} + + {/* Search Radius + Max Results (hidden for ObsID mode) */} + {searchMode !== 'obsid' && ( + + setSearchRadius(parseFloat(e.target.value) || 0.5)} + size="small" + sx={{ width: 200 }} + type="number" + inputProps={{ step: 0.1, min: 0.01, max: 10 }} + /> + { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val > 0) setMaxResults(val); + }} + size="small" + sx={{ width: 140 }} + type="number" + inputProps={{ min: 1, step: 50 }} + /> + + )} + + {/* Filters (shown for name/coordinates modes) */} + {searchMode !== 'obsid' && ( + + + + + + setMinExposure(e.target.value)} + size="small" + sx={{ width: 160 }} + type="number" + inputProps={{ min: 0, step: 100 }} + placeholder="e.g., 1000" + /> + setStartDate(e.target.value)} + size="small" + sx={{ width: 180 }} + type="date" + InputLabelProps={{ shrink: true }} + /> + setEndDate(e.target.value)} + size="small" + sx={{ width: 180 }} + type="date" + InputLabelProps={{ shrink: true }} + /> + {(minExposure || startDate || endDate) && ( + + )} + + + + + )} + + {/* Search Button */} + + + {/* Search Results */} + {searchMessage && ( + + {searchMessage} + + )} + + {searchResults.length > 0 && ( + + + + + ObsID + Target + RA + Dec + Exposure + + + Date/Time (UTC) + {dateSortOrder === 'asc' && } + {dateSortOrder === 'desc' && } + + + MJD + {isNicer && Status} + {isNuSTAR && Mode} + {isXMM && Status} + {hasXmmInstrumentData && Instruments} + {hasDetectorData && Detector} + {isChandra && Status} + Action + + + + {getSortedResults().map((obs) => ( + + + + + {obs.obsid} + + {isNuSTAR && obs.issue_flag === 1 && ( + + + + )} + + + + + + {obs.name} + + + + + + {formatCoord(obs.ra)} + + + + + {formatCoord(obs.dec)} + + + + {(() => { + const { label, instrument } = formatObservationExposure(obs); + const effectiveExposure = instrument === 'BAT' + ? obs.bat_exposure ?? 0 + : instrument === 'UVOT' + ? obs.uvot_exposure ?? 0 + : obs.exposure ?? 0; + return ( + + 10000 + ? 'success' + : effectiveExposure > 1000 + ? 'primary' + : 'default' + } + /> + + ); + })()} + + + + {formatMjdToDateTime(obs.time)} + + + + + {obs.time || 'N/A'} + + + {isNicer && ( + + {obs.processing_status ? ( + + + + ) : ( + + — + + )} + + )} + {isNuSTAR && ( + + {obs.observation_mode ? ( + + + + ) : ( + + — + + )} + + )} + {isXMM && ( + + + {obs.xmm_status ? ( + + + + ) : ( + — + )} + {obs.data_in_heasarc === 'N' && ( + + + + )} + + + )} + {hasXmmInstrumentData && ( + + + {(obs.pn_time ?? 0) > 0 && ( + + + + )} + {(obs.mos1_time ?? 0) > 0 && ( + + + + )} + {(obs.mos2_time ?? 0) > 0 && ( + + + + )} + {(obs.pn_time ?? 0) === 0 && (obs.mos1_time ?? 0) === 0 && (obs.mos2_time ?? 0) === 0 && ( + — + )} + + + )} + {hasDetectorData && ( + + {obs.detector ? ( + + + + ) : ( + — + )} + + )} + {isChandra && ( + + {obs.chandra_status ? ( + + + + ) : ( + — + )} + + )} + + + + handleBrowseFiles(obs)} + > + + + + + handleBrowseFallback(obs)} + > + + + + + + + ))} + +
+
+ )} + + {/* File Browser Dialog */} + + + {/* Help Text */} + + + How to use: + + +
    +
  1. Select a mission (e.g., NICER, NuSTAR)
  2. +
  3. Enter a source name, coordinates, or ObsID
  4. +
  5. Click Search to find observations
  6. +
  7. Click the folder icon to browse available files
  8. +
  9. Select an event file and click Download & Load
  10. +
+
+ + The file browser shows all available files in the observation directory. + Event files are marked with a star icon. + Use "Show Filters" to filter by minimum exposure or date range. + +
+
+ ); +}; + +export default HeasarcBrowserPanel; diff --git a/src/pages/DataIngestion/index.test.tsx b/src/pages/DataIngestion/index.test.tsx new file mode 100644 index 0000000..f7a1df8 --- /dev/null +++ b/src/pages/DataIngestion/index.test.tsx @@ -0,0 +1,398 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import DataIngestionPage, { + detectFormatFromExtension, + getLastLoadedFiles, + mergeGrantedSelections, + saveLastLoadedFiles, + validateRemoteSourceUrl, +} from './index'; + +const mocks = vi.hoisted(() => ({ + addNotification: vi.fn(), + getPort: vi.fn().mockResolvedValue(8765), + listEventLists: vi.fn(), + checkFileSize: vi.fn(), + checkBatchFileSize: vi.fn(), + listExportableObjects: vi.fn(), + exportObject: vi.fn(), +})); + +vi.mock('@/api/client', () => ({ apiClient: { getPort: mocks.getPort } })); +vi.mock('@/api/dataApi', () => ({ + dataApi: { + listEventLists: mocks.listEventLists, + checkFileSize: mocks.checkFileSize, + checkBatchFileSize: mocks.checkBatchFileSize, + }, +})); +vi.mock('@/api/jobApi', () => ({ jobApi: {} })); +vi.mock('@/api/ioApi', () => ({ + ioApi: { + listExportableObjects: mocks.listExportableObjects, + exportObject: mocks.exportObject, + }, +})); +vi.mock('@/store/uiStore', () => ({ + useUIStore: () => ({ addNotification: mocks.addNotification }), +})); +vi.mock('@/store/jobStore', () => ({ useJobStore: () => ({ jobs: {} }) })); +vi.mock('./HeasarcBrowserPanel', () => ({ default: () => null })); + +const exportCapabilityResponse = (...names: string[]) => ({ + success: true, + data: { + objects: names.map((name) => ({ + object_type: 'event_list', + name, + row_count: 2, + exportable: true, + formats: ['ecsv'], + reason: null, + })), + capability_matrix: { + event_list: { + ecsv: { supported: true, notes: 'Verified ECSV' }, + hdf5: { supported: false, notes: 'h5py is not installed' }, + }, + }, + format_allowlist: ['ecsv'], + excluded_formats: { hdf5: 'h5py is not installed' }, + }, + message: '', + error: null, +}); + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('Data Ingestion granted selections', () => { + const originalElectronApi = window.electronAPI; + const storage = new Map(); + const localStorageMock = { + clear: () => storage.clear(), + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + get length() { + return storage.size; + }, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + } satisfies Storage; + + beforeEach(() => { + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: localStorageMock, + }); + localStorage.clear(); + vi.clearAllMocks(); + mocks.getPort.mockResolvedValue(8765); + mocks.listEventLists.mockResolvedValue({ + success: true, + data: [], + message: '', + error: null, + }); + mocks.checkFileSize.mockResolvedValue({ + success: false, + data: null, + message: 'not needed in this UI test', + error: null, + }); + mocks.checkBatchFileSize.mockResolvedValue({ + success: false, + data: null, + message: 'not needed in this UI test', + error: null, + }); + mocks.listExportableObjects.mockReset(); + mocks.exportObject.mockResolvedValue({ + success: true, + data: { verified: true }, + message: 'Exported', + error: null, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: originalElectronApi, + }); + }); + + it('persists historical path hints without persisting grants', () => { + saveLastLoadedFiles( + [{ path: '/science/events.fits', grant: 'never-persist-this' }], + { '/science/events.fits': 'events' } + ); + + const serialized = localStorage.getItem('lastLoadedFiles'); + expect(serialized).not.toContain('never-persist-this'); + expect(getLastLoadedFiles()).toEqual( + expect.objectContaining({ + files: ['/science/events.fits'], + fileNames: { '/science/events.fits': 'events' }, + }) + ); + }); + + it('replaces stale authority only with a freshly selected grant', () => { + const current = [{ path: '/science/events.fits', grant: 'stale-grant' }]; + expect(mergeGrantedSelections(current, [])).toEqual({ merged: current, added: [] }); + expect( + mergeGrantedSelections(current, [ + { path: '/science/events.fits', grant: 'fresh-grant' }, + { path: '/science/second.hdf5', grant: 'second-grant' }, + ]) + ).toEqual({ + merged: [ + { path: '/science/events.fits', grant: 'fresh-grant' }, + { path: '/science/second.hdf5', grant: 'second-grant' }, + ], + added: [{ path: '/science/second.hdf5', grant: 'second-grant' }], + }); + }); + + it('shows history as a hint and requires a fresh native granted selection', async () => { + localStorage.setItem( + 'lastLoadedFiles', + JSON.stringify({ + files: ['/historical/events.fits'], + fileNames: { '/historical/events.fits': 'historical-events' }, + timestamp: Date.now(), + }) + ); + const openGrantedFile = vi.fn().mockResolvedValue([ + { path: '/fresh/events.fits', grant: 'fresh-grant' }, + ]); + const fileExists = vi.fn(); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { openGrantedFile, fileExists }, + }); + + render(); + + expect( + await screen.findByText(/Previous path hints \(fresh native selection required\)/) + ).toHaveTextContent('/historical/events.fits'); + await userEvent.click(screen.getByRole('button', { name: 'Reselect Last Files' })); + + expect(openGrantedFile).toHaveBeenCalledWith( + expect.objectContaining({ multiple: true, title: expect.stringContaining('Reselect') }) + ); + expect(openGrantedFile.mock.calls[0][0].filters[0].extensions).toContain('gz'); + expect(fileExists).not.toHaveBeenCalled(); + await waitFor(() => + expect(mocks.checkFileSize).toHaveBeenCalledWith({ + file_path: '/fresh/events.fits', + file_grant: 'fresh-grant', + }) + ); + expect(await screen.findByText('events.fits')).toBeInTheDocument(); + }); + + it('correlates redacted batch-size entries by request order when basenames collide', async () => { + const openGrantedFile = vi.fn().mockResolvedValue([ + { path: '/first/shared.fits', grant: 'first-grant' }, + { path: '/second/shared.fits', grant: 'second-grant' }, + ]); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { openGrantedFile }, + }); + mocks.checkBatchFileSize.mockResolvedValue({ + success: true, + data: { + files: [ + { + file_name: 'shared.fits', + size_mb: 11, + estimated_ram_mb: 22, + ram_percent: 10, + risk_level: 'safe', + }, + { + file_name: 'shared.fits', + size_mb: 33, + estimated_ram_mb: 66, + ram_percent: 20, + risk_level: 'safe', + }, + ], + total: { + size_mb: 44, + estimated_ram_mb: 88, + ram_percent: 30, + risk_level: 'safe', + }, + available_ram_mb: 1024, + file_count: 2, + recommend_partial_loading: false, + }, + message: '', + error: null, + }); + + render(); + await userEvent.click(screen.getByRole('button', { name: 'Browse Files' })); + + await waitFor(() => + expect(mocks.checkBatchFileSize).toHaveBeenCalledWith([ + { file_path: '/first/shared.fits', file_grant: 'first-grant' }, + { file_path: '/second/shared.fits', file_grant: 'second-grant' }, + ]) + ); + const nameInputs = await screen.findAllByPlaceholderText('Name'); + const firstRow = nameInputs[0].closest('.MuiListItem-root'); + const secondRow = nameInputs[1].closest('.MuiListItem-root'); + expect(firstRow).not.toBeNull(); + expect(secondRow).not.toBeNull(); + expect(within(firstRow as HTMLElement).getByText('11.0 MB')).toBeInTheDocument(); + expect(within(secondRow as HTMLElement).getByText('33.0 MB')).toBeInTheDocument(); + }); + + it('does not recognize pickle as an allowed input format', () => { + expect(detectFormatFromExtension('/science/events.pkl')).toBe('ogip'); + expect(detectFormatFromExtension('/science/events.ecsv')).toBe('ascii.ecsv'); + }); + + it('rejects non-HTTPS remote sources before submission', () => { + expect(validateRemoteSourceUrl('https://example.test/events.fits')).toBeNull(); + expect(validateRemoteSourceUrl('http://example.test/events.fits')).toBe( + 'Only HTTPS URLs are supported for remote event files.' + ); + expect(validateRemoteSourceUrl('ftp://example.test/events.fits')).toBe( + 'Only HTTPS URLs are supported for remote event files.' + ); + }); + + it('hides unavailable HDF5 and exports ECSV with an exact native write grant', async () => { + mocks.listEventLists.mockResolvedValue({ + success: true, + data: [ + { + name: 'events', + n_events: 2, + time_range: [0, 1], + }, + ], + message: '', + error: null, + }); + mocks.listExportableObjects.mockResolvedValue({ + success: true, + data: { + objects: [ + { + object_type: 'event_list', + name: 'events', + row_count: 2, + exportable: true, + formats: ['ecsv'], + reason: null, + }, + ], + capability_matrix: { + event_list: { + ecsv: { supported: true, notes: 'Verified ECSV' }, + hdf5: { supported: false, notes: 'h5py is not installed' }, + }, + }, + format_allowlist: ['ecsv'], + excluded_formats: { hdf5: 'h5py is not installed' }, + }, + message: '', + error: null, + }); + const saveGrantedFile = vi.fn().mockResolvedValue({ + path: '/exports/events.ecsv', + grant: 'write-grant', + }); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { saveGrantedFile }, + }); + + render(); + + expect(await screen.findByText('events')).toBeInTheDocument(); + await userEvent.click( + screen.getByRole('button', { name: 'Export to a native-selected destination' }) + ); + + expect(await screen.findByText('HDF5 unavailable: h5py is not installed')).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /HDF5 \(Recommended\)/ })).not.toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /ASCII ECSV/ })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Choose Location' })); + + expect(saveGrantedFile).toHaveBeenCalledWith({ + title: 'Export Event List: events', + defaultPath: 'events.ecsv', + filters: [{ name: 'ECSV Files', extensions: ['ecsv'] }], + }); + expect(mocks.exportObject).toHaveBeenCalledWith({ + object_type: 'event_list', + object_name: 'events', + format: 'ecsv', + destination_path: '/exports/events.ecsv', + destination_grant: 'write-grant', + }); + }); + + it('ignores an older export discovery response that completes out of order', async () => { + mocks.listEventLists.mockResolvedValue({ + success: true, + data: [ + { name: 'first-events', n_events: 2, time_range: [0, 1] }, + { name: 'second-events', n_events: 2, time_range: [0, 1] }, + ], + message: '', + error: null, + }); + const firstRequest = deferred>(); + const secondRequest = deferred>(); + mocks.listExportableObjects + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => secondRequest.promise); + + render(); + + expect(await screen.findByText('first-events')).toBeInTheDocument(); + const exportButtons = screen.getAllByRole('button', { + name: 'Export to a native-selected destination', + }); + await userEvent.click(exportButtons[0]); + await userEvent.click(exportButtons[1]); + expect(mocks.listExportableObjects).toHaveBeenCalledTimes(2); + + await act(async () => { + secondRequest.resolve(exportCapabilityResponse('first-events', 'second-events')); + await secondRequest.promise; + }); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/"second-events"/)).toBeInTheDocument(); + + await act(async () => { + firstRequest.resolve(exportCapabilityResponse('first-events', 'second-events')); + await firstRequest.promise; + }); + expect(within(dialog).getByText(/"second-events"/)).toBeInTheDocument(); + expect(within(dialog).queryByText(/"first-events"/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/DataIngestion/index.tsx b/src/pages/DataIngestion/index.tsx new file mode 100644 index 0000000..172940f --- /dev/null +++ b/src/pages/DataIngestion/index.tsx @@ -0,0 +1,3212 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + Box, + Typography, + Paper, + Button, + Grid, + Card, + CardContent, + TextField, + Select, + MenuItem, + FormControl, + InputLabel, + Alert, + CircularProgress, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + IconButton, + Chip, + Divider, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Collapse, + LinearProgress, + FormControlLabel, + Checkbox, + Tabs, + Tab, + Radio, + RadioGroup, +} from '@mui/material'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import DeleteIcon from '@mui/icons-material/Delete'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import InfoIcon from '@mui/icons-material/Info'; +import CloseIcon from '@mui/icons-material/Close'; +import LinkIcon from '@mui/icons-material/Link'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import SettingsIcon from '@mui/icons-material/Settings'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import SaveIcon from '@mui/icons-material/Save'; +import ClearAllIcon from '@mui/icons-material/ClearAll'; +import HistoryIcon from '@mui/icons-material/History'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import MemoryIcon from '@mui/icons-material/Memory'; +import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing'; +import BoltIcon from '@mui/icons-material/Bolt'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import NumbersIcon from '@mui/icons-material/Numbers'; +import QueryStatsIcon from '@mui/icons-material/QueryStats'; +import PublicIcon from '@mui/icons-material/Public'; +import { + dataApi, + EventListSummary, + EventListInfo, + FileSizeInfo, + EventListFullPreview, + FileMetadata, + SingleFileConfig, + BatchSizeResult, + ValidationIssue, + type EventInputFormat, +} from '@/api/dataApi'; +import { ioApi, type UtilityExportFormat } from '@/api/ioApi'; +import { jobApi } from '@/api/jobApi'; +import type { BatchFileConfig } from '@/types/job'; +import type { GrantedFileSelection } from '@/components/utilities/GrantedFileField'; +import HeasarcBrowserPanel from './HeasarcBrowserPanel'; +import { apiClient } from '@/api/client'; +import { useUIStore } from '@/store/uiStore'; +import { useJobStore } from '@/store/jobStore'; + +type AlertSeverity = 'success' | 'error' | 'warning' | 'info'; + +interface AlertState { + open: boolean; + message: string; + severity: AlertSeverity; +} + +// localStorage persistence for last loaded files +const LAST_LOADED_FILES_KEY = 'lastLoadedFiles'; + +export interface LastLoadedFilesData { + files: string[]; + fileNames: Record; + timestamp: number; +} + +const EVENT_FILE_FILTERS = [ + { + name: 'FITS / OGIP Event Files', + extensions: ['fits', 'fit', 'fts', 'evt', 'gz', 'fits.gz', 'fit.gz', 'fts.gz', 'evt.gz'], + }, + { name: 'HDF5 Event Files', extensions: ['hdf5', 'h5'] }, + { name: 'ECSV Event Files', extensions: ['ecsv'] }, +]; + +const RMF_FILE_FILTERS = [ + { name: 'Response Matrix Files', extensions: ['rmf', 'rsp'] }, + { name: 'FITS Files', extensions: ['fits', 'fit'] }, +]; + +type PerFileUiConfig = Omit< + Partial, + 'file_path' | 'file_grant' | 'name' | 'rmf_file' | 'rmf_grant' +>; + +type LegacySaveFormat = Extract; + +export const saveLastLoadedFiles = ( + files: GrantedFileSelection[], + names: Record +): void => { + const data: LastLoadedFilesData = { + files: files.map((file) => file.path), + fileNames: names, + timestamp: Date.now(), + }; + localStorage.setItem(LAST_LOADED_FILES_KEY, JSON.stringify(data)); +}; + +export const getLastLoadedFiles = (): LastLoadedFilesData | null => { + const saved = localStorage.getItem(LAST_LOADED_FILES_KEY); + if (!saved) return null; + try { + const parsed: unknown = JSON.parse(saved); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const record = parsed as Record; + if (!Array.isArray(record.files)) return null; + const files = record.files.filter( + (file): file is string => + typeof file === 'string' && file.length > 0 && file.length <= 4096 && !/[\0\r\n]/.test(file) + ); + const rawNames = + record.fileNames && typeof record.fileNames === 'object' && !Array.isArray(record.fileNames) + ? (record.fileNames as Record) + : {}; + const fileNames = Object.fromEntries( + files.flatMap((file) => { + const name = rawNames[file]; + return typeof name === 'string' && name.length <= 256 ? [[file, name]] : []; + }) + ); + return { + files, + fileNames, + timestamp: + typeof record.timestamp === 'number' && Number.isFinite(record.timestamp) + ? record.timestamp + : 0, + }; + } catch { + return null; + } +}; + +export const detectFormatFromExtension = (filePath: string): EventInputFormat => { + const ext = filePath.toLowerCase().split('.').pop(); + if (ext === 'hdf5' || ext === 'h5') return 'hdf5'; + if (ext === 'ecsv') return 'ascii.ecsv'; + return 'ogip'; +}; + +export const validateRemoteSourceUrl = (value: string): string | null => { + try { + const parsed = new URL(value); + return parsed.protocol === 'https:' + ? null + : 'Only HTTPS URLs are supported for remote event files.'; + } catch { + return 'Please enter a valid HTTPS URL.'; + } +}; + +export const mergeGrantedSelections = ( + current: GrantedFileSelection[], + incoming: GrantedFileSelection[] +): { merged: GrantedFileSelection[]; added: GrantedFileSelection[] } => { + const incomingByPath = new Map(incoming.map((selection) => [selection.path, selection])); + const uniqueIncoming = [...incomingByPath.values()]; + const currentPaths = new Set(current.map((selection) => selection.path)); + const merged = current.map((selection) => incomingByPath.get(selection.path) ?? selection); + const added = uniqueIncoming.filter((selection) => !currentPaths.has(selection.path)); + return { merged: [...merged, ...added], added }; +}; + +const DataIngestionPage: React.FC = () => { + // Global notification store + const { addNotification } = useUIStore(); + + // Job store for watching job completions and refreshing data + const { jobs } = useJobStore(); + + // Form state - Local File (batch mode) + const [selectedFiles, setSelectedFiles] = useState([]); + const [fileNames, setFileNames] = useState>({}); + const [fileFormat, setFileFormat] = useState('ogip'); + const [isLoading, setIsLoading] = useState(false); + + // Batch file settings mode + const [useSameSettings, setUseSameSettings] = useState(true); + const [perFileConfigs, setPerFileConfigs] = useState>({}); + const [perFileRmfSelections, setPerFileRmfSelections] = useState< + Record + >({}); + const [expandedFileSettings, setExpandedFileSettings] = useState>({}); + + // Batch size info + const [batchSizeInfo, setBatchSizeInfo] = useState(null); + const [batchSizeSelectionPaths, setBatchSizeSelectionPaths] = useState([]); + const [isCheckingBatchSize, setIsCheckingBatchSize] = useState(false); + + // Batch loading progress - kept for potential future use but not used with job queue + const [_batchProgress, _setBatchProgress] = useState<{ + loading: boolean; + total: number; + completed: number; + } | null>(null); + // Note: batchResult removed - job queue handles results in sidebar + + // Advanced options state + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + const [rmfSelection, setRmfSelection] = useState(null); + const [additionalColumns, setAdditionalColumns] = useState(''); + const [fileSizeInfo, setFileSizeInfo] = useState(null); + const [isCheckingFileSize, setIsCheckingFileSize] = useState(false); + + // Notes/Comments for the data + const [eventNotes, setEventNotes] = useState(''); + + + // Advanced loading options + const [highPrecision, setHighPrecision] = useState(false); + const [skipChecks, setSkipChecks] = useState(false); + + // True lazy loading options + const [useTrueLazyLoading, setUseTrueLazyLoading] = useState(false); + const [trueLazyMode, setTrueLazyMode] = useState<'time_range' | 'event_count'>('time_range'); + const [timeRangeStart, setTimeRangeStart] = useState(0); + const [timeRangeEnd, setTimeRangeEnd] = useState(100); + const [eventCountStart, setEventCountStart] = useState(0); + const [eventCount, setEventCount] = useState(10000); + const [fileMetadata, setFileMetadata] = useState(null); + const [isLoadingMetadata, setIsLoadingMetadata] = useState(false); + + // Tab state for data input method + const [dataInputTab, setDataInputTab] = useState(0); + + // Form state - URL Loading + const [urlInput, setUrlInput] = useState(''); + const [urlEventListName, setUrlEventListName] = useState(''); + const [urlFormat, setUrlFormat] = useState('ogip'); + const [isLoadingUrl, setIsLoadingUrl] = useState(false); + // Note: urlDownloadProgress removed - job queue handles progress in sidebar + + // Loaded data state + const [loadedEventLists, setLoadedEventLists] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + + // Alert state (local page alert) + const [alert, setAlert] = useState({ + open: false, + message: '', + severity: 'info', + }); + + // Details dialog state + const [detailsOpen, setDetailsOpen] = useState(false); + const [detailsLoading, setDetailsLoading] = useState(false); + const [selectedEventListDetails, setSelectedEventListDetails] = useState(null); + + // Full preview dialog state + const [fullPreviewOpen, setFullPreviewOpen] = useState(false); + const [fullPreviewLoading, setFullPreviewLoading] = useState(false); + const [fullPreviewData, setFullPreviewData] = useState(null); + const [previewTabValue, setPreviewTabValue] = useState(0); + + // Save format dialog state + const [saveFormatDialogOpen, setSaveFormatDialogOpen] = useState(false); + const [saveEventListName, setSaveEventListName] = useState(''); + const [selectedSaveFormat, setSelectedSaveFormat] = useState('hdf5'); + const [availableSaveFormats, setAvailableSaveFormats] = useState([]); + const [hdf5UnavailableReason, setHdf5UnavailableReason] = useState(null); + const [saveFormatsLoadingName, setSaveFormatsLoadingName] = useState(null); + const saveFormatsRequestIdRef = useRef(0); + + // Last loaded files state (for restore functionality) + const [hasLastLoadedFiles, setHasLastLoadedFiles] = useState(false); + const [lastLoadedHints, setLastLoadedHints] = useState([]); + + // Fetch loaded event lists on mount and after operations + const fetchEventLists = useCallback(async (): Promise => { + setIsRefreshing(true); + try { + // Ensure we have the correct port + await apiClient.getPort(); + const response = await dataApi.listEventLists(); + if (response.success && response.data) { + setLoadedEventLists(response.data); + } + } catch (error) { + console.error('Failed to fetch event lists:', error); + } finally { + setIsRefreshing(false); + } + }, []); + + useEffect(() => { + fetchEventLists(); + }, [fetchEventLists]); + + // Refresh event list when jobs complete + useEffect(() => { + // Count completed load jobs + const completedLoadJobs = Object.values(jobs).filter( + (job) => + job.status === 'completed' && + (job.type === 'load_event_list' || job.type === 'load_batch' || job.type === 'load_from_url') + ); + + if (completedLoadJobs.length > 0) { + // Refresh the event list to show newly loaded data + fetchEventLists(); + } + }, [jobs, fetchEventLists]); + + // Check for last loaded files on mount + useEffect(() => { + const lastLoaded = getLastLoadedFiles(); + setHasLastLoadedFiles(lastLoaded !== null && lastLoaded.files.length > 0); + setLastLoadedHints(lastLoaded?.files ?? []); + }, []); + + // Show alert helper - sends all alerts to the global notification center + const showAlert = (message: string, severity: AlertSeverity, title?: string): void => { + // Map severity to notification type + const typeMap: Record = { + success: 'success', + error: 'error', + warning: 'warning', + info: 'info', + }; + const defaultTitles: Record = { + success: 'Success', + error: 'Error', + warning: 'Warning', + info: 'Info', + }; + + addNotification({ + type: typeMap[severity], + title: title || defaultTitles[severity], + message, + }); + + // Clear any existing local alert + setAlert({ open: false, message: '', severity: 'info' }); + }; + + // Check file size when file is selected (single file) + const checkFileSize = async (file: GrantedFileSelection): Promise => { + setIsCheckingFileSize(true); + try { + await apiClient.getPort(); + const response = await dataApi.checkFileSize({ + file_path: file.path, + file_grant: file.grant, + }); + if (response.success && response.data) { + setFileSizeInfo(response.data); + // Auto-enable lazy loading if recommended for large files + if (response.data.recommend_lazy && !useTrueLazyLoading) { + setUseTrueLazyLoading(true); + } + } + } catch (error) { + console.error('Failed to check file size:', error); + setFileSizeInfo(null); + } finally { + setIsCheckingFileSize(false); + } + }; + + // Check batch file sizes when multiple files are selected + const checkBatchFileSize = async (files: GrantedFileSelection[]): Promise => { + if (files.length === 0) return; + + setIsCheckingBatchSize(true); + try { + await apiClient.getPort(); + const response = await dataApi.checkBatchFileSize( + files.map((file) => ({ file_path: file.path, file_grant: file.grant })) + ); + if (response.success && response.data) { + setBatchSizeInfo(response.data); + setBatchSizeSelectionPaths(files.map((file) => file.path)); + // Auto-enable lazy loading if recommended + if (response.data.recommend_partial_loading && !useTrueLazyLoading) { + setUseTrueLazyLoading(true); + } + } else { + setBatchSizeInfo(null); + setBatchSizeSelectionPaths([]); + } + } catch (error) { + console.error('Failed to check batch file sizes:', error); + setBatchSizeInfo(null); + setBatchSizeSelectionPaths([]); + } finally { + setIsCheckingBatchSize(false); + } + }; + + // Fetch file metadata for true lazy loading (uses first selected file as reference) + const fetchFileMetadata = async (): Promise => { + if (selectedFiles.length === 0) { + showAlert('Please select a file first', 'warning'); + return; + } + + setIsLoadingMetadata(true); + try { + await apiClient.getPort(); + // Use first file for metadata preview + const response = await dataApi.getFileMetadata({ + file_path: selectedFiles[0].path, + file_grant: selectedFiles[0].grant, + fmt: fileFormat, + }); + if (response.success && response.data) { + setFileMetadata(response.data); + // Auto-populate time range with full file duration + if (response.data.time_range[0] !== null && response.data.time_range[1] !== null) { + setTimeRangeStart(0); + setTimeRangeEnd(Math.min(response.data.duration, 100)); + } + // Auto-populate event count with recommendation + if (response.data.recommended_loading.suggested_chunk_size) { + setEventCount(response.data.recommended_loading.suggested_chunk_size); + } + const message = selectedFiles.length > 1 + ? `First file has ${response.data.total_events.toLocaleString()} events over ${response.data.duration.toFixed(1)}s (settings will apply to all files)` + : `File has ${response.data.total_events.toLocaleString()} events over ${response.data.duration.toFixed(1)}s`; + showAlert(message, 'success', 'File Metadata'); + } else { + showAlert(response.message || 'Failed to fetch file metadata', 'error', 'Metadata Error'); + } + } catch (error) { + console.error('Failed to fetch file metadata:', error); + showAlert('Failed to fetch file metadata', 'error', 'Metadata Error'); + } finally { + setIsLoadingMetadata(false); + } + }; + + const addGrantedFiles = ( + files: GrantedFileSelection[], + preferredNames: Record = {} + ): void => { + const { merged: mergedFiles, added: newFiles } = mergeGrantedSelections( + selectedFiles, + files + ); + setSelectedFiles(mergedFiles); + + if (newFiles.length > 0 && selectedFiles.length === 0) { + setFileFormat(detectFormatFromExtension(newFiles[0].path)); + } + + const mergedNames = { ...fileNames }; + newFiles.forEach((file) => { + const filename = file.path.split(/[\\/]/).pop() || 'event_list'; + const fallbackName = filename.split('.')[0] || 'event_list'; + const preferredName = preferredNames[file.path]?.trim(); + const baseName = preferredName || fallbackName; + let uniqueName = baseName; + let counter = 1; + while (Object.values(mergedNames).includes(uniqueName)) { + uniqueName = `${baseName}_${counter}`; + counter++; + } + mergedNames[file.path] = uniqueName; + }); + setFileNames(mergedNames); + + const mergedConfigs = { ...perFileConfigs }; + newFiles.forEach((file) => { + mergedConfigs[file.path] = { + fmt: detectFormatFromExtension(file.path), + high_precision: false, + skip_checks: false, + use_partial_loading: false, + partial_mode: 'time_range', + time_range_start: 0, + time_range_end: 100, + event_start_index: 0, + event_count: 10000, + notes: '', + }; + }); + setPerFileConfigs(mergedConfigs); + + if (mergedFiles.length > 1) { + void checkBatchFileSize(mergedFiles); + setFileSizeInfo(null); + } else if (mergedFiles.length === 1) { + void checkFileSize(mergedFiles[0]); + setBatchSizeInfo(null); + setBatchSizeSelectionPaths([]); + } + }; + + // Handle file selection via Electron dialog (supports multiple files). + const handleBrowseFiles = async (): Promise => { + if (!window.electronAPI?.openGrantedFile) { + showAlert('Granted file dialog not available (Electron API not found)', 'error'); + return; + } + + try { + const files = await window.electronAPI.openGrantedFile({ + title: 'Select Event List Files', + filters: EVENT_FILE_FILTERS, + multiple: true, + }); + if (files?.length) addGrantedFiles(files); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + showAlert(`Could not open the granted file dialog: ${detail}`, 'error'); + } + }; + + // Remove a file from the selection + const handleRemoveFile = (filePath: string): void => { + const newFiles = selectedFiles.filter((file) => file.path !== filePath); + setSelectedFiles(newFiles); + + // Update file names + const newNames = { ...fileNames }; + delete newNames[filePath]; + setFileNames(newNames); + + // Update per-file configs + const newConfigs = { ...perFileConfigs }; + delete newConfigs[filePath]; + setPerFileConfigs(newConfigs); + + setPerFileRmfSelections((previous) => { + const updated = { ...previous }; + delete updated[filePath]; + return updated; + }); + + // Re-check sizes + if (newFiles.length > 1) { + checkBatchFileSize(newFiles); + setFileSizeInfo(null); + } else if (newFiles.length === 1) { + checkFileSize(newFiles[0]); + setBatchSizeInfo(null); + setBatchSizeSelectionPaths([]); + } else { + setFileSizeInfo(null); + setBatchSizeInfo(null); + setBatchSizeSelectionPaths([]); + } + }; + + // Clear all selected files + const handleClearSelection = (): void => { + setSelectedFiles([]); + setFileNames({}); + setPerFileConfigs({}); + setPerFileRmfSelections({}); + setBatchSizeInfo(null); + setBatchSizeSelectionPaths([]); + setFileSizeInfo(null); + // Batch result cleared (job queue handles results) + setExpandedFileSettings({}); + }; + + // Historical paths are display hints only. A fresh native selection creates new grants. + const handleRestoreLastFiles = async (): Promise => { + const lastLoaded = getLastLoadedFiles(); + if (!lastLoaded || lastLoaded.files.length === 0) { + showAlert('No previously loaded files found', 'info'); + return; + } + + if (!window.electronAPI?.openGrantedFile) { + showAlert('Granted file dialog not available (Electron API not found)', 'error'); + return; + } + + try { + const files = await window.electronAPI.openGrantedFile({ + title: 'Reselect previously loaded Event List files', + filters: EVENT_FILE_FILTERS, + multiple: true, + }); + if (!files?.length) return; + addGrantedFiles(files, lastLoaded.fileNames); + showAlert( + `Granted ${files.length} freshly selected file${files.length === 1 ? '' : 's'}.`, + 'success' + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + showAlert(`Could not reselect previous files: ${detail}`, 'error'); + } + }; + + // Update file name + const handleFileNameChange = (filePath: string, newName: string): void => { + setFileNames((prev) => ({ + ...prev, + [filePath]: newName, + })); + }; + + // Update per-file config + const handlePerFileConfigChange = ( + filePath: string, + updates: PerFileUiConfig + ): void => { + setPerFileConfigs((prev) => ({ + ...prev, + [filePath]: { + ...prev[filePath], + ...updates, + }, + })); + }; + + // Toggle per-file settings expansion + const toggleFileSettings = (filePath: string): void => { + setExpandedFileSettings((prev) => ({ + ...prev, + [filePath]: !prev[filePath], + })); + }; + + // Handle RMF file selection + const handleBrowseRmfFile = async (): Promise => { + if (!window.electronAPI?.openGrantedFile) { + showAlert('Granted file dialog not available (Electron API not found)', 'error'); + return; + } + + try { + const files = await window.electronAPI.openGrantedFile({ + title: 'Select RMF (Response Matrix) File', + filters: RMF_FILE_FILTERS, + multiple: false, + }); + if (files?.length) setRmfSelection(files[0]); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + showAlert(`Could not open the granted RMF dialog: ${detail}`, 'error'); + } + }; + + // Handle per-file RMF file browsing + const handleBrowsePerFileRmf = async (filePath: string): Promise => { + if (!window.electronAPI?.openGrantedFile) { + showAlert('Granted file dialog not available (Electron API not found)', 'error'); + return; + } + + try { + const files = await window.electronAPI.openGrantedFile({ + title: 'Select RMF (Response Matrix) File', + filters: RMF_FILE_FILTERS, + multiple: false, + }); + if (files?.length) { + setPerFileRmfSelections((previous) => ({ + ...previous, + [filePath]: files[0], + })); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + showAlert(`Could not open the granted RMF dialog: ${detail}`, 'error'); + } + }; + + // Handle loading files (single or batch) - submits background jobs + const handleLoadFile = async (): Promise => { + if (selectedFiles.length === 0) { + showAlert('Please select a file first', 'warning'); + return; + } + + // Validate names + const names = Object.values(fileNames); + const emptyNames = selectedFiles.filter((file) => !fileNames[file.path]?.trim()); + if (emptyNames.length > 0) { + showAlert('Please provide names for all selected files', 'warning'); + return; + } + + // Check for duplicate names + const uniqueNames = new Set(names); + if (uniqueNames.size !== names.length) { + showAlert('File names must be unique', 'warning'); + return; + } + + setIsLoading(true); + // Batch result cleared (job queue handles results) + setAlert({ open: false, message: '', severity: 'info' }); + + try { + await apiClient.getPort(); + + // Parse additional columns if provided (for shared settings) + const additionalColumnsArray = additionalColumns.trim() + ? additionalColumns.split(',').map((col) => col.trim()).filter((col) => col) + : undefined; + + // Single file: submit a single load job + if (selectedFiles.length === 1) { + const source = selectedFiles[0]; + const jobParams = { + file_path: source.path, + file_grant: source.grant, + name: fileNames[source.path].trim(), + fmt: fileFormat, + additional_columns: additionalColumnsArray, + high_precision: highPrecision, + skip_checks: skipChecks, + notes: eventNotes.trim() || undefined, + use_partial_loading: useTrueLazyLoading, + partial_mode: trueLazyMode, + time_range_start: useTrueLazyLoading && trueLazyMode === 'time_range' ? timeRangeStart : undefined, + time_range_end: useTrueLazyLoading && trueLazyMode === 'time_range' ? timeRangeEnd : undefined, + event_start_index: useTrueLazyLoading && trueLazyMode === 'event_count' ? eventCountStart : undefined, + event_count: useTrueLazyLoading && trueLazyMode === 'event_count' ? eventCount : undefined, + }; + const response = await jobApi.submitLoadJob( + rmfSelection + ? { + ...jobParams, + rmf_file: rmfSelection.path, + rmf_grant: rmfSelection.grant, + } + : jobParams + ); + + if (response.success && response.data) { + showAlert( + `Job submitted: ${response.data.display_name}. Check sidebar for progress.`, + 'info', + 'Job Submitted' + ); + // Save files to localStorage before clearing form + saveLastLoadedFiles(selectedFiles, fileNames); + setHasLastLoadedFiles(true); + setLastLoadedHints(selectedFiles.map((file) => file.path)); + resetForm(); + } else { + showAlert(response.message || 'Failed to submit load job', 'error', 'Job Submit Failed'); + } + } else { + // Multiple files: submit a batch load job + const fileConfigs: BatchFileConfig[] = selectedFiles.map((file) => { + const perFile = perFileConfigs[file.path] || {}; + const perFileRmf = useSameSettings + ? undefined + : perFileRmfSelections[file.path]; + const fileConfig = { + file_path: file.path, + file_grant: file.grant, + name: fileNames[file.path].trim(), + fmt: useSameSettings ? fileFormat : (perFile.fmt || 'ogip'), + additional_columns: useSameSettings ? additionalColumnsArray : perFile.additional_columns, + high_precision: useSameSettings ? highPrecision : (perFile.high_precision || false), + skip_checks: useSameSettings ? skipChecks : (perFile.skip_checks || false), + use_partial_loading: useSameSettings ? useTrueLazyLoading : (perFile.use_partial_loading || false), + partial_mode: useSameSettings ? trueLazyMode : (perFile.partial_mode || 'time_range'), + time_range_start: useSameSettings ? timeRangeStart : perFile.time_range_start, + time_range_end: useSameSettings ? timeRangeEnd : perFile.time_range_end, + event_start_index: useSameSettings ? eventCountStart : perFile.event_start_index, + event_count: useSameSettings ? eventCount : perFile.event_count, + notes: useSameSettings ? (eventNotes.trim() || undefined) : (perFile.notes?.trim() || undefined), + }; + return perFileRmf + ? { + ...fileConfig, + rmf_file: perFileRmf.path, + rmf_grant: perFileRmf.grant, + } + : fileConfig; + }); + + const batchParams = { + files: fileConfigs, + use_same_settings: useSameSettings, + shared_fmt: fileFormat, + shared_additional_columns: additionalColumnsArray, + shared_high_precision: highPrecision, + shared_skip_checks: skipChecks, + shared_use_partial_loading: useTrueLazyLoading, + shared_partial_mode: trueLazyMode, + shared_time_range_start: useTrueLazyLoading ? timeRangeStart : undefined, + shared_time_range_end: useTrueLazyLoading ? timeRangeEnd : undefined, + shared_event_start_index: useTrueLazyLoading ? eventCountStart : undefined, + shared_event_count: useTrueLazyLoading ? eventCount : undefined, + }; + const response = await jobApi.submitBatchJob( + useSameSettings && rmfSelection + ? { + ...batchParams, + shared_rmf_file: rmfSelection.path, + shared_rmf_grant: rmfSelection.grant, + } + : batchParams + ); + + if (response.success && response.data) { + showAlert( + `Batch job submitted: ${selectedFiles.length} files. Check sidebar for progress.`, + 'info', + 'Batch Job Submitted' + ); + // Save files to localStorage and clear form + saveLastLoadedFiles(selectedFiles, fileNames); + setHasLastLoadedFiles(true); + setLastLoadedHints(selectedFiles.map((file) => file.path)); + resetForm(); + } else { + showAlert(response.message || 'Failed to submit batch job', 'error', 'Batch Job Submit Failed'); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + showAlert(`Error: ${errorMessage}`, 'error', 'Job Submit Error'); + } finally { + setIsLoading(false); + } + }; + + // Reset form after successful load + const resetForm = (): void => { + setSelectedFiles([]); + setFileNames({}); + setPerFileConfigs({}); + setPerFileRmfSelections({}); + setExpandedFileSettings({}); + setRmfSelection(null); + setAdditionalColumns(''); + setEventNotes(''); + setFileSizeInfo(null); + setBatchSizeInfo(null); + setBatchSizeSelectionPaths([]); + setFileMetadata(null); + setUseTrueLazyLoading(false); + setHighPrecision(false); + setSkipChecks(false); + setTimeRangeStart(0); + setTimeRangeEnd(100); + setEventCountStart(0); + setEventCount(10000); + // Batch result cleared (job queue handles results) + }; + + // Handle deleting an event list + const handleDeleteEventList = async (name: string): Promise => { + try { + await apiClient.getPort(); + const response = await dataApi.deleteEventList(name); + + if (response.success) { + showAlert(`Event List '${name}' deleted`, 'success', 'Data Deleted'); + await fetchEventLists(); + } else { + showAlert(response.message || 'Failed to delete Event List', 'error', 'Delete Failed'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + showAlert(`Error: ${errorMessage}`, 'error', 'Delete Error'); + } + }; + + // Discover verified export capabilities before opening the format dialog. + const handleSaveEventList = async (name: string): Promise => { + const requestId = ++saveFormatsRequestIdRef.current; + setSaveFormatsLoadingName(name); + try { + const response = await ioApi.listExportableObjects(); + if (requestId !== saveFormatsRequestIdRef.current) return; + + const eventList = response.data?.objects.find( + (object) => object.object_type === 'event_list' && object.name === name + ); + const formats = (eventList?.formats ?? []).filter( + (format): format is LegacySaveFormat => + (format === 'hdf5' || format === 'ecsv') && + Boolean(response.data?.format_allowlist.includes(format)) && + Boolean(response.data?.capability_matrix?.event_list?.[format]?.supported) + ); + if (!response.success || !eventList?.exportable || formats.length === 0) { + showAlert( + eventList?.reason || response.message || 'No verified export format is available.', + 'error', + 'Export Unavailable' + ); + return; + } + + setSaveEventListName(name); + setAvailableSaveFormats(formats); + setHdf5UnavailableReason( + formats.includes('hdf5') + ? null + : response.data?.capability_matrix?.event_list?.hdf5?.notes || + response.data?.excluded_formats.hdf5 || + 'HDF5 is unavailable in this runtime.' + ); + setSelectedSaveFormat(formats.includes('hdf5') ? 'hdf5' : formats[0]); + setSaveFormatDialogOpen(true); + } catch (error) { + if (requestId !== saveFormatsRequestIdRef.current) return; + const detail = error instanceof Error ? error.message : String(error); + showAlert(`Could not determine export capabilities: ${detail}`, 'error'); + } finally { + if (requestId === saveFormatsRequestIdRef.current) { + setSaveFormatsLoadingName(null); + } + } + }; + + // Export through a native write grant after a verified format is selected. + const handleConfirmSave = async (): Promise => { + setSaveFormatDialogOpen(false); + + if (!window.electronAPI?.saveGrantedFile) { + showAlert('Granted save dialog not available (Electron API not found)', 'error'); + return; + } + + const filterMap: Record = { + hdf5: { name: 'HDF5 Files', extensions: ['hdf5'] }, + ecsv: { name: 'ECSV Files', extensions: ['ecsv'] }, + }; + const filter = filterMap[selectedSaveFormat]; + + try { + const destination = await window.electronAPI.saveGrantedFile({ + title: `Export Event List: ${saveEventListName}`, + defaultPath: `${saveEventListName}.${selectedSaveFormat}`, + filters: [filter], + }); + if (!destination) return; + + const response = await ioApi.exportObject({ + object_type: 'event_list', + object_name: saveEventListName, + format: selectedSaveFormat, + destination_path: destination.path, + destination_grant: destination.grant, + }); + + if (response.success) { + showAlert( + `Event List '${saveEventListName}' exported to ${destination.path}`, + 'success', + 'Data Exported' + ); + } else { + showAlert(response.message || 'Failed to export Event List', 'error', 'Export Failed'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + showAlert(`Error: ${errorMessage}`, 'error', 'Export Error'); + } + }; + + // Handle clearing all event lists + const handleClearAll = async (): Promise => { + if (loadedEventLists.length === 0) { + showAlert('No event lists to clear', 'warning'); + return; + } + + try { + await apiClient.getPort(); + const response = await dataApi.clearAllEventLists(); + + if (response.success) { + showAlert(response.message || 'All event lists cleared', 'success', 'Data Cleared'); + await fetchEventLists(); + } else { + showAlert(response.message || 'Failed to clear event lists', 'error', 'Clear Failed'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + showAlert(`Error: ${errorMessage}`, 'error', 'Clear Error'); + } + }; + + // Handle loading from URL - submits a background job + const handleLoadFromUrl = async (): Promise => { + if (!urlInput.trim()) { + showAlert('Please enter a URL', 'warning'); + return; + } + + if (!urlEventListName.trim()) { + showAlert('Please provide a name for the Event List', 'warning'); + return; + } + + const urlValidationError = validateRemoteSourceUrl(urlInput.trim()); + if (urlValidationError) { + showAlert(urlValidationError, 'warning'); + return; + } + + setIsLoadingUrl(true); + setAlert({ open: false, message: '', severity: 'info' }); + + try { + await apiClient.getPort(); + + // Submit URL download job + const response = await jobApi.submitUrlJob({ + url: urlInput.trim(), + name: urlEventListName.trim(), + fmt: urlFormat, + high_precision: highPrecision, + skip_checks: skipChecks, + }); + + if (response.success && response.data) { + showAlert( + `URL download job submitted: ${response.data.display_name}. Check sidebar for progress.`, + 'info', + 'Job Submitted' + ); + // Reset form + setUrlInput(''); + setUrlEventListName(''); + } else { + showAlert(response.message || 'Failed to submit URL job', 'error', 'Job Submit Failed'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + showAlert(`Error: ${errorMessage}`, 'error', 'URL Job Submit Error'); + } finally { + setIsLoadingUrl(false); + } + }; + + // Handle viewing event list details + const handleViewDetails = async (name: string): Promise => { + setDetailsLoading(true); + setDetailsOpen(true); + + try { + await apiClient.getPort(); + const response = await dataApi.getEventListInfo(name); + + if (response.success && response.data) { + setSelectedEventListDetails(response.data); + } else { + showAlert(response.message || 'Failed to fetch details', 'error', 'Details Error'); + setDetailsOpen(false); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + showAlert(`Error: ${errorMessage}`, 'error', 'Details Error'); + setDetailsOpen(false); + } finally { + setDetailsLoading(false); + } + }; + + // Close details dialog + const handleCloseDetails = (): void => { + setDetailsOpen(false); + setSelectedEventListDetails(null); + }; + + // Handle viewing full preview of event list + const handleViewFullPreview = async (name: string): Promise => { + setFullPreviewLoading(true); + setFullPreviewOpen(true); + setPreviewTabValue(0); + + try { + await apiClient.getPort(); + const response = await dataApi.getEventListFullPreview(name, 10); + + if (response.success && response.data) { + setFullPreviewData(response.data); + } else { + showAlert(response.message || 'Failed to fetch full preview', 'error', 'Preview Error'); + setFullPreviewOpen(false); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + showAlert(`Error: ${errorMessage}`, 'error', 'Preview Error'); + setFullPreviewOpen(false); + } finally { + setFullPreviewLoading(false); + } + }; + + // Close full preview dialog + const handleCloseFullPreview = (): void => { + setFullPreviewOpen(false); + setFullPreviewData(null); + setPreviewTabValue(0); + }; + + // Format number with commas + const formatNumber = (num: number): string => { + return num.toLocaleString(); + }; + + // Format time range + const formatTimeRange = (range: [number, number]): string => { + const duration = range[1] - range[0]; + return `${duration.toFixed(2)}s`; + }; + + return ( + + + Data Ingestion + + + Load X-ray astronomy event list files for analysis + + + {/* Alert */} + {alert.open && ( + setAlert({ ...alert, open: false })} + sx={{ mb: 3 }} + > + {alert.message} + + )} + + {/* Data Input Tabs */} + + setDataInputTab(newValue)} + variant="fullWidth" + sx={{ borderBottom: 1, borderColor: 'divider' }} + > + } + iconPosition="start" + label="Local File" + sx={{ textTransform: 'none' }} + /> + } + iconPosition="start" + label="From URL" + sx={{ textTransform: 'none' }} + /> + } + iconPosition="start" + label="Browse HEASARC" + sx={{ textTransform: 'none' }} + /> + + + + {/* Tab 0: Load from Local File */} + {dataInputTab === 0 && ( + + + + Load Local File + + + + Load FITS/OGIP, HDF5, or ECSV event lists through the native file picker. + + + {/* File Selection */} + + + + {hasLastLoadedFiles && ( + + + + )} + + {hasLastLoadedFiles && lastLoadedHints.length > 0 && ( + + Previous path hints (fresh native selection required): {lastLoadedHints.join(', ')} + + )} + + {/* Selected Files List */} + {selectedFiles.length > 0 && ( + + + + Selected Files ({selectedFiles.length}) + {selectedFiles.length > 1 && ( + + )} + + + + + {/* Batch Size Info (for multiple files) */} + {(isCheckingBatchSize || isCheckingFileSize) && ( + + + + Checking file sizes... + + + )} + + {/* Batch Size Summary */} + {batchSizeInfo && !isCheckingBatchSize && ( + + + + {batchSizeInfo.total.risk_level === 'safe' ? ( + + ) : ( + + )} + + Total: {batchSizeInfo.total.size_mb.toFixed(1)} MB → ~{batchSizeInfo.total.estimated_ram_mb.toFixed(0)} MB RAM + + + + + + Available RAM: {batchSizeInfo.available_ram_mb.toFixed(0)} MB + + {batchSizeInfo.recommend_partial_loading && ( + + Consider using partial loading to reduce memory usage. + + )} + + )} + + {/* Single file size info */} + {fileSizeInfo && !isCheckingFileSize && selectedFiles.length === 1 && ( + + + {fileSizeInfo.risk_level === 'safe' ? ( + + ) : ( + + )} + + {fileSizeInfo.file_size_mb < 1 + ? `${(fileSizeInfo.file_size_bytes / 1024).toFixed(1)} KB` + : fileSizeInfo.file_size_gb >= 1 + ? `${fileSizeInfo.file_size_gb.toFixed(2)} GB` + : `${fileSizeInfo.file_size_mb.toFixed(1)} MB`} + + + + {fileSizeInfo.ram_usage_percent !== undefined && ( + + Would use ~{fileSizeInfo.ram_usage_percent.toFixed(0)}% of available RAM + + )} + + )} + + {/* Settings Mode Toggle (only for batch) */} + {selectedFiles.length > 1 && ( + + setUseSameSettings(e.target.checked)} + size="small" + /> + } + label={ + + Apply same settings to all files + + } + /> + + {useSameSettings + ? 'All files will use the format and options below' + : 'Each file can have different settings (expand to configure)'} + + + )} + + {/* File List */} + + + {selectedFiles.map((selection, index) => { + const filePath = selection.path; + const fileName = filePath.split(/[\\/]/).pop() || filePath; + const sizeInfoIndex = batchSizeSelectionPaths.indexOf(filePath); + const sizeInfo = sizeInfoIndex >= 0 + ? batchSizeInfo?.files[sizeInfoIndex] + : undefined; + const isExpanded = expandedFileSettings[filePath]; + + return ( + + {index > 0 && } + + + handleFileNameChange(filePath, e.target.value)} + placeholder="Name" + sx={{ width: 140, flexShrink: 0 }} + inputProps={{ style: { fontSize: '0.875rem' } }} + /> + + + {fileName} + + {sizeInfo && ( + + {sizeInfo.size_mb.toFixed(1)} MB + {sizeInfo.ram_percent > 30 && ( + + )} + + )} + + {/* Per-file settings button (only when not using same settings) */} + {selectedFiles.length > 1 && !useSameSettings && ( + toggleFileSettings(filePath)} + color={isExpanded ? 'primary' : 'default'} + > + + + )} + handleRemoveFile(filePath)} + color="error" + > + + + + + {/* Per-file settings (collapsed) */} + {!useSameSettings && isExpanded && ( + + + {/* Format */} + + + Format + + + + + {/* RMF File */} + + + RMF File (optional) + + + + + {perFileRmfSelections[filePath] && ( + setPerFileRmfSelections((previous) => ({ + ...previous, + [filePath]: undefined, + }))} + > + + + )} + + + + {/* Additional Columns */} + + handlePerFileConfigChange(filePath, { + additional_columns: e.target.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + })} + fullWidth + placeholder="e.g., PI, ENERGY, DET_ID" + helperText="Comma-separated" + inputProps={{ style: { fontSize: '0.75rem' } }} + /> + + + {/* Notes */} + + handlePerFileConfigChange(filePath, { + notes: e.target.value + })} + fullWidth + multiline + rows={2} + placeholder="Add notes about this file..." + inputProps={{ style: { fontSize: '0.75rem' } }} + /> + + + {/* High Precision & Skip Checks */} + + + handlePerFileConfigChange(filePath, { high_precision: e.target.checked })} + size="small" + /> + } + label={ + + + High precision + + } + /> + + + + + handlePerFileConfigChange(filePath, { skip_checks: e.target.checked })} + size="small" + /> + } + label={ + + + Skip checks + + } + /> + + + + {/* Partial Loading */} + + + handlePerFileConfigChange(filePath, { use_partial_loading: e.target.checked })} + size="small" + color="secondary" + /> + } + label={ + + + Partial loading + + } + /> + + + + {perFileConfigs[filePath]?.use_partial_loading && ( + <> + + + Mode + + + + {perFileConfigs[filePath]?.partial_mode === 'time_range' ? ( + <> + + handlePerFileConfigChange(filePath, { time_range_start: parseFloat(e.target.value) || 0 })} + fullWidth + /> + + + handlePerFileConfigChange(filePath, { time_range_end: parseFloat(e.target.value) || 100 })} + fullWidth + /> + + + ) : ( + <> + + handlePerFileConfigChange(filePath, { event_start_index: parseInt(e.target.value) || 0 })} + fullWidth + /> + + + handlePerFileConfigChange(filePath, { event_count: parseInt(e.target.value) || 10000 })} + fullWidth + /> + + + )} + + )} + + + )} + + + ); + })} + + + + )} + + + + {/* File Format - only shown when using same settings for all files */} + {useSameSettings && ( + + File Format + + + )} + + {/* Advanced Options Toggle - only shown when using same settings for all files */} + {useSameSettings && ( + + )} + + {/* Advanced Options Content - only shown when using same settings for all files */} + + + {/* RMF File */} + + RMF File (for PI → Energy calibration) + + + + + {rmfSelection && ( + setRmfSelection(null)}> + + + )} + + + {/* Additional Columns */} + + Additional Columns + + setAdditionalColumns(e.target.value)} + fullWidth + size="small" + placeholder="e.g., PI, ENERGY, DET_ID (comma-separated)" + helperText="Extra columns to read from the file" + sx={{ mb: 2 }} + /> + + {/* Notes/Comments */} + + Notes / Comments + + setEventNotes(e.target.value)} + fullWidth + size="small" + multiline + rows={2} + placeholder="Add notes about this data (e.g., observation details, analysis purpose...)" + helperText="Optional annotations stored with the event list" + sx={{ mb: 2 }} + /> + + + + {/* Loading Options */} + + + Loading Options + + + {/* High Precision */} + + High Precision Timing + + Uses numpy.float128 (128-bit) instead of float64 for time arrays, providing ~18 more decimal digits of precision. + + + Use when: Pulsar timing analysis, millisecond pulsars, phase-coherent timing, or any analysis requiring nanosecond-level accuracy. + + + Avoid when: General spectral/timing analysis, large files (increases memory ~2x), or when float64 precision is sufficient. + + + } + arrow + placement="right" + > + setHighPrecision(e.target.checked)} + size="small" + /> + } + label={ + + + High precision timing + + } + /> + + + {/* Skip Checks */} + + Skip Validation Checks + + Bypasses time ordering verification and GTI (Good Time Interval) validation during loading. + + + Use when: Loading trusted/verified data, re-loading previously validated files, or when you need faster loading and will validate manually. + + + Avoid when: Loading new/untrusted data, data from unfamiliar sources, or when data integrity is critical for your analysis. + + + } + arrow + placement="right" + > + setSkipChecks(e.target.checked)} + size="small" + /> + } + label={ + + + Skip validation checks + + } + /> + + + {/* Partial Loading */} + + Partial Loading + + Loads only a specific portion of the file (by time range or event count) without reading the entire file into memory. + + + Use when: Working with large FITS files (>1GB), exploring data before full analysis, or when you only need a specific time segment. + + + Avoid when: Using non-FITS formats (HDF5, CSV), or when you need the complete dataset for analysis. + + + Note: Only FITS formats are supported. + + + } + arrow + placement="right" + > + setUseTrueLazyLoading(e.target.checked)} + size="small" + color="secondary" + /> + } + label={ + + + Partial loading + + } + /> + + + {/* True Lazy Loading Options */} + {useTrueLazyLoading && ( + + {/* Get Metadata Button */} + + + {/* File Metadata Display */} + {fileMetadata && ( + + + Total Events: {fileMetadata.total_events.toLocaleString()} + + + Duration: {fileMetadata.duration.toFixed(2)}s + + + File Size: {fileMetadata.file_size_mb.toFixed(1)} MB + + + GTIs: {fileMetadata.gti_count} + + {fileMetadata.mission && ( + + Mission: {fileMetadata.mission} + + )} + + + )} + + {/* Mode Selection */} + + Loading Mode + + + + {/* Time Range Mode */} + {trueLazyMode === 'time_range' && ( + + setTimeRangeStart(parseFloat(e.target.value) || 0)} + size="small" + fullWidth + inputProps={{ min: 0, step: 1 }} + /> + setTimeRangeEnd(parseFloat(e.target.value) || 100)} + size="small" + fullWidth + inputProps={{ min: 0, step: 1 }} + /> + + )} + + {/* Event Count Mode */} + {trueLazyMode === 'event_count' && ( + + setEventCountStart(parseInt(e.target.value) || 0)} + size="small" + fullWidth + inputProps={{ min: 0, step: 1000 }} + /> + setEventCount(parseInt(e.target.value) || 10000)} + size="small" + fullWidth + inputProps={{ min: 1, step: 1000 }} + /> + + )} + + + {trueLazyMode === 'time_range' + ? `Will load events from ${timeRangeStart}s to ${timeRangeEnd}s (${timeRangeEnd - timeRangeStart}s duration)` + : `Will load ${eventCount.toLocaleString()} events starting from index ${eventCountStart.toLocaleString()}`} + + + )} + + {/* Loading mode indicator */} + {useTrueLazyLoading && ( + } + > + {trueLazyMode === 'time_range' + ? `Partial loading: Only events in [${timeRangeStart}s - ${timeRangeEnd}s] will be loaded.` + : `Partial loading: Only ${eventCount.toLocaleString()} events starting at index ${eventCountStart} will be loaded.`} + + )} +
+ + + {/* Load Button */} + + + {/* Note: Batch loading progress is now shown in the sidebar job queue */} + + )} + + {/* Tab 1: Load from URL */} + {dataInputTab === 1 && ( + + + + Load from URL + + + + Fetch event list data directly from a remote URL (use raw links for GitHub) + + + {/* URL Input */} + setUrlInput(e.target.value)} + fullWidth + size="small" + sx={{ mb: 2 }} + placeholder="https://example.com/data/events.fits" + helperText="Enter the direct link to the event file" + InputProps={{ + startAdornment: , + }} + /> + + {/* Event List Name */} + setUrlEventListName(e.target.value)} + fullWidth + size="small" + sx={{ mb: 2 }} + placeholder="Enter a name for this event list" + helperText="This name will be used to reference the data" + /> + + {/* File Format */} + + File Format + + + + {/* Note: Download progress is now shown in the sidebar job queue */} + + {/* Load Button */} + + + )} + + {/* Tab 2: Browse HEASARC */} + {dataInputTab === 2 && ( + + )} + + + + {/* Loaded Event Lists */} + + + Loaded Event Lists + + {loadedEventLists.length > 0 && ( + + )} + + + + + + + + + {loadedEventLists.length === 0 ? ( + + No event lists loaded yet. Use the options above to load your data. + + ) : ( + + {loadedEventLists.map((eventList, index) => ( + + {index > 0 && } + + + + {eventList.name} + + {eventList.has_energy && ( + + )} + {eventList.has_pi && ( + + )} + {eventList.gti_warnings && eventList.gti_warnings.length > 0 && ( + + 1 ? 's' : ''}`} + size="small" + color="warning" + icon={} + /> + + )} + {/* Validation issues */} + {eventList.validation_issues && eventList.validation_issues.length > 0 && (() => { + const errors = eventList.validation_issues.filter((v) => v.severity === 'error'); + const warnings = eventList.validation_issues.filter((v) => v.severity === 'warning'); + return ( + <> + {errors.length > 0 && ( + e.message).join('\n')}> + 1 ? 's' : ''}`} + size="small" + color="error" + icon={} + /> + + )} + {warnings.length > 0 && ( + w.message).join('\n')}> + 1 ? 's' : ''}`} + size="small" + color="warning" + variant="outlined" + /> + + )} + + ); + })()} + {/* Notes indicator */} + {eventList.notes && ( + + + + )} + + } + secondary={ + + + {formatNumber(eventList.n_events)} events + + + • + + + Duration: {formatTimeRange(eventList.time_range)} + + {eventList.gti_count !== undefined && eventList.gti_count > 0 && ( + <> + + • + + + {eventList.gti_count} GTI(s) + + + )} + + } + /> + + + handleViewDetails(eventList.name)} + > + + + + + handleViewFullPreview(eventList.name)} + color="secondary" + > + + + + + void handleSaveEventList(eventList.name)} + disabled={saveFormatsLoadingName === eventList.name} + color="primary" + > + {saveFormatsLoadingName === eventList.name ? ( + + ) : ( + + )} + + + + handleDeleteEventList(eventList.name)} + color="error" + > + + + + + + + ))} + + )} + + + {/* Event List Details Dialog */} + + + + Event List Details: {selectedEventListDetails?.name || ''} + + + + + + + {detailsLoading ? ( + + + + ) : selectedEventListDetails ? ( + + {/* Basic Info */} + + + Basic Information + + + + Events + {formatNumber(selectedEventListDetails.n_events)} + + + Duration + {selectedEventListDetails.duration.toFixed(6)}s + + + Mean Count Rate + + {selectedEventListDetails.mean_count_rate?.toFixed(6) || 'N/A'} cts/s + + + + MJDREF + {selectedEventListDetails.mjdref || 'N/A'} + + {selectedEventListDetails.mission && ( + + Mission + {selectedEventListDetails.mission} + + )} + {selectedEventListDetails.instrument && ( + + Instrument + {selectedEventListDetails.instrument} + + )} + + + + + + {/* Time Range */} + + + Time Information + + + + Start Time + {selectedEventListDetails.time_range[0].toFixed(6)} + + + End Time + {selectedEventListDetails.time_range[1].toFixed(6)} + + {selectedEventListDetails.min_time_diff !== undefined && ( + + Min Time Diff + {selectedEventListDetails.min_time_diff.toExponential(6)}s + + )} + + + + + + {/* Data Columns */} + + + Data Columns + + + + {selectedEventListDetails.has_energy && ( + + )} + {selectedEventListDetails.has_pi && ( + + )} + + + + {/* GTI Table */} + {selectedEventListDetails.gti_list && selectedEventListDetails.gti_list.length > 0 && ( + <> + + + + Good Time Intervals ({selectedEventListDetails.gti_count} GTI{selectedEventListDetails.gti_count !== 1 ? 's' : ''}) + {selectedEventListDetails.total_gti_time && ( + + Total: {selectedEventListDetails.total_gti_time.toFixed(6)}s + + )} + + + + + + # + Start + Stop + Duration + + + + {selectedEventListDetails.gti_list.map((gti, index) => ( + + {index + 1} + {gti[0].toFixed(6)} + {gti[1].toFixed(6)} + {(gti[1] - gti[0]).toFixed(6)}s + + ))} + +
+
+
+ + )} +
+ ) : ( + No details available + )} +
+ + + +
+ + {/* Full Preview Dialog */} + + + + + + Full Preview: {fullPreviewData?.name || ''} + + + + + + + + {fullPreviewLoading ? ( + + + + ) : fullPreviewData ? ( + + {/* Tabs for different sections */} + setPreviewTabValue(newValue)} + sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }} + variant="scrollable" + scrollButtons="auto" + > + + + + + + + + + + {/* Overview Tab */} + {previewTabValue === 0 && ( + + + + + + {formatNumber(fullPreviewData.n_events)} + + Total Events + + + + + + {fullPreviewData.duration.toFixed(6)}s + + Duration + + + + + + {fullPreviewData.mean_count_rate?.toFixed(6) || 'N/A'} + + Mean Count Rate (cts/s) + + + + + + {fullPreviewData.gti_count} + + GTI Count + + + + + + + {fullPreviewData.has_energy && } + {fullPreviewData.has_pi && } + {fullPreviewData.mission && } + {fullPreviewData.instrument && } + + + )} + + {/* Time Data Tab */} + {previewTabValue === 1 && ( + + Time Range + + + Start Time + + {fullPreviewData.time_range[0].toFixed(6)} + + + + End Time + + {fullPreviewData.time_range[1].toFixed(6)} + + + + MJDREF + + {fullPreviewData.mjdref || 'N/A'} + + + + + + + Time Statistics + + + Min Time Diff + + {fullPreviewData.min_time_diff?.toExponential(6) || 'N/A'}s + + + + Max Time Diff + + {fullPreviewData.max_time_diff?.toExponential(6) || 'N/A'}s + + + + Mean Time Diff + + {fullPreviewData.mean_time_diff?.toExponential(6) || 'N/A'}s + + + + Median Time Diff + + {fullPreviewData.median_time_diff?.toExponential(6) || 'N/A'}s + + + + Std Dev Time Diff + + {fullPreviewData.std_time_diff?.toExponential(6) || 'N/A'}s + + + + + {/* Per-GTI Rates */} + {fullPreviewData.per_gti_rates && fullPreviewData.per_gti_rates.length > 0 && ( + <> + + + Per-GTI Count Rates ({fullPreviewData.per_gti_rates.length} GTI{fullPreviewData.per_gti_rates.length !== 1 ? 's' : ''}) + + + + + + # + Start (s) + Stop (s) + Events + Duration (s) + Rate (cts/s) + + + + {fullPreviewData.per_gti_rates.map((gti, index) => ( + + {index + 1} + {gti.start.toFixed(6)} + {gti.stop.toFixed(6)} + {gti.events.toLocaleString()} + {gti.duration.toFixed(6)} + {gti.rate.toFixed(6)} + + ))} + +
+
+ + )} + + + + + Time Preview (first {fullPreviewData.times_preview.length} entries) + + + + + + # + Time (s) + + + + {fullPreviewData.times_preview.map((time, index) => ( + + {index + 1} + {time.toFixed(6)} + + ))} + +
+
+
+ )} + + {/* Energy & PI Tab */} + {previewTabValue === 2 && ( + + {/* Energy Section */} + Energy Data + {fullPreviewData.has_energy ? ( + <> + + + Energy Range + + {fullPreviewData.energy_range + ? `${fullPreviewData.energy_range[0].toFixed(5)} - ${fullPreviewData.energy_range[1].toFixed(5)} keV` + : 'N/A'} + + + + {fullPreviewData.energy_preview && ( + <> + + Energy Preview (first {fullPreviewData.energy_preview.length} entries) + + + {fullPreviewData.energy_preview.map((energy, index) => ( + + ))} + + + )} + + ) : ( + No energy data available in this EventList + )} + + + + {/* PI Section */} + PI (Pulse Invariant) Data + {fullPreviewData.has_pi ? ( + <> + + + PI Range + + {fullPreviewData.pi_range + ? `${fullPreviewData.pi_range[0]} - ${fullPreviewData.pi_range[1]}` + : 'N/A'} + + + + {fullPreviewData.pi_preview && ( + <> + + PI Preview (first {fullPreviewData.pi_preview.length} entries) + + + {fullPreviewData.pi_preview.map((pi, index) => ( + + ))} + + + )} + + ) : ( + No PI data available in this EventList + )} + + )} + + {/* GTIs Tab */} + {previewTabValue === 3 && ( + + + Good Time Intervals ({fullPreviewData.gti_count} GTI{fullPreviewData.gti_count !== 1 ? 's' : ''}) + + {fullPreviewData.total_gti_time && ( + + Total GTI Time: {fullPreviewData.total_gti_time.toFixed(6)}s + + )} + {fullPreviewData.gti_list && fullPreviewData.gti_list.length > 0 ? ( + + + + + # + Start (s) + Stop (s) + Duration (s) + + + + {fullPreviewData.gti_list.map((gti, index) => ( + + {index + 1} + {gti[0].toFixed(6)} + {gti[1].toFixed(6)} + {(gti[1] - gti[0]).toFixed(6)} + + ))} + +
+
+ ) : ( + No GTI data available + )} +
+ )} + + {/* Metadata Tab */} + {previewTabValue === 4 && ( + + Mission Metadata + + + Mission + {fullPreviewData.mission || 'N/A'} + + + Instrument + {fullPreviewData.instrument || 'N/A'} + + + Detector ID + {fullPreviewData.detector_id || 'N/A'} + + + + + + Time System + + + Time Reference + {fullPreviewData.timeref || 'N/A'} + + + Time System + {fullPreviewData.timesys || 'N/A'} + + + Ephemeris + {fullPreviewData.ephem || 'N/A'} + + + + {fullPreviewData.additional_columns && fullPreviewData.additional_columns.length > 0 && ( + <> + + Additional Columns + + {fullPreviewData.additional_columns.map((col, index) => ( + + ))} + + + )} + + {/* User Notes */} + {fullPreviewData.notes && ( + <> + + User Notes + + + {fullPreviewData.notes} + + + + )} + + )} + + {/* Header Tab */} + {previewTabValue === 5 && ( + + {fullPreviewData.header_info && Object.keys(fullPreviewData.header_info).length > 0 ? ( + <> + Key FITS Headers + + {fullPreviewData.header_info.object && ( + + Object + {fullPreviewData.header_info.object} + + )} + {fullPreviewData.header_info.obs_id && ( + + OBS_ID + {fullPreviewData.header_info.obs_id} + + )} + {(fullPreviewData.header_info.ra_nom !== undefined || fullPreviewData.header_info.ra_obj !== undefined) && ( + + RA + + {(fullPreviewData.header_info.ra_nom ?? fullPreviewData.header_info.ra_obj)?.toFixed(7) || 'N/A'}° + + + )} + {(fullPreviewData.header_info.dec_nom !== undefined || fullPreviewData.header_info.dec_obj !== undefined) && ( + + Dec + + {(fullPreviewData.header_info.dec_nom ?? fullPreviewData.header_info.dec_obj)?.toFixed(7) || 'N/A'}° + + + )} + {fullPreviewData.header_info.exposure !== undefined && ( + + Exposure + + {fullPreviewData.header_info.exposure?.toFixed(6) || 'N/A'}s + + + )} + {fullPreviewData.header_info.ontime !== undefined && ( + + Ontime + + {fullPreviewData.header_info.ontime?.toFixed(6) || 'N/A'}s + + + )} + {fullPreviewData.header_info.livetime !== undefined && ( + + Livetime + + {fullPreviewData.header_info.livetime?.toFixed(6) || 'N/A'}s + + + )} + {fullPreviewData.header_info.date_obs && ( + + DATE-OBS + {fullPreviewData.header_info.date_obs} + + )} + {fullPreviewData.header_info.date_end && ( + + DATE-END + {fullPreviewData.header_info.date_end} + + )} + {fullPreviewData.header_info.telescop && ( + + Telescope + {fullPreviewData.header_info.telescop} + + )} + {fullPreviewData.header_info.instrume && ( + + Instrument + {fullPreviewData.header_info.instrume} + + )} + {fullPreviewData.header_info.creator && ( + + Creator + {fullPreviewData.header_info.creator} + + )} + {fullPreviewData.header_info.observer && ( + + Observer + {fullPreviewData.header_info.observer} + + )} + {fullPreviewData.header_info.datamode && ( + + Data Mode + {fullPreviewData.header_info.datamode} + + )} + + + {/* Raw Header Table */} + {fullPreviewData.header_info.raw_header && Object.keys(fullPreviewData.header_info.raw_header).length > 0 && ( + <> + + + Raw FITS Header ({Object.keys(fullPreviewData.header_info.raw_header).length} entries) + + + + + + Keyword + Value + + + + {Object.entries(fullPreviewData.header_info.raw_header).map(([key, value]) => ( + + {key} + {value} + + ))} + +
+
+ + )} + + ) : ( + No FITS header information available for this EventList + )} +
+ )} + + {/* Validation Tab */} + {previewTabValue === 6 && ( + + Data Quality Validation + {fullPreviewData.validation_issues && fullPreviewData.validation_issues.length > 0 ? ( + <> + {/* Summary */} + + {(() => { + const passed = fullPreviewData.validation_issues.filter((v: ValidationIssue) => v.status === 'pass'); + const failed = fullPreviewData.validation_issues.filter((v: ValidationIssue) => v.status === 'fail'); + const skipped = fullPreviewData.validation_issues.filter((v: ValidationIssue) => v.status === 'skip'); + const errors = fullPreviewData.validation_issues.filter((v: ValidationIssue) => v.severity === 'error'); + const warnings = fullPreviewData.validation_issues.filter((v: ValidationIssue) => v.severity === 'warning'); + return ( + <> + } + /> + {failed.length > 0 && ( + } + /> + )} + {skipped.length > 0 && ( + + )} + {errors.length > 0 && ( + + )} + {warnings.length > 0 && ( + + )} + + ); + })()} + + + {/* All Checks List */} + + + + + Status + Check + Result + Details + + + + {fullPreviewData.validation_issues.map((issue: ValidationIssue, index: number) => ( + + + {issue.status === 'pass' && ( + + )} + {issue.status === 'fail' && ( + + )} + {issue.status === 'skip' && ( + + )} + + + + {issue.name || issue.type} + + {issue.description && ( + + {issue.description} + + )} + + + {issue.message} + + + {issue.status !== 'skip' && issue.total !== undefined && issue.total > 0 && ( + + {issue.count?.toLocaleString() || 0} / {issue.total?.toLocaleString()} + + )} + + + ))} + +
+
+ + ) : ( + + + No validation data available. Try reloading the event list. + + + )} +
+ )} +
+ ) : ( + No preview data available + )} +
+ + + +
+ + {/* Save Format Selection Dialog */} + setSaveFormatDialogOpen(false)} + maxWidth="xs" + fullWidth + > + + Choose Save Format + + + + Select a runtime-supported, reopen-verified format for "{saveEventListName}": + + {hdf5UnavailableReason && ( + + HDF5 unavailable: {hdf5UnavailableReason} + + )} + + setSelectedSaveFormat(e.target.value as LegacySaveFormat)} + > + {availableSaveFormats.includes('hdf5') && ( + } + label={ + + + HDF5 (Recommended) + + + Versioned Stingray Explorer schema with scientific round-trip verification. + + + } + /> + )} + {availableSaveFormats.includes('ecsv') && ( + } + label={ + + + ASCII ECSV + + + Human-readable table with metadata and reopen verification. + + + } + sx={{ mt: 1 }} + /> + )} + + + + + + + + + + {/* CSS for refresh animation */} + + + ); +}; + +export default DataIngestionPage; diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx new file mode 100644 index 0000000..dfa0eb0 --- /dev/null +++ b/src/pages/Home/index.tsx @@ -0,0 +1,301 @@ +import React from 'react'; +import { + Box, + Typography, + Card, + CardContent, + CardActionArea, + Grid, + Chip, + useTheme, +} from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import AnalyticsIcon from '@mui/icons-material/Analytics'; +import BuildIcon from '@mui/icons-material/Build'; +import ModelTrainingIcon from '@mui/icons-material/ModelTraining'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import ScienceIcon from '@mui/icons-material/Science'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; + +interface QuickAccessCard { + title: string; + description: string; + icon: React.ReactNode; + path: string; + color: string; + glowColor: string; +} + +/** + * Home page with quick access cards and welcome message + */ +const HomePage: React.FC = () => { + const navigate = useNavigate(); + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + + const quickAccessCards: QuickAccessCard[] = [ + { + title: 'Load Data', + description: 'Import FITS, HDF5, or text files for analysis', + icon: , + path: '/data-ingestion', + color: '#3b82f6', + glowColor: 'rgba(59, 130, 246, 0.15)', + }, + { + title: 'QuickLook Analysis', + description: 'Power spectra, cross spectra, light curves, and more', + icon: , + path: '/quicklook/power-spectrum', + color: '#00d4aa', + glowColor: 'rgba(0, 212, 170, 0.15)', + }, + { + title: 'Pulsar Analysis', + description: 'Period search, phase folding, and phaseograms', + icon: , + path: '/pulsar/search', + color: '#a855f7', + glowColor: 'rgba(168, 85, 247, 0.15)', + }, + { + title: 'Modeling', + description: 'Model fitting with MLE and MCMC methods', + icon: , + path: '/modeling/builder', + color: '#f59e0b', + glowColor: 'rgba(245, 158, 11, 0.15)', + }, + { + title: 'Simulator', + description: 'Generate synthetic light curves and event lists', + icon: , + path: '/simulator', + color: '#ef4444', + glowColor: 'rgba(239, 68, 68, 0.15)', + }, + { + title: 'Utilities', + description: 'GTI handling, statistics, and I/O tools', + icon: , + path: '/utilities/gti', + color: '#64748b', + glowColor: 'rgba(100, 116, 139, 0.15)', + }, + ]; + + return ( + + {/* Welcome hero section */} + + + + Welcome to Stingray Explorer + + + Next-Generation Spectral Timing Made Easy + + + A comprehensive data analysis and visualization dashboard for X-ray astronomy + time series data. Built on top of the Stingray library, it provides an intuitive + graphical interface for analyzing event lists, generating light curves, computing + various types of spectra, and performing advanced timing analysis. + + + {['Stingray 2.0+', 'X-ray Astronomy', 'Time Series Analysis'].map((label) => ( + + ))} + + + + + {/* Quick Access heading */} + + + Quick Access + + + + + {/* Quick Access Cards */} + + {quickAccessCards.map((card) => ( + + + navigate(card.path)} + sx={{ height: '100%', p: 1 }} + > + + + + {card.icon} + + + {card.title} + + + + {card.description} + + + + + + ))} + + + ); +}; + +export default HomePage; diff --git a/src/pages/Modeling/MCMCFitting/index.tsx b/src/pages/Modeling/MCMCFitting/index.tsx new file mode 100644 index 0000000..badf0eb --- /dev/null +++ b/src/pages/Modeling/MCMCFitting/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import PageTemplate from '@/components/common/PageTemplate'; + +const MCMCFittingPage: React.FC = () => { + return ( + + ); +}; + +export default MCMCFittingPage; diff --git a/src/pages/Modeling/MLEFitting/index.tsx b/src/pages/Modeling/MLEFitting/index.tsx new file mode 100644 index 0000000..0e056d4 --- /dev/null +++ b/src/pages/Modeling/MLEFitting/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import PageTemplate from '@/components/common/PageTemplate'; + +const MLEFittingPage: React.FC = () => { + return ( + + ); +}; + +export default MLEFittingPage; diff --git a/src/pages/Modeling/ModelBuilder/index.tsx b/src/pages/Modeling/ModelBuilder/index.tsx new file mode 100644 index 0000000..cfd9b66 --- /dev/null +++ b/src/pages/Modeling/ModelBuilder/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import PageTemplate from '@/components/common/PageTemplate'; + +const ModelBuilderPage: React.FC = () => { + return ( + + ); +}; + +export default ModelBuilderPage; diff --git a/src/pages/NotFound/index.tsx b/src/pages/NotFound/index.tsx new file mode 100644 index 0000000..9a02f2a --- /dev/null +++ b/src/pages/NotFound/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Box, Typography, Button } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import HomeIcon from '@mui/icons-material/Home'; + +const NotFoundPage: React.FC = () => { + const navigate = useNavigate(); + + return ( + + + theme.palette.mode === 'dark' + ? '0 0 40px rgba(0, 212, 170, 0.2)' + : 'none', + }} + > + 404 + + + Page not found + + + + ); +}; + +export default NotFoundPage; diff --git a/src/pages/Pulsar/PeriodSearch/index.tsx b/src/pages/Pulsar/PeriodSearch/index.tsx new file mode 100644 index 0000000..19786b1 --- /dev/null +++ b/src/pages/Pulsar/PeriodSearch/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import PageTemplate from '@/components/common/PageTemplate'; + +const PeriodSearchPage: React.FC = () => { + return ( + + ); +}; + +export default PeriodSearchPage; diff --git a/src/pages/Pulsar/PhaseFolding/index.tsx b/src/pages/Pulsar/PhaseFolding/index.tsx new file mode 100644 index 0000000..46cb219 --- /dev/null +++ b/src/pages/Pulsar/PhaseFolding/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import PageTemplate from '@/components/common/PageTemplate'; + +const PhaseFoldingPage: React.FC = () => { + return ( + + ); +}; + +export default PhaseFoldingPage; diff --git a/src/pages/Pulsar/Phaseogram/index.tsx b/src/pages/Pulsar/Phaseogram/index.tsx new file mode 100644 index 0000000..4b64a50 --- /dev/null +++ b/src/pages/Pulsar/Phaseogram/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import PageTemplate from '@/components/common/PageTemplate'; + +const PhaseogramPage: React.FC = () => { + return ( + + ); +}; + +export default PhaseogramPage; diff --git a/src/pages/QuickLook/AutoCorrelation/index.test.tsx b/src/pages/QuickLook/AutoCorrelation/index.test.tsx new file mode 100644 index 0000000..d5d6719 --- /dev/null +++ b/src/pages/QuickLook/AutoCorrelation/index.test.tsx @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const autoCorrelation = vi.fn(); +vi.mock('@/api/correlationApi', () => ({ + correlationApi: { + autoCorrelation: (...a: unknown[]) => autoCorrelation(...a), + }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import AutoCorrelationPage from './index'; + +describe('AutoCorrelationPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + autoCorrelation.mockResolvedValue({ + success: true, + data: { + time_lags: [-0.1, 0, 0.1], + corr: [2.0, 10.0, 2.0], + time_shift: 0, + dt: 0.1, + n: 3, + mode: 'same', + norm: 'none', + warnings: [], + }, + message: 'done', + error: null, + }); + }); + + it('computes an auto-correlation with parsed parameters and plots it', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + const dtField = screen.getByLabelText(/Time bin/); + await userEvent.clear(dtField); + await userEvent.type(dtField, '0.05'); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + await waitFor(() => + expect(autoCorrelation).toHaveBeenCalledWith( + expect.objectContaining({ event_list_name: 'obs1', dt: 0.05, mode: 'same', norm: 'none' }) + ) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + }); + + it('disables Compute until inputs are valid', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Compute/ }); + expect(button).toBeDisabled(); + }); + + it('renders warnings from the result when present', async () => { + autoCorrelation.mockResolvedValue({ + success: true, + data: { + time_lags: [-0.1, 0, 0.1], + corr: [null, 10.0, null], + time_shift: 0, + dt: 0.1, + n: 3, + mode: 'same', + norm: 'variance', + warnings: ['The correlation contains NaN values.'], + }, + message: 'done', + error: null, + }); + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + const dtField = screen.getByLabelText(/Time bin/); + await userEvent.clear(dtField); + await userEvent.type(dtField, '0.05'); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + expect(await screen.findByText(/The correlation contains NaN values\./)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/QuickLook/AutoCorrelation/index.tsx b/src/pages/QuickLook/AutoCorrelation/index.tsx new file mode 100644 index 0000000..a6c4a69 --- /dev/null +++ b/src/pages/QuickLook/AutoCorrelation/index.tsx @@ -0,0 +1,197 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { correlationApi, CorrelationData } from '@/api/correlationApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const AutoCorrelationPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.1'); + const [mode, setMode] = useState<'same' | 'full'>('same'); + const [norm, setNorm] = useState<'none' | 'variance'>('none'); + + const { result, running, error, run } = useAnalysisRunner('Auto Correlation'); + + const dtNum = parsePositiveNumber(dt); + const canRun = eventList !== '' && dtNum !== null && !running; + + const handleRun = (): void => { + if (!dtNum) return; + void run(() => + correlationApi.autoCorrelation({ + event_list_name: eventList, + dt: dtNum, + mode, + norm, + }) + ); + }; + + const traces: Data[] = result + ? [ + { + x: result.time_lags, + y: result.corr, + type: 'scattergl', + mode: 'lines', + line: { width: 1 }, + } as Data, + ] + : []; + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + + Mode + + + + Normalization + + + + {norm === 'variance' + ? "Variance normalization scales the correlation to roughly [-1, 1]." + : "Counts² scale (unnormalized correlation)."} + {' '}The time shift is always 0 for an auto-correlation by construction — it + is not shown as a measurement here. + + + + + + + + + + + + + Result + + {result && ( + <> + + + + + )} + + {error && ( + + {error} + + )} + {result && result.warnings.length > 0 && ( + + + {result.warnings.map((w, i) => ( + + {w} + + ))} + + + )} + {result ? ( + + ) : ( + + + Choose an event list and compute its auto-correlation. + + + )} + + + + + + ); +}; + +export default AutoCorrelationPage; diff --git a/src/pages/QuickLook/AvgCovarianceSpectrum/index.test.tsx b/src/pages/QuickLook/AvgCovarianceSpectrum/index.test.tsx new file mode 100644 index 0000000..e2d7a61 --- /dev/null +++ b/src/pages/QuickLook/AvgCovarianceSpectrum/index.test.tsx @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const avgCovarianceSpectrum = vi.fn(); +vi.mock('@/api/varenergyApi', () => ({ + varenergyApi: { + avgCovarianceSpectrum: (...a: unknown[]) => avgCovarianceSpectrum(...a), + }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import AvgCovarianceSpectrumPage from './index'; + +describe('AvgCovarianceSpectrumPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + avgCovarianceSpectrum.mockResolvedValue({ + success: true, + data: { + energy: [0.5, 1.5, 3, 5.5, 9], + spectrum: [140.2, 152.8, 159.1, 148.4, 141.9], + spectrum_error: [4.1, 3.9, 4.2, 4.0, 4.3], + freq_range: [0.1, 1], + ref_band: null, + norm: 'abs', + segment_size: 8, + n_segments_hint: 6, + warnings: [], + }, + message: 'done', + error: null, + }); + }); + + it('computes an averaged covariance spectrum with parsed default parameters and plots it', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + await waitFor(() => + expect(avgCovarianceSpectrum).toHaveBeenCalledWith( + expect.objectContaining({ + event_list_name: 'obs1', + bin_time: 0.01, + segment_size: 8, + freq_min: 0.1, + freq_max: 1, + energy_min: 0.5, + energy_max: 10, + n_bands: 5, + log_bands: false, + ref_min: null, + ref_max: null, + norm: 'abs', + }) + ) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + expect(screen.getByText(/segment 8 s/)).toBeInTheDocument(); + expect(screen.getByText(/≈ 6 segments/)).toBeInTheDocument(); + }); + + it('sends the reference band when both fields are filled in', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.type(screen.getByLabelText('Ref band min (keV)'), '2'); + await userEvent.type(screen.getByLabelText('Ref band max (keV)'), '6'); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + await waitFor(() => + expect(avgCovarianceSpectrum).toHaveBeenCalledWith( + expect.objectContaining({ ref_min: 2, ref_max: 6 }) + ) + ); + }); + + it('disables Compute until inputs are valid', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Compute/ }); + expect(button).toBeDisabled(); + }); + + it('disables Compute when the reference band is only half-filled', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.type(screen.getByLabelText('Ref band min (keV)'), '2'); + expect(screen.getByRole('button', { name: /Compute/ })).toBeDisabled(); + }); + + it('renders warnings from the result when present', async () => { + avgCovarianceSpectrum.mockResolvedValue({ + success: true, + data: { + energy: [0.5, 1.5, 3, 5.5, 9], + spectrum: [null, null, null, null, null], + spectrum_error: [null, null, null, null, null], + freq_range: [0.1, 1], + ref_band: null, + norm: 'abs', + segment_size: 8, + n_segments_hint: 6, + warnings: [ + 'the covariance spectrum could not be computed for any energy band (stingray returns NaN when the reference band shows no variability above the Poisson noise floor).', + ], + }, + message: 'done', + error: null, + }); + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + expect( + await screen.findByText(/the covariance spectrum could not be computed for any energy band/) + ).toBeInTheDocument(); + }); + + it('renders an advisory (not a blank chart) when the result is all-null with warnings', async () => { + avgCovarianceSpectrum.mockResolvedValue({ + success: true, + data: { + energy: [0.5, 1.5, 3, 5.5, 9], + spectrum: [null, null, null, null, null], + spectrum_error: [null, null, null, null, null], + freq_range: [0.1, 1], + ref_band: null, + norm: 'abs', + segment_size: 64, + n_segments_hint: 1, + warnings: [ + 'the covariance spectrum could not be computed for any energy band (stingray returns NaN when the reference band shows no variability above the Poisson noise floor).', + ], + }, + message: 'done', + error: null, + }); + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + expect( + await screen.findByText(/the covariance spectrum could not be computed for any energy band/) + ).toBeInTheDocument(); + expect(await screen.findByText(/No finite covariance values/)).toBeInTheDocument(); + // The blank/empty chart must not render alongside (or instead of) the advisory. + expect(screen.queryByTestId('chart')).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/QuickLook/AvgCovarianceSpectrum/index.tsx b/src/pages/QuickLook/AvgCovarianceSpectrum/index.tsx new file mode 100644 index 0000000..f84401e --- /dev/null +++ b/src/pages/QuickLook/AvgCovarianceSpectrum/index.tsx @@ -0,0 +1,405 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { varenergyApi, CovarianceSpectrumData } from '@/api/varenergyApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const AvgCovarianceSpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [binTime, setBinTime] = useState('0.01'); + const [segmentSize, setSegmentSize] = useState('8'); + const [freqMin, setFreqMin] = useState('0.1'); + const [freqMax, setFreqMax] = useState('1'); + const [energyMin, setEnergyMin] = useState('0.5'); + const [energyMax, setEnergyMax] = useState('10'); + const [nBands, setNBands] = useState('5'); + const [logBands, setLogBands] = useState(false); + const [refMin, setRefMin] = useState(''); + const [refMax, setRefMax] = useState(''); + const [norm, setNorm] = useState<'abs' | 'frac'>('abs'); + const [logEnergy, setLogEnergy] = useState(false); + + const { result, running, error, run } = useAnalysisRunner( + 'Averaged Covariance Spectrum' + ); + + const binTimeNum = parsePositiveNumber(binTime); + const segNum = parsePositiveNumber(segmentSize); + + const freqMinNum = parsePositiveNumber(freqMin); + const freqMaxNum = parsePositiveNumber(freqMax); + const freqMinInvalid = freqMin !== '' && freqMinNum === null; + const freqMaxInvalid = freqMax !== '' && freqMaxNum === null; + const freqInverted = freqMinNum !== null && freqMaxNum !== null && freqMaxNum <= freqMinNum; + const freqValid = !freqMinInvalid && !freqMaxInvalid && freqMinNum !== null && freqMaxNum !== null && !freqInverted; + + const energyMinNum = parsePositiveNumber(energyMin); + const energyMaxNum = parsePositiveNumber(energyMax); + const energyMinInvalid = energyMin !== '' && energyMinNum === null; + const energyMaxInvalid = energyMax !== '' && energyMaxNum === null; + const energyInverted = + energyMinNum !== null && energyMaxNum !== null && energyMaxNum <= energyMinNum; + const energyValid = + !energyMinInvalid && !energyMaxInvalid && energyMinNum !== null && energyMaxNum !== null && !energyInverted; + + const nBandsNum = parsePositiveNumber(nBands); + const nBandsValid = nBandsNum !== null && Number.isInteger(nBandsNum) && nBandsNum >= 2; + + const refMinNum = refMin !== '' ? parsePositiveNumber(refMin) : null; + const refMaxNum = refMax !== '' ? parsePositiveNumber(refMax) : null; + const refMinInvalid = refMin !== '' && refMinNum === null; + const refMaxInvalid = refMax !== '' && refMaxNum === null; + const refPartial = (refMin !== '') !== (refMax !== ''); + const refInverted = refMinNum !== null && refMaxNum !== null && refMaxNum <= refMinNum; + const refValid = !refMinInvalid && !refMaxInvalid && !refPartial && !refInverted; + + const canRun = + eventList !== '' && + binTimeNum !== null && + segNum !== null && + freqValid && + energyValid && + nBandsValid && + refValid && + !running; + + const freqHelperText = (raw: string, parsed: number | null): string => { + if (raw !== '' && parsed === null) return 'Must be a positive number'; + if (freqInverted) return 'f max must be > f min'; + return ' '; + }; + + const energyHelperText = (raw: string, parsed: number | null): string => { + if (raw !== '' && parsed === null) return 'Must be a positive number'; + if (energyInverted) return 'energy max must be > energy min'; + return ' '; + }; + + const refHelperText = (raw: string, parsed: number | null): string => { + if (raw !== '' && parsed === null) return 'Must be a positive number'; + if (refPartial) return 'Fill both or leave both blank'; + if (refInverted) return 'ref max must be > ref min'; + return ' '; + }; + + const handleRun = (): void => { + if ( + binTimeNum === null || + segNum === null || + freqMinNum === null || + freqMaxNum === null || + energyMinNum === null || + energyMaxNum === null || + nBandsNum === null + ) { + return; + } + void run(() => + varenergyApi.avgCovarianceSpectrum({ + event_list_name: eventList, + bin_time: binTimeNum, + segment_size: segNum, + freq_min: freqMinNum, + freq_max: freqMaxNum, + energy_min: energyMinNum, + energy_max: energyMaxNum, + n_bands: nBandsNum, + log_bands: logBands, + ref_min: refMinNum, + ref_max: refMaxNum, + norm, + }) + ); + }; + + const finiteSpectrum = result ? result.spectrum.filter((v): v is number => v !== null) : []; + const allNull = result !== null && result.spectrum.length > 0 && finiteSpectrum.length === 0; + + const traces: Data[] = result + ? [ + { + x: result.energy, + y: result.spectrum, + error_y: { type: 'data', array: result.spectrum_error, visible: true }, + type: 'scattergl', + mode: 'markers', + marker: { size: 7 }, + } as Data, + ] + : []; + + return ( + + + + + + + Parameters + + setBinTime(e.target.value)} + error={binTime !== '' && binTimeNum === null} + helperText={binTime !== '' && binTimeNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={ + segmentSize !== '' && segNum === null + ? 'Must be a positive number' + : 'Averaged over whole segments fitting the GTIs' + } + /> + + setFreqMin(e.target.value)} + error={freqMinInvalid || freqInverted} + helperText={freqHelperText(freqMin, freqMinNum)} + /> + setFreqMax(e.target.value)} + error={freqMaxInvalid || freqInverted} + helperText={freqHelperText(freqMax, freqMaxNum)} + /> + + + setEnergyMin(e.target.value)} + error={energyMinInvalid || energyInverted} + helperText={energyHelperText(energyMin, energyMinNum)} + /> + setEnergyMax(e.target.value)} + error={energyMaxInvalid || energyInverted} + helperText={energyHelperText(energyMax, energyMaxNum)} + /> + + setNBands(e.target.value)} + error={nBands !== '' && !nBandsValid} + helperText={nBands !== '' && !nBandsValid ? 'Must be an integer >= 2' : ' '} + /> + setLogBands(e.target.checked)} + /> + } + label="Log-spaced energy bands" + /> + + setRefMin(e.target.value)} + error={refMinInvalid || refPartial || refInverted} + helperText={refHelperText(refMin, refMinNum)} + /> + setRefMax(e.target.value)} + error={refMaxInvalid || refPartial || refInverted} + helperText={refHelperText(refMax, refMaxNum)} + /> + + + Leave the reference band blank to use the full energy range. Covariance measures + variability correlated with this band. + + + Normalization + + + + {norm === 'frac' + ? 'Fractional normalization is unitless (divided by the mean count rate).' + : 'Absolute normalization retains the source count-rate scale.'} + + + + + + + + + + + + + Result + + {result && ( + <> + + + + + {result.ref_band && ( + + )} + + )} + setLogEnergy(e.target.checked)} + /> + } + label="log energy" + /> + + {error && ( + + {error} + + )} + {result && result.warnings.length > 0 && ( + + + {result.warnings.map((w, i) => ( + + {w} + + ))} + + + )} + {result ? ( + allNull ? ( + + + No finite covariance values in any energy band. This is expected for + Poisson-dominated or weakly variable sources — it is not an error. See the + warnings above and try a coarser bin, fewer bands, or a source with stronger + correlated variability. + + + ) : ( + + ) + ) : ( + + + Choose an event list and compute its averaged covariance spectrum. + + + )} + + + + + + ); +}; + +export default AvgCovarianceSpectrumPage; diff --git a/src/pages/QuickLook/AvgCrossSpectrum/index.tsx b/src/pages/QuickLook/AvgCrossSpectrum/index.tsx new file mode 100644 index 0000000..cad715a --- /dev/null +++ b/src/pages/QuickLook/AvgCrossSpectrum/index.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { spectrumApi, PowerSpectrumData } from '@/api/spectrumApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const NORM_OPTIONS = ['leahy', 'frac', 'abs', 'none']; + +const AvgCrossSpectrumPage: React.FC = () => { + const [eventList1, setEventList1] = useState(''); + const [eventList2, setEventList2] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [segmentSize, setSegmentSize] = useState('16'); + const [norm, setNorm] = useState('leahy'); + const [outputName, setOutputName] = useState(''); + const [logX, setLogX] = useState(true); + const [logY, setLogY] = useState(true); + const [rebinFactor, setRebinFactor] = useState('0.02'); + const [logRebin, setLogRebin] = useState(true); + const [lastStoredName, setLastStoredName] = useState(null); + const lastActionRef = useRef<'create' | 'rebin'>('create'); + const { result, running, error, run } = useAnalysisRunner('Averaged Cross Spectrum'); + + useEffect(() => { + if (result?.name) { + setLastStoredName(result.name); + } else if (result && lastActionRef.current === 'create') { + setLastStoredName(null); + } + }, [result]); + + const dtNum = parsePositiveNumber(dt); + const segNum = parsePositiveNumber(segmentSize); + const rebinNum = parsePositiveNumber(rebinFactor); + const canRun = eventList1 !== '' && eventList2 !== '' && dtNum !== null && segNum !== null && !running; + const rebinValid = logRebin ? rebinNum !== null : rebinNum !== null && rebinNum > 1; + + const handleRun = (): void => { + lastActionRef.current = 'create'; + if (!dtNum || !segNum) return; + void run(() => + spectrumApi.createAveragedCrossSpectrum({ + event_list_1_name: eventList1, + event_list_2_name: eventList2, + dt: dtNum, + segment_size: segNum, + norm, + output_name: outputName.trim() || undefined, + }) + ); + }; + + const handleRebin = (): void => { + lastActionRef.current = 'rebin'; + if (!lastStoredName || !rebinNum) return; + void run(() => + spectrumApi.rebinSpectrum({ name: lastStoredName, rebin_factor: rebinNum, log: logRebin }) + ); + }; + + const magnitudeTrace: Data[] = result + ? [ + { + x: result.freq, + y: result.power, + type: 'scattergl', + mode: 'lines', + line: { color: '#00d4aa', width: 1 }, + }, + ] + : []; + + const phaseTrace: Data[] = + result && result.power_phase + ? [ + { + x: result.freq, + y: result.power_phase, + type: 'scattergl', + mode: 'markers', + marker: { color: '#3b82f6', size: 3 }, + }, + ] + : []; + + return ( + + + + + + + Parameters + + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + Normalization + + + setOutputName(e.target.value)} + helperText="Required to enable rebinning" + /> + + + + {lastStoredName && ( + <> + + + Rebin '{lastStoredName}' + setRebinFactor(e.target.value)} + error={rebinFactor !== '' && !rebinValid} + helperText={logRebin ? 'Each bin grows by (1 + f)' : 'Must be > 1'} + /> + { + const checked = e.target.checked; + setLogRebin(checked); + if (!checked && rebinNum !== null && rebinNum <= 1) { + setRebinFactor('2'); + } + }} + /> + } + label="Logarithmic" + /> + + + + )} + + + + + + + + + + Result + + {result?.norm && } + {result && } + {result?.segment_size !== undefined && ( + + )} + {result?.n_segments != null && ( + + )} + setLogX(e.target.checked)} />} + label="log f" + /> + setLogY(e.target.checked)} />} + label="log |C|" + /> + + {error && ( + + {error} + + )} + {result ? ( + + + + Cross-power magnitude + + + + {phaseTrace.length > 0 && ( + + + Cross-spectrum phase + + + + )} + + ) : ( + + + Choose two event lists and compute their averaged cross spectrum. + + + )} + + + + + + ); +}; + +export default AvgCrossSpectrumPage; diff --git a/src/pages/QuickLook/AvgPowerSpectrum/index.tsx b/src/pages/QuickLook/AvgPowerSpectrum/index.tsx new file mode 100644 index 0000000..0aeece5 --- /dev/null +++ b/src/pages/QuickLook/AvgPowerSpectrum/index.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { spectrumApi, PowerSpectrumData } from '@/api/spectrumApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const NORM_OPTIONS = ['leahy', 'frac', 'abs', 'none']; + +const AvgPowerSpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [segmentSize, setSegmentSize] = useState('16'); + const [norm, setNorm] = useState('leahy'); + const [outputName, setOutputName] = useState(''); + const [logX, setLogX] = useState(true); + const [logY, setLogY] = useState(true); + const [rebinFactor, setRebinFactor] = useState('0.02'); + const [logRebin, setLogRebin] = useState(true); + const [lastStoredName, setLastStoredName] = useState(null); + const lastActionRef = useRef<'create' | 'rebin'>('create'); + const { result, running, error, run } = useAnalysisRunner('Averaged Power Spectrum'); + + useEffect(() => { + if (result?.name) { + setLastStoredName(result.name); + } else if (result && lastActionRef.current === 'create') { + setLastStoredName(null); + } + }, [result]); + + const dtNum = parsePositiveNumber(dt); + const segNum = parsePositiveNumber(segmentSize); + const rebinNum = parsePositiveNumber(rebinFactor); + const canRun = eventList !== '' && dtNum !== null && segNum !== null && !running; + const rebinValid = logRebin ? rebinNum !== null : rebinNum !== null && rebinNum > 1; + + const handleRun = (): void => { + lastActionRef.current = 'create'; + if (!dtNum || !segNum) return; + void run(() => + spectrumApi.createAveragedPowerSpectrum({ + event_list_name: eventList, + dt: dtNum, + segment_size: segNum, + norm, + output_name: outputName.trim() || undefined, + }) + ); + }; + + const handleRebin = (): void => { + lastActionRef.current = 'rebin'; + if (!lastStoredName || !rebinNum) return; + void run(() => + spectrumApi.rebinSpectrum({ name: lastStoredName, rebin_factor: rebinNum, log: logRebin }) + ); + }; + + const plotData: Data[] = result + ? [ + { + x: result.freq, + y: result.power, + type: 'scattergl', + mode: 'lines', + line: { color: '#00d4aa', width: 1 }, + }, + ] + : []; + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + Normalization + + + setOutputName(e.target.value)} + placeholder={eventList ? `${eventList}_aps` : ''} + helperText="Required to enable rebinning" + /> + + + + {lastStoredName && ( + <> + + + Rebin '{lastStoredName}' + setRebinFactor(e.target.value)} + error={rebinFactor !== '' && !rebinValid} + helperText={logRebin ? 'Each bin grows by (1 + f)' : 'Must be > 1'} + /> + { + const checked = e.target.checked; + setLogRebin(checked); + if (!checked && rebinNum !== null && rebinNum <= 1) { + setRebinFactor('2'); + } + }} + /> + } + label="Logarithmic" + /> + + + + )} + + + + + + + + + + Result + + {result?.norm && } + {result && } + {result?.n_segments != null && ( + + )} + {result?.df !== undefined && ( + + )} + setLogX(e.target.checked)} />} + label="log f" + /> + setLogY(e.target.checked)} />} + label="log P" + /> + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Choose an event list and compute a power spectrum. + + + )} + + + + + + ); +}; + +export default AvgPowerSpectrumPage; diff --git a/src/pages/QuickLook/Bispectrum/index.tsx b/src/pages/QuickLook/Bispectrum/index.tsx new file mode 100644 index 0000000..77520b6 --- /dev/null +++ b/src/pages/QuickLook/Bispectrum/index.tsx @@ -0,0 +1,226 @@ +import React, { useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + Tab, + Tabs, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { timingApi, BispectrumData } from '@/api/timingApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const SCALE_OPTIONS = ['biased', 'unbiased']; +const WINDOW_OPTIONS = ['uniform', 'parzen', 'hamming', 'hanning', 'triangular', 'welch', 'blackmann', 'flat-top']; +const MAXLAG_CAP = 500; + +const BispectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.1'); + const [maxlag, setMaxlag] = useState('25'); + const [scale, setScale] = useState('unbiased'); + const [windowFn, setWindowFn] = useState('uniform'); + const [outputName, setOutputName] = useState(''); + const [tab, setTab] = useState(0); + const [logZ, setLogZ] = useState(true); + const { result, running, error, run } = useAnalysisRunner('Bispectrum'); + + const dtNum = parsePositiveNumber(dt); + const maxlagNum = parsePositiveNumber(maxlag); + const maxlagInt = maxlagNum === null ? null : Math.round(maxlagNum); + const maxlagValid = maxlagInt !== null && maxlagInt >= 1 && maxlagInt <= MAXLAG_CAP; + const canRun = eventList !== '' && dtNum !== null && maxlagValid && !running; + + const handleRun = (): void => { + if (!dtNum || !maxlagInt || !maxlagValid) return; + void run(() => + timingApi.createBispectrum({ + event_list_name: eventList, + dt: dtNum, + maxlag: maxlagInt, + scale, + window: windowFn, + output_name: outputName.trim() || undefined, + }) + ); + }; + + const heatmapData = useMemo(() => { + if (!result) return []; + if (tab === 0) { + const z = logZ + ? result.bispec_mag.map((row) => row.map((v) => (v > 0 ? Math.log10(v) : null))) + : result.bispec_mag; + return [ + { + z, + x: result.freq, + y: result.freq, + type: 'heatmap', + colorscale: 'Viridis', + colorbar: { title: { text: logZ ? 'log10 |B|' : '|B|' } }, + } as Data, + ]; + } + return [ + { + z: result.bispec_phase, + x: result.freq, + y: result.freq, + type: 'heatmap', + colorscale: 'RdBu', + zmid: 0, + colorbar: { title: { text: 'Phase (rad)' } }, + } as Data, + ]; + }, [result, tab, logZ]); + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setMaxlag(e.target.value)} + error={maxlag !== '' && !maxlagValid} + helperText={ + maxlag !== '' && !maxlagValid + ? 'Integer between 1 and 500' + : 'Bispectrum size is (2·maxlag+1)²; keep ≤ 100' + } + /> + + Scale + + + + Window + + + setOutputName(e.target.value)} + /> + + + + + + + + + + + setTab(v)} sx={{ flexGrow: 1 }}> + + + + {result && } + {result && } + {tab === 0 && ( + setLogZ(e.target.checked)} />} + label="log color" + /> + )} + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Compute a bispectrum to see magnitude and phase maps. + + + )} + + + + + + ); +}; + +export default BispectrumPage; diff --git a/src/pages/QuickLook/Coherence/index.tsx b/src/pages/QuickLook/Coherence/index.tsx new file mode 100644 index 0000000..49ceb3c --- /dev/null +++ b/src/pages/QuickLook/Coherence/index.tsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControlLabel, + Grid, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { timingApi, CoherenceData } from '@/api/timingApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const CoherencePage: React.FC = () => { + const [eventList1, setEventList1] = useState(''); + const [eventList2, setEventList2] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [segmentSize, setSegmentSize] = useState('16'); + const [logX, setLogX] = useState(true); + const { result, running, error, run } = useAnalysisRunner('Coherence'); + + const dtNum = parsePositiveNumber(dt); + const segNum = parsePositiveNumber(segmentSize); + const canRun = eventList1 !== '' && eventList2 !== '' && dtNum !== null && segNum !== null && !running; + + const handleRun = (): void => { + if (!dtNum || !segNum) return; + void run(() => + timingApi.calculateCoherence({ + event_list_1_name: eventList1, + event_list_2_name: eventList2, + dt: dtNum, + segment_size: segNum, + }) + ); + }; + + const traces: Data[] = result + ? [ + { + x: result.freq, + y: result.coherence, + type: 'scattergl', + mode: 'lines+markers', + marker: { size: 4, color: '#00d4aa' }, + line: { color: '#00d4aa', width: 1 }, + error_y: result.coherence_err + ? { type: 'data', array: result.coherence_err, visible: true, color: 'rgba(0, 212, 170, 0.35)' } + : undefined, + } as Data, + ] + : []; + + return ( + + + + + + + Parameters + + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + + + + + + + + + + + Result + + {result?.n_segments != null && ( + + )} + setLogX(e.target.checked)} />} + label="log f" + /> + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Choose two event lists and compute their coherence. + + + )} + + + + + + ); +}; + +export default CoherencePage; diff --git a/src/pages/QuickLook/CovarianceSpectrum/index.test.tsx b/src/pages/QuickLook/CovarianceSpectrum/index.test.tsx new file mode 100644 index 0000000..b39d63e --- /dev/null +++ b/src/pages/QuickLook/CovarianceSpectrum/index.test.tsx @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const covarianceSpectrum = vi.fn(); +vi.mock('@/api/varenergyApi', () => ({ + varenergyApi: { + covarianceSpectrum: (...a: unknown[]) => covarianceSpectrum(...a), + }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import CovarianceSpectrumPage from './index'; + +describe('CovarianceSpectrumPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + covarianceSpectrum.mockResolvedValue({ + success: true, + data: { + energy: [0.5, 1.5, 3, 5.5, 9], + spectrum: [140.2, 152.8, 159.1, 148.4, 141.9], + spectrum_error: [4.1, 3.9, 4.2, 4.0, 4.3], + freq_range: [0.1, 1], + ref_band: null, + norm: 'abs', + segment_size: 64, + n_segments_hint: 1, + warnings: [], + }, + message: 'done', + error: null, + }); + }); + + it('computes a covariance spectrum with parsed default parameters and plots it', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + await waitFor(() => + expect(covarianceSpectrum).toHaveBeenCalledWith( + expect.objectContaining({ + event_list_name: 'obs1', + bin_time: 0.1, + freq_min: 0.1, + freq_max: 1, + energy_min: 0.5, + energy_max: 10, + n_bands: 5, + log_bands: false, + ref_min: null, + ref_max: null, + norm: 'abs', + }) + ) + ); + // No segment_size field on this endpoint's request. + expect(covarianceSpectrum).not.toHaveBeenCalledWith( + expect.objectContaining({ segment_size: expect.anything() }) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + expect(screen.getByText(/1 segment · 64 s \(full GTI\)/)).toBeInTheDocument(); + }); + + it('sends the reference band when both fields are filled in', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.type(screen.getByLabelText('Ref band min (keV)'), '2'); + await userEvent.type(screen.getByLabelText('Ref band max (keV)'), '6'); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + await waitFor(() => + expect(covarianceSpectrum).toHaveBeenCalledWith( + expect.objectContaining({ ref_min: 2, ref_max: 6 }) + ) + ); + }); + + it('disables Compute until inputs are valid', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Compute/ }); + expect(button).toBeDisabled(); + }); + + it('disables Compute when the reference band is only half-filled', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.type(screen.getByLabelText('Ref band min (keV)'), '2'); + expect(screen.getByRole('button', { name: /Compute/ })).toBeDisabled(); + }); + + it('renders an advisory (not an error) when the result is all-null with warnings', async () => { + covarianceSpectrum.mockResolvedValue({ + success: true, + data: { + energy: [0.5, 1.5, 3, 5.5, 9], + spectrum: [null, null, null, null, null], + spectrum_error: [null, null, null, null, null], + freq_range: [0.1, 1], + ref_band: null, + norm: 'abs', + segment_size: 64, + n_segments_hint: 1, + warnings: [ + 'the covariance spectrum could not be computed for any energy band (stingray returns NaN when the reference band shows no variability above the Poisson noise floor).', + ], + }, + message: 'done', + error: null, + }); + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + expect( + await screen.findByText(/the covariance spectrum could not be computed for any energy band/) + ).toBeInTheDocument(); + expect(await screen.findByText(/No finite covariance values/)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/QuickLook/CovarianceSpectrum/index.tsx b/src/pages/QuickLook/CovarianceSpectrum/index.tsx new file mode 100644 index 0000000..1877c1c --- /dev/null +++ b/src/pages/QuickLook/CovarianceSpectrum/index.tsx @@ -0,0 +1,387 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { varenergyApi, CovarianceSpectrumData } from '@/api/varenergyApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +type CovarianceNorm = 'abs' | 'frac'; + +const CovarianceSpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [binTime, setBinTime] = useState('0.1'); + const [freqMin, setFreqMin] = useState('0.1'); + const [freqMax, setFreqMax] = useState('1'); + const [energyMin, setEnergyMin] = useState('0.5'); + const [energyMax, setEnergyMax] = useState('10'); + const [nBands, setNBands] = useState('5'); + const [logBands, setLogBands] = useState(false); + const [refMin, setRefMin] = useState(''); + const [refMax, setRefMax] = useState(''); + const [norm, setNorm] = useState('abs'); + const [logEnergy, setLogEnergy] = useState(false); + + const { result, running, error, run } = useAnalysisRunner( + 'Covariance Spectrum' + ); + + const binTimeNum = parsePositiveNumber(binTime); + + const freqMinNum = parsePositiveNumber(freqMin); + const freqMaxNum = parsePositiveNumber(freqMax); + const freqMinInvalid = freqMin !== '' && freqMinNum === null; + const freqMaxInvalid = freqMax !== '' && freqMaxNum === null; + const freqInverted = freqMinNum !== null && freqMaxNum !== null && freqMaxNum <= freqMinNum; + const freqValid = + !freqMinInvalid && !freqMaxInvalid && freqMinNum !== null && freqMaxNum !== null && !freqInverted; + + const energyMinNum = parsePositiveNumber(energyMin); + const energyMaxNum = parsePositiveNumber(energyMax); + const energyMinInvalid = energyMin !== '' && energyMinNum === null; + const energyMaxInvalid = energyMax !== '' && energyMaxNum === null; + const energyInverted = + energyMinNum !== null && energyMaxNum !== null && energyMaxNum <= energyMinNum; + const energyValid = + !energyMinInvalid && + !energyMaxInvalid && + energyMinNum !== null && + energyMaxNum !== null && + !energyInverted; + + const nBandsNum = parsePositiveNumber(nBands); + const nBandsValid = nBandsNum !== null && Number.isInteger(nBandsNum) && nBandsNum >= 2; + + const refMinNum = refMin !== '' ? parsePositiveNumber(refMin) : null; + const refMaxNum = refMax !== '' ? parsePositiveNumber(refMax) : null; + const refMinInvalid = refMin !== '' && refMinNum === null; + const refMaxInvalid = refMax !== '' && refMaxNum === null; + const refPartial = (refMin !== '') !== (refMax !== ''); + const refInverted = refMinNum !== null && refMaxNum !== null && refMaxNum <= refMinNum; + const refValid = !refMinInvalid && !refMaxInvalid && !refPartial && !refInverted; + + const canRun = + eventList !== '' && + binTimeNum !== null && + freqValid && + energyValid && + nBandsValid && + refValid && + !running; + + const freqHelperText = (raw: string, parsed: number | null): string => { + if (raw !== '' && parsed === null) return 'Must be a positive number'; + if (freqInverted) return 'f max must be > f min'; + return ' '; + }; + + const energyHelperText = (raw: string, parsed: number | null): string => { + if (raw !== '' && parsed === null) return 'Must be a positive number'; + if (energyInverted) return 'energy max must be > energy min'; + return ' '; + }; + + const refHelperText = (raw: string, parsed: number | null): string => { + if (raw !== '' && parsed === null) return 'Must be a positive number'; + if (refPartial) return 'Fill both or leave both blank'; + if (refInverted) return 'ref max must be > ref min'; + return ' '; + }; + + const handleRun = (): void => { + if ( + binTimeNum === null || + freqMinNum === null || + freqMaxNum === null || + energyMinNum === null || + energyMaxNum === null || + nBandsNum === null + ) { + return; + } + void run(() => + varenergyApi.covarianceSpectrum({ + event_list_name: eventList, + bin_time: binTimeNum, + freq_min: freqMinNum, + freq_max: freqMaxNum, + energy_min: energyMinNum, + energy_max: energyMaxNum, + n_bands: nBandsNum, + log_bands: logBands, + ref_min: refMinNum, + ref_max: refMaxNum, + norm, + }) + ); + }; + + const finiteSpectrum = result ? result.spectrum.filter((v): v is number => v !== null) : []; + const allNull = result !== null && result.spectrum.length > 0 && finiteSpectrum.length === 0; + + const traces: Data[] = result + ? [ + { + x: result.energy, + y: result.spectrum, + error_y: { type: 'data', array: result.spectrum_error, visible: true }, + type: 'scattergl', + mode: 'markers', + } as Data, + ] + : []; + + const yAxisLabel = result?.norm === 'frac' ? 'Covariance (fractional)' : 'Covariance (absolute)'; + + return ( + + + + + + + Parameters + + setBinTime(e.target.value)} + error={binTime !== '' && binTimeNum === null} + helperText={binTime !== '' && binTimeNum === null ? 'Must be a positive number' : ' '} + /> + + setFreqMin(e.target.value)} + error={freqMinInvalid || freqInverted} + helperText={freqHelperText(freqMin, freqMinNum)} + /> + setFreqMax(e.target.value)} + error={freqMaxInvalid || freqInverted} + helperText={freqHelperText(freqMax, freqMaxNum)} + /> + + + setEnergyMin(e.target.value)} + error={energyMinInvalid || energyInverted} + helperText={energyHelperText(energyMin, energyMinNum)} + /> + setEnergyMax(e.target.value)} + error={energyMaxInvalid || energyInverted} + helperText={energyHelperText(energyMax, energyMaxNum)} + /> + + setNBands(e.target.value)} + error={nBands !== '' && !nBandsValid} + helperText={nBands !== '' && !nBandsValid ? 'Must be an integer >= 2' : ' '} + /> + setLogBands(e.target.checked)} + /> + } + label="Log-spaced energy bands" + /> + + setRefMin(e.target.value)} + error={refMinInvalid || refPartial || refInverted} + helperText={refHelperText(refMin, refMinNum)} + /> + setRefMax(e.target.value)} + error={refMaxInvalid || refPartial || refInverted} + helperText={refHelperText(refMax, refMaxNum)} + /> + + + Leave the reference band blank to use the full energy range. Covariance measures + variability correlated with this band. + + + Normalization + + + + {norm === 'frac' + ? 'Fractional normalization is unitless (divided by the mean count rate).' + : 'Absolute normalization retains the source count-rate scale.'} + + + No segment-size control: the spectrum is computed over one segment spanning the + longest good-time interval. Use Avg Covariance Spectrum to average over multiple + segments. + + + + + + + + + + + + + Result + + {result && ( + <> + + + + {result.ref_band && ( + + )} + + )} + setLogEnergy(e.target.checked)} + /> + } + label="log energy" + /> + + {error && ( + + {error} + + )} + {result && result.warnings.length > 0 && ( + + + {result.warnings.map((w, i) => ( + + {w} + + ))} + + + )} + {result ? ( + allNull ? ( + + + No finite covariance values in any energy band. This is expected for + Poisson-dominated or weakly variable sources — it is not an error. See the + warnings above and try a coarser bin, fewer bands, or a source with stronger + correlated variability. + + + ) : ( + + ) + ) : ( + + + Choose an event list and compute its covariance spectrum. + + + )} + + + + + + ); +}; + +export default CovarianceSpectrumPage; diff --git a/src/pages/QuickLook/CrossCorrelation/index.test.tsx b/src/pages/QuickLook/CrossCorrelation/index.test.tsx new file mode 100644 index 0000000..2602f26 --- /dev/null +++ b/src/pages/QuickLook/CrossCorrelation/index.test.tsx @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const crossCorrelation = vi.fn(); +vi.mock('@/api/correlationApi', () => ({ + correlationApi: { + crossCorrelation: (...a: unknown[]) => crossCorrelation(...a), + }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import CrossCorrelationPage from './index'; + +async function selectEventList(label: string, name: string): Promise { + await userEvent.click(await screen.findByLabelText(label)); + await userEvent.click(await screen.findAllByText(new RegExp(name)).then((els) => els[els.length - 1])); +} + +describe('CrossCorrelationPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockResolvedValue({ + success: true, + data: [ + { name: 'obs1', n_events: 5000, time_range: [0, 100] }, + { name: 'obs2', n_events: 4000, time_range: [0, 100] }, + ], + message: '', + error: null, + }); + crossCorrelation.mockResolvedValue({ + success: true, + data: { + time_lags: [-0.5, 0, 0.5], + corr: [2.0, 10.0, 2.0], + time_shift: -0.5, + dt: 0.1, + n: 3, + mode: 'same', + norm: 'none', + warnings: [], + }, + message: 'done', + error: null, + }); + }); + + it('computes a cross-correlation with parsed parameters, plots it, and shows the time-shift chip', async () => { + renderWithProviders(); + await selectEventList('Event list 1', 'obs1'); + await selectEventList('Event list 2', 'obs2'); + const dtField = screen.getByLabelText(/Time bin/); + await userEvent.clear(dtField); + await userEvent.type(dtField, '0.1'); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + await waitFor(() => + expect(crossCorrelation).toHaveBeenCalledWith( + expect.objectContaining({ + event_list_1_name: 'obs1', + event_list_2_name: 'obs2', + dt: 0.1, + mode: 'same', + norm: 'none', + }) + ) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + expect(await screen.findByText(/time shift: -0.5000 s/)).toBeInTheDocument(); + expect( + screen.getByText(/Positive shift means the first list lags the second\./) + ).toBeInTheDocument(); + }); + + it('disables Compute until inputs are valid', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Compute/ }); + expect(button).toBeDisabled(); + }); + + it('omits the time-shift chip and renders warnings when time_shift is null', async () => { + crossCorrelation.mockResolvedValue({ + success: true, + data: { + time_lags: [-0.1, 0, 0.1], + corr: [null, 10.0, null], + time_shift: null, + dt: 0.1, + n: 3, + mode: 'same', + norm: 'variance', + warnings: ['The correlation contains NaN values.'], + }, + message: 'done', + error: null, + }); + renderWithProviders(); + await selectEventList('Event list 1', 'obs1'); + await selectEventList('Event list 2', 'obs2'); + const dtField = screen.getByLabelText(/Time bin/); + await userEvent.clear(dtField); + await userEvent.type(dtField, '0.1'); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + expect(await screen.findByText(/The correlation contains NaN values\./)).toBeInTheDocument(); + expect(screen.queryByText(/time shift:/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/QuickLook/CrossCorrelation/index.tsx b/src/pages/QuickLook/CrossCorrelation/index.tsx new file mode 100644 index 0000000..a737582 --- /dev/null +++ b/src/pages/QuickLook/CrossCorrelation/index.tsx @@ -0,0 +1,211 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { correlationApi, CorrelationData } from '@/api/correlationApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const MODE_OPTIONS = ['same', 'full']; +const NORM_OPTIONS = ['none', 'variance']; + +const CrossCorrelationPage: React.FC = () => { + const [eventList1, setEventList1] = useState(''); + const [eventList2, setEventList2] = useState(''); + const [dt, setDt] = useState('0.1'); + const [mode, setMode] = useState('same'); + const [norm, setNorm] = useState('none'); + const { result, running, error, run } = useAnalysisRunner('Cross Correlation'); + + const dtNum = parsePositiveNumber(dt); + const canRun = eventList1 !== '' && eventList2 !== '' && dtNum !== null && !running; + + const handleRun = (): void => { + if (!dtNum) return; + void run(() => + correlationApi.crossCorrelation({ + event_list_1_name: eventList1, + event_list_2_name: eventList2, + dt: dtNum, + mode, + norm, + }) + ); + }; + + const traces: Data[] = result + ? [ + { + x: result.time_lags, + y: result.corr, + type: 'scattergl', + mode: 'lines', + line: { color: '#00d4aa', width: 1 }, + } as Data, + ] + : []; + + const hasTimeShift = result != null && result.time_shift !== null; + + return ( + + + + + + + Parameters + + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + + Mode + + + + Normalization + + + + + + + + + + + + + + Result + + {result && } + {result && } + {result && } + {hasTimeShift && result && ( + + )} + + {error && ( + + {error} + + )} + {result && result.warnings.length > 0 && ( + + + {result.warnings.map((w, i) => ( + + {w} + + ))} + + + )} + {result ? ( + <> + + + Positive shift means the first list lags the second. + + + ) : ( + + + Choose two event lists and compute their cross-correlation. + + + )} + + + + + + ); +}; + +export default CrossCorrelationPage; diff --git a/src/pages/QuickLook/CrossSpectrum/index.tsx b/src/pages/QuickLook/CrossSpectrum/index.tsx new file mode 100644 index 0000000..e51b359 --- /dev/null +++ b/src/pages/QuickLook/CrossSpectrum/index.tsx @@ -0,0 +1,269 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { spectrumApi, PowerSpectrumData } from '@/api/spectrumApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const NORM_OPTIONS = ['leahy', 'frac', 'abs', 'none']; + +const CrossSpectrumPage: React.FC = () => { + const [eventList1, setEventList1] = useState(''); + const [eventList2, setEventList2] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [norm, setNorm] = useState('leahy'); + const [outputName, setOutputName] = useState(''); + const [logX, setLogX] = useState(true); + const [logY, setLogY] = useState(true); + const [rebinFactor, setRebinFactor] = useState('0.02'); + const [logRebin, setLogRebin] = useState(true); + const [lastStoredName, setLastStoredName] = useState(null); + const lastActionRef = useRef<'create' | 'rebin'>('create'); + const { result, running, error, run } = useAnalysisRunner('Cross Spectrum'); + + useEffect(() => { + if (result?.name) { + setLastStoredName(result.name); + } else if (result && lastActionRef.current === 'create') { + setLastStoredName(null); + } + }, [result]); + + const dtNum = parsePositiveNumber(dt); + const rebinNum = parsePositiveNumber(rebinFactor); + const canRun = eventList1 !== '' && eventList2 !== '' && dtNum !== null && !running; + const rebinValid = logRebin ? rebinNum !== null : rebinNum !== null && rebinNum > 1; + + const handleRun = (): void => { + lastActionRef.current = 'create'; + if (!dtNum) return; + void run(() => + spectrumApi.createCrossSpectrum({ + event_list_1_name: eventList1, + event_list_2_name: eventList2, + dt: dtNum, + norm, + output_name: outputName.trim() || undefined, + }) + ); + }; + + const handleRebin = (): void => { + lastActionRef.current = 'rebin'; + if (!lastStoredName || !rebinNum) return; + void run(() => + spectrumApi.rebinSpectrum({ name: lastStoredName, rebin_factor: rebinNum, log: logRebin }) + ); + }; + + const magnitudeTrace: Data[] = result + ? [ + { + x: result.freq, + y: result.power, + type: 'scattergl', + mode: 'lines', + line: { color: '#00d4aa', width: 1 }, + }, + ] + : []; + + const phaseTrace: Data[] = + result && result.power_phase + ? [ + { + x: result.freq, + y: result.power_phase, + type: 'scattergl', + mode: 'markers', + marker: { color: '#3b82f6', size: 3 }, + }, + ] + : []; + + return ( + + + + + + + Parameters + + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + + Normalization + + + setOutputName(e.target.value)} + helperText="Required to enable rebinning" + /> + + + + {lastStoredName && ( + <> + + + Rebin '{lastStoredName}' + setRebinFactor(e.target.value)} + error={rebinFactor !== '' && !rebinValid} + helperText={logRebin ? 'Each bin grows by (1 + f)' : 'Must be > 1'} + /> + { + const checked = e.target.checked; + setLogRebin(checked); + if (!checked && rebinNum !== null && rebinNum <= 1) { + setRebinFactor('2'); + } + }} + /> + } + label="Logarithmic" + /> + + + + )} + + + + + + + + + + Result + + {result?.norm && } + {result && } + setLogX(e.target.checked)} />} + label="log f" + /> + setLogY(e.target.checked)} />} + label="log |C|" + /> + + {error && ( + + {error} + + )} + {result ? ( + + + + Cross-power magnitude + + + + {phaseTrace.length > 0 && ( + + + Cross-spectrum phase + + + + )} + + ) : ( + + + Choose two event lists and compute their cross spectrum. + + + )} + + + + + + ); +}; + +export default CrossSpectrumPage; diff --git a/src/pages/QuickLook/DeadTimeCorrections/index.test.tsx b/src/pages/QuickLook/DeadTimeCorrections/index.test.tsx new file mode 100644 index 0000000..b531e93 --- /dev/null +++ b/src/pages/QuickLook/DeadTimeCorrections/index.test.tsx @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const pdsCorrection = vi.fn(); +const fadCorrection = vi.fn(); +vi.mock('@/api/deadtimeApi', () => ({ + deadtimeApi: { + pdsCorrection: (...a: unknown[]) => pdsCorrection(...a), + fadCorrection: (...a: unknown[]) => fadCorrection(...a), + }, +})); +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import DeadTimeCorrectionsPage from './index'; + +async function selectEventList(label: string, option: RegExp): Promise { + await userEvent.click(await screen.findByLabelText(label)); + await userEvent.click(await screen.findByRole('option', { name: option })); +} + +describe('DeadTimeCorrectionsPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockReset(); + pdsCorrection.mockReset(); + fadCorrection.mockReset(); + listEventLists.mockResolvedValue({ + success: true, + data: [ + { name: 'obs1', n_events: 5000, time_range: [0, 100] }, + { name: 'obs2', n_events: 4800, time_range: [0, 100] }, + ], + message: '', + error: null, + }); + pdsCorrection.mockResolvedValue({ + success: true, + data: { + freq: [1, 2, 3], + power_uncorrected: [1.66, null, 1.71], + power_corrected: [2.0, 1.99, 2.01], + rate: 171.5, + n_events: 17150, + exposure: 100, + n_segments: 5, + dt: 0.001, + segment_size: 20, + dead_time: 0.0025, + background_rate: 0, + limit_k: 200, + norm: 'leahy', + warnings: ['only 5 segments were averaged'], + }, + message: 'corrected', + error: null, + }); + fadCorrection.mockResolvedValue({ + success: true, + data: { + freq: [1, 2, 3], + pds1: [2.0, 1.9, null], + pds2: [2.1, 2.0, 1.95], + ptot: [4.1, 3.9, 3.9], + cs: [0.5, 0.4, 0.3], + cs_real: [0.5, -0.4, null], + n_segments: 12, + dt: 0.001, + segment_size: 8, + norm: 'frac', + smoothing_length: 24, + is_compliant: false, + fad_delta: 0.42, + warnings: ['FAD is not compliant: 42% deviation'], + }, + message: 'fad done', + error: null, + }); + }); + + it('converts incident to detected rates client-side and flags unphysical occupancy', async () => { + renderWithProviders(); + // Defaults: 300 c/s incident with a 2.5 ms dead time -> 300 / 1.75 + expect(await screen.findByText('Detected: 171.43 c/s')).toBeInTheDocument(); + expect(screen.getByText('Incident: 300.00 c/s')).toBeInTheDocument(); + expect(screen.getByText('Dead-time loss: 42.9%')).toBeInTheDocument(); + + // Same numbers read as a detected rate invert the other way: 300 / 0.25 + await userEvent.click(screen.getByRole('radio', { name: 'Detected' })); + expect(await screen.findByText('Incident: 1200.00 c/s')).toBeInTheDocument(); + + // 500 c/s x 2.5 ms >= 1 leaves the detector permanently busy + const rateField = screen.getByLabelText('Known rate (c/s)'); + await userEvent.clear(rateField); + await userEvent.type(rateField, '500'); + expect( + await screen.findByText('unphysical: detected rate x dead time must be < 1') + ).toBeInTheDocument(); + expect(screen.getByText('Incident: — c/s')).toBeInTheDocument(); + }); + + it('runs the model correction with the backend field names', async () => { + renderWithProviders(); + await selectEventList('Event list', /obs1/); + await userEvent.click(screen.getByRole('button', { name: /Compute correction/ })); + + await waitFor(() => + expect(pdsCorrection).toHaveBeenCalledWith( + expect.objectContaining({ + event_list_name: 'obs1', + dt: 0.001, + segment_size: 20, + dead_time: 0.0025, + background_rate: 0, + limit_k: 200, + }) + ) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + expect(screen.getByText('detected rate: 171.50 c/s')).toBeInTheDocument(); + expect(screen.getByText('5 segments')).toBeInTheDocument(); + expect(screen.getByText('only 5 segments were averaged')).toBeInTheDocument(); + expect( + screen.getByText(/Normalization fixed to Leahy \(required by the Zhang\+95 correction\)/) + ).toBeInTheDocument(); + }); + + it('runs the FAD correction with the backend field names', async () => { + renderWithProviders(); + await selectEventList('Detector 1 event list', /obs1/); + await selectEventList('Detector 2 event list', /obs2/); + await userEvent.click(screen.getByRole('button', { name: /Compute FAD/ })); + + await waitFor(() => + expect(fadCorrection).toHaveBeenCalledWith( + expect.objectContaining({ + event_list_1_name: 'obs1', + event_list_2_name: 'obs2', + dt: 0.001, + segment_size: 8, + norm: 'frac', + smoothing_length: null, + }) + ) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + expect(screen.getByText('12 segments')).toBeInTheDocument(); + expect(screen.getByText('FAD Δ: 0.420')).toBeInTheDocument(); + // is_compliant === false must surface the warnings prominently + expect(screen.getByText(/FAD self-check failed/)).toBeInTheDocument(); + expect(screen.getByText('FAD is not compliant: 42% deviation')).toBeInTheDocument(); + }); + + it('disables each Compute button until that panel has valid inputs', async () => { + renderWithProviders(); + const correctionButton = await screen.findByRole('button', { name: /Compute correction/ }); + const fadButton = screen.getByRole('button', { name: /Compute FAD/ }); + expect(correctionButton).toBeDisabled(); + expect(fadButton).toBeDisabled(); + + await selectEventList('Event list', /obs1/); + expect(correctionButton).toBeEnabled(); + expect(fadButton).toBeDisabled(); + + await userEvent.clear(screen.getByLabelText('PDS dt (s)')); + expect(correctionButton).toBeDisabled(); + + await selectEventList('Detector 1 event list', /obs1/); + expect(fadButton).toBeDisabled(); + await selectEventList('Detector 2 event list', /obs2/); + expect(fadButton).toBeEnabled(); + + await userEvent.type(screen.getByLabelText('Smoothing sigma (bins)'), 'x'); + expect(fadButton).toBeDisabled(); + expect(pdsCorrection).not.toHaveBeenCalled(); + expect(fadCorrection).not.toHaveBeenCalled(); + }); + + it('disables Compute FAD when both detector selectors hold the same event list', async () => { + renderWithProviders(); + const fadButton = screen.getByRole('button', { name: /Compute FAD/ }); + + await selectEventList('Detector 1 event list', /obs1/); + await selectEventList('Detector 2 event list', /obs2/); + expect(fadButton).toBeEnabled(); + expect( + screen.queryByText(/FAD needs two independent detectors/) + ).not.toBeInTheDocument(); + + // Selecting the same list for both detectors must disable the button again + // and explain why, instead of silently allowing a meaningless FAD run. + await selectEventList('Detector 2 event list', /obs1/); + expect(fadButton).toBeDisabled(); + expect(screen.getByText(/FAD needs two independent detectors/)).toBeInTheDocument(); + + await selectEventList('Detector 2 event list', /obs2/); + expect(fadButton).toBeEnabled(); + expect( + screen.queryByText(/FAD needs two independent detectors/) + ).not.toBeInTheDocument(); + + await userEvent.click(fadButton); + await waitFor(() => expect(fadCorrection).toHaveBeenCalled()); + }); +}); diff --git a/src/pages/QuickLook/DeadTimeCorrections/index.tsx b/src/pages/QuickLook/DeadTimeCorrections/index.tsx new file mode 100644 index 0000000..44c8416 --- /dev/null +++ b/src/pages/QuickLook/DeadTimeCorrections/index.tsx @@ -0,0 +1,717 @@ +import React, { useState } from 'react'; +import { + Alert, + AlertTitle, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + FormLabel, + Grid, + InputLabel, + MenuItem, + Radio, + RadioGroup, + Select, + Stack, + Switch, + TextField, + Typography, + useTheme, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { deadtimeApi, PdsCorrectionData, FadCorrectionData } from '@/api/deadtimeApi'; +import { parsePositiveNumber } from '@/utils/numbers'; +import { + UNPHYSICAL_DETECTED_RATE_MESSAGE, + deadTimeLossFraction, + detectedRateFromIncident, + incidentRateFromDetected, + parseNonNegativeNumber, +} from '@/utils/deadtime'; + +const FAD_NORMS = ['frac', 'leahy', 'abs', 'none']; + +/** Leahy white-noise power of a dead-time-free Poisson process. */ +const LEAHY_WHITE_NOISE = 2; + +const format = (value: number | null, digits: number): string => + value === null || !Number.isFinite(value) ? '—' : value.toFixed(digits); + +/** Backend advisories rendered inside a warning Alert. */ +const WarningLines: React.FC<{ warnings: string[] }> = ({ warnings }) => ( + <> + {warnings.map((warning, index) => ( + + {warning} + + ))} + +); + +const DeadTimeCorrectionsPage: React.FC = () => { + const theme = useTheme(); + const referenceLineColor = theme.palette.text.secondary; + + // Panel (a) — client-side rate calculator + const [rateMode, setRateMode] = useState<'incident' | 'detected'>('incident'); + const [knownRate, setKnownRate] = useState('300'); + const [calculatorDeadTime, setCalculatorDeadTime] = useState('0.0025'); + + // Panel (b) — model (Zhang+95) PDS correction + const [pdsEventList, setPdsEventList] = useState(''); + const [pdsDt, setPdsDt] = useState('0.001'); + const [pdsSegmentSize, setPdsSegmentSize] = useState('20'); + const [pdsDeadTime, setPdsDeadTime] = useState('0.0025'); + const [pdsBackgroundRate, setPdsBackgroundRate] = useState('0'); + const [pdsLimitK, setPdsLimitK] = useState('200'); + const [pdsLogAxes, setPdsLogAxes] = useState(true); + + // Panel (c) — FAD correction + const [fadEventList1, setFadEventList1] = useState(''); + const [fadEventList2, setFadEventList2] = useState(''); + const [fadDt, setFadDt] = useState('0.001'); + const [fadSegmentSize, setFadSegmentSize] = useState('8'); + const [fadNorm, setFadNorm] = useState('frac'); + const [fadSmoothingLength, setFadSmoothingLength] = useState(''); + const [showCospectrum, setShowCospectrum] = useState(false); + const fadNormLabelId = React.useId(); + + const { + result: pdsResult, + running: pdsRunning, + error: pdsError, + run: runPds, + } = useAnalysisRunner('Dead-time PDS Correction'); + const { + result: fadResult, + running: fadRunning, + error: fadError, + run: runFad, + } = useAnalysisRunner('FAD Correction'); + + // --- Panel (a): pure client-side rate conversions ------------------------- + const knownRateNum = parseNonNegativeNumber(knownRate); + const calculatorDeadTimeNum = parseNonNegativeNumber(calculatorDeadTime); + const calculatorInputsValid = knownRateNum !== null && calculatorDeadTimeNum !== null; + const incidentRate = + knownRateNum === null || calculatorDeadTimeNum === null + ? null + : rateMode === 'incident' + ? knownRateNum + : incidentRateFromDetected(knownRateNum, calculatorDeadTimeNum); + const detectedRate = + knownRateNum === null || calculatorDeadTimeNum === null + ? null + : rateMode === 'detected' + ? knownRateNum + : detectedRateFromIncident(knownRateNum, calculatorDeadTimeNum); + const occupancyUnphysical = + rateMode === 'detected' && calculatorInputsValid && incidentRate === null; + const lossFraction = + incidentRate !== null && detectedRate !== null + ? deadTimeLossFraction(incidentRate, detectedRate) + : null; + + // --- Panel (b): model correction ------------------------------------------ + const pdsDtNum = parsePositiveNumber(pdsDt); + const pdsSegmentNum = parsePositiveNumber(pdsSegmentSize); + const pdsDeadTimeNum = parsePositiveNumber(pdsDeadTime); + const pdsBackgroundNum = parseNonNegativeNumber(pdsBackgroundRate); + const pdsLimitKNum = parsePositiveNumber(pdsLimitK); + const pdsLimitKValid = pdsLimitKNum !== null && Number.isInteger(pdsLimitKNum); + const canRunPds = + pdsEventList !== '' && + pdsDtNum !== null && + pdsSegmentNum !== null && + pdsDeadTimeNum !== null && + pdsBackgroundNum !== null && + pdsLimitKValid && + !pdsRunning; + + const handleRunPds = (): void => { + if ( + pdsDtNum === null || + pdsSegmentNum === null || + pdsDeadTimeNum === null || + pdsBackgroundNum === null || + pdsLimitKNum === null + ) { + return; + } + void runPds(() => + deadtimeApi.pdsCorrection({ + event_list_name: pdsEventList, + dt: pdsDtNum, + segment_size: pdsSegmentNum, + dead_time: pdsDeadTimeNum, + background_rate: pdsBackgroundNum, + limit_k: pdsLimitKNum, + }) + ); + }; + + const pdsTraces: Data[] = pdsResult + ? [ + { + x: pdsResult.freq, + y: pdsResult.power_uncorrected, + type: 'scattergl', + mode: 'lines', + line: { width: 1 }, + name: 'Uncorrected', + } as Data, + { + x: pdsResult.freq, + y: pdsResult.power_corrected, + type: 'scattergl', + mode: 'lines', + line: { width: 1 }, + name: 'Dead-time corrected', + } as Data, + ] + : []; + + // Shape coordinates use raw data values on log axes in this plotly version + // (verified live: passing log10(2) rendered the line at y=0.3, not y=2). + const leahyLineY = LEAHY_WHITE_NOISE; + + // --- Panel (c): FAD correction -------------------------------------------- + const fadDtNum = parsePositiveNumber(fadDt); + const fadSegmentNum = parsePositiveNumber(fadSegmentSize); + const fadSmoothingNum = parsePositiveNumber(fadSmoothingLength); + const fadSmoothingInvalid = fadSmoothingLength !== '' && fadSmoothingNum === null; + const fadListsIdentical = fadEventList1 !== '' && fadEventList1 === fadEventList2; + const canRunFad = + fadEventList1 !== '' && + fadEventList2 !== '' && + !fadListsIdentical && + fadDtNum !== null && + fadSegmentNum !== null && + !fadSmoothingInvalid && + !fadRunning; + + const handleRunFad = (): void => { + if (fadDtNum === null || fadSegmentNum === null || fadSmoothingInvalid || fadListsIdentical) + return; + void runFad(() => + deadtimeApi.fadCorrection({ + event_list_1_name: fadEventList1, + event_list_2_name: fadEventList2, + dt: fadDtNum, + segment_size: fadSegmentNum, + norm: fadNorm, + smoothing_length: fadSmoothingNum, + }) + ); + }; + + const fadTraces: Data[] = fadResult + ? [ + { + x: fadResult.freq, + y: fadResult.pds1, + type: 'scattergl', + mode: 'lines', + line: { width: 1 }, + name: 'PDS 1', + } as Data, + { + x: fadResult.freq, + y: fadResult.pds2, + type: 'scattergl', + mode: 'lines', + line: { width: 1 }, + name: 'PDS 2', + } as Data, + { + x: fadResult.freq, + y: fadResult.ptot, + type: 'scattergl', + mode: 'lines', + line: { width: 1 }, + name: 'Total PDS', + } as Data, + ...(showCospectrum + ? [ + { + x: fadResult.freq, + y: fadResult.cs_real, + type: 'scattergl', + mode: 'lines', + line: { width: 1 }, + name: 'Re[CS] (signed cospectrum)', + } as Data, + ] + : []), + ] + : []; + + const fadSegmentsLow = fadResult !== null && fadResult.n_segments < 30; + const fadNonCompliant = fadResult !== null && fadResult.is_compliant === false; + + return ( + + + + + + + + Model correction parameters + + setPdsDt(e.target.value)} + error={pdsDt !== '' && pdsDtNum === null} + helperText={pdsDt !== '' && pdsDtNum === null ? 'Must be a positive number' : ' '} + /> + setPdsSegmentSize(e.target.value)} + error={pdsSegmentSize !== '' && pdsSegmentNum === null} + helperText={ + pdsSegmentSize !== '' && pdsSegmentNum === null + ? 'Must be a positive number' + : 'At least 3 × dt, at most the total exposure' + } + /> + setPdsDeadTime(e.target.value)} + error={pdsDeadTime !== '' && pdsDeadTimeNum === null} + helperText={ + pdsDeadTime !== '' && pdsDeadTimeNum === null + ? 'Must be a positive number' + : ' ' + } + /> + setPdsBackgroundRate(e.target.value)} + error={pdsBackgroundRate !== '' && pdsBackgroundNum === null} + helperText={ + pdsBackgroundRate !== '' && pdsBackgroundNum === null + ? 'Must be zero or a positive number' + : ' ' + } + /> + setPdsLimitK(e.target.value)} + error={pdsLimitK !== '' && !pdsLimitKValid} + helperText={ + pdsLimitK !== '' && !pdsLimitKValid ? 'Must be a positive integer' : ' ' + } + /> + + + + + + + + + FAD parameters + + + {fadListsIdentical && ( + + FAD needs two independent detectors observing the same source + simultaneously; the same list twice is not a valid input. + + )} + setFadDt(e.target.value)} + error={fadDt !== '' && fadDtNum === null} + helperText={fadDt !== '' && fadDtNum === null ? 'Must be a positive number' : ' '} + /> + setFadSegmentSize(e.target.value)} + error={fadSegmentSize !== '' && fadSegmentNum === null} + helperText={ + fadSegmentSize !== '' && fadSegmentNum === null + ? 'Must be a positive number' + : 'Aim for 30 or more segments' + } + /> + + FAD normalization + + + setFadSmoothingLength(e.target.value)} + error={fadSmoothingInvalid} + helperText={ + fadSmoothingInvalid + ? 'Must be a positive number' + : 'Gaussian sigma in frequency bins; blank uses 3 × segment size' + } + /> + + + + + + + + + + {/* (a) Rate calculator — no backend involved */} + + + + Rate calculator + + Non-paralyzable detector: r_det = r_in / (1 + r_in · τ) and r_in = r_det / + (1 − r_det · τ). Computed in the browser, no analysis is run. + + + Known rate is + setRateMode(e.target.value as 'incident' | 'detected')} + > + } + label="Incident" + /> + } + label="Detected" + /> + + + + setKnownRate(e.target.value)} + error={(knownRate !== '' && knownRateNum === null) || occupancyUnphysical} + helperText={ + occupancyUnphysical + ? UNPHYSICAL_DETECTED_RATE_MESSAGE + : knownRate !== '' && knownRateNum === null + ? 'Must be zero or a positive number' + : ' ' + } + /> + setCalculatorDeadTime(e.target.value)} + error={ + (calculatorDeadTime !== '' && calculatorDeadTimeNum === null) || + occupancyUnphysical + } + helperText={ + calculatorDeadTime !== '' && calculatorDeadTimeNum === null + ? 'Must be zero or a positive number' + : ' ' + } + /> + + + + + + + + + + + {/* (b) Model correction result */} + + + + + Model correction (Zhang+95) + + {pdsResult && ( + + )} + {pdsResult && ( + + )} + setPdsLogAxes(e.target.checked)} + /> + } + label="log axes" + /> + + + Normalization fixed to Leahy (required by the Zhang+95 correction). The dashed + line marks the Leahy white-noise level of 2. + + {pdsError && ( + + {pdsError} + + )} + {pdsResult && pdsResult.warnings.length > 0 && ( + + + + )} + {pdsRunning && ( + + Correcting… the first run of a session also pays a one-off numba compile, so + this can take a few seconds. + + )} + {pdsResult ? ( + + ) : ( + + + Choose an event list and compute the dead-time-corrected power spectrum. + + + )} + + + + {/* (c) FAD correction result */} + + + + + FAD correction (two detectors) + + {fadResult && ( + + )} + {fadResult && ( + + )} + setShowCospectrum(e.target.checked)} + /> + } + label="signed cospectrum" + /> + + + {showCospectrum + ? 'Re[CS] is signed, so the y axis is linear with a zero line while it is shown.' + : 'Corrected power spectra of each detector and of their sum, on log axes.'} + + {fadError && ( + + {fadError} + + )} + {fadResult && (fadResult.warnings.length > 0 || fadNonCompliant) && ( + + {fadNonCompliant && ( + + FAD self-check failed — the two event lists are probably not independent + simultaneous detectors + + )} + + + )} + {fadRunning && ( + + Running FAD… the first run of a session also pays a one-off numba compile, so + this can take a few seconds. + + )} + {fadResult ? ( + + ) : ( + + + Choose two simultaneous detector event lists and compute the FAD-corrected + spectra. + + + )} + + + + + + + ); +}; + +export default DeadTimeCorrectionsPage; diff --git a/src/pages/QuickLook/DynamicalPowerSpectrum/index.tsx b/src/pages/QuickLook/DynamicalPowerSpectrum/index.tsx new file mode 100644 index 0000000..6e438c5 --- /dev/null +++ b/src/pages/QuickLook/DynamicalPowerSpectrum/index.tsx @@ -0,0 +1,201 @@ +import React, { useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { spectrumApi, DynamicalPowerSpectrumData } from '@/api/spectrumApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const NORM_OPTIONS = ['leahy', 'frac', 'abs', 'none']; + +const DynamicalPowerSpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [segmentSize, setSegmentSize] = useState('8'); + const [norm, setNorm] = useState('leahy'); + const [outputName, setOutputName] = useState(''); + const [logZ, setLogZ] = useState(true); + const { result, running, error, run } = useAnalysisRunner( + 'Dynamical Power Spectrum' + ); + + const dtNum = parsePositiveNumber(dt); + const segNum = parsePositiveNumber(segmentSize); + const canRun = eventList !== '' && dtNum !== null && segNum !== null && !running; + + const handleRun = (): void => { + if (!dtNum || !segNum) return; + void run(() => + spectrumApi.createDynamicalPowerSpectrum({ + event_list_name: eventList, + dt: dtNum, + segment_size: segNum, + norm, + output_name: outputName.trim() || undefined, + }) + ); + }; + + // dyn_ps rows correspond to frequencies (n_freq x n_times) — matches + // Plotly's convention that z[i] pairs with y[i]. + const zValues: Array> | undefined = useMemo( + () => + result + ? logZ + ? result.dyn_ps.map((row) => row.map((v) => (v !== null && v > 0 ? Math.log10(v) : null))) + : result.dyn_ps + : undefined, + [result, logZ] + ); + + const heatmap: Data[] = useMemo( + () => + result && zValues + ? [ + { + z: zValues, + x: result.time, + y: result.freq, + type: 'heatmap', + colorscale: 'Viridis', + colorbar: { title: { text: logZ ? 'log10 P' : 'Power' } }, + } as Data, + ] + : [], + [result, zValues, logZ] + ); + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + Normalization + + + setOutputName(e.target.value)} + /> + + + + + + + + + + + + Result + + {result && ( + + )} + setLogZ(e.target.checked)} />} + label="log color" + /> + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Compute a dynamical power spectrum to see the time-frequency map. + + + )} + + + + + + ); +}; + +export default DynamicalPowerSpectrumPage; diff --git a/src/pages/QuickLook/EventList/index.test.tsx b/src/pages/QuickLook/EventList/index.test.tsx new file mode 100644 index 0000000..7c9c690 --- /dev/null +++ b/src/pages/QuickLook/EventList/index.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; + +const listEventLists = vi.fn(); +const getEventListInfo = vi.fn(); +const getEventListFullPreview = vi.fn(); +const deleteEventList = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { + listEventLists: (...a: unknown[]) => listEventLists(...a), + getEventListInfo: (...a: unknown[]) => getEventListInfo(...a), + getEventListFullPreview: (...a: unknown[]) => getEventListFullPreview(...a), + deleteEventList: (...a: unknown[]) => deleteEventList(...a), + }, +})); +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import EventListPage from './index'; + +describe('EventListPage', () => { + beforeEach(() => { + listEventLists.mockReset(); + getEventListInfo.mockReset(); + deleteEventList.mockReset(); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + getEventListInfo.mockResolvedValue({ + success: true, + data: { + name: 'obs1', + n_events: 5000, + time_range: [0, 100], + duration: 100, + mjdref: 56000, + gti_count: 2, + gti_list: [ + [0, 40], + [60, 100], + ], + mean_count_rate: 50, + }, + message: '', + error: null, + }); + }); + + it('lists event lists and shows details when one is selected', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByText(/obs1/)); + expect(await screen.findByText('Duration (s)')).toBeInTheDocument(); + expect(getEventListInfo).toHaveBeenCalledWith('obs1'); + // GTI table rows + expect(await screen.findByText('Good Time Intervals')).toBeInTheDocument(); + }); + + it('deletes an event list via the confirmation dialog', async () => { + deleteEventList.mockResolvedValue({ + success: true, + data: { name: 'obs1' }, + message: '', + error: null, + }); + renderWithProviders(); + await userEvent.click(await screen.findByRole('button', { name: 'Delete obs1' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(deleteEventList).toHaveBeenCalledWith('obs1'); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages/QuickLook/EventList/index.tsx b/src/pages/QuickLook/EventList/index.tsx new file mode 100644 index 0000000..602c879 --- /dev/null +++ b/src/pages/QuickLook/EventList/index.tsx @@ -0,0 +1,360 @@ +import React, { useState } from 'react'; +import type { Data } from 'plotly.js'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Grid, + IconButton, + List, + ListItem, + ListItemButton, + ListItemText, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + Tooltip, + Typography, +} from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import DeleteIcon from '@mui/icons-material/Delete'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import { EVENT_LISTS_QUERY_KEY, useEventLists } from '@/hooks/useEventLists'; +import { dataApi, EventListFullPreview, EventListInfo } from '@/api/dataApi'; +import { useUIStore } from '@/store/uiStore'; + +const mono = { fontFamily: '"JetBrains Mono", monospace' }; + +const eventListInfoKey = (name: string) => ['eventListInfo', name] as const; +const eventListPreviewKey = (name: string) => ['eventListPreview', name] as const; + +const formatNum = (v: number | null | undefined, digits = 3): string => + v === null || v === undefined + ? '—' + : Number(v).toLocaleString(undefined, { maximumFractionDigits: digits }); + +const InfoRow: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( + + + {label} + + + {value} + + +); + +const EventListPage: React.FC = () => { + const [selected, setSelected] = useState(null); + const [tab, setTab] = useState(0); + const [deleteTarget, setDeleteTarget] = useState(null); + const addNotification = useUIStore((s) => s.addNotification); + const queryClient = useQueryClient(); + const { data: eventLists, isLoading, isError, error, refetch, isFetching } = useEventLists(); + + const infoQuery = useQuery({ + queryKey: eventListInfoKey(selected ?? ''), + enabled: selected !== null, + queryFn: async (): Promise => { + const res = await dataApi.getEventListInfo(selected as string); + if (!res.success || !res.data) throw new Error(res.error || res.message); + return res.data; + }, + }); + + const previewQuery = useQuery({ + queryKey: eventListPreviewKey(selected ?? ''), + enabled: selected !== null && tab === 1, + queryFn: async (): Promise => { + const res = await dataApi.getEventListFullPreview(selected as string); + if (!res.success || !res.data) throw new Error(res.error || res.message); + return res.data; + }, + }); + + const handleDelete = async (): Promise => { + if (!deleteTarget) return; + const res = await dataApi.deleteEventList(deleteTarget); + if (res.success) { + addNotification({ type: 'success', title: 'Event List', message: `Deleted '${deleteTarget}'` }); + if (selected === deleteTarget) setSelected(null); + queryClient.removeQueries({ queryKey: eventListInfoKey(deleteTarget) }); + queryClient.removeQueries({ queryKey: eventListPreviewKey(deleteTarget) }); + await queryClient.invalidateQueries({ queryKey: EVENT_LISTS_QUERY_KEY }); + } else { + addNotification({ + type: 'error', + title: 'Event List', + message: res.error || res.message || 'Delete failed', + }); + } + setDeleteTarget(null); + }; + + const info = infoQuery.data; + const preview = previewQuery.data; + + return ( + + + + + + + Loaded event lists + + + refetch()} disabled={isFetching}> + {isFetching ? : } + + + + + {isError && ( + + {error instanceof Error ? error.message : 'Failed to load'} + + )} + {isLoading && } + {!isLoading && (eventLists?.length ?? 0) === 0 && ( + + Nothing loaded yet — use Data Ingestion first. + + )} + + {(eventLists ?? []).map((ev) => ( + setDeleteTarget(ev.name)} + > + + + } + > + setSelected(ev.name)}> + + + + ))} + + + + + + + + + {!selected && ( + + + Select an event list to inspect it. + + + )} + {selected && ( + <> + + + {selected} + + {info?.mission && } + {info?.instrument && } + + setTab(v)} sx={{ mb: 2 }}> + + + + + {tab === 0 && infoQuery.isError && ( + + {infoQuery.error instanceof Error ? infoQuery.error.message : 'Failed to load info'} + + )} + {tab === 0 && infoQuery.isLoading && } + + {tab === 0 && info && ( + + + + + + + + + + + + + {(info.validation_issues ?? []) + .filter((v) => v.severity === 'error' || v.severity === 'warning') + .map((v, i) => ( + + {v.message} + + ))} + {info.notes && ( + + {info.notes} + + )} + + {(info.gti_list?.length ?? 0) > 0 && ( + + + + Good Time Intervals + + + + + + # + Start + Stop + Dur. (s) + Rate (cts/s) + + + + {(info.gti_list ?? []).map((g, i) => { + const rate = info.per_gti_rates?.[i]; + return ( + + {i + 1} + {formatNum(g[0])} + {formatNum(g[1])} + {formatNum(g[1] - g[0])} + {formatNum(rate?.rate)} + + ); + })} + +
+
+
+ )} +
+ )} + + {tab === 1 && previewQuery.isLoading && } + {tab === 1 && previewQuery.isError && ( + + {previewQuery.error instanceof Error + ? previewQuery.error.message + : 'Failed to load preview'} + + )} + {tab === 1 && preview && ( + + + + Arrival time distribution (preview sample) + + + + {preview.has_energy && preview.energy_preview && ( + + + Energy distribution (preview sample) + + + + )} + + )} + + )} +
+
+
+
+ + setDeleteTarget(null)}> + Delete event list? + + + Remove '{deleteTarget}' from backend memory? This cannot be undone. + + + + + + + +
+ ); +}; + +export default EventListPage; diff --git a/src/pages/QuickLook/ExcessVarianceSpectrum/index.test.tsx b/src/pages/QuickLook/ExcessVarianceSpectrum/index.test.tsx new file mode 100644 index 0000000..548bf17 --- /dev/null +++ b/src/pages/QuickLook/ExcessVarianceSpectrum/index.test.tsx @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const excessVariance = vi.fn(); +vi.mock('@/api/varenergyApi', () => ({ + varenergyApi: { excessVariance: (...a: unknown[]) => excessVariance(...a) }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import ExcessVarianceSpectrumPage from './index'; + +describe('ExcessVarianceSpectrumPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockReset(); + excessVariance.mockReset(); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + }); + + it('computes the excess variance spectrum with parsed default parameters and plots it', async () => { + excessVariance.mockResolvedValue({ + success: true, + data: { + energy: [0.5, 1.5, 2.5, 5, 8], + spectrum: [0.4, 0.42, 0.38, 0.35, 0.3], + spectrum_error: [0.02, 0.02, 0.03, 0.03, 0.04], + normalization: 'fvar', + warnings: [], + }, + message: 'ok', + error: null, + }); + + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + + await screen.findByTestId('chart'); + expect(excessVariance).toHaveBeenCalledWith( + expect.objectContaining({ + event_list_name: 'obs1', + bin_time: 0.1, + energy_min: 0.5, + energy_max: 10, + n_bands: 5, + log_bands: false, + normalization: 'fvar', + }) + ); + expect(screen.getByText('normalization: fvar')).toBeInTheDocument(); + expect(screen.getByText('n bands: 5')).toBeInTheDocument(); + }); + + it('disables Compute until inputs are valid', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Compute/ }); + expect(button).toBeDisabled(); + }); + + it('surfaces the all-null advisory from warnings prominently when nothing computes', async () => { + excessVariance.mockResolvedValue({ + success: true, + data: { + energy: [0.5, 1.5, 2.5, 5, 8], + spectrum: [null, null, null, null, null], + spectrum_error: [null, null, null, null, null], + normalization: 'fvar', + warnings: [ + 'the excess variance spectrum could not be computed for any energy band (stingray returns NaN when the reference band shows no variability above the Poisson noise floor). Try a longer segment_size, a coarser bin_time, fewer energy bands, or a source with real variability.', + ], + }, + message: 'ok', + error: null, + }); + + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + + expect( + await screen.findByText(/could not be computed for any energy band/) + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/QuickLook/ExcessVarianceSpectrum/index.tsx b/src/pages/QuickLook/ExcessVarianceSpectrum/index.tsx new file mode 100644 index 0000000..907f931 --- /dev/null +++ b/src/pages/QuickLook/ExcessVarianceSpectrum/index.tsx @@ -0,0 +1,268 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { varenergyApi, ExcessVarianceData } from '@/api/varenergyApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const NORMALIZATION_OPTIONS = ['fvar', 'none'] as const; + +/** + * Positive-integer parser for the band-count field. `src/utils/numbers.ts` + * only exposes `parsePositiveNumber`/`parseNumber` (no integer variant), so + * this small helper lives locally rather than widening a shared module. + */ +function parsePositiveInteger(value: string): number | null { + if (value.trim() === '') return null; + const n = Number(value); + return Number.isFinite(n) && Number.isInteger(n) && n > 0 ? n : null; +} + +const ExcessVarianceSpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + // The backend report flags small bin_time as the most common cause of an + // all-NaN excess variance; 0.1 s is the recommended, pre-validated default. + const [binTime, setBinTime] = useState('0.1'); + const [energyMin, setEnergyMin] = useState('0.5'); + const [energyMax, setEnergyMax] = useState('10'); + const [nBands, setNBands] = useState('5'); + const [logBands, setLogBands] = useState(false); + const [normalization, setNormalization] = useState<'fvar' | 'none'>('fvar'); + + const { result, running, error, run } = useAnalysisRunner( + 'Excess Variance Spectrum' + ); + + const binTimeNum = parsePositiveNumber(binTime); + const energyMinNum = parsePositiveNumber(energyMin); + const energyMaxNum = parsePositiveNumber(energyMax); + const nBandsNum = parsePositiveInteger(nBands); + + const energyMinInvalid = energyMin !== '' && energyMinNum === null; + const energyMaxInvalid = energyMax !== '' && energyMaxNum === null; + const energyRangeInverted = + energyMinNum !== null && energyMaxNum !== null && energyMaxNum <= energyMinNum; + const energyRangeValid = + energyMinNum !== null && energyMaxNum !== null && !energyRangeInverted; + + const nBandsInvalid = nBands !== '' && (nBandsNum === null || nBandsNum < 2); + + const canRun = + eventList !== '' && + binTimeNum !== null && + energyRangeValid && + nBandsNum !== null && + nBandsNum >= 2 && + !running; + + const energyHelperText = (invalid: boolean): string => { + if (invalid) return 'Must be a positive number'; + if (energyRangeInverted) return 'E max must be > E min'; + return ' '; + }; + + const handleRun = (): void => { + if (binTimeNum === null || !energyRangeValid || nBandsNum === null || nBandsNum < 2) return; + void run(() => + varenergyApi.excessVariance({ + event_list_name: eventList, + bin_time: binTimeNum, + energy_min: energyMinNum as number, + energy_max: energyMaxNum as number, + n_bands: nBandsNum, + log_bands: logBands, + normalization, + }) + ); + }; + + // Filter nulls for the empty-result check only; the raw arrays (which may + // contain nulls) are still passed straight through to Plotly below. + const finiteSpectrum = result ? result.spectrum.filter((v): v is number => v !== null) : []; + const allNull = result !== null && result.spectrum.length > 0 && finiteSpectrum.length === 0; + + const traces: Data[] = result + ? [ + { + x: result.energy, + y: result.spectrum, + type: 'scattergl', + mode: 'markers', + marker: { size: 7 }, + error_y: { type: 'data', array: result.spectrum_error, visible: true }, + } as Data, + ] + : []; + + const yAxisTitle = normalization === 'fvar' ? 'F_var' : 'Excess variance'; + + return ( + + + + + + + Parameters + + setBinTime(e.target.value)} + error={binTime !== '' && binTimeNum === null} + helperText={ + binTime !== '' && binTimeNum === null + ? 'Must be a positive number' + : 'Small values can yield an all-NaN result; 0.1 s is a safe default' + } + /> + + setEnergyMin(e.target.value)} + error={energyMinInvalid || energyRangeInverted} + helperText={energyHelperText(energyMinInvalid)} + /> + setEnergyMax(e.target.value)} + error={energyMaxInvalid || energyRangeInverted} + helperText={energyHelperText(energyMaxInvalid)} + /> + + setNBands(e.target.value)} + error={nBandsInvalid} + helperText={nBandsInvalid ? 'Must be an integer ≥ 2' : ' '} + /> + setLogBands(e.target.checked)} + /> + } + label="Log-spaced energy bands" + /> + + Normalization + + + + + + + + + + + + + + Result + + {result && ( + <> + + + + )} + + {error && ( + + {error} + + )} + {result && result.warnings.length > 0 && ( + + + {result.warnings.map((w, i) => ( + + {w} + + ))} + + + )} + {result ? ( + + ) : ( + + + Choose an event list and compute its excess variance spectrum. + + + )} + + + + + + ); +}; + +export default ExcessVarianceSpectrumPage; diff --git a/src/pages/QuickLook/LagEnergySpectrum/index.test.tsx b/src/pages/QuickLook/LagEnergySpectrum/index.test.tsx new file mode 100644 index 0000000..b4c7679 --- /dev/null +++ b/src/pages/QuickLook/LagEnergySpectrum/index.test.tsx @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const lagSpectrum = vi.fn(); +vi.mock('@/api/varenergyApi', () => ({ + varenergyApi: { lagSpectrum: (...a: unknown[]) => lagSpectrum(...a) }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import LagEnergySpectrumPage from './index'; + +describe('LagEnergySpectrumPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + lagSpectrum.mockResolvedValue({ + success: true, + data: { + energy: [1, 2, 3, 4, 5], + spectrum: [-0.01, -0.005, 0, 0.004, 0.01], + spectrum_error: [0.002, 0.002, 0.002, 0.003, 0.003], + freq_range: [0.1, 1], + ref_band: null, + n_segments_hint: 8, + warnings: [], + }, + message: 'done', + error: null, + }); + }); + + it('computes the lag spectrum with the parsed default parameters', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + + await waitFor(() => + expect(lagSpectrum).toHaveBeenCalledWith( + expect.objectContaining({ + event_list_name: 'obs1', + bin_time: 0.01, + segment_size: 8, + freq_min: 0.1, + freq_max: 1, + energy_min: 0.5, + energy_max: 10, + n_bands: 5, + log_bands: false, + ref_min: null, + ref_max: null, + }) + ) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + }); + + it('sends the reference band only when both edges are filled', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.type(screen.getByLabelText('Ref min (keV)'), '2'); + await userEvent.type(screen.getByLabelText('Ref max (keV)'), '4'); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + + await waitFor(() => + expect(lagSpectrum).toHaveBeenCalledWith( + expect.objectContaining({ ref_min: 2, ref_max: 4 }) + ) + ); + }); + + it('disables Compute until an event list is selected', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Compute/ }); + expect(button).toBeDisabled(); + }); + + it('disables Compute when only one reference-band edge is filled', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + const button = screen.getByRole('button', { name: /Compute/ }); + expect(button).not.toBeDisabled(); + + await userEvent.type(screen.getByLabelText('Ref min (keV)'), '2'); + expect(button).toBeDisabled(); + + await userEvent.type(screen.getByLabelText('Ref max (keV)'), '4'); + expect(button).not.toBeDisabled(); + }); + + it('renders warnings from the result when present', async () => { + lagSpectrum.mockResolvedValue({ + success: true, + data: { + energy: [1, 2, 3, 4, 5], + spectrum: [null, -0.005, 0, 0.004, null], + spectrum_error: [null, 0.002, 0.002, 0.003, null], + freq_range: [0.1, 1], + ref_band: [2, 4], + n_segments_hint: 8, + warnings: [ + 'undefined maths while computing this spectrum (numpy: invalid value encountered in sqrt); any affected energy bands are returned as null', + ], + }, + message: 'done', + error: null, + }); + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + expect(await screen.findByText(/undefined maths while computing this spectrum/)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/QuickLook/LagEnergySpectrum/index.tsx b/src/pages/QuickLook/LagEnergySpectrum/index.tsx new file mode 100644 index 0000000..18506bf --- /dev/null +++ b/src/pages/QuickLook/LagEnergySpectrum/index.tsx @@ -0,0 +1,386 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControlLabel, + Grid, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { varenergyApi, LagSpectrumData } from '@/api/varenergyApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const LagEnergySpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [binTime, setBinTime] = useState('0.01'); + const [segmentSize, setSegmentSize] = useState('8'); + const [freqMin, setFreqMin] = useState('0.1'); + const [freqMax, setFreqMax] = useState('1'); + const [energyMin, setEnergyMin] = useState('0.5'); + const [energyMax, setEnergyMax] = useState('10'); + const [nBands, setNBands] = useState('5'); + const [logBands, setLogBands] = useState(false); + const [refMin, setRefMin] = useState(''); + const [refMax, setRefMax] = useState(''); + + const { result, running, error, run } = useAnalysisRunner('Lag-Energy Spectrum'); + + const binTimeNum = parsePositiveNumber(binTime); + const segNum = parsePositiveNumber(segmentSize); + const freqMinNum = parsePositiveNumber(freqMin); + const freqMaxNum = parsePositiveNumber(freqMax); + const energyMinNum = parsePositiveNumber(energyMin); + const energyMaxNum = parsePositiveNumber(energyMax); + const nBandsNum = parsePositiveNumber(nBands); + const nBandsValid = nBandsNum !== null && Number.isInteger(nBandsNum) && nBandsNum >= 2; + + const freqRangeInverted = + freqMinNum !== null && freqMaxNum !== null && freqMaxNum <= freqMinNum; + const energyRangeInverted = + energyMinNum !== null && energyMaxNum !== null && energyMaxNum <= energyMinNum; + + const refMinNum = parsePositiveNumber(refMin); + const refMaxNum = parsePositiveNumber(refMax); + const refMinFilled = refMin.trim() !== ''; + const refMaxFilled = refMax.trim() !== ''; + const refBandPartial = refMinFilled !== refMaxFilled; + const refBandInverted = + refMinNum !== null && refMaxNum !== null && refMaxNum <= refMinNum; + const refBandValid = + (!refMinFilled && !refMaxFilled) || + (refMinFilled && + refMaxFilled && + refMinNum !== null && + refMaxNum !== null && + !refBandInverted); + + const canRun = + eventList !== '' && + binTimeNum !== null && + segNum !== null && + freqMinNum !== null && + freqMaxNum !== null && + !freqRangeInverted && + energyMinNum !== null && + energyMaxNum !== null && + !energyRangeInverted && + nBandsValid && + refBandValid && + !running; + + const handleRun = (): void => { + if ( + binTimeNum === null || + segNum === null || + freqMinNum === null || + freqMaxNum === null || + energyMinNum === null || + energyMaxNum === null || + nBandsNum === null + ) { + return; + } + const useRefBand = + refMinFilled && refMaxFilled && refMinNum !== null && refMaxNum !== null; + void run(() => + varenergyApi.lagSpectrum({ + event_list_name: eventList, + bin_time: binTimeNum, + segment_size: segNum, + freq_min: freqMinNum, + freq_max: freqMaxNum, + energy_min: energyMinNum, + energy_max: energyMaxNum, + n_bands: nBandsNum, + log_bands: logBands, + ref_min: useRefBand ? refMinNum : null, + ref_max: useRefBand ? refMaxNum : null, + }) + ); + }; + + const finiteSpectrum = result ? result.spectrum.filter((v): v is number => v !== null) : []; + const allNull = result !== null && result.spectrum.length > 0 && finiteSpectrum.length === 0; + + const traces: Data[] = result + ? [ + { + x: result.energy, + y: result.spectrum, + type: 'scattergl', + mode: 'markers', + marker: { size: 7, color: '#00d4aa' }, + error_y: { + type: 'data', + array: result.spectrum_error, + visible: true, + color: 'rgba(0, 212, 170, 0.35)', + }, + } as Data, + ] + : []; + + return ( + + + + + + + Parameters + + setBinTime(e.target.value)} + error={binTime !== '' && binTimeNum === null} + helperText={binTime !== '' && binTimeNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + setFreqMin(e.target.value)} + error={(freqMin !== '' && freqMinNum === null) || freqRangeInverted} + helperText={ + freqMin !== '' && freqMinNum === null + ? 'Must be a positive number' + : freqRangeInverted + ? 'f max must be > f min' + : ' ' + } + /> + setFreqMax(e.target.value)} + error={(freqMax !== '' && freqMaxNum === null) || freqRangeInverted} + helperText={ + freqMax !== '' && freqMaxNum === null + ? 'Must be a positive number' + : freqRangeInverted + ? 'f max must be > f min' + : ' ' + } + /> + + + setEnergyMin(e.target.value)} + error={(energyMin !== '' && energyMinNum === null) || energyRangeInverted} + helperText={ + energyMin !== '' && energyMinNum === null + ? 'Must be a positive number' + : energyRangeInverted + ? 'max must be > min' + : ' ' + } + /> + setEnergyMax(e.target.value)} + error={(energyMax !== '' && energyMaxNum === null) || energyRangeInverted} + helperText={ + energyMax !== '' && energyMaxNum === null + ? 'Must be a positive number' + : energyRangeInverted + ? 'max must be > min' + : ' ' + } + /> + + setNBands(e.target.value)} + error={nBands !== '' && !nBandsValid} + helperText={nBands !== '' && !nBandsValid ? 'Must be an integer >= 2' : ' '} + /> + setLogBands(e.target.checked)} + /> + } + label="Log-spaced energy bands" + /> + Reference band (optional) + + setRefMin(e.target.value)} + error={(refMinFilled && refMinNum === null) || refBandPartial || refBandInverted} + helperText={ + refMinFilled && refMinNum === null + ? 'Must be a positive number' + : refBandPartial + ? 'Fill both or leave both blank' + : refBandInverted + ? 'ref max must be > ref min' + : ' ' + } + /> + setRefMax(e.target.value)} + error={(refMaxFilled && refMaxNum === null) || refBandPartial || refBandInverted} + helperText={ + refMaxFilled && refMaxNum === null + ? 'Must be a positive number' + : refBandPartial + ? 'Fill both or leave both blank' + : refBandInverted + ? 'ref max must be > ref min' + : ' ' + } + /> + + + Leave both blank to use the full band as the reference; fill both to compare against a + specific band. stingray requires both edges or neither. + + + + + + + + + + + + + Result + + {result && ( + <> + + + {result.ref_band && ( + + )} + + )} + + {error && ( + + {error} + + )} + {result && result.warnings.length > 0 && ( + + + {result.warnings.map((w, i) => ( + + {w} + + ))} + + + )} + {result ? ( + allNull ? ( + + + No finite lag values in any energy band. See the warnings above for why (e.g. low + count rate or no variability above the Poisson floor) and try a coarser bin, fewer + bands, or a longer segment. + + + ) : ( + + ) + ) : ( + + + Choose an event list and compute the lag-energy spectrum. + + + )} + + + + + + ); +}; + +export default LagEnergySpectrumPage; diff --git a/src/pages/QuickLook/LightCurve/index.test.tsx b/src/pages/QuickLook/LightCurve/index.test.tsx new file mode 100644 index 0000000..a66fc45 --- /dev/null +++ b/src/pages/QuickLook/LightCurve/index.test.tsx @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const createFromEventList = vi.fn(); +const listLightcurves = vi.fn(); +const getLightcurveData = vi.fn(); +const rebin = vi.fn(); +const deleteLightcurve = vi.fn(); +vi.mock('@/api/lightcurveApi', () => ({ + lightcurveApi: { + createFromEventList: (...a: unknown[]) => createFromEventList(...a), + listLightcurves: (...a: unknown[]) => listLightcurves(...a), + getLightcurveData: (...a: unknown[]) => getLightcurveData(...a), + rebin: (...a: unknown[]) => rebin(...a), + deleteLightcurve: (...a: unknown[]) => deleteLightcurve(...a), + }, +})); +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import LightCurvePage from './index'; + +describe('LightCurvePage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + listLightcurves.mockResolvedValue({ success: true, data: [], message: '', error: null }); + createFromEventList.mockResolvedValue({ + success: true, + data: { + name: 'obs1_lc', + time: [0.5, 1.5, 2.5], + counts: [10, 12, 9], + dt: 1, + n_bins: 3, + plot_stride: 1, + count_rate_mean: 10.3, + }, + message: 'created', + error: null, + }); + }); + + it('creates a light curve with parsed parameters and plots it', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + const dtField = screen.getByLabelText(/Time bin/); + await userEvent.clear(dtField); + await userEvent.type(dtField, '1.0'); + await userEvent.click(screen.getByRole('button', { name: /Generate/ })); + await waitFor(() => + expect(createFromEventList).toHaveBeenCalledWith( + expect.objectContaining({ event_list_name: 'obs1', dt: 1, output_name: 'obs1_lc' }) + ) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + }); + + it('disables Generate until inputs are valid', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Generate/ }); + expect(button).toBeDisabled(); + }); +}); diff --git a/src/pages/QuickLook/LightCurve/index.tsx b/src/pages/QuickLook/LightCurve/index.tsx new file mode 100644 index 0000000..d86b001 --- /dev/null +++ b/src/pages/QuickLook/LightCurve/index.tsx @@ -0,0 +1,291 @@ +import React, { useEffect, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + FormControl, + Grid, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import DeleteIcon from '@mui/icons-material/Delete'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { lightcurveApi, LightcurveData, LightcurveSummary } from '@/api/lightcurveApi'; +import { parsePositiveNumber } from '@/utils/numbers'; +import { useUIStore } from '@/store/uiStore'; + +const LIGHTCURVES_QUERY_KEY = ['lightcurves'] as const; + +const LightCurvePage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('1.0'); + const [outputName, setOutputName] = useState(''); + const [existingSelection, setExistingSelection] = useState(''); + const [rebinFactor, setRebinFactor] = useState('2'); + const addNotification = useUIStore((s) => s.addNotification); + const queryClient = useQueryClient(); + const { result, running, error, run } = useAnalysisRunner('Light Curve'); + + const existingQuery = useQuery({ + queryKey: LIGHTCURVES_QUERY_KEY, + queryFn: async (): Promise => { + const res = await lightcurveApi.listLightcurves(); + if (!res.success) throw new Error(res.error || res.message); + return res.data ?? []; + }, + }); + + // Any successful create/rebin changes the stored set — refresh the list. + useEffect(() => { + if (result) void queryClient.invalidateQueries({ queryKey: LIGHTCURVES_QUERY_KEY }); + }, [result, queryClient]); + + // Auto-clear a stored-curve selection that no longer exists in the list + // (deleted elsewhere or backend restart) so we never act on stale names. + useEffect(() => { + if ( + !existingQuery.isLoading && + !existingQuery.isFetching && + existingSelection !== '' && + existingQuery.data !== undefined && + !existingQuery.data.some((lc) => lc.name === existingSelection) + ) { + setExistingSelection(''); + } + }, [existingQuery.data, existingQuery.isLoading, existingQuery.isFetching, existingSelection]); + + const dtNum = parsePositiveNumber(dt); + const canRun = eventList !== '' && dtNum !== null && !running; + const rebinNum = parsePositiveNumber(rebinFactor); + + const handleGenerate = (): void => { + if (!dtNum || !eventList) return; + const name = outputName.trim() || `${eventList}_lc`; + void run(() => + lightcurveApi.createFromEventList({ event_list_name: eventList, dt: dtNum, output_name: name }) + ); + }; + + const handleView = (): void => { + if (!existingSelection) return; + void run(() => lightcurveApi.getLightcurveData(existingSelection)); + }; + + const handleRebin = (): void => { + if (!result?.name || !rebinNum) return; + void run(() => + lightcurveApi.rebin({ + name: result.name, + rebin_factor: rebinNum, + output_name: `${result.name}_r${rebinNum}`, + }) + ); + }; + + const handleDelete = async (name: string): Promise => { + const res = await lightcurveApi.deleteLightcurve(name); + if (res.success) { + addNotification({ type: 'success', title: 'Light Curve', message: `Deleted '${name}'` }); + await queryClient.invalidateQueries({ queryKey: LIGHTCURVES_QUERY_KEY }); + } else { + addNotification({ + type: 'error', + title: 'Light Curve', + message: res.error || res.message || 'Delete failed', + }); + } + }; + + const plotData: Data[] = result + ? [ + { + x: result.time, + y: result.counts, + type: 'scattergl', + mode: 'lines', + line: { color: '#00d4aa', width: 1 }, + }, + ] + : []; + + return ( + + + + + + + Generate from event list + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setOutputName(e.target.value)} + placeholder={eventList ? `${eventList}_lc` : 'name'} + /> + + + + {result?.name && ( + <> + + + Rebin '{result.name}' + setRebinFactor(e.target.value)} + error={rebinFactor !== '' && rebinNum === null} + helperText={ + rebinFactor !== '' && rebinNum === null ? 'Must be a positive number' : ' ' + } + /> + + + + )} + + + + Stored light curves + + Light curve + + + + + + + { + void handleDelete(existingSelection); + setExistingSelection(''); + }} + > + + + + + + + + + + + + + + + + {result?.name ? `Light curve: ${result.name}` : 'Result'} + + {result && } + {result && } + {result?.count_rate_mean !== undefined && ( + + )} + + {result?.plot_stride !== undefined && result.plot_stride > 1 && ( + + Showing 1 of every {result.plot_stride} bins for display performance (full + resolution is stored in the backend). + + )} + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Generate a light curve or view a stored one. + + + )} + + + + + + ); +}; + +export default LightCurvePage; diff --git a/src/pages/QuickLook/PowerColors/index.tsx b/src/pages/QuickLook/PowerColors/index.tsx new file mode 100644 index 0000000..d1b817a --- /dev/null +++ b/src/pages/QuickLook/PowerColors/index.tsx @@ -0,0 +1,252 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + CircularProgress, + Grid, + Stack, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { timingApi, PowerColorsData } from '@/api/timingApi'; +import { parsePositiveNumber } from '@/utils/numbers'; +import { computePowerColorRatios } from '@/utils/powerColors'; + +interface BandInput { + label: string; + fmin: string; + fmax: string; +} + +// Heil et al. (2015) bands; require dt <= 1/(2*16) s for the top band. +const DEFAULT_BANDS: BandInput[] = [ + { label: 'A', fmin: '0.0039', fmax: '0.031' }, + { label: 'B', fmin: '0.031', fmax: '0.25' }, + { label: 'C', fmin: '0.25', fmax: '2.0' }, + { label: 'D', fmin: '2.0', fmax: '16.0' }, +]; + +const BAND_COLORS = ['#00d4aa', '#3b82f6', '#f59e0b', '#ef4444']; + +const PowerColorsPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.03125'); + const [segmentSize, setSegmentSize] = useState('64'); + const [bands, setBands] = useState(DEFAULT_BANDS); + const { result, running, error, run } = useAnalysisRunner('Power Colors'); + + const dtNum = parsePositiveNumber(dt); + const segNum = parsePositiveNumber(segmentSize); + + const parsedBands = bands.map((b) => ({ + label: b.label, + fmin: parsePositiveNumber(b.fmin), + fmax: parsePositiveNumber(b.fmax), + })); + const bandsValid = parsedBands.every( + (b) => b.fmin !== null && b.fmax !== null && b.fmax > b.fmin + ); + const nyquist = dtNum !== null ? 1 / (2 * dtNum) : null; + const bandExceedsNyquist = + nyquist !== null && parsedBands.some((b) => b.fmax !== null && b.fmax > nyquist); + const canRun = eventList !== '' && dtNum !== null && segNum !== null && bandsValid && !running; + + const updateBand = (index: number, field: 'fmin' | 'fmax', value: string): void => { + setBands((prev) => prev.map((b, i) => (i === index ? { ...b, [field]: value } : b))); + }; + + const handleRun = (): void => { + if (!dtNum || !segNum || !bandsValid) return; + const freq_ranges: Record = {}; + for (const b of parsedBands) { + freq_ranges[b.label] = [b.fmin as number, b.fmax as number]; + } + void run(() => + timingApi.calculatePowerColors({ + event_list_name: eventList, + dt: dtNum, + segment_size: segNum, + freq_ranges, + }) + ); + }; + + const bandTraces: Data[] = result + ? Object.entries(result.power_colors).map(([label, values], i) => ({ + x: result.time, + y: values, + type: 'scattergl' as const, + mode: 'lines+markers' as const, + marker: { size: 4, color: BAND_COLORS[i % BAND_COLORS.length] }, + line: { width: 1, color: BAND_COLORS[i % BAND_COLORS.length] }, + name: label, + })) + : []; + + const ratios = result + ? computePowerColorRatios( + result.power_colors, + bands.map((b) => b.label) + ) + : null; + + const scatterTrace: Data[] = ratios + ? [ + { + x: ratios.pc1, + y: ratios.pc2, + type: 'scattergl' as const, + mode: 'markers' as const, + marker: { size: 6, color: '#00d4aa' }, + }, + ] + : []; + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText="Nyquist must cover the highest band" + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText="Must exceed 1/f_min of the lowest band" + /> + Frequency bands (Hz) + {bands.map((b, i) => ( + + + {b.label} + + updateBand(i, 'fmin', e.target.value)} + /> + updateBand(i, 'fmax', e.target.value)} + /> + + ))} + {!bandsValid && ( + Each band needs 0 < f min < f max. + )} + {bandExceedsNyquist && ( + + A band's f max exceeds the Nyquist frequency 1/(2·dt); frequency bins above it are + dropped. + + )} + + + + + + + + + + {error && ( + + {error} + + )} + {result ? ( + + + + Band power vs time + + + + {ratios && ratios.pc1.length > 0 && ( + + + Power-color diagram (PC1 = C/A, PC2 = B/D) + + + Ratios use band-mean (not band-integrated) power: PC tracks match literature power + colors up to constant per-band factors, so absolute values are not comparable to + published hue diagrams. + + + + )} + + ) : ( + + + Compute band powers to populate the power-color diagram. + + + )} + + + + + + ); +}; + +export default PowerColorsPage; diff --git a/src/pages/QuickLook/PowerSpectrum/index.tsx b/src/pages/QuickLook/PowerSpectrum/index.tsx new file mode 100644 index 0000000..3a7aa5d --- /dev/null +++ b/src/pages/QuickLook/PowerSpectrum/index.tsx @@ -0,0 +1,234 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { spectrumApi, PowerSpectrumData } from '@/api/spectrumApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const NORM_OPTIONS = ['leahy', 'frac', 'abs', 'none']; + +const PowerSpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [norm, setNorm] = useState('leahy'); + const [outputName, setOutputName] = useState(''); + const [logX, setLogX] = useState(true); + const [logY, setLogY] = useState(true); + const [rebinFactor, setRebinFactor] = useState('0.02'); + const [logRebin, setLogRebin] = useState(true); + const [lastStoredName, setLastStoredName] = useState(null); + const lastActionRef = useRef<'create' | 'rebin'>('create'); + const { result, running, error, run } = useAnalysisRunner('Power Spectrum'); + + useEffect(() => { + if (result?.name) { + setLastStoredName(result.name); + } else if (result && lastActionRef.current === 'create') { + setLastStoredName(null); + } + }, [result]); + + const dtNum = parsePositiveNumber(dt); + const rebinNum = parsePositiveNumber(rebinFactor); + const canRun = eventList !== '' && dtNum !== null && !running; + const rebinValid = logRebin ? rebinNum !== null : rebinNum !== null && rebinNum > 1; + + const handleRun = (): void => { + lastActionRef.current = 'create'; + if (!dtNum) return; + void run(() => + spectrumApi.createPowerSpectrum({ + event_list_name: eventList, + dt: dtNum, + norm, + output_name: outputName.trim() || undefined, + }) + ); + }; + + const handleRebin = (): void => { + lastActionRef.current = 'rebin'; + if (!lastStoredName || !rebinNum) return; + void run(() => + spectrumApi.rebinSpectrum({ name: lastStoredName, rebin_factor: rebinNum, log: logRebin }) + ); + }; + + const plotData: Data[] = result + ? [ + { + x: result.freq, + y: result.power, + type: 'scattergl', + mode: 'lines', + line: { color: '#00d4aa', width: 1 }, + }, + ] + : []; + + return ( + + + + + + + Parameters + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + + Normalization + + + setOutputName(e.target.value)} + placeholder={eventList ? `${eventList}_ps` : ''} + helperText="Required to enable rebinning" + /> + + + + {lastStoredName && ( + <> + + + Rebin '{lastStoredName}' + setRebinFactor(e.target.value)} + error={rebinFactor !== '' && !rebinValid} + helperText={logRebin ? 'Each bin grows by (1 + f)' : 'Must be > 1'} + /> + { + const checked = e.target.checked; + setLogRebin(checked); + if (!checked && rebinNum !== null && rebinNum <= 1) { + setRebinFactor('2'); + } + }} + /> + } + label="Logarithmic" + /> + + + + )} + + + + + + + + + + Result + + {result?.norm && } + {result && } + {result?.df !== undefined && ( + + )} + setLogX(e.target.checked)} />} + label="log f" + /> + setLogY(e.target.checked)} />} + label="log P" + /> + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Choose an event list and compute a power spectrum. + + + )} + + + + + + ); +}; + +export default PowerSpectrumPage; diff --git a/src/pages/QuickLook/RmsEnergySpectrum/index.test.tsx b/src/pages/QuickLook/RmsEnergySpectrum/index.test.tsx new file mode 100644 index 0000000..bbf491f --- /dev/null +++ b/src/pages/QuickLook/RmsEnergySpectrum/index.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const rmsSpectrum = vi.fn(); +vi.mock('@/api/varenergyApi', () => ({ + varenergyApi: { rmsSpectrum: (...a: unknown[]) => rmsSpectrum(...a) }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import RmsEnergySpectrumPage from './index'; + +describe('RmsEnergySpectrumPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + rmsSpectrum.mockResolvedValue({ + success: true, + data: { + energy: [1, 2, 3, 4, 5], + spectrum: [0.4, 0.35, 0.3, 0.28, 0.25], + spectrum_error: [0.02, 0.02, 0.02, 0.03, 0.03], + freq_range: [0.1, 1], + norm: 'frac', + n_segments_hint: 8, + warnings: [], + }, + message: 'done', + error: null, + }); + }); + + it('computes the rms spectrum with the parsed default parameters', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + + await waitFor(() => + expect(rmsSpectrum).toHaveBeenCalledWith( + expect.objectContaining({ + event_list_name: 'obs1', + bin_time: 0.1, + segment_size: 8, + freq_min: 0.1, + freq_max: 1, + energy_min: 0.5, + energy_max: 10, + n_bands: 5, + log_bands: false, + norm: 'frac', + }) + ) + ); + expect(await screen.findByTestId('chart')).toBeInTheDocument(); + }); + + it('disables Compute until an event list is selected', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Compute/ }); + expect(button).toBeDisabled(); + }); +}); diff --git a/src/pages/QuickLook/RmsEnergySpectrum/index.tsx b/src/pages/QuickLook/RmsEnergySpectrum/index.tsx new file mode 100644 index 0000000..5512eb7 --- /dev/null +++ b/src/pages/QuickLook/RmsEnergySpectrum/index.tsx @@ -0,0 +1,328 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { varenergyApi, RmsSpectrumData } from '@/api/varenergyApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +type RmsNorm = 'frac' | 'abs'; + +const RmsEnergySpectrumPage: React.FC = () => { + const [eventList, setEventList] = useState(''); + const [binTime, setBinTime] = useState('0.1'); + const [segmentSize, setSegmentSize] = useState('8'); + const [freqMin, setFreqMin] = useState('0.1'); + const [freqMax, setFreqMax] = useState('1'); + const [energyMin, setEnergyMin] = useState('0.5'); + const [energyMax, setEnergyMax] = useState('10'); + const [nBands, setNBands] = useState('5'); + const [logBands, setLogBands] = useState(false); + const [norm, setNorm] = useState('frac'); + + const { result, running, error, run } = useAnalysisRunner('RMS Energy Spectrum'); + + const binTimeNum = parsePositiveNumber(binTime); + const segNum = parsePositiveNumber(segmentSize); + const freqMinNum = parsePositiveNumber(freqMin); + const freqMaxNum = parsePositiveNumber(freqMax); + const energyMinNum = parsePositiveNumber(energyMin); + const energyMaxNum = parsePositiveNumber(energyMax); + const nBandsNum = parsePositiveNumber(nBands); + const nBandsValid = nBandsNum !== null && Number.isInteger(nBandsNum) && nBandsNum >= 2; + + const freqRangeInverted = + freqMinNum !== null && freqMaxNum !== null && freqMaxNum <= freqMinNum; + const energyRangeInverted = + energyMinNum !== null && energyMaxNum !== null && energyMaxNum <= energyMinNum; + + const canRun = + eventList !== '' && + binTimeNum !== null && + segNum !== null && + freqMinNum !== null && + freqMaxNum !== null && + !freqRangeInverted && + energyMinNum !== null && + energyMaxNum !== null && + !energyRangeInverted && + nBandsValid && + !running; + + const handleRun = (): void => { + if ( + binTimeNum === null || + segNum === null || + freqMinNum === null || + freqMaxNum === null || + energyMinNum === null || + energyMaxNum === null || + nBandsNum === null + ) { + return; + } + void run(() => + varenergyApi.rmsSpectrum({ + event_list_name: eventList, + bin_time: binTimeNum, + segment_size: segNum, + freq_min: freqMinNum, + freq_max: freqMaxNum, + energy_min: energyMinNum, + energy_max: energyMaxNum, + n_bands: nBandsNum, + log_bands: logBands, + norm, + }) + ); + }; + + const finiteSpectrum = result ? result.spectrum.filter((v): v is number => v !== null) : []; + const allNull = result !== null && result.spectrum.length > 0 && finiteSpectrum.length === 0; + + const traces: Data[] = result + ? [ + { + x: result.energy, + y: result.spectrum, + type: 'scattergl', + mode: 'markers', + marker: { size: 7, color: '#00d4aa' }, + error_y: { + type: 'data', + array: result.spectrum_error, + visible: true, + color: 'rgba(0, 212, 170, 0.35)', + }, + } as Data, + ] + : []; + + const yAxisLabel = result?.norm === 'abs' ? 'Absolute rms (counts/s)' : 'Fractional rms'; + + return ( + + + + + + + Parameters + + setBinTime(e.target.value)} + error={binTime !== '' && binTimeNum === null} + helperText={binTime !== '' && binTimeNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + setFreqMin(e.target.value)} + error={(freqMin !== '' && freqMinNum === null) || freqRangeInverted} + helperText={ + freqMin !== '' && freqMinNum === null + ? 'Must be a positive number' + : freqRangeInverted + ? 'f max must be > f min' + : ' ' + } + /> + setFreqMax(e.target.value)} + error={(freqMax !== '' && freqMaxNum === null) || freqRangeInverted} + helperText={ + freqMax !== '' && freqMaxNum === null + ? 'Must be a positive number' + : freqRangeInverted + ? 'f max must be > f min' + : ' ' + } + /> + + + setEnergyMin(e.target.value)} + error={(energyMin !== '' && energyMinNum === null) || energyRangeInverted} + helperText={ + energyMin !== '' && energyMinNum === null + ? 'Must be a positive number' + : energyRangeInverted + ? 'max must be > min' + : ' ' + } + /> + setEnergyMax(e.target.value)} + error={(energyMax !== '' && energyMaxNum === null) || energyRangeInverted} + helperText={ + energyMax !== '' && energyMaxNum === null + ? 'Must be a positive number' + : energyRangeInverted + ? 'max must be > min' + : ' ' + } + /> + + setNBands(e.target.value)} + error={nBands !== '' && !nBandsValid} + helperText={nBands !== '' && !nBandsValid ? 'Must be an integer >= 2' : ' '} + /> + setLogBands(e.target.checked)} + /> + } + label="Log-spaced energy bands" + /> + + Normalization + + + + + + + + + + + + + + Result + + {result && ( + <> + + + + + )} + + {error && ( + + {error} + + )} + {result && result.warnings.length > 0 && ( + + + {result.warnings.map((w, i) => ( + + {w} + + ))} + + + )} + {result ? ( + allNull ? ( + + + No finite rms values in any energy band. See the warnings above for why (e.g. low + count rate) and try a coarser bin, fewer bands, or a wider segment. + + + ) : ( + + ) + ) : ( + + + Choose an event list and compute the rms-energy spectrum. + + + )} + + + + + + ); +}; + +export default RmsEnergySpectrumPage; diff --git a/src/pages/QuickLook/TimeLags/index.tsx b/src/pages/QuickLook/TimeLags/index.tsx new file mode 100644 index 0000000..12a3298 --- /dev/null +++ b/src/pages/QuickLook/TimeLags/index.tsx @@ -0,0 +1,213 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControlLabel, + Grid, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { timingApi, TimeLagsData } from '@/api/timingApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +const TimeLagsPage: React.FC = () => { + const [eventList1, setEventList1] = useState(''); + const [eventList2, setEventList2] = useState(''); + const [dt, setDt] = useState('0.0625'); + const [segmentSize, setSegmentSize] = useState('16'); + const [freqMin, setFreqMin] = useState(''); + const [freqMax, setFreqMax] = useState(''); + const [logX, setLogX] = useState(true); + const { result, running, error, run } = useAnalysisRunner('Time Lags'); + + const dtNum = parsePositiveNumber(dt); + const segNum = parsePositiveNumber(segmentSize); + const fMin = parsePositiveNumber(freqMin); + const fMax = parsePositiveNumber(freqMax); + const freqMinInvalid = freqMin !== '' && fMin === null; + const freqMaxInvalid = freqMax !== '' && fMax === null; + const freqRangePartial = (fMin !== null) !== (fMax !== null); + const freqRangeInverted = fMin !== null && fMax !== null && fMax <= fMin; + const freqRangeValid = + !freqMinInvalid && !freqMaxInvalid && !freqRangePartial && !freqRangeInverted; + const canRun = + eventList1 !== '' && + eventList2 !== '' && + dtNum !== null && + segNum !== null && + freqRangeValid && + !running; + + const freqHelperText = (raw: string, parsed: number | null): string => { + if (raw !== '' && parsed === null) return 'Must be a positive number'; + if (freqRangePartial) return 'Fill both or leave both blank'; + if (freqRangeInverted) return 'f max must be > f min'; + return ' '; + }; + + const handleRun = (): void => { + if (!dtNum || !segNum) return; + const freq_range: [number, number] | undefined = + fMin !== null && fMax !== null && fMax > fMin ? [fMin, fMax] : undefined; + void run(() => + timingApi.calculateTimeLags({ + event_list_1_name: eventList1, + event_list_2_name: eventList2, + dt: dtNum, + segment_size: segNum, + freq_range, + }) + ); + }; + + const traces: Data[] = result + ? [ + { + x: result.freq, + y: result.time_lags, + type: 'scattergl', + mode: 'lines+markers', + marker: { size: 4, color: '#00d4aa' }, + line: { color: '#00d4aa', width: 1 }, + error_y: result.time_lags_err + ? { type: 'data', array: result.time_lags_err, visible: true, color: 'rgba(0, 212, 170, 0.35)' } + : undefined, + } as Data, + ] + : []; + + return ( + + + + + + + Parameters + + + setDt(e.target.value)} + error={dt !== '' && dtNum === null} + helperText={dt !== '' && dtNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + setFreqMin(e.target.value)} + error={freqMinInvalid || freqRangePartial || freqRangeInverted} + helperText={freqHelperText(freqMin, fMin)} + /> + setFreqMax(e.target.value)} + error={freqMaxInvalid || freqRangePartial || freqRangeInverted} + helperText={freqHelperText(freqMax, fMax)} + /> + + + + + + + + + + + + + Result + + {result?.freq_range && ( + + )} + setLogX(e.target.checked)} />} + label="log f" + /> + + {error && ( + + {error} + + )} + {result ? ( + + ) : ( + + + Choose two event lists and compute their time lags. + + + )} + + + + + + ); +}; + +export default TimeLagsPage; diff --git a/src/pages/QuickLook/VariableEnergySpectrum/index.test.tsx b/src/pages/QuickLook/VariableEnergySpectrum/index.test.tsx new file mode 100644 index 0000000..60adb59 --- /dev/null +++ b/src/pages/QuickLook/VariableEnergySpectrum/index.test.tsx @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...a: unknown[]) => listEventLists(...a) }, +})); + +const variableEnergySpectrum = vi.fn(); +vi.mock('@/api/varenergyApi', () => ({ + varenergyApi: { + variableEnergySpectrum: (...a: unknown[]) => variableEnergySpectrum(...a), + }, +})); +type CapturedChartProps = { data: unknown[]; layout: { yaxis?: { type?: string } } }; +let plotlyChartCalls: CapturedChartProps[] = []; +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: (props: CapturedChartProps) => { + plotlyChartCalls.push(props); + return
; + }, +})); + +import VariableEnergySpectrumPage from './index'; + +describe('VariableEnergySpectrumPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockReset(); + variableEnergySpectrum.mockReset(); + plotlyChartCalls = []; + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 5000, time_range: [0, 100] }], + message: '', + error: null, + }); + variableEnergySpectrum.mockResolvedValue({ + success: true, + data: { + energy: [1, 2, 3, 4, 5], + // 0 is a legitimate CountSpectrum value for an empty energy band (not NaN/null), + // so a fix for finding 19 must render it rather than dropping it via a log axis. + counts: { spectrum: [100, 90, 0, 70, 60], error: [10, 9, 0, 7, 6] }, + rms: { spectrum: [0.1, 0.12, 0.15, 0.13, 0.11], error: [0.01, 0.01, 0.01, 0.01, 0.01] }, + lag: { spectrum: [0, 0.01, -0.01, 0.02, -0.02], error: [0.005, 0.005, 0.005, 0.005, 0.005] }, + freq_range: [0.1, 1], + ref_band: null, + norm: 'frac', + n_segments_hint: 4, + warnings: [], + }, + message: 'done', + error: null, + }); + }); + + it('computes the variable-energy spectrum with parsed parameters and plots three panels', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + + await waitFor(() => + expect(variableEnergySpectrum).toHaveBeenCalledWith( + expect.objectContaining({ + event_list_name: 'obs1', + bin_time: 0.1, + segment_size: 8, + freq_min: 0.1, + freq_max: 1, + energy_min: 0.5, + energy_max: 10, + n_bands: 5, + log_bands: false, + }) + ) + ); + + const charts = await screen.findAllByTestId('chart'); + expect(charts).toHaveLength(3); + expect(screen.getByText('The reference band affects only the lag panel.')).toBeInTheDocument(); + }); + + it('disables Compute until an event list is selected', async () => { + renderWithProviders(); + const button = await screen.findByRole('button', { name: /Compute/ }); + expect(button).toBeDisabled(); + }); + + it('renders the counts panel on a linear y-axis so a legitimate zero-count band is visible', async () => { + renderWithProviders(); + await userEvent.click(await screen.findByLabelText('Event list')); + await userEvent.click(await screen.findByText(/obs1/)); + await userEvent.click(screen.getByRole('button', { name: /Compute/ })); + + await waitFor(() => expect(plotlyChartCalls).toHaveLength(3)); + + // The counts panel is the first of the three PlotlyChart panels rendered. + const countsPanel = plotlyChartCalls[0]; + expect(countsPanel.layout.yaxis?.type).toBe('linear'); + const countsTrace = countsPanel.data[0] as { y: number[] }; + // The zero-count band must survive untouched (not filtered out, not turned into null). + expect(countsTrace.y).toEqual([100, 90, 0, 70, 60]); + }); +}); diff --git a/src/pages/QuickLook/VariableEnergySpectrum/index.tsx b/src/pages/QuickLook/VariableEnergySpectrum/index.tsx new file mode 100644 index 0000000..68b3e52 --- /dev/null +++ b/src/pages/QuickLook/VariableEnergySpectrum/index.tsx @@ -0,0 +1,407 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControlLabel, + Grid, + Stack, + Switch, + TextField, + Typography, + useTheme, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { varenergyApi, VariableEnergySpectrumData } from '@/api/varenergyApi'; +import { parsePositiveNumber } from '@/utils/numbers'; + +/** Shared helper text for a required min/max numeric pair. */ +const rangeHelperText = (raw: string, parsed: number | null, inverted: boolean): string => { + if (raw !== '' && parsed === null) return 'Must be a positive number'; + if (inverted) return 'max must be > min'; + return ' '; +}; + +const VariableEnergySpectrumPage: React.FC = () => { + const theme = useTheme(); + + const [eventList, setEventList] = useState(''); + const [binTime, setBinTime] = useState('0.1'); + const [segmentSize, setSegmentSize] = useState('8'); + const [freqMin, setFreqMin] = useState('0.1'); + const [freqMax, setFreqMax] = useState('1'); + const [energyMin, setEnergyMin] = useState('0.5'); + const [energyMax, setEnergyMax] = useState('10'); + const [nBands, setNBands] = useState('5'); + const [logBands, setLogBands] = useState(false); + const [refMin, setRefMin] = useState(''); + const [refMax, setRefMax] = useState(''); + const [logX, setLogX] = useState(false); + + const { result, running, error, run } = useAnalysisRunner( + 'Variable Energy Spectrum' + ); + + const binTimeNum = parsePositiveNumber(binTime); + const segNum = parsePositiveNumber(segmentSize); + + const freqMinNum = parsePositiveNumber(freqMin); + const freqMaxNum = parsePositiveNumber(freqMax); + const freqRangeInverted = + freqMinNum !== null && freqMaxNum !== null && freqMaxNum <= freqMinNum; + + const energyMinNum = parsePositiveNumber(energyMin); + const energyMaxNum = parsePositiveNumber(energyMax); + const energyRangeInverted = + energyMinNum !== null && energyMaxNum !== null && energyMaxNum <= energyMinNum; + + const nBandsNum = ((): number | null => { + const n = Number(nBands); + return Number.isInteger(n) && n >= 2 ? n : null; + })(); + const nBandsInvalid = nBands !== '' && nBandsNum === null; + + const refMinNum = parsePositiveNumber(refMin); + const refMaxNum = parsePositiveNumber(refMax); + const refPartial = (refMin !== '') !== (refMax !== ''); + const refInverted = refMinNum !== null && refMaxNum !== null && refMaxNum <= refMinNum; + const refValid = + !refPartial && + !(refMin !== '' && refMinNum === null) && + !(refMax !== '' && refMaxNum === null) && + !refInverted; + + const refHelperText = (raw: string, parsed: number | null): string => { + if (raw !== '' && parsed === null) return 'Must be a positive number'; + if (refPartial) return 'Fill both or leave both blank'; + if (refInverted) return 'max must be > min'; + return ' '; + }; + + const canRun = + eventList !== '' && + binTimeNum !== null && + segNum !== null && + freqMinNum !== null && + freqMaxNum !== null && + !freqRangeInverted && + energyMinNum !== null && + energyMaxNum !== null && + !energyRangeInverted && + nBandsNum !== null && + refValid && + !running; + + const handleRun = (): void => { + if ( + !binTimeNum || + !segNum || + !freqMinNum || + !freqMaxNum || + !energyMinNum || + !energyMaxNum || + !nBandsNum + ) { + return; + } + void run(() => + varenergyApi.variableEnergySpectrum({ + event_list_name: eventList, + bin_time: binTimeNum, + segment_size: segNum, + freq_min: freqMinNum, + freq_max: freqMaxNum, + energy_min: energyMinNum, + energy_max: energyMaxNum, + n_bands: nBandsNum, + log_bands: logBands, + ref_min: refMinNum ?? undefined, + ref_max: refMaxNum ?? undefined, + }) + ); + }; + + const energyAxis: { title: { text: string }; type: 'log' | 'linear' } = { + title: { text: 'Energy (keV)' }, + type: logX ? 'log' : 'linear', + }; + + const countsTraces: Data[] = result + ? [ + { + x: result.energy, + y: result.counts.spectrum, + type: 'scattergl', + mode: 'lines+markers', + error_y: { type: 'data', array: result.counts.error, visible: true }, + } as Data, + ] + : []; + + const rmsTraces: Data[] = result + ? [ + { + x: result.energy, + y: result.rms.spectrum, + type: 'scattergl', + mode: 'lines+markers', + error_y: { type: 'data', array: result.rms.error, visible: true }, + } as Data, + ] + : []; + + const lagTraces: Data[] = result + ? [ + { + x: result.energy, + y: result.lag.spectrum, + type: 'scattergl', + mode: 'lines+markers', + error_y: { type: 'data', array: result.lag.error, visible: true }, + } as Data, + ] + : []; + + return ( + + + + + + + Parameters + + setBinTime(e.target.value)} + error={binTime !== '' && binTimeNum === null} + helperText={binTime !== '' && binTimeNum === null ? 'Must be a positive number' : ' '} + /> + setSegmentSize(e.target.value)} + error={segmentSize !== '' && segNum === null} + helperText={segmentSize !== '' && segNum === null ? 'Must be a positive number' : ' '} + /> + + setFreqMin(e.target.value)} + error={(freqMin !== '' && freqMinNum === null) || freqRangeInverted} + helperText={rangeHelperText(freqMin, freqMinNum, freqRangeInverted)} + /> + setFreqMax(e.target.value)} + error={(freqMax !== '' && freqMaxNum === null) || freqRangeInverted} + helperText={rangeHelperText(freqMax, freqMaxNum, freqRangeInverted)} + /> + + Energy bands (keV) + + setEnergyMin(e.target.value)} + error={(energyMin !== '' && energyMinNum === null) || energyRangeInverted} + helperText={rangeHelperText(energyMin, energyMinNum, energyRangeInverted)} + /> + setEnergyMax(e.target.value)} + error={(energyMax !== '' && energyMaxNum === null) || energyRangeInverted} + helperText={rangeHelperText(energyMax, energyMaxNum, energyRangeInverted)} + /> + + setNBands(e.target.value)} + error={nBandsInvalid} + helperText={nBandsInvalid ? 'Must be an integer ≥ 2' : ' '} + /> + setLogBands(e.target.checked)} /> + } + label="Log-spaced energy bands" + /> + Reference band (optional) + + setRefMin(e.target.value)} + error={!refValid} + helperText={refHelperText(refMin, refMinNum)} + /> + setRefMax(e.target.value)} + error={!refValid} + helperText={refHelperText(refMax, refMaxNum)} + /> + + + + + + + + + + + + + Result + + {result?.freq_range && ( + + )} + {result && ( + + )} + {result?.ref_band && ( + + )} + setLogX(e.target.checked)} />} + label="log E" + /> + + {error && ( + + {error} + + )} + {result && result.warnings.length > 0 && ( + + + {result.warnings.map((w, i) => ( + + {w} + + ))} + + + )} + {result ? ( + + + + Counts vs energy + + + + + + Fractional rms vs energy + + + + + + Lag vs energy + + + + The reference band affects only the lag panel. + + + + ) : ( + + + Choose an event list and compute the variable-energy spectrum. + + + )} + + + + + + ); +}; + +export default VariableEnergySpectrumPage; diff --git a/src/pages/Simulator/index.tsx b/src/pages/Simulator/index.tsx new file mode 100644 index 0000000..c0fecad --- /dev/null +++ b/src/pages/Simulator/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import PageTemplate from '@/components/common/PageTemplate'; + +const SimulatorPage: React.FC = () => { + return ( + + ); +}; + +export default SimulatorPage; diff --git a/src/pages/Utilities/GTI/GtiCommon.test.ts b/src/pages/Utilities/GTI/GtiCommon.test.ts new file mode 100644 index 0000000..925be4c --- /dev/null +++ b/src/pages/Utilities/GTI/GtiCommon.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { finiteNumber, positiveNumber } from './GtiCommon'; + +describe('strict GTI scalar parsing', () => { + it('preserves signed decimal and exponent notation', () => { + expect(finiteNumber(' -1.25e2 ')).toBe(-125); + expect(positiveNumber('+2.5E-1')).toBe(0.25); + }); + + it('rejects JavaScript hexadecimal and binary notation', () => { + expect(finiteNumber('0x10')).toBeNull(); + expect(positiveNumber('0b10')).toBeNull(); + }); +}); diff --git a/src/pages/Utilities/GTI/GtiCommon.tsx b/src/pages/Utilities/GTI/GtiCommon.tsx new file mode 100644 index 0000000..a1deb13 --- /dev/null +++ b/src/pages/Utilities/GTI/GtiCommon.tsx @@ -0,0 +1,361 @@ +import React, { useId } from 'react'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; +import { + Alert, + Box, + Button, + CircularProgress, + FormControl, + FormHelperText, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import type { Data } from 'plotly.js'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import { + NumericResultTable, + UtilityWarnings, +} from '@/components/utilities/UtilityResult'; +import type { + GtiIntervalPayload, + GtiSetOperation, + GtiTimeReference, +} from '@/api/gtiApi'; +import { parseGtiRows } from '@/utils/utilityInputs'; +import { + parseNumber as parseFiniteDecimal, + parsePositiveNumber as parsePositiveFiniteDecimal, +} from '@/utils/numbers'; + +export interface ParsedGtis { + rows: [number, number][] | null; + error: string | null; +} + +export interface InspectedGtiRows { + rows: string; + sourceName: string; +} + +export const TIME_REFERENCE_LABELS: Record = { + absolute_mission_time: 'Absolute mission time', + relative_seconds: 'Relative seconds', +}; + +export const SET_OPERATION_LABELS: Record = { + intersection: 'Intersection — shared good time', + union: 'Union — coalesce overlap/touching', + append: 'Append — separate sets only', +}; + +export function parseStrictGtis(text: string, label = 'GTIs'): ParsedGtis { + const parsed = parseGtiRows(text); + if (!parsed.value) { + return { rows: null, error: `${label}: ${parsed.error ?? 'invalid rows'}` }; + } + for (let index = 0; index < parsed.value.length; index += 1) { + const [start, stop] = parsed.value[index]; + if (stop <= start) { + return { + rows: null, + error: `${label} row ${index + 1}: stop must be greater than start`, + }; + } + if (index > 0) { + const [previousStart, previousStop] = parsed.value[index - 1]; + if (start < previousStart) { + return { + rows: null, + error: `${label} row ${index + 1}: starts before row ${index}; preserve time order`, + }; + } + if (start < previousStop) { + return { + rows: null, + error: `${label} row ${index + 1}: overlaps row ${index}`, + }; + } + } + } + return { rows: parsed.value, error: null }; +} + +export function finiteNumber(text: string): number | null { + return parseFiniteDecimal(text); +} + +export function positiveNumber(text: string): number | null { + return parsePositiveFiniteDecimal(text); +} + +export function rowsToText(rows: Array<{ start: number; stop: number }>): string { + return rows.map((row) => `${row.start}, ${row.stop}`).join('\n'); +} + +function appendGtiRow(text: string): string { + if (text.trim() === '') return '0, 1'; + const parsed = parseGtiRows(text); + if (!parsed.value) return `${text.trimEnd()}\n0, 1`; + const previousStop = parsed.value[parsed.value.length - 1][1]; + const start = previousStop + 1; + return `${text.trimEnd()}\n${start}, ${start + 1}`; +} + +function removeLastGtiRow(text: string): string { + const lines = text.split(/\r?\n/).filter((line) => line.trim() !== ''); + lines.pop(); + return lines.join('\n'); +} + +export function formatMetric(value: number | null, suffix = ''): string { + if (value === null) return 'Unavailable'; + const formatted = value.toLocaleString(undefined, { + maximumSignificantDigits: 10, + useGrouping: false, + }); + return `${formatted}${suffix}`; +} + +export const TabPanel: React.FC<{ + active: number; + index: number; + children: React.ReactNode; +}> = ({ active, index, children }) => ( + +); + +export const RunButton: React.FC<{ + label: string; + running: boolean; + disabled: boolean; + onClick: () => void; + save?: boolean; +}> = ({ label, running, disabled, onClick, save = false }) => ( + +); + +export const GtiRowsField: React.FC<{ + label: string; + value: string; + onChange: (value: string) => void; + error: string | null; + editableRows?: boolean; + disabled?: boolean; +}> = ({ label, value, onChange, error, editableRows = false, disabled = false }) => ( + + onChange(event.target.value)} + multiline + minRows={4} + maxRows={12} + fullWidth + disabled={disabled} + error={value.trim() !== '' && error !== null} + helperText={ + value.trim() !== '' && error + ? error + : 'One start, stop pair per line. Rows remain in the order entered.' + } + inputProps={{ spellCheck: false }} + /> + {editableRows ? ( + + + + + ) : null} + +); + +export const MetricCard: React.FC<{ label: string; value: string }> = ({ label, value }) => ( + + + {label} + + + {value} + + +); + +export function intervalTrace(payload: GtiIntervalPayload, name: string): Data[] { + // Use the backend-sanitized duration values instead of subtracting extreme + // finite endpoints again in JavaScript, where the difference can overflow. + const lengths = payload.plot.interval_indices.map( + (intervalIndex) => payload.lengths_s[intervalIndex - 1] ?? null + ); + return [ + { + type: 'bar', + orientation: 'h', + x: lengths, + base: payload.plot.starts, + y: payload.plot.interval_indices, + name, + marker: { color: '#00a98f' }, + hovertemplate: 'start=%{base:.12g}s
length=%{x:.12g}s', + } as Data, + ]; +} + +export const IntervalResult: React.FC<{ + title: string; + payload: GtiIntervalPayload; + timeReference?: GtiTimeReference; +}> = ({ title, payload, timeReference }) => ( + + {timeReference ? ( + + Time reference: {TIME_REFERENCE_LABELS[timeReference]}. Values are reported in seconds and + are not shifted or reinterpreted by the renderer. + + ) : null} + + + + + + + + + + + + + + + {payload.intervals.length === 0 ? ( + No positive-duration intervals are present in this result. + ) : ( + <> + ({ ...interval }))} + /> + + + {title} — bounded plot preview + + {payload.plot.stride > 1 ? ( + + Showing every {payload.plot.stride.toLocaleString()}th interval from{' '} + {payload.plot.source_points.toLocaleString()} source intervals. The table above remains exact. + + ) : null} + + + + )} + +); + +export const RunnerError: React.FC<{ + error: string | null; + warnings?: string[]; +}> = ({ error, warnings = [] }) => + error || warnings.length ? ( + + {error ? {error} : null} + + + ) : null; + +export const TimeReferenceControl: React.FC<{ + value: GtiTimeReference; + onChange: (value: GtiTimeReference) => void; + disabled?: boolean; +}> = ({ value, onChange, disabled = false }) => { + const labelId = `gti-time-reference-${useId()}`; + return ( + + Time reference + + + {value === 'absolute_mission_time' + ? 'Seconds on the mission clock; interpret with MJDREF, not as UTC seconds.' + : 'Seconds from a user-chosen origin; values are labelled, not shifted.'} + + + ); +}; diff --git a/src/pages/Utilities/GTI/InspectionPanel.tsx b/src/pages/Utilities/GTI/InspectionPanel.tsx new file mode 100644 index 0000000..c10e6e5 --- /dev/null +++ b/src/pages/Utilities/GTI/InspectionPanel.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useState } from 'react'; +import { Alert, Grid, Stack } from '@mui/material'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import { ProvenancePanel, UtilityWarnings } from '@/components/utilities/UtilityResult'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { gtiApi, type GtiInspectionData } from '@/api/gtiApi'; +import { + formatMetric, + type InspectedGtiRows, + IntervalResult, + MetricCard, + rowsToText, + RunButton, + RunnerError, +} from './GtiCommon'; + +interface InspectionPanelProps { + onInspectedRowsChange: (rows: InspectedGtiRows | null) => void; +} + +const InspectionPanel: React.FC = ({ onInspectedRowsChange }) => { + const [eventListName, setEventListName] = useState(''); + const runner = useAnalysisRunner('Inspect GTIs'); + const onEventListChange = useCallback((name: string) => { + runner.reset(); + onInspectedRowsChange(null); + setEventListName(name); + }, [onInspectedRowsChange, runner]); + + const runInspection = (): void => { + if (eventListName === '') return; + const sourceName = eventListName; + void runner.run(async () => { + const response = await gtiApi.inspect({ event_list_name: sourceName }); + if (response.success && response.data) { + onInspectedRowsChange({ + rows: rowsToText(response.data.intervals), + sourceName, + }); + } + return response; + }); + }; + const result = runner.result?.event_list_name === eventListName ? runner.result : null; + + return ( + + + Inspection reports the EventList's effective .gti exactly as stored. It + does not infer good time from the first and last event, and mission-clock seconds must be + interpreted with MJDREF. + + + + + + + + + + + {result ? ( + + + {result.gti_status !== 'available' ? ( + + Effective GTI status: {result.gti_status}. No synthetic interval was + substituted. + + ) : null} + + + + + + + + + + + + ) : ( + Select a loaded EventList to inspect its effective GTIs. + )} + + ); +}; + +export default InspectionPanel; diff --git a/src/pages/Utilities/GTI/MaskSavePanel.tsx b/src/pages/Utilities/GTI/MaskSavePanel.tsx new file mode 100644 index 0000000..2865d7f --- /dev/null +++ b/src/pages/Utilities/GTI/MaskSavePanel.tsx @@ -0,0 +1,296 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Alert, Button, Card, CardContent, Grid, Paper, Stack, TextField, Typography } from '@mui/material'; +import type { Data } from 'plotly.js'; +import { useQueryClient } from '@tanstack/react-query'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import { + NumericResultTable, + ProvenancePanel, + UtilityWarnings, +} from '@/components/utilities/UtilityResult'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { EVENT_LISTS_QUERY_KEY, useEventLists } from '@/hooks/useEventLists'; +import { gtiApi, type GtiMaskPreviewData, type GtiMaskSaveData } from '@/api/gtiApi'; +import { validateDerivedName } from '@/utils/utilityInputs'; +import { + formatMetric, + GtiRowsField, + type InspectedGtiRows, + IntervalResult, + MetricCard, + parseStrictGtis, + RunButton, + RunnerError, +} from './GtiCommon'; + +interface MaskSavePanelProps { + inspectedRows: InspectedGtiRows | null; + rows: string; + onRowsChange: (rows: string) => void; +} + +const MaskSavePanel: React.FC = ({ inspectedRows, rows, onRowsChange }) => { + const queryClient = useQueryClient(); + const { data: eventLists } = useEventLists(); + const [eventListName, setEventListName] = useState(''); + const [destinationName, setDestinationName] = useState(''); + const [previewedSignature, setPreviewedSignature] = useState(null); + const parsed = useMemo(() => parseStrictGtis(rows, 'Mask GTIs'), [rows]); + const signature = `${eventListName}\u0000${rows}`; + const previewRunner = useAnalysisRunner('Preview GTI mask'); + const saveRunner = useAnalysisRunner('Save filtered EventList'); + const onEventListChange = useCallback((name: string) => setEventListName(name), []); + const destinationValidationError = + destinationName === '' ? null : validateDerivedName(destinationName); + const duplicateName = + destinationName !== '' && + (eventLists ?? []).some((eventList) => eventList.name === destinationName); + const previewIsCurrent = previewedSignature === signature; + const inspectedRowsMatchSource = + inspectedRows !== null && inspectedRows.sourceName === eventListName; + + const runPreview = (): void => { + if (eventListName === '' || !parsed.rows) return; + const requestSignature = signature; + void previewRunner.run(async () => { + const response = await gtiApi.previewMask({ + event_list_name: eventListName, + gtis: parsed.rows as [number, number][], + }); + if (response.success) setPreviewedSignature(requestSignature); + return response; + }); + }; + + const runSave = (): void => { + if ( + eventListName === '' || + !parsed.rows || + destinationName === '' || + destinationValidationError || + duplicateName || + !previewIsCurrent + ) { + return; + } + void saveRunner.run(async () => { + const response = await gtiApi.saveMask({ + event_list_name: eventListName, + gtis: parsed.rows as [number, number][], + destination_name: destinationName, + }); + if (response.success) { + await queryClient.invalidateQueries({ queryKey: EVENT_LISTS_QUERY_KEY }); + } + return response; + }); + }; + + const maskRowsForTable = useMemo( + () => + previewRunner.result + ? previewRunner.result.mask_preview.time.map((time, index) => ({ + index: index + 1, + time, + retained: previewRunner.result?.mask_preview.retained[index] ?? false, + })) + : [], + [previewRunner.result] + ); + + return ( + + + + + + Non-destructive GTI filter + + Requested rows are intersected with the source EventList's effective GTIs. + Mask rows are always absolute mission-clock seconds. Preview is read-only; Save as + creates a new EventList and never alters the source. + + + + {inspectedRows ? ( + + ) : null} + {inspectedRows && eventListName !== '' && !inspectedRowsMatchSource ? ( + + Those inspected GTIs belong to {inspectedRows.sourceName}, not {eventListName}. + Inspect {eventListName} before applying its effective GTIs. + + ) : null} + + setDestinationName(event.target.value)} + disabled={saveRunner.running} + error={!!destinationValidationError || duplicateName} + helperText={ + duplicateName + ? 'An EventList with this name already exists' + : destinationValidationError ?? + 'A unique name is required; the source is never overwritten.' + } + /> + {!previewIsCurrent && previewRunner.result ? ( + + Source or GTI rows changed after preview. Preview again before saving. + + ) : null} + + + {saveRunner.result ? ( + + + Saved {saveRunner.result.retained_event_count.toLocaleString()} events as{' '} + {saveRunner.result.destination_name}. + + + Time basis: {saveRunner.result.time_reference.replace(/_/g, ' ')} ({saveRunner.result.time_unit}). + + + + + ) : null} + + + + + + + + {previewRunner.result ? ( + <> + + + + + + + + + + + + + + + + {previewRunner.result.mask_preview.truncated ? ( + + Exact mask table is capped at{' '} + {previewRunner.result.mask_preview.shown.toLocaleString()} of{' '} + {previewRunner.result.mask_preview.total.toLocaleString()} events. + + ) : null} + + + + Bounded event-mask plot preview + + (value ? 1 : 0)), + marker: { + size: 6, + color: previewRunner.result.plot.retained.map((value) => + value ? '#00a98f' : '#d05a6e' + ), + }, + hovertemplate: 'time=%{x:.12g}s
retained=%{y}', + } as Data, + ]} + layout={{ + xaxis: { title: { text: 'Mission time (s)' } }, + yaxis: { + title: { text: 'Mask' }, + tickmode: 'array', + tickvals: [0, 1], + ticktext: ['Rejected', 'Retained'], + range: [-0.25, 1.25], + }, + }} + /> +
+ + + + ) : ( + Choose a source and valid rows, then preview the mask. + )} +
+
+
+ ); +}; + +export default MaskSavePanel; diff --git a/src/pages/Utilities/GTI/SegmentationPanel.tsx b/src/pages/Utilities/GTI/SegmentationPanel.tsx new file mode 100644 index 0000000..eafb128 --- /dev/null +++ b/src/pages/Utilities/GTI/SegmentationPanel.tsx @@ -0,0 +1,321 @@ +import React, { useMemo, useState } from 'react'; +import { Alert, Button, Card, CardContent, Grid, Paper, Stack, TextField, Typography } from '@mui/material'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import { + NumericResultTable, + ProvenancePanel, + UtilityWarnings, +} from '@/components/utilities/UtilityResult'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { + gtiApi, + type GtiExposureSegmentsData, + type GtiFixedSegmentsData, + type GtiIntervalPayload, + type GtiTimeReference, +} from '@/api/gtiApi'; +import { + formatMetric, + GtiRowsField, + type InspectedGtiRows, + intervalTrace, + IntervalResult, + MetricCard, + parseStrictGtis, + positiveNumber, + RunButton, + RunnerError, + TIME_REFERENCE_LABELS, + TimeReferenceControl, +} from './GtiCommon'; + +interface SegmentationPanelProps { + inspectedRows: InspectedGtiRows | null; + rows: string; + onRowsChange: (rows: string) => void; + timeReference: GtiTimeReference; + onTimeReferenceChange: (reference: GtiTimeReference) => void; +} + +const SegmentationPanel: React.FC = ({ + inspectedRows, + rows, + onRowsChange, + timeReference, + onTimeReferenceChange, +}) => { + const parsed = useMemo(() => parseStrictGtis(rows, 'Segment GTIs'), [rows]); + const [fixedSize, setFixedSize] = useState(''); + const fixedSizeNumber = positiveNumber(fixedSize); + const fixedMeaningError = useMemo(() => { + if (fixedSizeNumber === null || !parsed.rows) return null; + const hasUsableInterval = parsed.rows.some( + ([start, stop]) => stop - start >= fixedSizeNumber + ); + return hasUsableInterval ? null : 'Segment size exceeds every GTI length'; + }, [fixedSizeNumber, parsed.rows]); + const fixedRunner = useAnalysisRunner('Generate fixed segments'); + const [exposurePerChunk, setExposurePerChunk] = useState(''); + const [separationThreshold, setSeparationThreshold] = useState(''); + const exposureNumber = positiveNumber(exposurePerChunk); + const separationNumber = + separationThreshold.trim() === '' ? undefined : positiveNumber(separationThreshold); + const exposureRunner = useAnalysisRunner('Split GTIs by exposure'); + + const runFixedSegments = (): void => { + if (!parsed.rows || fixedSizeNumber === null || fixedMeaningError) return; + void fixedRunner.run(() => + gtiApi.fixedSegments({ + gtis: parsed.rows as [number, number][], + segment_size: fixedSizeNumber, + time_reference: timeReference, + }) + ); + }; + + const runExposureSegments = (): void => { + if ( + !parsed.rows || + exposureNumber === null || + (separationThreshold.trim() !== '' && separationNumber === null) + ) { + return; + } + void exposureRunner.run(() => + gtiApi.exposureSegments({ + gtis: parsed.rows as [number, number][], + exposure_per_chunk: exposureNumber, + time_reference: timeReference, + ...(typeof separationNumber === 'number' + ? { new_interval_if_gti_sep: separationNumber } + : {}), + }) + ); + }; + + const exposureRows = useMemo( + () => + exposureRunner.result + ? exposureRunner.result.chunks.flatMap((chunk) => + chunk.intervals.map((interval) => ({ + chunk: chunk.chunk_index, + interval: interval.index, + start: interval.start, + stop: interval.stop, + length_s: interval.length_s, + })) + ) + : [], + [exposureRunner.result] + ); + const exposurePlotPayload: GtiIntervalPayload | null = exposureRunner.result + ? { + intervals: [], + interval_count: exposureRunner.result.interval_count, + lengths_s: [], + separations_s: [], + total_exposure_s: exposureRunner.result.output_exposure_s, + overall_time_span_s: 0, + duty_cycle: null, + plot: { + starts: exposureRunner.result.plot.starts, + stops: exposureRunner.result.plot.stops, + interval_indices: exposureRunner.result.plot.chunk_indices, + stride: exposureRunner.result.plot.stride, + source_points: exposureRunner.result.plot.source_points, + }, + } + : null; + + const useInspectedRows = (): void => { + if (!inspectedRows) return; + onRowsChange(inspectedRows.rows); + onTimeReferenceChange('absolute_mission_time'); + }; + + return ( + + + {inspectedRows ? ( + + ) : null} + + + + + + + Fixed-duration intervals + + Generates only complete, non-overlapping segments fully contained in good time. + Short remainders are reported and omitted. + + setFixedSize(event.target.value)} + error={fixedSize !== '' && (fixedSizeNumber === null || !!fixedMeaningError)} + helperText={ + fixedSize !== '' && fixedSizeNumber === null + ? 'Must be a positive finite number' + : fixedMeaningError ?? ' ' + } + /> + + + + + + + + + + Approximate-exposure chunks + + Splits GTIs into chunk groups near the requested exposure while preserving GTI + boundaries. Chunk exposure is approximate, not guaranteed exact. + + setExposurePerChunk(event.target.value)} + error={exposurePerChunk !== '' && exposureNumber === null} + helperText={ + exposurePerChunk !== '' && exposureNumber === null + ? 'Must be a positive finite number' + : ' ' + } + /> + setSeparationThreshold(event.target.value)} + error={separationThreshold !== '' && separationNumber === null} + helperText={ + separationThreshold !== '' && separationNumber === null + ? 'Must be a positive finite number or blank' + : 'Optional; blank leaves Stingray grouping unchanged.' + } + /> + + + + + + + + {fixedRunner.result ? ( + + + + + + + + + + + + + + ) : null} + + {exposureRunner.result && exposurePlotPayload ? ( + + + Time reference: {TIME_REFERENCE_LABELS[exposureRunner.result.time_reference]}. Values + are reported in seconds without renderer-side shifting. + + + + + + + + + + + + + + Exposure chunks — bounded plot preview + + + + + + ) : null} + {!fixedRunner.result && !exposureRunner.result ? ( + Enter valid rows and choose a segmentation method. + ) : null} + + ); +}; + +export default SegmentationPanel; diff --git a/src/pages/Utilities/GTI/SetOperationsPanel.tsx b/src/pages/Utilities/GTI/SetOperationsPanel.tsx new file mode 100644 index 0000000..e4837cb --- /dev/null +++ b/src/pages/Utilities/GTI/SetOperationsPanel.tsx @@ -0,0 +1,269 @@ +import React, { useMemo, useState } from 'react'; +import { + Alert, + Button, + Card, + CardContent, + FormControl, + FormHelperText, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { ProvenancePanel, UtilityWarnings } from '@/components/utilities/UtilityResult'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { + gtiApi, + type GtiBadTimeData, + type GtiSetOperation, + type GtiSetOperationData, + type GtiTimeReference, +} from '@/api/gtiApi'; +import { + finiteNumber, + formatMetric, + GtiRowsField, + type InspectedGtiRows, + IntervalResult, + MetricCard, + parseStrictGtis, + RunButton, + RunnerError, + SET_OPERATION_LABELS, + TimeReferenceControl, +} from './GtiCommon'; + +interface SetOperationsPanelProps { + inspectedRows: InspectedGtiRows | null; + leftRows: string; + onLeftRowsChange: (rows: string) => void; + timeReference: GtiTimeReference; + onTimeReferenceChange: (reference: GtiTimeReference) => void; +} + +const SetOperationsPanel: React.FC = ({ + inspectedRows, + leftRows, + onLeftRowsChange, + timeReference, + onTimeReferenceChange, +}) => { + const [rightRows, setRightRows] = useState(''); + const [operation, setOperation] = useState('intersection'); + const leftParsed = useMemo(() => parseStrictGtis(leftRows, 'Left GTIs'), [leftRows]); + const rightParsed = useMemo(() => parseStrictGtis(rightRows, 'Right GTIs'), [rightRows]); + const setRunner = useAnalysisRunner('GTI set operation'); + const [observationStart, setObservationStart] = useState(''); + const [observationStop, setObservationStop] = useState(''); + const startNumber = finiteNumber(observationStart); + const stopNumber = finiteNumber(observationStop); + const badRangeError = + startNumber !== null && stopNumber !== null && stopNumber <= startNumber + ? 'Observation stop must be greater than observation start' + : null; + const badTimeRunner = useAnalysisRunner('Generate bad-time intervals'); + + const runSetOperation = (): void => { + if (!leftParsed.rows || !rightParsed.rows) return; + void setRunner.run(() => + gtiApi.setOperation({ + left_gtis: leftParsed.rows as [number, number][], + right_gtis: rightParsed.rows as [number, number][], + operation, + time_reference: timeReference, + }) + ); + }; + + const runBadTimes = (): void => { + if (!leftParsed.rows || startNumber === null || stopNumber === null || badRangeError) return; + void badTimeRunner.run(() => + gtiApi.badTimeIntervals({ + gtis: leftParsed.rows as [number, number][], + start_time: startNumber, + stop_time: stopNumber, + time_reference: timeReference, + }) + ); + }; + + const useInspectedRows = (): void => { + if (!inspectedRows) return; + onLeftRowsChange(inspectedRows.rows); + onTimeReferenceChange('absolute_mission_time'); + }; + + return ( + + + + + + + + + + {inspectedRows ? ( + + ) : null} + + + + + + Set operation + + + {operation === 'intersection' + ? 'Returns only time accepted by both sets.' + : operation === 'union' + ? 'Coalesces overlapping and touching intervals explicitly.' + : 'Requires mutually exclusive inputs; use union for overlaps.'} + + + + + + + + + + + + + + {setRunner.result ? ( + + Merge strategy: {setRunner.result.merge_strategy} + + + + + ) : null} + + + + + Bad-time interval complement + + Treat the left rows as good time and return every gap inside an explicit observation + range. + + + + setObservationStart(event.target.value)} + error={observationStart !== '' && startNumber === null} + helperText={observationStart !== '' && startNumber === null ? 'Must be finite' : ' '} + /> + + + setObservationStop(event.target.value)} + error={observationStop !== '' && (stopNumber === null || !!badRangeError)} + helperText={ + observationStop !== '' && stopNumber === null + ? 'Must be finite' + : badRangeError ?? ' ' + } + /> + + + + + + + + + + {badTimeRunner.result ? ( + + + + + + + + + + + + + + ) : null} + + ); +}; + +export default SetOperationsPanel; diff --git a/src/pages/Utilities/GTI/ValidationPanel.tsx b/src/pages/Utilities/GTI/ValidationPanel.tsx new file mode 100644 index 0000000..f33a9fc --- /dev/null +++ b/src/pages/Utilities/GTI/ValidationPanel.tsx @@ -0,0 +1,137 @@ +import React, { useMemo, useState } from 'react'; +import { Alert, Button, Card, CardContent, Grid, Stack, Typography } from '@mui/material'; +import { ProvenancePanel, UtilityWarnings } from '@/components/utilities/UtilityResult'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { gtiApi, type GtiTimeReference, type GtiValidationData } from '@/api/gtiApi'; +import { + GtiRowsField, + type InspectedGtiRows, + IntervalResult, + parseStrictGtis, + RunButton, + RunnerError, + TimeReferenceControl, +} from './GtiCommon'; + +interface ValidationPanelProps { + inspectedRows: InspectedGtiRows | null; + onUseValidatedRows: (rows: string, reference: GtiTimeReference) => void; +} + +const ValidationPanel: React.FC = ({ + inspectedRows, + onUseValidatedRows, +}) => { + const [rows, setRows] = useState(''); + const [timeReference, setTimeReference] = + useState('absolute_mission_time'); + const [validatedSignature, setValidatedSignature] = useState(null); + const parsed = useMemo(() => parseStrictGtis(rows), [rows]); + const runner = useAnalysisRunner('Validate GTIs'); + const signature = `${timeReference}\u0000${rows}`; + const resultIsCurrent = validatedSignature === signature; + + const runValidation = (): void => { + if (!parsed.rows) return; + const requestSignature = signature; + void runner.run(async () => { + const response = await gtiApi.validate({ + gtis: parsed.rows as [number, number][], + time_reference: timeReference, + }); + if (response.success) setValidatedSignature(requestSignature); + return response; + }); + }; + + const useInspectedRows = (): void => { + if (!inspectedRows) return; + setRows(inspectedRows.rows); + setTimeReference('absolute_mission_time'); + }; + + return ( + + + + + + Manual GTI rows + + Paste or edit ordered start/stop pairs. Validation never silently sorts, merges, + or changes an interval. + + + {inspectedRows ? ( + + ) : null} + + + + + + + + + + {runner.result ? ( + <> + {!resultIsCurrent ? ( + + These validation results belong to the previous row content or time reference. + Validate again before reusing the rows. + + ) : ( + + All rows are finite, ordered, positive in length, and non-overlapping. + + )} + + + + {resultIsCurrent && timeReference === 'relative_seconds' ? ( + + Relative rows are copied to set operations and segmentation, but not to the + absolute-mission-time EventList mask. + + ) : null} + + + ) : ( + Enter rows and run the explicit validation step. + )} + + + + ); +}; + +export default ValidationPanel; diff --git a/src/pages/Utilities/GTI/index.test.tsx b/src/pages/Utilities/GTI/index.test.tsx new file mode 100644 index 0000000..bda5428 --- /dev/null +++ b/src/pages/Utilities/GTI/index.test.tsx @@ -0,0 +1,652 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...args: unknown[]) => listEventLists(...args) }, +})); + +const inspect = vi.fn(); +const validate = vi.fn(); +const setOperation = vi.fn(); +const badTimeIntervals = vi.fn(); +const previewMask = vi.fn(); +const saveMask = vi.fn(); +const fixedSegments = vi.fn(); +const exposureSegments = vi.fn(); +vi.mock('@/api/gtiApi', () => ({ + gtiApi: { + inspect: (...args: unknown[]) => inspect(...args), + validate: (...args: unknown[]) => validate(...args), + setOperation: (...args: unknown[]) => setOperation(...args), + badTimeIntervals: (...args: unknown[]) => badTimeIntervals(...args), + previewMask: (...args: unknown[]) => previewMask(...args), + saveMask: (...args: unknown[]) => saveMask(...args), + fixedSegments: (...args: unknown[]) => fixedSegments(...args), + exposureSegments: (...args: unknown[]) => exposureSegments(...args), + }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: ({ data }: { data: unknown }) =>
{JSON.stringify(data)}
, +})); + +import GTIPage from './index'; +import { intervalTrace } from './GtiCommon'; + +type TestInterval = { index: number; start: number; stop: number; length_s: number }; + +function intervalPayload( + intervals: TestInterval[] = [ + { index: 1, start: 0, stop: 10, length_s: 10 }, + { index: 2, start: 12, stop: 20, length_s: 8 }, + ], + plotIntervals: TestInterval[] = intervals +) { + const total = intervals.reduce((sum, row) => sum + row.length_s, 0); + const span = intervals.length > 0 ? intervals[intervals.length - 1].stop - intervals[0].start : 0; + return { + intervals, + interval_count: intervals.length, + lengths_s: intervals.map((row) => row.length_s), + separations_s: intervals.slice(1).map((row, index) => row.start - intervals[index].stop), + total_exposure_s: total, + overall_time_span_s: span, + duty_cycle: span > 0 ? total / span : null, + plot: { + starts: plotIntervals.map((row) => row.start), + stops: plotIntervals.map((row) => row.stop), + interval_indices: plotIntervals.map((row) => row.index), + stride: plotIntervals.length === intervals.length ? 1 : 2, + source_points: intervals.length, + }, + }; +} + +function response(data: T, message = 'done') { + return { success: true, data, message, error: null }; +} + +it('uses sanitized durations for plots instead of overflowing endpoint subtraction', () => { + const payload = { + intervals: [{ index: 1, start: -1e308, stop: 1e308, length_s: null }], + interval_count: 1, + lengths_s: [null], + separations_s: [], + total_exposure_s: null, + overall_time_span_s: null, + duty_cycle: null, + plot: { + starts: [-1e308], + stops: [1e308], + interval_indices: [1], + stride: 1, + source_points: 1, + }, + }; + + const trace = intervalTrace(payload, 'Extreme GTI')[0] as { x: unknown[] }; + + expect(trace.x).toEqual([null]); + expect(JSON.stringify(trace)).not.toContain('Infinity'); +}); + +const provenance = { operation: 'test' }; + +async function selectEventList(label: string, option: RegExp): Promise { + const panel = screen.getByRole('tabpanel'); + await userEvent.click(await within(panel).findByLabelText(label)); + await userEvent.click(await screen.findByRole('option', { name: option })); +} + +async function chooseSelect(label: string, option: RegExp): Promise { + const panel = screen.getByRole('tabpanel'); + await userEvent.click(within(panel).getByLabelText(label)); + await userEvent.click(await screen.findByRole('option', { name: option })); +} + +async function fill(label: string, value: string): Promise { + const field = within(screen.getByRole('tabpanel')).getByLabelText(label); + await userEvent.clear(field); + await userEvent.type(field, value); +} + +describe('GTIPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + for (const mock of [ + listEventLists, + inspect, + validate, + setOperation, + badTimeIntervals, + previewMask, + saveMask, + fixedSegments, + exposureSegments, + ]) { + mock.mockReset(); + } + listEventLists.mockResolvedValue( + response([ + { name: 'obs1', n_events: 4, time_range: [0, 20] }, + { name: 'existing', n_events: 2, time_range: [1, 2] }, + ]) + ); + const common = intervalPayload(); + inspect.mockResolvedValue( + response({ + ...common, + event_list_name: 'obs1', + event_count: 4, + gti_status: 'available', + gti_origin: 'effective_event_list_gti', + time_unit: 's', + time_reference: 'absolute_mission_time', + mjdref: 59000, + warnings: [], + provenance, + }) + ); + validate.mockResolvedValue( + response({ + ...common, + valid: true, + time_unit: 's', + time_reference: 'relative_seconds', + warnings: [], + provenance, + }) + ); + setOperation.mockResolvedValue( + response({ + ...common, + operation: 'union', + merge_strategy: 'union with touching intervals coalesced', + time_unit: 's', + time_reference: 'relative_seconds', + warnings: ['Touching boundaries were coalesced.'], + provenance, + }) + ); + badTimeIntervals.mockResolvedValue( + response({ + ...intervalPayload([{ index: 1, start: 10, stop: 12, length_s: 2 }]), + observation_start: 0, + observation_stop: 20, + good_exposure_s: 18, + bad_exposure_s: 2, + time_unit: 's', + time_reference: 'relative_seconds', + warnings: [], + provenance, + }) + ); + previewMask.mockResolvedValue( + response({ + event_list_name: 'obs1', + source_event_count: 4, + retained_event_count: 3, + rejected_event_count: 1, + retained_exposure_s: 10, + time_unit: 's', + time_reference: 'absolute_mission_time', + applied_gtis: intervalPayload([{ index: 1, start: 0, stop: 10, length_s: 10 }]), + mask_preview: { + time: [1, 2, 3, 4], + retained: [true, false, true, true], + shown: 4, + total: 4, + truncated: false, + }, + plot: { time: [1, 4], retained: [1, 1], stride: 2, source_points: 4 }, + warnings: ['Requested intervals were clipped to effective GTIs.'], + provenance, + }) + ); + saveMask.mockResolvedValue( + response({ + source_event_list_name: 'obs1', + destination_name: 'derived', + time_unit: 's', + time_reference: 'absolute_mission_time', + source_event_count: 4, + retained_event_count: 3, + rejected_event_count: 1, + retained_exposure_s: 10, + applied_gtis: intervalPayload([{ index: 1, start: 0, stop: 10, length_s: 10 }]), + warnings: [], + provenance, + }) + ); + fixedSegments.mockResolvedValue( + response({ + ...intervalPayload([ + { index: 1, start: 0, stop: 5, length_s: 5 }, + { index: 2, start: 5, stop: 10, length_s: 5 }, + ]), + segment_size_s: 5, + source_exposure_s: 25, + segmented_exposure_s: 20, + unused_exposure_s: 5, + time_unit: 's', + time_reference: 'relative_seconds', + warnings: ['5 s of remainder was omitted.'], + provenance, + }) + ); + exposureSegments.mockResolvedValue( + response({ + exposure_per_chunk_s: 8, + new_interval_if_gti_sep_s: 2, + source_exposure_s: 25, + output_exposure_s: 25, + chunk_count: 2, + interval_count: 2, + chunks: [ + { + chunk_index: 1, + ...intervalPayload([{ index: 1, start: 0, stop: 10, length_s: 10 }]), + }, + { + chunk_index: 2, + ...intervalPayload([{ index: 1, start: 20, stop: 35, length_s: 15 }]), + }, + ], + plot: { + starts: [0, 20], + stops: [10, 35], + chunk_indices: [1, 2], + stride: 1, + source_points: 2, + }, + time_unit: 's', + time_reference: 'relative_seconds', + warnings: ['Exposure splitting is approximate.'], + provenance, + }) + ); + }); + + it('is ready, explains effective GTIs, and handles an empty EventList registry', async () => { + listEventLists.mockResolvedValue(response([])); + renderWithProviders(); + + expect(screen.getByRole('heading', { name: 'GTI Functionality' })).toBeInTheDocument(); + expect(screen.queryByText(/coming soon/i)).not.toBeInTheDocument(); + expect(screen.getByText(/does not infer good time from the first and last event/i)).toBeInTheDocument(); + expect((await screen.findAllByText(/No event lists loaded/)).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: 'Inspect effective GTIs' })).toBeDisabled(); + expect(inspect).not.toHaveBeenCalled(); + }); + + it('rejects malformed, overlapping, and non-positive manual rows before submission', async () => { + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Edit & validate' })); + + await fill('Manual GTIs', '0, 10\n9, 12'); + expect(screen.getByText(/row 2: overlaps row 1/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Validate rows' })).toBeDisabled(); + + await fill('Manual GTIs', '4, 4'); + expect(screen.getByText(/stop must be greater than start/)).toBeInTheDocument(); + expect(validate).not.toHaveBeenCalled(); + }); + + it('sends exact validation, set-operation, and BTI payloads with time-reference metadata', async () => { + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Edit & validate' })); + await fill('Manual GTIs', '0, 10\n12, 20'); + await chooseSelect('Time reference', /Relative seconds/); + await userEvent.click(screen.getByRole('button', { name: 'Validate rows' })); + + await waitFor(() => + expect(validate).toHaveBeenCalledWith({ + gtis: [ + [0, 10], + [12, 20], + ], + time_reference: 'relative_seconds', + }) + ); + expect(await screen.findByText(/All rows are finite/)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('tab', { name: 'Set operations & BTIs' })); + await fill('Left / good GTIs', '0, 10\n12, 20'); + await fill('Right GTIs', '5, 8\n20, 25'); + await chooseSelect('Set operation', /Union/); + await chooseSelect('Time reference', /Relative seconds/); + await userEvent.click(screen.getByRole('button', { name: 'Compute set result' })); + + await waitFor(() => + expect(setOperation).toHaveBeenCalledWith({ + left_gtis: [ + [0, 10], + [12, 20], + ], + right_gtis: [ + [5, 8], + [20, 25], + ], + operation: 'union', + time_reference: 'relative_seconds', + }) + ); + expect(await screen.findByText('Touching boundaries were coalesced.')).toBeInTheDocument(); + + await fill('Observation start (s)', '0'); + await fill('Observation stop (s)', '20'); + await userEvent.click(screen.getByRole('button', { name: 'Generate BTIs' })); + await waitFor(() => + expect(badTimeIntervals).toHaveBeenCalledWith({ + gtis: [ + [0, 10], + [12, 20], + ], + start_time: 0, + stop_time: 20, + time_reference: 'relative_seconds', + }) + ); + }, 10_000); + + it('keeps exact inspection rows and the previous result after a later error', async () => { + const exactRows = [ + { index: 1, start: 0, stop: 10, length_s: 10 }, + { index: 2, start: 12, stop: 20, length_s: 8 }, + ]; + inspect + .mockResolvedValueOnce( + response({ + ...intervalPayload(exactRows, [exactRows[0]]), + event_list_name: 'obs1', + event_count: 4, + gti_status: 'available', + gti_origin: 'effective_event_list_gti', + time_unit: 's', + time_reference: 'absolute_mission_time', + mjdref: 59000, + warnings: ['Inspection warning'], + provenance, + }) + ) + .mockResolvedValueOnce({ + success: false, + data: null, + message: 'could not inspect', + error: 'backend exploded', + }); + renderWithProviders(); + await selectEventList('Event list to inspect', /obs1/); + const button = screen.getByRole('button', { name: 'Inspect effective GTIs' }); + await userEvent.click(button); + + await waitFor(() => expect(inspect).toHaveBeenCalledWith({ event_list_name: 'obs1' })); + + const table = await screen.findByRole('table', { name: 'Effective GTIs — exact intervals' }); + expect(within(table).getByText('12')).toBeInTheDocument(); + expect(screen.getByText('Inspection warning')).toBeInTheDocument(); + expect(screen.getByTestId('chart')).toHaveTextContent('"base":[0]'); + expect(screen.getByTestId('chart')).not.toHaveTextContent('"base":[0,12]'); + + await userEvent.click(button); + expect(await screen.findByText('backend exploded')).toBeInTheDocument(); + expect(screen.getByRole('table', { name: 'Effective GTIs — exact intervals' })).toBeInTheDocument(); + expect(screen.getByText('Inspection warning')).toBeInTheDocument(); + }); + + it('preserves inspected and validated row handoffs across the extracted workflows', async () => { + renderWithProviders(); + await selectEventList('Event list to inspect', /obs1/); + await userEvent.click(screen.getByRole('button', { name: 'Inspect effective GTIs' })); + await screen.findByRole('table', { name: 'Effective GTIs — exact intervals' }); + + await userEvent.click(screen.getByRole('tab', { name: 'Edit & validate' })); + await userEvent.click( + screen.getByRole('button', { name: 'Use inspected effective GTIs from obs1' }) + ); + expect(within(screen.getByRole('tabpanel')).getByLabelText('Manual GTIs')).toHaveValue( + '0, 10\n12, 20' + ); + await chooseSelect('Time reference', /Relative seconds/); + await userEvent.click(screen.getByRole('button', { name: 'Validate rows' })); + await screen.findByText(/All rows are finite/); + await userEvent.click( + screen.getByRole('button', { name: 'Use validated rows in compatible tools' }) + ); + + await userEvent.click(screen.getByRole('tab', { name: 'Set operations & BTIs' })); + expect(within(screen.getByRole('tabpanel')).getByLabelText('Left / good GTIs')).toHaveValue( + '0, 10\n12, 20' + ); + expect(within(screen.getByRole('tabpanel')).getByLabelText('Time reference')).toHaveTextContent( + 'Relative seconds' + ); + + await userEvent.click(screen.getByRole('tab', { name: 'Segmentation' })); + expect(within(screen.getByRole('tabpanel')).getByLabelText('GTIs to segment')).toHaveValue( + '0, 10\n12, 20' + ); + + await userEvent.click(screen.getByRole('tab', { name: 'Mask & save' })); + expect(within(screen.getByRole('tabpanel')).getByLabelText('Requested mask GTIs')).toHaveValue(''); + expect(screen.getByText(/Relative rows are copied to set operations and segmentation/)).not.toBeVisible(); + }); + + it('never applies inspected GTIs to a different EventList source', async () => { + renderWithProviders(); + await selectEventList('Event list to inspect', /obs1/); + await userEvent.click(screen.getByRole('button', { name: 'Inspect effective GTIs' })); + await screen.findByRole('table', { name: 'Effective GTIs — exact intervals' }); + + await userEvent.click(screen.getByRole('tab', { name: 'Mask & save' })); + await selectEventList('Source event list', /existing/); + + const reuseButton = screen.getByRole('button', { + name: 'Use inspected effective GTIs from obs1', + }); + expect(reuseButton).toBeDisabled(); + expect(screen.getByText(/belong to obs1, not existing/)).toBeInTheDocument(); + expect(within(screen.getByRole('tabpanel')).getByLabelText('Requested mask GTIs')).toHaveValue( + '' + ); + }); + + it('previews exact masks, blocks known duplicate names, surfaces a race error, then saves uniquely', async () => { + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Mask & save' })); + await selectEventList('Source event list', /obs1/); + await fill('Requested mask GTIs', '0, 10'); + await userEvent.click(screen.getByRole('button', { name: 'Preview mask' })); + + await waitFor(() => + expect(previewMask).toHaveBeenCalledWith({ event_list_name: 'obs1', gtis: [[0, 10]] }) + ); + const maskTable = await screen.findByRole('table', { name: 'Exact mask preview' }); + expect(within(maskTable).getByText('false')).toBeInTheDocument(); + expect(screen.getByText('Requested intervals were clipped to effective GTIs.')).toBeInTheDocument(); + expect(screen.getAllByTestId('chart')[0]).toHaveTextContent('"x":[1,4]'); + + await fill('Save as EventList name', 'existing'); + expect(screen.getByText('An EventList with this name already exists')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save as new EventList' })).toBeDisabled(); + + saveMask.mockResolvedValueOnce({ + success: false, + data: null, + message: "EventList 'race-name' already exists; choose a unique name", + error: null, + }); + await fill('Save as EventList name', 'race-name'); + await userEvent.click(screen.getByRole('button', { name: 'Save as new EventList' })); + expect(await screen.findByText(/race-name.*already exists/)).toBeInTheDocument(); + expect(screen.getByRole('table', { name: 'Exact mask preview' })).toBeInTheDocument(); + + await fill('Save as EventList name', 'derived'); + await userEvent.click(screen.getByRole('button', { name: 'Save as new EventList' })); + await waitFor(() => + expect(saveMask).toHaveBeenLastCalledWith({ + event_list_name: 'obs1', + gtis: [[0, 10]], + destination_name: 'derived', + }) + ); + expect(await screen.findByText(/Saved 3 events as/)).toBeInTheDocument(); + expect(screen.getByText('Time basis: absolute mission time (s).')).toBeInTheDocument(); + await waitFor(() => expect(listEventLists.mock.calls.length).toBeGreaterThan(1)); + }, 10_000); + + it('invalidates a stale mask preview when the rows change', async () => { + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Mask & save' })); + await selectEventList('Source event list', /obs1/); + await fill('Requested mask GTIs', '0, 10'); + await userEvent.click(screen.getByRole('button', { name: 'Preview mask' })); + await screen.findByRole('table', { name: 'Exact mask preview' }); + await fill('Save as EventList name', 'derived'); + expect(screen.getByRole('button', { name: 'Save as new EventList' })).toBeEnabled(); + + await fill('Requested mask GTIs', '0, 8'); + expect(screen.getByText(/Preview again before saving/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save as new EventList' })).toBeDisabled(); + expect(saveMask).not.toHaveBeenCalled(); + }); + + it('validates segment size and sends exact fixed/exposure payloads with tables and plots', async () => { + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Segmentation' })); + await fill('GTIs to segment', '0, 10\n20, 35'); + await chooseSelect('Time reference', /Relative seconds/); + + await fill('Segment size (s)', '100'); + expect(screen.getByText('Segment size exceeds every GTI length')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Generate fixed segments' })).toBeDisabled(); + await fill('Segment size (s)', '5'); + await userEvent.click(screen.getByRole('button', { name: 'Generate fixed segments' })); + await waitFor(() => + expect(fixedSegments).toHaveBeenCalledWith({ + gtis: [ + [0, 10], + [20, 35], + ], + segment_size: 5, + time_reference: 'relative_seconds', + }) + ); + expect(await screen.findByText('5 s of remainder was omitted.')).toBeInTheDocument(); + expect(screen.getByRole('table', { name: 'Fixed segments — exact intervals' })).toBeInTheDocument(); + + await fill('Exposure per chunk (s)', '8'); + await fill('Start new chunk when GTI gap exceeds (s)', '2'); + await userEvent.click(screen.getByRole('button', { name: 'Split by exposure' })); + await waitFor(() => + expect(exposureSegments).toHaveBeenCalledWith({ + gtis: [ + [0, 10], + [20, 35], + ], + exposure_per_chunk: 8, + new_interval_if_gti_sep: 2, + time_reference: 'relative_seconds', + }) + ); + expect(await screen.findByText('Exposure splitting is approximate.')).toBeInTheDocument(); + expect( + screen.getByRole('table', { name: 'Exposure chunks — exact intervals' }) + ).toBeInTheDocument(); + expect(screen.getAllByTestId('chart').length).toBeGreaterThanOrEqual(2); + }); +}); + +describe('gtiApi route boundary', () => { + it('uses the explicit utility endpoints and forwards only typed request fields', async () => { + const [{ gtiApi: actualGtiApi }, { apiClient }] = await Promise.all([ + vi.importActual('@/api/gtiApi'), + import('@/api/client'), + ]); + const post = vi.spyOn(apiClient, 'post').mockImplementation(async () => ({ + success: true, + data: null, + message: '', + error: null, + })); + const rows: [number, number][] = [[0, 10]]; + + await actualGtiApi.inspect({ event_list_name: 'obs1' }); + await actualGtiApi.validate({ gtis: rows, time_reference: 'relative_seconds' }); + await actualGtiApi.setOperation({ + left_gtis: rows, + right_gtis: [[20, 30]], + operation: 'append', + time_reference: 'relative_seconds', + }); + await actualGtiApi.badTimeIntervals({ + gtis: rows, + start_time: 0, + stop_time: 20, + time_reference: 'relative_seconds', + }); + await actualGtiApi.previewMask({ event_list_name: 'obs1', gtis: rows }); + await actualGtiApi.saveMask({ + event_list_name: 'obs1', + gtis: rows, + destination_name: 'derived', + }); + await actualGtiApi.fixedSegments({ + gtis: rows, + segment_size: 5, + time_reference: 'relative_seconds', + }); + await actualGtiApi.exposureSegments({ + gtis: rows, + exposure_per_chunk: 8, + new_interval_if_gti_sep: 2, + time_reference: 'relative_seconds', + }); + + expect(post.mock.calls).toEqual([ + ['/api/utilities/gti/inspect', { event_list_name: 'obs1' }], + ['/api/utilities/gti/validate', { gtis: rows, time_reference: 'relative_seconds' }], + [ + '/api/utilities/gti/set-operation', + { + left_gtis: rows, + right_gtis: [[20, 30]], + operation: 'append', + time_reference: 'relative_seconds', + }, + ], + [ + '/api/utilities/gti/bad-time-intervals', + { + gtis: rows, + start_time: 0, + stop_time: 20, + time_reference: 'relative_seconds', + }, + ], + ['/api/utilities/gti/mask/preview', { event_list_name: 'obs1', gtis: rows }], + [ + '/api/utilities/gti/mask/save', + { event_list_name: 'obs1', gtis: rows, destination_name: 'derived' }, + ], + [ + '/api/utilities/gti/segment/fixed', + { gtis: rows, segment_size: 5, time_reference: 'relative_seconds' }, + ], + [ + '/api/utilities/gti/segment/exposure', + { + gtis: rows, + exposure_per_chunk: 8, + time_reference: 'relative_seconds', + new_interval_if_gti_sep: 2, + }, + ], + ]); + post.mockRestore(); + }); +}); diff --git a/src/pages/Utilities/GTI/index.tsx b/src/pages/Utilities/GTI/index.tsx new file mode 100644 index 0000000..f08edc6 --- /dev/null +++ b/src/pages/Utilities/GTI/index.tsx @@ -0,0 +1,109 @@ +import React, { useCallback, useState } from 'react'; +import { Box, Paper, Tab, Tabs } from '@mui/material'; +import PageTemplate from '@/components/common/PageTemplate'; +import type { GtiTimeReference } from '@/api/gtiApi'; +import { TabPanel, type InspectedGtiRows } from './GtiCommon'; +import InspectionPanel from './InspectionPanel'; +import MaskSavePanel from './MaskSavePanel'; +import SegmentationPanel from './SegmentationPanel'; +import SetOperationsPanel from './SetOperationsPanel'; +import ValidationPanel from './ValidationPanel'; + +const TAB_LABELS = [ + 'Inspection', + 'Edit & validate', + 'Set operations & BTIs', + 'Mask & save', + 'Segmentation', +] as const; + +const GTIPage: React.FC = () => { + const [tab, setTab] = useState(0); + const [inspectedRows, setInspectedRows] = useState(null); + const [setRows, setSetRows] = useState(''); + const [setTimeReference, setSetTimeReference] = + useState('absolute_mission_time'); + const [maskRows, setMaskRows] = useState(''); + const [segmentRows, setSegmentRows] = useState(''); + const [segmentTimeReference, setSegmentTimeReference] = + useState('absolute_mission_time'); + + const useValidatedRows = useCallback( + (rows: string, reference: GtiTimeReference): void => { + setSetRows(rows); + setSetTimeReference(reference); + setSegmentRows(rows); + setSegmentTimeReference(reference); + if (reference === 'absolute_mission_time') setMaskRows(rows); + }, + [] + ); + + return ( + + + setTab(next)} + variant="scrollable" + scrollButtons="auto" + aria-label="GTI workbench tools" + sx={{ borderBottom: 1, borderColor: 'divider', px: 1 }} + > + {TAB_LABELS.map((label, index) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default GTIPage; diff --git a/src/pages/Utilities/IO/index.test.tsx b/src/pages/Utilities/IO/index.test.tsx new file mode 100644 index 0000000..ea1e206 --- /dev/null +++ b/src/pages/Utilities/IO/index.test.tsx @@ -0,0 +1,872 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; +import type { + ExportableObjectsResult, + FileInspectionResult, + RmfInspectionResult, +} from '@/api/ioApi'; + +const inspectFile = vi.fn(); +const inspectRmf = vi.fn(); +const convertPi = vi.fn(); +const convertEventList = vi.fn(); +const listExportableObjects = vi.fn(); +const exportObject = vi.fn(); +const listEventLists = vi.fn(); + +vi.mock('@/api/ioApi', () => ({ + ioApi: { + inspectFile: (...args: unknown[]) => inspectFile(...args), + inspectRmf: (...args: unknown[]) => inspectRmf(...args), + convertPi: (...args: unknown[]) => convertPi(...args), + convertEventList: (...args: unknown[]) => convertEventList(...args), + listExportableObjects: (...args: unknown[]) => listExportableObjects(...args), + exportObject: (...args: unknown[]) => exportObject(...args), + }, +})); + +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...args: unknown[]) => listEventLists(...args) }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +import IOPage from './index'; + +const CAPABILITIES: ExportableObjectsResult['capability_matrix'] = { + event_list: { + csv: { supported: true, notes: 'Tabular only' }, + ecsv: { supported: true, notes: 'Metadata' }, + json: { supported: true, notes: 'Envelope' }, + fits: { supported: true, notes: 'Generic FITS with GTI' }, + hdf5: { + supported: false, + notes: 'Optional HDF5 support is unavailable.', + reason: 'HDF5 dependency h5py is not installed.', + extensions: ['.hdf5'], + dependency: { name: 'h5py', available: false, version: null }, + }, + }, + lightcurve: { + csv: { supported: true, notes: 'Tabular only' }, + ecsv: { supported: true, notes: 'Metadata' }, + json: { supported: true, notes: 'Envelope' }, + fits: { supported: true, notes: 'Generic FITS' }, + hdf5: { + supported: false, + notes: 'Optional HDF5 support is unavailable.', + reason: 'HDF5 dependency h5py is not installed.', + extensions: ['.hdf5'], + dependency: { name: 'h5py', available: false, version: null }, + }, + }, + analysis_result: { + csv: { supported: true, notes: 'Tabular only' }, + ecsv: { supported: true, notes: 'Metadata' }, + json: { supported: true, notes: 'Envelope' }, + fits: { supported: true, notes: 'Generic FITS' }, + hdf5: { + supported: false, + notes: 'Optional HDF5 support is unavailable.', + reason: 'HDF5 dependency h5py is not installed.', + extensions: ['.hdf5'], + dependency: { name: 'h5py', available: false, version: null }, + }, + }, +}; + +const HDF5_CAPABILITIES: ExportableObjectsResult['capability_matrix'] = { + event_list: { + ...CAPABILITIES.event_list, + hdf5: { + supported: true, + notes: 'Lossless Stingray Explorer schema with semantic reopen verification.', + reason: null, + extensions: ['.hdf5'], + dependency: { name: 'h5py', available: true, version: '3.15.1' }, + }, + }, + lightcurve: { + ...CAPABILITIES.lightcurve, + hdf5: { + supported: true, + notes: 'Lossless Stingray Explorer schema with semantic reopen verification.', + reason: null, + extensions: ['.hdf5'], + dependency: { name: 'h5py', available: true, version: '3.15.1' }, + }, + }, + analysis_result: { + ...CAPABILITIES.analysis_result, + hdf5: { + supported: true, + notes: 'Lossless Stingray Explorer schema with semantic reopen verification.', + reason: null, + extensions: ['.hdf5'], + dependency: { name: 'h5py', available: true, version: '3.15.1' }, + }, + }, +}; + +function catalog( + objects: ExportableObjectsResult['objects'] = [] +): ExportableObjectsResult { + return { + objects, + capability_matrix: CAPABILITIES, + format_allowlist: ['csv', 'ecsv', 'json', 'fits'], + excluded_formats: { + pickle: 'Unsafe deserialization format', + hdf5: 'HDF5 dependency h5py is not installed.', + }, + row_cap: 2_000_000, + provenance: { operation: 'list_exportable_objects' }, + }; +} + +function catalogWithHdf5( + objects: ExportableObjectsResult['objects'] = [] +): ExportableObjectsResult { + return { + ...catalog(objects), + capability_matrix: HDF5_CAPABILITIES, + format_allowlist: ['csv', 'ecsv', 'json', 'fits', 'hdf5'], + excluded_formats: { pickle: 'Unsafe deserialization format' }, + }; +} + +function success(data: T, message = 'Done') { + return { success: true, data, message, error: null }; +} + +const INSPECTION: FileInspectionResult = { + path: '/science/events.fits', + filename: 'events.fits', + extension: '.fits', + size_bytes: 4096, + supported: true, + detected_type: 'fits', + hdus: [ + { + index: 1, + name: 'EVENTS', + type: 'binary_table', + row_count: 2, + dimensions: [16, 2], + columns: [ + { name: 'TIME', format: 'D', unit: 's' }, + { name: 'PI', format: 'J', unit: null }, + ], + timing: { + status: 'available', + note: 'Exact split keyword reference', + mjdref: { + decimal: '58000.12345678901234568', + stingray_value: '58000.12345678901235', + source_keywords: { MJDREFI: '58000', MJDREFF: '0.12345678901234568' }, + }, + keywords: { + MJDREFI: 58000, + MJDREFF: 0.12345678901234568, + TIMESYS: 'TT', + TIMEUNIT: 's', + TIMEZERO: 0, + TSTART: 12.5, + TSTOP: 42.5, + CLOCKAPP: true, + }, + high_precision_keywords: { + TSTART: { + decimal: '12.500000000000000001', + stingray_value: '12.5', + source_keywords: { + TSTARTI: '12', + TSTARTF: '0.500000000000000001', + }, + }, + }, + }, + }, + ], + warnings: [], + provenance: { operation: 'inspect_file' }, +}; + +const UNITLESS_RMF_INSPECTION: RmfInspectionResult = { + path: '/calibration/unitless.rmf', + filename: 'unitless.rmf', + size_bytes: 2048, + channel_count: 2, + channel_min: 0, + channel_max: 1, + energy_min: 0.1, + energy_max: 0.4, + energy_unit: null, + conversion_supported: false, + contiguous_channels: true, + preview_rows: [ + { channel: 0, energy_min: 0.1, energy_max: 0.2, energy_midpoint: 0.15 }, + ], + preview_truncated: true, + warnings: ['EBOUNDS energy units are missing.'], + provenance: { operation: 'inspect_rmf' }, +}; + +describe('General I/O Utilities page', () => { + const originalElectronApi = window.electronAPI; + + beforeEach(() => { + vi.clearAllMocks(); + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listExportableObjects.mockResolvedValue(success(catalog())); + listEventLists.mockResolvedValue( + success([ + { name: 'events', n_events: 3, time_range: [0, 2], has_pi: true }, + { name: 'already_loaded', n_events: 2, time_range: [0, 1], has_pi: true }, + ]) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: originalElectronApi, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: originalElectronApi, + }); + }); + + it('is ready and presents the verified capability matrix even with no loaded objects', async () => { + renderWithProviders(); + + expect(screen.getByRole('heading', { name: 'General I/O Functionality' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Data Ingestion' })).toHaveAttribute( + 'href', + '/data-ingestion' + ); + expect(screen.queryByText(/coming soon/i)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('tab', { name: 'Export / conversion' })); + + expect(await screen.findByText('No compatible loaded objects are available to export.')).toBeInTheDocument(); + expect(screen.getByText('Verified format compatibility')).toBeInTheDocument(); + expect(screen.getAllByText(/Supported — Tabular only/).length).toBeGreaterThan(0); + expect(screen.getByText(/PICKLE: Unsafe deserialization format/)).toBeInTheDocument(); + expect(screen.getByText('HDF5 dependency h5py is not installed.')).toBeInTheDocument(); + }); + + it('does not offer HDF5 when the backend reports the capability as unavailable', async () => { + listExportableObjects.mockResolvedValue( + success( + catalog([ + { + object_type: 'event_list', + name: 'events', + row_count: 3, + exportable: true, + formats: ['fits', 'hdf5'], + reason: null, + }, + ]) + ) + ); + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Export / conversion' })); + await screen.findByLabelText('Loaded object'); + + await userEvent.click(screen.getByLabelText('Format')); + expect(screen.getByRole('option', { name: 'FITS' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'HDF5' })).not.toBeInTheDocument(); + expect(screen.getByText('HDF5 dependency h5py is not installed.')).toBeInTheDocument(); + }); + + it('preserves a native file selection on cancellation and sends the exact inspection payload', async () => { + const openGrantedFile = vi + .fn() + .mockResolvedValueOnce([{ path: '/science/events.fits', grant: 'read-grant' }]) + .mockResolvedValueOnce(null); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { ...originalElectronApi, openGrantedFile }, + }); + inspectFile.mockResolvedValue(success(INSPECTION)); + renderWithProviders(); + + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + expect(openGrantedFile).toHaveBeenNthCalledWith(1, { + title: 'Scientific file', + filters: undefined, + }); + expect(screen.getByLabelText('Scientific file')).toHaveValue('/science/events.fits'); + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + expect(screen.getByLabelText('Scientific file')).toHaveValue('/science/events.fits'); + + await userEvent.click(screen.getByRole('button', { name: 'Inspect file' })); + expect(inspectFile).toHaveBeenCalledWith({ + file_path: '/science/events.fits', + file_grant: 'read-grant', + }); + expect(await screen.findByText('58000.12345678901234568')).toBeInTheDocument(); + expect(screen.getByText('58000.12345678901235')).toBeInTheDocument(); + expect(screen.getAllByText(/TSTART=12\.5/).length).toBeGreaterThan(0); + expect(screen.getByText(/CLOCKAPP=true/)).toBeInTheDocument(); + expect(screen.getByText(/TSTART=12.500000000000000001/)).toBeInTheDocument(); + expect(screen.getAllByText('EVENTS').length).toBeGreaterThan(0); + await userEvent.click(screen.getByRole('tab', { name: 'RMF utilities' })); + await userEvent.click(screen.getByRole('tab', { name: 'File inspector' })); + expect(screen.getByText('58000.12345678901234568')).toBeVisible(); + }); + + it('recognizes exact non-MJD timing metadata as available for display', async () => { + const openGrantedFile = vi.fn().mockResolvedValue([ + { path: '/science/timing-only.fits', grant: 'read-grant' }, + ]); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { ...originalElectronApi, openGrantedFile }, + }); + inspectFile.mockResolvedValue( + success({ + ...INSPECTION, + path: '/science/timing-only.fits', + hdus: INSPECTION.hdus.map((hdu) => ({ + ...hdu, + timing: { + ...hdu.timing, + status: 'missing' as const, + mjdref: null, + keywords: { TSTART: 12.5, TSTOP: 42.5, TIMEUNIT: 's' }, + }, + })), + }) + ); + renderWithProviders(); + + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + await userEvent.click(screen.getByRole('button', { name: 'Inspect file' })); + + expect(await screen.findByText(/TSTART=12.500000000000000001/)).toBeInTheDocument(); + expect( + screen.queryByText(/No unambiguous time-reference metadata was found/) + ).not.toBeInTheDocument(); + }); + + it('clears a completed file inspection when a different file is selected', async () => { + const openGrantedFile = vi + .fn() + .mockResolvedValueOnce([{ path: '/science/events.fits', grant: 'first-grant' }]) + .mockResolvedValueOnce([{ path: '/science/other.fits', grant: 'second-grant' }]); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { ...originalElectronApi, openGrantedFile }, + }); + inspectFile.mockResolvedValue(success(INSPECTION)); + renderWithProviders(); + + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + await userEvent.click(screen.getByRole('button', { name: 'Inspect file' })); + expect(await screen.findByText('58000.12345678901234568')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + expect(screen.getByLabelText('Scientific file')).toHaveValue('/science/other.fits'); + expect(screen.queryByText('58000.12345678901234568')).not.toBeInTheDocument(); + expect(screen.getByText(/Choose a file to inspect/)).toBeInTheDocument(); + }); + + it('does not invent keV for a unitless RMF and clears results after RMF changes', async () => { + const openGrantedFile = vi + .fn() + .mockResolvedValueOnce([{ path: '/calibration/unitless.rmf', grant: 'first-grant' }]) + .mockResolvedValueOnce([{ path: '/calibration/other.rmf', grant: 'second-grant' }]); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { ...originalElectronApi, openGrantedFile }, + }); + inspectRmf.mockResolvedValue(success(UNITLESS_RMF_INSPECTION)); + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'RMF utilities' })); + + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + expect(openGrantedFile).toHaveBeenCalledWith({ + title: 'RMF file', + filters: undefined, + }); + await userEvent.click(screen.getByRole('button', { name: 'Inspect RMF' })); + + expect(await screen.findByText('Energy range (unit not declared)')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Energy low' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Energy low (keV)' })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + expect(screen.getByLabelText('RMF file')).toHaveValue('/calibration/other.rmf'); + expect(screen.queryByLabelText('RMF inspection result')).not.toBeInTheDocument(); + }); + + it('rejects fractional PI locally, then converts valid channels with the exact RMF payload', async () => { + const openGrantedFile = vi + .fn() + .mockResolvedValueOnce([ + { path: '/calibration/response.rmf', grant: 'rmf-grant' }, + ]) + .mockResolvedValueOnce([ + { path: '/calibration/replacement.rmf', grant: 'replacement-grant' }, + ]); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { ...originalElectronApi, openGrantedFile }, + }); + convertPi.mockResolvedValue( + success({ + rows: [ + { index: 0, pi: 0, energy: 0.15 }, + { index: 1, pi: 2, energy: 0.6 }, + ], + count: 2, + energy_unit: 'keV', + plot: { arrays: [[0, 2], [0.15, 0.6]], stride: 1, source_points: 2 }, + warnings: ['Exact EBOUNDS channel matches were required.'], + provenance: { operation: 'rmf_pi_to_energy' }, + }) + ); + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'RMF utilities' })); + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + + const piField = screen.getByLabelText('PI values'); + await userEvent.type(piField, '0, 1.5'); + expect(screen.getByText('PI value 2 must be a non-negative integer')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Convert PI values' })).toBeDisabled(); + expect(convertPi).not.toHaveBeenCalled(); + + await userEvent.clear(piField); + await userEvent.type(piField, '0, 2'); + await userEvent.click(screen.getByRole('button', { name: 'Convert PI values' })); + expect(convertPi).toHaveBeenCalledWith({ + pi_values: [0, 2], + rmf_path: '/calibration/response.rmf', + rmf_grant: 'rmf-grant', + }); + expect(await screen.findByTestId('io-chart')).toBeInTheDocument(); + expect(screen.getByText('Exact EBOUNDS channel matches were required.')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + expect(screen.queryByTestId('io-chart')).not.toBeInTheDocument(); + }); + + it('shows loading and errors while preserving the previous successful inspection', async () => { + const openGrantedFile = vi.fn().mockResolvedValue([ + { path: '/science/events.fits', grant: 'read-grant' }, + ]); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { ...originalElectronApi, openGrantedFile }, + }); + inspectFile.mockResolvedValueOnce( + success({ ...INSPECTION, warnings: ['Retained inspection advisory.'] }) + ); + renderWithProviders(); + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + await userEvent.click(screen.getByRole('button', { name: 'Inspect file' })); + expect(await screen.findByText('58000.12345678901234568')).toBeInTheDocument(); + + let resolveFailure: ((value: unknown) => void) | undefined; + inspectFile.mockImplementationOnce( + () => new Promise((resolve) => { resolveFailure = resolve; }) + ); + await userEvent.click(screen.getByRole('button', { name: 'Inspect file' })); + expect(screen.getByRole('button', { name: 'Inspecting…' })).toBeDisabled(); + resolveFailure?.({ + success: false, + data: null, + message: 'Malformed FITS', + error: 'Invalid header', + warnings: ['Failure-specific FITS advisory.'], + }); + + expect(await screen.findByText('Invalid header')).toBeInTheDocument(); + expect(screen.getByText('Failure-specific FITS advisory.')).toBeInTheDocument(); + expect(screen.getByText('Retained inspection advisory.')).toBeInTheDocument(); + expect(screen.getByText('58000.12345678901234568')).toBeInTheDocument(); + }); + + it('binds save-dialog extension to the selected format, preserves cancellation, and exports exactly', async () => { + listExportableObjects.mockResolvedValue( + success( + catalog([ + { + object_type: 'event_list', + name: 'events', + row_count: 3, + exportable: true, + formats: ['fits', 'csv'], + reason: null, + }, + { + object_type: 'analysis_result', + name: 'not_tabular', + row_count: null, + exportable: false, + formats: [], + reason: 'No one-dimensional columns', + }, + ]) + ) + ); + const saveGrantedFile = vi + .fn() + .mockResolvedValueOnce({ path: '/exports/events.csv', grant: 'write-grant' }) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('save dialog process failed')); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { ...originalElectronApi, saveGrantedFile }, + }); + exportObject.mockResolvedValue( + success({ + path: '/exports/events.csv', + bytes: 128, + format: 'csv', + row_count: 3, + object_type: 'event_list', + object_name: 'events', + verified: true, + warnings: ['CSV stores tabular values only.'], + provenance: { operation: 'export_loaded_object' }, + }) + ); + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Export / conversion' })); + await screen.findByLabelText('Loaded object'); + + await userEvent.click(screen.getByLabelText('Loaded object')); + expect(screen.getByRole('option', { name: /not_tabular/ })).toHaveAttribute('aria-disabled', 'true'); + await userEvent.keyboard('{Escape}'); + await userEvent.click(screen.getByLabelText('Format')); + await userEvent.click(screen.getByRole('option', { name: 'CSV' })); + expect(screen.getByText(/CSV scientific-data behavior:/)).toBeInTheDocument(); + expect(screen.getAllByText('Tabular only').length).toBeGreaterThan(0); + await userEvent.click(screen.getByRole('button', { name: 'Choose destination' })); + expect(saveGrantedFile).toHaveBeenLastCalledWith({ + title: 'Export events', + defaultPath: 'events.csv', + filters: [{ name: 'CSV', extensions: ['csv'] }], + }); + expect(screen.getByLabelText('Destination')).toHaveValue('/exports/events.csv'); + + await userEvent.click(screen.getByRole('button', { name: 'Choose destination' })); + expect(screen.getByLabelText('Destination')).toHaveValue('/exports/events.csv'); + await userEvent.click(screen.getByRole('button', { name: 'Choose destination' })); + expect(await screen.findByText(/save dialog process failed/)).toBeInTheDocument(); + expect(screen.getByLabelText('Destination')).toHaveValue('/exports/events.csv'); + await userEvent.click(screen.getByRole('button', { name: 'Export' })); + expect(exportObject).toHaveBeenCalledWith({ + object_type: 'event_list', + object_name: 'events', + format: 'csv', + destination_path: '/exports/events.csv', + destination_grant: 'write-grant', + }); + expect(await screen.findByText('Reopened / verified')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Format')); + await userEvent.click(screen.getByRole('option', { name: 'FITS' })); + expect(screen.getByLabelText('Destination')).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled(); + }); + + it('offers only capability-advertised HDF5 with an exact .hdf5 destination and verification record', async () => { + listExportableObjects.mockResolvedValue( + success( + catalogWithHdf5([ + { + object_type: 'event_list', + name: 'events', + row_count: 3, + exportable: true, + formats: ['fits', 'hdf5'], + reason: null, + }, + ]) + ) + ); + const saveGrantedFile = vi.fn().mockResolvedValue({ + path: '/exports/events.hdf5', + grant: 'hdf5-write-grant', + }); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { ...originalElectronApi, saveGrantedFile }, + }); + exportObject.mockResolvedValueOnce( + success({ + path: '/exports/events.hdf5', + bytes: 4096, + format: 'hdf5', + row_count: 3, + object_type: 'event_list', + object_name: 'events', + verified: true, + verification: { + schema: 'stingray-explorer.hdf5.v1', + table_path: 'stingray_explorer/table', + semantic_round_trip: true, + checks: ['Column order, dtypes, units, and masks match.', 'Explicit GTI state matches.'], + h5py_version: '3.15.1', + }, + warnings: ['HDF5 was reopened before publication.'], + provenance: { operation: 'export_loaded_object', schema: 'stingray-explorer.hdf5.v1' }, + }) + ); + + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Export / conversion' })); + await screen.findByLabelText('Loaded object'); + expect(screen.queryByText(/HDF5 unavailable:/)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Format')); + await userEvent.click(screen.getByRole('option', { name: 'HDF5' })); + expect(screen.getByText(/HDF5 scientific-data behavior:/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Choose destination' })); + expect(saveGrantedFile).toHaveBeenCalledWith({ + title: 'Export events', + defaultPath: 'events.hdf5', + filters: [{ name: 'HDF5', extensions: ['hdf5'] }], + }); + + await userEvent.click(screen.getByRole('button', { name: 'Export' })); + expect(exportObject).toHaveBeenCalledWith({ + object_type: 'event_list', + object_name: 'events', + format: 'hdf5', + destination_path: '/exports/events.hdf5', + destination_grant: 'hdf5-write-grant', + }); + expect(await screen.findByLabelText('Export verification metadata')).toBeInTheDocument(); + expect(screen.getByText('stingray-explorer.hdf5.v1')).toBeInTheDocument(); + expect(screen.getByText('stingray_explorer/table')).toBeInTheDocument(); + expect(screen.getByText('Explicit GTI state matches.')).toBeInTheDocument(); + expect(screen.getByText('HDF5 was reopened before publication.')).toBeInTheDocument(); + }); + + it('shows the exact per-object reason when HDF5 cannot represent a loaded object losslessly', async () => { + const unsupportedReason = + "Column 'cross' has complex values that the verified HDF5 schema does not support."; + listExportableObjects.mockResolvedValue( + success( + catalogWithHdf5([ + { + object_type: 'analysis_result', + name: 'cross_spectrum', + row_count: 2, + exportable: true, + formats: ['fits'], + format_reasons: { hdf5: unsupportedReason }, + reason: null, + }, + ]) + ) + ); + + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Export / conversion' })); + await screen.findByLabelText('Loaded object'); + expect(screen.getByText(unsupportedReason)).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Format')); + expect(screen.getByRole('option', { name: 'FITS' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'HDF5' })).not.toBeInTheDocument(); + }); + + it('keeps the last successful HDF5 result visible when a later export fails', async () => { + listExportableObjects.mockResolvedValue( + success( + catalogWithHdf5([ + { + object_type: 'analysis_result', + name: 'periodogram', + row_count: 2, + exportable: true, + formats: ['hdf5'], + reason: null, + }, + ]) + ) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + ...originalElectronApi, + saveGrantedFile: vi.fn().mockResolvedValue({ + path: '/exports/periodogram.hdf5', + grant: 'analysis-write-grant', + }), + }, + }); + exportObject + .mockResolvedValueOnce( + success({ + path: '/exports/periodogram.hdf5', + bytes: 2048, + format: 'hdf5', + row_count: 2, + object_type: 'analysis_result', + object_name: 'periodogram', + verified: true, + verification: { + schema: 'stingray-explorer.hdf5.v1', + table_path: 'stingray_explorer/table', + semantic_round_trip: true, + checks: ['Analysis metadata matches.'], + h5py_version: '3.15.1', + }, + warnings: ['Successful export advisory.'], + provenance: { operation: 'export_loaded_object' }, + }) + ) + .mockResolvedValueOnce({ + success: false, + data: null, + message: 'HDF5 verification failed', + error: 'Reopened units did not match', + warnings: ['Failed artifact was removed before publication.'], + }); + + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Export / conversion' })); + await screen.findByLabelText('Loaded object'); + await userEvent.click(screen.getByRole('button', { name: 'Choose destination' })); + await userEvent.click(screen.getByRole('button', { name: 'Export' })); + expect(await screen.findByText('Analysis metadata matches.')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Export' })); + expect(await screen.findByText('Reopened units did not match')).toBeInTheDocument(); + expect(screen.getByText('Failed artifact was removed before publication.')).toBeInTheDocument(); + expect(screen.getByText('Successful export advisory.')).toBeInTheDocument(); + expect(screen.getByText('Analysis metadata matches.')).toBeInTheDocument(); + }); + + it('requires a unique derived name and sends an explicit save-as conversion', async () => { + const initialObjects: ExportableObjectsResult['objects'] = [ + { + object_type: 'event_list', + name: 'events', + row_count: 3, + exportable: true, + formats: ['fits', 'csv'], + reason: null, + }, + { + object_type: 'event_list', + name: 'already_loaded', + row_count: 2, + exportable: true, + formats: ['fits'], + reason: null, + }, + ]; + listExportableObjects + .mockResolvedValueOnce(success(catalog(initialObjects))) + .mockResolvedValue( + success( + catalog([ + ...initialObjects, + { + object_type: 'event_list', + name: 'events_calibrated', + row_count: 3, + exportable: true, + formats: ['fits', 'csv'], + reason: null, + }, + ]) + ) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + ...originalElectronApi, + openGrantedFile: vi.fn().mockResolvedValue([ + { path: '/calibration/response.rmf', grant: 'rmf-grant' }, + ]), + }, + }); + convertEventList + .mockResolvedValueOnce( + success({ + source_name: 'events', + saved: false, + saved_name: null, + event_count: 3, + energy_unit: 'keV', + preview_rows: [{ index: 0, pi: 0, energy: 0.15 }], + preview_truncated: true, + plot: { arrays: [[0], [0.15]], stride: 3, source_points: 3 }, + pi_preserved: true, + warnings: [], + provenance: { operation: 'rmf_event_list_pi_to_energy' }, + }) + ) + .mockResolvedValueOnce(success({ + source_name: 'events', + saved: true, + saved_name: 'events_calibrated', + event_count: 3, + energy_unit: 'keV', + preview_rows: [{ index: 0, pi: 0, energy: 0.15 }], + preview_truncated: true, + plot: { arrays: [[0], [0.15]], stride: 3, source_points: 3 }, + pi_preserved: true, + warnings: [], + provenance: { operation: 'rmf_event_list_pi_to_energy' }, + })); + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'RMF utilities' })); + await userEvent.click(screen.getByRole('button', { name: 'Choose' })); + await userEvent.click(await screen.findByLabelText('Source EventList')); + await userEvent.click(screen.getByRole('option', { name: /events \(3 events\)/ })); + + await userEvent.click(screen.getByRole('button', { name: 'Preview conversion' })); + expect(convertEventList).toHaveBeenLastCalledWith({ + event_list_name: 'events', + rmf_path: '/calibration/response.rmf', + rmf_grant: 'rmf-grant', + save_as: null, + }); + expect(await screen.findByText(/no object was saved and the source was not modified/)).toBeInTheDocument(); + + const saveAs = screen.getByLabelText('Save as (new EventList name)'); + await userEvent.type(saveAs, 'events'); + expect(screen.getByText('Destination name must differ from the source EventList')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Convert and save derived EventList' })).toBeDisabled(); + await userEvent.clear(saveAs); + await userEvent.type(saveAs, 'already_loaded'); + expect(screen.getByText(/already_loaded.*already exists/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Convert and save derived EventList' })).toBeDisabled(); + await userEvent.clear(saveAs); + await userEvent.type(saveAs, 'events_calibrated'); + await userEvent.click(screen.getByRole('button', { name: 'Convert and save derived EventList' })); + + expect(convertEventList).toHaveBeenLastCalledWith({ + event_list_name: 'events', + rmf_path: '/calibration/response.rmf', + rmf_grant: 'rmf-grant', + save_as: 'events_calibrated', + }); + expect(await screen.findByText(/Saved events_calibrated with 3 events/)).toBeInTheDocument(); + await waitFor(() => expect(listExportableObjects).toHaveBeenCalledTimes(2)); + expect(screen.getByText(/events_calibrated.*already exists/)).toBeInTheDocument(); + + const saveButton = screen.getByRole('button', { + name: 'Convert and save derived EventList', + }); + expect(saveButton).toBeDisabled(); + fireEvent.click(saveButton); + expect(convertEventList).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/pages/Utilities/IO/index.tsx b/src/pages/Utilities/IO/index.tsx new file mode 100644 index 0000000..97eda53 --- /dev/null +++ b/src/pages/Utilities/IO/index.tsx @@ -0,0 +1,1172 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Divider, + FormControl, + Grid, + InputLabel, + Link, + MenuItem, + Paper, + Select, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from '@mui/material'; +import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined'; +import CalculateOutlinedIcon from '@mui/icons-material/CalculateOutlined'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import SaveAltIcon from '@mui/icons-material/SaveAlt'; +import SearchIcon from '@mui/icons-material/Search'; +import { useQueryClient } from '@tanstack/react-query'; +import { Link as RouterLink } from 'react-router-dom'; +import PageTemplate from '@/components/common/PageTemplate'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import GrantedFileField, { + GrantedFileSelection, +} from '@/components/utilities/GrantedFileField'; +import { + NumericResultTable, + ProvenancePanel, + ResultCell, + UtilityWarnings, +} from '@/components/utilities/UtilityResult'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { EVENT_LISTS_QUERY_KEY } from '@/hooks/useEventLists'; +import { parseNumericArray, validateDerivedName } from '@/utils/utilityInputs'; +import { + EventListConversionResult, + ExportableObject, + ExportableObjectsResult, + ExportResult, + FileInspectionResult, + ioApi, + PiConversionResult, + RmfInspectionResult, + UtilityExportFormat, +} from '@/api/ioApi'; + +const EMPTY_EXPORTABLE_OBJECTS: ExportableObject[] = []; +const EXPORT_DESTINATION_EXTENSION: Record = { + fits: 'fits', + csv: 'csv', + ecsv: 'ecsv', + json: 'json', + hdf5: 'hdf5', +}; + +interface SummaryDatum { + label: string; + value: React.ReactNode; +} + +const SummaryGrid: React.FC<{ values: SummaryDatum[] }> = ({ values }) => ( + + {values.map((item) => ( + + + + {item.label} + + + {item.value} + + + + ))} + +); + +const LoadingButtonContent: React.FC<{ loading: boolean; idle: string; busy: string }> = ({ + loading, + idle, + busy, +}) => ( + <> + {loading ? : null} + {loading ? busy : idle} + +); + +function formatBytes(size: number | null | undefined): string { + if (size == null || !Number.isFinite(size) || size < 0) return 'Unknown'; + if (size < 1024) return `${size} B`; + if (size < 1024 ** 2) return `${(size / 1024).toFixed(2)} KiB`; + if (size < 1024 ** 3) return `${(size / 1024 ** 2).toFixed(2)} MiB`; + return `${(size / 1024 ** 3).toFixed(2)} GiB`; +} + +function formatMaybe(value: unknown): string { + if (value == null || value === '') return 'Not provided'; + return String(value); +} + +function getObjectName(object: ExportableObject): string { + return object.name; +} + +function getObjectFormats( + object: ExportableObject | undefined, + catalog: ExportableObjectsResult | null +): UtilityExportFormat[] { + if (!object || !catalog) return []; + const advertisedFormats = new Set(catalog.format_allowlist); + const capabilityMatrix = catalog.capability_matrix[object.object_type]; + return object.formats.filter( + (format) => advertisedFormats.has(format) && capabilityMatrix[format]?.supported === true + ); +} + +function getObjectKey(object: ExportableObject): string { + return `${object.object_type}:${getObjectName(object)}`; +} + +function fileHduRows(result: FileInspectionResult): Array> { + return result.hdus.map((hdu) => ({ + index: hdu.index, + name: hdu.name, + type: hdu.type, + shape: hdu.dimensions ? hdu.dimensions.join(' × ') : 'Not applicable', + rows: hdu.row_count, + columns: hdu.columns + .map((column) => `${column.name} [${column.format}${column.unit ? `; ${column.unit}` : ''}]`) + .join(', '), + })); +} + +function fileTimingRows(result: FileInspectionResult): Array> { + return result.hdus.map((hdu) => ({ + hdu: hdu.index, + name: hdu.name, + status: hdu.timing.status, + mjdref: hdu.timing.mjdref?.decimal ?? null, + stingray_mjdref: hdu.timing.mjdref?.stingray_value ?? null, + source: hdu.timing.mjdref + ? Object.entries(hdu.timing.mjdref.source_keywords) + .map(([keyword, value]) => `${keyword}=${value}`) + .join(' + ') + : null, + timesys: hdu.timing.keywords.TIMESYS ?? null, + timeref: hdu.timing.keywords.TIMEREF ?? null, + timeunit: hdu.timing.keywords.TIMEUNIT ?? null, + all_keywords: + Object.entries(hdu.timing.keywords) + .map(([keyword, value]) => `${keyword}=${formatMaybe(value)}`) + .join('; ') || null, + exact_split_values: + Object.entries(hdu.timing.high_precision_keywords) + .map(([keyword, value]) => { + const sources = Object.entries(value.source_keywords) + .map(([sourceKeyword, sourceValue]) => `${sourceKeyword}=${sourceValue}`) + .join(' + '); + return `${keyword}=${value.decimal} (Stingray ${value.stingray_value}; ${sources})`; + }) + .join('; ') || null, + note: hdu.timing.note, + })); +} + +function rmfRows(result: RmfInspectionResult): Array> { + return result.preview_rows.map((bound) => ({ + channel: bound.channel, + energy_min: bound.energy_min, + energy_max: bound.energy_max, + energy_midpoint: bound.energy_midpoint, + })); +} + +function conversionRows(result: PiConversionResult): Array> { + return result.rows.map((row) => ({ index: row.index, pi: row.pi, energy: row.energy })); +} + +function previewValues(result: PiConversionResult): { + pi: number[]; + energy: Array; +} { + return { pi: result.plot.arrays[0], energy: result.plot.arrays[1] }; +} + +const ExportCapabilitySummary: React.FC<{ catalog: ExportableObjectsResult }> = ({ catalog }) => ( + + Verified format compatibility + ({ + key: allowedFormat, + label: allowedFormat.toUpperCase(), + })), + ]} + rows={Object.entries(catalog.capability_matrix).map(([objectType, matrix]) => ({ + object_type: objectType.replace(/_/g, ' '), + ...Object.fromEntries( + catalog.format_allowlist.map((allowedFormat) => { + const capability = matrix[allowedFormat]; + return [ + allowedFormat, + capability?.supported + ? `Supported — ${capability.notes}` + : capability?.reason || capability?.notes || 'Not supported', + ]; + }) + ), + }))} + pageSize={10} + /> + {catalog.excluded_formats.hdf5 ? ( + + HDF5 unavailable: {catalog.excluded_formats.hdf5} + + ) : null} + + Maximum export size: {catalog.row_cap.toLocaleString()} rows.{' '} + {Object.entries(catalog.excluded_formats) + .filter(([format]) => format !== 'hdf5') + .map(([format, reason]) => `${format.toUpperCase()}: ${reason}`) + .join(' ')} + + +); + +interface FileInspectorPanelProps { + selection: GrantedFileSelection | null; + onSelectionChange: (selection: GrantedFileSelection | null) => void; +} + +const FileInspectorPanel: React.FC = ({ selection, onSelectionChange }) => { + const inspection = useAnalysisRunner('File inspection'); + + const changeSelection = (nextSelection: GrantedFileSelection | null): void => { + inspection.reset(); + onSelectionChange(nextSelection); + }; + + const inspect = (): void => { + if (!selection) return; + void inspection.run(() => + ioApi.inspectFile({ file_path: selection.path, file_grant: selection.grant }) + ); + }; + + const result = inspection.result?.path === selection?.path ? inspection.result : null; + const hasTiming = + result?.hdus.some( + (hdu) => + hdu.timing.mjdref != null || + Object.keys(hdu.timing.keywords).length > 0 || + Object.keys(hdu.timing.high_precision_keywords).length > 0 + ) ?? false; + + return ( + + + Inspect an explicitly selected local file. FITS headers and HDU structure are read without + materializing full event tables. + + + + + + {inspection.error ? {inspection.error} : null} + + {result ? ( + + + + ), + }, + ]} + /> + + + Time reference + + {!hasTiming ? ( + + No unambiguous time-reference metadata was found in the inspected headers. + + ) : null} + {result.hdus.length > 0 ? ( + + ) : null} + + {result.hdus.length > 0 ? ( + + ) : ( + This file has no FITS HDU table to display. + )} + + + ) : ( + Choose a file to inspect its format, structure, and timing metadata. + )} + + ); +}; + +interface RmfPanelProps { + rmfSelection: GrantedFileSelection | null; + onRmfSelectionChange: (selection: GrantedFileSelection | null) => void; + eventLists: ExportableObject[]; + onEventListSaved: () => void; +} + +const RmfPanel: React.FC = ({ + rmfSelection, + onRmfSelectionChange, + eventLists, + onEventListSaved, +}) => { + const queryClient = useQueryClient(); + const rmfInspection = useAnalysisRunner('RMF inspection'); + const piConversion = useAnalysisRunner('PI-to-energy conversion'); + const eventConversion = useAnalysisRunner('Derived EventList conversion'); + const [piText, setPiText] = useState(''); + const [sourceEventList, setSourceEventList] = useState(''); + const [saveAs, setSaveAs] = useState(''); + const [piResultSignature, setPiResultSignature] = useState(null); + const [piRequestSignature, setPiRequestSignature] = useState(null); + const [eventResultSignature, setEventResultSignature] = useState(null); + const [eventRequestSignature, setEventRequestSignature] = useState(null); + + const piInputSignature = JSON.stringify({ rmfSelection, piText }); + const eventInputSignature = JSON.stringify({ rmfSelection, sourceEventList, saveAs }); + const panelRunning = + rmfInspection.running || piConversion.running || eventConversion.running; + + const parsedPi = useMemo(() => { + const parsed = parseNumericArray(piText, 'PI values'); + if (!parsed.value) return parsed; + const invalidIndex = parsed.value.findIndex( + (value) => !Number.isSafeInteger(value) || value < 0 + ); + if (invalidIndex >= 0) { + return { + value: null, + error: `PI value ${invalidIndex + 1} must be a non-negative integer`, + }; + } + return parsed; + }, [piText]); + + const derivedNameError = saveAs === '' ? 'A new destination name is required' : validateDerivedName(saveAs); + const isSameName = saveAs !== '' && saveAs === sourceEventList; + const isDuplicateName = + saveAs !== '' && + (eventLists.some((object) => getObjectName(object) === saveAs) || + (eventConversion.result?.saved === true && + eventConversion.result.saved_name === saveAs)); + const eventNameError = derivedNameError ?? (isSameName + ? 'Destination name must differ from the source EventList' + : isDuplicateName + ? `An EventList named "${saveAs}" already exists` + : null); + const selectedRmfInspection = + rmfInspection.result?.path === rmfSelection?.path ? rmfInspection.result : null; + const conversionUnavailable = selectedRmfInspection?.conversion_supported === false; + + const changeRmfSelection = (selection: GrantedFileSelection | null): void => { + rmfInspection.reset(); + piConversion.reset(); + eventConversion.reset(); + onRmfSelectionChange(selection); + }; + + const inspectRmf = (): void => { + if (!rmfSelection) return; + void rmfInspection.run(() => + ioApi.inspectRmf({ rmf_path: rmfSelection.path, rmf_grant: rmfSelection.grant }) + ); + }; + + const convertPi = (): void => { + if (!rmfSelection || !parsedPi.value) return; + const submittedSignature = piInputSignature; + setPiRequestSignature(submittedSignature); + void piConversion.run(async () => { + const response = await ioApi.convertPi({ + pi_values: parsedPi.value as number[], + rmf_path: rmfSelection.path, + rmf_grant: rmfSelection.grant, + }); + if (response.success && response.data) setPiResultSignature(submittedSignature); + return response; + }); + }; + + const convertEventList = (save: boolean): void => { + if ( + !rmfSelection || + !sourceEventList || + conversionUnavailable || + (save && eventNameError) + ) return; + const submittedSignature = eventInputSignature; + setEventRequestSignature(submittedSignature); + void eventConversion.run(async () => { + const response = await ioApi.convertEventList({ + event_list_name: sourceEventList, + rmf_path: rmfSelection.path, + rmf_grant: rmfSelection.grant, + save_as: save ? saveAs : null, + }); + if (save && response.success && response.data?.saved) { + onEventListSaved(); + void queryClient.invalidateQueries({ queryKey: EVENT_LISTS_QUERY_KEY }); + } + if (response.success && response.data) setEventResultSignature(submittedSignature); + return response; + }); + }; + + const piResult = + piResultSignature === piInputSignature ? piConversion.result : null; + const eventResult = + eventResultSignature === eventInputSignature ? eventConversion.result : null; + const piFeedbackCurrent = piRequestSignature === piInputSignature; + const eventFeedbackCurrent = eventRequestSignature === eventInputSignature; + const plot = piResult ? previewValues(piResult) : null; + const energyUnit = piResult?.energy_unit ?? 'keV'; + + return ( + + + + Response matrix + + + RMF calibration maps exact integer PI channels to calibrated energy bounds. Unmatched + channels are reported instead of silently receiving an energy. + + + + + + + {rmfInspection.error ? {rmfInspection.error} : null} + + {selectedRmfInspection ? ( + + + + {rmfRows(selectedRmfInspection).length > 0 ? ( + + ) : null} + + + ) : null} + + + + + + + + Convert pasted PI values + + + setPiText(event.target.value)} + disabled={panelRunning} + placeholder="0, 1, 2, 10" + multiline + minRows={3} + error={piText !== '' && parsedPi.error != null} + helperText={piText !== '' ? parsedPi.error ?? 'Comma, space, or newline separated integer channels' : 'Comma, space, or newline separated integer channels'} + /> + + + + {piFeedbackCurrent && piConversion.error ? ( + {piConversion.error} + ) : null} + + {piResult ? ( + + + {plot && plot.pi.length > 0 ? ( + + + + ) : null} + + + + ) : piConversion.result ? ( + + PI or RMF inputs changed. Convert again to view results for the current inputs. + + ) : null} + + + + + + + + Convert a loaded EventList + + + The source stays unchanged. Conversion is saved atomically under a new, unique EventList + name while preserving the original PI channels and RMF provenance. + + {eventLists.length === 0 ? ( + Load an EventList with PI channels before using this conversion. + ) : ( + + { + setSourceEventList(name); + setSaveAs(''); + }} + requiredCapability="pi" + disabled={panelRunning} + /> + setSaveAs(event.target.value)} + disabled={panelRunning} + error={saveAs !== '' && eventNameError != null} + helperText={saveAs !== '' ? eventNameError ?? 'A new object; the source is never mutated' : 'Required; must be unique'} + /> + + + + + {eventFeedbackCurrent && eventConversion.error ? ( + {eventConversion.error} + ) : null} + + {eventResult ? ( + + + {eventResult.saved + ? `Saved ${eventResult.saved_name ?? saveAs} with ${eventResult.event_count.toLocaleString()} events` + : `Previewed ${eventResult.event_count.toLocaleString()} calibrated events; no object was saved and the source was not modified.`} + + + + {eventResult.plot.arrays[0].length > 0 ? ( + + + + ) : null} + {eventResult.preview_rows.length > 0 ? ( + ({ + index: row.index, + pi: row.pi, + energy: row.energy, + }))} + /> + ) : null} + + + ) : eventConversion.result ? ( + + EventList, RMF, or destination inputs changed. Convert again for the current source. + + ) : null} + + )} + + + ); +}; + +interface ExportPanelProps { + catalog: ExportableObjectsResult | null; + loadingObjects: boolean; + objectsError: string | null; + onRefresh: () => void; +} + +const ExportPanel: React.FC = ({ + catalog, + loadingObjects, + objectsError, + onRefresh, +}) => { + const objects = catalog?.objects ?? EMPTY_EXPORTABLE_OBJECTS; + const exportRunner = useAnalysisRunner('Scientific data export'); + const [objectKey, setObjectKey] = useState(''); + const [format, setFormat] = useState(''); + const [destination, setDestination] = useState(null); + const [destinationError, setDestinationError] = useState(null); + + useEffect(() => { + const current = objects.find((object) => getObjectKey(object) === objectKey); + if (current?.exportable) { + const formats = getObjectFormats(current, catalog); + if (format && formats.includes(format)) return; + setFormat(formats[0] ?? ''); + setDestination(null); + setDestinationError(null); + return; + } + const first = objects.find((object) => object.exportable); + setObjectKey(first ? getObjectKey(first) : ''); + setFormat(first ? getObjectFormats(first, catalog)[0] ?? '' : ''); + setDestination(null); + setDestinationError(null); + }, [objects, objectKey, format, catalog]); + + const selectedObject = useMemo( + () => objects.find((object) => getObjectKey(object) === objectKey), + [objects, objectKey] + ); + const formats = getObjectFormats(selectedObject, catalog); + const selectedCapability = + selectedObject && format && catalog + ? catalog.capability_matrix[selectedObject.object_type][format] + : null; + + const selectObject = (nextKey: string): void => { + const nextObject = objects.find((object) => getObjectKey(object) === nextKey); + setObjectKey(nextKey); + setFormat(getObjectFormats(nextObject, catalog)[0] ?? ''); + setDestination(null); + setDestinationError(null); + }; + + const selectFormat = (nextFormat: UtilityExportFormat): void => { + setFormat(nextFormat); + setDestination(null); + setDestinationError(null); + }; + + const chooseDestination = async (): Promise => { + if (!selectedObject || !format) return; + setDestinationError(null); + if (!window.electronAPI?.saveGrantedFile) { + setDestinationError('The native save dialog is unavailable.'); + return; + } + try { + const extension = EXPORT_DESTINATION_EXTENSION[format]; + const selected = await window.electronAPI.saveGrantedFile({ + title: `Export ${getObjectName(selectedObject)}`, + defaultPath: `${getObjectName(selectedObject)}.${extension}`, + filters: [{ name: format.toUpperCase(), extensions: [extension] }], + }); + // Cancelling the native dialog intentionally preserves the previous destination. + if (selected) setDestination(selected); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + setDestinationError(`Could not open the native save dialog: ${detail}`); + } + }; + + const exportData = (): void => { + if (!selectedObject || !format || !destination) return; + void exportRunner.run(() => + ioApi.exportObject({ + object_type: selectedObject.object_type, + object_name: getObjectName(selectedObject), + format, + destination_path: destination.path, + destination_grant: destination.grant, + }) + ); + }; + + return ( + + + + Export a compatible loaded object through an explicit native destination. Existing files + are never overwritten, and the backend verifies the written artifact when supported. + + + + {objectsError ? {objectsError} : null} + {catalog ? : null} + {loadingObjects ? ( + }> + Loading compatible objects… + + ) : objects.length === 0 ? ( + No compatible loaded objects are available to export. + ) : ( + + + + + Loaded object + + + + + + Format + + + + + {formats.length === 0 ? ( + The selected object has no verified export format. + ) : null} + {selectedCapability ? ( + + {format.toUpperCase()} scientific-data behavior:{' '} + {selectedCapability.notes} + + ) : null} + {selectedObject + ? Object.entries(selectedObject.format_reasons ?? {}).map( + ([unavailableFormat, reason]) => ( + + + {unavailableFormat.toUpperCase()} unavailable for{' '} + {getObjectName(selectedObject)}: + {' '} + {reason} + + ) + ) + : null} + + + + + {destinationError ? {destinationError} : null} + + + + {exportRunner.error ? {exportRunner.error} : null} + + {exportRunner.result ? ( + + + Exported to {exportRunner.result.path} + + + + {exportRunner.result.verification ? ( + + Semantic round-trip verification + + + {exportRunner.result.verification.checks.map((check) => ( + + {check} + + ))} + + + ) : null} + + + ) : null} + + )} + + ); +}; + +const IOPage: React.FC = () => { + const [tab, setTab] = useState(0); + const [fileSelection, setFileSelection] = useState(null); + const [rmfSelection, setRmfSelection] = useState(null); + const [catalog, setCatalog] = useState(null); + const [loadingObjects, setLoadingObjects] = useState(true); + const [objectsError, setObjectsError] = useState(null); + const [refreshSequence, setRefreshSequence] = useState(0); + + useEffect(() => { + let active = true; + setLoadingObjects(true); + setObjectsError(null); + void ioApi + .listExportableObjects() + .then((response) => { + if (!active) return; + if (response.success && response.data) { + setCatalog(response.data); + setObjectsError(null); + } else { + setObjectsError( + response.error ?? response.message ?? 'Could not list exportable objects' + ); + } + setLoadingObjects(false); + }) + .catch((error: unknown) => { + if (!active) return; + setObjectsError(error instanceof Error ? error.message : 'Could not list exportable objects'); + setLoadingObjects(false); + }); + return () => { + active = false; + }; + }, [refreshSequence]); + + const eventLists = useMemo( + () => (catalog?.objects ?? EMPTY_EXPORTABLE_OBJECTS).filter( + (object) => object.object_type === 'event_list' + ), + [catalog] + ); + + return ( + + + This workbench inspects and converts data already available to the app. To load a new + EventList or Lightcurve, go to{' '} + + Data Ingestion + + . + + + setTab(value)} + aria-label="General I/O operations" + variant="scrollable" + scrollButtons="auto" + sx={{ borderBottom: 1, borderColor: 'divider', px: 1 }} + > + + + + + + + + + + + + ); +}; + +export default IOPage; diff --git a/src/pages/Utilities/Misc/index.test.tsx b/src/pages/Utilities/Misc/index.test.tsx new file mode 100644 index 0000000..6f66891 --- /dev/null +++ b/src/pages/Utilities/Misc/index.test.tsx @@ -0,0 +1,423 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; +import { apiClient } from '@/api/client'; + +const capabilities = vi.fn(); +const linearRebin = vi.fn(); +const logarithmicRebin = vi.fn(); +const estimateBaseline = vi.fn(); +const generateWindow = vi.fn(); +const optimalBinTime = vi.fn(); +const nearestPowerOfTwo = vi.fn(); +const adjustSegmentSize = vi.fn(); +const poissonErrors = vi.fn(); +const standardError = vi.fn(); +const equalCountEnergyRanges = vi.fn(); + +vi.mock('@/api/miscApi', () => ({ + miscApi: { + capabilities: (...args: unknown[]) => capabilities(...args), + linearRebin: (...args: unknown[]) => linearRebin(...args), + logarithmicRebin: (...args: unknown[]) => logarithmicRebin(...args), + estimateBaseline: (...args: unknown[]) => estimateBaseline(...args), + generateWindow: (...args: unknown[]) => generateWindow(...args), + optimalBinTime: (...args: unknown[]) => optimalBinTime(...args), + nearestPowerOfTwo: (...args: unknown[]) => nearestPowerOfTwo(...args), + adjustSegmentSize: (...args: unknown[]) => adjustSegmentSize(...args), + poissonErrors: (...args: unknown[]) => poissonErrors(...args), + standardError: (...args: unknown[]) => standardError(...args), + equalCountEnergyRanges: (...args: unknown[]) => equalCountEnergyRanges(...args), + }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: ({ data }: { data: unknown[] }) => ( +
plot
+ ), +})); + +vi.mock('@/components/analysis/EventListSelector', () => ({ + default: ({ + label, + value, + onChange, + }: { + label: string; + value: string; + onChange: (value: string) => void; + }) => ( + + ), +})); + +import MiscPage from './index'; + +const provenance = { + operation: 'test_operation', + input_source: { kind: 'pasted_values' }, + parameters: {}, + stingray_version: '2.2.10', +}; + +const capabilityData = { + window_types: ['uniform', 'hamming', 'blackmann'], + rebin: { + modes: ['linear', 'logarithmic'], + linear_methods: ['sum', 'mean'], + logarithmic_method: 'mean', + linear_uncertainty_workaround_required: true, + linear_uncertainty_workaround_reference: 'StingraySoftware/stingray#953', + linear_uncertainty_support: 'uniform spacing and integer ratio only', + }, + baseline_defaults: { + lambda: 1e11, + asymmetry: 0.001, + iterations: 10, + offset_correction: false, + }, + runtime_advisories: { + nearest_power_of_two: 'Fail closed when installed Stingray is not mathematically nearest.', + }, + limits: { + max_array_values: 100_000, + max_exact_output_values: 100_000, + max_matrix_cells: 200_000, + max_baseline_iterations: 100, + max_fft_samples: 16_777_216, + max_poisson_count: 200_000, + max_energy_ranges: 1_000, + }, + warnings: [], + provenance, +}; + +const rebinData = { + mode: 'linear', + method: 'sum', + units: { x: 'same as input x', y: 'same as input y', y_error: 'same as input y', samples_per_bin: 'input samples' }, + original: { x: [0.5, 1.5, 2.5, 3.5], y: [2, 4, 6, 8], y_error: [1, 1, 1, 1] }, + rebinned: { x: [1, 3], y: [6, 14], y_error: [Math.SQRT2, Math.SQRT2], samples_per_bin: [2, 2] }, + error_semantics: 'independent one-standard-deviation uncertainties propagated in quadrature', + plot_preview: { + original: { values: { x: [0.5, 1.5, 2.5, 3.5], y: [2, 4, 6, 8], y_error: [1, 1, 1, 1] }, stride: 1, source_points: 4 }, + rebinned: { values: { x: [1, 3], y: [6, 14], y_error: [Math.SQRT2, Math.SQRT2] }, stride: 1, source_points: 2 }, + }, + warnings: ['Squared standard uncertainties were supplied for Stingray 2.2.10.'], + provenance, +}; + +const baselineData = { + x: [0, 1, 2], + original: [2, 4, 8], + baseline: [1, 2, 3], + corrected: [1, 2, 5], + units: { x: 'same as input x', original: 'same as input y', baseline: 'same as input y', corrected: 'same as input y' }, + plot_preview: { values: { x: [0, 1, 2], original: [2, 4, 8], baseline: [1, 2, 3], corrected: [1, 2, 5] }, stride: 1, source_points: 3 }, + warnings: ['Baseline solver advisory.'], + provenance, +}; + +function ok(data: T) { + return { success: true, data, message: 'done', error: null }; +} + +function activePanel() { + return screen.getByRole('tabpanel'); +} + +async function openTab(name: string): Promise { + await userEvent.click(screen.getByRole('tab', { name })); +} + +async function replaceField(label: string | RegExp, value: string): Promise { + const field = within(activePanel()).getByLabelText(label); + await userEvent.clear(field); + await userEvent.type(field, value); +} + +describe('MiscPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + capabilities.mockResolvedValue(ok(capabilityData)); + linearRebin.mockResolvedValue(ok(rebinData)); + logarithmicRebin.mockResolvedValue(ok({ ...rebinData, mode: 'logarithmic', method: 'mean', warnings: [] })); + estimateBaseline.mockResolvedValue(ok(baselineData)); + generateWindow.mockResolvedValue(ok({ + window_type: 'blackmann', + n_samples: 4, + sample_index: [0, 1, 2, 3], + window: [0, 0.5, 0.5, 0], + units: { sample_index: 'sample', window: 'dimensionless', summary: 'dimensionless unless named in bins' }, + summary: { minimum: 0, maximum: 0.5, sum: 1, mean: 0.25, rms: 0.3535, energy: 0.5, coherent_gain: 0.25, equivalent_noise_bandwidth_bins: 2 }, + plot_preview: { values: { sample_index: [0, 1, 2, 3], window: [0, 0.5, 0.5, 0] }, stride: 1, source_points: 4 }, + warnings: [], + provenance, + })); + optimalBinTime.mockResolvedValue(ok({ requested_bin_time: 2.1, adjusted_bin_time: 2, sample_count: 256, delta: -0.1, fractional_change: -0.0476, changed: true, units: 'same time units', warnings: ['Bin time changed.'], provenance })); + nearestPowerOfTwo.mockResolvedValue(ok({ requested_value: 6, nearest_power_of_two: 8, delta: 2, fractional_change: 1 / 3, changed: true, units: 'dimensionless', warnings: ['Value changed.'], provenance })); + adjustSegmentSize.mockResolvedValue(ok({ requested_segment_size: 10.1, adjusted_segment_size: 10, sample_count: 10, delta: -0.1, fractional_change: -0.0099, changed: true, units: 'same time units', warnings: ['Segment changed.'], provenance })); + poissonErrors.mockResolvedValue(ok({ counts: [0, 4, 9], symmetric_error: [0.92, 2.06, 3.04], confidence_sigma: 1, units: { counts: 'count', symmetric_error: 'count', confidence_sigma: 'standard deviations' }, assumptions: 'Independent Poisson observations.', plot_preview: { values: { counts: [0, 4, 9], symmetric_error: [0.92, 2.06, 3.04] }, stride: 1, source_points: 3 }, warnings: [], provenance })); + standardError.mockResolvedValue(ok({ mean: [2, 3], calculated_sample_mean: [2, 3], standard_error: [1, 1], sample_count: 2, column_count: 2, mean_source: 'calculated_arithmetic_mean', units: { mean: 'same as input samples', calculated_sample_mean: 'same as input samples', standard_error: 'same as input samples', sample_count: 'samples', column_count: 'columns' }, assumptions: 'Rows are independent samples.', plot_preview: { values: { column_index: [0, 1], mean: [2, 3], standard_error: [1, 1] }, stride: 1, source_points: 2 }, warnings: [], provenance })); + equalCountEnergyRanges.mockResolvedValue(ok({ bin_edges: [1, 2, 3, 4, 5], counts: [1, 1, 1, 1], n_ranges: 4, selected_count: 4, excluded_count: 0, energy_min: 1, energy_max: 5, energy_unit: 'keV', plot_preview: { values: { rank: [0, 1, 2, 3], energy: [1, 2, 3, 5] }, stride: 1, source_points: 4 }, warnings: [], provenance })); + }); + + it('shows a bounded empty state and disables submission while capabilities load', async () => { + capabilities.mockReturnValue(new Promise(() => undefined)); + renderWithProviders(); + expect(screen.getByText(/Loading installed Stingray capabilities/)).toBeInTheDocument(); + expect(within(activePanel()).getByRole('button', { name: 'Rebin data' })).toBeDisabled(); + expect(within(activePanel()).getByText(/Paste aligned x\/y arrays/)).toBeInTheDocument(); + }); + + it('submits the exact linear payload, blocks duplicate runs, and renders warnings, tables and plot', async () => { + let resolveRequest: ((value: ReturnType) => void) | undefined; + linearRebin.mockReturnValueOnce(new Promise((resolve) => { resolveRequest = resolve; })); + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + expect(screen.getByText(/Stingray 2\.2\.10 linear-error workaround/)).toBeInTheDocument(); + await replaceField('x values', '0.5 1.5 2.5 3.5'); + await replaceField('y values', '2 4 6 8'); + await replaceField(/1σ uncertainties/, '1 1 1 1'); + await replaceField(/Original dx/, '1'); + const button = within(activePanel()).getByRole('button', { name: 'Rebin data' }); + await userEvent.click(button); + expect(linearRebin).toHaveBeenCalledWith({ x: [0.5, 1.5, 2.5, 3.5], y: [2, 4, 6, 8], dx_new: 2, y_error: [1, 1, 1, 1], method: 'sum', dx: 1 }); + expect(button).toBeDisabled(); + button.click(); + expect(linearRebin).toHaveBeenCalledTimes(1); + await act(async () => resolveRequest?.(ok(rebinData))); + expect(await within(activePanel()).findByText('Squared standard uncertainties were supplied for Stingray 2.2.10.')).toBeInTheDocument(); + expect(within(activePanel()).getByText('Original exact values')).toBeInTheDocument(); + expect(within(activePanel()).getByText('Rebinned exact values')).toBeInTheDocument(); + expect(within(activePanel()).getByTestId('chart')).toHaveAttribute('data-traces', '2'); + }); + + it('rejects fractional-overlap linear uncertainty propagation before calling the API', async () => { + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + await replaceField('x values', '0.5 1.5 2.5 3.5'); + await replaceField('y values', '2 4 6 8'); + await replaceField(/1σ uncertainties/, '1 1 1 1'); + await replaceField('New dx', '2.5'); + expect(within(activePanel()).getByText(/requires an integer new dx \/ original dx ratio/)).toBeInTheDocument(); + expect(within(activePanel()).getByRole('button', { name: 'Rebin data' })).toBeDisabled(); + expect(linearRebin).not.toHaveBeenCalled(); + }); + + it('rejects a linear target wider than the covered input span', async () => { + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + await replaceField('x values', '0 0.1'); + await replaceField('y values', '1 2'); + await replaceField('New dx', '0.3'); + await replaceField(/Original dx/, '0.1'); + + expect( + within(activePanel()).getByText(/no complete output bin fits/) + ).toBeInTheDocument(); + expect(within(activePanel()).getByRole('button', { name: 'Rebin data' })).toBeDisabled(); + expect(linearRebin).not.toHaveBeenCalled(); + }); + + it('accepts an ULP-quantized uniform grid at a large absolute offset', async () => { + const x = Array.from({ length: 20 }, (_, index) => 1e12 + index * 0.1); + const y = Array.from({ length: 20 }, (_, index) => index + 1); + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + fireEvent.change(within(activePanel()).getByLabelText('x values'), { + target: { value: x.join(' ') }, + }); + fireEvent.change(within(activePanel()).getByLabelText('y values'), { + target: { value: y.join(' ') }, + }); + fireEvent.change(within(activePanel()).getByLabelText(/1σ uncertainties/), { + target: { value: Array(20).fill('0.1').join(' ') }, + }); + await replaceField('New dx', '0.2'); + await replaceField(/Original dx/, '0.1'); + + const button = within(activePanel()).getByRole('button', { name: 'Rebin data' }); + expect(button).toBeEnabled(); + await userEvent.click(button); + + await waitFor(() => + expect(linearRebin).toHaveBeenCalledWith({ + x, + y, + dx_new: 0.2, + y_error: Array(20).fill(0.1), + method: 'sum', + dx: 0.1, + }) + ); + }); + + it('switches to logarithmic rebinning and submits its mean-only contract', async () => { + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + const panel = within(activePanel()); + await userEvent.click(panel.getByLabelText('Rebin mode')); + await userEvent.click(await screen.findByRole('option', { name: 'Logarithmic' })); + await replaceField('x values', '1 2 3 4'); + await replaceField('y values', '2 4 6 8'); + await userEvent.click(within(activePanel()).getByRole('button', { name: 'Rebin data' })); + await waitFor(() => expect(logarithmicRebin).toHaveBeenCalledWith({ x: [1, 2, 3, 4], y: [2, 4, 6, 8], factor: 0.1, y_error: null, dx: null })); + expect(await within(activePanel()).findByTestId('chart')).toBeInTheDocument(); + }); + + it('preserves the last baseline result after a later soft failure', async () => { + estimateBaseline.mockResolvedValueOnce(ok(baselineData)).mockResolvedValueOnce({ success: false, data: null, message: 'Solver rejected the update', error: null }); + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + await openTab('Baseline'); + await replaceField('x values', '0 1 2'); + await replaceField('y values', '2 4 8'); + await userEvent.click(within(activePanel()).getByRole('button', { name: 'Estimate baseline' })); + await waitFor(() => expect(estimateBaseline).toHaveBeenCalledWith({ x: [0, 1, 2], y: [2, 4, 8], lam: 1e11, asymmetry: 0.001, iterations: 10, offset_correction: false })); + expect(await within(activePanel()).findByText('Baseline solver advisory.')).toBeInTheDocument(); + await replaceField('Lambda (smoothness)', '1000'); + await userEvent.click(within(activePanel()).getByRole('button', { name: 'Estimate baseline' })); + expect(await within(activePanel()).findByText('Solver rejected the update')).toBeInTheDocument(); + expect(within(activePanel()).getByText('Exact baseline result')).toBeInTheDocument(); + expect(within(activePanel()).getByTestId('chart')).toBeInTheDocument(); + }); + + it('derives exact window spellings from capabilities and renders coefficients', async () => { + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + await openTab('Windows'); + const panel = within(activePanel()); + await userEvent.click(panel.getByLabelText('Window type')); + await userEvent.click(await screen.findByRole('option', { name: 'blackmann' })); + await replaceField('Sample count N', '4'); + await userEvent.click(panel.getByRole('button', { name: 'Generate window' })); + await waitFor(() => expect(generateWindow).toHaveBeenCalledWith({ n_samples: 4, window_type: 'blackmann' })); + expect(await within(activePanel()).findByText('Exact coefficients')).toBeInTheDocument(); + expect(within(activePanel()).getByTestId('chart')).toBeInTheDocument(); + }); + + it('runs and switches among all three sampling operations with exact payloads', async () => { + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + await openTab('Sampling'); + let panel = within(activePanel()); + await userEvent.click(panel.getByRole('button', { name: 'Calculate bin time' })); + await waitFor(() => expect(optimalBinTime).toHaveBeenCalledWith({ fft_length: 512, proposed_bin_time: 2.1 })); + await userEvent.click(panel.getByLabelText('Sampling operation')); + await userEvent.click(await screen.findByRole('option', { name: 'Nearest power of two' })); + panel = within(activePanel()); + expect(panel.getByText(/Fail closed when installed Stingray/)).toBeInTheDocument(); + await userEvent.click(panel.getByRole('button', { name: 'Find nearest power' })); + await waitFor(() => expect(nearestPowerOfTwo).toHaveBeenCalledWith({ value: 6 })); + await userEvent.click(panel.getByLabelText('Sampling operation')); + await userEvent.click(await screen.findByRole('option', { name: 'Integer-sample segment' })); + panel = within(activePanel()); + await userEvent.click(panel.getByRole('button', { name: 'Adjust segment' })); + await waitFor(() => expect(adjustSegmentSize).toHaveBeenCalledWith({ segment_size: 10.1, dt: 1, tolerance: 0.01 })); + expect(await within(activePanel()).findByText('Adjustment result')).toBeInTheDocument(); + }); + + it('runs Poisson and standard-error operations with exact matrix parsing', async () => { + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + await openTab('Errors'); + await replaceField('Poisson counts', '0 4 9'); + await userEvent.click(within(activePanel()).getByRole('button', { name: 'Calculate Poisson errors' })); + await waitFor(() => expect(poissonErrors).toHaveBeenCalledWith({ counts: [0, 4, 9] })); + expect(await within(activePanel()).findByText('Exact Poisson errors')).toBeInTheDocument(); + await userEvent.click(within(activePanel()).getByLabelText('Error operation')); + await userEvent.click(await screen.findByRole('option', { name: 'Column-wise standard error' })); + await replaceField('Sample matrix', '1, 2\n3, 4'); + await userEvent.click(within(activePanel()).getByRole('button', { name: 'Calculate standard error' })); + await waitFor(() => expect(standardError).toHaveBeenCalledWith({ samples: [[1, 2], [3, 4]], mean: null })); + expect(await within(activePanel()).findByText('Exact standard errors')).toBeInTheDocument(); + }); + + it('matches backend absolute tolerance for a near-zero reference mean', async () => { + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + await openTab('Errors'); + await userEvent.click(within(activePanel()).getByLabelText('Error operation')); + await userEvent.click(await screen.findByRole('option', { name: 'Column-wise standard error' })); + await replaceField('Sample matrix', '-1\n1'); + await replaceField('Reference mean (optional)', '0.00000000005'); + + expect( + within(activePanel()).getByText(/must match the arithmetic sample mean/) + ).toBeInTheDocument(); + expect( + within(activePanel()).getByRole('button', { name: 'Calculate standard error' }) + ).toBeDisabled(); + expect(standardError).not.toHaveBeenCalled(); + }); + + it('keeps pasted and EventList energy sources mutually exclusive', async () => { + renderWithProviders(); + await screen.findByText(/Runtime caps:/); + await openTab('Energy ranges'); + await replaceField('Energy values', '1 2 3 5'); + await userEvent.click(within(activePanel()).getByRole('button', { name: 'Create energy ranges' })); + await waitFor(() => expect(equalCountEnergyRanges).toHaveBeenCalledWith({ n_ranges: 4, energies: [1, 2, 3, 5], energy_min: null, energy_max: null, energy_unit: 'keV' })); + expect(await within(activePanel()).findByText('Exact energy ranges')).toBeInTheDocument(); + await userEvent.click(within(activePanel()).getByLabelText('Energy source')); + await userEvent.click(await screen.findByRole('option', { name: 'Loaded EventList' })); + await userEvent.selectOptions(within(activePanel()).getByLabelText('Energy EventList'), 'energy-events'); + await userEvent.click(within(activePanel()).getByRole('button', { name: 'Create energy ranges' })); + await waitFor(() => expect(equalCountEnergyRanges).toHaveBeenLastCalledWith({ n_ranges: 4, event_list_name: 'energy-events', energy_min: null, energy_max: null, energy_unit: 'keV' })); + expect(within(activePanel()).getByTestId('chart')).toBeInTheDocument(); + }); + + it('surfaces a capabilities error and leaves scientific submissions disabled', async () => { + capabilities.mockResolvedValue({ success: false, data: null, message: 'Runtime inspection failed', error: null }); + renderWithProviders(); + expect(await screen.findByText(/Runtime inspection failed/)).toBeInTheDocument(); + expect(within(activePanel()).getByRole('button', { name: 'Rebin data' })).toBeDisabled(); + }); + + it('maps every typed API method to its exact route and renderer payload', async () => { + const { miscApi: actualApi } = await vi.importActual( + '@/api/miscApi' + ); + const response = { success: true, data: {}, message: 'ok', error: null }; + const get = vi.spyOn(apiClient, 'get').mockResolvedValue(response); + const post = vi.spyOn(apiClient, 'post').mockResolvedValue(response); + + await actualApi.capabilities(); + await actualApi.linearRebin({ x: [1, 2], y: [3, 4], dx_new: 2, method: 'mean' }); + await actualApi.logarithmicRebin({ x: [1, 2], y: [3, 4], factor: 0.2 }); + await actualApi.estimateBaseline({ x: [0, 1, 2], y: [2, 3, 5], lam: 10, asymmetry: 0.1, iterations: 4, offset_correction: true }); + await actualApi.generateWindow({ n_samples: 8, window_type: 'blackmann' }); + await actualApi.optimalBinTime({ fft_length: 512, proposed_bin_time: 2.1 }); + await actualApi.nearestPowerOfTwo({ value: 6 }); + await actualApi.adjustSegmentSize({ segment_size: 10.1, dt: 1, tolerance: 0.01 }); + await actualApi.poissonErrors({ counts: [0, 4] }); + await actualApi.standardError({ samples: [[1, 2], [3, 4]] }); + await actualApi.equalCountEnergyRanges({ n_ranges: 2, event_list_name: 'energy-events', energy_unit: 'keV' }); + + expect(get).toHaveBeenCalledWith('/api/utilities/misc/capabilities'); + expect(post.mock.calls).toEqual([ + ['/api/utilities/misc/rebin/linear', { x: [1, 2], y: [3, 4], dx_new: 2, y_error: null, method: 'mean', dx: null }], + ['/api/utilities/misc/rebin/logarithmic', { x: [1, 2], y: [3, 4], factor: 0.2, y_error: null, dx: null }], + ['/api/utilities/misc/baseline', { x: [0, 1, 2], y: [2, 3, 5], lam: 10, asymmetry: 0.1, iterations: 4, offset_correction: true }], + ['/api/utilities/misc/window', { n_samples: 8, window_type: 'blackmann' }], + ['/api/utilities/misc/sampling/optimal-bin-time', { fft_length: 512, proposed_bin_time: 2.1 }], + ['/api/utilities/misc/sampling/nearest-power-of-two', { value: 6 }], + ['/api/utilities/misc/sampling/segment-size', { segment_size: 10.1, dt: 1, tolerance: 0.01 }], + ['/api/utilities/misc/errors/poisson', { counts: [0, 4] }], + ['/api/utilities/misc/errors/standard', { samples: [[1, 2], [3, 4]], mean: null }], + ['/api/utilities/misc/energy-ranges', { n_ranges: 2, energies: null, event_list_name: 'energy-events', energy_min: null, energy_max: null, energy_unit: 'keV' }], + ]); + get.mockRestore(); + post.mockRestore(); + }); +}); diff --git a/src/pages/Utilities/Misc/index.tsx b/src/pages/Utilities/Misc/index.tsx new file mode 100644 index 0000000..54b6653 --- /dev/null +++ b/src/pages/Utilities/Misc/index.tsx @@ -0,0 +1,1022 @@ +import React from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Checkbox, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import { useQuery } from '@tanstack/react-query'; +import type { Data } from 'plotly.js'; +import PageTemplate from '@/components/common/PageTemplate'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import { + NumericResultTable, + ProvenancePanel, + UtilityWarnings, + type ResultColumn, + type ResultCell, +} from '@/components/utilities/UtilityResult'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { + miscApi, + type BaselineData, + type EnergyRangesData, + type MiscCapabilities, + type NamedPlotPreview, + type NearestPowerOfTwoData, + type NullableNumber, + type OptimalBinTimeData, + type PoissonErrorData, + type RebinData, + type SegmentSizeData, + type StandardErrorData, + type UtilityData, + type WindowData, +} from '@/api/miscApi'; +import { + parseNumericArray, + parseNumericMatrix, + parsePositiveInteger, +} from '@/utils/utilityInputs'; +import { parseNumber, parsePositiveNumber } from '@/utils/numbers'; + +type MainTab = 'rebin' | 'baseline' | 'window' | 'sampling' | 'errors' | 'energy'; +type RebinMode = 'linear' | 'logarithmic'; +type SamplingMode = 'optimal' | 'power' | 'segment'; +type ErrorMode = 'poisson' | 'standard'; + +const MAIN_TABS: Array<{ value: MainTab; label: string }> = [ + { value: 'rebin', label: 'Rebinning' }, + { value: 'baseline', label: 'Baseline' }, + { value: 'window', label: 'Windows' }, + { value: 'sampling', label: 'Sampling' }, + { value: 'errors', label: 'Errors' }, + { value: 'energy', label: 'Energy ranges' }, +]; + +const SERIES_COLUMNS: ResultColumn[] = [ + { key: 'index', label: 'Index' }, + { key: 'x', label: 'x', unit: 'input x unit' }, + { key: 'y', label: 'y', unit: 'input y unit' }, + { key: 'yError', label: '1σ uncertainty', unit: 'input y unit' }, + { key: 'samples', label: 'Samples/bin' }, +]; + +interface OptionalNumberResult { + value: number | null; + error: string | null; +} + +function parseOptionalNumber(text: string, label: string): OptionalNumberResult { + if (text.trim() === '') return { value: null, error: null }; + const value = parseNumber(text); + return value === null + ? { value: null, error: `${label} must be a finite number` } + : { value, error: null }; +} + +function numberUlp(value: number): number { + const magnitude = Math.abs(value); + if (magnitude === 0) return Number.MIN_VALUE; + const exponent = Math.floor(Math.log2(magnitude)); + return Math.max(Number.MIN_VALUE, 2 ** (exponent - 52)); +} + +function matchesUniformSpacing(values: number[], spacing: number): boolean { + const ordinaryTolerance = + 1e-10 * Math.abs(spacing) + 16 * Number.EPSILON * Math.max(1, Math.abs(spacing)); + return values.slice(1).every((value, index) => { + const observed = value - values[index]; + const endpointRoundoff = 0.5 * (numberUlp(value) + numberUlp(values[index])); + const subtractionRoundoff = 0.5 * numberUlp(observed); + return ( + Math.abs(observed - spacing) <= + Math.max(ordinaryTolerance, endpointRoundoff + subtractionRoundoff) + ); + }); +} + +function nullableAt(values: NullableNumber[] | undefined, index: number): NullableNumber { + return values?.[index] ?? null; +} + +function samplesAt( + samples: RebinData['rebinned']['samples_per_bin'], + index: number +): NullableNumber { + return Array.isArray(samples) ? (samples[index] ?? null) : samples; +} + +function previewValues(preview: NamedPlotPreview, key: string): NullableNumber[] { + return preview.values[key] ?? []; +} + +function buildSeriesRows(series: RebinData['original'] | RebinData['rebinned']): Array> { + return series.x.map((x, index) => ({ + index, + x, + y: nullableAt(series.y, index), + yError: nullableAt(series.y_error ?? undefined, index), + samples: 'samples_per_bin' in series ? samplesAt(series.samples_per_bin, index) : null, + })); +} + +function WorkbenchPanel({ + active, + value, + children, +}: { + active: boolean; + value: MainTab; + children: React.ReactNode; +}): React.ReactElement { + return ( + + ); +} + +function ParameterCard({ children }: { children: React.ReactNode }): React.ReactElement { + return ( + + + {children} + + + ); +} + +function RunButton({ + running, + disabled, + children, + onClick, +}: { + running: boolean; + disabled: boolean; + children: React.ReactNode; + onClick: () => void; +}): React.ReactElement { + return ( + + ); +} + +function ResultCard({ + result, + running, + error, + warnings, + emptyText, + children, +}: { + result: T | null; + running: boolean; + error: string | null; + warnings?: string[]; + emptyText: string; + children: (value: T) => React.ReactNode; +}): React.ReactElement { + return ( + + + + + Result + {running && result ? ( + + Updating; the last successful result remains visible. + + ) : null} + + {error ? {error} : null} + + {result ? ( + <> + + {children(result)} + } + /> + + ) : ( + + {running ? : null} + {emptyText} + + )} + + + + ); +} + +function RebinWorkbench({ capabilities }: { capabilities?: MiscCapabilities }): React.ReactElement { + const [mode, setMode] = React.useState('linear'); + const [xText, setXText] = React.useState(''); + const [yText, setYText] = React.useState(''); + const [errorText, setErrorText] = React.useState(''); + const [resolution, setResolution] = React.useState('2'); + const [factor, setFactor] = React.useState('0.1'); + const [oldResolution, setOldResolution] = React.useState(''); + const [method, setMethod] = React.useState<'sum' | 'mean'>('sum'); + const linearRunner = useAnalysisRunner('Linear rebinning'); + const logarithmicRunner = useAnalysisRunner('Logarithmic rebinning'); + const activeRunner = mode === 'linear' ? linearRunner : logarithmicRunner; + const maxValues = capabilities?.limits.max_array_values; + const x = React.useMemo( + () => parseNumericArray(xText, 'x values', maxValues), + [xText, maxValues] + ); + const y = React.useMemo( + () => parseNumericArray(yText, 'y values', maxValues), + [yText, maxValues] + ); + const yError = React.useMemo( + () => + errorText.trim() === '' + ? { value: null, error: null } + : parseNumericArray(errorText, 'Uncertainties', maxValues), + [errorText, maxValues] + ); + const dx = parseOptionalNumber(oldResolution, 'Original dx'); + const targetResolution = parsePositiveNumber(resolution); + const growth = parsePositiveNumber(factor); + + let validationError = !capabilities ? 'Runtime capabilities are loading' : x.error ?? y.error; + if (!validationError && x.value && x.value.length < 2) validationError = 'x needs at least two values'; + if (!validationError && y.value && y.value.length < 2) validationError = 'y needs at least two values'; + if (!validationError && x.value && y.value && x.value.length !== y.value.length) { + validationError = 'x, y and optional uncertainties must have equal lengths'; + } + if (!validationError && x.value) { + const bad = x.value.findIndex((value, index) => index > 0 && value <= x.value![index - 1]); + if (bad >= 0) validationError = `x must be strictly increasing (problem at value ${bad + 1})`; + if (mode === 'logarithmic' && x.value.some((value) => value <= 0)) { + validationError = 'Logarithmic rebinning requires positive x values'; + } + } + if (!validationError && yError.error) validationError = yError.error; + if (!validationError && yError.value && x.value && yError.value.length !== x.value.length) { + validationError = 'x, y and optional uncertainties must have equal lengths'; + } + if (!validationError && yError.value?.some((value) => value < 0)) { + validationError = 'Uncertainties must be non-negative'; + } + if (!validationError && dx.error) validationError = dx.error; + if (!validationError && dx.value !== null && dx.value <= 0) { + validationError = 'Original dx must be positive'; + } + if (!validationError && mode === 'linear' && targetResolution === null) { + validationError = 'New dx must be a positive number'; + } + if (!validationError && mode === 'linear' && targetResolution !== null && x.value) { + const observedSpacing = x.value.slice(1).map((value, index) => value - x.value![index]); + let requiredSpacing = + dx.value ?? [...observedSpacing].sort((left, right) => left - right)[Math.floor(observedSpacing.length / 2)]; + let largestSpacing = dx.value ?? 0; + if (dx.value === null) { + for (const spacing of observedSpacing) largestSpacing = Math.max(largestSpacing, spacing); + } + if (targetResolution < largestSpacing) { + validationError = 'New dx must be at least as large as every input resolution'; + } else { + const tailResolution = dx.value ?? observedSpacing[observedSpacing.length - 1]; + const coveredSpan = + tailResolution === undefined + ? Number.NaN + : x.value[x.value.length - 1] - x.value[0] + tailResolution; + const spanTolerance = + 8 * Number.EPSILON * Math.max(1, Math.abs(coveredSpan), Math.abs(targetResolution)); + if (!Number.isFinite(coveredSpan) || coveredSpan <= 0) { + validationError = 'The covered input span must be a positive finite number'; + } else if (targetResolution > coveredSpan + spanTolerance) { + validationError = `New dx is wider than the covered input span (${coveredSpan.toPrecision(8)}), so no complete output bin fits`; + } + } + if (!validationError && yError.value && requiredSpacing !== undefined) { + if (!matchesUniformSpacing(x.value, requiredSpacing)) { + validationError = 'Linear uncertainty propagation requires uniform x spacing; an explicit dx must match that spacing'; + } else { + const nearestFactor = Math.round(targetResolution / requiredSpacing); + if (nearestFactor >= 1) { + const candidateSpacing = targetResolution / nearestFactor; + if (matchesUniformSpacing(x.value, candidateSpacing)) requiredSpacing = candidateSpacing; + } + const ratio = targetResolution / requiredSpacing; + if (Math.abs(ratio - Math.round(ratio)) > 1e-10 * Math.max(1, Math.abs(ratio))) { + validationError = 'Linear uncertainty propagation requires an integer new dx / original dx ratio; fractional-overlap errors are unsupported in Stingray 2.2.10'; + } + } + } + } + if (!validationError && mode === 'logarithmic' && growth === null) { + validationError = 'Growth factor must be a positive number'; + } + + const handleRun = (): void => { + if (validationError || !x.value || !y.value) return; + if (mode === 'linear' && targetResolution !== null) { + void linearRunner.run(() => + miscApi.linearRebin({ + x: x.value!, + y: y.value!, + dx_new: targetResolution, + y_error: yError.value, + method, + dx: dx.value, + }) + ); + } else if (mode === 'logarithmic' && growth !== null) { + void logarithmicRunner.run(() => + miscApi.logarithmicRebin({ + x: x.value!, + y: y.value!, + factor: growth, + y_error: yError.value, + dx: dx.value, + }) + ); + } + }; + + return ( + + + + Rebin parameters + + Rebin mode + + + setXText(event.target.value)} + multiline + minRows={3} + size="small" + placeholder="0.5, 1.5, 2.5, 3.5" + helperText="Comma, whitespace or newline separated; strictly increasing." + /> + setYText(event.target.value)} + multiline + minRows={3} + size="small" + placeholder="2, 4, 6, 8" + helperText="Same length as x." + /> + setErrorText(event.target.value)} + multiline + minRows={2} + size="small" + helperText="Independent, non-negative standard uncertainties in y units." + /> + {mode === 'linear' ? ( + <> + setResolution(event.target.value)} + size="small" + helperText="Same units as x; must not be finer than the input resolution." + /> + + Aggregation + + + + ) : ( + setFactor(event.target.value)} + size="small" + helperText="Positive f; output widths grow by 1 + f. Log rebinning always averages." + /> + )} + setOldResolution(event.target.value)} + size="small" + helperText="Leave blank to infer spacing from x." + /> + {validationError && (xText !== '' || yText !== '') ? ( + {validationError} + ) : null} + + Rebin data + + + {capabilities?.rebin.linear_uncertainty_workaround_required ? ( + + Stingray 2.2.10 linear-error workaround: standard uncertainties are + squared before the installed linear helper and reported as quadrature 1σ errors. The + response records this compatibility path and upstream reference{' '} + {capabilities.rebin.linear_uncertainty_workaround_reference}. Supported domain:{' '} + {capabilities.rebin.linear_uncertainty_support}. + + ) : null} + + + + {(result) => { + const original = result.plot_preview.original; + const rebinned = result.plot_preview.rebinned; + const traces: Data[] = [ + { + x: previewValues(original, 'x'), + y: previewValues(original, 'y'), + type: 'scattergl', + mode: 'lines+markers', + name: 'Original', + } as Data, + { + x: previewValues(rebinned, 'x'), + y: previewValues(rebinned, 'y'), + type: 'scattergl', + mode: 'lines+markers', + name: 'Rebinned', + } as Data, + ]; + return ( + <> + + x and every dx use the same input unit. Mean preserves y units; sum reports the + summed y quantity. {result.error_semantics ?? 'No uncertainties were supplied.'} + + + + + + + + + + + + ); + }} + + + + ); +} + +function BaselineWorkbench({ capabilities }: { capabilities?: MiscCapabilities }): React.ReactElement { + const [xText, setXText] = React.useState(''); + const [yText, setYText] = React.useState(''); + const [lambda, setLambda] = React.useState('100000000000'); + const [asymmetry, setAsymmetry] = React.useState('0.001'); + const [iterations, setIterations] = React.useState('10'); + const [offsetCorrection, setOffsetCorrection] = React.useState(false); + const runner = useAnalysisRunner('Baseline estimation'); + const maxValues = capabilities?.limits.max_array_values; + const x = React.useMemo( + () => parseNumericArray(xText, 'x values', maxValues), + [xText, maxValues] + ); + const y = React.useMemo( + () => parseNumericArray(yText, 'y values', maxValues), + [yText, maxValues] + ); + const smoothing = parsePositiveNumber(lambda); + const probability = parseNumber(asymmetry); + const iterationCount = parsePositiveInteger(iterations); + let validationError = !capabilities ? 'Runtime capabilities are loading' : x.error ?? y.error; + if (!validationError && x.value && x.value.length < 3) validationError = 'At least three x/y points are required'; + if (!validationError && x.value && y.value && x.value.length !== y.value.length) validationError = 'x and y must have equal lengths'; + if (!validationError && x.value?.some((value, index) => index > 0 && value <= x.value![index - 1])) validationError = 'x must be strictly increasing'; + if (!validationError && smoothing === null) validationError = 'Lambda must be positive'; + if (!validationError && (probability === null || probability <= 0 || probability >= 1)) validationError = 'Asymmetry must be strictly between 0 and 1'; + if (!validationError && (iterationCount === null || iterationCount > capabilities!.limits.max_baseline_iterations)) validationError = `Iterations must be an integer from 1 to ${capabilities!.limits.max_baseline_iterations}`; + + const handleRun = (): void => { + if (validationError || !x.value || !y.value || smoothing === null || probability === null || iterationCount === null) return; + void runner.run(() => miscApi.estimateBaseline({ + x: x.value!, + y: y.value!, + lam: smoothing, + asymmetry: probability, + iterations: iterationCount, + offset_correction: offsetCorrection, + })); + }; + + return ( + + + + Asymmetric least squares + + Estimates a slowly varying baseline, then returns both the fitted baseline and + baseline-subtracted series. + + setXText(event.target.value)} multiline minRows={3} size="small" helperText="Strictly increasing; arbitrary x units." /> + setYText(event.target.value)} multiline minRows={3} size="small" helperText="Same length and units as the measured series." /> + setLambda(event.target.value)} size="small" helperText="Positive; larger values produce a smoother baseline." /> + setAsymmetry(event.target.value)} size="small" helperText="Dimensionless and strictly between 0 and 1." /> + setIterations(event.target.value)} size="small" helperText={`Integer; runtime cap ${capabilities?.limits.max_baseline_iterations ?? '…'}.`} /> + setOffsetCorrection(event.target.checked)} />} label="Apply Stingray offset correction" /> + {validationError && (xText !== '' || yText !== '') ? {validationError} : null} + Estimate baseline + + + + + {(result) => { + const values = result.plot_preview.values; + const rows = result.x.map((value, index) => ({ index, x: value, original: result.original[index], baseline: result.baseline[index], corrected: result.corrected[index] })); + return ( + <> + x keeps the supplied x unit. Original, baseline and corrected values all share the supplied y unit; corrected = original − baseline. + + + + ); + }} + + + + ); +} + +function WindowWorkbench({ capabilities }: { capabilities?: MiscCapabilities }): React.ReactElement { + const [sampleCount, setSampleCount] = React.useState('64'); + const [windowType, setWindowType] = React.useState(''); + const runner = useAnalysisRunner('Window generation'); + React.useEffect(() => { + if (!windowType && capabilities?.window_types.length) setWindowType(capabilities.window_types[0]); + }, [capabilities, windowType]); + const count = parsePositiveInteger(sampleCount); + const cap = capabilities?.limits.max_exact_output_values; + const validationError = !capabilities + ? 'Runtime window capabilities are loading' + : count === null || count < 2 || count > cap! + ? `Sample count must be an integer from 2 to ${cap!.toLocaleString()}` + : !capabilities.window_types.includes(windowType) + ? 'Choose a window exposed by the installed Stingray runtime' + : null; + const handleRun = (): void => { + if (validationError || count === null) return; + void runner.run(() => miscApi.generateWindow({ n_samples: count, window_type: windowType })); + }; + return ( + + + + Analysis window + The allowlist below comes from the installed public Stingray implementation, including its exact spellings. + setSampleCount(event.target.value)} size="small" helperText="Dimensionless sample count; N ≥ 2." /> + + Window type + + + {validationError && capabilities ? {validationError} : null} + Generate window + + + + + {(result) => { + const rows = result.sample_index.map((index, position) => ({ index, coefficient: result.window[position] })); + const summaryRows = Object.entries(result.summary).map(([metric, value]) => ({ metric, value })); + return ( + <> + Sample index and coefficients are dimensionless. ENBW is reported in FFT-bin units; coherent gain is the coefficient mean. + + + + + + + ); + }} + + + + ); +} + +function SamplingWorkbench({ capabilities }: { capabilities?: MiscCapabilities }): React.ReactElement { + const [mode, setMode] = React.useState('optimal'); + const [fftLength, setFftLength] = React.useState('512'); + const [proposedBinTime, setProposedBinTime] = React.useState('2.1'); + const [powerValue, setPowerValue] = React.useState('6'); + const [segmentSize, setSegmentSize] = React.useState('10.1'); + const [dt, setDt] = React.useState('1'); + const [tolerance, setTolerance] = React.useState('0.01'); + type SamplingResult = OptimalBinTimeData | NearestPowerOfTwoData | SegmentSizeData; + const optimalRunner = useAnalysisRunner('Optimal FFT bin time'); + const powerRunner = useAnalysisRunner('Nearest power of two'); + const segmentRunner = useAnalysisRunner('Segment-size adjustment'); + const fft = parsePositiveNumber(fftLength); + const proposed = parsePositiveNumber(proposedBinTime); + const requestedPower = parsePositiveInteger(powerValue); + const requestedSegment = parsePositiveNumber(segmentSize); + const sampleTime = parsePositiveNumber(dt); + const toleranceValue = parseNumber(tolerance); + let validationError: string | null = !capabilities ? 'Runtime capabilities are loading' : null; + if (!validationError && mode === 'optimal') { + if (fft === null || proposed === null) validationError = 'FFT length and proposed bin time must be positive'; + else if (proposed > fft) validationError = 'Proposed bin time must not exceed FFT length'; + else if (fft / proposed > capabilities!.limits.max_fft_samples) validationError = 'Requested FFT exceeds the runtime sample cap'; + } else if (!validationError && mode === 'power') { + if (requestedPower === null || requestedPower < 2 || requestedPower > capabilities!.limits.max_fft_samples) validationError = `Value must be an integer from 2 to ${capabilities!.limits.max_fft_samples.toLocaleString()}`; + } else if (!validationError && mode === 'segment') { + if (requestedSegment === null || sampleTime === null) validationError = 'Segment size and dt must be positive'; + else if (requestedSegment < sampleTime) validationError = 'Segment size must be at least one dt'; + else if (requestedSegment / sampleTime > capabilities!.limits.max_fft_samples) validationError = 'Segment exceeds the runtime sample cap'; + else if (toleranceValue === null || toleranceValue < 0 || toleranceValue >= 1) validationError = 'Tolerance must be at least 0 and less than 1'; + } + + const handleRun = (): void => { + if (validationError) return; + if (mode === 'optimal' && fft !== null && proposed !== null) void optimalRunner.run(() => miscApi.optimalBinTime({ fft_length: fft, proposed_bin_time: proposed })); + if (mode === 'power' && requestedPower !== null) void powerRunner.run(() => miscApi.nearestPowerOfTwo({ value: requestedPower })); + if (mode === 'segment' && requestedSegment !== null && sampleTime !== null && toleranceValue !== null) void segmentRunner.run(() => miscApi.adjustSegmentSize({ segment_size: requestedSegment, dt: sampleTime, tolerance: toleranceValue })); + }; + const activeRunner = mode === 'optimal' ? optimalRunner : mode === 'power' ? powerRunner : segmentRunner; + const resultRows = activeRunner.result + ? mode === 'optimal' + ? [{ requested: (activeRunner.result as OptimalBinTimeData).requested_bin_time, adjusted: (activeRunner.result as OptimalBinTimeData).adjusted_bin_time, samples: (activeRunner.result as OptimalBinTimeData).sample_count, delta: (activeRunner.result as OptimalBinTimeData).delta, fractionalChange: (activeRunner.result as OptimalBinTimeData).fractional_change }] + : mode === 'power' + ? [{ requested: (activeRunner.result as NearestPowerOfTwoData).requested_value, adjusted: (activeRunner.result as NearestPowerOfTwoData).nearest_power_of_two, delta: (activeRunner.result as NearestPowerOfTwoData).delta, fractionalChange: (activeRunner.result as NearestPowerOfTwoData).fractional_change }] + : [{ requested: (activeRunner.result as SegmentSizeData).requested_segment_size, adjusted: (activeRunner.result as SegmentSizeData).adjusted_segment_size, samples: (activeRunner.result as SegmentSizeData).sample_count, delta: (activeRunner.result as SegmentSizeData).delta, fractionalChange: (activeRunner.result as SegmentSizeData).fractional_change }] + : []; + return ( + + + + Sampling and binning + + Sampling operation + + + {mode === 'optimal' ? <> + setFftLength(event.target.value)} size="small" helperText="Duration/span in the chosen time unit." /> + setProposedBinTime(event.target.value)} size="small" helperText="Same time unit as FFT length." /> + : null} + {mode === 'power' ? setPowerValue(event.target.value)} size="small" helperText="Dimensionless integer ≥ 2; midpoint ties choose the upper power." /> : null} + {mode === 'power' && capabilities ? ( + + {capabilities.runtime_advisories.nearest_power_of_two} + + ) : null} + {mode === 'segment' ? <> + setSegmentSize(event.target.value)} size="small" helperText="Duration in the chosen time unit." /> + setDt(event.target.value)} size="small" helperText="Same time unit as segment size." /> + setTolerance(event.target.value)} size="small" helperText="Absolute fraction of one sample; 0 ≤ tolerance < 1." /> + : null} + {validationError && capabilities ? {validationError} : null} + {mode === 'optimal' ? 'Calculate bin time' : mode === 'power' ? 'Find nearest power' : 'Adjust segment'} + + + + + {(result) => <> + {mode === 'power' ? 'All values are dimensionless integers.' : 'Requested, adjusted and delta values use the same time unit supplied in the form. Fractional change is dimensionless.'} + + {result.changed ? The requested value was changed; see the warning and signed delta above. : The requested value already satisfies the helper.} + } + + + + ); +} + +function ErrorWorkbench({ capabilities }: { capabilities?: MiscCapabilities }): React.ReactElement { + const [mode, setMode] = React.useState('poisson'); + const [countsText, setCountsText] = React.useState(''); + const [matrixText, setMatrixText] = React.useState(''); + const [meanText, setMeanText] = React.useState(''); + type ErrorResult = PoissonErrorData | StandardErrorData; + const poissonRunner = useAnalysisRunner('Poisson errors'); + const standardRunner = useAnalysisRunner('Standard error'); + const maxValues = capabilities?.limits.max_array_values; + const counts = React.useMemo(() => parseNumericArray(countsText, 'Counts', maxValues), [countsText, maxValues]); + const matrix = React.useMemo(() => parseNumericMatrix(matrixText), [matrixText]); + const providedMean = React.useMemo(() => meanText.trim() === '' ? { value: null, error: null } : parseNumericArray(meanText, 'Mean', maxValues), [meanText, maxValues]); + let validationError: string | null = !capabilities ? 'Runtime capabilities are loading' : null; + if (!validationError && mode === 'poisson') { + validationError = counts.error; + if (!validationError && counts.value?.some((value) => !Number.isInteger(value) || value < 0)) validationError = 'Poisson counts must be non-negative integers'; + if (!validationError && counts.value?.some((value) => value > capabilities!.limits.max_poisson_count)) validationError = `Largest count exceeds the ${capabilities!.limits.max_poisson_count.toLocaleString()} lookup cap`; + } else if (!validationError) { + validationError = matrix.error ?? providedMean.error; + if (!validationError && matrix.value && providedMean.value && matrix.value[0].length !== providedMean.value.length) validationError = `Mean must contain exactly ${matrix.value[0].length} values`; + if (!validationError && matrix.value && providedMean.value) { + const arithmeticMean = matrix.value[0].map((_value, column) => + matrix.value!.reduce((sum, row) => sum + row[column], 0) / matrix.value!.length + ); + const mismatch = providedMean.value.findIndex((value, column) => { + const expected = arithmeticMean[column]; + // Mirror numpy.allclose(rtol=1e-10, atol=1e-12) in the backend, + // especially around a zero reference mean where relative tolerance + // alone is not meaningful. + return Math.abs(value - expected) > 1e-12 + 1e-10 * Math.abs(expected); + }); + if (mismatch >= 0) { + validationError = `Reference mean column ${mismatch + 1} must match the arithmetic sample mean; leave it blank to calculate automatically`; + } + } + if (!validationError && matrix.value && matrix.value.length * matrix.value[0].length > capabilities!.limits.max_matrix_cells) validationError = `Matrix exceeds the ${capabilities!.limits.max_matrix_cells.toLocaleString()}-cell cap`; + } + const handleRun = (): void => { + if (validationError) return; + if (mode === 'poisson' && counts.value) void poissonRunner.run(() => miscApi.poissonErrors({ counts: counts.value! })); + if (mode === 'standard' && matrix.value) void standardRunner.run(() => miscApi.standardError({ samples: matrix.value!, mean: providedMean.value })); + }; + const activeRunner = mode === 'poisson' ? poissonRunner : standardRunner; + return ( + + + + Statistical error helper + + Error operation + + + {mode === 'poisson' ? setCountsText(event.target.value)} multiline minRows={4} size="small" helperText="Independent, non-negative integer counts." /> : <> + setMatrixText(event.target.value)} multiline minRows={5} size="small" placeholder={'1, 2, 3\n2, 4, 6'} helperText="One independent sample per line; every row must have the same columns." /> + setMeanText(event.target.value)} multiline minRows={2} size="small" helperText="Leave blank for the arithmetic column mean. A supplied mean must match it, or the request is rejected." /> + } + {validationError && (countsText !== '' || matrixText !== '') ? {validationError} : null} + {mode === 'poisson' ? 'Calculate Poisson errors' : 'Calculate standard error'} + + + + + {(result) => mode === 'poisson' ? (() => { + const value = result as PoissonErrorData; + const rows = value.counts.map((count, index) => ({ index, count, error: value.symmetric_error[index] })); + return <> + {value.assumptions} Count and error units are counts; the confidence convention is {value.confidence_sigma}σ. + + + ; + })() : (() => { + const value = result as StandardErrorData; + const rows = value.mean.map((mean, index) => ({ column: index, mean, sampleMean: value.calculated_sample_mean[index], standardError: value.standard_error[index] })); + return <> + {value.assumptions} Mean and standard error retain each input column's units. Samples: {value.sample_count}; mean source: {value.mean_source}. + + + ; + })()} + + + + ); +} + +function EnergyRangesWorkbench({ capabilities }: { capabilities?: MiscCapabilities }): React.ReactElement { + const [source, setSource] = React.useState<'pasted' | 'event'>('pasted'); + const [energiesText, setEnergiesText] = React.useState(''); + const [eventList, setEventList] = React.useState(''); + const [rangeCount, setRangeCount] = React.useState('4'); + const [minimum, setMinimum] = React.useState(''); + const [maximum, setMaximum] = React.useState(''); + const [unit, setUnit] = React.useState('keV'); + const runner = useAnalysisRunner('Equal-count energy ranges'); + const maxValues = capabilities?.limits.max_array_values; + const energies = React.useMemo(() => parseNumericArray(energiesText, 'Energies', maxValues), [energiesText, maxValues]); + const nRanges = parsePositiveInteger(rangeCount); + const lower = parseOptionalNumber(minimum, 'Minimum energy'); + const upper = parseOptionalNumber(maximum, 'Maximum energy'); + let validationError: string | null = !capabilities ? 'Runtime capabilities are loading' : null; + if (!validationError && (nRanges === null || nRanges > capabilities!.limits.max_energy_ranges)) validationError = `Range count must be an integer from 1 to ${capabilities!.limits.max_energy_ranges.toLocaleString()}`; + if (!validationError && source === 'pasted') { + validationError = energies.error; + if (!validationError && energies.value && energies.value.length < 2) validationError = 'At least two energy values are required'; + if (!validationError && energies.value && nRanges !== null && energies.value.length < nRanges) validationError = `At least ${nRanges} energy values are required for ${nRanges} ranges`; + if (!validationError && unit.trim().length === 0) validationError = 'Energy unit is required for pasted values'; + } + if (!validationError && source === 'event' && eventList === '') validationError = 'Choose an EventList with energy data'; + if (!validationError && lower.error) validationError = lower.error; + if (!validationError && upper.error) validationError = upper.error; + if (!validationError && lower.value !== null && upper.value !== null && lower.value >= upper.value) validationError = 'Minimum energy must be smaller than maximum energy'; + if (!validationError && source === 'pasted' && energies.value && nRanges !== null) { + const selected = energies.value.filter( + (value) => + (lower.value === null || value >= lower.value) && + (upper.value === null || value <= upper.value) + ); + if (selected.length < nRanges) { + validationError = `Only ${selected.length} energies fall inside the requested limits; ${nRanges} are required`; + } else if (new Set(selected).size < nRanges) { + validationError = 'Too few distinct energies remain for positive-width ranges; reduce the range count'; + } + } + const handleRun = (): void => { + if (validationError || nRanges === null) return; + const commonParams = { + n_ranges: nRanges, + energy_min: lower.value, + energy_max: upper.value, + energy_unit: source === 'event' ? 'keV' : unit.trim(), + }; + if (source === 'pasted' && energies.value) { + void runner.run(() => + miscApi.equalCountEnergyRanges({ ...commonParams, energies: energies.value! }) + ); + } else if (source === 'event') { + void runner.run(() => + miscApi.equalCountEnergyRanges({ ...commonParams, event_list_name: eventList }) + ); + } + }; + return ( + + + + Equal-count energy ranges + Percentile edges aim to place the same number of selected events in each positive-width energy range. + + Energy source + + + {source === 'pasted' ? <> + setEnergiesText(event.target.value)} multiline minRows={5} size="small" helperText="Finite values; comma, whitespace or newline separated." /> + setUnit(event.target.value)} size="small" helperText="For example keV. The backend preserves this label." /> + : <> + + Loaded EventList energy follows Stingray's public keV convention. The source object is copied for read-only calculation and is not mutated. + } + setRangeCount(event.target.value)} size="small" helperText="Positive integer; every returned range must have nonzero width." /> + setMinimum(event.target.value)} size="small" helperText="Inclusive lower filter in the selected energy unit." /> + setMaximum(event.target.value)} size="small" helperText="Inclusive upper filter in the selected energy unit." /> + {validationError && (energiesText !== '' || eventList !== '' || minimum !== '' || maximum !== '') ? {validationError} : null} + Create energy ranges + + + + + {(result) => { + const rows = result.counts.map((count, index) => ({ bin: index + 1, lower: result.bin_edges[index], upper: result.bin_edges[index + 1], count })); + return <> + Edges use {result.energy_unit}; counts are dimensionless event counts. {result.selected_count.toLocaleString()} selected and {result.excluded_count.toLocaleString()} excluded by the requested limits. + + + ; + }} + + + + ); +} + +const MiscPage: React.FC = () => { + const [tab, setTab] = React.useState('rebin'); + const [visitedTabs, setVisitedTabs] = React.useState>( + () => new Set(['rebin']) + ); + const capabilitiesQuery = useQuery({ + queryKey: ['misc-utility-capabilities'], + queryFn: async () => { + const response = await miscApi.capabilities(); + if (!response.success || !response.data) throw new Error(response.error || response.message || 'Failed to load Misc utility capabilities'); + return response.data; + }, + staleTime: Number.POSITIVE_INFINITY, + }); + const capabilities = capabilitiesQuery.data; + const handleTabChange = (_event: React.SyntheticEvent, value: MainTab): void => { + setTab(value); + setVisitedTabs((current) => { + if (current.has(value)) return current; + const next = new Set(current); + next.add(value); + return next; + }); + }; + return ( + + + {capabilitiesQuery.isPending ? }>Loading installed Stingray capabilities and allocation limits… : null} + {capabilitiesQuery.isError ? Could not load runtime capabilities: {capabilitiesQuery.error instanceof Error ? capabilitiesQuery.error.message : 'unknown error'} : null} + {capabilities ? Renderer inputs are checked before submission. Runtime caps: {capabilities.limits.max_array_values.toLocaleString()} values per array, {capabilities.limits.max_matrix_cells.toLocaleString()} matrix cells and {capabilities.limits.max_exact_output_values.toLocaleString()} exact output values. Plot previews may be decimated, but the paginated tables use the exact returned arrays. : null} + + + {MAIN_TABS.map((item) => ( + + ))} + + + + + {visitedTabs.has('baseline') ? : null} + {visitedTabs.has('window') ? : null} + {visitedTabs.has('sampling') ? : null} + {visitedTabs.has('errors') ? : null} + {visitedTabs.has('energy') ? : null} + + ); +}; + +export default MiscPage; diff --git a/src/pages/Utilities/MissionIO/IdentificationPanel.tsx b/src/pages/Utilities/MissionIO/IdentificationPanel.tsx new file mode 100644 index 0000000..d38f79d --- /dev/null +++ b/src/pages/Utilities/MissionIO/IdentificationPanel.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { + Alert, + Button, + Card, + CardContent, + CircularProgress, + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, + Stack, + Typography, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import GrantedFileField, { + type GrantedFileSelection, +} from '@/components/utilities/GrantedFileField'; +import { + missionIoApi, + type IdentifyMissionParams, + type MissionIdentificationData, +} from '@/api/missionIoApi'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { ProvenancePanel } from '@/components/utilities/UtilityResult'; +import { + MappingTable, + mergeWarnings, + MissionFieldCards, + OptionalOverrides, + ResultFeedback, + ResultSection, + displayValue, + hasOverrideErrors, + optionalText, + type OverrideValues, +} from './MissionCommon'; + +const EMPTY_OVERRIDES: OverrideValues = { mission: '', instrument: '', mode: '' }; + +const IdentificationPanel: React.FC = () => { + const [sourceType, setSourceType] = React.useState<'event_list' | 'fits'>('event_list'); + const [eventListName, setEventListName] = React.useState(''); + const [fitsFile, setFitsFile] = React.useState(null); + const [overrides, setOverrides] = React.useState(EMPTY_OVERRIDES); + const { + result: runnerResult, + running, + error, + warnings, + run, + } = useAnalysisRunner('Mission identification'); + const [resultSignature, setResultSignature] = React.useState(null); + const [requestSignature, setRequestSignature] = React.useState(null); + + const inputSignature = JSON.stringify({ sourceType, eventListName, fitsFile, overrides }); + const result = resultSignature === inputSignature ? runnerResult : null; + const feedbackIsCurrent = requestSignature === inputSignature; + + const sourceReady = sourceType === 'event_list' ? eventListName !== '' : fitsFile !== null; + const overridesInvalid = hasOverrideErrors(overrides); + + const identify = (): void => { + if (!sourceReady || overridesInvalid) return; + const overrideParams: { + mission_override?: string; + instrument_override?: string; + mode_override?: string; + } = {}; + const mission = optionalText(overrides.mission); + const instrument = optionalText(overrides.instrument); + const mode = optionalText(overrides.mode); + if (mission) overrideParams.mission_override = mission; + if (instrument) overrideParams.instrument_override = instrument; + if (mode) overrideParams.mode_override = mode; + let params: IdentifyMissionParams; + if (sourceType === 'event_list') { + params = { event_list_name: eventListName, ...overrideParams }; + } else { + if (!fitsFile) return; + params = { + file_path: fitsFile.path, + file_grant: fitsFile.grant, + ...overrideParams, + }; + } + const submittedSignature = inputSignature; + setRequestSignature(submittedSignature); + void run(async () => { + const response = await missionIoApi.identify(params); + if (response.success && response.data) setResultSignature(submittedSignature); + return response; + }); + }; + + return ( + + + + + Identify mission metadata + + Inspect one loaded EventList or one explicitly selected FITS file. Every value is + labelled with its origin; inferred aliases and user overrides remain visible. + + + Source + setSourceType(event.target.value as 'event_list' | 'fits')} + > + } label="Loaded EventList" /> + } label="Selected FITS file" /> + + + {sourceType === 'event_list' ? ( + + ) : ( + + )} + + + + + + + + + {result ? ( + + + {result.mapping ? ( + + ) : ( + + No runtime FITS mapping is available until a supported mission is identified or + supplied as an override. + + )} + {result.timing_metadata && Object.keys(result.timing_metadata).length > 0 ? ( + + + + Timing metadata + {Object.entries(result.timing_metadata).map(([key, entry]) => { + const preciseValue = + 'decimal' in entry && typeof entry.decimal === 'string' + ? entry.decimal + : displayValue(entry.value); + const components = + key === 'mjdref' ? result.timing_metadata?.mjdref?.components : null; + return ( + + + {key}: {preciseValue}{' '} + + from {entry.source ?? 'unknown source'} + + + {components ? ( + + MJDREFI={components.integer.value} from{' '} + {components.integer.source ?? 'unknown source'}; MJDREFF= + {components.fraction.value} from{' '} + {components.fraction.source ?? 'unknown source'} + + ) : null} + + ); + })} + + + + ) : null} + {result.hdus ? ( + + Inspected {result.hdus.length} FITS HDU{result.hdus.length === 1 ? '' : 's'} without + loading the full event table. + + ) : null} + + + ) : runnerResult ? ( + + Identification inputs changed. Run identification again to view results for this source. + + ) : ( + Choose a source to inspect its mission metadata and mappings. + )} + + ); +}; + +export default IdentificationPanel; diff --git a/src/pages/Utilities/MissionIO/InterpretationPanel.tsx b/src/pages/Utilities/MissionIO/InterpretationPanel.tsx new file mode 100644 index 0000000..37340b6 --- /dev/null +++ b/src/pages/Utilities/MissionIO/InterpretationPanel.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Alert, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Stack, + Typography, +} from '@mui/material'; +import PreviewIcon from '@mui/icons-material/Preview'; +import GrantedFileField, { + type GrantedFileSelection, +} from '@/components/utilities/GrantedFileField'; +import { + NumericResultTable, + ProvenancePanel, +} from '@/components/utilities/UtilityResult'; +import { + missionIoApi, + type InterpretMissionParams, + type MissionInterpretationData, +} from '@/api/missionIoApi'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { + MissionFieldCards, + mergeWarnings, + hasOverrideErrors, + OptionalOverrides, + ResultFeedback, + ResultSection, + optionalText, + type OverrideValues, +} from './MissionCommon'; +import { CAPABILITIES_QUERY_KEY, loadCapabilities } from './MissionDatabasePanel'; + +const EMPTY_OVERRIDES: OverrideValues = { mission: '', instrument: '', mode: '' }; + +const InterpretationPanel: React.FC = () => { + const capabilities = useQuery({ + queryKey: CAPABILITIES_QUERY_KEY, + queryFn: loadCapabilities, + staleTime: 60_000, + }); + const [fitsFile, setFitsFile] = React.useState(null); + const [overrides, setOverrides] = React.useState(EMPTY_OVERRIDES); + const { + result: runnerResult, + running, + error, + warnings, + run, + } = useAnalysisRunner('Mission-specific FITS interpretation'); + const [resultSignature, setResultSignature] = React.useState(null); + const [requestSignature, setRequestSignature] = React.useState(null); + const inputSignature = JSON.stringify({ fitsFile, overrides }); + const result = resultSignature === inputSignature ? runnerResult : null; + const feedbackIsCurrent = requestSignature === inputSignature; + const overridesInvalid = hasOverrideErrors(overrides); + + const interpret = (): void => { + if (!fitsFile || overridesInvalid) return; + const params: InterpretMissionParams = { + file_path: fitsFile.path, + file_grant: fitsFile.grant, + }; + const mission = optionalText(overrides.mission); + const instrument = optionalText(overrides.instrument); + const mode = optionalText(overrides.mode); + if (mission) params.mission_override = mission; + if (instrument) params.instrument_override = instrument; + if (mode) params.mode_override = mode; + const submittedSignature = inputSignature; + setRequestSignature(submittedSignature); + void run(async () => { + const response = await missionIoApi.interpret(params); + if (response.success && response.data) setResultSignature(submittedSignature); + return response; + }); + }; + + const supported = capabilities.data?.missions.filter( + (row) => row.specialized_interpretation.supported + ); + + return ( + + + Specialized interpretation is exposed only where the installed public Stingray API has an + interpreter. It decodes mission-specific event channels; it does not calibrate energy. + + + {capabilities.isLoading ? ( + + + Checking runtime interpreter support… + + ) : capabilities.isError ? ( + Could not load runtime interpreter capabilities. + ) : ( + + {(supported ?? []).length > 0 ? ( + supported?.map((row) => ( + + )) + ) : ( + + )} + + )} + + + + + Read-only FITS interpretation + + The backend operates on a bounded in-memory copy. The selected FITS file is never + changed. + + + + + + + + + + {result ? ( + + + Read-only result: the source file was not modified. + + + + + + + + + + {result.preview_truncated ? ( + + Showing {result.preview_count.toLocaleString()} of{' '} + {result.event_count.toLocaleString()} rows. + + ) : null} + + + ) : runnerResult ? ( + + Interpretation inputs changed. Run interpretation again to view results for this source. + + ) : ( + + Choose a supported mission FITS file to preview read-only interpretation. + + )} + + ); +}; + +export default InterpretationPanel; diff --git a/src/pages/Utilities/MissionIO/MissionCommon.tsx b/src/pages/Utilities/MissionIO/MissionCommon.tsx new file mode 100644 index 0000000..9393c05 --- /dev/null +++ b/src/pages/Utilities/MissionIO/MissionCommon.tsx @@ -0,0 +1,255 @@ +import React from 'react'; +import { + Alert, + Box, + Card, + CardContent, + Chip, + Grid, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import type { MissionMapping, MissionSourceField } from '@/api/missionIoApi'; +import { UtilityWarnings } from '@/components/utilities/UtilityResult'; + +const MAPPING_ROWS: Array<[keyof MissionMapping, string]> = [ + ['event_hdu', 'Event HDU'], + ['gti_hdu', 'GTI HDU'], + ['time_column', 'Time column'], + ['energy_or_channel_column', 'Energy / channel column'], + ['detector_column', 'Detector column'], + ['instrument_keyword', 'Instrument keyword'], + ['mode_keyword', 'Mode keyword'], +]; + +export function displayValue(value: unknown): string { + if (value === null || value === undefined || value === '') return 'Not defined'; + if (Array.isArray(value)) return value.map(displayValue).join(', '); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +export function optionalText(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; +} + +export function mergeWarnings(...groups: Array): string[] { + return [...new Set(groups.flatMap((group) => group ?? []))]; +} + +export const MappingTable: React.FC<{ mapping: MissionMapping; title?: string }> = ({ + mapping, + title = 'Runtime FITS mapping', +}) => ( + + + + + {title} + + + + {MAPPING_ROWS.map(([key, label]) => ( + + + {label} + + + {displayValue(mapping[key])} + + + ))} + +
+
+); + +const SOURCE_LABELS: Record = { + event_list_attribute: 'EventList attribute', + fits_header: 'FITS header', + override: 'User override', + missing: 'Missing', +}; + +const MissionFieldCard: React.FC<{ + label: string; + field: MissionSourceField; +}> = ({ label, field }) => ( + + + + + {label} + + {field.value ?? 'Not identified'} + + + {field.inferred ? : null} + {field.override ? : null} + {label === 'Mission' && field.database_supported !== undefined ? ( + + ) : null} + + + Source: {field.source ?? 'none'} + + {field.raw_value && field.raw_value !== field.value ? ( + + Raw value: {field.raw_value} + + ) : null} + + + +); + +export const MissionFieldCards: React.FC<{ + mission: MissionSourceField; + instrument: MissionSourceField; + mode: MissionSourceField; +}> = ({ mission, instrument, mode }) => ( + + {[ + ['Mission', mission], + ['Instrument', instrument], + ['Observing mode', mode], + ].map(([label, field]) => ( + + + + ))} + +); + +export interface OverrideValues { + mission: string; + instrument: string; + mode: string; +} + +const OVERRIDE_LIMITS = { + mission: 128, + instrument: 128, + mode: 256, +} as const; + +export type OverrideErrors = Record; + +export function overrideErrors(values: OverrideValues): OverrideErrors { + return { + mission: + values.mission.length > OVERRIDE_LIMITS.mission + ? `Mission override must be at most ${OVERRIDE_LIMITS.mission} characters` + : null, + instrument: + values.instrument.length > OVERRIDE_LIMITS.instrument + ? `Instrument override must be at most ${OVERRIDE_LIMITS.instrument} characters` + : null, + mode: + values.mode.length > OVERRIDE_LIMITS.mode + ? `Mode override must be at most ${OVERRIDE_LIMITS.mode} characters` + : null, + }; +} + +export function hasOverrideErrors(values: OverrideValues): boolean { + return Object.values(overrideErrors(values)).some((error) => error !== null); +} + +export const OptionalOverrides: React.FC<{ + values: OverrideValues; + onChange: (values: OverrideValues) => void; + disabled?: boolean; + missionRequired?: boolean; +}> = ({ values, onChange, disabled = false, missionRequired = false }) => { + const errors = overrideErrors(values); + return ( + + + Overrides are accepted only when source metadata are absent. They never replace a mission, + instrument, or mode already present in an EventList or FITS header. + + + + onChange({ ...values, mission: event.target.value })} + disabled={disabled} + required={missionRequired} + error={errors.mission !== null} + helperText={errors.mission} + inputProps={{ maxLength: OVERRIDE_LIMITS.mission }} + /> + + + onChange({ ...values, instrument: event.target.value })} + disabled={disabled} + error={errors.instrument !== null} + helperText={errors.instrument} + inputProps={{ maxLength: OVERRIDE_LIMITS.instrument }} + /> + + + onChange({ ...values, mode: event.target.value })} + disabled={disabled} + error={errors.mode !== null} + helperText={errors.mode} + inputProps={{ maxLength: OVERRIDE_LIMITS.mode }} + /> + + + + ); +}; + +export const ResultFeedback: React.FC<{ + error: string | null; + warnings?: string[]; +}> = ({ error, warnings }) => ( + + {error ? {error} : null} + + +); + +export const ResultSection: React.FC<{ + title: string; + children: React.ReactNode; +}> = ({ title, children }) => ( + + + {title} + {children} + + +); diff --git a/src/pages/Utilities/MissionIO/MissionDatabasePanel.tsx b/src/pages/Utilities/MissionIO/MissionDatabasePanel.tsx new file mode 100644 index 0000000..363f06b --- /dev/null +++ b/src/pages/Utilities/MissionIO/MissionDatabasePanel.tsx @@ -0,0 +1,294 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Alert, + Button, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import { + missionIoApi, + type MissionCapabilitiesData, + type MissionInfoData, + type RoughConversionCapability, +} from '@/api/missionIoApi'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { ProvenancePanel, UtilityWarnings } from '@/components/utilities/UtilityResult'; +import { + MappingTable, + mergeWarnings, + ResultFeedback, + ResultSection, + displayValue, + optionalText, +} from './MissionCommon'; + +export const CAPABILITIES_QUERY_KEY = ['missionIoCapabilities'] as const; + +function capabilityColor( + status: RoughConversionCapability['status'] +): 'success' | 'warning' | 'default' { + if (status === 'supported') return 'success'; + if (status === 'conditional') return 'warning'; + return 'default'; +} + +export async function loadCapabilities(): Promise { + const response = await missionIoApi.getCapabilities(); + if (!response.success || response.data === null) { + throw new Error(response.error || response.message || 'Could not load mission capabilities'); + } + return response.data; +} + +const MissionDatabasePanel: React.FC = () => { + const capabilities = useQuery({ + queryKey: CAPABILITIES_QUERY_KEY, + queryFn: loadCapabilities, + staleTime: 60_000, + }); + const [mission, setMission] = React.useState(''); + const [instrument, setInstrument] = React.useState(''); + const [mode, setMode] = React.useState(''); + const { result, running, error, warnings, run } = useAnalysisRunner( + 'Mission mapping lookup' + ); + const instrumentError = + instrument.length > 128 ? 'Instrument must be at most 128 characters' : null; + const modeError = mode.length > 256 ? 'Mode must be at most 256 characters' : null; + + const inspect = (): void => { + if (mission === '' || instrumentError || modeError) return; + const params: { mission: string; instrument?: string; mode?: string } = { mission }; + const selectedInstrument = optionalText(instrument); + const selectedMode = optionalText(mode); + if (selectedInstrument) params.instrument = selectedInstrument; + if (selectedMode) params.mode = selectedMode; + void run(() => missionIoApi.getMissionInfo(params)); + }; + + if (capabilities.isLoading) { + return ( + + + Reading the runtime mission database… + + ); + } + + if (capabilities.isError || !capabilities.data) { + return ( + void capabilities.refetch()}> + Retry + + } + > + {capabilities.error instanceof Error + ? capabilities.error.message + : 'Could not load the runtime mission database'} + + ); + } + + const data = capabilities.data; + return ( + + + {data.support_note} Precise PI calibration uses {data.precise_calibration.method} in{' '} + {data.precise_calibration.location}. + + + + + + + Source: {data.database_source} + + + + + + + + + Mission + Core FITS mapping + Instruments / modes + Rough PI → energy + Specialized interpreter + + + + {data.missions.map((row) => ( + + + {row.mission} + + + + Event HDU: {displayValue(row.mapping.event_hdu)} + + + Time: {displayValue(row.mapping.time_column)} + + + Energy/channel: {displayValue(row.mapping.energy_or_channel_column)} + + + + + Instruments: {displayValue(row.instruments)} + + + Modes: {displayValue(row.modes)} + + + + + + + {row.rough_pi_to_energy.message ?? 'No additional details'} + + {row.rough_pi_to_energy.dependencies.length > 0 ? ( + + Dependencies: {row.rough_pi_to_energy.dependencies.join(', ')} + + ) : null} + + + + + {row.specialized_interpretation.scope ? ( + + {row.specialized_interpretation.scope} + + ) : null} + + + ))} + +
+
+ + + + + Inspect one runtime mapping + + + + Mission + + + + + setInstrument(event.target.value)} + disabled={running} + error={instrumentError !== null} + helperText={instrumentError} + inputProps={{ maxLength: 128 }} + /> + + + setMode(event.target.value)} + disabled={running} + error={modeError !== null} + helperText={modeError} + inputProps={{ maxLength: 256 }} + /> + + + + + + + + + {result ? ( + + + + + + + + + ) : null} +
+ ); +}; + +export default MissionDatabasePanel; diff --git a/src/pages/Utilities/MissionIO/RoughConversionPanel.tsx b/src/pages/Utilities/MissionIO/RoughConversionPanel.tsx new file mode 100644 index 0000000..b12ce34 --- /dev/null +++ b/src/pages/Utilities/MissionIO/RoughConversionPanel.tsx @@ -0,0 +1,494 @@ +import React from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { + Alert, + AlertTitle, + Button, + Card, + CardContent, + Checkbox, + Chip, + CircularProgress, + FormControl, + FormControlLabel, + FormLabel, + Grid, + Radio, + RadioGroup, + Stack, + TextField, + Typography, +} from '@mui/material'; +import CalculateIcon from '@mui/icons-material/Calculate'; +import type { Data } from 'plotly.js'; +import EventListSelector from '@/components/analysis/EventListSelector'; +import PlotlyChart from '@/components/plots/PlotlyChart'; +import { + NumericResultTable, + ProvenancePanel, +} from '@/components/utilities/UtilityResult'; +import { + missionIoApi, + type ApproximateConversionData, + type ConversionDependency, + type ConvertPiParams, +} from '@/api/missionIoApi'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { EVENT_LISTS_QUERY_KEY, useEventLists } from '@/hooks/useEventLists'; +import { parseNumericArray, validateDerivedName } from '@/utils/utilityInputs'; +import { parsePositiveNumber } from '@/utils/numbers'; +import { + MissionFieldCards, + mergeWarnings, + OptionalOverrides, + ResultFeedback, + ResultSection, + hasOverrideErrors, + optionalText, + type OverrideValues, +} from './MissionCommon'; + +const EMPTY_OVERRIDES: OverrideValues = { mission: '', instrument: '', mode: '' }; +const XTE_MIN_EXCLUSIVE_MJD = 50_081; +const XTE_MAX_INCLUSIVE_MJD = 55_931; + +function parsePiValues(text: string): { value: number[] | null; error: string | null } { + const parsed = parseNumericArray(text, 'PI values'); + if (!parsed.value) return parsed; + const invalidIndex = parsed.value.findIndex( + (value) => !Number.isSafeInteger(value) || value < 0 + ); + if (invalidIndex >= 0) { + return { + value: null, + error: `PI value ${invalidIndex + 1} must be a non-negative integer channel`, + }; + } + return parsed; +} + +function parseDetectorIds(text: string): { value: number[] | null; error: string | null } { + if (text.trim() === '') return { value: null, error: null }; + const parsed = parseNumericArray(text, 'Detector IDs'); + if (!parsed.value) return parsed; + const invalidIndex = parsed.value.findIndex( + (value) => !Number.isSafeInteger(value) || value < 0 || value > 4 + ); + if (invalidIndex >= 0) { + return { + value: null, + error: `Detector ID ${invalidIndex + 1} must be an integer in the RXTE PCU range 0-4`, + }; + } + return parsed; +} + +function dependencyLabel(name: string, dependency: ConversionDependency): string { + if (dependency.used === false) { + if (dependency.requested_value != null) { + return `${name}: requested ${dependency.requested_value}; not used`; + } + if (dependency.value != null) return `${name}: ${dependency.value}; not used`; + return `${name}: not used`; + } + return `${name}: ${dependency.value ?? dependency.source ?? (dependency.used ? 'used' : 'not provided')}`; +} + +const RoughConversionPanel: React.FC = () => { + const queryClient = useQueryClient(); + const eventLists = useEventLists(); + const [sourceType, setSourceType] = React.useState<'pasted' | 'event_list'>('pasted'); + const [piText, setPiText] = React.useState(''); + const [eventListName, setEventListName] = React.useState(''); + const [overrides, setOverrides] = React.useState(EMPTY_OVERRIDES); + const [epochMjd, setEpochMjd] = React.useState(''); + const [detectorText, setDetectorText] = React.useState(''); + const [saveDerived, setSaveDerived] = React.useState(false); + const [saveAs, setSaveAs] = React.useState(''); + const { + result: runnerResult, + running, + error, + warnings, + run, + } = useAnalysisRunner('Approximate PI-to-energy conversion'); + const [resultSignature, setResultSignature] = React.useState(null); + const [requestSignature, setRequestSignature] = React.useState(null); + + const inputSignature = JSON.stringify({ + sourceType, + piText, + eventListName, + overrides, + epochMjd, + detectorText, + saveDerived, + saveAs, + }); + const result = resultSignature === inputSignature ? runnerResult : null; + const feedbackIsCurrent = requestSignature === inputSignature; + + const parsedPi = sourceType === 'pasted' ? parsePiValues(piText) : { value: null, error: null }; + const parsedDetectors = parseDetectorIds(detectorText); + const missionKey = overrides.mission.trim().toLowerCase(); + const isXte = missionKey === 'xte' || missionKey === 'rxte'; + const isAxaf = missionKey === 'axaf' || missionKey === 'chandra'; + const pastedPiCount = sourceType === 'pasted' ? parsedPi.value?.length : undefined; + const selectedEventCount = eventLists.data?.find( + (eventList) => eventList.name === eventListName + )?.n_events; + const expectedDetectorCount = pastedPiCount ?? selectedEventCount; + const xtePiRangeError = + isXte && parsedPi.value + ? (() => { + const index = parsedPi.value.findIndex((value) => value > 255); + return index >= 0 + ? `PI value ${index + 1} must be in the RXTE PCA channel range 0-255` + : null; + })() + : null; + const axafPiRangeError = + isAxaf && parsedPi.value + ? (() => { + const index = parsedPi.value.findIndex((value) => value < 1); + return index >= 0 + ? `PI value ${index + 1} must be at least 1 for AXAF/Chandra conversion` + : null; + })() + : null; + const missionPiError = parsedPi.error ?? xtePiRangeError ?? axafPiRangeError; + const instrument = overrides.instrument.trim(); + const xteInstrumentError = !isXte + ? null + : instrument !== '' && instrument.toLowerCase() !== 'pca' + ? 'RXTE rough conversion supports only the PCA instrument' + : sourceType === 'pasted' && instrument === '' + ? 'RXTE pasted-PI conversion requires instrument override PCA' + : null; + const epochNumber = epochMjd.trim() === '' ? undefined : parsePositiveNumber(epochMjd); + const numericEpochError = + epochNumber === null + ? 'Epoch MJD must be a positive finite number' + : null; + const xteEpochRangeError = + isXte && typeof epochNumber === 'number' && + !(epochNumber > XTE_MIN_EXCLUSIVE_MJD && epochNumber <= XTE_MAX_INCLUSIVE_MJD) + ? `RXTE PCA calibration requires ${XTE_MIN_EXCLUSIVE_MJD} < epoch MJD ≤ ${XTE_MAX_INCLUSIVE_MJD}` + : null; + const epochError = + numericEpochError ?? + xteEpochRangeError ?? + (isXte && sourceType === 'pasted' && epochNumber === undefined + ? 'RXTE pasted-PI conversion requires an observation epoch in MJD' + : null); + const detectorCardinalityError = + isXte && + parsedDetectors.value && + expectedDetectorCount !== undefined && + parsedDetectors.value.length !== 1 && + parsedDetectors.value.length !== expectedDetectorCount + ? `Provide one detector ID to broadcast or ${expectedDetectorCount} IDs, one per PI channel` + : null; + const detectorError = + parsedDetectors.error ?? + (isXte && sourceType === 'pasted' && parsedDetectors.value === null + ? 'RXTE pasted-PI conversion requires detector IDs (PCU 0-4)' + : detectorCardinalityError); + const nameValidationError = + saveDerived && saveAs !== '' ? validateDerivedName(saveAs) : null; + const duplicateName = + saveDerived && + saveAs !== '' && + eventLists.data?.some((eventList) => eventList.name === saveAs); + const nameError = nameValidationError ?? (saveAs === eventListName && saveAs !== '' + ? 'Destination name must differ from the source EventList' + : duplicateName + ? `An EventList named "${saveAs}" already exists` + : null); + const sourceReady = + sourceType === 'pasted' ? parsedPi.value !== null : eventListName !== ''; + const pastedMissionMissing = sourceType === 'pasted' && overrides.mission.trim() === ''; + const overridesInvalid = hasOverrideErrors(overrides); + const saveReady = + sourceType !== 'event_list' || !saveDerived || (saveAs !== '' && nameError === null); + const canRun = + sourceReady && + !pastedMissionMissing && + missionPiError === null && + xteInstrumentError === null && + detectorError === null && + epochError === null && + !overridesInvalid && + saveReady && + !running; + + const convert = (): void => { + if (!canRun) return; + const mission = optionalText(overrides.mission); + const instrument = optionalText(overrides.instrument); + const mode = optionalText(overrides.mode); + const commonParams = { + ...(mission ? { mission_override: mission } : {}), + ...(instrument ? { instrument_override: instrument } : {}), + ...(mode ? { mode_override: mode } : {}), + ...(typeof epochNumber === 'number' ? { epoch_mjd: epochNumber } : {}), + ...(parsedDetectors.value ? { detector_ids: parsedDetectors.value } : {}), + }; + let params: ConvertPiParams; + if (sourceType === 'pasted') { + if (!parsedPi.value) return; + params = { ...commonParams, pi_values: parsedPi.value }; + } else { + params = { + ...commonParams, + event_list_name: eventListName, + ...(saveDerived ? { save_as: saveAs } : {}), + }; + } + + const submittedSignature = inputSignature; + setRequestSignature(submittedSignature); + void run(async () => { + const response = await missionIoApi.convertPi(params); + if (response.success && response.data) setResultSignature(submittedSignature); + if (response.success && response.data?.saved_event_list) { + void queryClient.invalidateQueries({ queryKey: EVENT_LISTS_QUERY_KEY }); + } + return response; + }); + }; + + const plotData: Data[] = result + ? [ + { + x: result.rows.map((row) => row.pi), + y: result.rows.map((row) => row.energy_kev), + type: 'scattergl', + mode: 'lines+markers', + name: 'Approximate energy', + } as Data, + ] + : []; + + return ( + + + APPROXIMATE rough conversion only + Mission relations are not a substitute for RMF calibration. Use RMF-based PI-to-energy + conversion in General I/O for precise calibrated energies. + + + + + + Preview rough PI-to-energy conversion + + PI source + + setSourceType(event.target.value as 'pasted' | 'event_list') + } + > + } label="Pasted PI channels" /> + } label="Loaded EventList" /> + + + + {sourceType === 'pasted' ? ( + setPiText(event.target.value)} + disabled={running} + error={piText.trim() !== '' && missionPiError !== null} + helperText={ + piText.trim() !== '' && missionPiError + ? missionPiError + : 'Comma, space, or newline-separated non-negative integer channels' + } + /> + ) : ( + + )} + + + + + + setEpochMjd(event.target.value)} + disabled={running} + error={epochError !== null} + helperText={epochError ?? 'Required by RXTE PCA; may be derived from EventList timing metadata'} + /> + + + setDetectorText(event.target.value)} + disabled={running} + error={detectorError !== null} + helperText={ + detectorError ?? + 'RXTE PCA PCU IDs 0-4: one value to broadcast or one per PI channel' + } + /> + + + + {sourceType === 'event_list' ? ( + + setSaveDerived(event.target.checked)} + disabled={running} + /> + } + label="Save converted data as a new EventList" + /> + {saveDerived ? ( + setSaveAs(event.target.value)} + disabled={running} + error={saveAs !== '' && nameError !== null} + helperText={ + (saveAs !== '' && nameError) || + 'The source remains unchanged; the backend rejects duplicate names atomically' + } + /> + ) : null} + + ) : null} + + {pastedMissionMissing ? ( + A mission override is required for pasted PI channels. + ) : null} + {isXte && + [xteInstrumentError, epochError, detectorError, xtePiRangeError].some(Boolean) ? ( + + RXTE PCA input requirements + + {[xteInstrumentError, epochError, detectorError, xtePiRangeError] + .filter((message): message is string => message !== null) + .map((message) => ( + + {message} + + ))} + + + ) : null} + + + + + + + {result ? ( + + + These values are approximate. For publication-quality calibrated energies, use{' '} + {result.precise_calibration.method} in {result.precise_calibration.location}. + + + + + + {Object.entries(result.dependencies).map(([name, dependency]) => ( + + ))} + {result.saved_event_list ? ( + + ) : null} + + + row.detector_id !== undefined) + ? [{ key: 'detector_id', label: 'Detector ID' }] + : []), + ]} + rows={result.rows} + /> + {result.preview_truncated ? ( + + Showing {result.preview_count.toLocaleString()} of {result.count.toLocaleString()} rows. + + ) : null} + + + ) : runnerResult ? ( + + Conversion inputs changed. Run conversion again to view results for this source. + + ) : ( + Supply PI channels or choose an EventList to preview conversion. + )} + + ); +}; + +export default RoughConversionPanel; diff --git a/src/pages/Utilities/MissionIO/index.test.tsx b/src/pages/Utilities/MissionIO/index.test.tsx new file mode 100644 index 0000000..2b86929 --- /dev/null +++ b/src/pages/Utilities/MissionIO/index.test.tsx @@ -0,0 +1,554 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; + +const listEventLists = vi.fn(); +vi.mock('@/api/dataApi', () => ({ + dataApi: { listEventLists: (...args: unknown[]) => listEventLists(...args) }, +})); + +const getCapabilities = vi.fn(); +const getMissionInfo = vi.fn(); +const identify = vi.fn(); +const convertPi = vi.fn(); +const interpret = vi.fn(); +vi.mock('@/api/missionIoApi', () => ({ + missionIoApi: { + getCapabilities: (...args: unknown[]) => getCapabilities(...args), + getMissionInfo: (...args: unknown[]) => getMissionInfo(...args), + identify: (...args: unknown[]) => identify(...args), + convertPi: (...args: unknown[]) => convertPi(...args), + interpret: (...args: unknown[]) => interpret(...args), + }, +})); + +vi.mock('@/components/plots/PlotlyChart', () => ({ + default: () =>
, +})); + +vi.mock('@/components/utilities/GrantedFileField', () => ({ + default: ({ + label, + onChange, + }: { + label: string; + onChange: (value: { path: string; grant: string }) => void; + }) => ( + + ), +})); + +import MissionIOPage from './index'; + +const mapping = { + event_hdu: 'EVENTS', + gti_hdu: 'GTI', + time_column: 'TIME', + energy_or_channel_column: 'PI', + detector_column: 'DET_ID', + instrument_keyword: 'INSTRUME', + mode_keyword: 'DATAMODE', +}; + +const field = ( + value: string | null, + sourceType: 'event_list_attribute' | 'fits_header' | 'override' | 'missing', + source: string | null +) => ({ + value, + raw_value: value, + source, + source_type: sourceType, + inferred: false, + override: sourceType === 'override', +}); + +const capabilitiesData = { + missions: [ + { + mission: 'nicer', + mapping, + instruments: ['XTI'], + modes: ['EVENT'], + rough_pi_to_energy: { + status: 'unsupported' as const, + approximate: false, + dependencies: [], + message: 'No public rough PI-to-energy conversion is available.', + }, + specialized_interpretation: { supported: false, scope: null }, + }, + { + mission: 'xte', + mapping, + instruments: ['PCA'], + modes: ['GoodXenon'], + rough_pi_to_energy: { + status: 'conditional' as const, + approximate: true, + dependencies: ['mission', 'instrument=PCA', 'epoch_mjd', 'detector_id'], + message: 'Approximate RXTE PCA conversion requires epoch and PCU.', + }, + specialized_interpretation: { + supported: true, + scope: 'XTE PCA science-event FITS (XTE_SE, TEVTB2 and PHA)', + }, + }, + ], + mission_count: 2, + raw_database_entry_count: 3, + database_source: 'runtime stingray.mission_support.read_mission_info', + support_note: 'Mappings do not imply conversion or interpreter support.', + precise_calibration: { method: 'RMF-based PI-to-energy conversion', location: 'General I/O' }, + provenance: { operation: 'mission_io.list_capabilities', read_only: true }, + warnings: [], +}; + +const identificationData = { + source: { type: 'loaded_event_list', name: 'obs1' }, + mission: { + ...field('NICER', 'fits_header' as const, 'EventList.header:MISSION'), + database_supported: true, + }, + instrument: field('XTI', 'event_list_attribute', 'EventList.instr'), + mode: field(null, 'missing', null), + mapping, + timing_metadata: { + mjdref: { + value: 56658.00077759259, + decimal: '56658.000777592592592593', + source: 'EventList.header:MJDREFI + EventList.header:MJDREFF', + components: { + integer: { value: '56658', source: 'EventList.header:MJDREFI' }, + fraction: { + value: '0.000777592592592593', + source: 'EventList.header:MJDREFF', + }, + }, + }, + tstart: { value: 12.5, source: 'EventList.header:TSTART' }, + }, + provenance: { operation: 'mission_io.identify_source', read_only: true }, + warnings: ['Observing-mode metadata is missing.'], +}; + +const missionInfoData = { + mission: 'xte', + requested_mission: 'xte', + mission_name_inferred: false, + instrument: 'PCA', + mode: null, + mapping, + available_instruments: ['PCA'], + available_modes: ['GoodXenon'], + capabilities: { + rough_pi_to_energy: { + status: 'conditional' as const, + approximate: true, + dependencies: ['instrument=PCA', 'epoch_mjd', 'detector_id'], + }, + specialized_interpretation: { + supported: true, + scope: 'XTE PCA science-event FITS (XTE_SE, TEVTB2 and PHA)', + }, + }, + precise_calibration: { method: 'RMF-based PI-to-energy conversion', location: 'General I/O' }, + provenance: { operation: 'mission_io.get_mission_info', read_only: true }, + warnings: [], +}; + +const conversionData = ( + saved: string | null = null, + requestedEpoch: number | null = null +) => ({ + label: 'APPROXIMATE rough PI-to-energy conversion', + conversion_type: 'rough_approximate' as const, + approximate: true as const, + energy_unit: 'keV' as const, + mission: field('nicer', 'override', 'request.mission_override'), + instrument: field(null, 'missing', null), + mode: field(null, 'missing', null), + dependencies: { + mission: { required: true, value: 'nicer' }, + epoch_mjd: { + required: false, + used: false, + value: null, + requested_value: requestedEpoch, + source: null, + }, + }, + count: 3, + rows: [ + { index: 0, pi: 1, energy_kev: 0.01 }, + { index: 1, pi: 2, energy_kev: 0.02 }, + { index: 2, pi: 3, energy_kev: 0.03 }, + ], + preview_count: 3, + preview_truncated: false, + saved_event_list: saved, + precise_calibration: { method: 'RMF-based PI-to-energy conversion', location: 'General I/O' }, + provenance: { operation: 'mission_io.approximate_pi_to_energy', approximate: true }, + warnings: ['APPROXIMATE conversion: use RMF calibration for precise energies.'], +}); + +async function selectEventList(label: string, option: RegExp): Promise { + await userEvent.click(await screen.findByLabelText(label)); + await userEvent.click(await screen.findByRole('option', { name: option })); +} + +async function openTab(name: string): Promise { + await userEvent.click(screen.getByRole('tab', { name })); +} + +describe('MissionIOPage', () => { + beforeEach(() => { + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + listEventLists.mockReset(); + getCapabilities.mockReset(); + getMissionInfo.mockReset(); + identify.mockReset(); + convertPi.mockReset(); + interpret.mockReset(); + + listEventLists.mockResolvedValue({ + success: true, + data: [{ name: 'obs1', n_events: 3, time_range: [0, 2], has_pi: true }], + message: '', + error: null, + }); + getCapabilities.mockResolvedValue({ + success: true, + data: capabilitiesData, + message: 'capabilities', + error: null, + }); + identify.mockResolvedValue({ + success: true, + data: identificationData, + message: 'identified', + error: null, + }); + getMissionInfo.mockResolvedValue({ + success: true, + data: missionInfoData, + message: 'mapping', + error: null, + }); + convertPi.mockImplementation((params: { save_as?: string; epoch_mjd?: number }) => + Promise.resolve({ + success: true, + data: conversionData(params.save_as ?? null, params.epoch_mjd ?? null), + message: 'converted', + error: null, + }) + ); + interpret.mockResolvedValue({ + success: false, + data: null, + message: "Stingray 2.2.10 has no specialized event interpretation for mission 'nicer'.", + error: null, + }); + }); + + it('starts ready, exposes the four tools, and keeps identify disabled for an empty source', async () => { + listEventLists.mockResolvedValueOnce({ success: true, data: [], message: '', error: null }); + renderWithProviders(); + + expect(screen.getByText('Mission-Specific I/O')).toBeInTheDocument(); + expect(screen.queryByText(/under construction/i)).not.toBeInTheDocument(); + expect(screen.getAllByRole('tab')).toHaveLength(4); + expect(screen.getByRole('button', { name: 'Identify mission' })).toBeDisabled(); + expect(await screen.findByText(/No event lists loaded/)).toBeInTheDocument(); + }); + + it('identifies a loaded EventList with an exact payload and renders value sources', async () => { + renderWithProviders(); + await selectEventList('EventList to identify', /obs1/); + await userEvent.click(screen.getByRole('button', { name: 'Identify mission' })); + + await waitFor(() => expect(identify).toHaveBeenCalledWith({ event_list_name: 'obs1' })); + expect(await screen.findByText('NICER')).toBeInTheDocument(); + expect(screen.getByText('FITS header')).toBeInTheDocument(); + expect(screen.getByText('EventList attribute')).toBeInTheDocument(); + expect(screen.getByText(/Source: EventList.header:MISSION/)).toBeInTheDocument(); + expect(screen.getByText('56658.000777592592592593')).toBeInTheDocument(); + expect(screen.getByText(/MJDREFI=56658/)).toBeInTheDocument(); + expect(screen.getByText(/tstart:/)).toBeInTheDocument(); + expect(screen.getByText('Observing-mode metadata is missing.')).toBeInTheDocument(); + expect(screen.getByText(/mission_io.identify_source/)).toBeInTheDocument(); + }); + + it('handles missing mission metadata without dereferencing an absent mapping', async () => { + identify.mockResolvedValueOnce({ + success: true, + data: { + source: { type: 'loaded_event_list', name: 'obs1' }, + mission: field(null, 'missing', null), + instrument: field(null, 'missing', null), + mode: field(null, 'missing', null), + mapping: null, + warnings: ['Mission metadata is missing.'], + }, + message: 'identified with missing metadata', + error: null, + }); + renderWithProviders(); + await selectEventList('EventList to identify', /obs1/); + await userEvent.click(screen.getByRole('button', { name: 'Identify mission' })); + + expect(await screen.findByText('Mission metadata is missing.')).toBeInTheDocument(); + expect(screen.getAllByText('Not identified')).toHaveLength(3); + expect( + screen.getByText(/No runtime FITS mapping is available until a supported mission/i) + ).toBeInTheDocument(); + expect(screen.queryByRole('table', { name: 'Runtime FITS mapping' })).not.toBeInTheDocument(); + }); + + it('shows runtime support honestly for unsupported and specialized missions', async () => { + renderWithProviders(); + await openTab('Mission database'); + + expect(await screen.findByText('2 unique mission mappings')).toBeInTheDocument(); + expect(screen.getByRole('table', { name: 'Runtime mission capability table' })).toBeInTheDocument(); + expect(screen.getByText('No public rough PI-to-energy conversion is available.')).toBeInTheDocument(); + expect(screen.getByText('Not available')).toBeInTheDocument(); + expect(screen.getByText('XTE PCA science-event FITS (XTE_SE, TEVTB2 and PHA)')).toBeInTheDocument(); + expect(screen.getByText(/mission_io.list_capabilities/)).toBeInTheDocument(); + }); + + it('renders provenance for a selected runtime mission mapping', async () => { + renderWithProviders(); + await openTab('Mission database'); + await screen.findByText('2 unique mission mappings'); + + await userEvent.click(screen.getByLabelText('Mission')); + await userEvent.click(screen.getByRole('option', { name: 'xte' })); + await userEvent.type(screen.getByLabelText('Instrument (optional)'), 'PCA'); + await userEvent.click(screen.getByRole('button', { name: 'Inspect mission mapping' })); + + await waitFor(() => + expect(getMissionInfo).toHaveBeenCalledWith({ mission: 'xte', instrument: 'PCA' }) + ); + expect(await screen.findByText(/mission_io.get_mission_info/)).toBeInTheDocument(); + }); + + it('validates pasted PI channels and sends the exact approximate-conversion payload', async () => { + renderWithProviders(); + await openTab('Approximate conversion'); + + const runButton = screen.getByRole('button', { name: 'Run approximate conversion' }); + expect(runButton).toBeDisabled(); + expect(screen.getByText(/mission override is required for pasted PI channels/i)).toBeInTheDocument(); + + await userEvent.type(screen.getByLabelText('PI values'), '1 2.5 3'); + expect(screen.getByText(/must be a non-negative integer channel/)).toBeInTheDocument(); + expect(runButton).toBeDisabled(); + + await userEvent.clear(screen.getByLabelText('PI values')); + await userEvent.type(screen.getByLabelText('PI values'), '1, 2, 3'); + await userEvent.type(screen.getByLabelText(/Mission override \(required\)/), 'nicer'); + expect(runButton).toBeEnabled(); + await userEvent.click(runButton); + + await waitFor(() => + expect(convertPi).toHaveBeenCalledWith({ + pi_values: [1, 2, 3], + mission_override: 'nicer', + }) + ); + expect(await screen.findByText('APPROXIMATE rough PI-to-energy conversion')).toBeInTheDocument(); + expect(screen.getByText(/not a substitute for RMF calibration/i)).toBeInTheDocument(); + expect(screen.getByTestId('mission-conversion-chart')).toBeInTheDocument(); + expect(screen.getByRole('table', { name: 'Approximate converted values' })).toBeInTheDocument(); + }); + + it('labels a requested non-XTE epoch as unused', async () => { + renderWithProviders(); + await openTab('Approximate conversion'); + await userEvent.type(screen.getByLabelText('PI values'), '1, 2, 3'); + await userEvent.type(screen.getByLabelText(/Mission override \(required\)/), 'nicer'); + await userEvent.type(screen.getByLabelText(/Observation epoch/), '60000'); + await userEvent.click(screen.getByRole('button', { name: 'Run approximate conversion' })); + + await waitFor(() => + expect(convertPi).toHaveBeenCalledWith({ + pi_values: [1, 2, 3], + mission_override: 'nicer', + epoch_mjd: 60000, + }) + ); + expect( + await screen.findByText('epoch_mjd: requested 60000; not used') + ).toBeInTheDocument(); + }); + + it('enforces RXTE PCA dependencies, PI range, and detector cardinality before submit', async () => { + renderWithProviders(); + await openTab('Approximate conversion'); + + const piField = screen.getByLabelText('PI values'); + const missionField = screen.getByLabelText(/Mission override \(required\)/); + const instrumentField = screen.getByLabelText('Instrument override (if missing)'); + const epochField = screen.getByLabelText(/Observation epoch/); + const detectorField = screen.getByLabelText(/Detector IDs/); + const runButton = screen.getByRole('button', { name: 'Run approximate conversion' }); + + await userEvent.type(piField, '1, 256, 3'); + await userEvent.type(missionField, 'XTE'); + expect(screen.getByText(/requires instrument override PCA/)).toBeInTheDocument(); + expect(screen.getAllByText(/requires an observation epoch/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/requires detector IDs/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/channel range 0-255/).length).toBeGreaterThan(0); + expect(runButton).toBeDisabled(); + + await userEvent.type(instrumentField, 'HEXTE'); + expect(screen.getByText(/supports only the PCA instrument/)).toBeInTheDocument(); + await userEvent.clear(instrumentField); + await userEvent.type(instrumentField, 'PCA'); + await userEvent.type(epochField, '0b10'); + expect( + screen.getAllByText(/Epoch MJD must be a positive finite number/).length + ).toBeGreaterThan(0); + expect(runButton).toBeDisabled(); + await userEvent.clear(epochField); + await userEvent.type(epochField, '50081'); + expect(screen.getAllByText(/50081 < epoch MJD ≤ 55931/).length).toBeGreaterThan(0); + expect(runButton).toBeDisabled(); + await userEvent.clear(epochField); + await userEvent.type(epochField, '55931'); + await userEvent.type(detectorField, '0, 1'); + expect(screen.getAllByText(/one detector ID to broadcast or 3 IDs/).length).toBeGreaterThan(0); + + await userEvent.clear(detectorField); + await userEvent.type(detectorField, '0'); + await userEvent.clear(piField); + await userEvent.type(piField, '1, 2, 3'); + expect(runButton).toBeEnabled(); + await userEvent.click(runButton); + + await waitFor(() => + expect(convertPi).toHaveBeenCalledWith({ + pi_values: [1, 2, 3], + mission_override: 'XTE', + instrument_override: 'PCA', + epoch_mjd: 55931, + detector_ids: [0], + }) + ); + }); + + it('rejects zero-valued pasted AXAF channels before submit', async () => { + renderWithProviders(); + await openTab('Approximate conversion'); + await userEvent.type(screen.getByLabelText('PI values'), '0, 1'); + await userEvent.type(screen.getByLabelText(/Mission override \(required\)/), 'AXAF'); + + expect(screen.getByText(/must be at least 1 for AXAF\/Chandra/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Run approximate conversion' })).toBeDisabled(); + expect(convertPi).not.toHaveBeenCalled(); + }); + + it('uses an explicit validated Save-as name for a detached EventList', async () => { + listEventLists.mockResolvedValueOnce({ + success: true, + data: [ + { name: 'obs1', n_events: 3, time_range: [0, 2], has_pi: true }, + { name: 'already-loaded', n_events: 2, time_range: [0, 1], has_pi: true }, + ], + message: '', + error: null, + }); + renderWithProviders(); + await openTab('Approximate conversion'); + await userEvent.click(screen.getByRole('radio', { name: 'Loaded EventList' })); + await selectEventList('EventList with PI channels', /obs1/); + await userEvent.click(screen.getByRole('checkbox', { name: /Save converted data/ })); + + const saveName = screen.getByLabelText('Save as EventList name'); + await userEvent.type(saveName, ' bad'); + expect(screen.getByText(/must not start or end with whitespace/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Run approximate conversion' })).toBeDisabled(); + + await userEvent.clear(saveName); + await userEvent.type(saveName, 'already-loaded'); + expect(screen.getByText(/already-loaded.*already exists/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Run approximate conversion' })).toBeDisabled(); + + await userEvent.clear(saveName); + await userEvent.type(saveName, 'obs1-energy'); + await userEvent.click(screen.getByRole('button', { name: 'Run approximate conversion' })); + + await waitFor(() => + expect(convertPi).toHaveBeenCalledWith({ + event_list_name: 'obs1', + save_as: 'obs1-energy', + }) + ); + expect(await screen.findByText('Saved as obs1-energy')).toBeInTheDocument(); + }); + + it('shows loading and preserves the last identification after a later failure', async () => { + let resolveFirst: ((value: unknown) => void) | undefined; + identify.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ); + renderWithProviders(); + await selectEventList('EventList to identify', /obs1/); + await userEvent.click(screen.getByRole('button', { name: 'Identify mission' })); + expect(screen.getByRole('button', { name: 'Identifying…' })).toBeDisabled(); + + await act(async () => { + resolveFirst?.({ + success: true, + data: identificationData, + message: 'identified', + error: null, + }); + }); + expect(await screen.findByText('NICER')).toBeInTheDocument(); + + identify.mockResolvedValueOnce({ + success: false, + data: null, + message: 'Mission metadata could not be read', + error: null, + warnings: ['The selected source remains unmodified.'], + }); + await userEvent.click(screen.getByRole('button', { name: 'Identify mission' })); + expect(await screen.findByText('Mission metadata could not be read')).toBeInTheDocument(); + expect(screen.getByText('The selected source remains unmodified.')).toBeInTheDocument(); + expect(screen.getByText('NICER')).toBeInTheDocument(); + }); + + it('submits a granted FITS path and reports unsupported specialized interpretation', async () => { + renderWithProviders(); + await openTab('Specialized interpretation'); + expect(await screen.findByText(/xte: xte pca science-event fits/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Choose FITS file to interpret' })); + await userEvent.type(screen.getByLabelText('Mission override (if missing)'), 'nicer'); + await userEvent.click(screen.getByRole('button', { name: 'Interpret selected FITS' })); + + await waitFor(() => + expect(interpret).toHaveBeenCalledWith({ + file_path: '/selected/mission.evt', + file_grant: 'read-grant', + mission_override: 'nicer', + }) + ); + expect( + await screen.findByText(/no specialized event interpretation for mission 'nicer'/i) + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Utilities/MissionIO/index.tsx b/src/pages/Utilities/MissionIO/index.tsx new file mode 100644 index 0000000..d9d41c1 --- /dev/null +++ b/src/pages/Utilities/MissionIO/index.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Box, Tab, Tabs } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import StorageIcon from '@mui/icons-material/Storage'; +import CalculateIcon from '@mui/icons-material/Calculate'; +import PreviewIcon from '@mui/icons-material/Preview'; +import PageTemplate from '@/components/common/PageTemplate'; +import IdentificationPanel from './IdentificationPanel'; +import MissionDatabasePanel from './MissionDatabasePanel'; +import RoughConversionPanel from './RoughConversionPanel'; +import InterpretationPanel from './InterpretationPanel'; + +const TabPanel: React.FC<{ + active: number; + index: number; + children: React.ReactNode; +}> = ({ active, index, children }) => + active === index ? ( + + {children} + + ) : null; + +const MissionIOPage: React.FC = () => { + const [activeTab, setActiveTab] = React.useState(0); + + return ( + + setActiveTab(value)} + variant="scrollable" + scrollButtons="auto" + aria-label="Mission-specific I/O tools" + > + } iconPosition="start" label="Identify" /> + } + iconPosition="start" + label="Mission database" + /> + } + iconPosition="start" + label="Approximate conversion" + /> + } + iconPosition="start" + label="Specialized interpretation" + /> + + + + + + + + + + + + + + + ); +}; + +export default MissionIOPage; diff --git a/src/pages/Utilities/StatisticalFunctions/GaussianPanel.tsx b/src/pages/Utilities/StatisticalFunctions/GaussianPanel.tsx new file mode 100644 index 0000000..d951582 --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/GaussianPanel.tsx @@ -0,0 +1,174 @@ +import { useState, type FormEvent } from 'react'; +import { + Alert, + Card, + CardContent, + Chip, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import type { GaussianResult, StatisticalSidedness } from '@/api/statisticsApi'; +import { statisticsApi } from '@/api/statisticsApi'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { ResultFrame, SubmitButton } from './WorkbenchComponents'; +import { finiteNumber } from './validation'; + +type InputMode = 'probability' | 'log_probability'; + +export function GaussianPanel() { + const [inputMode, setInputMode] = useState('probability'); + const [rawValue, setRawValue] = useState('0.0027'); + const [sidedness, setSidedness] = useState('one-sided'); + const runner = useAnalysisRunner('Gaussian Significance'); + + const parsed = finiteNumber( + rawValue, + inputMode === 'probability' ? 'Tail probability' : 'Natural log probability' + ); + const domainError = + parsed.error ?? + (inputMode === 'probability' && parsed.value !== null && !(parsed.value > 0 && parsed.value < 1) + ? 'Tail probability must be greater than 0 and less than 1' + : inputMode === 'log_probability' && parsed.value !== null && parsed.value >= 0 + ? 'Natural log probability must be less than 0' + : null); + const validValue = domainError === null ? parsed.value : null; + + const submit = (event: FormEvent): void => { + event.preventDefault(); + if (validValue === null || runner.running) return; + void runner.run(() => + statisticsApi.gaussian( + inputMode === 'probability' + ? { probability: validValue, sidedness } + : { log_probability: validValue, sidedness } + ) + ); + }; + + const rows = runner.result + ? [ + { + quantity: runner.result.input_mode === 'probability' ? 'Input probability' : 'Input ln(p)', + value: + runner.result.input_mode === 'probability' + ? runner.result.input_probability + : runner.result.input_log_probability, + unit: + runner.result.input_mode === 'probability' + ? runner.result.units.input_probability + : runner.result.units.input_log_probability, + }, + { + quantity: 'Effective one-sided upper-tail probability', + value: runner.result.effective_one_sided_probability, + unit: 'dimensionless probability', + }, + { + quantity: 'ln(effective one-sided probability)', + value: runner.result.effective_one_sided_log_probability, + unit: 'natural logarithm', + }, + { + quantity: 'Equivalent Gaussian significance', + value: runner.result.sigma, + unit: runner.result.units.sigma ?? 'standard deviations', + }, + ] + : []; + + return ( + + + + + + Probability → Gaussian sigma + + Converts a tail probability to an equivalent standard-normal deviation. Log input + keeps extremely small probabilities numerically meaningful. + + + Probability input mode + + + setRawValue(event.target.value)} + error={domainError !== null} + helperText={ + domainError ?? + (inputMode === 'probability' + ? 'Domain: 0 < p < 1' + : 'Domain: finite ln(p) < 0; natural logarithm') + } + inputProps={{ inputMode: 'decimal' }} + /> + + Tail convention + + + + {sidedness === 'one-sided' + ? 'p is the upper-tail area P(Z ≥ σ).' + : 'p is the combined probability in both tails. Each tail uses p / 2 before conversion.'}{' '} + All log probabilities are natural logarithms. + + + + + + + + + ) : undefined + } + emptyText="Enter a probability and choose its tail convention." + /> + + + ); +} diff --git a/src/pages/Utilities/StatisticalFunctions/ProbabilityWorkbench.tsx b/src/pages/Utilities/StatisticalFunctions/ProbabilityWorkbench.tsx new file mode 100644 index 0000000..8088188 --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/ProbabilityWorkbench.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Box, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material'; +import { GaussianPanel } from './GaussianPanel'; +import { TrialCorrectionPanel } from './TrialCorrectionPanel'; + +type ProbabilityTool = 'gaussian' | 'trials'; + +export function ProbabilityWorkbench() { + const [tool, setTool] = useState('gaussian'); + + return ( + + value && setTool(value)} + aria-label="Probability tool" + > + Gaussian significance + Trial correction + + + + + ); +} diff --git a/src/pages/Utilities/StatisticalFunctions/StatisticFamilyForm.tsx b/src/pages/Utilities/StatisticalFunctions/StatisticFamilyForm.tsx new file mode 100644 index 0000000..ac6473f --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/StatisticFamilyForm.tsx @@ -0,0 +1,162 @@ +import { + Alert, + Box, + Card, + CardContent, + Divider, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import type { FamilyConfig } from './familyConfigs'; +import { SubmitButton } from './WorkbenchComponents'; +import type { FamilyOperation, StatisticFamilyModel } from './useStatisticFamily'; + +interface StatisticFamilyFormProps { + config: FamilyConfig; + model: StatisticFamilyModel; +} + +export function StatisticFamilyForm({ config, model }: StatisticFamilyFormProps) { + return ( + + + + + {config.title} + + {config.description} + + + + + value && model.setOperation(value) + } + aria-label={`${config.title} operation`} + > + Evaluate observation + Find detection level + + {model.operation === 'evaluate' ? ( + model.setRawStatistic(event.target.value)} + error={model.statisticError !== null} + helperText={model.statisticError ?? config.statisticDomain} + inputProps={{ inputMode: 'decimal' }} + /> + ) : ( + model.setRawFalseAlarm(event.target.value)} + error={model.falseAlarm.error !== null} + helperText={ + model.falseAlarm.error ?? 'Desired global/post-trial FAP; domain: 0 < p < 1' + } + inputProps={{ inputMode: 'decimal' }} + /> + )} + model.setRawTrials(event.target.value)} + error={model.trials.error !== null} + helperText={ + model.trials.error ?? 'Exact independent-trial correction; positive integer' + } + inputProps={{ inputMode: 'numeric' }} + /> + {config.hasHarmonics && ( + model.setRawHarmonics(event.target.value)} + error={model.harmonics.error !== null} + helperText={model.harmonics.error ?? 'Includes the fundamental; positive integer'} + inputProps={{ inputMode: 'numeric' }} + /> + )} + {config.hasSummedSpectra && ( + model.setRawSummed(event.target.value)} + error={model.summed.error !== null} + helperText={model.summed.error ?? 'Number averaged; positive integer'} + inputProps={{ inputMode: 'numeric' }} + /> + )} + {config.hasRebin && ( + model.setRawRebin(event.target.value)} + error={model.rebin.error !== null} + helperText={model.rebin.error ?? 'Averaged frequency bins per output power'} + inputProps={{ inputMode: 'numeric' }} + /> + )} + {config.hasSamples && ( + model.setRawSamples(event.target.value)} + error={model.samples.error !== null || model.sampleRelationError !== null} + helperText={ + model.samples.error ?? + model.sampleRelationError ?? + 'Integer ≥ 3 and greater than phase bins' + } + inputProps={{ inputMode: 'numeric' }} + /> + )} + {config.hasPhaseBins && ( + model.setRawPhaseBins(event.target.value)} + error={model.phaseBins.error !== null || model.sampleRelationError !== null} + helperText={ + model.phaseBins.error ?? + model.sampleRelationError ?? + `Integer ≥ ${config.minimumPhaseBins ?? 1}${config.hasSamples ? ' and less than samples' : ''}` + } + inputProps={{ inputMode: 'numeric' }} + /> + )} + + {model.operation === 'evaluate' + ? `The result is an overall false-alarm probability after ${model.rawTrials || 'n'} independent trial(s). ${config.significantDirection === 'larger' ? 'Larger' : 'Smaller'} observed values are more significant.` + : `The input is the desired overall false-alarm probability. The backend applies the trial correction before finding the ${config.significantDirection === 'larger' ? 'upper' : 'lower'}-tail threshold.`} + + + + + + ); +} diff --git a/src/pages/Utilities/StatisticalFunctions/StatisticFamilyPanel.tsx b/src/pages/Utilities/StatisticalFunctions/StatisticFamilyPanel.tsx new file mode 100644 index 0000000..a09002e --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/StatisticFamilyPanel.tsx @@ -0,0 +1,57 @@ +import { Chip, Grid } from '@mui/material'; +import type { FamilyConfig } from './familyConfigs'; +import { StatisticFamilyForm } from './StatisticFamilyForm'; +import { ResultFrame } from './WorkbenchComponents'; +import { useStatisticFamily } from './useStatisticFamily'; + +interface StatisticFamilyPanelProps { + config: FamilyConfig; +} + +export function StatisticFamilyPanel({ config }: StatisticFamilyPanelProps) { + const model = useStatisticFamily(config); + + return ( + + + + + + + + + + ) : undefined + } + emptyText={ + model.operation === 'evaluate' + ? 'Enter an observed statistic to evaluate its global false-alarm probability.' + : 'Enter a desired global false-alarm probability to calculate the threshold.' + } + /> + + + ); +} diff --git a/src/pages/Utilities/StatisticalFunctions/TrialCorrectionPanel.tsx b/src/pages/Utilities/StatisticalFunctions/TrialCorrectionPanel.tsx new file mode 100644 index 0000000..615abff --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/TrialCorrectionPanel.tsx @@ -0,0 +1,167 @@ +import { useState, type FormEvent } from 'react'; +import { + Alert, + Card, + CardContent, + Chip, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import type { TrialCorrectionResult, TrialDirection } from '@/api/statisticsApi'; +import { statisticsApi } from '@/api/statisticsApi'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import { ResultFrame, SubmitButton } from './WorkbenchComponents'; +import { count, finiteNumber } from './validation'; + +export function TrialCorrectionPanel() { + const [direction, setDirection] = useState('single-to-multi'); + const [rawProbability, setRawProbability] = useState('0.001'); + const [rawTrials, setRawTrials] = useState('100'); + const runner = useAnalysisRunner('Independent Trial Correction'); + + const parsedProbability = finiteNumber(rawProbability, 'Probability'); + const probabilityError = + parsedProbability.error ?? + (parsedProbability.value !== null && parsedProbability.value < 0 + ? 'Probability must be at least 0' + : direction === 'single-to-multi' && + parsedProbability.value !== null && + parsedProbability.value > 1 + ? 'Single-trial probability must be at most 1' + : direction === 'multi-to-single' && + parsedProbability.value !== null && + parsedProbability.value >= 1 + ? 'Overall multi-trial probability must be less than 1' + : null); + const validProbability = probabilityError === null ? parsedProbability.value : null; + const trials = count(rawTrials, 'Independent trials'); + + const submit = (event: FormEvent): void => { + event.preventDefault(); + if (validProbability === null || trials.value === null || runner.running) return; + const nTrials = trials.value; + void runner.run(() => + statisticsApi.trials({ direction, probability: validProbability, n_trials: nTrials }) + ); + }; + + const outputLabel = + runner.result?.direction === 'single-to-multi' + ? 'Overall multi-trial probability' + : 'Equivalent single-trial probability'; + const rows = runner.result + ? [ + { + quantity: + runner.result.direction === 'single-to-multi' + ? 'Input single-trial probability' + : 'Input overall multi-trial probability', + value: runner.result.input_probability, + unit: runner.result.units.input_probability, + }, + { + quantity: outputLabel, + value: runner.result.output_probability, + unit: runner.result.units.output_probability, + }, + { + quantity: 'Independent trials', + value: runner.result.n_trials, + unit: 'count', + }, + ] + : []; + + return ( + + + + + + Independent-trial correction + + Applies Stingray's exact independent-trial (Šidák/binomial) conversion in + either direction, rather than the small-probability approximation. + + + Correction direction + + + setRawProbability(event.target.value)} + error={probabilityError !== null} + helperText={ + probabilityError ?? + (direction === 'single-to-multi' ? 'Domain: 0 ≤ p ≤ 1' : 'Domain: 0 ≤ p < 1') + } + inputProps={{ inputMode: 'decimal' }} + /> + setRawTrials(event.target.value)} + error={trials.error !== null} + helperText={ + trials.error ?? 'Positive integer; trials must be statistically independent' + } + inputProps={{ inputMode: 'numeric' }} + /> + + + + + + + + ) : undefined + } + emptyText="Choose a direction and number of independent trials." + /> + {runner.result && ( + + {runner.result.independence_assumption} + + )} + + + ); +} diff --git a/src/pages/Utilities/StatisticalFunctions/WorkbenchComponents.tsx b/src/pages/Utilities/StatisticalFunctions/WorkbenchComponents.tsx new file mode 100644 index 0000000..976f92b --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/WorkbenchComponents.tsx @@ -0,0 +1,132 @@ +import type { ReactNode } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Stack, + Typography, +} from '@mui/material'; +import CalculateOutlinedIcon from '@mui/icons-material/CalculateOutlined'; +import { + NumericResultTable, + ProvenancePanel, + type ResultCell, + UtilityWarnings, +} from '@/components/utilities/UtilityResult'; + +interface WorkbenchTabPanelProps { + active: number; + index: number; + children: ReactNode; +} + +export function WorkbenchTabPanel({ active, index, children }: WorkbenchTabPanelProps) { + return ( + + ); +} + +interface ResultFrameProps { + title: string; + running: boolean; + error: string | null; + requestWarnings?: string[]; + result: { warnings: string[]; provenance: Record } | null; + rows: Array>; + chips?: ReactNode; + emptyText: string; +} + +export function ResultFrame({ + title, + running, + error, + requestWarnings = [], + result, + rows, + chips, + emptyText, +}: ResultFrameProps) { + return ( + + + + + + {title} + + {chips} + {running && } + + {error && ( + + {error} + {result ? ' The previous successful result remains available below.' : ''} + + )} + + {result ? ( + <> + + + + + ) : ( + + {running ? ( + + ) : ( + + )} + + {running ? 'Running the calculation…' : emptyText} + + + )} + + + + ); +} + +interface SubmitButtonProps { + running: boolean; + disabled: boolean; + label: string; +} + +export function SubmitButton({ running, disabled, label }: SubmitButtonProps) { + return ( + + ); +} diff --git a/src/pages/Utilities/StatisticalFunctions/familyConfigs.ts b/src/pages/Utilities/StatisticalFunctions/familyConfigs.ts new file mode 100644 index 0000000..0fa1116 --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/familyConfigs.ts @@ -0,0 +1,74 @@ +export type FamilyKey = 'pds' | 'z2' | 'fold' | 'pdm'; + +export interface FamilyConfig { + key: FamilyKey; + title: string; + description: string; + statisticLabel: string; + statisticDefault: string; + statisticDomain: string; + resultUnit: string; + significantDirection: 'larger' | 'smaller'; + hasSummedSpectra?: boolean; + hasRebin?: boolean; + hasHarmonics?: boolean; + hasPhaseBins?: boolean; + hasSamples?: boolean; + minimumPhaseBins?: number; +} + +export const FAMILY_CONFIGS: Record = { + pds: { + key: 'pds', + title: 'Power-spectrum significance', + description: + 'Evaluate a Leahy-normalized PDS power under the white-noise χ² model, including averaging and rebinning.', + statisticLabel: 'Observed Leahy power', + statisticDefault: '20', + statisticDomain: 'Domain: finite power ≥ 0; dimensionless Leahy normalization', + resultUnit: 'dimensionless Leahy-normalized power', + significantDirection: 'larger', + hasSummedSpectra: true, + hasRebin: true, + }, + z2: { + key: 'z2', + title: 'Z²ₙ significance', + description: + 'Evaluate the Z²ₙ periodicity statistic for an explicit harmonic count and optional averaged periodograms.', + statisticLabel: 'Observed Z-squared statistic', + statisticDefault: '20', + statisticDomain: 'Domain: finite Z²ₙ ≥ 0; dimensionless', + resultUnit: 'dimensionless Z-squared-n statistic', + significantDirection: 'larger', + hasSummedSpectra: true, + hasHarmonics: true, + }, + fold: { + key: 'fold', + title: 'Epoch-folding significance', + description: + 'Evaluate a folded-profile χ² statistic using the number of phase bins and independent search trials.', + statisticLabel: 'Observed epoch-folding statistic', + statisticDefault: '20', + statisticDomain: 'Domain: finite statistic ≥ 0; dimensionless', + resultUnit: 'dimensionless epoch-folding statistic', + significantDirection: 'larger', + hasPhaseBins: true, + minimumPhaseBins: 3, + }, + pdm: { + key: 'pdm', + title: 'Phase-dispersion significance', + description: + 'Evaluate the inverse PDM peak with Stingray’s lower-tail beta distribution. Smaller values are more significant.', + statisticLabel: 'Observed inverse PDM peak statistic', + statisticDefault: '0.2', + statisticDomain: 'Domain: 0 ≤ statistic ≤ 1; dimensionless', + resultUnit: 'dimensionless phase-dispersion statistic', + significantDirection: 'smaller', + hasPhaseBins: true, + hasSamples: true, + minimumPhaseBins: 2, + }, +}; diff --git a/src/pages/Utilities/StatisticalFunctions/index.test.tsx b/src/pages/Utilities/StatisticalFunctions/index.test.tsx new file mode 100644 index 0000000..65cc64f --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/index.test.tsx @@ -0,0 +1,491 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/testUtils'; +import { useUIStore } from '@/store/uiStore'; +import type { + GaussianResult, + StatisticDetectionResult, + StatisticEvaluationResult, + TrialCorrectionResult, +} from '@/api/statisticsApi'; + +const apiMocks = vi.hoisted(() => ({ + gaussian: vi.fn(), + trials: vi.fn(), + evaluatePds: vi.fn(), + detectPds: vi.fn(), + evaluateZ2: vi.fn(), + detectZ2: vi.fn(), + evaluateFold: vi.fn(), + detectFold: vi.fn(), + evaluatePdm: vi.fn(), + detectPdm: vi.fn(), +})); + +vi.mock('@/api/statisticsApi', () => ({ statisticsApi: apiMocks })); + +import StatisticalFunctionsPage from './index'; + +const provenance = { + operation: 'statistics.test', + input_source: 'user-supplied scalar inputs', + parameters: {}, + stingray_version: '2.2.10', + public_api_calls: ['stingray.stats.test'], +}; + +function success(data: T) { + return { success: true, data, message: 'done', error: null }; +} + +const gaussianResult: GaussianResult = { + calculation: 'gaussian_significance', + input_mode: 'probability', + input_probability: 0.0027, + input_log_probability: -5.9145, + effective_one_sided_probability: 0.0027, + effective_one_sided_log_probability: -5.9145, + sigma: 2.78215, + sidedness: 'one-sided', + tail: 'upper', + direction: 'probability_to_gaussian_sigma', + units: { + input_probability: 'dimensionless probability', + input_log_probability: 'natural logarithm of a dimensionless probability', + sigma: 'standard deviations from the Gaussian mean', + }, + warnings: [], + provenance, +}; + +const trialResult: TrialCorrectionResult = { + calculation: 'trial_correction', + direction: 'single-to-multi', + input_probability: 0.001, + output_probability: 0.0952, + n_trials: 100, + independence_assumption: 'Trials are assumed to be statistically independent.', + units: { + input_probability: 'dimensionless probability', + output_probability: 'dimensionless probability', + }, + warnings: [], + provenance, +}; + +function evaluationResult( + family: StatisticEvaluationResult['family'], + observedStatistic: number, + overrides: Partial = {} +): StatisticEvaluationResult { + return { + family, + calculation: 'probability', + observed_statistic: observedStatistic, + probability: 0.01, + log_probability: -4.605170186, + probability_scope: 'overall_post_trial', + n_trials: 1, + tail: family === 'phase_dispersion' ? 'lower' : 'upper', + more_significant_when: family === 'phase_dispersion' ? 'smaller' : 'larger', + direction: 'observed_statistic_to_false_alarm_probability', + units: { + observed_statistic: 'dimensionless statistic', + probability: 'dimensionless probability', + log_probability: 'natural logarithm of a dimensionless probability', + }, + warnings: [], + provenance, + ...overrides, + }; +} + +function detectionResult( + family: StatisticDetectionResult['family'], + overrides: Partial = {} +): StatisticDetectionResult { + const lower = family === 'phase_dispersion'; + return { + family, + calculation: 'detection_level', + false_alarm_probability: 0.01, + false_alarm_probability_scope: 'overall_post_trial', + detection_level: lower ? 0.15 : 20, + n_trials: 1, + tail: lower ? 'lower' : 'upper', + decision_rule: lower + ? 'observed_statistic <= detection_level' + : 'observed_statistic >= detection_level', + direction: 'false_alarm_probability_to_detection_level', + units: { + false_alarm_probability: 'dimensionless probability', + detection_level: 'dimensionless statistic', + }, + warnings: [], + provenance, + ...overrides, + }; +} + +async function replaceValue(element: HTMLElement, value: string): Promise { + await userEvent.clear(element); + if (value !== '') await userEvent.type(element, value); +} + +async function chooseSelect(label: string, option: string, scope: HTMLElement): Promise { + await userEvent.click(within(scope).getByLabelText(label)); + await userEvent.click(await screen.findByRole('option', { name: option })); +} + +describe('StatisticalFunctionsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + useUIStore.setState({ notifications: [], unreadNotificationCount: 0 }); + apiMocks.gaussian.mockResolvedValue(success(gaussianResult)); + apiMocks.trials.mockResolvedValue(success(trialResult)); + apiMocks.evaluatePds.mockResolvedValue(success(evaluationResult('pds', 20))); + apiMocks.detectPds.mockResolvedValue(success(detectionResult('pds'))); + apiMocks.evaluateZ2.mockResolvedValue(success(evaluationResult('z2_n', 20))); + apiMocks.detectZ2.mockResolvedValue(success(detectionResult('z2_n'))); + apiMocks.evaluateFold.mockResolvedValue(success(evaluationResult('epoch_folding', 20))); + apiMocks.detectFold.mockResolvedValue(success(detectionResult('epoch_folding'))); + apiMocks.evaluatePdm.mockResolvedValue(success(evaluationResult('phase_dispersion', 0.2))); + apiMocks.detectPdm.mockResolvedValue(success(detectionResult('phase_dispersion'))); + }); + + it('renders five linked workbench tabs and is marked ready', () => { + renderWithProviders(); + + const tabs = screen.getAllByRole('tab'); + expect(tabs.map((tab) => tab.textContent)).toEqual([ + 'Probability & trials', + 'Power spectrum', + 'Z-squared', + 'Epoch folding', + 'Phase dispersion', + ]); + expect(tabs[0]).toHaveAttribute('aria-controls', 'statistics-panel-0'); + expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'statistics-tab-0'); + expect(screen.queryByText(/coming soon/i)).not.toBeInTheDocument(); + }); + + it('binds every typed API method to its exact explicit route and default payload', async () => { + const { statisticsApi: actualApi } = await vi.importActual< + typeof import('@/api/statisticsApi') + >('@/api/statisticsApi'); + const { apiClient } = await vi.importActual('@/api/client'); + const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ + success: false, + data: null, + message: 'stub', + error: null, + }); + + await actualApi.gaussian({ probability: 0.1, sidedness: 'one-sided' }); + await actualApi.trials({ direction: 'single-to-multi', probability: 0.1, n_trials: 3 }); + await actualApi.evaluatePds({ power: 10 }); + await actualApi.detectPds({ false_alarm_probability: 0.01 }); + await actualApi.evaluateZ2({ z2: 12 }); + await actualApi.detectZ2({ false_alarm_probability: 0.02 }); + await actualApi.evaluateFold({ statistic: 13, n_phase_bins: 16 }); + await actualApi.detectFold({ false_alarm_probability: 0.03, n_phase_bins: 16 }); + await actualApi.evaluatePdm({ statistic: 0.2, n_samples: 100, n_phase_bins: 10 }); + await actualApi.detectPdm({ false_alarm_probability: 0.04, n_samples: 100, n_phase_bins: 10 }); + + expect(post.mock.calls).toEqual([ + ['/api/utilities/statistics/gaussian', { probability: 0.1, sidedness: 'one-sided' }], + [ + '/api/utilities/statistics/trials', + { direction: 'single-to-multi', probability: 0.1, n_trials: 3 }, + ], + [ + '/api/utilities/statistics/pds/evaluate', + { power: 10, n_trials: 1, n_summed_spectra: 1, n_rebin: 1 }, + ], + [ + '/api/utilities/statistics/pds/detection', + { false_alarm_probability: 0.01, n_trials: 1, n_summed_spectra: 1, n_rebin: 1 }, + ], + [ + '/api/utilities/statistics/z2/evaluate', + { z2: 12, harmonics: 2, n_trials: 1, n_summed_spectra: 1 }, + ], + [ + '/api/utilities/statistics/z2/detection', + { false_alarm_probability: 0.02, harmonics: 2, n_trials: 1, n_summed_spectra: 1 }, + ], + [ + '/api/utilities/statistics/fold/evaluate', + { statistic: 13, n_phase_bins: 16, n_trials: 1 }, + ], + [ + '/api/utilities/statistics/fold/detection', + { false_alarm_probability: 0.03, n_phase_bins: 16, n_trials: 1 }, + ], + [ + '/api/utilities/statistics/pdm/evaluate', + { statistic: 0.2, n_samples: 100, n_phase_bins: 10, n_trials: 1 }, + ], + [ + '/api/utilities/statistics/pdm/detection', + { false_alarm_probability: 0.04, n_samples: 100, n_phase_bins: 10, n_trials: 1 }, + ], + ]); + }); + + it('sends exactly one probability input with explicit two-sided semantics', async () => { + renderWithProviders(); + const panel = screen.getByRole('tabpanel'); + + await replaceValue(within(panel).getByLabelText('Tail probability p'), '0.05'); + await chooseSelect('Tail convention', 'Two-sided total probability', panel); + await userEvent.click(within(panel).getByRole('button', { name: 'Convert to Gaussian sigma' })); + + await waitFor(() => + expect(apiMocks.gaussian).toHaveBeenCalledWith({ probability: 0.05, sidedness: 'two-sided' }) + ); + expect(apiMocks.gaussian.mock.calls[0][0]).not.toHaveProperty('log_probability'); + }); + + it('sends natural-log probability directly for underflow-safe Gaussian conversion', async () => { + const logResult: GaussianResult = { + ...gaussianResult, + input_mode: 'log_probability', + input_probability: null, + input_log_probability: -1000, + effective_one_sided_probability: 0, + effective_one_sided_log_probability: -1000, + sigma: 44.6157, + warnings: [ + 'The linear probability underflowed to 0.0 in floating-point; the finite natural-log probability preserves the significance.', + ], + }; + apiMocks.gaussian.mockResolvedValueOnce(success(logResult)); + renderWithProviders(); + const panel = screen.getByRole('tabpanel'); + + await chooseSelect('Probability input mode', 'Natural log ln(p)', panel); + await replaceValue(within(panel).getByLabelText('Natural log probability ln(p)'), '-1000'); + await userEvent.click(within(panel).getByRole('button', { name: 'Convert to Gaussian sigma' })); + + await waitFor(() => + expect(apiMocks.gaussian).toHaveBeenCalledWith({ + log_probability: -1000, + sidedness: 'one-sided', + }) + ); + expect(apiMocks.gaussian.mock.calls[0][0]).not.toHaveProperty('probability'); + expect((await within(panel).findAllByText('-1000')).length).toBe(2); + expect(within(panel).getByText(/linear probability underflowed/i)).toBeInTheDocument(); + }); + + it('supports both exact independent-trial correction directions and boundary validation', async () => { + renderWithProviders(); + const panel = screen.getByRole('tabpanel'); + await userEvent.click(within(panel).getByRole('button', { name: 'Trial correction' })); + + await replaceValue(within(panel).getByLabelText('Single-trial probability'), '0'); + await replaceValue(within(panel).getByLabelText('Independent trials'), '12'); + const submit = within(panel).getByRole('button', { name: 'Correct probability' }); + expect(submit).toBeEnabled(); + await userEvent.click(submit); + await waitFor(() => + expect(apiMocks.trials).toHaveBeenCalledWith({ + direction: 'single-to-multi', + probability: 0, + n_trials: 12, + }) + ); + + await chooseSelect( + 'Correction direction', + 'Overall multi-trial → single trial', + panel + ); + await replaceValue(within(panel).getByLabelText('Overall multi-trial probability'), '1'); + expect(within(panel).getByText(/must be less than 1/i)).toBeInTheDocument(); + expect(within(panel).getByRole('button', { name: 'Correct probability' })).toBeDisabled(); + }); + + it('sends exact PDS evaluate and detection payloads and preserves each operation result', async () => { + apiMocks.evaluatePds.mockResolvedValueOnce( + success( + evaluationResult('pds', 24, { + probability: 0, + log_probability: -800, + n_trials: 8, + n_summed_spectra: 3, + n_rebin: 2, + warnings: ['Linear probability underflowed; use the natural-log probability.'], + }) + ) + ); + apiMocks.detectPds.mockResolvedValueOnce( + success( + detectionResult('pds', { + false_alarm_probability: 0.02, + detection_level: 18.25, + n_trials: 8, + n_summed_spectra: 3, + n_rebin: 2, + }) + ) + ); + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Power spectrum' })); + let panel = screen.getByRole('tabpanel'); + + await replaceValue(within(panel).getByLabelText('Observed Leahy power'), '24'); + await replaceValue(within(panel).getByLabelText('Independent trials'), '8'); + await replaceValue(within(panel).getByLabelText('Averaged spectra'), '3'); + await replaceValue(within(panel).getByLabelText('Rebin factor'), '2'); + await userEvent.click( + within(panel).getByRole('button', { name: 'Evaluate false-alarm probability' }) + ); + await waitFor(() => + expect(apiMocks.evaluatePds).toHaveBeenCalledWith({ + power: 24, + n_trials: 8, + n_summed_spectra: 3, + n_rebin: 2, + }) + ); + expect(await within(panel).findByText('-800')).toBeInTheDocument(); + expect(within(panel).getByText(/Linear probability underflowed/)).toBeInTheDocument(); + + await userEvent.click(within(panel).getByRole('button', { name: 'Find detection level' })); + await replaceValue(within(panel).getByLabelText('Overall false-alarm probability'), '0.02'); + await userEvent.click(within(panel).getByRole('button', { name: 'Calculate detection level' })); + await waitFor(() => + expect(apiMocks.detectPds).toHaveBeenCalledWith({ + false_alarm_probability: 0.02, + n_trials: 8, + n_summed_spectra: 3, + n_rebin: 2, + }) + ); + expect(await within(panel).findByText('18.25')).toBeInTheDocument(); + expect(within(panel).getByText('observed_statistic >= detection_level')).toBeInTheDocument(); + + await userEvent.click(within(panel).getByRole('button', { name: 'Evaluate observation' })); + panel = screen.getByRole('tabpanel'); + expect(within(panel).getByText('-800')).toBeInTheDocument(); + }); + + it('disables duplicate submission while a request is running', async () => { + let resolveRequest: ((value: ReturnType>) => void) | undefined; + apiMocks.gaussian.mockReturnValueOnce( + new Promise((resolve) => { + resolveRequest = resolve; + }) + ); + renderWithProviders(); + const panel = screen.getByRole('tabpanel'); + + await userEvent.click(within(panel).getByRole('button', { name: 'Convert to Gaussian sigma' })); + const runningButton = within(panel).getByRole('button', { name: 'Calculating…' }); + expect(runningButton).toBeDisabled(); + expect(apiMocks.gaussian).toHaveBeenCalledTimes(1); + + resolveRequest?.(success(gaussianResult)); + expect(await within(panel).findByText('2.78215')).toBeInTheDocument(); + }); + + it('shows an error while preserving the previous successful result', async () => { + apiMocks.gaussian + .mockResolvedValueOnce(success(gaussianResult)) + .mockResolvedValueOnce({ + success: false, + data: null, + message: 'Probability conversion failed', + error: null, + }); + renderWithProviders(); + const panel = screen.getByRole('tabpanel'); + const submit = within(panel).getByRole('button', { name: 'Convert to Gaussian sigma' }); + + await userEvent.click(submit); + expect(await within(panel).findByText('2.78215')).toBeInTheDocument(); + await userEvent.click(submit); + + expect(await within(panel).findByText(/Probability conversion failed/)).toBeInTheDocument(); + expect(within(panel).getByText(/previous successful result remains/i)).toBeInTheDocument(); + expect(within(panel).getByText('2.78215')).toBeInTheDocument(); + }); + + it('validates statistic domains and PDM sample/bin ordering before submission', async () => { + renderWithProviders(); + await userEvent.click(screen.getByRole('tab', { name: 'Phase dispersion' })); + const panel = screen.getByRole('tabpanel'); + + await replaceValue(within(panel).getByLabelText('Observed inverse PDM peak statistic'), '1.1'); + expect(within(panel).getByText(/PDM statistic must be at most 1/)).toBeInTheDocument(); + expect( + within(panel).getByRole('button', { name: 'Evaluate false-alarm probability' }) + ).toBeDisabled(); + + await replaceValue(within(panel).getByLabelText('Observed inverse PDM peak statistic'), '0.2'); + await replaceValue(within(panel).getByLabelText('Time-series samples'), '10'); + await replaceValue(within(panel).getByLabelText('Phase bins'), '10'); + expect(within(panel).getAllByText(/samples must be greater than phase bins/i).length).toBeGreaterThan(0); + expect( + within(panel).getByRole('button', { name: 'Evaluate false-alarm probability' }) + ).toBeDisabled(); + expect(apiMocks.evaluatePdm).not.toHaveBeenCalled(); + }); + + it('uses exact Z², fold, and PDM payloads and surfaces the PDM lower-tail rule', async () => { + renderWithProviders(); + + await userEvent.click(screen.getByRole('tab', { name: 'Z-squared' })); + let panel = screen.getByRole('tabpanel'); + await replaceValue(within(panel).getByLabelText('Observed Z-squared statistic'), '30'); + await replaceValue(within(panel).getByLabelText('Harmonics'), '4'); + await replaceValue(within(panel).getByLabelText('Independent trials'), '5'); + await replaceValue(within(panel).getByLabelText('Averaged periodograms'), '2'); + await userEvent.click(within(panel).getByRole('button', { name: 'Evaluate false-alarm probability' })); + await waitFor(() => + expect(apiMocks.evaluateZ2).toHaveBeenCalledWith({ + z2: 30, + harmonics: 4, + n_trials: 5, + n_summed_spectra: 2, + }) + ); + + await userEvent.click(screen.getByRole('tab', { name: 'Epoch folding' })); + panel = screen.getByRole('tabpanel'); + await userEvent.click(within(panel).getByRole('button', { name: 'Find detection level' })); + await replaceValue(within(panel).getByLabelText('Overall false-alarm probability'), '0.03'); + await replaceValue(within(panel).getByLabelText('Phase bins'), '32'); + await replaceValue(within(panel).getByLabelText('Independent trials'), '6'); + await userEvent.click(within(panel).getByRole('button', { name: 'Calculate detection level' })); + await waitFor(() => + expect(apiMocks.detectFold).toHaveBeenCalledWith({ + false_alarm_probability: 0.03, + n_phase_bins: 32, + n_trials: 6, + }) + ); + + await userEvent.click(screen.getByRole('tab', { name: 'Phase dispersion' })); + panel = screen.getByRole('tabpanel'); + await userEvent.click(within(panel).getByRole('button', { name: 'Find detection level' })); + await replaceValue(within(panel).getByLabelText('Overall false-alarm probability'), '0.02'); + await replaceValue(within(panel).getByLabelText('Time-series samples'), '120'); + await replaceValue(within(panel).getByLabelText('Phase bins'), '12'); + await replaceValue(within(panel).getByLabelText('Independent trials'), '9'); + await userEvent.click(within(panel).getByRole('button', { name: 'Calculate detection level' })); + await waitFor(() => + expect(apiMocks.detectPdm).toHaveBeenCalledWith({ + false_alarm_probability: 0.02, + n_samples: 120, + n_phase_bins: 12, + n_trials: 9, + }) + ); + expect(await within(panel).findByText('observed_statistic <= detection_level')).toBeInTheDocument(); + expect(within(panel).getAllByText('lower tail').length).toBeGreaterThan(0); + }); +}); diff --git a/src/pages/Utilities/StatisticalFunctions/index.tsx b/src/pages/Utilities/StatisticalFunctions/index.tsx new file mode 100644 index 0000000..67e3646 --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/index.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { Alert, Card, Tab, Tabs } from '@mui/material'; +import PageTemplate from '@/components/common/PageTemplate'; +import { FAMILY_CONFIGS } from './familyConfigs'; +import { ProbabilityWorkbench } from './ProbabilityWorkbench'; +import { StatisticFamilyPanel } from './StatisticFamilyPanel'; +import { WorkbenchTabPanel } from './WorkbenchComponents'; + +const TAB_LABELS = [ + 'Probability & trials', + 'Power spectrum', + 'Z-squared', + 'Epoch folding', + 'Phase dispersion', +]; + +export default function StatisticalFunctionsPage() { + const [tab, setTab] = useState(0); + + return ( + + + Every probability reported by a statistic is a false-alarm probability under its stated + noise model. Evaluate uses an observed statistic; detection computes the statistic threshold + for a requested global false-alarm probability. All log probabilities are natural logarithms. + + + setTab(value)} + variant="scrollable" + scrollButtons="auto" + aria-label="Statistical function categories" + > + {TAB_LABELS.map((label, index) => ( + + ))} + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/Utilities/StatisticalFunctions/useStatisticFamily.ts b/src/pages/Utilities/StatisticalFunctions/useStatisticFamily.ts new file mode 100644 index 0000000..e2adb3c --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/useStatisticFamily.ts @@ -0,0 +1,242 @@ +import { useState, type FormEvent } from 'react'; +import type { StatisticDetectionResult, StatisticEvaluationResult } from '@/api/statisticsApi'; +import { statisticsApi } from '@/api/statisticsApi'; +import { useAnalysisRunner } from '@/hooks/useAnalysisRunner'; +import type { FamilyConfig } from './familyConfigs'; +import { count, finiteNumber, probability } from './validation'; + +export type FamilyOperation = 'evaluate' | 'detection'; + +export function useStatisticFamily(config: FamilyConfig) { + const [operation, setOperation] = useState('evaluate'); + const [rawStatistic, setRawStatistic] = useState(config.statisticDefault); + const [rawFalseAlarm, setRawFalseAlarm] = useState('0.01'); + const [rawTrials, setRawTrials] = useState('1'); + const [rawSummed, setRawSummed] = useState('1'); + const [rawRebin, setRawRebin] = useState('1'); + const [rawHarmonics, setRawHarmonics] = useState('2'); + const [rawPhaseBins, setRawPhaseBins] = useState(config.key === 'pdm' ? '10' : '16'); + const [rawSamples, setRawSamples] = useState('100'); + const evaluation = useAnalysisRunner(`${config.title} Evaluation`); + const detection = useAnalysisRunner( + `${config.title} Detection Level` + ); + + const parsedStatistic = finiteNumber(rawStatistic, config.statisticLabel); + const statisticError = + parsedStatistic.error ?? + (parsedStatistic.value !== null && parsedStatistic.value < 0 + ? `${config.statisticLabel} must be at least 0` + : config.key === 'pdm' && parsedStatistic.value !== null && parsedStatistic.value > 1 + ? 'PDM statistic must be at most 1' + : null); + const validStatistic = statisticError === null ? parsedStatistic.value : null; + const falseAlarm = probability(rawFalseAlarm, 'Overall false-alarm probability'); + const trials = count(rawTrials, 'Independent trials'); + const summed = count( + rawSummed, + config.key === 'z2' ? 'Averaged periodograms' : 'Averaged spectra' + ); + const rebin = count(rawRebin, 'Rebin factor'); + const harmonics = count(rawHarmonics, 'Harmonics'); + const phaseBins = count(rawPhaseBins, 'Phase bins', config.minimumPhaseBins ?? 1); + const samples = count(rawSamples, 'Time-series samples', 3); + const sampleRelationError = + config.hasSamples && + samples.value !== null && + phaseBins.value !== null && + samples.value <= phaseBins.value + ? 'Time-series samples must be greater than phase bins' + : null; + + const commonValid = + trials.value !== null && + (!config.hasSummedSpectra || summed.value !== null) && + (!config.hasRebin || rebin.value !== null) && + (!config.hasHarmonics || harmonics.value !== null) && + (!config.hasPhaseBins || phaseBins.value !== null) && + (!config.hasSamples || (samples.value !== null && sampleRelationError === null)); + const activeRunner = operation === 'evaluate' ? evaluation : detection; + const canSubmit = + commonValid && + (operation === 'evaluate' ? validStatistic !== null : falseAlarm.value !== null) && + !activeRunner.running; + + const submit = (event: FormEvent): void => { + event.preventDefault(); + if (!canSubmit || trials.value === null) return; + + if (operation === 'evaluate' && validStatistic !== null) { + switch (config.key) { + case 'pds': + if (summed.value === null || rebin.value === null) return; + void evaluation.run(() => + statisticsApi.evaluatePds({ + power: validStatistic, + n_trials: trials.value as number, + n_summed_spectra: summed.value as number, + n_rebin: rebin.value as number, + }) + ); + return; + case 'z2': + if (harmonics.value === null || summed.value === null) return; + void evaluation.run(() => + statisticsApi.evaluateZ2({ + z2: validStatistic, + harmonics: harmonics.value as number, + n_trials: trials.value as number, + n_summed_spectra: summed.value as number, + }) + ); + return; + case 'fold': + if (phaseBins.value === null) return; + void evaluation.run(() => + statisticsApi.evaluateFold({ + statistic: validStatistic, + n_phase_bins: phaseBins.value as number, + n_trials: trials.value as number, + }) + ); + return; + case 'pdm': + if (samples.value === null || phaseBins.value === null) return; + void evaluation.run(() => + statisticsApi.evaluatePdm({ + statistic: validStatistic, + n_samples: samples.value as number, + n_phase_bins: phaseBins.value as number, + n_trials: trials.value as number, + }) + ); + return; + } + } + + if (operation === 'detection' && falseAlarm.value !== null) { + switch (config.key) { + case 'pds': + if (summed.value === null || rebin.value === null) return; + void detection.run(() => + statisticsApi.detectPds({ + false_alarm_probability: falseAlarm.value as number, + n_trials: trials.value as number, + n_summed_spectra: summed.value as number, + n_rebin: rebin.value as number, + }) + ); + return; + case 'z2': + if (harmonics.value === null || summed.value === null) return; + void detection.run(() => + statisticsApi.detectZ2({ + false_alarm_probability: falseAlarm.value as number, + harmonics: harmonics.value as number, + n_trials: trials.value as number, + n_summed_spectra: summed.value as number, + }) + ); + return; + case 'fold': + if (phaseBins.value === null) return; + void detection.run(() => + statisticsApi.detectFold({ + false_alarm_probability: falseAlarm.value as number, + n_phase_bins: phaseBins.value as number, + n_trials: trials.value as number, + }) + ); + return; + case 'pdm': + if (samples.value === null || phaseBins.value === null) return; + void detection.run(() => + statisticsApi.detectPdm({ + false_alarm_probability: falseAlarm.value as number, + n_samples: samples.value as number, + n_phase_bins: phaseBins.value as number, + n_trials: trials.value as number, + }) + ); + return; + } + } + }; + + const evaluationRows = evaluation.result + ? [ + { + quantity: 'Observed statistic', + value: evaluation.result.observed_statistic, + unit: evaluation.result.units.observed_statistic ?? config.resultUnit, + }, + { + quantity: 'Overall false-alarm probability', + value: evaluation.result.probability, + unit: evaluation.result.units.probability ?? 'post-trial, dimensionless', + }, + { + quantity: 'ln(overall false-alarm probability)', + value: evaluation.result.log_probability, + unit: evaluation.result.units.log_probability ?? 'natural logarithm', + }, + ] + : []; + const detectionRows = detection.result + ? [ + { + quantity: 'Desired overall false-alarm probability', + value: detection.result.false_alarm_probability, + unit: + detection.result.units.false_alarm_probability ?? 'post-trial, dimensionless', + }, + { + quantity: 'Detection level', + value: detection.result.detection_level, + unit: detection.result.units.detection_level ?? config.resultUnit, + }, + { + quantity: 'Detection decision rule', + value: detection.result.decision_rule, + unit: detection.result.tail === 'lower' ? 'lower tail' : 'upper tail', + }, + ] + : []; + + return { + operation, + setOperation, + rawStatistic, + setRawStatistic, + rawFalseAlarm, + setRawFalseAlarm, + rawTrials, + setRawTrials, + rawSummed, + setRawSummed, + rawRebin, + setRawRebin, + rawHarmonics, + setRawHarmonics, + rawPhaseBins, + setRawPhaseBins, + rawSamples, + setRawSamples, + statisticError, + falseAlarm, + trials, + summed, + rebin, + harmonics, + phaseBins, + samples, + sampleRelationError, + activeRunner, + canSubmit, + submit, + activeResult: operation === 'evaluate' ? evaluation.result : detection.result, + activeRows: operation === 'evaluate' ? evaluationRows : detectionRows, + }; +} + +export type StatisticFamilyModel = ReturnType; diff --git a/src/pages/Utilities/StatisticalFunctions/validation.test.ts b/src/pages/Utilities/StatisticalFunctions/validation.test.ts new file mode 100644 index 0000000..7d81a71 --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/validation.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { count, finiteNumber } from './validation'; + +describe('strict statistical scalar parsing', () => { + it('accepts signed decimal and exponent notation', () => { + expect(finiteNumber(' -2.5e+3 ', 'Statistic')).toEqual({ value: -2500, error: null }); + expect(count('+2e1', 'Trials')).toEqual({ value: 20, error: null }); + }); + + it('rejects JavaScript hexadecimal and binary notation', () => { + expect(finiteNumber('0x10', 'Statistic')).toEqual({ + value: null, + error: 'Statistic must be a finite number', + }); + expect(count('0b10', 'Trials')).toEqual({ + value: null, + error: 'Trials must be a finite number', + }); + }); +}); diff --git a/src/pages/Utilities/StatisticalFunctions/validation.ts b/src/pages/Utilities/StatisticalFunctions/validation.ts new file mode 100644 index 0000000..f2d00c2 --- /dev/null +++ b/src/pages/Utilities/StatisticalFunctions/validation.ts @@ -0,0 +1,39 @@ +import { parseNumber } from '@/utils/numbers'; + +const MAX_COUNT = 2_147_483_647; + +export interface ParsedField { + value: number | null; + error: string | null; +} + +export function finiteNumber(raw: string, label: string): ParsedField { + if (raw.trim() === '') return { value: null, error: `${label} is required` }; + const value = parseNumber(raw); + return value !== null + ? { value, error: null } + : { value: null, error: `${label} must be a finite number` }; +} + +export function probability(raw: string, label: string): ParsedField { + const parsed = finiteNumber(raw, label); + if (parsed.value === null) return parsed; + return parsed.value > 0 && parsed.value < 1 + ? parsed + : { value: null, error: `${label} must be greater than 0 and less than 1` }; +} + +export function count(raw: string, label: string, minimum = 1): ParsedField { + const parsed = finiteNumber(raw, label); + if (parsed.value === null) return parsed; + if (!Number.isSafeInteger(parsed.value)) { + return { value: null, error: `${label} must be an integer` }; + } + if (parsed.value < minimum || parsed.value > MAX_COUNT) { + return { + value: null, + error: `${label} must be ${minimum}–${MAX_COUNT.toLocaleString()}`, + }; + } + return parsed; +} diff --git a/src/store/jobStore.ts b/src/store/jobStore.ts new file mode 100644 index 0000000..4406901 --- /dev/null +++ b/src/store/jobStore.ts @@ -0,0 +1,221 @@ +/** + * Zustand store for managing background job state. + * + * This store tracks all jobs in the queue, including their status, + * progress, and results. It receives updates from the SSE stream + * and provides methods for managing jobs. + */ + +import { create } from 'zustand'; +import type { Job, JobStatus, JobStreamEvent } from '@/types/job'; + +interface JobStore { + /** All jobs indexed by ID */ + jobs: Record; + + /** Whether the SSE connection is active */ + isConnected: boolean; + + /** Connection error message, if any */ + connectionError: string | null; + + /** Reconnection attempt count */ + reconnectAttempts: number; + + // Actions + + /** Set SSE connection status */ + setConnected: (connected: boolean, error?: string | null) => void; + + /** Increment reconnection attempts */ + incrementReconnectAttempts: () => void; + + /** Reset reconnection attempts */ + resetReconnectAttempts: () => void; + + /** Add or update a job */ + upsertJob: (job: Job) => void; + + /** Update an existing job */ + updateJob: (jobId: string, updates: Partial) => void; + + /** Remove a job */ + removeJob: (jobId: string) => void; + + /** Handle an SSE event */ + handleEvent: (event: JobStreamEvent) => void; + + /** Clear all completed/failed/cancelled jobs */ + clearCompletedJobs: () => void; + + /** Set multiple jobs at once (for initial state) */ + setJobs: (jobs: Job[]) => void; + + // Computed selectors (as functions) + + /** Get all jobs as an array, sorted by creation time (newest first) */ + getJobsArray: () => Job[]; + + /** Get active (pending or running) jobs */ + getActiveJobs: () => Job[]; + + /** Get completed jobs (completed, failed, or cancelled) */ + getCompletedJobs: () => Job[]; + + /** Get count of active jobs */ + getActiveJobCount: () => number; + + /** Get a specific job by ID */ + getJob: (jobId: string) => Job | undefined; +} + +const isActiveStatus = (status: JobStatus): boolean => { + return status === 'pending' || status === 'running'; +}; + +const isCompletedStatus = (status: JobStatus): boolean => { + return status === 'completed' || status === 'failed' || status === 'cancelled'; +}; + +export const useJobStore = create((set, get) => ({ + // Initial state + jobs: {}, + isConnected: false, + connectionError: null, + reconnectAttempts: 0, + + // Actions + setConnected: (connected: boolean, error: string | null = null): void => { + set({ isConnected: connected, connectionError: error }); + }, + + incrementReconnectAttempts: (): void => { + set((state) => ({ reconnectAttempts: state.reconnectAttempts + 1 })); + }, + + resetReconnectAttempts: (): void => { + set({ reconnectAttempts: 0 }); + }, + + upsertJob: (job: Job): void => { + set((state) => ({ + jobs: { ...state.jobs, [job.id]: job }, + })); + }, + + updateJob: (jobId: string, updates: Partial): void => { + set((state) => { + const existing = state.jobs[jobId]; + if (!existing) return state; + + return { + jobs: { + ...state.jobs, + [jobId]: { ...existing, ...updates }, + }, + }; + }); + }, + + removeJob: (jobId: string): void => { + set((state) => { + const { [jobId]: _, ...rest } = state.jobs; + return { jobs: rest }; + }); + }, + + handleEvent: (event: JobStreamEvent): void => { + const { type } = event; + + switch (type) { + case 'initial_state': { + // Set all jobs from initial state + const jobsMap: Record = {}; + for (const job of event.jobs) { + jobsMap[job.id] = job; + } + set({ jobs: jobsMap }); + break; + } + + case 'job_created': + case 'job_started': + case 'job_progress': + case 'job_completed': + case 'job_failed': + case 'job_cancelled': { + // Update the job + set((state) => ({ + jobs: { ...state.jobs, [event.job.id]: event.job }, + })); + break; + } + + case 'heartbeat': + // Heartbeat doesn't change state, just confirms connection is alive + break; + + default: + console.warn('[JobStore] Unknown event type:', type); + } + }, + + clearCompletedJobs: (): void => { + set((state) => { + const filtered: Record = {}; + for (const [id, job] of Object.entries(state.jobs)) { + if (!isCompletedStatus(job.status)) { + filtered[id] = job; + } + } + return { jobs: filtered }; + }); + }, + + setJobs: (jobs: Job[]): void => { + const jobsMap: Record = {}; + for (const job of jobs) { + jobsMap[job.id] = job; + } + set({ jobs: jobsMap }); + }, + + // Computed selectors + getJobsArray: (): Job[] => { + const { jobs } = get(); + return Object.values(jobs).sort((a, b) => { + // Sort by created_at descending (newest first) + return b.created_at.localeCompare(a.created_at); + }); + }, + + getActiveJobs: (): Job[] => { + const { jobs } = get(); + return Object.values(jobs) + .filter((job) => isActiveStatus(job.status)) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + }, + + getCompletedJobs: (): Job[] => { + const { jobs } = get(); + return Object.values(jobs) + .filter((job) => isCompletedStatus(job.status)) + .sort((a, b) => { + // Sort by completed_at descending (newest first) + const aTime = a.completed_at || a.created_at; + const bTime = b.completed_at || b.created_at; + return bTime.localeCompare(aTime); + }); + }, + + getActiveJobCount: (): number => { + const { jobs } = get(); + return Object.values(jobs).filter((job) => isActiveStatus(job.status)).length; + }, + + getJob: (jobId: string): Job | undefined => { + return get().jobs[jobId]; + }, +})); + +export default useJobStore; diff --git a/src/store/logStore.ts b/src/store/logStore.ts new file mode 100644 index 0000000..cc72346 --- /dev/null +++ b/src/store/logStore.ts @@ -0,0 +1,161 @@ +import { create } from 'zustand'; + +/** + * Log entry type + */ +export interface LogEntry { + id: string; + timestamp: Date; + level: 'info' | 'warn' | 'error' | 'debug'; + source: 'python' | 'electron' | 'frontend'; + message: string; +} + +/** + * Log store state interface + */ +interface LogStoreState { + logs: LogEntry[]; + maxLogs: number; + isOpen: boolean; + filter: { + levels: Set; + sources: Set; + search: string; + }; +} + +/** + * Log store actions interface + */ +interface LogStoreActions { + addLog: (log: Omit) => void; + clearLogs: () => void; + togglePanel: () => void; + setOpen: (isOpen: boolean) => void; + setFilter: (filter: Partial) => void; + toggleLevelFilter: (level: LogEntry['level']) => void; + toggleSourceFilter: (source: LogEntry['source']) => void; + setSearchFilter: (search: string) => void; +} + +type LogStore = LogStoreState & LogStoreActions; + +/** + * Generate unique ID for log entries + */ +const generateId = (): string => { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +}; + +/** + * Zustand store for application logs + */ +export const useLogStore = create((set) => ({ + logs: [], + maxLogs: 1000, + isOpen: false, + filter: { + levels: new Set(['info', 'warn', 'error', 'debug']), + sources: new Set(['python', 'electron', 'frontend']), + search: '', + }, + + addLog: (log): void => { + set((state) => { + const newLog: LogEntry = { + ...log, + id: generateId(), + timestamp: new Date(), + }; + + // Keep only the last maxLogs entries + const logs = [...state.logs, newLog]; + if (logs.length > state.maxLogs) { + logs.shift(); + } + + return { logs }; + }); + }, + + clearLogs: (): void => { + set({ logs: [] }); + }, + + togglePanel: (): void => { + set((state) => ({ isOpen: !state.isOpen })); + }, + + setOpen: (isOpen): void => { + set({ isOpen }); + }, + + setFilter: (filter): void => { + set((state) => ({ + filter: { ...state.filter, ...filter }, + })); + }, + + toggleLevelFilter: (level): void => { + set((state) => { + const levels = new Set(state.filter.levels); + if (levels.has(level)) { + levels.delete(level); + } else { + levels.add(level); + } + return { filter: { ...state.filter, levels } }; + }); + }, + + toggleSourceFilter: (source): void => { + set((state) => { + const sources = new Set(state.filter.sources); + if (sources.has(source)) { + sources.delete(source); + } else { + sources.add(source); + } + return { filter: { ...state.filter, sources } }; + }); + }, + + setSearchFilter: (search): void => { + set((state) => ({ + filter: { ...state.filter, search }, + })); + }, +})); + +/** + * Selector for filtered logs + */ +export const selectFilteredLogs = (state: LogStore): LogEntry[] => { + return state.logs.filter((log) => { + // Filter by level + if (!state.filter.levels.has(log.level)) { + return false; + } + + // Filter by source + if (!state.filter.sources.has(log.source)) { + return false; + } + + // Filter by search text + if (state.filter.search) { + const searchLower = state.filter.search.toLowerCase(); + return log.message.toLowerCase().includes(searchLower); + } + + return true; + }); +}; + +/** + * Helper function to add logs from outside React components + */ +export const addLog = (log: Omit): void => { + useLogStore.getState().addLog(log); +}; diff --git a/src/store/uiStore.ts b/src/store/uiStore.ts new file mode 100644 index 0000000..61430fe --- /dev/null +++ b/src/store/uiStore.ts @@ -0,0 +1,140 @@ +/** + * UI state store for global application state + */ + +import { create } from 'zustand'; + +export interface ProcessResources { + memoryMb: number; + cpuPercent: number; +} + +export interface AppResources { + // Individual process resources + backend: ProcessResources | null; + electronMain: ProcessResources | null; + electronRenderer: ProcessResources | null; + // Combined totals (what the app is actually using) + totalMemoryMb: number; + totalCpuPercent: number; // Raw sum (can exceed 100% on multi-core) + // System reference info + systemMemoryTotalMb: number; + systemMemoryAvailableMb: number; + systemCpuCount: number; + // Calculated app percentage of system capacity + appMemoryPercent: number; + appCpuPercent: number; // Normalized to total system CPU capacity (0-100%) +} + +// Legacy interface for backwards compatibility +export interface SystemResources { + cpuPercent: number; + memoryUsedGb: number; + memoryTotalGb: number; + memoryPercent: number; +} + +interface UIState { + // Panel visibility + logPanelOpen: boolean; + rightToolbarCollapsed: boolean; + + // Processing state + isProcessing: boolean; + processingMessage: string; + processingProgress: number | null; // null = indeterminate + + // System resources (legacy) + systemResources: SystemResources | null; + // App-specific resources (new) + appResources: AppResources | null; + + // Notifications + notifications: Notification[]; + unreadNotificationCount: number; + + // Search + searchOpen: boolean; + searchQuery: string; + + // Actions + toggleLogPanel: () => void; + setLogPanelOpen: (open: boolean) => void; + toggleRightToolbar: () => void; + + setProcessing: (isProcessing: boolean, message?: string, progress?: number | null) => void; + + setSystemResources: (resources: SystemResources | null) => void; + setAppResources: (resources: AppResources | null) => void; + + addNotification: (notification: Omit) => void; + markNotificationRead: (id: string) => void; + clearNotifications: () => void; + + setSearchOpen: (open: boolean) => void; + setSearchQuery: (query: string) => void; +} + +/** Notification type for UI alerts */ +export type NotificationType = 'info' | 'success' | 'warning' | 'error'; + +export interface Notification { + id: string; + timestamp: Date; + type: NotificationType; + title: string; + message: string; + read: boolean; +} + +export const useUIStore = create((set) => ({ + // Initial state + logPanelOpen: false, + rightToolbarCollapsed: false, + isProcessing: false, + processingMessage: '', + processingProgress: null, + systemResources: null, + appResources: null, + notifications: [], + unreadNotificationCount: 0, + searchOpen: false, + searchQuery: '', + + // Actions + toggleLogPanel: () => set((state) => ({ logPanelOpen: !state.logPanelOpen })), + setLogPanelOpen: (open) => set({ logPanelOpen: open }), + toggleRightToolbar: () => set((state) => ({ rightToolbarCollapsed: !state.rightToolbarCollapsed })), + + setProcessing: (isProcessing, message = '', progress = null) => + set({ isProcessing, processingMessage: message, processingProgress: progress }), + + setSystemResources: (resources) => set({ systemResources: resources }), + setAppResources: (resources) => set({ appResources: resources }), + + addNotification: (notification) => set((state) => { + const newNotification: Notification = { + ...notification, + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date(), + read: false, + }; + return { + notifications: [newNotification, ...state.notifications].slice(0, 50), // Keep last 50 + unreadNotificationCount: state.unreadNotificationCount + 1, + }; + }), + + markNotificationRead: (id) => set((state) => { + const notifications = state.notifications.map((n) => + n.id === id ? { ...n, read: true } : n + ); + const unreadCount = notifications.filter((n) => !n.read).length; + return { notifications, unreadNotificationCount: unreadCount }; + }), + + clearNotifications: () => set({ notifications: [], unreadNotificationCount: 0 }), + + setSearchOpen: (open) => set({ searchOpen: open }), + setSearchQuery: (query) => set({ searchQuery: query }), +})); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..42fc380 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,16 @@ +import '@testing-library/jest-dom/vitest'; + +// MUI useMediaQuery requires matchMedia, absent in jsdom +if (!window.matchMedia) { + window.matchMedia = (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }) as MediaQueryList; +} diff --git a/src/test/testUtils.tsx b/src/test/testUtils.tsx new file mode 100644 index 0000000..3ffe01e --- /dev/null +++ b/src/test/testUtils.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +/** Render with the app's provider stack (React Query + Router) for component tests. */ +export function renderWithProviders(ui: React.ReactElement): RenderResult { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + }); +} diff --git a/src/types/backendStatus.ts b/src/types/backendStatus.ts new file mode 100644 index 0000000..1ff7aeb --- /dev/null +++ b/src/types/backendStatus.ts @@ -0,0 +1,13 @@ +export type BackendPhase = 'starting' | 'ready' | 'error' | 'stopped'; + +export interface BackendStatus { + revision: number; + phase: BackendPhase; + port: number | null; + error: string | null; +} + +export interface BackendStatusSource { + getBackendStatus: () => Promise; + onBackendStatus: (callback: (status: BackendStatus) => void) => () => void; +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts new file mode 100644 index 0000000..3f6dfd5 --- /dev/null +++ b/src/types/electron.d.ts @@ -0,0 +1,100 @@ +/** + * Type definitions for Electron API exposed via preload script + */ +import type { BackendStatus } from './backendStatus'; + +export interface ElectronAPI { + // File System Operations + openGrantedFile: (options?: { + title?: string; + filters?: { name: string; extensions: string[] }[]; + multiple?: boolean; + }) => Promise<{ path: string; grant: string }[] | null>; + + saveGrantedFile: (options?: { + title?: string; + defaultPath?: string; + filters?: { name: string; extensions: string[] }[]; + }) => Promise<{ path: string; grant: string } | null>; + + // Python Backend Communication + getBackendPort: () => Promise; + + isPythonRunning: () => Promise; + + getBackendStatus: () => Promise; + + onBackendStatus: (callback: (status: BackendStatus) => void) => () => void; + + restartPython: () => Promise; + + onPythonReady: (callback: (port: number) => void) => () => void; + + onPythonStarting: (callback: () => void) => () => void; + + onPythonError: (callback: (error: string) => void) => () => void; + + // Application Info + getAppVersion: () => Promise; + + getAppName: () => Promise; + + getPlatform: () => Promise; + + isDev: () => Promise; + + // Window Controls + minimizeWindow: () => void; + + maximizeWindow: () => void; + + closeWindow: () => void; + + toggleFullscreen: () => void; + + openDevTools: () => void; + + // Shell Operations + openExternal: (url: string) => Promise; + + // Clipboard Operations + copyToClipboard: (text: string) => void; + + readFromClipboard: () => Promise; + + // Log Events + onLog: ( + callback: (log: { + level: 'info' | 'warn' | 'error' | 'debug'; + source: 'python' | 'electron'; + message: string; + }) => void + ) => () => void; + + sendLog: (log: { level: 'info' | 'warn' | 'error' | 'debug'; message: string }) => void; + + signalLogReady: () => void; + + // Resource Monitoring + getElectronResources: () => Promise<{ + main: { + memory_mb: number; + heap_used_mb: number; + heap_total_mb: number; + cpu_percent?: number; + }; + renderer: { + memory_mb: number; + cpu_percent: number; + } | null; + timestamp: number; + }>; +} + +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} + +export {}; diff --git a/src/types/job.ts b/src/types/job.ts new file mode 100644 index 0000000..01c84a2 --- /dev/null +++ b/src/types/job.ts @@ -0,0 +1,207 @@ +/** + * TypeScript types for the background job queue system. + */ + +/** + * Job status enum values. + */ +export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + +/** + * Job type enum values. + */ +export type JobType = 'load_event_list' | 'load_batch' | 'load_from_url'; + +export type JobEventInputFormat = 'ogip' | 'fits' | 'hdf5' | 'ascii.ecsv'; + +export interface PublicBatchJobSuccess { + name: string; +} + +export interface PublicBatchJobFailure { + name: string; + error: string; +} + +/** Public, redacted scientific/load summary. It intentionally has no paths or request params. */ +export interface PublicJobResult { + event_count?: number; + time_start?: number; + time_end?: number; + warnings?: string[]; + successful?: PublicBatchJobSuccess[]; + failed?: PublicBatchJobFailure[]; + success_count?: number; + failure_count?: number; + total_files?: number; +} + +/** + * Represents a background job in the queue. + */ +export interface Job { + /** Unique identifier for the job (UUID) */ + id: string; + /** Type of job */ + type: JobType; + /** Current status of the job */ + status: JobStatus; + /** Progress percentage (0.0 to 1.0) */ + progress: number; + /** Human-readable progress message */ + progress_message: string; + /** Total number of items to process (for batch jobs) */ + total_items: number; + /** Number of items completed */ + completed_items: number; + /** ISO timestamp when job was created */ + created_at: string; + /** ISO timestamp when job started running */ + started_at: string | null; + /** ISO timestamp when job completed/failed/cancelled */ + completed_at: string | null; + /** Redacted scientific/load summary on successful completion. */ + result: PublicJobResult | null; + /** Error message on failure */ + error: string | null; + /** Human-readable name for the job (shown in UI) */ + display_name: string; +} + +/** + * SSE event types for job updates. + */ +export type JobEventType = + | 'initial_state' + | 'job_created' + | 'job_started' + | 'job_progress' + | 'job_completed' + | 'job_failed' + | 'job_cancelled' + | 'heartbeat'; + +/** + * Base SSE event structure. + */ +interface BaseJobStreamEvent { + type: JobEventType; + timestamp: string; +} + +/** + * Initial state event sent when SSE connection is established. + */ +export interface InitialStateEvent extends BaseJobStreamEvent { + type: 'initial_state'; + jobs: Job[]; +} + +/** + * Job update event (created, started, progress, completed, failed, cancelled). + */ +export interface JobUpdateEvent extends BaseJobStreamEvent { + type: 'job_created' | 'job_started' | 'job_progress' | 'job_completed' | 'job_failed' | 'job_cancelled'; + job: Job; +} + +/** + * Heartbeat event to keep connection alive. + */ +export interface HeartbeatEvent extends BaseJobStreamEvent { + type: 'heartbeat'; +} + +/** + * Union type for all SSE events. + */ +export type JobStreamEvent = InitialStateEvent | JobUpdateEvent | HeartbeatEvent; + +/** + * Name conflict check result. + */ +export interface NameConflictResult { + has_conflict: boolean; + conflict_source?: 'loaded_data' | 'pending_job'; + job_id?: string; + suggested_name?: string; +} + +/** + * Request parameters for submitting a single file load job. + */ +type OptionalRmfGrant = + | { rmf_file: string; rmf_grant: string } + | { rmf_file?: never; rmf_grant?: never }; + +type OptionalSharedRmfGrant = + | { shared_rmf_file: string; shared_rmf_grant: string } + | { shared_rmf_file?: never; shared_rmf_grant?: never }; + +export type SubmitLoadJobParams = OptionalRmfGrant & { + file_path: string; + file_grant: string; + name: string; + fmt?: JobEventInputFormat; + additional_columns?: string[]; + high_precision?: boolean; + skip_checks?: boolean; + notes?: string; + use_partial_loading?: boolean; + partial_mode?: 'time_range' | 'event_count'; + time_range_start?: number; + time_range_end?: number; + event_start_index?: number; + event_count?: number; +}; + +/** + * File configuration for batch loading. + */ +export type BatchFileConfig = OptionalRmfGrant & { + file_path: string; + file_grant: string; + name: string; + fmt?: JobEventInputFormat; + additional_columns?: string[]; + high_precision?: boolean; + skip_checks?: boolean; + use_partial_loading?: boolean; + partial_mode?: 'time_range' | 'event_count'; + time_range_start?: number; + time_range_end?: number; + event_start_index?: number; + event_count?: number; + notes?: string; +}; + +/** + * Request parameters for submitting a batch load job. + */ +export type SubmitBatchJobParams = OptionalSharedRmfGrant & { + files: BatchFileConfig[]; + use_same_settings?: boolean; + shared_fmt?: JobEventInputFormat; + shared_additional_columns?: string[]; + shared_high_precision?: boolean; + shared_skip_checks?: boolean; + shared_use_partial_loading?: boolean; + shared_partial_mode?: 'time_range' | 'event_count'; + shared_time_range_start?: number; + shared_time_range_end?: number; + shared_event_start_index?: number; + shared_event_count?: number; +}; + +/** + * Request parameters for submitting a URL load job. + */ +export type SubmitUrlJobParams = OptionalRmfGrant & { + url: string; + name: string; + fmt?: JobEventInputFormat; + additional_columns?: string[]; + high_precision?: boolean; + skip_checks?: boolean; + notes?: string; +}; diff --git a/src/utils/deadtime.test.ts b/src/utils/deadtime.test.ts new file mode 100644 index 0000000..1bf996e --- /dev/null +++ b/src/utils/deadtime.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { + UNPHYSICAL_DETECTED_RATE_MESSAGE, + deadTimeLossFraction, + detectedRateFromIncident, + incidentRateFromDetected, + parseNonNegativeNumber, +} from './deadtime'; + +describe('detectedRateFromIncident', () => { + it('applies r_det = r_in / (1 + r_in * dead_time)', () => { + // 300 c/s incident with a 2.5 ms dead time -> 300 / 1.75 + expect(detectedRateFromIncident(300, 0.0025)).toBeCloseTo(171.428571, 6); + }); + + it('is the identity for a zero dead time', () => { + expect(detectedRateFromIncident(300, 0)).toBe(300); + }); + + it('rejects negative or non-finite inputs', () => { + expect(detectedRateFromIncident(-1, 0.0025)).toBeNull(); + expect(detectedRateFromIncident(300, -0.0025)).toBeNull(); + expect(detectedRateFromIncident(Number.NaN, 0.0025)).toBeNull(); + expect(detectedRateFromIncident(300, Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); + +describe('incidentRateFromDetected', () => { + it('applies r_in = r_det / (1 - r_det * dead_time)', () => { + // 300 * 0.0025 = 0.75 occupancy -> 300 / 0.25 + expect(incidentRateFromDetected(300, 0.0025)).toBeCloseTo(1200, 9); + }); + + it('inverts detectedRateFromIncident', () => { + const detected = detectedRateFromIncident(300, 0.0025) as number; + expect(incidentRateFromDetected(detected, 0.0025)).toBeCloseTo(300, 9); + }); + + it('returns null when detected rate x dead time reaches 1', () => { + expect(incidentRateFromDetected(400, 0.0025)).toBeNull(); + expect(incidentRateFromDetected(500, 0.0025)).toBeNull(); + }); + + it('rejects negative or non-finite inputs', () => { + expect(incidentRateFromDetected(-1, 0.0025)).toBeNull(); + expect(incidentRateFromDetected(300, -0.0025)).toBeNull(); + expect(incidentRateFromDetected(Number.NaN, 0.0025)).toBeNull(); + }); + + it('exposes the unphysical-occupancy message for the UI', () => { + expect(UNPHYSICAL_DETECTED_RATE_MESSAGE).toBe( + 'unphysical: detected rate x dead time must be < 1' + ); + }); +}); + +describe('deadTimeLossFraction', () => { + it('is (incident - detected) / incident', () => { + expect(deadTimeLossFraction(300, 171.428571)).toBeCloseTo(0.428571, 6); + }); + + it('returns null when the incident rate is zero or invalid', () => { + expect(deadTimeLossFraction(0, 0)).toBeNull(); + expect(deadTimeLossFraction(-5, 1)).toBeNull(); + expect(deadTimeLossFraction(300, Number.NaN)).toBeNull(); + }); +}); + +describe('parseNonNegativeNumber', () => { + it('accepts zero and positive numbers', () => { + expect(parseNonNegativeNumber('0')).toBe(0); + expect(parseNonNegativeNumber(' 2.5 ')).toBe(2.5); + }); + + it('rejects blank, negative and non-numeric values', () => { + expect(parseNonNegativeNumber('')).toBeNull(); + expect(parseNonNegativeNumber(' ')).toBeNull(); + expect(parseNonNegativeNumber('-1')).toBeNull(); + expect(parseNonNegativeNumber('abc')).toBeNull(); + expect(parseNonNegativeNumber('Infinity')).toBeNull(); + }); +}); diff --git a/src/utils/deadtime.ts b/src/utils/deadtime.ts new file mode 100644 index 0000000..47ed941 --- /dev/null +++ b/src/utils/deadtime.ts @@ -0,0 +1,59 @@ +/** + * Client-side dead-time rate conversions for a NON-PARALYZABLE detector, + * mirroring `stingray.deadtime.filters.r_det` / `r_in`: + * + * r_det = r_in / (1 + r_in * dead_time) + * r_in = r_det / (1 - r_det * dead_time) + * + * The second relation only exists while `r_det * dead_time < 1`; at or above 1 + * the detector would be busy for more than 100% of the time, which is + * unphysical (and is the same guard the backend applies before calling + * `deadtime_correct`). stingray does not implement the paralyzable case, so + * these are the only conversions offered. + */ + +/** Helper text shown when a detected rate implies >= 100% detector occupancy. */ +export const UNPHYSICAL_DETECTED_RATE_MESSAGE = + 'unphysical: detected rate x dead time must be < 1'; + +/** Parse a text-field value into a finite non-negative number, or null if invalid. */ +export function parseNonNegativeNumber(value: string): number | null { + if (value.trim() === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +/** Detected (observed) rate for an incident rate and dead time, or null if inputs are invalid. */ +export function detectedRateFromIncident( + incidentRate: number, + deadTime: number +): number | null { + if (!Number.isFinite(incidentRate) || !Number.isFinite(deadTime)) return null; + if (incidentRate < 0 || deadTime < 0) return null; + return incidentRate / (1 + incidentRate * deadTime); +} + +/** + * Incident (true) rate implied by a detected rate and dead time. + * Returns null when `detectedRate * deadTime >= 1` (unphysical occupancy) or + * when the inputs are invalid. + */ +export function incidentRateFromDetected( + detectedRate: number, + deadTime: number +): number | null { + if (!Number.isFinite(detectedRate) || !Number.isFinite(deadTime)) return null; + if (detectedRate < 0 || deadTime < 0) return null; + if (detectedRate * deadTime >= 1) return null; + return detectedRate / (1 - detectedRate * deadTime); +} + +/** Fraction of incident events lost to dead time, or null when it is undefined. */ +export function deadTimeLossFraction( + incidentRate: number, + detectedRate: number +): number | null { + if (!Number.isFinite(incidentRate) || !Number.isFinite(detectedRate)) return null; + if (incidentRate <= 0) return null; + return (incidentRate - detectedRate) / incidentRate; +} diff --git a/src/utils/numbers.test.ts b/src/utils/numbers.test.ts new file mode 100644 index 0000000..376a588 --- /dev/null +++ b/src/utils/numbers.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { parseNumber, parsePositiveNumber } from './numbers'; + +describe('parsePositiveNumber', () => { + it('parses valid positive numbers', () => { + expect(parsePositiveNumber('0.0625')).toBe(0.0625); + expect(parsePositiveNumber('32')).toBe(32); + expect(parsePositiveNumber(' +6.25e-2 ')).toBe(0.0625); + }); + + it('rejects zero, negatives, and junk', () => { + expect(parsePositiveNumber('0')).toBeNull(); + expect(parsePositiveNumber('-1')).toBeNull(); + expect(parsePositiveNumber('abc')).toBeNull(); + expect(parsePositiveNumber('')).toBeNull(); + expect(parsePositiveNumber('1e999')).toBeNull(); + expect(parsePositiveNumber('0x10')).toBeNull(); + expect(parsePositiveNumber('0b10')).toBeNull(); + }); +}); + +describe('parseNumber', () => { + it('parses any finite number', () => { + expect(parseNumber('-2.5')).toBe(-2.5); + expect(parseNumber('0')).toBe(0); + expect(parseNumber('-.5E+2')).toBe(-50); + expect(parseNumber('+1.')).toBe(1); + }); + + it('rejects non-numeric input', () => { + expect(parseNumber('1e999')).toBeNull(); + expect(parseNumber('x')).toBeNull(); + expect(parseNumber('')).toBeNull(); + expect(parseNumber(' ')).toBeNull(); + expect(parseNumber('0x10')).toBeNull(); + expect(parseNumber('0b10')).toBeNull(); + }); +}); diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts new file mode 100644 index 0000000..12071a8 --- /dev/null +++ b/src/utils/numbers.ts @@ -0,0 +1,22 @@ +const DECIMAL_NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; + +/** Whether text uses ordinary decimal/exponent notation (never JS hex, binary, or octal). */ +export function isDecimalNumberText(value: string): boolean { + const trimmed = value.trim(); + return trimmed !== '' && DECIMAL_NUMBER_PATTERN.test(trimmed); +} + +/** Parse a text-field value into a finite positive decimal number, or null if invalid. */ +export function parsePositiveNumber(value: string): number | null { + const n = parseNumber(value); + if (n === null) return null; + return Number.isFinite(n) && n > 0 ? n : null; +} + +/** Parse a text-field value into any finite decimal number, or null if invalid. */ +export function parseNumber(value: string): number | null { + const trimmed = value.trim(); + if (!isDecimalNumberText(trimmed)) return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +} diff --git a/src/utils/powerColors.test.ts b/src/utils/powerColors.test.ts new file mode 100644 index 0000000..2a61c1d --- /dev/null +++ b/src/utils/powerColors.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { computePowerColorRatios } from './powerColors'; + +describe('computePowerColorRatios', () => { + const powerColors = { + A: [1, 2], + B: [10, 20], + C: [100, 200], + D: [5, 10], + }; + + it('computes PC1 = C/A and PC2 = B/D per segment', () => { + const result = computePowerColorRatios(powerColors, ['A', 'B', 'C', 'D']); + expect(result).not.toBeNull(); + expect(result?.pc1).toEqual([100, 100]); + expect(result?.pc2).toEqual([2, 2]); + }); + + it('returns null when a band is missing or count is not 4', () => { + expect(computePowerColorRatios(powerColors, ['A', 'B', 'C'])).toBeNull(); + expect(computePowerColorRatios({ A: [1] }, ['A', 'B', 'C', 'D'])).toBeNull(); + }); + + it('skips segments with non-positive denominators', () => { + const result = computePowerColorRatios( + { A: [0, 1], B: [1, 1], C: [1, 1], D: [1, 1] }, + ['A', 'B', 'C', 'D'] + ); + expect(result?.pc1).toEqual([1]); + expect(result?.pc2).toEqual([1]); + }); + + it('skips segments where any band value is null', () => { + const result = computePowerColorRatios( + { A: [1, null], B: [1, 1], C: [1, 1], D: [1, 1] }, + ['A', 'B', 'C', 'D'] + ); + expect(result?.pc1).toEqual([1]); + expect(result?.pc2).toEqual([1]); + }); +}); diff --git a/src/utils/powerColors.ts b/src/utils/powerColors.ts new file mode 100644 index 0000000..8502cda --- /dev/null +++ b/src/utils/powerColors.ts @@ -0,0 +1,34 @@ +/** + * Power-color ratios following the Heil et al. (2015) convention: with four + * frequency bands A < B < C < D (ascending f_min), + * PC1 = P(C) / P(A) and PC2 = P(B) / P(D) + * computed per dynamical-spectrum segment. + */ +export interface PowerColorRatios { + pc1: number[]; + pc2: number[]; +} + +export function computePowerColorRatios( + powerColors: Record>, + bandOrder: string[] +): PowerColorRatios | null { + if (bandOrder.length !== 4) return null; + const [a, b, c, d] = bandOrder.map((key) => powerColors[key]); + if (!a || !b || !c || !d) return null; + + const n = Math.min(a.length, b.length, c.length, d.length); + const pc1: number[] = []; + const pc2: number[] = []; + for (let i = 0; i < n; i++) { + const av = a[i]; + const bv = b[i]; + const cv = c[i]; + const dv = d[i]; + if (av != null && bv != null && cv != null && dv != null && av > 0 && bv > 0 && cv > 0 && dv > 0) { + pc1.push(cv / av); + pc2.push(bv / dv); + } + } + return { pc1, pc2 }; +} diff --git a/src/utils/utilityInputs.test.ts b/src/utils/utilityInputs.test.ts new file mode 100644 index 0000000..78a6a22 --- /dev/null +++ b/src/utils/utilityInputs.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + parseGtiRows, + parseNumericArray, + parseNumericMatrix, + parsePositiveInteger, + validateDerivedName, +} from './utilityInputs'; + +describe('utility input parsing', () => { + it('parses finite numeric arrays across supported delimiters', () => { + expect(parseNumericArray('1, 2\n3 4').value).toEqual([1, 2, 3, 4]); + expect(parseNumericArray('-.5, +2.0e3').value).toEqual([-0.5, 2000]); + expect(parseNumericArray('1, NaN').error).toMatch(/finite/); + expect(parseNumericArray('0x10').error).toMatch(/decimal/); + expect(parseNumericArray('1 2 3', 'x', 2).error).toMatch(/cap/); + }); + + it('preserves GTI row order and identifies malformed rows', () => { + expect(parseGtiRows('5, 6\n1 2').value).toEqual([[5, 6], [1, 2]]); + expect(parseGtiRows('1 2 3').error).toMatch(/row 1/); + expect(parseGtiRows('0x10, 20').error).toMatch(/finite decimal/); + }); + + it('requires a rectangular multi-row matrix', () => { + expect(parseNumericMatrix('1 2\n3 4').value).toEqual([[1, 2], [3, 4]]); + expect(parseNumericMatrix('1 2\n3').error).toMatch(/expected 2/); + expect(parseNumericMatrix('1 2').error).toMatch(/two/); + expect(parseNumericMatrix('1 2\n3 4', 3).error).toMatch(/3-value cap/); + }); + + it('validates unique destination names and positive integers', () => { + expect(validateDerivedName('filtered-events_2')).toBeNull(); + expect(validateDerivedName(' ../bad')).not.toBeNull(); + expect(parsePositiveInteger('3')).toBe(3); + expect(parsePositiveInteger('1e3')).toBe(1000); + expect(parsePositiveInteger('3.5')).toBeNull(); + expect(parsePositiveInteger('0x10')).toBeNull(); + }); +}); diff --git a/src/utils/utilityInputs.ts b/src/utils/utilityInputs.ts new file mode 100644 index 0000000..5b27a26 --- /dev/null +++ b/src/utils/utilityInputs.ts @@ -0,0 +1,111 @@ +import { isDecimalNumberText } from './numbers'; + +export const MAX_UTILITY_VALUES = 100_000; +export const MAX_GTI_ROWS = 10_000; +export const MAX_MATRIX_CELLS = 200_000; + +export interface ParseResult { + value: T | null; + error: string | null; +} + +/** Parse comma/space/newline-delimited finite numbers with an allocation cap. */ +export function parseNumericArray( + text: string, + label = 'Values', + maxValues = MAX_UTILITY_VALUES +): ParseResult { + const trimmed = text.trim(); + if (!trimmed) return { value: null, error: `${label} are required` }; + const tokens = trimmed.split(/[\s,]+/); + if (tokens.length > maxValues) { + return { value: null, error: `${label} contain ${tokens.length.toLocaleString()} values; the cap is ${maxValues.toLocaleString()}` }; + } + const values: number[] = []; + for (let index = 0; index < tokens.length; index += 1) { + if (!isDecimalNumberText(tokens[index])) { + return { + value: null, + error: `${label} value ${index + 1} is not a finite decimal number`, + }; + } + const value = Number(tokens[index]); + if (!Number.isFinite(value)) { + return { value: null, error: `${label} value ${index + 1} is not a finite number` }; + } + values.push(value); + } + return { value: values, error: null }; +} + +/** Parse one ``start, stop`` GTI per non-empty line without sorting or merging. */ +export function parseGtiRows(text: string): ParseResult<[number, number][]> { + const lines = text.split(/\r?\n/).filter((line) => line.trim() !== ''); + if (lines.length === 0) return { value: null, error: 'At least one GTI row is required' }; + if (lines.length > MAX_GTI_ROWS) { + return { value: null, error: `GTI input contains ${lines.length.toLocaleString()} rows; the cap is ${MAX_GTI_ROWS.toLocaleString()}` }; + } + const rows: [number, number][] = []; + for (let index = 0; index < lines.length; index += 1) { + const parts = lines[index].trim().split(/[\s,]+/); + if (parts.length !== 2) { + return { value: null, error: `GTI row ${index + 1} must contain exactly start and stop` }; + } + if (!parts.every((part) => isDecimalNumberText(part))) { + return { + value: null, + error: `GTI row ${index + 1} must contain finite decimal start and stop values`, + }; + } + const start = Number(parts[0]); + const stop = Number(parts[1]); + if (!Number.isFinite(start) || !Number.isFinite(stop)) { + return { value: null, error: `GTI row ${index + 1} must contain finite numbers` }; + } + rows.push([start, stop]); + } + return { value: rows, error: null }; +} + +/** Parse one equally sized numeric sample vector per line for SEM calculations. */ +export function parseNumericMatrix( + text: string, + maxCells = MAX_MATRIX_CELLS +): ParseResult { + const lines = text.split(/\r?\n/).filter((line) => line.trim() !== ''); + if (lines.length < 2) { + return { value: null, error: 'At least two sample rows are required' }; + } + const rows: number[][] = []; + let columns: number | null = null; + let cells = 0; + for (let index = 0; index < lines.length; index += 1) { + const parsed = parseNumericArray(lines[index], `Sample row ${index + 1}`); + if (!parsed.value) return { value: null, error: parsed.error }; + columns ??= parsed.value.length; + if (parsed.value.length !== columns) { + return { value: null, error: `Sample row ${index + 1} has ${parsed.value.length} columns; expected ${columns}` }; + } + cells += parsed.value.length; + if (cells > maxCells) { + return { value: null, error: `Sample matrix exceeds the ${maxCells.toLocaleString()}-value cap` }; + } + rows.push(parsed.value); + } + return { value: rows, error: null }; +} + +export function validateDerivedName(name: string): string | null { + if (name !== name.trim()) return 'Name must not start or end with whitespace'; + if (!/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/.test(name)) { + return "Use 1-64 letters, digits, spaces, '.', '_' or '-', starting with a letter or digit"; + } + return null; +} + +export function parsePositiveInteger(value: string): number | null { + const trimmed = value.trim(); + if (!isDecimalNumberText(trimmed)) return null; + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} diff --git a/test_astropy_roundtrip.py b/test_astropy_roundtrip.py deleted file mode 100644 index 8e319d4..0000000 --- a/test_astropy_roundtrip.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -Test script for Astropy export/import roundtrip functionality. - -This script verifies that EventLists can be exported to Astropy Tables -and imported back without data loss. -""" - -import numpy as np -import tempfile -import os -from stingray import EventList -from utils.state_manager import state_manager -from services import ServiceRegistry - - -def test_astropy_roundtrip(): - """Test the complete roundtrip: EventList -> Astropy Table -> EventList.""" - print("=" * 60) - print("Testing Astropy Roundtrip Functionality") - print("=" * 60) - - # Initialize services - services = ServiceRegistry(state_manager) - - # Create a test EventList - print("\n1. Creating test EventList...") - n_events = 1000 - times = np.sort(np.random.uniform(0, 100, n_events)) - energies = np.random.uniform(1, 10, n_events) - gti = np.array([[0, 100]]) - - test_event_list = EventList( - time=times, - energy=energies, - gti=gti - ) - - print(f" Created EventList with {len(test_event_list.time)} events") - print(f" Time range: {test_event_list.time[0]:.2f} - {test_event_list.time[-1]:.2f}") - print(f" Energy range: {test_event_list.energy.min():.2f} - {test_event_list.energy.max():.2f} keV") - - # Add to state - state_manager.add_event_data("test_eventlist", test_event_list) - - # Test export to different formats - formats_to_test = ["ascii.ecsv", "fits", "hdf5"] - - for fmt in formats_to_test: - print(f"\n{'=' * 60}") - print(f"Testing format: {fmt}") - print(f"{'=' * 60}") - - # Create temporary file - suffix = { - "ascii.ecsv": ".ecsv", - "fits": ".fits", - "hdf5": ".h5", - "votable": ".xml" - }.get(fmt, ".dat") - - with tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) as tmp: - temp_path = tmp.name - - try: - # Export - print(f"\n2. Exporting EventList to {fmt}...") - export_result = services.data.export_event_list_to_astropy_table( - event_list_name="test_eventlist", - output_path=temp_path, - fmt=fmt - ) - - if not export_result["success"]: - print(f" FAILED: {export_result['message']}") - continue - - print(f" SUCCESS: Exported to {temp_path}") - print(f" Rows: {export_result['metadata']['n_rows']}") - print(f" File size: {os.path.getsize(temp_path) / 1024:.2f} KB") - - # Import - print(f"\n3. Importing EventList from {fmt}...") - import_name = f"imported_{fmt.replace('.', '_')}" - import_result = services.data.import_event_list_from_astropy_table( - file_path=temp_path, - name=import_name, - fmt=fmt - ) - - if not import_result["success"]: - print(f" FAILED: {import_result['message']}") - continue - - print(f" SUCCESS: Imported as '{import_name}'") - print(f" Events: {import_result['metadata']['n_events']}") - - # Verify data integrity - print(f"\n4. Verifying data integrity...") - imported_event_list = state_manager.get_event_data(import_name) - - # Check number of events - original_n_events = len(test_event_list.time) - imported_n_events = len(imported_event_list.time) - - if original_n_events != imported_n_events: - print(f" WARNING: Event count mismatch!") - print(f" Original: {original_n_events}, Imported: {imported_n_events}") - else: - print(f" Event count: {imported_n_events} (matches)") - - # Check time data - time_diff = np.abs(test_event_list.time - imported_event_list.time).max() - print(f" Max time difference: {time_diff:.2e} seconds") - - if time_diff < 1e-6: - print(f" Time data: EXACT MATCH") - else: - print(f" Time data: CLOSE MATCH (within tolerance)") - - # Check energy data - if hasattr(imported_event_list, 'energy') and imported_event_list.energy is not None: - energy_diff = np.abs(test_event_list.energy - imported_event_list.energy).max() - print(f" Max energy difference: {energy_diff:.2e} keV") - - if energy_diff < 1e-6: - print(f" Energy data: EXACT MATCH") - else: - print(f" Energy data: CLOSE MATCH (within tolerance)") - else: - print(f" Energy data: NOT PRESERVED (expected for some formats)") - - print(f"\n ROUNDTRIP TEST PASSED for {fmt}") - - except Exception as e: - print(f"\n ERROR: {str(e)}") - import traceback - traceback.print_exc() - - finally: - # Cleanup - if os.path.exists(temp_path): - os.unlink(temp_path) - print(f"\n Cleaned up temporary file: {temp_path}") - - print(f"\n{'=' * 60}") - print("All roundtrip tests completed") - print(f"{'=' * 60}") - - -if __name__ == "__main__": - test_astropy_roundtrip() diff --git a/tests/test_dataloading/test_dataingestion.py b/tests/test_dataloading/test_dataingestion.py deleted file mode 100644 index 3f2b924..0000000 --- a/tests/test_dataloading/test_dataingestion.py +++ /dev/null @@ -1,189 +0,0 @@ -import pytest -from unittest.mock import MagicMock, patch -from modules.DataLoading.DataIngestion import ( - create_loadingdata_output_box, - load_event_data, - save_loaded_files, - delete_selected_files, - preview_loaded_files, - clear_loaded_files, - create_event_list, - simulate_event_list, - create_warning_handler, -) - - -def test_create_loadingdata_output_box(): - content = "File loaded successfully." - output_box = create_loadingdata_output_box(content) - assert output_box.output_content == content - - -@patch("dataingestion.loaded_event_data", []) -def test_load_event_data_no_file_selected( - output_box_container, warning_box_container, warning_handler, mock_file_selector, filename_input, format_input, format_checkbox -): - # Set up file selector with no selection - mock_file_selector.value = [] - load_event_data( - event=None, - file_selector=mock_file_selector, - filename_input=filename_input, - format_input=format_input, - format_checkbox=format_checkbox, - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert "No file selected" in output_box_container[0].output_content - - -@patch("dataingestion.loaded_event_data", []) -@patch("dataingestion.EventList.read") -def test_load_event_data_success(mock_read, output_box_container, warning_box_container, warning_handler, mock_file_selector, filename_input, format_input, format_checkbox): - # Mock EventList read to return a valid event - mock_read.return_value = MagicMock() - - load_event_data( - event=None, - file_selector=mock_file_selector, - filename_input=filename_input, - format_input=format_input, - format_checkbox=format_checkbox, - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert len(output_box_container) > 0 - assert "loaded successfully" in output_box_container[0].output_content - - -@patch("dataingestion.loaded_event_data", [("file1", MagicMock())]) -def test_load_event_data_duplicate_file( - output_box_container, warning_box_container, warning_handler, mock_file_selector, filename_input, format_input, format_checkbox -): - # Test with duplicate file name - filename_input.value = "file1" - load_event_data( - event=None, - file_selector=mock_file_selector, - filename_input=filename_input, - format_input=format_input, - format_checkbox=format_checkbox, - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert "already exists in memory" in output_box_container[0].output_content - - -@patch("dataingestion.os.path.exists", return_value=False) -@patch("dataingestion.loaded_event_data", [("file1", MagicMock())]) -def test_save_loaded_files_success(mock_exists, output_box_container, warning_box_container, warning_handler, filename_input, format_input, format_checkbox): - save_loaded_files( - event=None, - filename_input=filename_input, - format_input=format_input, - format_checkbox=format_checkbox, - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert "saved successfully" in output_box_container[0].output_content - - -@patch("dataingestion.os.path.exists", return_value=True) -@patch("dataingestion.loaded_event_data", [("file1", MagicMock())]) -def test_save_loaded_files_duplicate_name(mock_exists, output_box_container, warning_box_container, warning_handler, filename_input, format_input, format_checkbox): - save_loaded_files( - event=None, - filename_input=filename_input, - format_input=format_input, - format_checkbox=format_checkbox, - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert "already exists" in output_box_container[0].output_content - - -@patch("dataingestion.os.remove") -def test_delete_selected_files_success(mock_remove, output_box_container, warning_box_container, warning_handler, mock_file_selector): - delete_selected_files( - event=None, - file_selector=mock_file_selector, - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert "deleted successfully" in output_box_container[0].output_content - - -def test_preview_loaded_files_no_data(output_box_container, warning_box_container, warning_handler): - preview_loaded_files( - event=None, - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert "No valid files or light curves loaded" in output_box_container[0].output_content - - -@patch("dataingestion.loaded_event_data", [("event1", MagicMock(time=[0.1, 0.2], mjdref=58000, gti=[[0, 1]]) )]) -def test_preview_loaded_files_with_data(output_box_container, warning_box_container, warning_handler): - preview_loaded_files( - event=None, - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert "Event List - event1" in output_box_container[0].output_content - - -@patch("dataingestion.loaded_event_data", [("event1", MagicMock())]) -def test_clear_loaded_files(output_box_container, warning_box_container): - clear_loaded_files( - event=None, - output_box_container=output_box_container, - warning_box_container=warning_box_container, - ) - assert "cleared" in output_box_container[0].output_content - - -def test_create_event_list_missing_data(output_box_container, warning_box_container, warning_handler): - create_event_list( - event=None, - times_input=MagicMock(value=""), - energy_input=MagicMock(value=""), - pi_input=MagicMock(value=""), - gti_input=MagicMock(value=""), - mjdref_input=MagicMock(value=""), - name_input=MagicMock(value=""), - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert "Please enter Photon Arrival Times and MJDREF" in output_box_container[0].output_content - - -def test_simulate_event_list(output_box_container, warning_box_container, warning_handler): - simulate_event_list( - event=None, - time_slider=MagicMock(value=10), - count_slider=MagicMock(value=5), - dt_input=MagicMock(value=0.1), - name_input=MagicMock(value="simulated_event"), - method_selector=MagicMock(value="Standard Method"), - output_box_container=output_box_container, - warning_box_container=warning_box_container, - warning_handler=warning_handler, - ) - assert "simulated successfully" in output_box_container[0].output_content - - -def test_create_warning_handler(): - handler = create_warning_handler() - with pytest.warns(None) as record: - handler.warn("Test warning", category=UserWarning) - assert len(record) == 1 - assert record[0].message.args[0] == "Test warning" diff --git a/tests/test_lazy_loader.py b/tests/test_lazy_loader.py deleted file mode 100644 index 91f87ba..0000000 --- a/tests/test_lazy_loader.py +++ /dev/null @@ -1,506 +0,0 @@ -""" -Unit tests for the LazyEventLoader class. - -This test suite covers: -- LazyEventLoader initialization and file handling -- Metadata extraction without loading full data -- Memory usage estimation -- Safety checks and risk assessment -- File size formatting -- Error handling for invalid files -""" - -import pytest -import os -import tempfile -import numpy as np -from unittest.mock import MagicMock, patch, PropertyMock -from utils.lazy_loader import LazyEventLoader, assess_loading_risk - - -# ============================================================================= -# Fixtures -# ============================================================================= - -@pytest.fixture -def mock_fits_file(): - """Create a temporary mock FITS file.""" - with tempfile.NamedTemporaryFile(suffix='.fits', delete=False) as f: - # Write some dummy data to make it a non-zero size - f.write(b'SIMPLE = T' * 100) # Fake FITS header - temp_path = f.name - - yield temp_path - - # Cleanup - if os.path.exists(temp_path): - os.remove(temp_path) - - -@pytest.fixture -def mock_fits_reader(): - """Create a mock FITSTimeseriesReader.""" - mock_reader = MagicMock() - mock_reader.gti = np.array([[0, 1000], [1100, 2000]]) - mock_reader.mjdref = 58000.0 - return mock_reader - - -# ============================================================================= -# Test: LazyEventLoader Initialization -# ============================================================================= - -def test_lazy_loader_init_with_nonexistent_file(): - """Test initialization with non-existent file raises FileNotFoundError.""" - with pytest.raises(FileNotFoundError): - LazyEventLoader("/path/to/nonexistent/file.fits") - - -def test_lazy_loader_init_with_invalid_fits(mock_fits_file): - """Test initialization with invalid FITS file raises ValueError.""" - # The mock file isn't a real FITS file, so this should fail - with pytest.raises(ValueError, match="Failed to open FITS file"): - LazyEventLoader(mock_fits_file) - - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_lazy_loader_init_success(mock_reader_class, mock_fits_file): - """Test successful initialization.""" - mock_reader_class.return_value = MagicMock() - - loader = LazyEventLoader(mock_fits_file) - - assert loader.file_path == mock_fits_file - assert loader.file_size > 0 - assert loader.reader is not None - mock_reader_class.assert_called_once_with(mock_fits_file, data_kind="times") - - -# ============================================================================= -# Test: Metadata Extraction -# ============================================================================= - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_get_metadata(mock_reader_class, mock_fits_file, mock_fits_reader): - """Test metadata extraction without loading event data.""" - mock_reader_class.return_value = mock_fits_reader - - loader = LazyEventLoader(mock_fits_file) - metadata = loader.get_metadata() - - # Check all expected keys present - assert 'gti' in metadata - assert 'mjdref' in metadata - assert 'n_events_estimate' in metadata - assert 'time_range' in metadata - assert 'file_size_mb' in metadata - assert 'file_size_gb' in metadata - assert 'duration_s' in metadata - assert 'estimated_count_rate' in metadata - - # Check values - assert np.array_equal(metadata['gti'], mock_fits_reader.gti) - assert metadata['mjdref'] == 58000.0 - assert metadata['duration_s'] == 1900.0 # (1000-0) + (2000-1100) - assert metadata['n_events_estimate'] > 0 - - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_get_metadata_time_range(mock_reader_class, mock_fits_file, mock_fits_reader): - """Test that time_range is correctly extracted from GTIs.""" - mock_reader_class.return_value = mock_fits_reader - - loader = LazyEventLoader(mock_fits_file) - metadata = loader.get_metadata() - - time_range = metadata['time_range'] - assert time_range == (0.0, 2000.0) # min and max from GTIs - - -# ============================================================================= -# Test: Memory Estimation -# ============================================================================= - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_estimate_memory_usage_fits(mock_reader_class, mock_fits_file): - """Test memory estimation for FITS files.""" - mock_reader_class.return_value = MagicMock() - - loader = LazyEventLoader(mock_fits_file) - estimated = loader.estimate_memory_usage('fits') - - # FITS multiplier is 3x (based on Stingray benchmarks: 2GB → 5.2GB = 2.6x, rounded to 3x) - expected = loader.file_size * 3 - assert estimated == expected - - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_estimate_memory_usage_hdf5(mock_reader_class, mock_fits_file): - """Test memory estimation for HDF5 files.""" - mock_reader_class.return_value = MagicMock() - - loader = LazyEventLoader(mock_fits_file) - estimated = loader.estimate_memory_usage('hdf5') - - # HDF5 multiplier is 2x (more efficient format) - expected = loader.file_size * 2 - assert estimated == expected - - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_estimate_memory_usage_pickle(mock_reader_class, mock_fits_file): - """Test memory estimation for pickle files.""" - mock_reader_class.return_value = MagicMock() - - loader = LazyEventLoader(mock_fits_file) - estimated = loader.estimate_memory_usage('pickle') - - # Pickle multiplier is 1.5x (most efficient format) - expected = loader.file_size * 1.5 - assert estimated == expected - - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_estimate_memory_usage_unknown_format(mock_reader_class, mock_fits_file): - """Test memory estimation for unknown format defaults to conservative multiplier.""" - mock_reader_class.return_value = MagicMock() - - loader = LazyEventLoader(mock_fits_file) - estimated = loader.estimate_memory_usage('unknown_format') - - # Default multiplier is 3x (conservative default, same as FITS) - expected = loader.file_size * 3 - assert estimated == expected - - -# ============================================================================= -# Test: Safety Checks -# ============================================================================= - -@patch('utils.lazy_loader.FITSTimeseriesReader') -@patch('utils.lazy_loader.psutil.virtual_memory') -def test_can_load_safely_safe(mock_vmem, mock_reader_class, mock_fits_file): - """Test can_load_safely returns True when safe.""" - mock_reader_class.return_value = MagicMock() - - # Mock large available memory - mock_vmem.return_value.available = 16 * 1024**3 # 16 GB - - loader = LazyEventLoader(mock_fits_file) - # Small file, lots of memory -> should be safe - assert loader.can_load_safely(safety_margin=0.5) is True - - -@patch('utils.lazy_loader.FITSTimeseriesReader') -@patch('utils.lazy_loader.psutil.virtual_memory') -def test_can_load_safely_unsafe(mock_vmem, mock_reader_class, mock_fits_file): - """Test can_load_safely returns False when unsafe.""" - mock_reader_class.return_value = MagicMock() - - # Mock small available memory relative to file size - # File is ~1.1 KB, with 3x multiplier = ~3.3 KB needed - # Set available to 5 KB, so 50% margin = 2.5 KB safe limit - # 3.3 KB > 2.5 KB -> should be unsafe - mock_vmem.return_value.available = 5 * 1024 # 5 KB - - loader = LazyEventLoader(mock_fits_file) - # File needs more memory than safe limit -> should be unsafe - assert loader.can_load_safely(safety_margin=0.5) is False - - -@patch('utils.lazy_loader.FITSTimeseriesReader') -@patch('utils.lazy_loader.psutil.virtual_memory') -def test_can_load_safely_custom_margin(mock_vmem, mock_reader_class, mock_fits_file): - """Test can_load_safely with custom safety margin.""" - mock_reader_class.return_value = MagicMock() - - # Mock specific available memory - mock_vmem.return_value.available = 1 * 1024**3 # 1 GB - - loader = LazyEventLoader(mock_fits_file) - - # With high safety margin (10%), should be safer - result_high_margin = loader.can_load_safely(safety_margin=0.1) - - # With low safety margin (90%), should be less safe - result_low_margin = loader.can_load_safely(safety_margin=0.9) - - # High margin is more conservative (more likely to be unsafe) - # Low margin is less conservative (more likely to be safe) - # For small test file, both might be True, but the logic is correct - - -# ============================================================================= -# Test: System Memory Info -# ============================================================================= - -@patch('utils.lazy_loader.FITSTimeseriesReader') -@patch('utils.lazy_loader.psutil.virtual_memory') -@patch('utils.lazy_loader.psutil.Process') -def test_get_system_memory_info(mock_process, mock_vmem, mock_reader_class, mock_fits_file): - """Test system memory info retrieval.""" - mock_reader_class.return_value = MagicMock() - - # Mock memory values - mock_vmem.return_value.total = 16 * 1024**3 # 16 GB - mock_vmem.return_value.available = 8 * 1024**3 # 8 GB - mock_vmem.return_value.used = 8 * 1024**3 # 8 GB - mock_vmem.return_value.percent = 50.0 - - mock_process.return_value.memory_info.return_value.rss = 256 * 1024**2 # 256 MB - - loader = LazyEventLoader(mock_fits_file) - mem_info = loader.get_system_memory_info() - - # Check all expected keys - assert 'total_mb' in mem_info - assert 'available_mb' in mem_info - assert 'used_mb' in mem_info - assert 'percent' in mem_info - assert 'process_mb' in mem_info - - # Check values - assert mem_info['total_mb'] == 16 * 1024 # 16 GB in MB - assert mem_info['available_mb'] == 8 * 1024 # 8 GB in MB - assert mem_info['percent'] == 50.0 - assert mem_info['process_mb'] == 256.0 - - -# ============================================================================= -# Test: File Size Formatting -# ============================================================================= - -def test_format_file_size_bytes(): - """Test formatting bytes.""" - assert LazyEventLoader.format_file_size(500) == "500.0 B" - - -def test_format_file_size_kilobytes(): - """Test formatting kilobytes.""" - assert LazyEventLoader.format_file_size(1500) == "1.5 KB" - - -def test_format_file_size_megabytes(): - """Test formatting megabytes.""" - assert LazyEventLoader.format_file_size(2 * 1024**2) == "2.0 MB" - - -def test_format_file_size_gigabytes(): - """Test formatting gigabytes.""" - assert LazyEventLoader.format_file_size(3.5 * 1024**3) == "3.5 GB" - - -def test_format_file_size_terabytes(): - """Test formatting terabytes.""" - assert LazyEventLoader.format_file_size(1.2 * 1024**4) == "1.2 TB" - - -# ============================================================================= -# Test: Risk Assessment Function -# ============================================================================= - -@patch('utils.lazy_loader.psutil.virtual_memory') -def test_assess_loading_risk_safe(mock_vmem): - """Test risk assessment returns 'safe' for small files.""" - mock_vmem.return_value.available = 16 * 1024**3 # 16 GB - - file_size = 100 * 1024**2 # 100 MB - risk = assess_loading_risk(file_size, file_format='fits') - - # 100 MB * 3 = 300 MB needed - # 300 MB / 16 GB = ~0.02 (2%) -> safe - assert risk == 'safe' - - -@patch('utils.lazy_loader.psutil.virtual_memory') -def test_assess_loading_risk_caution(mock_vmem): - """Test risk assessment returns 'caution' for medium files.""" - mock_vmem.return_value.available = 2 * 1024**3 # 2 GB - - file_size = 350 * 1024**2 # 350 MB - risk = assess_loading_risk(file_size, file_format='fits') - - # 350 MB * 3 = 1050 MB needed - # 1050 MB / 2048 MB = ~0.51 (51%) -> caution - assert risk == 'caution' - - -@patch('utils.lazy_loader.psutil.virtual_memory') -def test_assess_loading_risk_risky(mock_vmem): - """Test risk assessment returns 'risky' for large files.""" - mock_vmem.return_value.available = 2 * 1024**3 # 2 GB - - file_size = 480 * 1024**2 # 480 MB - risk = assess_loading_risk(file_size, file_format='fits') - - # 480 MB * 3 = 1440 MB needed - # 1440 MB / 2048 MB = ~0.70 (70%) -> risky - assert risk == 'risky' - - -@patch('utils.lazy_loader.psutil.virtual_memory') -def test_assess_loading_risk_critical(mock_vmem): - """Test risk assessment returns 'critical' for very large files.""" - mock_vmem.return_value.available = 1 * 1024**3 # 1 GB - - file_size = 350 * 1024**2 # 350 MB - risk = assess_loading_risk(file_size, file_format='fits') - - # 350 MB * 3 = 1050 MB needed - # 1050 MB / 1024 MB = ~1.03 (103%) -> critical - assert risk == 'critical' - - -@patch('utils.lazy_loader.psutil.virtual_memory') -def test_assess_loading_risk_different_formats(mock_vmem): - """Test risk assessment with different file formats.""" - mock_vmem.return_value.available = 4 * 1024**3 # 4 GB - - # Use different file sizes to test format-specific multipliers - # FITS: 1000 MB * 3 = 3000 MB (73% -> risky) - risk_fits = assess_loading_risk(1000 * 1024**2, file_format='fits', available_memory=4 * 1024**3) - - # HDF5: 850 MB * 2 = 1700 MB (41% -> caution) - risk_hdf5 = assess_loading_risk(850 * 1024**2, file_format='hdf5', available_memory=4 * 1024**3) - - # Pickle: 600 MB * 1.5 = 900 MB (22% -> safe) - risk_pickle = assess_loading_risk(600 * 1024**2, file_format='pickle', available_memory=4 * 1024**3) - - assert risk_fits in ['risky', 'critical'] - assert risk_hdf5 in ['safe', 'caution'] - assert risk_pickle == 'safe' - - -# ============================================================================= -# Test: Context Manager -# ============================================================================= - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_context_manager(mock_reader_class, mock_fits_file): - """Test LazyEventLoader as context manager.""" - mock_reader_class.return_value = MagicMock() - - with LazyEventLoader(mock_fits_file) as loader: - assert loader is not None - assert isinstance(loader, LazyEventLoader) - - -# ============================================================================= -# Test: String Representation -# ============================================================================= - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_repr(mock_reader_class, mock_fits_file): - """Test string representation.""" - mock_reader_class.return_value = MagicMock() - - loader = LazyEventLoader(mock_fits_file) - repr_str = repr(loader) - - assert 'LazyEventLoader' in repr_str - assert mock_fits_file in repr_str - assert 'KB' in repr_str or 'MB' in repr_str or 'GB' in repr_str - - -# ============================================================================= -# Test: Load Full (with mocking) -# ============================================================================= - -@patch('utils.lazy_loader.FITSTimeseriesReader') -@patch('utils.lazy_loader.EventList') -def test_load_full(mock_eventlist_class, mock_reader_class, mock_fits_file): - """Test load_full method.""" - mock_reader_class.return_value = MagicMock() - mock_event_list = MagicMock() - mock_event_list.time = np.arange(1000) - mock_eventlist_class.read.return_value = mock_event_list - - loader = LazyEventLoader(mock_fits_file) - events = loader.load_full() - - assert events is not None - mock_eventlist_class.read.assert_called_once() - - -@patch('utils.lazy_loader.FITSTimeseriesReader') -@patch('utils.lazy_loader.EventList') -def test_load_full_with_additional_columns(mock_eventlist_class, mock_reader_class, mock_fits_file): - """Test load_full with additional columns.""" - mock_reader_class.return_value = MagicMock() - mock_event_list = MagicMock() - mock_eventlist_class.read.return_value = mock_event_list - - loader = LazyEventLoader(mock_fits_file) - loader.load_full(additional_columns=['DETID', 'RAWX']) - - # Verify additional_columns was passed - call_kwargs = mock_eventlist_class.read.call_args[1] - assert 'additional_columns' in call_kwargs - assert call_kwargs['additional_columns'] == ['DETID', 'RAWX'] - - -# ============================================================================= -# Test: Stream Segments (with mocking) -# ============================================================================= - -@patch('utils.lazy_loader.FITSTimeseriesReader') -@patch('utils.lazy_loader.time_intervals_from_gtis') -def test_stream_segments(mock_time_intervals, mock_reader_class, mock_fits_file, mock_fits_reader): - """Test stream_segments method.""" - mock_reader_class.return_value = mock_fits_reader - - # Mock time intervals - mock_time_intervals.return_value = ( - np.array([0, 100, 200]), - np.array([100, 200, 300]) - ) - - # Mock filtered times - mock_fits_reader.filter_at_time_intervals.return_value = [ - np.array([10, 20, 30]), - np.array([110, 120]), - np.array([210, 220, 230, 240]) - ] - - loader = LazyEventLoader(mock_fits_file) - segments = list(loader.stream_segments(segment_size=100)) - - assert len(segments) == 3 - assert len(segments[0]) == 3 # First segment has 3 events - assert len(segments[1]) == 2 # Second segment has 2 events - assert len(segments[2]) == 4 # Third segment has 4 events - - -# ============================================================================= -# Test: Edge Cases -# ============================================================================= - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_metadata_with_zero_duration(mock_reader_class, mock_fits_file): - """Test metadata extraction with zero duration GTIs.""" - mock_reader = MagicMock() - mock_reader.gti = np.array([[0, 0]]) # Zero duration - mock_reader.mjdref = 58000.0 - mock_reader_class.return_value = mock_reader - - loader = LazyEventLoader(mock_fits_file) - metadata = loader.get_metadata() - - # Should handle zero duration gracefully - assert metadata['duration_s'] == 0.0 - assert metadata['estimated_count_rate'] == 0 # Avoid division by zero - - -@patch('utils.lazy_loader.FITSTimeseriesReader') -def test_metadata_with_no_mjdref(mock_reader_class, mock_fits_file): - """Test metadata extraction when MJDREF is missing.""" - mock_reader = MagicMock() - mock_reader.gti = np.array([[0, 1000]]) - del mock_reader.mjdref # Remove attribute - mock_reader_class.return_value = mock_reader - - loader = LazyEventLoader(mock_fits_file) - metadata = loader.get_metadata() - - # Should default to 0.0 - assert metadata['mjdref'] == 0.0 diff --git a/tests/test_lazy_loading_integration.py b/tests/test_lazy_loading_integration.py deleted file mode 100644 index 444a186..0000000 --- a/tests/test_lazy_loading_integration.py +++ /dev/null @@ -1,642 +0,0 @@ -""" -Integration tests for lazy loading workflow. - -This test suite covers end-to-end lazy loading functionality: -- DataService integration with lazy loading -- Memory usage verification -- Performance comparison (standard vs lazy) -- Error handling with real FITS files -- StateManager integration -- Large file handling scenarios -""" - -import pytest -import os -import tempfile -import numpy as np -import psutil -from unittest.mock import patch, MagicMock -from astropy.io import fits -from stingray import EventList - -from services.data_service import DataService -from utils.state_manager import StateManager -from utils.lazy_loader import LazyEventLoader, assess_loading_risk - - -# ============================================================================= -# Fixtures -# ============================================================================= - -@pytest.fixture -def state_manager(): - """Create a fresh StateManager instance for each test.""" - return StateManager() - - -@pytest.fixture -def data_service(state_manager): - """Create DataService instance with StateManager.""" - service = DataService(state_manager) - return service - - -@pytest.fixture -def sample_evt_file(): - """Path to real small sample EVT file.""" - return "files/data/monol_testA.evt" - - -@pytest.fixture -def sample_fits_file(): - """Path to real small sample FITS file.""" - return "files/data/lcurveA.fits" - - -@pytest.fixture -def synthetic_small_fits(): - """ - Create a synthetic small FITS file (~100KB) for testing. - - Yields path to temporary file, cleaned up after test. - """ - # Create temporary file - fd, tmp_path = tempfile.mkstemp(suffix='.evt') - os.close(fd) - - try: - # Generate synthetic event data - n_events = 10000 - tstart = 0.0 - duration = 1000.0 - - times = np.sort(np.random.uniform(tstart, tstart + duration, n_events)) - energy = np.random.uniform(0.5, 10.0, n_events) - pi = (energy * 100).astype(np.int32) - - # Create FITS file structure - # Primary HDU - primary = fits.PrimaryHDU() - - # Events extension - col1 = fits.Column(name='TIME', format='D', array=times) - col2 = fits.Column(name='ENERGY', format='E', array=energy) - col3 = fits.Column(name='PI', format='J', array=pi) - - cols = fits.ColDefs([col1, col2, col3]) - events_hdu = fits.BinTableHDU.from_columns(cols) - events_hdu.header['EXTNAME'] = 'EVENTS' - events_hdu.header['TELESCOP'] = 'TEST' - events_hdu.header['INSTRUME'] = 'SYNTHETIC' - events_hdu.header['MJDREFI'] = 55000 - events_hdu.header['MJDREFF'] = 0.0 - events_hdu.header['TIMEZERO'] = 0.0 - events_hdu.header['TIMEUNIT'] = 's' - # Add required timing keywords - events_hdu.header['TSTART'] = tstart - events_hdu.header['TSTOP'] = tstart + duration - events_hdu.header['TIMESYS'] = 'TT' - events_hdu.header['TIMEREF'] = 'LOCAL' - - # GTI extension - gti_start = np.array([tstart]) - gti_stop = np.array([tstart + duration]) - - col1 = fits.Column(name='START', format='D', array=gti_start) - col2 = fits.Column(name='STOP', format='D', array=gti_stop) - - gti_cols = fits.ColDefs([col1, col2]) - gti_hdu = fits.BinTableHDU.from_columns(gti_cols) - gti_hdu.header['EXTNAME'] = 'GTI' - - # Write FITS file - hdul = fits.HDUList([primary, events_hdu, gti_hdu]) - hdul.writeto(tmp_path, overwrite=True) - - yield tmp_path - - finally: - # Cleanup - if os.path.exists(tmp_path): - os.remove(tmp_path) - - -@pytest.fixture -def synthetic_large_fits_info(): - """ - Return parameters for a hypothetical large FITS file. - - We don't actually create it (too slow/large), but return - characteristics for testing logic. - """ - return { - 'file_size': 2.5 * 1024**3, # 2.5 GB - 'n_events': 200_000_000, # 200 million events - 'duration': 50000.0, # seconds - } - - -# ============================================================================= -# Integration Tests: DataService with Lazy Loading -# ============================================================================= - -def test_load_event_list_lazy_small_file_safe(data_service, synthetic_small_fits): - """ - Test lazy loading with a small file that's safe to load. - - Should use standard loading method since file is small. - """ - result = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="test_small", - safety_margin=0.5 - ) - - # Should succeed - assert result["success"] is True - assert result["data"] is not None - assert isinstance(result["data"], EventList) - - # Should use standard method for small file - assert result["metadata"]["method"] == "standard" - assert result["metadata"]["memory_safe"] is True - - # Verify data is in state manager - assert data_service.state.has_event_data("test_small") - retrieved = data_service.state.get_event_data("test_small") - assert len(retrieved) == len(result["data"].time) - - -def test_load_event_list_lazy_duplicate_name(data_service, synthetic_small_fits): - """Test that lazy loading prevents duplicate names.""" - # Load first time - result1 = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="duplicate_test", - safety_margin=0.5 - ) - assert result1["success"] is True - - # Try loading again with same name - result2 = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="duplicate_test", - safety_margin=0.5 - ) - assert result2["success"] is False - assert "already exists" in result2["message"] - - -def test_load_event_list_lazy_nonexistent_file(data_service): - """Test lazy loading with non-existent file.""" - result = data_service.load_event_list_lazy( - file_path="/nonexistent/file.evt", - name="test_missing", - safety_margin=0.5 - ) - - assert result["success"] is False - assert result["data"] is None - assert "error" in result - - -def test_check_file_size_small_file(data_service, synthetic_small_fits): - """Test file size checking with small file.""" - result = data_service.check_file_size(synthetic_small_fits) - - assert result["success"] is True - data = result["data"] - - # Verify structure - assert "file_size_bytes" in data - assert "file_size_mb" in data - assert "file_size_gb" in data - assert "risk_level" in data - assert "recommend_lazy" in data - assert "estimated_memory_mb" in data - assert "memory_info" in data - - # Small file should be safe - assert data["risk_level"] == "safe" - assert data["recommend_lazy"] is False - assert data["file_size_gb"] < 0.1 - - -def test_check_file_size_with_real_evt(data_service, sample_evt_file): - """Test file size checking with real sample EVT file.""" - if not os.path.exists(sample_evt_file): - pytest.skip(f"Sample file {sample_evt_file} not found") - - result = data_service.check_file_size(sample_evt_file) - - assert result["success"] is True - data = result["data"] - - # Should be safe for small file - assert data["risk_level"] == "safe" - assert data["file_size_mb"] < 1.0 # Sample files are < 1MB - - -def test_get_file_metadata(data_service, synthetic_small_fits): - """Test metadata extraction without loading full data.""" - result = data_service.get_file_metadata(synthetic_small_fits) - - assert result["success"] is True - metadata = result["data"] - - # Verify metadata structure - assert "gti" in metadata - assert "mjdref" in metadata - assert "n_events_estimate" in metadata - assert "time_range" in metadata - assert "file_size_mb" in metadata - assert "duration_s" in metadata - - # Verify reasonable values - assert metadata["duration_s"] > 0 - assert metadata["n_events_estimate"] > 0 - - -def test_is_large_file(data_service, synthetic_small_fits): - """Test large file detection.""" - # Small file - assert data_service.is_large_file(synthetic_small_fits, threshold_gb=1.0) is False - - # With very small threshold - assert data_service.is_large_file(synthetic_small_fits, threshold_gb=0.00001) is True - - -# ============================================================================= -# Integration Tests: Memory Usage Monitoring -# ============================================================================= - -def test_memory_usage_during_loading(data_service, synthetic_small_fits): - """ - Test that memory usage is tracked during loading. - - Verifies performance monitoring integration. - """ - # Get initial memory - process = psutil.Process() - mem_before = process.memory_info().rss / (1024**2) # MB - - # Load file - result = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="mem_test", - safety_margin=0.5 - ) - - # Get final memory - mem_after = process.memory_info().rss / (1024**2) # MB - - # Should succeed - assert result["success"] is True - - # Memory should increase (but not by much for small file) - mem_increase = mem_after - mem_before - assert mem_increase >= 0 # Memory should not decrease - - # For small test file (~100KB), increase should be < 50 MB - assert mem_increase < 50 - - -def test_lazy_loader_memory_info(synthetic_small_fits): - """Test LazyEventLoader memory info reporting.""" - loader = LazyEventLoader(synthetic_small_fits) - mem_info = loader.get_system_memory_info() - - # Verify structure - assert "total_mb" in mem_info - assert "available_mb" in mem_info - assert "used_mb" in mem_info - assert "percent" in mem_info - assert "process_mb" in mem_info - - # Verify reasonable values - assert mem_info["total_mb"] > 0 - assert mem_info["available_mb"] > 0 - assert 0 <= mem_info["percent"] <= 100 - - -# ============================================================================= -# Integration Tests: Error Handling -# ============================================================================= - -def test_load_corrupted_fits_file(data_service): - """Test loading a corrupted FITS file.""" - # Create corrupted file - fd, tmp_path = tempfile.mkstemp(suffix='.evt') - try: - os.write(fd, b"This is not a valid FITS file") - os.close(fd) - - result = data_service.load_event_list_lazy( - file_path=tmp_path, - name="corrupted", - safety_margin=0.5 - ) - - # Should fail gracefully - assert result["success"] is False - assert "error" in result - - finally: - if os.path.exists(tmp_path): - os.remove(tmp_path) - - -def test_load_with_memory_error_simulation(data_service, synthetic_small_fits): - """ - Test handling of MemoryError during loading. - - Simulates out-of-memory condition. - """ - # Patch EventList.read to raise MemoryError - with patch('utils.lazy_loader.EventList.read', side_effect=MemoryError("Out of memory")): - result = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="oom_test", - safety_margin=0.5 - ) - - # Should fail with specific message - assert result["success"] is False - assert "Out of memory" in result["message"] or "memory" in result["message"].lower() - - -# ============================================================================= -# Integration Tests: Performance Comparison -# ============================================================================= - -def test_standard_vs_lazy_loading_workflow(data_service, synthetic_small_fits): - """ - Compare standard vs lazy loading workflow. - - For small files, both should work, but lazy adds overhead. - """ - import time - - # Test standard loading - start = time.time() - result_standard = data_service.load_event_list( - file_path=synthetic_small_fits, - name="standard_test", - fmt="ogip" - ) - time_standard = time.time() - start - - assert result_standard["success"] is True - - # Test lazy loading (with new name) - start = time.time() - result_lazy = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="lazy_test", - safety_margin=0.5 - ) - time_lazy = time.time() - start - - assert result_lazy["success"] is True - - # Both should produce same size event list - ev1 = result_standard["data"] - ev2 = result_lazy["data"] - assert len(ev1.time) == len(ev2.time) - - # Print timing info for reference - print(f"\nTiming comparison:") - print(f" Standard: {time_standard:.4f}s") - print(f" Lazy: {time_lazy:.4f}s") - print(f" Ratio: {time_lazy/time_standard:.2f}x") - - -# ============================================================================= -# Integration Tests: Risk Assessment -# ============================================================================= - -def test_assess_loading_risk_integration(synthetic_large_fits_info): - """Test risk assessment with realistic large file parameters.""" - file_size = synthetic_large_fits_info['file_size'] - - # Get actual available memory - available_mem = psutil.virtual_memory().available - - # Assess risk - risk = assess_loading_risk(file_size, file_format='fits', available_memory=available_mem) - - # For 2.5 GB file with 8x multiplier (20 GB needed): - # - If available < 33 GB: critical (>90%) - # - If available < 67 GB: risky (60-90%) - # - If available < 22 GB: caution (30-60%) - # This will vary by system - - assert risk in ['safe', 'caution', 'risky', 'critical'] - - # Log for debugging - print(f"\nRisk assessment for {file_size/(1024**3):.1f}GB file:") - print(f" Available RAM: {available_mem/(1024**3):.1f}GB") - print(f" Risk level: {risk}") - - -def test_lazy_loading_recommendation_logic(data_service, synthetic_small_fits): - """Test the logic for recommending lazy loading.""" - result = data_service.check_file_size(synthetic_small_fits) - - assert result["success"] is True - data = result["data"] - - # For small file: should NOT recommend lazy loading - assert data["recommend_lazy"] is False - - # Manually test logic with mocked large file - with patch('os.path.getsize', return_value=2.5 * 1024**3): # 2.5 GB - result_large = data_service.check_file_size("fake_large.evt") - - if result_large["success"]: - # Should recommend lazy for large file - assert result_large["data"]["recommend_lazy"] is True - assert result_large["data"]["file_size_gb"] > 1.0 - - -# ============================================================================= -# Integration Tests: Streaming Operations -# ============================================================================= - -def test_lazy_loader_streaming_segments(synthetic_small_fits): - """Test streaming segments from LazyEventLoader.""" - loader = LazyEventLoader(synthetic_small_fits) - - # Stream in 100s segments - segments = list(loader.stream_segments(segment_size=100.0)) - - # Should get multiple segments - assert len(segments) > 0 - - # Each segment should be a numpy array - for segment in segments: - assert isinstance(segment, np.ndarray) - assert len(segment) > 0 - - # Total events should match full load - total_streamed = sum(len(seg) for seg in segments) - - full_events = loader.load_full() - assert total_streamed == len(full_events.time) - - -def test_lazy_loader_lightcurve_streaming(synthetic_small_fits): - """Test streaming lightcurve creation.""" - loader = LazyEventLoader(synthetic_small_fits) - - # Create lightcurve via streaming - lc_segments = list(loader.create_lightcurve_streaming( - segment_size=100.0, - dt=1.0 - )) - - # Should get segments - assert len(lc_segments) > 0 - - # Each segment should be (times, counts) tuple - for times, counts in lc_segments: - assert isinstance(times, np.ndarray) - assert isinstance(counts, np.ndarray) - assert len(times) == len(counts) - assert len(times) > 0 - - -# ============================================================================= -# Integration Tests: Full Workflow -# ============================================================================= - -def test_complete_lazy_loading_workflow(data_service, synthetic_small_fits): - """ - Test complete workflow: check size -> load with lazy -> verify -> delete. - - This simulates the full user workflow in the dashboard. - """ - # Step 1: Check file size - check_result = data_service.check_file_size(synthetic_small_fits) - assert check_result["success"] is True - - file_info = check_result["data"] - print(f"\nFile info: {file_info['file_size_mb']:.2f} MB, risk: {file_info['risk_level']}") - - # Step 2: Get metadata (fast preview) - metadata_result = data_service.get_file_metadata(synthetic_small_fits) - assert metadata_result["success"] is True - - metadata = metadata_result["data"] - print(f"Metadata: ~{metadata['n_events_estimate']} events, {metadata['duration_s']:.1f}s duration") - - # Step 3: Load with lazy method (auto-decides standard vs lazy) - load_result = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="workflow_test", - safety_margin=0.5 - ) - assert load_result["success"] is True - - event_list = load_result["data"] - print(f"Loaded: {len(event_list.time)} events via {load_result['metadata']['method']} method") - - # Step 4: Verify data is accessible - get_result = data_service.get_event_list("workflow_test") - assert get_result["success"] is True - assert get_result["data"] is not None - - # Step 5: List all event lists - list_result = data_service.list_event_lists() - assert list_result["success"] is True - assert len(list_result["data"]) >= 1 - - # Step 6: Delete - delete_result = data_service.delete_event_list("workflow_test") - assert delete_result["success"] is True - - # Verify deleted - assert not data_service.state.has_event_data("workflow_test") - - -def test_multiple_files_mixed_loading(data_service, synthetic_small_fits): - """Test loading multiple files with different methods.""" - # Load first file with standard method - result1 = data_service.load_event_list( - file_path=synthetic_small_fits, - name="file1", - fmt="ogip" - ) - assert result1["success"] is True - - # Load second file with lazy method - result2 = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="file2", - safety_margin=0.5 - ) - assert result2["success"] is True - - # Both should be accessible - assert data_service.state.has_event_data("file1") - assert data_service.state.has_event_data("file2") - - # List should show both - list_result = data_service.list_event_lists() - assert len(list_result["data"]) == 2 - - -# ============================================================================= -# Edge Cases -# ============================================================================= - -def test_empty_file_handling(data_service): - """Test handling of empty FITS file.""" - fd, tmp_path = tempfile.mkstemp(suffix='.evt') - os.close(fd) - - try: - result = data_service.load_event_list_lazy( - file_path=tmp_path, - name="empty", - safety_margin=0.5 - ) - - # Should fail (empty file is invalid FITS) - assert result["success"] is False - - finally: - if os.path.exists(tmp_path): - os.remove(tmp_path) - - -def test_very_high_safety_margin(data_service, synthetic_small_fits): - """Test lazy loading with very conservative safety margin.""" - # 99% safety margin means only use 1% of available RAM - result = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="conservative", - safety_margin=0.01 # Only use 1% of RAM - ) - - # Should still succeed for small file - # (might use 'standard_risky' method if safety check fails) - assert result["success"] is True - - -def test_zero_safety_margin(data_service, synthetic_small_fits): - """Test lazy loading with zero safety margin (risky!).""" - # Safety margin of 0 means no safety checks - result = data_service.load_event_list_lazy( - file_path=synthetic_small_fits, - name="risky", - safety_margin=0.0 - ) - - # Should fail or warn (depends on implementation) - # Small file should still load - assert result["success"] is True or "warning" in result["message"].lower() - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/tests/test_performance_monitoring.py b/tests/test_performance_monitoring.py deleted file mode 100644 index 6e3c6cb..0000000 --- a/tests/test_performance_monitoring.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Test Script for Performance Monitoring - -This script tests that the PerformanceMonitor tracks operations correctly -and provides accurate statistics. -""" - -import sys -import os -import time - -# Add parent directory to path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from utils.performance_monitor import performance_monitor -import numpy as np - -def test_operation_tracking(): - """Test that operations are tracked correctly.""" - print("=" * 60) - print("Testing Performance Monitoring") - print("=" * 60) - - # Clear any existing history - performance_monitor.clear_history() - - print(f"\nInitial state:") - summary = performance_monitor.get_summary() - print(f" Total operations: {summary['total_operations']}") - print(f" Unique operations: {summary['unique_operations']}") - - # Test 1: Track a simple operation - print(f"\n{'='*60}") - print("Test 1: Tracking Simple Operation") - print(f"{'='*60}") - - with performance_monitor.track_operation("test_operation_1"): - # Simulate some work - time.sleep(0.1) - result = sum(range(1000)) - - summary = performance_monitor.get_summary() - print(f"\nAfter 1 operation:") - print(f" Total operations: {summary['total_operations']}") - print(f" Average duration: {summary['avg_duration_ms']:.2f} ms") - print(f" Success rate: {summary['success_rate']:.1f}%") - - # Test 2: Track multiple operations - print(f"\n{'='*60}") - print("Test 2: Tracking Multiple Operations") - print(f"{'='*60}") - - for i in range(5): - with performance_monitor.track_operation("batch_operation"): - time.sleep(0.05) - _ = np.random.rand(100) - - stats = performance_monitor.get_operation_stats("batch_operation") - print(f"\nStats for 'batch_operation':") - print(f" Count: {stats['count']}") - print(f" Average: {stats['avg_ms']:.2f} ms") - print(f" Min: {stats['min_ms']:.2f} ms") - print(f" Max: {stats['max_ms']:.2f} ms") - print(f" Median: {stats['median_ms']:.2f} ms") - print(f" Success rate: {stats['success_rate']:.1f}%") - - # Test 3: Track failed operation - print(f"\n{'='*60}") - print("Test 3: Tracking Failed Operation") - print(f"{'='*60}") - - try: - with performance_monitor.track_operation("failing_operation"): - raise ValueError("Intentional test error") - except ValueError: - pass # Expected - - failed_ops = performance_monitor.get_failed_operations(limit=10) - print(f"\nFailed operations: {len(failed_ops)}") - if failed_ops: - failed_op = failed_ops[0] - print(f" Operation: {failed_op.operation_name}") - print(f" Duration: {failed_op.duration_ms:.2f} ms") - print(f" Error: {failed_op.metadata.get('error', 'N/A')}") - - # Test 4: Get recent operations - print(f"\n{'='*60}") - print("Test 4: Recent Operations") - print(f"{'='*60}") - - recent = performance_monitor.get_recent_operations(limit=5) - print(f"\nLast {len(recent)} operations:") - for op in recent: - status = "[OK]" if op.success else "[X]" - print(f" {status} {op.operation_name}: {op.duration_ms:.2f} ms") - - # Test 5: Summary statistics - print(f"\n{'='*60}") - print("Test 5: Summary Statistics") - print(f"{'='*60}") - - summary = performance_monitor.get_summary() - print(f"\nOverall Summary:") - print(f" Total operations: {summary['total_operations']}") - print(f" Unique operations: {summary['unique_operations']}") - print(f" Total duration: {summary['total_duration_ms']:.2f} ms") - print(f" Average duration: {summary['avg_duration_ms']:.2f} ms") - print(f" Success rate: {summary['success_rate']:.1f}%") - print(f" Most frequent: {summary['most_frequent']}") - print(f" Slowest: {summary['slowest']}") - - print(f"\n{'='*60}") - print("[PASS] All performance monitoring tests completed successfully!") - print(f"{'='*60}") - - return summary['total_operations'] > 0 - -def test_metadata_tracking(): - """Test that metadata is tracked correctly.""" - print(f"\n{'='*60}") - print("Test 6: Metadata Tracking") - print(f"{'='*60}") - - with performance_monitor.track_operation("metadata_test", - file_size=1024, - format="ogip"): - time.sleep(0.05) - - recent = performance_monitor.get_recent_operations(limit=1) - if recent: - op = recent[0] - print(f"\nOperation: {op.operation_name}") - print(f"Metadata:") - for key, value in op.metadata.items(): - print(f" {key}: {value}") - - print(f"\n{'='*60}") - print("[PASS] Metadata tracking test completed successfully!") - print(f"{'='*60}") - - return True - -def test_slow_operations(): - """Test identification of slow operations.""" - print(f"\n{'='*60}") - print("Test 7: Slow Operations Detection") - print(f"{'='*60}") - - # Create some fast and slow operations - with performance_monitor.track_operation("fast_operation"): - time.sleep(0.01) - - with performance_monitor.track_operation("slow_operation"): - time.sleep(0.15) - - # Get slow operations (threshold: 100ms) - slow_ops = performance_monitor.get_slow_operations(threshold_ms=100.0, limit=10) - print(f"\nOperations slower than 100ms: {len(slow_ops)}") - for op in slow_ops: - print(f" {op.operation_name}: {op.duration_ms:.2f} ms") - - print(f"\n{'='*60}") - print("[PASS] Slow operations detection test completed successfully!") - print(f"{'='*60}") - - return len(slow_ops) > 0 - -if __name__ == "__main__": - try: - # Run tests - test1_passed = test_operation_tracking() - test2_passed = test_metadata_tracking() - test3_passed = test_slow_operations() - - # Summary - print(f"\n{'='*60}") - print("TEST SUMMARY") - print(f"{'='*60}") - print(f"Operation Tracking Test: {'[PASS] PASSED' if test1_passed else '[FAIL] FAILED'}") - print(f"Metadata Tracking Test: {'[PASS] PASSED' if test2_passed else '[FAIL] FAILED'}") - print(f"Slow Operations Test: {'[PASS] PASSED' if test3_passed else '[FAIL] FAILED'}") - print(f"{'='*60}\n") - - if test1_passed and test2_passed and test3_passed: - print("SUCCESS: All tests passed! Performance monitoring is working correctly.") - sys.exit(0) - else: - print("[WARN] Some tests failed. Please review the output above.") - sys.exit(1) - - except Exception as e: - print(f"\n[FAIL] Test failed with error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/tests/test_reactive_updates.py b/tests/test_reactive_updates.py deleted file mode 100644 index 5c15d70..0000000 --- a/tests/test_reactive_updates.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Test Script for Reactive State Updates - -This script tests that the StateManager's reactive parameters trigger -UI updates when state changes occur. -""" - -import sys -import os - -# Add parent directory to path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from utils.state_manager import StateManager -from stingray.events import EventList -import numpy as np - -def test_reactive_parameters(): - """Test that reactive parameters update correctly.""" - print("=" * 60) - print("Testing Reactive State Updates") - print("=" * 60) - - # Create state manager - state = StateManager() - - print(f"\nInitial state:") - print(f" Event data count: {state.event_data_count}") - print(f" Light curve count: {state.light_curve_count}") - print(f" Timeseries count: {state.timeseries_count}") - print(f" Memory usage: {state.memory_usage_mb:.2f} MB") - print(f" Last operation: '{state.last_operation}'") - - # Test 1: Add event data - print(f"\n{'='*60}") - print("Test 1: Adding Event Data") - print(f"{'='*60}") - - # Create mock event list - times = np.arange(0, 100, 0.1) - event_list = EventList(times, gti=np.array([[0, 100]])) - - # Add to state - state.add_event_data("test_event_1", event_list) - - print(f"\nAfter adding 1 event list:") - print(f" Event data count: {state.event_data_count}") - print(f" Last operation: '{state.last_operation}'") - - # Test 2: Add more event data - print(f"\n{'='*60}") - print("Test 2: Adding More Event Data") - print(f"{'='*60}") - - event_list_2 = EventList(times, gti=np.array([[0, 100]])) - state.add_event_data("test_event_2", event_list_2) - - print(f"\nAfter adding 2nd event list:") - print(f" Event data count: {state.event_data_count}") - print(f" Last operation: '{state.last_operation}'") - - # Test 3: Remove event data - print(f"\n{'='*60}") - print("Test 3: Removing Event Data") - print(f"{'='*60}") - - state.remove_event_data("test_event_1") - - print(f"\nAfter removing 1 event list:") - print(f" Event data count: {state.event_data_count}") - print(f" Last operation: '{state.last_operation}'") - - # Test 4: Clear all data - print(f"\n{'='*60}") - print("Test 4: Clearing All Data") - print(f"{'='*60}") - - state.clear_event_data() - - print(f"\nAfter clearing all event data:") - print(f" Event data count: {state.event_data_count}") - print(f" Last operation: '{state.last_operation}'") - - # Test 5: Test stats - print(f"\n{'='*60}") - print("Test 5: State Statistics") - print(f"{'='*60}") - - # Add some data again - state.add_event_data("test_1", event_list) - state.add_event_data("test_2", event_list_2) - - stats = state.get_stats() - print(f"\nState statistics:") - for key, value in stats.items(): - print(f" {key}: {value}") - - print(f"\n{'='*60}") - print("[PASS] All reactive update tests completed successfully!") - print(f"{'='*60}") - - return True - -def test_param_watchers(): - """Test that param watchers can be attached and triggered.""" - print(f"\n{'='*60}") - print("Test 6: Parameter Watchers") - print(f"{'='*60}") - - state = StateManager() - - # Track changes - changes = [] - - def on_event_count_change(event): - changes.append(('event_data_count', event.new)) - print(f" [OK] event_data_count changed to: {event.new}") - - def on_operation_change(event): - changes.append(('last_operation', event.new)) - print(f" [OK] last_operation changed to: '{event.new}'") - - # Attach watchers - state.param.watch(on_event_count_change, 'event_data_count') - state.param.watch(on_operation_change, 'last_operation') - - print("\nAttached watchers. Now adding event data...") - - # Create and add event list - times = np.arange(0, 100, 0.1) - event_list = EventList(times, gti=np.array([[0, 100]])) - state.add_event_data("watched_event", event_list) - - print(f"\nChanges detected: {len(changes)}") - for param_name, value in changes: - print(f" - {param_name}: {value}") - - print(f"\n{'='*60}") - print("[PASS] Parameter watcher tests completed successfully!") - print(f"{'='*60}") - - return len(changes) > 0 - -if __name__ == "__main__": - try: - # Run tests - test1_passed = test_reactive_parameters() - test2_passed = test_param_watchers() - - # Summary - print(f"\n{'='*60}") - print("TEST SUMMARY") - print(f"{'='*60}") - print(f"Reactive Parameters Test: {'[PASS] PASSED' if test1_passed else '[FAIL] FAILED'}") - print(f"Parameter Watchers Test: {'[PASS] PASSED' if test2_passed else '[FAIL] FAILED'}") - print(f"{'='*60}\n") - - if test1_passed and test2_passed: - print("SUCCESS: All tests passed! Reactive state updates are working correctly.") - sys.exit(0) - else: - print("[WARN] Some tests failed. Please review the output above.") - sys.exit(1) - - except Exception as e: - print(f"\n[FAIL] Test failed with error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/tests/test_state_manager.py b/tests/test_state_manager.py deleted file mode 100644 index f8cb940..0000000 --- a/tests/test_state_manager.py +++ /dev/null @@ -1,480 +0,0 @@ -""" -Unit tests for the StateManager class. - -This test suite covers: -- Event data management (add, get, remove, update, clear) -- Light curve management -- Time series management -- Memory limits and LRU eviction -- Observer pattern -- Error handling and validation -""" - -import pytest -from unittest.mock import MagicMock, patch -from utils.state_manager import StateManager - - -# ============================================================================= -# Fixtures -# ============================================================================= - -@pytest.fixture -def state_manager(): - """Create a fresh StateManager instance for each test.""" - return StateManager() - - -@pytest.fixture -def mock_event_list(): - """Create a mock EventList object.""" - mock = MagicMock() - mock.__sizeof__ = MagicMock(return_value=1024 * 1024) # 1 MB - return mock - - -@pytest.fixture -def mock_light_curve(): - """Create a mock Lightcurve object.""" - mock = MagicMock() - mock.__sizeof__ = MagicMock(return_value=512 * 1024) # 512 KB - return mock - - -@pytest.fixture -def mock_timeseries(): - """Create a mock timeseries object.""" - mock = MagicMock() - mock.__sizeof__ = MagicMock(return_value=256 * 1024) # 256 KB - return mock - - -# ============================================================================= -# Event Data Management Tests -# ============================================================================= - -class TestEventDataManagement: - """Tests for event data management methods.""" - - def test_add_event_data_success(self, state_manager, mock_event_list): - """Test successfully adding event data.""" - state_manager.add_event_data("test_event", mock_event_list) - - assert state_manager.has_event_data("test_event") - assert state_manager.get_event_data("test_event") == mock_event_list - assert len(state_manager.get_event_data()) == 1 - - def test_add_event_data_duplicate_name_raises_error(self, state_manager, mock_event_list): - """Test that adding duplicate name raises ValueError.""" - state_manager.add_event_data("test_event", mock_event_list) - - with pytest.raises(ValueError, match="already exists"): - state_manager.add_event_data("test_event", mock_event_list) - - def test_add_event_data_empty_name_raises_error(self, state_manager, mock_event_list): - """Test that empty name raises ValueError.""" - with pytest.raises(ValueError, match="cannot be empty"): - state_manager.add_event_data("", mock_event_list) - - with pytest.raises(ValueError, match="cannot be empty"): - state_manager.add_event_data(" ", mock_event_list) - - def test_get_event_data_by_name(self, state_manager, mock_event_list): - """Test retrieving event data by name.""" - state_manager.add_event_data("test_event", mock_event_list) - - result = state_manager.get_event_data("test_event") - assert result == mock_event_list - - def test_get_event_data_nonexistent_returns_none(self, state_manager): - """Test that getting nonexistent data returns None.""" - result = state_manager.get_event_data("nonexistent") - assert result is None - - def test_get_all_event_data(self, state_manager, mock_event_list): - """Test retrieving all event data.""" - mock_event_list2 = MagicMock() - mock_event_list2.__sizeof__ = MagicMock(return_value=1024 * 1024) - - state_manager.add_event_data("event1", mock_event_list) - state_manager.add_event_data("event2", mock_event_list2) - - all_data = state_manager.get_event_data() - assert len(all_data) == 2 - assert all_data[0] == ("event1", mock_event_list) - assert all_data[1] == ("event2", mock_event_list2) - - def test_get_event_data_names(self, state_manager, mock_event_list): - """Test retrieving all event data names.""" - state_manager.add_event_data("event1", mock_event_list) - state_manager.add_event_data("event2", mock_event_list) - - names = state_manager.get_event_data_names() - assert names == ["event1", "event2"] - - def test_remove_event_data_success(self, state_manager, mock_event_list): - """Test successfully removing event data.""" - state_manager.add_event_data("test_event", mock_event_list) - - result = state_manager.remove_event_data("test_event") - assert result is True - assert not state_manager.has_event_data("test_event") - - def test_remove_event_data_nonexistent_returns_false(self, state_manager): - """Test that removing nonexistent data returns False.""" - result = state_manager.remove_event_data("nonexistent") - assert result is False - - def test_update_event_data_success(self, state_manager, mock_event_list): - """Test successfully updating event data.""" - state_manager.add_event_data("test_event", mock_event_list) - - new_mock = MagicMock() - new_mock.__sizeof__ = MagicMock(return_value=1024 * 1024) - - result = state_manager.update_event_data("test_event", new_mock) - assert result is True - assert state_manager.get_event_data("test_event") == new_mock - - def test_update_event_data_nonexistent_returns_false(self, state_manager, mock_event_list): - """Test that updating nonexistent data returns False.""" - result = state_manager.update_event_data("nonexistent", mock_event_list) - assert result is False - - def test_clear_event_data(self, state_manager, mock_event_list): - """Test clearing all event data.""" - state_manager.add_event_data("event1", mock_event_list) - state_manager.add_event_data("event2", mock_event_list) - - state_manager.clear_event_data() - assert len(state_manager.get_event_data()) == 0 - - def test_has_event_data(self, state_manager, mock_event_list): - """Test checking if event data exists.""" - assert not state_manager.has_event_data("test_event") - - state_manager.add_event_data("test_event", mock_event_list) - assert state_manager.has_event_data("test_event") - - -# ============================================================================= -# Light Curve Management Tests -# ============================================================================= - -class TestLightCurveManagement: - """Tests for light curve management methods.""" - - def test_add_light_curve_success(self, state_manager, mock_light_curve): - """Test successfully adding light curve.""" - state_manager.add_light_curve("test_lc", mock_light_curve) - - assert state_manager.has_light_curve("test_lc") - assert state_manager.get_light_curve("test_lc") == mock_light_curve - - def test_add_light_curve_duplicate_raises_error(self, state_manager, mock_light_curve): - """Test that adding duplicate name raises ValueError.""" - state_manager.add_light_curve("test_lc", mock_light_curve) - - with pytest.raises(ValueError, match="already exists"): - state_manager.add_light_curve("test_lc", mock_light_curve) - - def test_get_all_light_curves(self, state_manager, mock_light_curve): - """Test retrieving all light curves.""" - state_manager.add_light_curve("lc1", mock_light_curve) - state_manager.add_light_curve("lc2", mock_light_curve) - - all_lcs = state_manager.get_light_curve() - assert len(all_lcs) == 2 - - def test_get_light_curve_names(self, state_manager, mock_light_curve): - """Test retrieving all light curve names.""" - state_manager.add_light_curve("lc1", mock_light_curve) - state_manager.add_light_curve("lc2", mock_light_curve) - - names = state_manager.get_light_curve_names() - assert names == ["lc1", "lc2"] - - def test_remove_light_curve(self, state_manager, mock_light_curve): - """Test removing light curve.""" - state_manager.add_light_curve("test_lc", mock_light_curve) - - result = state_manager.remove_light_curve("test_lc") - assert result is True - assert not state_manager.has_light_curve("test_lc") - - def test_update_light_curve(self, state_manager, mock_light_curve): - """Test updating light curve.""" - state_manager.add_light_curve("test_lc", mock_light_curve) - - new_mock = MagicMock() - new_mock.__sizeof__ = MagicMock(return_value=512 * 1024) - - result = state_manager.update_light_curve("test_lc", new_mock) - assert result is True - assert state_manager.get_light_curve("test_lc") == new_mock - - def test_clear_light_curves(self, state_manager, mock_light_curve): - """Test clearing all light curves.""" - state_manager.add_light_curve("lc1", mock_light_curve) - state_manager.add_light_curve("lc2", mock_light_curve) - - state_manager.clear_light_curves() - assert len(state_manager.get_light_curve()) == 0 - - -# ============================================================================= -# Time Series Management Tests -# ============================================================================= - -class TestTimeSeriesManagement: - """Tests for time series management methods.""" - - def test_add_timeseries_success(self, state_manager, mock_timeseries): - """Test successfully adding timeseries.""" - state_manager.add_timeseries_data("test_ts", mock_timeseries) - - assert state_manager.has_timeseries_data("test_ts") - assert state_manager.get_timeseries_data("test_ts") == mock_timeseries - - def test_add_timeseries_duplicate_raises_error(self, state_manager, mock_timeseries): - """Test that adding duplicate name raises ValueError.""" - state_manager.add_timeseries_data("test_ts", mock_timeseries) - - with pytest.raises(ValueError, match="already exists"): - state_manager.add_timeseries_data("test_ts", mock_timeseries) - - def test_remove_timeseries(self, state_manager, mock_timeseries): - """Test removing timeseries.""" - state_manager.add_timeseries_data("test_ts", mock_timeseries) - - result = state_manager.remove_timeseries_data("test_ts") - assert result is True - assert not state_manager.has_timeseries_data("test_ts") - - -# ============================================================================= -# Memory Management Tests -# ============================================================================= - -class TestMemoryManagement: - """Tests for memory management features.""" - - def test_max_event_lists_eviction(self, state_manager, mock_event_list): - """Test that oldest event is evicted when MAX_EVENT_LISTS is reached.""" - state_manager.MAX_EVENT_LISTS = 3 - - state_manager.add_event_data("event1", mock_event_list) - state_manager.add_event_data("event2", mock_event_list) - state_manager.add_event_data("event3", mock_event_list) - - # Adding 4th should evict event1 - state_manager.add_event_data("event4", mock_event_list) - - assert not state_manager.has_event_data("event1") - assert state_manager.has_event_data("event2") - assert state_manager.has_event_data("event3") - assert state_manager.has_event_data("event4") - assert len(state_manager.get_event_data()) == 3 - - def test_memory_usage_calculation(self, state_manager, mock_event_list): - """Test memory usage calculation.""" - state_manager.add_event_data("event1", mock_event_list) - - usage = state_manager.get_memory_usage() - assert usage['current_mb'] > 0 - assert usage['max_mb'] > 0 - assert 0 <= usage['usage_percent'] <= 100 - - @patch('psutil.virtual_memory') - def test_dynamic_memory_limit(self, mock_vm): - """Test that memory limit is dynamically calculated from system RAM.""" - # Mock system with 16 GB RAM - mock_vm.return_value.total = 16 * 1024 * 1024 * 1024 # 16 GB in bytes - - sm = StateManager() - - # Should be 80% of 16 GB = 12.8 GB = 13107.2 MB - expected_limit = (16 * 1024 * 0.80) - assert abs(sm.MAX_MEMORY_MB - expected_limit) < 1 # Allow small rounding error - - def test_set_memory_usage_percent(self, state_manager): - """Test changing memory usage percentage.""" - original_limit = state_manager.MAX_MEMORY_MB - - state_manager.set_memory_usage_percent(0.50) # 50% - - # New limit should be approximately half of 80% limit - assert state_manager.MAX_MEMORY_MB < original_limit - - def test_set_memory_usage_percent_invalid_raises_error(self, state_manager): - """Test that invalid percentage raises ValueError.""" - with pytest.raises(ValueError, match="between 0.1"): - state_manager.set_memory_usage_percent(0.05) # Too low - - with pytest.raises(ValueError, match="between 0.1"): - state_manager.set_memory_usage_percent(1.5) # Too high - - def test_get_system_memory_info(self, state_manager): - """Test getting system memory information.""" - info = state_manager.get_system_memory_info() - - assert 'total_mb' in info - assert 'available_mb' in info - assert 'allocated_limit_mb' in info - assert info['total_mb'] > 0 - - -# ============================================================================= -# Observer Pattern Tests -# ============================================================================= - -class TestObserverPattern: - """Tests for observer pattern implementation.""" - - def test_register_observer(self, state_manager): - """Test registering an observer.""" - callback = MagicMock() - - state_manager.register_observer(callback) - assert callback in state_manager._observers - - def test_observer_notified_on_add(self, state_manager, mock_event_list): - """Test that observers are notified when data is added.""" - callback = MagicMock() - state_manager.register_observer(callback) - - state_manager.add_event_data("test_event", mock_event_list) - - callback.assert_called_once() - call_args = callback.call_args[0] - assert call_args[0] == 'event_data_added' - assert call_args[1]['name'] == 'test_event' - - def test_observer_notified_on_remove(self, state_manager, mock_event_list): - """Test that observers are notified when data is removed.""" - callback = MagicMock() - state_manager.add_event_data("test_event", mock_event_list) - - state_manager.register_observer(callback) - state_manager.remove_event_data("test_event") - - callback.assert_called_once() - call_args = callback.call_args[0] - assert call_args[0] == 'event_data_removed' - - def test_observer_notified_on_clear(self, state_manager, mock_event_list): - """Test that observers are notified when data is cleared.""" - callback = MagicMock() - state_manager.add_event_data("test_event", mock_event_list) - - state_manager.register_observer(callback) - state_manager.clear_event_data() - - callback.assert_called_once() - call_args = callback.call_args[0] - assert call_args[0] == 'event_data_cleared' - - def test_unregister_observer(self, state_manager, mock_event_list): - """Test unregistering an observer.""" - callback = MagicMock() - state_manager.register_observer(callback) - state_manager.unregister_observer(callback) - - assert callback not in state_manager._observers - - # Should not be called after unregistering - state_manager.add_event_data("test_event", mock_event_list) - callback.assert_not_called() - - def test_observer_error_doesnt_break_state(self, state_manager, mock_event_list): - """Test that errors in observers don't break state management.""" - def bad_callback(event_type, data): - raise Exception("Observer error!") - - state_manager.register_observer(bad_callback) - - # Should not raise exception - state_manager.add_event_data("test_event", mock_event_list) - - # Data should still be added - assert state_manager.has_event_data("test_event") - - -# ============================================================================= -# Clear All Tests -# ============================================================================= - -class TestClearAll: - """Tests for clearing all state.""" - - def test_clear_all(self, state_manager, mock_event_list, mock_light_curve, mock_timeseries): - """Test clearing all state data.""" - state_manager.add_event_data("event1", mock_event_list) - state_manager.add_light_curve("lc1", mock_light_curve) - state_manager.add_timeseries_data("ts1", mock_timeseries) - - state_manager.clear_all() - - assert len(state_manager.get_event_data()) == 0 - assert len(state_manager.get_light_curve()) == 0 - assert len(state_manager.get_timeseries_data()) == 0 - - -# ============================================================================= -# Statistics Tests -# ============================================================================= - -class TestStatistics: - """Tests for statistics and info methods.""" - - def test_get_stats(self, state_manager, mock_event_list): - """Test getting statistics.""" - state_manager.add_event_data("event1", mock_event_list) - - stats = state_manager.get_stats() - - assert stats['total_additions'] >= 1 - assert stats['event_data_count'] == 1 - assert stats['total_items'] == 1 - assert 'memory_usage' in stats - assert 'system_memory' in stats - - def test_repr(self, state_manager, mock_event_list): - """Test string representation.""" - state_manager.add_event_data("event1", mock_event_list) - - repr_str = repr(state_manager) - assert "StateManager" in repr_str - assert "event_data=1" in repr_str - - -# ============================================================================= -# Integration Tests -# ============================================================================= - -class TestIntegration: - """Integration tests for combined operations.""" - - def test_mixed_data_types(self, state_manager, mock_event_list, mock_light_curve, mock_timeseries): - """Test managing multiple data types simultaneously.""" - state_manager.add_event_data("event1", mock_event_list) - state_manager.add_light_curve("lc1", mock_light_curve) - state_manager.add_timeseries_data("ts1", mock_timeseries) - - assert state_manager.has_event_data("event1") - assert state_manager.has_light_curve("lc1") - assert state_manager.has_timeseries_data("ts1") - - stats = state_manager.get_stats() - assert stats['total_items'] == 3 - - def test_eviction_statistics(self, state_manager, mock_event_list): - """Test that eviction updates statistics.""" - state_manager.MAX_EVENT_LISTS = 2 - - state_manager.add_event_data("event1", mock_event_list) - state_manager.add_event_data("event2", mock_event_list) - state_manager.add_event_data("event3", mock_event_list) # Should trigger eviction - - stats = state_manager.get_stats() - assert stats['total_evictions'] == 1 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..adcc688 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@components/*": ["src/components/*"], + "@pages/*": ["src/pages/*"], + "@api/*": ["src/api/*"], + "@store/*": ["src/store/*"], + "@hooks/*": ["src/hooks/*"], + "@types/*": ["src/types/*"], + "@utils/*": ["src/utils/*"] + } + }, + "include": ["src/**/*", "electron/**/*"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..ad7bc16 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts", "electron.vite.config.ts", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1a85fd7 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + test: { + environment: 'jsdom', + // Required for @testing-library/react v16 auto-cleanup between tests + globals: true, + setupFiles: ['src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + }, +});