diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index ffa799142..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(find:*)", - "Bash(jj log:*)", - "Bash(jj status:*)", - "Bash(jj show:*)", - "Bash(jj file list:*)", - "Bash(ls:*)", - "Bash(git checkout:*)", - "Bash(git pull:*)", - "Bash(jj branch:*)", - "Bash(jj git fetch:*)", - "Bash(jj help:*)", - "Bash(jj bookmark:*)", - "Bash(jj describe:*)", - "Bash(jj rebase:*)", - "WebFetch(domain:localhost)", - "mcp__playwright__browser_navigate", - "mcp__playwright__browser_type", - "mcp__playwright__browser_click", - "mcp__playwright__browser_take_screenshot", - "mcp__playwright__browser_snapshot", - "mcp__playwright__browser_press_key", - "mcp__playwright__browser_close", - "Bash(pkill:*)", - "Bash(source:*)", - "Bash(python:*)", - "Bash(pip install:*)", - "Bash(timeout 10 python:*)", - "Bash(grep:*)", - "Bash(nox:*)", - "Bash(# Navigate to python dir and set up venv\nPYTHON_DIR=\"\"/Users/mike/dev/amateur_astro/myPiFinder/wt-radec/python\"\"\ncd \"\"$PYTHON_DIR\"\"\npython3.9 -m venv .venv\nsource .venv/bin/activate\npip install -r requirements.txt\npip install -r requirements_dev.txt)", - "Bash(PYTHON_DIR=\"/Users/mike/dev/amateur_astro/myPiFinder/wt-radec/python\")", - "Bash(pytest:*)", - "Bash(sed:*)", - ], - "deny": [] - } -} diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..3550a30f2 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/bootstrap-tarball.yml b/.github/workflows/bootstrap-tarball.yml new file mode 100644 index 000000000..e1bf59fd6 --- /dev/null +++ b/.github/workflows/bootstrap-tarball.yml @@ -0,0 +1,117 @@ +name: Build Bootstrap Tarball + +on: + push: + branches: [nixos] + paths: + - 'nixos/bootstrap.nix' + - 'flake.nix' + - '.github/workflows/bootstrap-tarball.yml' + workflow_dispatch: + inputs: + version: + description: "Version tag (e.g. 2.5.0)" + required: false + default: "2.5.0" + type: string + +jobs: + build-bootstrap: + runs-on: [self-hosted, aarch64] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set version + run: | + if [ -n "${{ inputs.version }}" ]; then + echo "VERSION=${{ inputs.version }}" >> "$GITHUB_ENV" + else + echo "VERSION=2.5.0" >> "$GITHUB_ENV" + fi + + - name: Install Nix + uses: cachix/install-nix-action@v30 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Setup Cachix + uses: cachix/cachix-action@v15 + with: + name: pifinder + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Build bootstrap SD image + run: | + nix build .#images.bootstrap --print-out-paths > /tmp/image_path + echo "IMAGE_PATH=$(cat /tmp/image_path)" >> "$GITHUB_ENV" + + - name: Extract boot and rootfs + run: | + # Clean up any leftover from previous runs + sudo rm -rf /tmp/tarball-staging /tmp/bootstrap.img /mnt/boot /mnt/root + + # The SD image output is a directory containing sd-image/*.img.zst + IMAGE_FILE=$(find "$IMAGE_PATH" -type f \( -name "*.img" -o -name "*.img.zst" \) | head -1) + echo "Found image: $IMAGE_FILE" + + if [[ "${IMAGE_FILE}" == *.zst ]]; then + zstd -d "${IMAGE_FILE}" -o /tmp/bootstrap.img + else + cp "${IMAGE_FILE}" /tmp/bootstrap.img + fi + + LOOP=$(sudo losetup --find --show --partscan /tmp/bootstrap.img) + echo "LOOP=${LOOP}" >> "$GITHUB_ENV" + + sudo mkdir -p /mnt/boot /mnt/root + sudo mount "${LOOP}p1" /mnt/boot + sudo mount "${LOOP}p2" /mnt/root + + mkdir -p /tmp/tarball-staging + sudo cp -a /mnt/boot /tmp/tarball-staging/boot + sudo cp -a /mnt/root /tmp/tarball-staging/rootfs + + sudo umount /mnt/boot /mnt/root + sudo losetup -d "${LOOP}" + rm -f /tmp/bootstrap.img + + - name: Create bootstrap tarball + run: | + TARBALL="pifinder-bootstrap-v${VERSION}.tar.gz" + + # Clean up any leftover files from previous runs + sudo rm -rf /tmp/tarball-staging/manifest.json "/tmp/${TARBALL}" "/tmp/${TARBALL}.sha256" + + cat > /tmp/tarball-staging/manifest.json <> "$GITHUB_ENV" + echo "TARBALL_NAME=${TARBALL}" >> "$GITHUB_ENV" + + sha256sum "/tmp/${TARBALL}" | awk '{print $1}' > "/tmp/${TARBALL}.sha256" + echo "SHA256=$(cat /tmp/${TARBALL}.sha256)" >> "$GITHUB_ENV" + + ls -lh "/tmp/${TARBALL}" + + - name: Upload tarball artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.TARBALL_NAME }} + path: | + ${{ env.TARBALL }} + ${{ env.TARBALL }}.sha256 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..771a0d313 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,155 @@ +name: Build PiFinder NixOS + +on: + push: + branches: [main, nixos] + pull_request: + types: [labeled, synchronize, opened] + workflow_dispatch: + +concurrency: + group: build-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + # Try Pi5 native build first (fast) + build-native: + if: | + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.labels.*.name, 'testable') + runs-on: [self-hosted, aarch64] + timeout-minutes: 30 + outputs: + success: ${{ steps.build.outcome == 'success' }} + store_path: ${{ steps.push.outputs.store_path }} + steps: + - uses: actions/checkout@v4 + + - uses: cachix/cachix-action@v15 + with: + name: pifinder + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Build NixOS system closure + id: build + run: | + nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + -L --no-link + + - name: Push to Cachix + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --json | jq -r '.[].outputs.out') + echo "$STORE_PATH" | cachix push pifinder + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + # Wait up to 15 min for native builder, then decide on fallback + native-wait: + if: | + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.labels.*.name, 'testable') + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + need_emulated: ${{ steps.wait.outputs.need_emulated }} + steps: + - name: Wait for native build + id: wait + env: + GH_TOKEN: ${{ github.token }} + run: | + for i in $(seq 1 30); do + sleep 30 + RESULT=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \ + --jq '.jobs[] | select(.name == "build-native") | .conclusion // "pending"' 2>/dev/null || echo "pending") + echo "Check $i/30: build-native=$RESULT" + if [ "$RESULT" = "success" ]; then + echo "need_emulated=false" >> "$GITHUB_OUTPUT" + exit 0 + elif [ "$RESULT" = "failure" ] || [ "$RESULT" = "cancelled" ]; then + echo "need_emulated=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "Native build not done after 15 min, falling back to emulated" + echo "need_emulated=true" >> "$GITHUB_OUTPUT" + + # Fallback to QEMU emulation if Pi5 unavailable or slow + build-emulated: + needs: native-wait + if: needs.native-wait.outputs.need_emulated == 'true' + runs-on: ubuntu-latest + timeout-minutes: 360 + outputs: + store_path: ${{ steps.push.outputs.store_path }} + steps: + - uses: actions/checkout@v4 + + - uses: cachix/install-nix-action@v27 + with: + extra_nix_config: | + extra-platforms = aarch64-linux + extra-system-features = big-parallel + + - uses: cachix/cachix-action@v15 + with: + name: pifinder + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Register QEMU binfmt for aarch64 + run: sudo apt-get update && sudo apt-get install -y qemu-user-static + + - name: Build NixOS system closure + run: | + nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --system aarch64-linux \ + -L --no-link + + - name: Push to Cachix + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --system aarch64-linux \ + --json | jq -r '.[].outputs.out') + echo "$STORE_PATH" | cachix push pifinder + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + # Commit pifinder-build.json with store path to the same branch + stamp-build: + needs: [build-native, build-emulated] + if: always() && (needs.build-native.result == 'success' || needs.build-emulated.result == 'success') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Write pifinder-build.json + run: | + STORE_PATH="${{ needs.build-native.outputs.store_path || needs.build-emulated.outputs.store_path }}" + BRANCH="${{ github.head_ref || github.ref_name }}" + SHORT_SHA=$(git rev-parse --short HEAD) + PR_NUMBER="${{ github.event.pull_request.number }}" + if [ -n "$PR_NUMBER" ]; then + VERSION="PR#${PR_NUMBER}-${SHORT_SHA}" + else + VERSION="${BRANCH}-${SHORT_SHA}" + fi + jq -n --arg sp "$STORE_PATH" --arg v "$VERSION" \ + '{store_path: $sp, version: $v}' > pifinder-build.json + + - name: Commit pifinder-build.json + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add pifinder-build.json + git diff --staged --quiet || git commit -m "chore: stamp build [skip ci]" + git push diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..37e24bfbc --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,56 @@ +name: Lint & Test +on: [push, pull_request] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: false + - uses: DeterminateSystems/magic-nix-cache-action@main + - name: Lint + run: nix develop --command bash -c "cd python && ruff check" + - name: Format check + run: nix develop --command bash -c "cd python && ruff format --check" + - name: Check for removed config keys + if: github.event_name == 'pull_request' + run: | + BASE_SHA="${{ github.event.pull_request.base.sha }}" + git show "$BASE_SHA:default_config.json" 2>/dev/null \ + | python3 -c "import json,sys; print('\n'.join(sorted(json.load(sys.stdin).keys())))" \ + > /tmp/base_keys.txt || exit 0 + python3 -c "import json,sys; print('\n'.join(sorted(json.load(sys.stdin).keys())))" \ + < default_config.json > /tmp/head_keys.txt + REMOVED=$(comm -23 /tmp/base_keys.txt /tmp/head_keys.txt) + if [ -n "$REMOVED" ]; then + while IFS= read -r key; do + echo "::warning file=default_config.json::Config key '$key' was removed — this may break user preferences across release switches" + done <<< "$REMOVED" + fi + + type-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: false + - uses: DeterminateSystems/magic-nix-cache-action@main + - name: Type check + run: nix develop --command bash -c "cd python && mypy --install-types --non-interactive ." + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: false + - uses: DeterminateSystems/magic-nix-cache-action@main + - name: Smoke tests + run: nix develop --command bash -c "cd python && pytest -m smoke" + - name: Unit tests + run: nix develop --command bash -c "cd python && pytest -m unit" diff --git a/.github/workflows/nox.yml b/.github/workflows/nox.yml deleted file mode 100644 index 1fd793bed..000000000 --- a/.github/workflows/nox.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: nox -on: [push, pull_request] -jobs: - nox: - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./python - steps: - - uses: actions/checkout@v4 - - uses: wntrblm/nox@2024.04.15 - with: - python-versions: "3.9" - - run: nox -s lint format type_hints smoke_tests unit_tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..213b8787e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,112 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version (e.g., 2.5.0)' + required: true + notes: + description: 'Release notes' + required: true + type: + description: 'Release type' + type: choice + options: + - stable + - beta + default: stable + source_branch: + description: 'Source branch (default: main, use release/X.Y for hotfixes)' + required: false + default: 'main' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_branch }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: cachix/install-nix-action@v27 + with: + extra_nix_config: | + extra-platforms = aarch64-linux + extra-system-features = big-parallel + + - uses: cachix/cachix-action@v15 + with: + name: pifinder + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Register QEMU binfmt for aarch64 + run: sudo apt-get update && sudo apt-get install -y qemu-user-static + + - name: Build and push closure to Cachix + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --system aarch64-linux -L --json | jq -r '.[].outputs.out') + echo "$STORE_PATH" | cachix push pifinder + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + - name: Stamp pifinder-build.json and create tag + run: | + TAG="v${{ inputs.version }}" + [[ "${{ inputs.type }}" == "beta" ]] && TAG="${TAG}-beta" + + STORE_PATH="${{ steps.push.outputs.store_path }}" + VERSION="${{ inputs.version }}" + jq -n --arg sp "$STORE_PATH" --arg v "$VERSION" \ + '{store_path: $sp, version: $v}' > pifinder-build.json + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add pifinder-build.json + git diff --staged --quiet || git commit -m "release: stamp build for $TAG [skip ci]" + git push origin "${{ inputs.source_branch }}" + + git tag "$TAG" + git push origin "$TAG" + echo "TAG=$TAG" >> $GITHUB_ENV + + - name: Build SD image + run: | + nix build .#images.pifinder \ + --system aarch64-linux \ + -L -o result-sd + mkdir -p release + for f in result-sd/sd-image/*.img.zst; do + cp "$f" "release/pifinder-${TAG}.img.zst" + done + + - name: Build bootstrap tarball + run: | + nix build .#images.bootstrap \ + --system aarch64-linux \ + -L -o result-bootstrap + for f in result-bootstrap/tarball/*.tar.zst; do + cp "$f" "release/pifinder-bootstrap-${TAG}.tar.zst" + done + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: pifinder-release-${{ env.TAG }} + path: release/pifinder-*.zst + retention-days: 90 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ env.TAG }} + name: PiFinder ${{ env.TAG }} + body: ${{ inputs.notes }} + prerelease: ${{ inputs.type == 'beta' }} + files: | + release/pifinder-*.img.zst + release/pifinder-bootstrap-*.tar.zst diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a538aa17..317fee59c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,27 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks repos: - - repo: https://github.com/saltstack/mirrors-nox - rev: 'v2022.11.21' # Use the sha / tag you want to point at + - repo: local hooks: - - id: nox - files: ^.*\.py$ - args: - - -f - - python/noxfile.py - - -s - - type_hints - - smoke_tests - - -- + - id: ruff-lint + name: ruff lint + entry: bash -c 'cd python && ruff check' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: ruff-format + name: ruff format check + entry: bash -c 'cd python && ruff format --check' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: mypy + name: mypy type check + entry: bash -c 'cd python && mypy .' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: smoke-tests + name: smoke tests + entry: bash -c 'cd python && pytest -m smoke' + language: system + files: ^python/.*\.py$ + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index bac5f63c2..298aa2859 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,3 +118,35 @@ Tests use pytest with custom markers for different test types. The smoke tests p - **I18n Support:** Babel integration for multi-language UI The codebase follows modern Python practices with type hints, comprehensive testing, and automated code quality checks integrated into the development workflow. + +## NixOS Development + +**CRITICAL: Never run `nix build` or `nix eval` on Pi 4 targets.** The Pi 4 lacks sufficient resources and will hang/crash. Always build on pi5.local (GitHub Actions runner), push to cachix, then trigger the upgrade service: +```bash +# Build on pi5 +ssh pi5.local 'nix build --no-link --print-out-paths github:mrosseel/PiFinder/nixos#nixosConfigurations.pifinder.config.system.build.toplevel' +# Push to cachix (so Pi can download signed paths) +ssh pi5.local 'cachix push pifinder ' +# Trigger upgrade on target Pi (downloads from cachix, activates, reboots) +ssh pifinder@ 'echo "" > /run/pifinder/upgrade-ref && sudo systemctl start --no-block pifinder-upgrade.service' +# Monitor progress +ssh pifinder@ 'cat /run/pifinder/upgrade-status' +``` + +**Netboot deployment (dev Pi on proxnix NFS):** +```bash +./deploy-image-to-nfs.sh # Build and deploy to NFS +``` + +**Power control (Shelly plug via Home Assistant):** +```bash +~/.local/bin/pifinder-power-off.sh # Turn off PiFinder +~/.local/bin/pifinder-power-on.sh # Turn on PiFinder +``` + +**Check Pi status:** +```bash +ssh pifinder@192.168.5.146 # SSH to netboot Pi +systemctl status pifinder # Check service status +journalctl -u pifinder -f # Follow service logs +``` diff --git a/NIXOS_STATUS.md b/NIXOS_STATUS.md new file mode 100644 index 000000000..d56c759bc --- /dev/null +++ b/NIXOS_STATUS.md @@ -0,0 +1,54 @@ +# NixOS Migration Status + +## What Works +- **PWM LEDs** - Fixed with proper pinctrl overlay routing PWM0_1 to GPIO 13 +- **Boot splash** - Static red splash screen on OLED during boot +- **PAM authentication** - Fixed /etc symlinks using /etc/static +- **Netboot** - TFTP/NFS working with u-boot → extlinux chain +- **CI/CD** - Pi5 native builds on self-hosted runner with ubuntu-latest fallback +- **Cachix** - pifinder.cachix.org for binary cache + +## Recent Fixes (this session) +1. PWM overlay: added pinctrl to route PWM signal to GPIO 13 +2. Boot splash: changed to static mode (no animation) +3. PAM symlinks: use `/etc/static/pam.d` not direct closure paths +4. CI workflow: use Pi5 `[self-hosted, aarch64]` runner, fallback to ubuntu-latest +5. pifinder service: `Type=simple` instead of `Type=idle` (was causing ~2min delay) +6. Deploy script: `rm -rf pam.d` before symlink (can't overwrite directory) + +## Commits Pushed (nixos branch) +- `957b55e` - fix: PWM overlay pinctrl and boot splash improvements +- `f00b041` - ci: use Pi5 native runner with ubuntu-latest fallback +- `78c1eb9` - fix(ci): use correct flake output names +- `721e59b` - fix: use /etc/static for symlinks in deploy script +- `258a367` - fix: use Type=simple for pifinder service +- `bf4d561` - fix: remove pam.d before symlink in deploy script + +## Known Issues / TODO +1. ~~**WiFi kernel oops**~~ - CLOSED: Just a harmless FORTIFY_SOURCE warning in brcmfmac driver (struct flexible array declared as 1-byte field). WiFi hardware works fine. Using ethernet for netboot anyway. +2. **Python startup slow** - 1m46s between systemd starting service and Python first log. Not systemd delay - it's Python import/NFS latency. Consider: + - Lazy imports + - Local caching of Python bytecode + - Profiling import time with `python -X importtime` +3. **IP changes** - Pi getting different DHCP IPs (146, 150) - consider static IP +4. **Samba** - Taking 10.7s at boot, do we need it? +5. **firewall.service** - Taking 16s, could optimize or disable if not needed + +## Files Changed +- `nixos/hardware.nix` - PWM overlay with pinctrl +- `nixos/services.nix` - boot-splash static, pifinder Type=simple +- `nixos/pkgs/boot-splash.c` - static mode, red color fix +- `flake.nix` - initrd splash changes +- `deploy-image-to-nfs.sh` - /etc/static symlinks, rm before ln +- `.github/workflows/build.yml` - Pi5 runner, fallback, correct flake outputs + +## Deploy Command +```bash +./deploy-image-to-nfs.sh +``` + +## Test After Reboot +```bash +ssh pifinder@192.168.5.146 "systemd-analyze blame | head -10" +ssh pifinder@192.168.5.146 "journalctl -u pifinder --no-pager | head -20" +``` diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 000000000..10b3633c6 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,65 @@ +# PiFinder v2.4.0 Release Notes + +## Major New Features + +### Sky Quality Meter (SQM) - Experimental +A new Sky Quality Meter feature measures sky brightness and displays the corresponding Bortle scale classification. This helps observers assess observing conditions at their location. +- Real-time SQM measurement with Bortle class display +- Calibration UI for accurate readings across different camera configurations +- Camera profiles for IMX296 and other supported sensors +- Rotating constellation/SQM display in title bar + +### Camera Auto Exposure +Automatic exposure control using a PID-based algorithm that adapts to changing sky conditions. +- Asymmetric tuning for responsive exposure adjustments +- Exposure sweep functionality for calibration +- SNR-based thresholds derived from camera profiles +- Visual exposure overlay in preview mode + +### Cedar-Detect System Service +Cedar-Detect now runs as a dedicated system service rather than a subprocess, improving stability and resource management. This change is transparent to users but provides better crash recovery and memory handling. + +## Improvements + +### GPS +- **Configurable baud rate**: GPS baud rate can now be configured via the Advanced settings menu (#345) +- **Reorganized GPS settings**: GPS options now grouped under Settings > Advanced > GPS Settings +- **GPSD improvements**: Fixed lock_type handling for GPSD-based GPS messages (#358) +- **Early dongle fix**: Fixed issue where some GPS dongles never reported sky data (#373) + +### Catalogs +- **WDS catalog**: Improved loading speed with background loading for better UI responsiveness (#352, #355) +- **Async search**: Search is now asynchronous for improved responsiveness +- **Comet catalog**: Better refresh and download handling with non-blocking updates (#353) +- **Bright stars**: Fixed off-by-one error in bright stars catalog + +### User Interface +- **EQ mode**: Push-to now uses +/- buttons rather than arrows for clearer directional guidance +- **Settings reorganization**: Advanced settings (PiFinder Type, Camera Type, GPS Settings) now grouped under an "Advanced" submenu +- **Preview cleanup**: Removed background subtraction and gamma functions from preview +- **Experimental menu**: Moved higher in menu structure for easier access + +## Bug Fixes + +- Fixed preview crash when marking menu items are missing +- Fixed crash when screenshot title contains a slash +- Fixed OSX logging levels by applying log config in each subprocess +- Fixed various solver stability issues with improved error handling +- Fixed typo in SkySafari documentation +- Fixed typo in menu + +## Hardware & Documentation + +- Adjusted GPS antenna holder sizing +- Improved case tolerances and new dovetail design +- Updated shroud with tighter tolerance +- Added instructions to build guide for testing LEDs and buttons +- Clarified DIY vs Assembled parts in case documentation + +## Migration Notes + +This release includes a migration script (`migration_source/v2.4.0.sh`) that sets up the Cedar-Detect system service. The migration will run automatically during the update process. + +--- + +**Full Changelog**: 40 commits from release to main diff --git a/astro_data/pifinder_objects.db b/astro_data/pifinder_objects.db index ac3c348a1..5e7eb9ac5 100644 Binary files a/astro_data/pifinder_objects.db and b/astro_data/pifinder_objects.db differ diff --git a/bin/cedar-detect-server-aarch64 b/bin/cedar-detect-server-aarch64 deleted file mode 100755 index 7b44b89b7..000000000 Binary files a/bin/cedar-detect-server-aarch64 and /dev/null differ diff --git a/bin/cedar-detect-server-arm64 b/bin/cedar-detect-server-arm64 deleted file mode 100755 index ea792437f..000000000 Binary files a/bin/cedar-detect-server-arm64 and /dev/null differ diff --git a/case/v3/common/pi_mount.stl b/case/v3/common/pi_mount.stl index 599f0b79d..c0b709da3 100644 Binary files a/case/v3/common/pi_mount.stl and b/case/v3/common/pi_mount.stl differ diff --git a/default_config.json b/default_config.json index aa10ca9f8..694ed8225 100644 --- a/default_config.json +++ b/default_config.json @@ -15,6 +15,11 @@ "chart_dso": 128, "chart_reticle": 128, "chart_constellations": 64, + "obj_chart_crosshair": "pulse", + "obj_chart_crosshair_style": "simple", + "obj_chart_crosshair_speed": "2.0", + "obj_chart_lm_mode": "auto", + "obj_chart_lm_fixed": 14.0, "solve_pixel": [256, 256], "gps_type": "ublox", "gps_baud_rate": 9600, @@ -175,5 +180,6 @@ "active_telescope_index": 0, "active_eyepiece_index": 0 }, - "imu_threshold_scale": 1 + "imu_threshold_scale": 1, + "software_unstable_unlocked": false } diff --git a/deploy-image-to-nfs.sh b/deploy-image-to-nfs.sh new file mode 100755 index 000000000..1aec63990 --- /dev/null +++ b/deploy-image-to-nfs.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy PiFinder NixOS netboot configuration to proxnix +# +# Builds the pifinder-netboot closure (NFS root baked in), copies the nix store +# closure to NFS, and sets up TFTP with kernel/initrd/firmware for PXE boot. +# +# Boot sequence: Pi firmware → u-boot → extlinux/extlinux.conf (TFTP) → NFS root + +PROXNIX="mike@192.168.5.12" +NFS_ROOT="/srv/nfs/pifinder" +TFTP_ROOT="/srv/tftp" +PI_IP="192.168.5.150" +PI_MAC="e4-5f-01-b7-37-31" # For PXE boot speedup + +# SSH options to prevent timeout during long transfers +SSH_OPTS="-o ServerAliveInterval=30 -o ServerAliveCountMax=10" +export RSYNC_RSH="ssh ${SSH_OPTS}" + +SSH_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrPg9hSgxwg0EECxXSpYi7t3F/w/BgpymlD1uUDedRz mike@nixtop" + +# Password hash for "solveit" +SHADOW_HASH='$6$upbQ1/Jfh7zDiIYW$jPVQdYJCZn/Pe/OIGx89DZm9trIhEJp7Q4LNZsq/5x9csj6U08.P2avebrQIDJCEyD0xipsV6C19Sr5iAbCuv1' + +# ── Helpers ────────────────────────────────────────────────────────────────── + +run_proxnix() { + ssh ${SSH_OPTS} "${PROXNIX}" "bash -euo pipefail -c \"$1\"" +} + +# ── Build netboot closure ──────────────────────────────────────────────────── + +echo "=== Building pifinder-netboot closure ===" +nix build .#nixosConfigurations.pifinder-netboot.config.system.build.toplevel \ + -o result-netboot --system aarch64-linux + +CLOSURE=$(readlink -f result-netboot) +echo "Closure: $CLOSURE" + +# Extract paths from closure +KERNEL=$(readlink -f result-netboot/kernel) +INITRD=$(readlink -f result-netboot/initrd) +DTBS=$(readlink -f result-netboot/dtbs) +INIT_PATH="${CLOSURE}/init" + +KERNEL_NAME=$(basename "$(dirname "$KERNEL")")-Image +INITRD_NAME=$(basename "$(dirname "$INITRD")")-initrd + +echo "Kernel: $KERNEL" +echo "Initrd: $INITRD" +echo "DTBs: $DTBS" +echo "Init: $INIT_PATH" + +# ── Stop TFTP — prevent Pi from netbooting during deploy ───────────────────── + +echo "Stopping TFTP server..." +ssh "${PROXNIX}" "sudo systemctl stop atftpd.service" + +# ── Halt Pi if running — prevent NFS corruption ────────────────────────────── + +if ssh -o ConnectTimeout=3 -o BatchMode=yes "pifinder@${PI_IP}" "echo ok" 2>/dev/null; then + echo "Pi is running — halting..." + ssh "pifinder@${PI_IP}" "echo solveit | sudo -S poweroff" 2>/dev/null || true + echo "Waiting for Pi to go down..." + sleep 3 + while ping -c1 -W1 "${PI_IP}" &>/dev/null; do sleep 1; done + echo "Pi is down" +else + echo "Pi not reachable, proceeding" +fi + +# ── Backup SSH host keys ───────────────────────────────────────────────────── + +echo "Backing up SSH host keys..." +ssh "${PROXNIX}" "sudo cp -a ${NFS_ROOT}/etc/ssh/ssh_host_* /tmp/ 2>/dev/null || true" + +# ── Copy nix store closure to NFS ──────────────────────────────────────────── + +echo "Copying nix store closure to NFS..." +ssh "${PROXNIX}" "sudo mkdir -p ${NFS_ROOT}/nix/store" + +# Get list of store paths and stream via tar (fast, handles duplicates via overwrite) +STORE_PATHS=$(nix path-info -r "$CLOSURE") +TOTAL_PATHS=$(echo "$STORE_PATHS" | wc -l) +echo "Streaming ${TOTAL_PATHS} store paths via tar..." + +# Rsync store paths with -R to preserve directory structure +# shellcheck disable=SC2086 +rsync -avR --rsync-path="sudo rsync" $STORE_PATHS "${PROXNIX}:${NFS_ROOT}/" +echo "Transfer complete" + +# ── Set up NFS root directory structure ────────────────────────────────────── + +echo "Setting up NFS root directory structure..." +ssh "${PROXNIX}" "sudo bash -euo pipefail" << SETUP +# Create standard directories (bin/usr are symlinks, not dirs) +mkdir -p ${NFS_ROOT}/{etc/ssh,home/pifinder/.ssh,root/.ssh,var,tmp,proc,sys,dev,run,boot} +chmod 1777 ${NFS_ROOT}/tmp + +# Symlinks from NixOS system (remove existing dirs/symlinks first) +rm -rf ${NFS_ROOT}/bin ${NFS_ROOT}/usr +ln -sfT ${CLOSURE}/sw/bin ${NFS_ROOT}/bin +ln -sfT ${CLOSURE}/sw ${NFS_ROOT}/usr + +# /etc/static points to the NixOS etc derivation (required for PAM, etc.) +ln -sfT ${CLOSURE}/etc ${NFS_ROOT}/etc/static + +# Critical /etc symlinks that NixOS activation would normally create +rm -rf ${NFS_ROOT}/etc/pam.d 2>/dev/null || true +ln -sfT /etc/static/pam.d ${NFS_ROOT}/etc/pam.d +ln -sfT /etc/static/bashrc ${NFS_ROOT}/etc/bashrc +# passwd/shadow/group are created as real files later (need to be writable for netboot) +rm -f ${NFS_ROOT}/etc/passwd ${NFS_ROOT}/etc/shadow ${NFS_ROOT}/etc/group 2>/dev/null || true +ln -sfT /etc/static/sudoers ${NFS_ROOT}/etc/sudoers 2>/dev/null || true +ln -sfT /etc/static/sudoers.d ${NFS_ROOT}/etc/sudoers.d 2>/dev/null || true +ln -sfT /etc/static/nsswitch.conf ${NFS_ROOT}/etc/nsswitch.conf 2>/dev/null || true +ln -sfT /etc/static/systemd ${NFS_ROOT}/etc/systemd 2>/dev/null || true +ln -sfT /etc/static/polkit-1 ${NFS_ROOT}/etc/polkit-1 2>/dev/null || true + +# Create nix profile symlinks +mkdir -p ${NFS_ROOT}/nix/var/nix/profiles +ln -sfT ${CLOSURE} ${NFS_ROOT}/nix/var/nix/profiles/system +ln -sfT ${CLOSURE} ${NFS_ROOT}/run/current-system 2>/dev/null || true +SETUP + +# ── Restore SSH host keys ──────────────────────────────────────────────────── + +echo "Restoring/generating SSH host keys..." +ssh "${PROXNIX}" "bash -euo pipefail -c ' +if ls /tmp/ssh_host_* >/dev/null 2>&1; then + sudo cp -a /tmp/ssh_host_* ${NFS_ROOT}/etc/ssh/ + echo \"Restored existing host keys\" +else + sudo ssh-keygen -A -f ${NFS_ROOT} + echo \"Generated new host keys\" +fi +'" + +# ── Link NixOS /etc files ──────────────────────────────────────────────────── + +echo "Linking NixOS etc files..." +ssh "${PROXNIX}" "sudo bash -euo pipefail -c ' +ln -sf /etc/static/ssh/sshd_config ${NFS_ROOT}/etc/ssh/sshd_config +ln -sf /etc/static/ssh/ssh_config ${NFS_ROOT}/etc/ssh/ssh_config 2>/dev/null || true +ln -sf /etc/static/ssh/moduli ${NFS_ROOT}/etc/ssh/moduli 2>/dev/null || true +# pam.d already symlinked to /etc/static/pam.d in SETUP block +'" + +# ── Static user files ──────────────────────────────────────────────────────── + +echo "Creating static user files..." + +ssh "${PROXNIX}" "sudo tee ${NFS_ROOT}/etc/passwd > /dev/null" << 'PASSWD' +root:x:0:0:System administrator:/root:/run/current-system/sw/bin/bash +pifinder:x:1000:100::/home/pifinder:/run/current-system/sw/bin/bash +nobody:x:65534:65534:Unprivileged account:/var/empty:/run/current-system/sw/bin/nologin +sshd:x:993:993:SSH daemon user:/var/empty:/run/current-system/sw/bin/nologin +avahi:x:994:994:Avahi daemon user:/var/empty:/run/current-system/sw/bin/nologin +gpsd:x:992:992:GPSD daemon user:/var/empty:/run/current-system/sw/bin/nologin +PASSWD + +ssh "${PROXNIX}" "sudo tee ${NFS_ROOT}/etc/group > /dev/null" << 'GROUP' +root:x:0: +wheel:x:1:pifinder +users:x:100:pifinder +kmem:x:9:pifinder +input:x:174:pifinder +nobody:x:65534: +spi:x:996:pifinder +i2c:x:997:pifinder +gpio:x:998:pifinder +dialout:x:995:pifinder +video:x:994:pifinder +networkmanager:x:993:pifinder +sshd:x:993: +avahi:x:994: +gpsd:x:992: +GROUP + +ssh "${PROXNIX}" "echo 'root:${SHADOW_HASH}:1:::::: +pifinder:${SHADOW_HASH}:1:::::: +nobody:!:1:::::: +sshd:!:1:::::: +avahi:!:1:::::: +gpsd:!:1::::::' | sudo tee ${NFS_ROOT}/etc/shadow > /dev/null" + +run_proxnix "sudo chmod 644 ${NFS_ROOT}/etc/passwd ${NFS_ROOT}/etc/group" +run_proxnix "sudo chmod 640 ${NFS_ROOT}/etc/shadow" + +# ── SSH authorized_keys ────────────────────────────────────────────────────── + +echo "Setting up SSH authorized_keys..." +ssh "${PROXNIX}" "echo '${SSH_PUBKEY}' | sudo tee ${NFS_ROOT}/home/pifinder/.ssh/authorized_keys > /dev/null" +ssh "${PROXNIX}" "echo '${SSH_PUBKEY}' | sudo tee ${NFS_ROOT}/root/.ssh/authorized_keys > /dev/null" +run_proxnix "sudo chown -R 1000:100 ${NFS_ROOT}/home/pifinder" +run_proxnix "sudo chmod 700 ${NFS_ROOT}/home/pifinder/.ssh ${NFS_ROOT}/root/.ssh" +run_proxnix "sudo chmod 600 ${NFS_ROOT}/home/pifinder/.ssh/authorized_keys ${NFS_ROOT}/root/.ssh/authorized_keys" + +# ── PiFinder symlink ───────────────────────────────────────────────────────── + +echo "Setting up PiFinder directory..." +# Find pifinder-src from the current closure (not just any old one in the store) +PFSRC_REL=$(nix path-info -r "$CLOSURE" | grep pifinder-src | head -1) +echo "PiFinder source from closure: $PFSRC_REL" +ssh "${PROXNIX}" "sudo bash -euo pipefail -c ' +PFSRC=\"${NFS_ROOT}${PFSRC_REL}\" +if [ ! -d \"\$PFSRC\" ]; then + echo \"ERROR: pifinder-src not found: \$PFSRC\" + exit 1 +fi +PFHOME=${NFS_ROOT}/home/pifinder/PiFinder + +echo \"PiFinder source: ${PFSRC_REL}\" + +[ -L \"\$PFHOME\" ] && rm \"\$PFHOME\" +[ -d \"\$PFHOME\" ] && rm -rf \"\$PFHOME\" + +ln -sfT \"${PFSRC_REL}\" \"\$PFHOME\" + +mkdir -p ${NFS_ROOT}/home/pifinder/PiFinder_data +chown 1000:100 ${NFS_ROOT}/home/pifinder/PiFinder_data +'" + +# ── Copy firmware to TFTP (from raspberrypi firmware package) ──────────────── + +echo "Copying firmware to TFTP..." +FW_PKG=$(nix build nixpkgs#raspberrypifw --print-out-paths --system aarch64-linux 2>/dev/null) +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}" + +# Copy firmware files +rsync -avz "${FW_PKG}/share/raspberrypi/boot/"*.{elf,dat,bin,dtb} "${PROXNIX}:/tmp/fw/" +ssh "${PROXNIX}" "sudo cp /tmp/fw/* ${TFTP_ROOT}/ && rm -rf /tmp/fw" + +# Copy custom u-boot with network boot priority +UBOOT=$(nix build .#packages.aarch64-linux.uboot-netboot --print-out-paths --system aarch64-linux 2>/dev/null) +echo "Using custom u-boot: $UBOOT" +rsync -avz "${UBOOT}/u-boot.bin" "${PROXNIX}:/tmp/u-boot-rpi4.bin" +ssh "${PROXNIX}" "sudo mv /tmp/u-boot-rpi4.bin ${TFTP_ROOT}/" + +# ── Copy kernel, initrd, DTBs to TFTP ──────────────────────────────────────── + +echo "Copying kernel/initrd/DTBs to TFTP..." +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/nixos" +rsync -avz "${KERNEL}" "${PROXNIX}:/tmp/${KERNEL_NAME}" +rsync -avz "${INITRD}" "${PROXNIX}:/tmp/${INITRD_NAME}" +ssh "${PROXNIX}" "sudo mv /tmp/${KERNEL_NAME} /tmp/${INITRD_NAME} ${TFTP_ROOT}/nixos/" + +# Copy NixOS-built DTBs (with camera overlay baked in) to dtbs/ subdirectory +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/dtbs" +rsync -avz "${DTBS}/broadcom/" "${PROXNIX}:/tmp/dtbs/" +ssh "${PROXNIX}" "sudo cp /tmp/dtbs/*.dtb ${TFTP_ROOT}/dtbs/ && sudo rm -rf /tmp/dtbs" + +# Copy overlays from kernel package +KERNEL_DIR=$(dirname "$KERNEL") +rsync -avz "${KERNEL_DIR}/dtbs/overlays/" "${PROXNIX}:/tmp/overlays/" +ssh "${PROXNIX}" "sudo rm -rf ${TFTP_ROOT}/overlays && sudo mv /tmp/overlays ${TFTP_ROOT}/" + +# ── Write config.txt for u-boot ────────────────────────────────────────────── + +echo "Writing config.txt..." +ssh "${PROXNIX}" "sudo tee ${TFTP_ROOT}/config.txt > /dev/null" << CONFIG +[pi4] +kernel=u-boot-rpi4.bin +enable_gic=1 +armstub=armstub8-gic.bin + +disable_overscan=1 +arm_boost=1 + +[all] +arm_64bit=1 +enable_uart=1 +avoid_warnings=1 +CONFIG + +# ── Generate extlinux/extlinux.conf ──────────────────────────────────────────── + +echo "Generating extlinux/extlinux.conf..." +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/extlinux && sudo tee ${TFTP_ROOT}/extlinux/extlinux.conf > /dev/null" << EXTLINUX +TIMEOUT 10 +DEFAULT nixos-default + +LABEL nixos-default + MENU LABEL NixOS - Default + LINUX /nixos/${KERNEL_NAME} + INITRD /nixos/${INITRD_NAME} + FDTDIR /dtbs + APPEND init=${INIT_PATH} ip=dhcp console=ttyS0,115200n8 console=ttyAMA0,115200n8 console=tty0 loglevel=4 +EXTLINUX + +# ── Create pxelinux.cfg for faster MAC-based boot ───────────────────────────── + +echo "Creating pxelinux.cfg/01-${PI_MAC}..." +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/pxelinux.cfg && sudo ln -sf ../extlinux/extlinux.conf ${TFTP_ROOT}/pxelinux.cfg/01-${PI_MAC}" + +# ── Clean up old artifacts ─────────────────────────────────────────────────── + +echo "Cleaning up old artifacts..." +ssh "${PROXNIX}" "sudo rm -f ${TFTP_ROOT}/cmdline.txt ${TFTP_ROOT}/nixos/patched-initrd 2>/dev/null || true" +ssh "${PROXNIX}" "sudo rm -f /tmp/ssh_host_*" + +# ── Restart TFTP ───────────────────────────────────────────────────────────── + +echo "Restarting TFTP server..." +ssh "${PROXNIX}" "sudo systemctl start atftpd.service" + +# ── Verification ───────────────────────────────────────────────────────────── + +echo "" +echo "==========================================" +echo "VERIFYING DEPLOYMENT CONSISTENCY" +echo "==========================================" +VERIFY_FAILED=0 + +echo -n "Checking u-boot... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/u-boot-rpi4.bin"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking config.txt... " +if ssh "${PROXNIX}" "grep -q 'kernel=u-boot-rpi4.bin' ${TFTP_ROOT}/config.txt"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking extlinux/extlinux.conf... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/extlinux/extlinux.conf"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking kernel... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/nixos/${KERNEL_NAME}"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking initrd... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/nixos/${INITRD_NAME}"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking NFS closure... " +if ssh "${PROXNIX}" "test -f ${NFS_ROOT}${INIT_PATH}"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking PiFinder symlink... " +PFSRC_TARGET=$(ssh "${PROXNIX}" "readlink ${NFS_ROOT}/home/pifinder/PiFinder 2>/dev/null || true") +if [ -n "$PFSRC_TARGET" ] && ssh "${PROXNIX}" "test -d ${NFS_ROOT}${PFSRC_TARGET}/python"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo "==========================================" + +if [ $VERIFY_FAILED -eq 1 ]; then + echo "=== DEPLOY FAILED VERIFICATION — DO NOT BOOT ===" + exit 1 +fi + +echo "=== Deploy complete and verified ===" +echo "" +echo "Boot chain: Pi firmware → u-boot → extlinux/extlinux.conf → NFS root" +echo "To boot the Pi: power cycle it" diff --git a/docs/source/build_guide.rst b/docs/source/build_guide.rst index 263416f89..a9b3d65c8 100644 --- a/docs/source/build_guide.rst +++ b/docs/source/build_guide.rst @@ -8,7 +8,6 @@ Introduction and Overview Welcome to the PiFinder build guide! This guide is split into three main parts, one for building the :ref:`UI Board` with Screen and Buttons, a section related to :ref:`3d printing` and preparing the case parts, and one for :ref:`final assembly`. Along with these sections, please consult the :doc:`Bill of Materials` for a full list of parts required and reach out with any questions via `email `_ or `discord `_ -If you've received a kit with an assembled UI Board + 3d Parts, you can jump right to the :ref:`final assembly`. Otherwise, fire up that 3d printer and get the :ref:`parts printing` while you work to assemble the UI Hat. PiFinder UI Hat ======================== diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..4d2ec76d4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixos-hardware": { + "locked": { + "lastModified": 1770631810, + "narHash": "sha256-b7iK/x+zOXbjhRqa+XBlYla4zFvPZyU5Ln2HJkiSnzc=", + "owner": "NixOS", + "repo": "nixos-hardware", + "rev": "2889685785848de940375bf7fea5e7c5a3c8d502", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixos-hardware", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1770617025, + "narHash": "sha256-1jZvgZoAagZZB6NwGRv2T2ezPy+X6EFDsJm+YSlsvEs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2db38e08fdadcc0ce3232f7279bab59a15b94482", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixos-hardware": "nixos-hardware", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..2f39b6563 --- /dev/null +++ b/flake.nix @@ -0,0 +1,401 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixos-hardware.url = "github:NixOS/nixos-hardware"; + }; + + outputs = { self, nixpkgs, nixos-hardware, ... }: let + # Shared modules for all PiFinder configurations + commonModules = [ + nixos-hardware.nixosModules.raspberry-pi-4 + ./nixos/hardware.nix + ./nixos/networking.nix + ./nixos/services.nix + ./nixos/python-env.nix + # Pass git revision to pifinder-src for build identity + ({ ... }: { + _module.args.pifinderGitRev = self.shortRev or self.dirtyShortRev or "unknown"; + }) + # Headless — strip X11, fonts, docs, desktop bloat + ({ lib, ... }: { + services.xserver.enable = false; + security.polkit.enable = true; + fonts.fontconfig.enable = false; + documentation.enable = false; + documentation.man.enable = false; + documentation.nixos.enable = false; + xdg.portal.enable = false; + services.pipewire.enable = false; + services.pulseaudio.enable = false; + boot.initrd.availableKernelModules = lib.mkForce [ "mmc_block" "usbhid" "usb_storage" "vc4" ]; + }) + ]; + + mkPifinderSystem = { includeSDImage ? false }: nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + modules = commonModules ++ [ + { pifinder.devMode = false; } + # Camera specialisations — base is imx462 (default), specialisations for others + ({ ... }: { + specialisation = { + imx296.configuration = { pifinder.cameraType = "imx296"; }; + imx477.configuration = { pifinder.cameraType = "imx477"; }; + }; + }) + ({ lib, ... }: { + boot.supportedFilesystems = lib.mkForce [ "vfat" "ext4" ]; + boot.loader.timeout = 0; + }) + ] ++ nixpkgs.lib.optionals includeSDImage [ + "${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix" + ({ config, pkgs, lib, ... }: + let + ubootSD = pkgs.ubootRaspberryPi4_64bit.override { + extraConfig = '' + CONFIG_CMD_PXE=y + CONFIG_CMD_SYSBOOT=y + CONFIG_BOOTDELAY=0 + CONFIG_PREBOOT="" + CONFIG_BOOTCOMMAND="sysboot mmc 0:2 any 0x02400000 /boot/extlinux/extlinux.conf" + CONFIG_PCI=n + CONFIG_USB=n + CONFIG_CMD_USB=n + CONFIG_CMD_PCI=n + CONFIG_USB_KEYBOARD=n + CONFIG_BCMGENET=n + ''; + }; + configTxt = pkgs.writeText "config.txt" '' + [pi3] + kernel=u-boot-rpi3.bin + + [pi02] + kernel=u-boot-rpi3.bin + + [pi4] + kernel=u-boot-rpi4.bin + enable_gic=1 + armstub=armstub8-gic.bin + + disable_overscan=1 + arm_boost=1 + + [cm4] + otg_mode=1 + + [all] + arm_64bit=1 + enable_uart=1 + avoid_warnings=1 + ''; + catalog-images = pkgs.stdenv.mkDerivation { + pname = "pifinder-catalog-images"; + version = "1.0"; + src = pkgs.fetchurl { + url = "https://files.miker.be/public/pifinder/catalog_images.tar.zst"; + hash = "sha256-20YOmO2qy2W27nIFV4Aqibu0MLip4gymHrfe411+VNg="; + }; + nativeBuildInputs = [ pkgs.zstd ]; + unpackPhase = "tar xf $src"; + installPhase = "mv catalog_images $out"; + }; + gaia-stars = pkgs.stdenv.mkDerivation { + pname = "pifinder-gaia-stars"; + version = "1.0"; + src = pkgs.fetchurl { + url = "https://files.miker.be/public/pifinder/gaia_stars.tar.zst"; + hash = "sha256-vmsOz7U0X4bnMZrcKjiwIk0YYy/AqRV2+fzaH7qO8wo="; + }; + nativeBuildInputs = [ pkgs.zstd ]; + unpackPhase = "tar xf $src"; + installPhase = "mv gaia_stars $out"; + }; + in { + sdImage.populateRootCommands = '' + mkdir -p ./files/home/pifinder/PiFinder_data + cp -r ${catalog-images} ./files/home/pifinder/PiFinder_data/catalog_images + chmod -R u+w ./files/home/pifinder/PiFinder_data/catalog_images + cp -r ${gaia-stars} ./files/home/pifinder/PiFinder_data/gaia_stars + chmod -R u+w ./files/home/pifinder/PiFinder_data/gaia_stars + ''; + sdImage.populateFirmwareCommands = lib.mkForce '' + (cd ${pkgs.raspberrypifw}/share/raspberrypi/boot && cp bootcode.bin fixup*.dat start*.elf $NIX_BUILD_TOP/firmware/) + + cp ${configTxt} firmware/config.txt + + # Pi3 files + cp ${pkgs.ubootRaspberryPi3_64bit}/u-boot.bin firmware/u-boot-rpi3.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-2-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b-plus.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-cm3.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2-w.dtb firmware/ + + # Pi4 files + cp ${ubootSD}/u-boot.bin firmware/u-boot-rpi4.bin + cp ${pkgs.raspberrypi-armstubs}/armstub8-gic.bin firmware/armstub8-gic.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-4-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-400.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4s.dtb firmware/ + ''; + }) + ] ++ nixpkgs.lib.optionals (!includeSDImage) [ + # Minimal filesystem stub for closure builds (CI) + ({ lib, ... }: { + fileSystems."/" = { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + }; + fileSystems."/boot/firmware" = { + device = "/dev/disk/by-label/FIRMWARE"; + fsType = "vfat"; + }; + }) + ]; + }; + + # Netboot configuration — NFS root, DHCP network in initrd + mkPifinderNetboot = nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + modules = commonModules ++ [ + { pifinder.devMode = true; } + { pifinder.cameraType = nixpkgs.lib.mkDefault "imx477"; } # HQ camera for netboot dev + # Camera specialisations for netboot (base is imx477) + ({ ... }: { + specialisation = { + imx296.configuration = { pifinder.cameraType = "imx296"; }; + imx462.configuration = { pifinder.cameraType = "imx462"; }; + }; + }) + ({ lib, pkgs, ... }: + let + boot-splash = import ./nixos/pkgs/boot-splash.nix { inherit pkgs; }; + in { + # Static passwd/group — NFS can't run activation scripts + users.mutableUsers = false; + # DNS for netboot (udhcpc doesn't configure resolvconf properly) + networking.nameservers = [ "192.168.5.1" "8.8.8.8" ]; + boot.supportedFilesystems = lib.mkForce [ "vfat" "ext4" "nfs" ]; + boot.initrd.supportedFilesystems = [ "nfs" ]; + # Add SPI kernel module for early OLED splash + boot.initrd.kernelModules = [ "spi_bcm2835" ]; + # Override the minimal module list from commonModules — add network drivers + # Note: genet (RPi4 ethernet) is built into the kernel, not a module + boot.initrd.availableKernelModules = lib.mkForce [ + "mmc_block" "usbhid" "usb_storage" "vc4" + ]; + # Add boot-splash to initrd + boot.initrd.extraUtilsCommands = '' + copy_bin_and_libs ${boot-splash}/bin/boot-splash + ''; + # Disable predictable interface names so eth0 works + boot.kernelParams = [ "net.ifnames=0" "biosdevname=0" ]; + boot.initrd.network = { + enable = true; + }; + # Show static splash, then configure network + boot.initrd.postDeviceCommands = '' + # Create device nodes for SPI OLED + mkdir -p /dev + mknod -m 666 /dev/spidev0.0 c 153 0 2>/dev/null || true + mknod -m 666 /dev/gpiochip0 c 254 0 2>/dev/null || true + + # Show static splash image (--static flag = display once and exit) + boot-splash --static || true + # Wait for interface to appear (up to 30 seconds) + echo "Waiting for eth0..." + for i in $(seq 1 60); do + if ip link show eth0 >/dev/null 2>&1; then + echo "eth0 found after $i attempts" + break + fi + sleep 0.5 + done + + ip link set eth0 up + + # Wait for link carrier (cable connected) + echo "Waiting for link carrier..." + for i in $(seq 1 20); do + if [ "$(cat /sys/class/net/eth0/carrier 2>/dev/null)" = "1" ]; then + echo "Link up after $i attempts" + break + fi + sleep 0.5 + done + + # DHCP with retries + echo "Starting DHCP..." + for attempt in 1 2 3; do + if udhcpc -i eth0 -t 5 -T 3 -n -q -s /etc/udhcpc.script; then + echo "DHCP succeeded on attempt $attempt" + break + fi + echo "DHCP attempt $attempt failed, retrying..." + sleep 2 + done + + # Verify we got an IP + if ip addr show eth0 | grep -q "inet "; then + echo "Network configured:" + ip addr show eth0 + else + echo "WARNING: No IP address on eth0!" + ip addr show eth0 + fi + ''; + # NFS root filesystem - NFSv4 with disabled caching for Nix compatibility + fileSystems."/" = { + device = "192.168.5.12:/srv/nfs/pifinder"; + fsType = "nfs"; + options = [ "vers=4" "noac" "actimeo=0" ]; + }; + # Dummy /boot — not used for netboot but NixOS requires it + fileSystems."/boot" = { + device = "none"; + fsType = "tmpfs"; + neededForBoot = false; + }; + }) + ]; + }; + # Custom u-boot variants + pkgsAarch64 = import nixpkgs { system = "aarch64-linux"; }; + # SD boot: skip PCI/USB/net probe, go straight to mmc extlinux + ubootSD = pkgsAarch64.ubootRaspberryPi4_64bit.override { + extraConfig = '' + CONFIG_CMD_PXE=y + CONFIG_CMD_SYSBOOT=y + CONFIG_BOOTDELAY=0 + CONFIG_PREBOOT="" + CONFIG_BOOTCOMMAND="sysboot mmc 0:2 any 0x02400000 /boot/extlinux/extlinux.conf" + CONFIG_PCI=n + CONFIG_USB=n + CONFIG_CMD_USB=n + CONFIG_CMD_PCI=n + CONFIG_USB_KEYBOARD=n + CONFIG_BCMGENET=n + ''; + }; + # Netboot: PCI + DHCP + PXE + ubootNetboot = pkgsAarch64.ubootRaspberryPi4_64bit.override { + extraConfig = '' + CONFIG_BOOTCOMMAND="pci enum; dhcp; pxe get; pxe boot" + ''; + }; + + in { + nixosConfigurations = { + # SD card boot — camera baked into DT, switched via specialisations + pifinder = mkPifinderSystem {}; + # NFS netboot — for development on proxnix + pifinder-netboot = mkPifinderNetboot; + }; + images = { + # SD card image + pifinder = (mkPifinderSystem { includeSDImage = true; }).config.system.build.sdImage; + # Migration bootstrap tarball + bootstrap = let + system = mkPifinderSystem {}; + toplevel = system.config.system.build.toplevel; + pkgs = import nixpkgs { system = "aarch64-linux"; }; + closure = pkgs.closureInfo { rootPaths = [ toplevel ]; }; + kernelParams = builtins.concatStringsSep " " system.config.boot.kernelParams; + configTxt = pkgs.writeText "config.txt" '' + [pi3] + kernel=u-boot-rpi3.bin + + [pi02] + kernel=u-boot-rpi3.bin + + [pi4] + kernel=u-boot-rpi4.bin + enable_gic=1 + armstub=armstub8-gic.bin + + disable_overscan=1 + arm_boost=1 + + [cm4] + otg_mode=1 + + [all] + arm_64bit=1 + enable_uart=1 + avoid_warnings=1 + ''; + fw = "${pkgs.raspberrypifw}/share/raspberrypi/boot"; + in pkgs.stdenv.mkDerivation { + name = "pifinder-bootstrap-tarball"; + __structuredAttrs = true; + unsafeDiscardReferences.out = true; + nativeBuildInputs = [ pkgs.zstd ]; + buildCommand = '' + root=$(mktemp -d) + + # Copy store closure into isolated root (avoids sandbox nix symlink) + mkdir -p "$root/nix/store" + while IFS= read -r path; do + cp -a "$path" "$root$path" + done < ${closure}/store-paths + + # Nix DB registration for nix-store --load-db + cp ${closure}/registration "$root/nix-path-registration" + + # System profile symlink + mkdir -p "$root/nix/var/nix/profiles" + ln -s ${toplevel} "$root/nix/var/nix/profiles/system" + + # Boot firmware (migration init moves these to FAT32 partition) + mkdir -p "$root/boot" + cp ${fw}/bootcode.bin "$root/boot/" + cp ${fw}/fixup4.dat "$root/boot/" + cp ${fw}/start4.elf "$root/boot/" + cp ${pkgs.raspberrypi-armstubs}/armstub8-gic.bin "$root/boot/" + cp ${ubootSD}/u-boot.bin "$root/boot/u-boot-rpi4.bin" + cp ${pkgs.ubootRaspberryPi3_64bit}/u-boot.bin "$root/boot/u-boot-rpi3.bin" + cp ${configTxt} "$root/boot/config.txt" + + # Device trees + cp ${fw}/bcm2711-rpi-4-b.dtb "$root/boot/" + cp ${fw}/bcm2711-rpi-400.dtb "$root/boot/" + cp ${fw}/bcm2711-rpi-cm4.dtb "$root/boot/" + cp ${fw}/bcm2710-rpi-3-b.dtb "$root/boot/" + cp ${fw}/bcm2710-rpi-3-b-plus.dtb "$root/boot/" + cp ${fw}/bcm2710-rpi-zero-2.dtb "$root/boot/" + cp ${fw}/bcm2710-rpi-zero-2-w.dtb "$root/boot/" + + # Bootloader config + mkdir -p "$root/boot/extlinux" + cat > "$root/boot/extlinux/extlinux.conf" < $out/tarball/pifinder-bootstrap.tar.zst) + echo "file system-tarball $out/tarball/pifinder-bootstrap.tar.zst" > $out/nix-support/hydra-build-products + ''; + }; + }; + packages.aarch64-linux = { + uboot-sd = ubootSD; + uboot-netboot = ubootNetboot; + }; + + devShells.x86_64-linux.default = let + pkgs = import nixpkgs { system = "x86_64-linux"; }; + pyPkgs = import ./nixos/pkgs/python-packages.nix { inherit pkgs; }; + cedar-detect = import ./nixos/pkgs/cedar-detect.nix { inherit pkgs; }; + in pkgs.mkShell { + packages = [ pyPkgs.devEnv pkgs.ruff cedar-detect ]; + }; + }; +} diff --git a/nixos/hardware.nix b/nixos/hardware.nix new file mode 100644 index 000000000..128dac681 --- /dev/null +++ b/nixos/hardware.nix @@ -0,0 +1,130 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.pifinder; + + # Camera driver name mapping + cameraDriver = { + imx296 = "imx296"; + imx462 = "imx290"; # imx462 uses imx290 driver + imx477 = "imx477"; + }.${cfg.cameraType}; + + # Compile DTS text to DTBO + compileOverlay = name: dtsText: pkgs.deviceTree.compileDTS { + name = "${name}-dtbo"; + dtsFile = pkgs.writeText "${name}.dts" dtsText; + }; + + # SPI0 — no nixos-hardware option, use custom overlay + spi0Dtbo = compileOverlay "spi0" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &spi0 { status = "okay"; }; + ''; + + # UART3 for GPS on /dev/ttyAMA1 + uart3Dtbo = compileOverlay "uart3" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &uart3 { status = "okay"; }; + ''; + + # I2C1 (ARM bus) — nixos-hardware overlay is bypassed by our mkForce DTB package + i2c1Dtbo = compileOverlay "i2c1" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &i2c1 { + status = "okay"; + clock-frequency = <${toString cfg.i2cFrequency}>; + }; + ''; + + # PWM on GPIO 13 (PWM channel 1) for keypad backlight + # GPIO 13 = PWM0_1 when ALT0 (function 4) + pwmDtbo = compileOverlay "pwm" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &gpio { + pwm_pin13: pwm_pin13 { + brcm,pins = <13>; + brcm,function = <4>; /* ALT0 = PWM0_1 */ + }; + }; + &pwm { + status = "okay"; + pinctrl-names = "default"; + pinctrl-0 = <&pwm_pin13>; + }; + ''; + + # Camera overlay from kernel's DTB overlays directory + cameraDtbo = "${config.boot.kernelPackages.kernel}/dtbs/overlays/${cameraDriver}.dtbo"; +in { + options.pifinder = { + cameraType = lib.mkOption { + type = lib.types.enum [ "imx296" "imx462" "imx477" ]; + default = "imx462"; + description = "Camera sensor type for PiFinder"; + }; + i2cFrequency = lib.mkOption { + type = lib.types.int; + default = 10000; + description = "I2C1 bus clock frequency in Hz (10 kHz for BNO055 IMU)"; + }; + }; + + config = { + # Only include RPi 4B device tree (not CM4 variants) + hardware.deviceTree.filter = "*rpi-4-b.dtb"; + # Explicit DTB name so extlinux uses FDT instead of FDTDIR + # (DTBs are in broadcom/ subdirectory, FDTDIR doesn't descend into it) + hardware.deviceTree.name = "broadcom/bcm2711-rpi-4-b.dtb"; + + # I2C enabled (loads i2c-dev module, creates i2c group) + hardware.i2c.enable = true; + + # Apply all DT overlays via fdtoverlay, bypassing NixOS apply_overlays.py + # which rejects RPi camera overlays due to compatible string mismatch + # (overlays declare "brcm,bcm2835" but kernel DTBs use "brcm,bcm2711") + hardware.deviceTree.package = let + kernelDtbs = config.hardware.deviceTree.dtbSource; + in lib.mkForce (pkgs.runCommand "device-tree-with-overlays" { + nativeBuildInputs = [ pkgs.dtc ]; + } '' + mkdir -p $out/broadcom + for dtb in ${kernelDtbs}/broadcom/*rpi-4-b.dtb; do + fdtoverlay -i "$dtb" \ + -o "$out/broadcom/$(basename $dtb)" \ + ${i2c1Dtbo} ${spi0Dtbo} ${uart3Dtbo} ${pwmDtbo} ${cameraDtbo} + done + ''); + + # udev rules for hardware access without root + services.udev.extraRules = '' + SUBSYSTEM=="spidev", GROUP="spi", MODE="0660" + SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660" + SUBSYSTEM=="pwm", GROUP="gpio", MODE="0660" + SUBSYSTEM=="gpio", GROUP="gpio", MODE="0660" + KERNEL=="gpiomem", GROUP="gpio", MODE="0660" + KERNEL=="ttyAMA1", GROUP="dialout", MODE="0660" + # DMA heap for libcamera/picamera2 (CMA memory allocation) + SUBSYSTEM=="dma_heap", GROUP="video", MODE="0660" + ''; + + users.users.root.initialPassword = "solveit"; + users.users.pifinder = { + isNormalUser = true; + initialPassword = "solveit"; + extraGroups = [ "spi" "i2c" "gpio" "dialout" "video" "networkmanager" "systemd-journal" "input" "kmem" ]; + }; + users.groups = { + spi = {}; + i2c = {}; + gpio = {}; + }; + }; +} diff --git a/nixos/networking.nix b/nixos/networking.nix new file mode 100644 index 000000000..8c2af0a55 --- /dev/null +++ b/nixos/networking.nix @@ -0,0 +1,85 @@ +{ config, lib, pkgs, ... }: +{ + networking = { + hostName = "pifinder"; + networkmanager.enable = true; + wireless.enable = false; # NetworkManager handles WiFi + firewall = { + checkReversePath = "loose"; # Allow multi-interface (WiFi + ethernet) on same subnet + allowedUDPPorts = [ 53 67 ]; # DNS + DHCP for AP mode + allowedTCPPorts = [ 80 ]; # PiFinder web UI (other ports via service openFirewall) + }; + }; + + # dnsmasq for NetworkManager AP shared mode (DHCP for AP clients) + services.dnsmasq.enable = false; # NM manages its own dnsmasq instance + environment.systemPackages = [ pkgs.dnsmasq ]; + + # Wired ethernet with DHCP (autoconnect) + environment.etc."NetworkManager/system-connections/Wired.nmconnection" = { + text = '' + [connection] + id=Wired + type=ethernet + autoconnect=true + + [ipv4] + method=auto + + [ipv6] + method=auto + ''; + mode = "0600"; + }; + + # Policy routing: when ethernet and WiFi are on the same subnet, the kernel + # sends all replies via ethernet (lower metric), breaking TCP on the WiFi IP. + # This dispatcher adds a policy route so WiFi-sourced replies go out WiFi. + environment.etc."NetworkManager/dispatcher.d/50-policy-route" = { + text = '' + #!/bin/sh + [ "$1" = "wlan0" ] || [ "$1" = "end0" ] || exit 0 + case "$2" in up|down|dhcp4-change) ;; *) exit 0 ;; esac + + ip rule del from all table 100 2>/dev/null || true + ip route flush table 100 2>/dev/null || true + + WLAN_CIDR=$(ip -4 -o addr show wlan0 2>/dev/null | awk '{print $4}') + [ -z "$WLAN_CIDR" ] && exit 0 + WLAN_IP=''${WLAN_CIDR%%/*} + WLAN_GW=$(ip route show dev wlan0 default 2>/dev/null | awk '{print $3}' | head -1) + [ -z "$WLAN_IP" ] || [ -z "$WLAN_GW" ] && exit 0 + WLAN_NET=$(python3 -c "import ipaddress; print(ipaddress.ip_interface('$WLAN_CIDR').network)") + + ip route add "$WLAN_NET" dev wlan0 src "$WLAN_IP" table 100 + ip route add default via "$WLAN_GW" dev wlan0 table 100 + ip rule add from "$WLAN_IP" table 100 priority 100 + ''; + mode = "0755"; + }; + + # Pre-configured AP profile (activated on demand via nmcli) + environment.etc."NetworkManager/system-connections/PiFinder-AP.nmconnection" = { + text = '' + [connection] + id=PiFinder-AP + type=wifi + autoconnect=true + autoconnect-priority=-1 + + [wifi] + mode=ap + ssid=PiFinderAP + band=bg + channel=7 + + [ipv4] + method=shared + address1=10.10.10.1/24 + + [ipv6] + method=disabled + ''; + mode = "0600"; + }; +} diff --git a/nixos/pkgs/boot-splash.c b/nixos/pkgs/boot-splash.c new file mode 100644 index 000000000..74881a855 --- /dev/null +++ b/nixos/pkgs/boot-splash.c @@ -0,0 +1,276 @@ +/* + * boot-splash - Early boot splash for PiFinder + * + * Displays welcome image with Knight Rider animation until stopped. + * Designed for NixOS early boot (before Python starts). + * + * Hardware: SPI0.0, DC=GPIO24, RST=GPIO25, 128x128 SSD1351 OLED + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define WIDTH 128 +#define HEIGHT 128 +#define SPI_DEVICE "/dev/spidev0.0" +#define SPI_SPEED 40000000 +#define GPIO_DC 24 +#define GPIO_RST 25 + +/* RGB565 colors (display interprets as RGB despite BGR setting) */ +#define COL_BLACK 0x0000 +#define COL_RED 0xF800 + +/* Include generated image data */ +#include "welcome_image.h" + +static int spi_fd = -1; +static int gpio_fd = -1; +static struct gpio_v2_line_request dc_req; +static struct gpio_v2_line_request rst_req; +static uint16_t framebuf[WIDTH * HEIGHT]; +static volatile int running = 1; + +static void signal_handler(int sig) { + (void)sig; + running = 0; +} + +static void msleep(int ms) { + struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (ms % 1000) * 1000000L }; + nanosleep(&ts, NULL); +} + +static int gpio_request_line(int chip_fd, int pin, struct gpio_v2_line_request *req) { + struct gpio_v2_line_request r = {0}; + r.offsets[0] = pin; + r.num_lines = 1; + r.config.flags = GPIO_V2_LINE_FLAG_OUTPUT; + snprintf(r.consumer, sizeof(r.consumer), "boot-splash"); + + if (ioctl(chip_fd, GPIO_V2_GET_LINE_IOCTL, &r) < 0) { + perror("GPIO_V2_GET_LINE_IOCTL"); + return -1; + } + *req = r; + return 0; +} + +static void gpio_set(struct gpio_v2_line_request *req, int value) { + struct gpio_v2_line_values vals = {0}; + vals.bits = value ? 1 : 0; + vals.mask = 1; + ioctl(req->fd, GPIO_V2_LINE_SET_VALUES_IOCTL, &vals); +} + +static void spi_write(const uint8_t *data, size_t len) { + const size_t chunk_size = 4096; + while (len > 0) { + size_t this_len = len > chunk_size ? chunk_size : len; + struct spi_ioc_transfer tr = {0}; + tr.tx_buf = (unsigned long)data; + tr.len = this_len; + tr.speed_hz = SPI_SPEED; + tr.bits_per_word = 8; + ioctl(spi_fd, SPI_IOC_MESSAGE(1), &tr); + data += this_len; + len -= this_len; + } +} + +static void ssd1351_cmd(uint8_t cmd) { + gpio_set(&dc_req, 0); + spi_write(&cmd, 1); +} + +static void ssd1351_data(const uint8_t *data, size_t len) { + gpio_set(&dc_req, 1); + spi_write(data, len); +} + +static void ssd1351_init(void) { + uint8_t d; + + /* Hardware reset */ + gpio_set(&rst_req, 1); + msleep(10); + gpio_set(&rst_req, 0); + msleep(10); + gpio_set(&rst_req, 1); + msleep(10); + + ssd1351_cmd(0xFD); d = 0x12; ssd1351_data(&d, 1); /* Unlock */ + ssd1351_cmd(0xFD); d = 0xB1; ssd1351_data(&d, 1); /* Unlock commands */ + ssd1351_cmd(0xAE); /* Display off */ + ssd1351_cmd(0xB3); d = 0xF1; ssd1351_data(&d, 1); /* Clock divider */ + ssd1351_cmd(0xCA); d = 0x7F; ssd1351_data(&d, 1); /* Mux ratio */ + + uint8_t col[2] = {0x00, 0x7F}; + ssd1351_cmd(0x15); ssd1351_data(col, 2); /* Column address */ + uint8_t row[2] = {0x00, 0x7F}; + ssd1351_cmd(0x75); ssd1351_data(row, 2); /* Row address */ + + ssd1351_cmd(0xA0); d = 0x74; ssd1351_data(&d, 1); /* BGR, 65k color */ + ssd1351_cmd(0xA1); d = 0x00; ssd1351_data(&d, 1); /* Start line */ + ssd1351_cmd(0xA2); d = 0x00; ssd1351_data(&d, 1); /* Display offset */ + ssd1351_cmd(0xB5); d = 0x00; ssd1351_data(&d, 1); /* GPIO */ + ssd1351_cmd(0xAB); d = 0x01; ssd1351_data(&d, 1); /* Function select */ + ssd1351_cmd(0xB1); d = 0x32; ssd1351_data(&d, 1); /* Precharge */ + + uint8_t vsl[3] = {0xA0, 0xB5, 0x55}; + ssd1351_cmd(0xB4); ssd1351_data(vsl, 3); /* VSL */ + + ssd1351_cmd(0xBE); d = 0x05; ssd1351_data(&d, 1); /* VCOMH */ + ssd1351_cmd(0xC7); d = 0x0F; ssd1351_data(&d, 1); /* Master contrast */ + ssd1351_cmd(0xB6); d = 0x01; ssd1351_data(&d, 1); /* Precharge2 */ + ssd1351_cmd(0xA6); /* Normal display */ + + uint8_t contrast[3] = {0xFF, 0xFF, 0xFF}; + ssd1351_cmd(0xC1); ssd1351_data(contrast, 3); /* Contrast */ +} + +static void ssd1351_flush(void) { + uint8_t col[2] = {0x00, 0x7F}; + ssd1351_cmd(0x15); ssd1351_data(col, 2); + uint8_t row[2] = {0x00, 0x7F}; + ssd1351_cmd(0x75); ssd1351_data(row, 2); + ssd1351_cmd(0x5C); /* Write RAM */ + + uint8_t buf[WIDTH * HEIGHT * 2]; + for (int i = 0; i < WIDTH * HEIGHT; i++) { + buf[i * 2] = framebuf[i] >> 8; + buf[i * 2 + 1] = framebuf[i] & 0xFF; + } + ssd1351_data(buf, sizeof(buf)); +} + +static void draw_scanner(int pos, int scanner_width) { + /* Copy welcome image to framebuffer */ + memcpy(framebuf, welcome_image, sizeof(framebuf)); + + /* Draw Knight Rider scanner at bottom (last 4 rows) */ + int y_start = HEIGHT - 4; + int center = pos; + + for (int x = 0; x < WIDTH; x++) { + int dist = abs(x - center); + uint16_t color = COL_BLACK; + + if (dist < scanner_width) { + /* Gradient: brighter at center */ + int intensity = 31 - (dist * 31 / scanner_width); + if (intensity < 8) intensity = 8; /* Minimum brightness */ + /* RGB565: RRRRRGGGGGGBBBBB - red is high 5 bits */ + color = ((uint16_t)intensity & 0x1F) << 11; + } + + for (int y = y_start; y < HEIGHT; y++) { + framebuf[y * WIDTH + x] = color; + } + } + + ssd1351_flush(); +} + +static int hw_init(void) { + spi_fd = open(SPI_DEVICE, O_RDWR); + if (spi_fd < 0) { + perror("open spi"); + return -1; + } + + uint8_t mode = SPI_MODE_0; + uint8_t bits = 8; + uint32_t speed = SPI_SPEED; + ioctl(spi_fd, SPI_IOC_WR_MODE, &mode); + ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, &bits); + ioctl(spi_fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed); + + gpio_fd = open("/dev/gpiochip0", O_RDWR); + if (gpio_fd < 0) { + perror("open gpiochip0"); + return -1; + } + + if (gpio_request_line(gpio_fd, GPIO_DC, &dc_req) < 0) + return -1; + if (gpio_request_line(gpio_fd, GPIO_RST, &rst_req) < 0) + return -1; + + ssd1351_init(); + return 0; +} + +static void hw_cleanup(void) { + if (dc_req.fd > 0) close(dc_req.fd); + if (rst_req.fd > 0) close(rst_req.fd); + if (gpio_fd >= 0) close(gpio_fd); + if (spi_fd >= 0) close(spi_fd); +} + +static void show_static_image(void) { + memcpy(framebuf, welcome_image, sizeof(framebuf)); + ssd1351_flush(); +} + +int main(int argc, char *argv[]) { + int static_mode = 0; + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--static") == 0) { + static_mode = 1; + } + } + + signal(SIGTERM, signal_handler); + signal(SIGINT, signal_handler); + + if (hw_init() < 0) { + fprintf(stderr, "Hardware init failed\n"); + hw_cleanup(); + return 1; + } + + /* Turn on display */ + ssd1351_cmd(0xAF); + + if (static_mode) { + /* Static mode: show image once and exit */ + show_static_image(); + hw_cleanup(); + return 0; + } + + /* Animation mode: Knight Rider scanner */ + int pos = 0; + int dir = 1; + int scanner_width = 20; + + while (running) { + draw_scanner(pos, scanner_width); + + pos += dir * 4; /* Speed */ + if (pos >= WIDTH - scanner_width/2) { + pos = WIDTH - scanner_width/2; + dir = -1; + } else if (pos <= scanner_width/2) { + pos = scanner_width/2; + dir = 1; + } + + msleep(30); /* ~33 FPS */ + } + + hw_cleanup(); + return 0; +} diff --git a/nixos/pkgs/boot-splash.nix b/nixos/pkgs/boot-splash.nix new file mode 100644 index 000000000..9dfad935e --- /dev/null +++ b/nixos/pkgs/boot-splash.nix @@ -0,0 +1,24 @@ +{ pkgs }: + +pkgs.stdenv.mkDerivation { + pname = "boot-splash"; + version = "0.1.0"; + + src = ./.; + + buildInputs = [ pkgs.linuxHeaders ]; + + buildPhase = '' + $CC -O2 -Wall -o boot-splash boot-splash.c + ''; + + installPhase = '' + mkdir -p $out/bin + cp boot-splash $out/bin/ + ''; + + meta = { + description = "Early boot splash for PiFinder OLED display"; + platforms = [ "aarch64-linux" ]; + }; +} diff --git a/nixos/pkgs/cedar-detect-Cargo.lock b/nixos/pkgs/cedar-detect-Cargo.lock new file mode 100644 index 000000000..e5bb4b5eb --- /dev/null +++ b/nixos/pkgs/cedar-detect-Cargo.lock @@ -0,0 +1,2633 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cedar_detect" +version = "0.8.0" +dependencies = [ + "approx", + "clap", + "env_logger", + "image", + "imageproc", + "libc", + "log", + "prctl", + "prost", + "prost-build", + "prost-types", + "tokio", + "tonic", + "tonic-build", + "tonic-web", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imageproc" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.5", + "rand_distr", + "rayon", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.13.0", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prctl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059a34f111a9dee2ce1ac2826a68b24601c4298cfeb1a587c3cb493d5ab46f52" +dependencies = [ + "libc", + "nix", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4ef6dd70a610078cb4e338a0f79d06bc759ff1b22d2120c2ff02ae264ba9c2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn", +] + +[[package]] +name = "tonic-web" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3b0e1cedbf19fdfb78ef3d672cb9928e0a91a9cb4629cc0c916e8cff8aaaa1" +dependencies = [ + "base64", + "bytes", + "http", + "http-body", + "hyper", + "pin-project", + "tokio-stream", + "tonic", + "tower-http", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core 0.5.1", +] diff --git a/nixos/pkgs/cedar-detect.nix b/nixos/pkgs/cedar-detect.nix new file mode 100644 index 000000000..da84d849d --- /dev/null +++ b/nixos/pkgs/cedar-detect.nix @@ -0,0 +1,27 @@ +{ pkgs }: +pkgs.rustPlatform.buildRustPackage rec { + pname = "cedar-detect-server"; + version = "0.5.0-unstable-2026-02-11"; + + src = pkgs.fetchFromGitHub { + owner = "smroid"; + repo = "cedar-detect"; + rev = "da6be9d318976a1a0853ecdf6dd6cefe41615352"; + hash = "sha256-SqWJ35cBOSCu8w5nK2lcdlMWK/bHINatzjr/p+MH3/o="; + }; + + cargoLock.lockFile = ./cedar-detect-Cargo.lock; + + postPatch = '' + ln -s ${./cedar-detect-Cargo.lock} Cargo.lock + ''; + + nativeBuildInputs = [ pkgs.protobuf ]; + + cargoBuildFlags = [ "--bin" "cedar-detect-server" ]; + + meta = { + description = "Cedar Detect star detection gRPC server"; + homepage = "https://github.com/smroid/cedar-detect"; + }; +} diff --git a/nixos/pkgs/pifinder-src.nix b/nixos/pkgs/pifinder-src.nix new file mode 100644 index 000000000..e680425da --- /dev/null +++ b/nixos/pkgs/pifinder-src.nix @@ -0,0 +1,71 @@ +{ pkgs, python ? pkgs.python313, gitRev ? "unknown" }: +let + tetra3-src = pkgs.fetchFromGitHub { + owner = "smroid"; + repo = "cedar-solve"; + rev = "cded265ca1c41e4e526f91e06d3c7ef99bc37288"; + hash = "sha256-eJtBuBmsElEojXLYfYy3gQ/s2+8qjyvOYAqROe4sNO0="; + }; + + # Hipparcos star catalog for starfield plotting + hip_main = pkgs.fetchurl { + url = "https://cdsarc.cds.unistra.fr/ftp/cats/I/239/hip_main.dat"; + sha256 = "1q0n6sa55z92bad8gy6r9axkd802798nxkipjh6iciyn0jqspkjq"; + }; + + # Stable astro data — catalogs, star patterns, ephemeris (~193MB, rarely changes) + astro-data = pkgs.stdenv.mkDerivation { + pname = "pifinder-astro-data"; + version = "1.0"; + src = ../../astro_data; + phases = [ "installPhase" ]; + installPhase = '' + mkdir -p $out + cp -r $src/* $out/ + cp ${hip_main} $out/hip_main.dat + ''; + }; + +in +pkgs.stdenv.mkDerivation { + pname = "pifinder-src"; + version = "0.0.1"; + src = ../..; + + nativeBuildInputs = [ python ]; + phases = [ "installPhase" ]; + + installPhase = '' + mkdir -p $out + + # Copy everything except build artifacts and non-runtime directories + cp -r --no-preserve=mode $src/* $out/ || true + + # Remove directories not needed at runtime + rm -rf $out/.git $out/.github $out/nixos $out/result* $out/.venv + rm -rf $out/case $out/docs $out/gerbers $out/kicad + rm -rf $out/migration_source $out/pi_config_files $out/scripts + rm -rf $out/bin + + # Strip doc photos from images/ but keep welcome.png (used at runtime) + find $out/images -type f ! -name 'welcome.png' -delete + + # Replace astro_data with symlink to stable derivation + rm -rf $out/astro_data + ln -s ${astro-data} $out/astro_data + + # tetra3/cedar-solve is a git submodule — Nix doesn't include submodule + # contents, so we fetch it separately and graft it into the source tree. + rm -rf $out/python/PiFinder/tetra3 + cp -r ${tetra3-src} $out/python/PiFinder/tetra3 + + # Generate build identity from git metadata (overwrites committed CI version) + cat > $out/pifinder-build.json <=1.2.0", "poetry-dynamic-versioning"]' 'requires = ["poetry-core>=1.2.0"]' \ + --replace-fail 'build-backend = "poetry_dynamic_versioning.backend"' 'build-backend = "poetry.core.masonry.api"' + ''; + doCheck = false; + }; + + pydeepskylog = self.buildPythonPackage rec { + pname = "pydeepskylog"; + version = "1.6"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-3erm0ASBfPtQ1cngzsqkZUrnKoLNIBu8U1D6iA4ePmE="; + }; + propagatedBuildInputs = [ self.requests ]; + doCheck = false; + }; + + python-pam = self.buildPythonPackage rec { + pname = "python-pam"; + version = "2.0.2"; + format = "pyproject"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-lyNSNbqbgtuugGjRCZUIRVlJsnX3cnPKIv29ix+12VA="; + }; + nativeBuildInputs = [ self.setuptools self.six ]; + postPatch = '' + substituteInPlace src/pam/__internals.py \ + --replace-fail 'find_library("pam")' '"${pkgs.pam}/lib/libpam.so"' \ + --replace-fail 'find_library("pam_misc")' '"${pkgs.pam}/lib/libpam_misc.so"' + ''; + doCheck = false; + }; + + # --- Display stack: luma.core -> luma.oled, luma.lcd --- + + luma-core = self.buildPythonPackage rec { + pname = "luma.core"; + version = "2.4.2"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-ljwmQWTUN09UnVfbCVmeDKRYzqG9BeFpOYl2Gb5Obb0="; + }; + propagatedBuildInputs = [ + self.pillow + self.smbus2 + self.pyftdi + self.cbor2 + self.deprecated + ]; + dontCheckRuntimeDeps = true; + doCheck = false; + }; + + luma-oled = self.buildPythonPackage rec { + pname = "luma.oled"; + version = "3.13.0"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-fioNakyWjGSYAlXWgewnkU2avVpmqQGbKJvzrQUMISU="; + }; + propagatedBuildInputs = [ self.luma-core ]; + doCheck = false; + }; + + pyhotkey = self.buildPythonPackage rec { + pname = "PyHotKey"; + version = "1.5.2"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-ObV5wDjnhQwmqmfMH5F9VUbJ2XPOYKuZH9OGodSdGrQ="; + }; + propagatedBuildInputs = [ self.pynput ]; + pythonRelaxDeps = [ "pynput" ]; + nativeBuildInputs = [ pkgs.python313Packages.pythonRelaxDepsHook ]; + doCheck = false; + }; + + luma-emulator = self.buildPythonPackage rec { + pname = "luma.emulator"; + version = "1.5.0"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-0PCbFz9BQmXadpL+THw348tU9PgTjhNfixtHFeN4248="; + }; + propagatedBuildInputs = [ self.luma-core self.pygame ]; + doCheck = false; + }; + + luma-lcd = self.buildPythonPackage rec { + pname = "luma.lcd"; + version = "2.11.0"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-1GBE6W/TmUPr5Iph51M3FXG+FJekvqlrcuOpxzL77uQ="; + }; + propagatedBuildInputs = [ self.luma-core ]; + doCheck = false; + }; + + python-libinput = self.buildPythonPackage rec { + pname = "python-libinput"; + version = "0.3.0a0"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-fj08l4aqp5vy8UYBZIWBtGJLaS0/DZGZkC0NCDQhkwI="; + }; + buildInputs = [ pkgs.libinput pkgs.systemd ]; + nativeBuildInputs = [ pkgs.pkg-config ]; + propagatedBuildInputs = [ self.cffi ]; + postPatch = '' + substituteInPlace setup.py \ + --replace-fail 'from imp import load_source' 'import importlib.util, types +def load_source(name, path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod' + substituteInPlace libinput/__init__.py \ + --replace-fail "CDLL('libudev.so.1')" "CDLL('${lib.getLib pkgs.systemd}/lib/libudev.so.1')" \ + --replace-fail "CDLL('libinput.so.10')" "CDLL('${lib.getLib pkgs.libinput}/lib/libinput.so.10')" + ''; + doCheck = false; + }; + + # --- Hardware-only packages (aarch64/Pi) --- + + RPi-GPIO = self.buildPythonPackage rec { + pname = "RPi.GPIO"; + version = "0.7.1"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-zWHEsDw3tiu6SlrP6phidJwzxhjgKV5+kKpHE/s3O3A="; + }; + postPatch = '' + python3 -c ' + import sys + with open("source/cpuinfo.c") as f: src = f.read() + old = " if (!found)\n return -1;" + new = ( + " if (!found) {\n" + " FILE *fp2 = fopen(\"/proc/device-tree/compatible\", \"r\");\n" + " if (fp2) {\n" + " char compat[256] = {0};\n" + " fread(compat, 1, sizeof(compat)-1, fp2);\n" + " fclose(fp2);\n" + " if (strstr(compat, \"raspberrypi\")) {\n" + " found = 1;\n" + " strcpy(revision, \"c03115\");\n" + " }\n" + " }\n" + " }\n" + "\n" + " if (!found)\n" + " return -1;" + ) + assert old in src, "pattern not found in cpuinfo.c" + src = src.replace(old, new, 1) + with open("source/cpuinfo.c", "w") as f: f.write(src) + ' + ''; + doCheck = false; + }; + + rpi-hardware-pwm = self.buildPythonPackage rec { + pname = "rpi-hardware-pwm"; + version = "0.3.0"; + pyproject = true; + build-system = [ self.setuptools ]; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/be/0c/4308050d8b6bbe24e8e54b38e48b287b1e356efce33cd485ee4387fc92a9/rpi_hardware_pwm-0.3.0.tar.gz"; + hash = "sha256-HshwYzp5XpijEGhWXwZ/gvZKjhZ4BpvPjdcC+i+zGyY="; + }; + doCheck = false; + }; + + adafruit-circuitpython-typing = self.buildPythonPackage rec { + pname = "adafruit-circuitpython-typing"; + version = "1.12.3"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/65/a2/40a3440aed2375371507af668570b68523ee01db9c25c47ce5a05883170e/adafruit_circuitpython_typing-1.12.3.tar.gz"; + hash = "sha256-Y/GW+DTkeEK81M+MN6qgxh4a610H8FbIdfwwFs2pGhI="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ self.typing-extensions ]; + doCheck = false; + dontCheckRuntimeDeps = true; + }; + + adafruit-platformdetect = self.buildPythonPackage rec { + pname = "Adafruit-PlatformDetect"; + version = "3.73.0"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/3c/83/79eb6746d01d64bd61f02b12a2637fad441f7823a4f540842e0a47dbcfd8/adafruit_platformdetect-3.73.0.tar.gz"; + hash = "sha256-IwkJityP+Hs9mkpdOu6+P3t/VasOE9Get1/6hl82+rg="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + doCheck = false; + }; + + adafruit-pureio = self.buildPythonPackage rec { + pname = "Adafruit-PureIO"; + version = "1.1.11"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/e5/b7/f1672435116822079bbdab42163f9e6424769b7db778873d95d18c085230/Adafruit_PureIO-1.1.11.tar.gz"; + hash = "sha256-xM+7NlcxlC0fEJKhFvR9/a4K7xjFsn8QcrWCStXqjHw="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + doCheck = false; + }; + + adafruit-blinka = self.buildPythonPackage rec { + pname = "Adafruit-Blinka"; + version = "8.47.0"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/4a/30/84193a19683732387ec5f40661b589fcee29e0ab47c1e7dee36fb92efe9b/adafruit_blinka-8.47.0.tar.gz"; + hash = "sha256-Q2qFasw4v5xTRtuMQTuiraledi9qqXp9viOENMy8hRk="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ + self.RPi-GPIO + self.adafruit-platformdetect + self.adafruit-pureio + self.adafruit-circuitpython-typing + ]; + pythonRelaxDeps = true; + pythonRemoveDeps = [ "binho-host-adapter" "pyftdi" "sysv-ipc" ]; + dontCheckRuntimeDeps = true; + doCheck = false; + }; + + adafruit-circuitpython-busdevice = self.buildPythonPackage rec { + pname = "adafruit-circuitpython-busdevice"; + version = "5.2.9"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/a8/04/cf8d2ebfe0d171b7c8fe3425f1e2e80ed59738855d419e5486f5d2fa9145/adafruit_circuitpython_busdevice-5.2.9.tar.gz"; + hash = "sha256-n5w984UJFBDaxZYZGOR17Ij67X/1Q61tdCCPCMJWZRM="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ + self.adafruit-blinka + self.adafruit-circuitpython-typing + ]; + doCheck = false; + }; + + adafruit-circuitpython-register = self.buildPythonPackage rec { + pname = "adafruit-circuitpython-register"; + version = "1.10.0"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/0f/f1/b7e16545dac1056227ca9c612966ec26d69a04a99df6892aec27a71884af/adafruit_circuitpython_register-1.10.0.tar.gz"; + hash = "sha256-vH6191d2bxAqhyZXPgylwp6h1+UBweN1nGxOnhNmD3o="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ + self.adafruit-blinka + self.adafruit-circuitpython-busdevice + self.adafruit-circuitpython-typing + ]; + doCheck = false; + }; + + adafruit-circuitpython-bno055 = self.buildPythonPackage rec { + pname = "adafruit-circuitpython-bno055"; + version = "5.4.16"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/8d/20/ad6bb451c5bf228af869bf045d4fc415174e7c042dfc1d998e9c0bc8ad21/adafruit_circuitpython_bno055-5.4.16.tar.gz"; + hash = "sha256-kL/bz689GF/sZxgbzv+bEPQ4F5zQqjl+k4ctSwlK3aA="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ + self.adafruit-blinka + self.adafruit-circuitpython-busdevice + self.adafruit-circuitpython-register + self.adafruit-circuitpython-typing + ]; + doCheck = false; + }; + + pidng = self.buildPythonPackage rec { + pname = "pidng"; + version = "4.0.9"; + pyproject = true; + build-system = [ self.setuptools ]; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/source/p/pidng/pidng-4.0.9.tar.gz"; + hash = "sha256-Vg6wCAhvinFf2eGrmYgXp9TIUAp/Fhuc5q9asnUB+Cw="; + }; + propagatedBuildInputs = [ self.numpy ]; + doCheck = false; + }; + + simplejpeg = self.buildPythonPackage rec { + pname = "simplejpeg"; + version = "1.9.0"; + format = "wheel"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/88/8b/d8ca384f1362371d61690d7460d3ae4cec4a5a25d9eb06cd15623de3725a/simplejpeg-1.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"; + hash = "sha256-oMN1Ew9zuwgimj3tOS2E7i2Raz6H5+xdKsTke3FENGo="; + }; + propagatedBuildInputs = [ self.numpy ]; + doCheck = false; + }; + + healpy = self.buildPythonPackage rec { + pname = "healpy"; + version = "1.19.0"; + format = "wheel"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/f6/d4/a60ed9a50768ff5e896dd94d878496ae16767925ea32c49d5a4189ab818a/healpy-1.19.0-cp313-cp313-manylinux_2_28_aarch64.whl"; + hash = "sha256-kgwKHGdJwFyK2VIqWiYw97yDEkxXQu9Q+RufXmob3Mc="; + }; + propagatedBuildInputs = [ self.numpy self.astropy self.matplotlib ]; + doCheck = false; + }; + + python-prctl = self.buildPythonPackage rec { + pname = "python-prctl"; + version = "1.8.1"; + pyproject = true; + build-system = [ self.setuptools ]; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/source/p/python-prctl/python-prctl-1.8.1.tar.gz"; + hash = "sha256-tMqaJafU8azk//0fOi5k71II/gX5KfPt1eJwgcp+Z84="; + }; + buildInputs = [ pkgs.libcap ]; + doCheck = false; + }; + + v4l2-python3 = self.buildPythonPackage rec { + pname = "v4l2-python3"; + version = "0.3.4"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-YliResgEmsaYcaXg39bYnVXJ5/gOgSwe+LqIeb2hxYc="; + }; + doCheck = false; + }; + + videodev2 = self.buildPythonPackage rec { + pname = "videodev2"; + version = "0.0.4"; + format = "wheel"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/68/30/4982441a03860ab8f656702d8a2c13d0cf6f56d65bfb78fe288028dcb473/videodev2-0.0.4-py3-none-any.whl"; + hash = "sha256-0196s53bBtUP7Japm/yNW4tSW8fqA3iCWdOGOT8aZLo="; + }; + doCheck = false; + }; + + picamera2 = self.buildPythonPackage rec { + pname = "picamera2"; + version = "0.3.22"; + pyproject = true; + build-system = [ self.setuptools ]; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-iShpgUNCu8uHS7jeehtgWJhEm/UhJjn0bw2qpkbWgy0="; + }; + postPatch = '' + substituteInPlace picamera2/previews/__init__.py \ + --replace-fail 'from .drm_preview import DrmPreview' \ + 'try: + from .drm_preview import DrmPreview +except ImportError: + DrmPreview = None' + ''; + propagatedBuildInputs = [ + self.numpy + self.pillow + self.piexif + self.v4l2-python3 + self.videodev2 + self.pidng + self.simplejpeg + self.python-prctl + pkgs.libcamera + ]; + postFixup = '' + wrapPythonProgramsIn "$out" "$out ${pkgs.libcamera}/lib/python3.13/site-packages" + ''; + dontCheckRuntimeDeps = true; + doCheck = false; + }; + }; + }; + + commonPackages = ps: with ps; [ + # Packages from nixpkgs + numpy + scipy + scikit-learn + pillow + pandas + grpcio + protobuf + bottle + cheroot + requests + pytz + skyfield + tqdm + pyjwt + aiofiles + json5 + smbus2 + spidev + pygobject3 + av + dbus-python + timezonefinder + jsonschema + libarchive-c + + # Custom packages (cross-platform) + sh + gpsdclient + dataclasses-json + pydeepskylog + python-pam + luma-oled + luma-lcd + python-libinput + ]; + + hardwarePackages = ps: with ps; [ + RPi-GPIO + rpi-hardware-pwm + adafruit-blinka + adafruit-circuitpython-bno055 + picamera2 + pidng + simplejpeg + python-prctl + videodev2 + healpy + ]; + + devPackages = ps: with ps; [ + pytest + mypy + luma-emulator + pyhotkey + ]; + + pifinderEnv = pifinderPython.withPackages (ps: + commonPackages ps ++ hardwarePackages ps + ); + + devEnv = pifinderPython.withPackages (ps: + commonPackages ps ++ devPackages ps + ); + +in { + inherit pifinderPython commonPackages hardwarePackages devPackages pifinderEnv devEnv; +} diff --git a/nixos/pkgs/welcome_image.h b/nixos/pkgs/welcome_image.h new file mode 100644 index 000000000..ef8cfc2ff --- /dev/null +++ b/nixos/pkgs/welcome_image.h @@ -0,0 +1,1027 @@ +// Auto-generated from welcome.png - 128x128 BGR565 +static const uint16_t welcome_image[16384] = { + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, + 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, + 0x6800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, + 0x6800, 0x6800, 0x7000, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6800, 0x6800, 0x7000, + 0x7000, 0x5800, 0x5800, 0x6000, 0x6800, 0x7800, 0x6000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6800, + 0x6000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, + 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x4000, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, + 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x7800, 0x8000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, + 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x8000, 0x9000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x4000, 0x4000, 0x4800, 0x4800, + 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, + 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x5000, 0x5800, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6800, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x6800, 0x6800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x6800, 0x6000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x5000, 0x6000, 0x6800, 0x7800, 0x8000, 0x6000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x6000, 0x8000, 0x7800, 0x7000, 0x6800, 0x5800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3000, 0x4800, 0x6000, 0x7800, 0x8800, 0x9000, 0x8000, 0x7000, 0x6800, 0x5800, 0x5000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x5800, 0x6000, 0x7000, 0x7800, 0x8800, 0xA000, 0xA000, 0x9000, 0x8000, 0x6800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6800, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x5000, 0x7000, + 0x8800, 0x8000, 0x6800, 0x5800, 0x4800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5800, 0x7000, 0x9000, 0xA800, 0xB000, 0x9000, 0x7000, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x7000, 0x6800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x6800, 0x8800, 0x7800, 0x6000, + 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x5000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x6000, 0x6800, 0x8000, 0x9800, + 0xA800, 0x9000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x8000, 0x6800, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x7800, 0x8000, 0x6000, 0x3800, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5800, 0x6000, 0x6000, 0x5800, 0x5000, + 0x6000, 0x8000, 0xA000, 0x9800, 0x7000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5800, 0x8000, 0x7000, 0x5000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5800, 0x7000, 0x9800, 0xA000, 0x7800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x8000, 0x7000, 0x4000, 0x3000, 0x3000, 0x4800, 0x3800, 0x3800, 0x3800, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x6000, 0x9000, 0xA000, 0x7000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, + 0x3000, 0x3000, 0x3000, 0x4800, 0x7800, 0x7800, 0x4000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x6800, 0x9800, 0x9800, 0x8000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x7000, 0xA800, 0x8000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, 0x7800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3000, 0x3000, 0x6800, 0x8000, 0x5000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x4800, 0x5000, 0x5000, 0x6000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, 0xA800, 0x8800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x6800, 0x9800, 0x8000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x4800, 0x8000, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x8800, 0xA000, 0x6800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x8000, 0x4800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xA000, 0x8000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, + 0x8800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3800, 0x4000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x9000, 0x9800, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6800, + 0x7800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x7800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x8000, + 0xA000, 0x6800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x7000, 0x5000, 0x5800, 0x6000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x8800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x7000, 0xA000, 0x7000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, + 0x5000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x4000, 0x5000, 0x6000, + 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x5000, 0x6800, 0xA800, 0x7800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x3000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x8800, + 0xA800, 0x4000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x5800, + 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x6800, 0xA800, 0x7800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x4800, + 0x5800, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x6800, 0xA800, 0x7800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x3800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, + 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xA800, 0x7000, 0x5800, 0x7000, 0x6800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x4000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x4000, 0x4800, 0x5000, 0x4800, 0x4800, 0x6000, 0x9800, 0x5000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xA800, 0x6800, 0x6000, 0x6000, 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x6000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, 0xA800, 0x6800, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x8000, 0xA000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0xA000, 0x9000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xB000, 0x7800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, + 0x7000, 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x4000, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x7800, 0xB000, 0x6000, 0x5800, 0x5800, 0x5000, 0x6800, + 0x9800, 0x6800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3800, 0x6000, 0x3800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4800, 0x4000, 0x5000, 0x5000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, + 0x4000, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x9000, 0x8800, 0x5800, 0x5800, 0x5000, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x5800, 0x6800, 0x5800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5000, 0x6800, 0x6000, 0x4800, 0x3800, 0x4000, 0x3800, 0x5800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5800, 0x4000, 0x4000, + 0x4000, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0xA000, 0x6800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5000, 0x7000, 0x8000, 0x8000, 0x6800, 0x5800, 0x4000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x5800, 0x6800, 0x8000, 0x8800, 0x7000, 0x5800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4800, 0x6000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x6800, 0x9000, 0x5000, 0x5000, 0x5000, + 0x4800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x5800, 0x8000, 0x7000, 0x5800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x4000, 0x5800, 0x7800, 0x8800, 0x6000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x9000, 0x7000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x6800, 0x6800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x4800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x7800, 0x6000, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x5800, 0x8000, 0x6800, 0x4000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3800, 0x4000, 0x5000, 0x7000, 0x8800, 0x6800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x5000, 0x8000, 0x5000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x5800, 0x9800, 0x4800, 0x4800, + 0x4000, 0x4000, 0x8000, 0x7800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x7000, 0x7000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x4800, + 0x7800, 0x6800, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x4000, 0x7000, 0x8800, 0x5000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x8800, 0x7000, 0x4800, + 0x4800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5000, + 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x9000, 0x8800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x6000, 0x7800, + 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x8000, 0x6800, + 0x3800, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x5800, 0x9800, 0x4800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0x6800, 0x3000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x7000, + 0x7800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x8800, 0x6800, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x4800, 0x4800, 0x4800, 0x5800, 0x5800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x7800, 0x5800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x6000, 0x8000, 0x4000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x6000, 0x9000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x4800, 0x4800, 0x4800, 0x5800, 0x5800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x7000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x7800, 0x5000, 0x2800, 0x2800, 0x2800, + 0x3000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x5800, 0x8800, 0x4000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x9000, + 0x5000, 0x3800, 0x3800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7800, 0x5000, 0x2800, 0x2800, 0x3000, 0x2800, + 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5800, 0x8000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x6800, + 0x7000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6000, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x4800, + 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x5800, 0x2800, 0x2800, 0x3800, 0x4000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x6000, 0x7800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x4800, + 0x8800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x6800, 0x6000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, + 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, 0x6800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x7000, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x8000, 0x5000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x7800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x8000, 0x5000, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x6000, 0x7000, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x4800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x8800, 0x7000, 0x5000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x7800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x8000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4800, 0x8800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0xA000, 0x7800, 0x5000, 0x4800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, 0x6800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x7000, 0x8000, 0x5000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x8800, 0x4800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x8000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0xD000, 0x9000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x7000, 0x6000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x7000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x7000, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x5800, 0x7800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x8000, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x5800, + 0x4000, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3800, 0x8000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x8800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, + 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x4000, 0xA000, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0x5800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3800, 0x6000, + 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x7800, 0x5000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x9000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x7000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2000, 0x2800, 0x6800, 0x5800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x4000, 0x2800, 0x3000, 0x6000, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x5000, 0x6800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, + 0x3000, 0x3800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x5800, 0x7000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x7800, 0x5000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x5000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x7000, 0x6800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2000, 0x8000, 0x2800, 0x2000, 0x4000, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x5800, 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x5800, 0x6000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x8800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x7000, 0x5800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x5800, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x3800, 0x7000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x8000, 0x4800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x6000, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x4800, 0x5800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3800, 0x3000, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x6800, 0x5800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5800, 0x7000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x5800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3000, + 0x9000, 0x6000, 0x2000, 0x2800, 0x2800, 0x2800, 0x5800, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x5800, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5000, 0x7800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x2800, 0x2800, 0x2800, 0x4800, 0x3000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, + 0x7000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x4000, 0x5800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x4000, 0x6000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x4800, + 0x5800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x6000, 0x7000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x6000, 0x6000, 0x6000, + 0x5000, 0x6000, 0x4000, 0x5800, 0x6000, 0x5000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5000, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0xF800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x8800, 0x9000, 0x7800, 0x4000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0xA000, 0xA800, 0xA800, 0xA800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x5800, 0x6000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x8000, 0xA000, 0x8000, + 0x6000, 0xB000, 0x6800, 0x5800, 0x9800, 0x8000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0xB800, 0xF800, 0xF800, 0xE000, 0xD000, + 0xC000, 0xB800, 0xB800, 0xC000, 0xC000, 0xB800, 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0xF800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7800, 0x9800, 0x9800, 0xA000, 0xA000, 0x9800, 0x9800, 0x9800, 0x9000, 0x9800, 0x9800, + 0x9800, 0x9000, 0x7800, 0x2800, 0x2800, 0x8800, 0x9000, 0x9000, 0x9000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0xA000, 0xB000, 0xA800, 0xA800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x9800, 0x4000, + 0x4000, 0xA000, 0x9000, 0x5000, 0xB000, 0x8000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, + 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0xB800, 0xF800, 0xF800, 0xF000, 0xE000, + 0xC800, 0xC000, 0xC000, 0xC000, 0xC800, 0xC800, 0xC000, 0xB800, 0x7800, 0x2800, 0x2800, 0x3800, 0x4800, 0xF800, 0xF800, 0xF800, + 0xF800, 0xF800, 0x2800, 0x2000, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x9800, 0xA000, 0x9800, 0x9800, 0x9800, 0x9800, 0x9800, + 0x9800, 0x9800, 0x7800, 0x2800, 0x2800, 0x9000, 0x9000, 0x9000, 0x9000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0xA800, 0xB000, 0xB000, 0xB800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4800, 0x9800, 0x4000, + 0x4000, 0xA800, 0x9800, 0x8800, 0x8800, 0x8000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0xB800, 0xF800, 0xF800, 0xF800, 0xF000, + 0xE000, 0xD000, 0xC800, 0xD000, 0xC800, 0xC800, 0xC800, 0xC000, 0xB000, 0x8800, 0x2000, 0x8000, 0xA800, 0x4800, 0x2000, 0xF800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x9800, 0xA000, 0xA000, 0xA000, 0xA000, 0xA000, 0xA000, + 0xA000, 0xA000, 0x8800, 0x2800, 0x2800, 0x5000, 0x9000, 0x9000, 0x7800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x5000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0xA800, 0xB800, 0xB800, 0xB800, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x9800, 0x4000, + 0x4000, 0xB000, 0x7000, 0xA800, 0x6800, 0x8000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0xB000, 0xF800, 0xF800, 0xF800, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0xA000, 0xC800, 0xC000, 0xB800, 0xB000, 0x6800, 0x4000, 0x5000, 0x3000, 0x2000, 0xF800, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x7000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x5000, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0xA800, 0xB800, 0xB800, 0xB800, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x3800, 0x4000, 0x5800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x6000, 0x4000, + 0x4000, 0x6000, 0x4000, 0x5800, 0x4800, 0x5000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, + 0x2000, 0x2000, 0x2000, 0x4800, 0x3000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0xA800, 0xF000, 0xF800, 0xF800, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0xA800, 0xC000, 0xB800, 0xB000, 0xA000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x7800, 0x2800, 0x2000, 0x3000, 0x4000, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4800, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, + 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x5800, 0x3800, 0x3800, 0xB000, 0xB800, 0xC000, 0xC000, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x3800, 0x4000, 0x5800, 0x4800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, + 0x2000, 0x2000, 0x2000, 0x5800, 0x4800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0xA000, 0xE800, 0xF800, 0xF800, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x8800, 0xC000, 0xB800, 0xB800, 0xB800, 0x3000, 0x2000, 0x5800, 0xB000, 0xA000, + 0x9800, 0x8800, 0x2800, 0x2800, 0x2800, 0x7000, 0x9000, 0x9800, 0x9800, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x8800, 0xA000, 0xA000, 0xA000, 0x7000, 0x6000, 0x2800, 0x6000, 0x8800, 0x8800, 0x9000, + 0x5800, 0x4000, 0x6000, 0x9000, 0x9000, 0x9800, 0x8800, 0x6000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x6000, + 0xA000, 0xC800, 0xC800, 0xD000, 0x8800, 0x3800, 0xB000, 0xC000, 0xC000, 0xC000, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x7000, 0x9000, 0xB800, 0xB800, 0xB800, 0xA000, 0x8000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0xB000, 0xB800, 0xA800, + 0xA000, 0x4800, 0x6800, 0x9000, 0x9800, 0x8000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, + 0x2000, 0x2000, 0x2800, 0x4800, 0x5800, 0x2000, 0x4000, 0x3800, 0x2000, 0x2800, 0x2800, 0x9800, 0xE000, 0xE800, 0xF000, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0xB800, 0xB800, 0xB000, 0xB000, 0x4000, 0x2000, 0x6000, 0xB800, 0xB000, + 0xA000, 0x8800, 0x2000, 0x2800, 0x2800, 0x7800, 0x9800, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x9000, 0xA800, 0xA800, 0xA000, 0x7800, 0x5000, 0x2800, 0x6000, 0x9000, 0x9000, 0x9000, + 0x7800, 0x9000, 0x9000, 0x9000, 0x9000, 0x9000, 0x9800, 0x9800, 0x6800, 0x3000, 0x3000, 0x4000, 0x3000, 0x3000, 0x7000, 0xC000, + 0xC000, 0xC800, 0xC800, 0xC800, 0xD000, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0x7000, 0x4000, 0x4000, 0x4000, 0x4800, 0xA800, + 0xC000, 0xC000, 0xC000, 0xC000, 0xB800, 0xB800, 0xB800, 0xA800, 0x5000, 0x4000, 0x4000, 0x4800, 0x4800, 0xB000, 0xB800, 0xA800, + 0xA000, 0x7000, 0xA000, 0xA000, 0xA000, 0x8800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, + 0x2000, 0x2800, 0x2000, 0x3800, 0x7000, 0x2000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x9800, 0xD000, 0xD800, 0xE000, 0xB000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x5000, 0xB800, 0xB800, 0xB000, 0xB000, 0x3000, 0x2000, 0x6000, 0xC800, 0xC000, + 0xB000, 0x9000, 0x2800, 0x2800, 0x4000, 0x8800, 0xA000, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x9000, 0xA800, 0xA800, 0xA800, 0x8800, 0x4000, 0x2800, 0x6800, 0x9000, 0x9800, 0x9800, + 0xA000, 0xA000, 0x9800, 0x9000, 0x9000, 0x9000, 0x9800, 0xA000, 0xA000, 0x4000, 0x3000, 0x4000, 0x4000, 0x5800, 0xB800, 0xB800, + 0xC000, 0xC000, 0xC800, 0xC800, 0xC800, 0xD000, 0xC800, 0xC000, 0xC000, 0xC000, 0x6800, 0x4000, 0x4000, 0x4000, 0xC800, 0xD000, + 0xC800, 0xC800, 0xC000, 0xC000, 0xB800, 0xB800, 0xB800, 0xB800, 0xA800, 0x4800, 0x4000, 0x4000, 0x4800, 0xA800, 0xB800, 0xA800, + 0xA800, 0xA000, 0xA000, 0xA000, 0xA800, 0x9800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2000, 0x2800, 0x7800, 0x3000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x9800, 0xD000, 0xD000, 0xD800, 0xA800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x8800, 0xA800, 0xB000, 0xB800, 0xA800, 0x2800, 0x2000, 0x6000, 0xD000, 0xC800, + 0xB800, 0xA000, 0x2800, 0x5800, 0x7800, 0x8000, 0xA000, 0xA800, 0xA800, 0x8000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x9000, 0xA800, 0xA800, 0xA800, 0x9000, 0x3000, 0x2800, 0x6800, 0xA000, 0xA000, 0xA000, + 0xA000, 0x7800, 0x2800, 0x2800, 0x5000, 0x9000, 0x9800, 0x9800, 0xA000, 0x7000, 0x4000, 0x6800, 0x6000, 0x9800, 0xB000, 0xB800, + 0xB800, 0xA800, 0x8000, 0x5800, 0x5000, 0xA800, 0xC800, 0xC000, 0xC000, 0xC000, 0x6800, 0x4000, 0x4000, 0xA000, 0xE800, 0xD800, + 0xD000, 0xC000, 0x7800, 0x3800, 0x4800, 0xA000, 0xC000, 0xC000, 0xC000, 0x8000, 0x4000, 0x4000, 0x4000, 0xA800, 0xB800, 0xA800, + 0xA800, 0xA000, 0x7000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x4800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x9000, 0xC000, 0xC800, 0xD000, 0xA800, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x8000, 0xA800, 0xA000, 0xA800, 0xB000, 0x8800, 0x5000, 0x2800, 0x6000, 0xC800, 0xC800, + 0xC000, 0xA800, 0x4800, 0x6000, 0x3000, 0x7800, 0xA800, 0xB000, 0xB800, 0xB800, 0xB800, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, + 0xC000, 0x9800, 0x2800, 0x2800, 0x2800, 0x9800, 0xB000, 0xA800, 0xA800, 0x8000, 0x2800, 0x2800, 0x7000, 0xA800, 0xA800, 0xA800, + 0x8800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0xA000, 0xA000, 0xA000, 0x8000, 0x5000, 0x9000, 0x5800, 0xA800, 0xA800, 0xB000, + 0xB800, 0x4800, 0x7800, 0x4800, 0x3800, 0x4000, 0xB800, 0xC000, 0xC000, 0xC800, 0x6800, 0x4000, 0x4000, 0xE800, 0xF000, 0xE000, + 0xD000, 0x8000, 0x3800, 0x3800, 0x3800, 0x4800, 0xB800, 0xC000, 0xC000, 0xB800, 0x4000, 0x4000, 0x4000, 0xA800, 0xB000, 0xA800, + 0xA800, 0x6800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x6800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x8000, 0xA800, 0xB800, 0xC000, 0xD000, + 0xF000, 0xF800, 0xF800, 0xE800, 0xD000, 0xB800, 0xA800, 0xA000, 0xA000, 0x9000, 0x2000, 0x2800, 0x2800, 0x6000, 0xC000, 0xC000, + 0xC000, 0xB000, 0x2800, 0x2800, 0x2800, 0x8000, 0xA800, 0xB800, 0xC000, 0xC800, 0xC800, 0xC800, 0xC800, 0xC800, 0xC800, 0xC800, + 0xC800, 0xA000, 0x2800, 0x2800, 0x2800, 0x9800, 0xB800, 0xB000, 0xB000, 0x7800, 0x2800, 0x2800, 0x7000, 0xA800, 0xB000, 0xB000, + 0x8800, 0x2800, 0x2800, 0x2800, 0x2800, 0x6800, 0xA800, 0xA800, 0xA800, 0x9000, 0x3000, 0x2800, 0x6000, 0xA800, 0xA800, 0xA800, + 0x9800, 0x3000, 0x8800, 0x3800, 0x3000, 0x3800, 0xB000, 0xC000, 0xC000, 0xC000, 0x7000, 0x4000, 0x7000, 0xF000, 0xF000, 0xE000, + 0xD000, 0x4800, 0x3800, 0x3800, 0x3800, 0x4000, 0xA000, 0xC000, 0xC000, 0xC000, 0x6000, 0x4000, 0x4000, 0xA800, 0xB000, 0xB000, + 0xB000, 0x5800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5800, 0x5800, 0x4800, 0x5000, 0x5800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7800, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x7000, 0x9000, 0xA000, 0xB000, 0xC800, + 0xE000, 0xF000, 0xF000, 0xE000, 0xD000, 0xC000, 0xB000, 0xA000, 0x7800, 0x3000, 0x2000, 0x2000, 0x2000, 0x5800, 0xC000, 0xB800, + 0xB800, 0xA800, 0x2000, 0x2800, 0x2800, 0x8000, 0xA800, 0xB800, 0xC000, 0xC800, 0xD000, 0xD000, 0xC800, 0xD000, 0xD800, 0xE000, + 0xD800, 0xA800, 0x2800, 0x2800, 0x2800, 0xA000, 0xB800, 0xB800, 0xB800, 0x6800, 0x2000, 0x2000, 0x7000, 0xB000, 0xB800, 0xB800, + 0x8800, 0x2800, 0x2800, 0x3000, 0x3800, 0x6800, 0xA800, 0xB000, 0xB000, 0x9000, 0x3000, 0x2800, 0x7000, 0xA800, 0xA800, 0xA800, + 0x8000, 0x3800, 0x8000, 0x3000, 0x3000, 0x3800, 0xB000, 0xB800, 0xB800, 0xB800, 0x6800, 0x3800, 0x8800, 0xE800, 0xE800, 0xE000, + 0xC000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x9800, 0xC800, 0xC000, 0xC000, 0x7000, 0x4000, 0x4000, 0xA800, 0xB800, 0xB800, + 0xB800, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x5000, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, 0x6800, 0x9000, 0x9800, 0xA800, 0xC000, + 0xD000, 0xD800, 0xE000, 0xD800, 0xD000, 0xC000, 0x7800, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xC000, 0xB800, + 0xB800, 0xA800, 0x2000, 0x2000, 0x2000, 0x7800, 0xA800, 0xB800, 0xC000, 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0xA800, 0xC000, 0xC000, 0xC000, 0x7000, 0x2000, 0x2000, 0x7800, 0xB800, 0xB800, 0xB800, + 0x8800, 0x2800, 0x2800, 0x3000, 0x3800, 0x6800, 0xA000, 0xA800, 0xB800, 0x9000, 0x3000, 0x3000, 0x7000, 0xA800, 0xA800, 0xA800, + 0x7000, 0x5000, 0x6800, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xB800, 0xC000, 0x6800, 0x3800, 0x9000, 0xE000, 0xE000, 0xE000, + 0xD800, 0xD000, 0xD000, 0xD800, 0xD800, 0xD800, 0xD800, 0xD000, 0xC800, 0xC000, 0x8000, 0x4000, 0x4000, 0xA800, 0xC000, 0xC000, + 0xC000, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x6800, 0x6800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x7800, 0x3000, 0x2800, 0x2000, 0x2000, 0x7000, 0x9800, 0x9800, 0xA000, 0x8800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xC000, 0xB800, + 0xB000, 0xA000, 0x2000, 0x2000, 0x2000, 0x8000, 0xB000, 0xB800, 0xC000, 0x9000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0xB000, 0xD000, 0xD000, 0xC800, 0x7000, 0x1800, 0x2000, 0x7800, 0xC000, 0xC000, 0xC000, + 0x9000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6800, 0xA000, 0xA000, 0xA800, 0x8800, 0x3000, 0x3000, 0x7000, 0xA000, 0xA000, 0xA000, + 0x7000, 0x6000, 0x5800, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xC000, 0xC000, 0x6800, 0x3800, 0x8800, 0xD800, 0xD800, 0xD800, + 0xD000, 0xD000, 0xD000, 0xD800, 0xD800, 0xE000, 0xE000, 0xE000, 0xD800, 0xD000, 0x8000, 0x4000, 0x4000, 0xB000, 0xC000, 0xC000, + 0xC800, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5000, 0x6000, 0x2000, 0x2000, 0x2000, 0x7800, 0xA000, 0xA000, 0xA000, 0x8000, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xB800, 0xB000, + 0xA800, 0xA000, 0x2800, 0x2800, 0x2800, 0x8000, 0xB000, 0xB000, 0xB800, 0x9000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0xB000, 0xD800, 0xE000, 0xE800, 0x7800, 0x2800, 0x2000, 0x8000, 0xC000, 0xC000, 0xC000, + 0x9000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x7000, 0xA000, 0xA000, 0xA800, + 0x8000, 0x7800, 0x4000, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xC000, 0xC800, 0x6800, 0x3800, 0x8800, 0xD000, 0xD000, 0xD000, + 0xD000, 0xD000, 0xD000, 0xD800, 0xD800, 0xE000, 0xE000, 0xE000, 0xE000, 0xD800, 0x8800, 0x3800, 0x4000, 0xB800, 0xC000, 0xC800, + 0xC800, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x7000, 0x3800, 0x2000, 0x2000, 0x7800, 0x9800, 0x9800, 0xA000, 0x8000, + 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5000, 0xB000, 0xB000, + 0xA800, 0x9800, 0x2000, 0x2000, 0x2800, 0x8000, 0xA800, 0xA800, 0xB000, 0x8800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0xB800, 0xE000, 0xF000, 0xF000, 0x9800, 0x3800, 0x2000, 0x8000, 0xC800, 0xC800, 0xC800, + 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x6000, 0xA800, 0xA800, 0xA800, + 0x9800, 0x8000, 0x3000, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xC000, 0xC800, 0x6800, 0x3800, 0x6800, 0xC800, 0xC800, 0xC800, + 0xB800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC800, + 0xC000, 0x5800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x7000, 0x2800, 0x2800, 0x7000, 0x9000, 0x9000, 0x9000, 0x7800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xB000, 0xB800, + 0xB000, 0x9800, 0x2000, 0x2000, 0x2800, 0x8000, 0xA800, 0xA800, 0xA800, 0x8000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0xC000, 0xE000, 0xF000, 0xF000, 0x9000, 0x2000, 0x2000, 0x8800, 0xD000, 0xD000, 0xC800, + 0x9800, 0x3000, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x3800, 0xB000, 0xB000, 0xB000, + 0xB000, 0x7000, 0x3000, 0x3000, 0x3000, 0x4000, 0xB800, 0xC000, 0xC000, 0xC800, 0x6800, 0x3000, 0x3800, 0xC000, 0xC800, 0xC800, + 0xC800, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x5800, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC000, + 0xC000, 0x5800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0x6000, 0x2800, 0x6800, 0x9000, 0x9000, 0x9000, 0x7000, + 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xB000, 0xB000, + 0xB800, 0xA000, 0x2000, 0x2000, 0x2800, 0x7800, 0xA000, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2800, 0x6000, 0xB800, 0xE000, 0xF000, 0xF800, 0x8800, 0x2000, 0x2000, 0x8800, 0xD000, 0xD000, 0xC800, + 0xA000, 0x3800, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x3000, 0x9800, 0xB800, 0xC000, + 0xB800, 0xA800, 0x5000, 0x3000, 0x4800, 0x9800, 0xC000, 0xC000, 0xC000, 0xC000, 0x6800, 0x3000, 0x3000, 0x8800, 0xC800, 0xD000, + 0xD000, 0xD000, 0x9000, 0x3800, 0x3800, 0x3800, 0x9000, 0xD000, 0xD000, 0x6800, 0x3800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC000, + 0xC000, 0x5000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6800, 0x5000, 0x6000, 0x8800, 0x9000, 0x9000, 0x7000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x5000, 0xA800, 0xB000, + 0xB800, 0xA000, 0x2000, 0x2000, 0x2000, 0x7800, 0xA000, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x5000, 0x7000, 0xB800, 0xE800, 0xF800, 0xF800, 0x8800, 0x2000, 0x2800, 0x8800, 0xD000, 0xD000, 0xC800, + 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xB000, 0xA800, 0xA000, 0x8800, 0x3000, 0x3000, 0x3000, 0x5000, 0xC000, 0xC000, + 0xC000, 0xB800, 0xB800, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0x6000, 0x3000, 0x3000, 0x3000, 0xA800, 0xD000, + 0xD000, 0xD800, 0xD800, 0xE000, 0xE000, 0xE000, 0xE000, 0xD800, 0xD800, 0xC800, 0x4800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC000, + 0xC000, 0x5800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0x7000, 0x8800, 0x8800, 0x8800, 0x6800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0xA000, 0xB000, + 0xB800, 0xA800, 0x2000, 0x2000, 0x2000, 0x8000, 0xA000, 0xA800, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2000, 0x4800, 0x7000, 0x3000, 0xB800, 0xF000, 0xF800, 0xF800, 0x9000, 0x2800, 0x2800, 0x9000, 0xD800, 0xD000, 0xD000, + 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xB000, 0xA800, 0xA000, 0x8800, 0x3000, 0x3000, 0x3000, 0x3000, 0x7800, 0xC800, + 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0xB800, 0xB000, 0xC000, 0xC000, 0xC000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0xA800, + 0xD000, 0xD800, 0xE000, 0xE000, 0xE800, 0xE800, 0xE000, 0xE000, 0xC800, 0x6800, 0x3800, 0x3800, 0x3800, 0xB800, 0xC800, 0xC000, + 0xC000, 0x6000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x3000, 0x8000, 0x8800, 0x8800, 0x8800, 0x6800, + 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0xA000, 0xB000, + 0xB800, 0xA800, 0x2000, 0x2800, 0x2800, 0x8000, 0xA800, 0xA800, 0xA800, 0x8000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x3000, + 0x2800, 0x4800, 0x7000, 0x3000, 0x2000, 0xB800, 0xF000, 0xF800, 0xF800, 0x9000, 0x2800, 0x2800, 0x9800, 0xE000, 0xE000, 0xD800, + 0xA000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xB800, 0xB000, 0xA800, 0x9000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x9000, + 0x9800, 0xC000, 0xC000, 0xC000, 0x8800, 0x5000, 0x8000, 0xC000, 0xC000, 0xC000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x6800, 0x9800, 0xE000, 0xE000, 0xE800, 0xE800, 0xB800, 0x8800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0xB800, 0xC000, 0xC000, + 0xC000, 0x5000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x7000, 0x5000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x5000, 0x7000, 0x6000, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x3000, 0x3000, 0x2800, 0x4800, 0x7800, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3000, 0x3800, 0x5800, 0x6000, 0x5800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x6800, 0x6000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2000, 0x5000, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, + 0x6800, 0x2800, 0x3800, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x7000, 0x5000, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x5800, 0x5000, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x5800, 0x7000, + 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x4800, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7000, 0x5800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x4000, 0x8000, 0x3000, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4000, + 0x7000, 0x6000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x3800, 0x2800, 0x2000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x6000, 0x7000, 0x4000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0x5800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2800, 0x5000, 0x7800, 0x5800, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5800, 0x7800, 0x5000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x8000, 0x3000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x6800, 0x6800, 0x7000, 0x6800, 0x4800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x4800, 0x6800, 0x7800, 0x5000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, 0x3000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x5000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x4000, 0x2800, 0x2000, 0x4000, 0x6000, 0x7800, 0x7000, 0x5800, 0x4800, 0x3000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x3000, 0x4800, 0x5800, 0x7000, 0x7800, 0x6000, 0x4000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0x7800, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x4800, 0x5800, 0x4800, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x4800, 0x5800, 0x4800, 0x3800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x8000, 0x4000, 0x2800, 0x2800, 0x3000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4800, 0x5800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2800, 0x4000, + 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x5000, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x6800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x4000, 0x3800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x5800, 0x7800, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4800, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x4800, 0x8000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, + 0x6000, 0x5000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x8000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, + 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x5800, 0x3800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x6000, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, + 0x4000, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x3800, 0xA000, 0x5800, 0x1800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, 0x7000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x4800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x8800, + 0x8000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x7800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x5800, 0x4800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x5800, + 0x5000, 0x4000, 0x4800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x4000, 0x8000, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x3000, 0x4000, 0x5800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x2800, 0x2800, 0x4000, 0x8000, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x3800, 0x3000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4800, 0x4800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x6800, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x2800, 0x4000, 0x7800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x1800, 0x1800, + 0x2000, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2800, 0x3000, 0x3000, 0x3000, 0x2000, 0x2000, 0x2800, 0x4800, 0x4800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x4000, 0x8000, 0x5000, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x5000, 0x3800, 0x3000, 0x3800, 0x3800, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x3800, 0x3800, 0x3800, 0x2800, 0x1800, 0x2000, + 0x3000, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x3000, 0x4000, 0x4000, 0x4000, 0x3800, 0x2000, 0x2000, 0x3800, 0x4800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x4000, 0x8000, 0x4800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3800, 0x3000, 0x2800, 0x3000, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, + 0x3800, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x3800, 0x5000, 0x4800, 0x4800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x4800, 0x8000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, + 0x3800, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x3800, 0x4800, 0x4800, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3000, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, + 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x3000, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2800, 0x2800, 0x3800, 0x4800, 0x4800, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3800, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x6800, 0x7000, + 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2800, 0x3800, 0x3800, 0x2800, 0x2000, 0x2000, 0x2000, 0x4800, 0x4800, + 0x7000, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x4000, 0x7800, 0x5800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, 0x2800, 0x3800, 0x3800, 0x3800, 0x3800, 0x1800, 0x2000, 0x2000, 0x4000, 0x4000, + 0x3800, 0x7000, 0x5800, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x7800, 0x4000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x4000, 0x4000, + 0x2000, 0x2800, 0x5800, 0x7000, 0x4000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x2800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x7800, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3000, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x3800, 0x4000, + 0x2000, 0x2000, 0x2000, 0x3800, 0x6800, 0x6800, 0x4000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, 0x3000, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, + 0x2800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x7000, 0x7000, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, + 0x2800, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x3800, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4800, 0x7800, 0x5800, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2800, 0x6000, 0x3800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x6800, 0x7800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4000, 0x6800, 0x5800, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x5800, 0xA000, 0x3800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x4000, 0x6800, 0x7800, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x1800, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x3800, 0x6000, 0x6800, 0x4800, 0x2800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3800, 0x2800, 0x1800, 0x2000, 0x1800, 0x2000, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x5000, 0x7800, 0x7000, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x3800, 0x2000, 0x2800, 0x2800, 0x3800, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x5000, 0x7000, 0x6000, 0x4000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x2800, 0x3000, + 0x3000, 0x2000, 0x1800, 0x1800, 0x2000, 0x1800, 0x2800, 0x3000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x5000, 0x7000, + 0x7800, 0x6000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3000, 0x2000, 0x2000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x3800, + 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x3800, 0x5800, + 0x7000, 0x6800, 0x5800, 0x4000, 0x2800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, 0x4000, + 0x4800, 0x2000, 0x1800, 0x1800, 0x2800, 0x4800, 0x2800, 0x2000, 0x2800, 0x3000, 0x4800, 0x6000, 0x7800, 0x7800, 0x6000, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x3800, 0x3000, 0x1800, 0x1800, 0x1800, 0x3000, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, + 0x2000, 0x3000, 0x4000, 0x5800, 0x7000, 0x7000, 0x6000, 0x5000, 0x4800, 0x4000, 0x3800, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2800, 0x3000, 0x4800, 0x6000, 0x5800, 0x6800, 0x7800, 0x7800, 0x6800, 0x5000, 0x3800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3000, 0x3000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, + 0x3000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x3800, 0x3800, 0x3000, 0x3000, 0x2800, 0x1800, 0x2000, 0x2000, 0x1800, + 0x2000, 0x2800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2800, 0x3000, 0x1800, 0x2800, 0x2800, 0x2800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x3000, 0x4000, 0x4800, 0x6000, 0x6800, 0x4000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x4000, 0x6000, 0x5800, 0x4800, 0x4800, 0x3800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x3000, 0x3800, 0x3000, 0x3000, 0x2800, 0x1800, 0x1800, 0x2000, 0x1800, + 0x2000, 0x2800, 0x1800, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x1800, 0x2800, 0x2800, 0x2800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, + 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x2800, 0x2000, 0x1800, 0x1800, 0x1800, 0x3000, 0x3800, 0x3800, 0x4000, + 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x5000, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x1800, 0x2000, 0x2800, 0x1800, + 0x2000, 0x2800, 0x1800, 0x2800, 0x2800, 0x1800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x6000, 0x7000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x4800, 0x2800, 0x3000, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2000, 0x1000, 0x2800, 0x3000, 0x1800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x2000, 0x1800, 0x1800, 0x2000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2000, 0x2800, 0x3000, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x1800, 0x2000, 0x2000, 0x3000, 0x2800, 0x1800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x2000, 0x3000, 0x2000, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x1800, 0x1800, 0x2000, 0x2800, 0x2800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x3000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x4800, 0x4000, 0x2000, 0x2000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, +}; diff --git a/nixos/python-env.nix b/nixos/python-env.nix new file mode 100644 index 000000000..8433fc864 --- /dev/null +++ b/nixos/python-env.nix @@ -0,0 +1,38 @@ +{ config, lib, pkgs, ... }: +let + pyPkgs = import ./pkgs/python-packages.nix { inherit pkgs lib; }; + env = pyPkgs.pifinderEnv; +in { + # libcamera overlay — enable Python bindings for picamera2 + nixpkgs.overlays = [(final: prev: { + libcamera = prev.libcamera.overrideAttrs (old: { + mesonFlags = (old.mesonFlags or []) ++ [ + "-Dpycamera=enabled" + ]; + buildInputs = (old.buildInputs or []) ++ [ + final.python313 + final.python313.pkgs.pybind11 + ]; + }); + })]; + + environment.systemPackages = [ + env + pkgs.gobject-introspection + pkgs.networkmanager + pkgs.libcamera + pkgs.gpsd + ]; + + # Ensure GI_TYPELIB_PATH includes NetworkManager typelib + environment.sessionVariables.GI_TYPELIB_PATH = lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.networkmanager + pkgs.glib + ]; + + # Add libcamera Python bindings to PYTHONPATH (for picamera2) + environment.sessionVariables.PYTHONPATH = "${pkgs.libcamera}/lib/python3.13/site-packages"; + + # Export the Python environment for use by services.nix + _module.args.pifinderPythonEnv = env; +} diff --git a/nixos/services.nix b/nixos/services.nix new file mode 100644 index 000000000..b8f65126d --- /dev/null +++ b/nixos/services.nix @@ -0,0 +1,587 @@ +{ config, lib, pkgs, pifinderPythonEnv, pifinderGitRev, ... }: +let + cfg = config.pifinder; + cedar-detect = import ./pkgs/cedar-detect.nix { inherit pkgs; }; + pifinder-src = import ./pkgs/pifinder-src.nix { inherit pkgs; gitRev = pifinderGitRev; }; + boot-splash = import ./pkgs/boot-splash.nix { inherit pkgs; }; + pifinder-switch-camera = pkgs.writeShellScriptBin "pifinder-switch-camera" '' + CAM="$1" + PERSIST="/var/lib/pifinder/camera-type" + mkdir -p /var/lib/pifinder + + SPEC="/run/current-system/specialisation/$CAM" + if [ "$CAM" = "${cfg.cameraType}" ]; then + /run/current-system/bin/switch-to-configuration boot + elif [ -d "$SPEC" ]; then + "$SPEC/bin/switch-to-configuration" boot + else + echo "Unknown camera: $CAM" >&2; exit 1 + fi + echo "$CAM" > "$PERSIST" + ''; +in { + options.pifinder = { + devMode = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable development mode (NFS netboot support, etc.)"; + }; + }; + + config = { + # --------------------------------------------------------------------------- + # Camera switch wrapper (used by pifinder UI via sudo) + # --------------------------------------------------------------------------- + environment.systemPackages = with pkgs; [ + pifinder-switch-camera + + # Diagnostic tools for SSH troubleshooting + htop + vim + tcpdump + iftop + lsof + strace + file + dnsutils # dig, nslookup + curl + usbutils # lsusb + pciutils # lspci + i2c-tools # i2cdetect (sensor debugging) + iotop + ]; + + + + # --------------------------------------------------------------------------- + # Cachix binary substituter — Pi downloads pre-built paths, never compiles + # --------------------------------------------------------------------------- + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.nixos.org" + "https://pifinder.cachix.org" + ]; + trusted-public-keys = [ + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + "pifinder.cachix.org-1:ALuxYs8tMU34zwSTWjenI2wpJA+AclmW6H5vyTgnTjc=" + ]; + }; + + # --------------------------------------------------------------------------- + # SD card optimizations + # --------------------------------------------------------------------------- + + # Keep 2 generations max in bootloader + boot.loader.generic-extlinux-compatible.configurationLimit = 2; + + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 3d"; + }; + # Disable store optimization on NFS (hard links cause issues) + nix.settings.auto-optimise-store = !cfg.devMode; + + boot.tmp.useTmpfs = true; + boot.tmp.tmpfsSize = "200M"; + + services.journald.extraConfig = '' + Storage=volatile + RuntimeMaxUse=50M + ''; + + zramSwap = { + enable = true; + memoryPercent = 50; + }; + + fileSystems."/" = lib.mkDefault { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + options = [ "noatime" "nodiratime" ]; + }; + + # --------------------------------------------------------------------------- + # Tmpfiles — runtime directory for upgrade ref file + # --------------------------------------------------------------------------- + systemd.tmpfiles.rules = [ + "d /run/pifinder 0755 pifinder users -" + ]; + + # --------------------------------------------------------------------------- + # PWM permissions setup for keypad backlight + # --------------------------------------------------------------------------- + systemd.services.pwm-permissions = { + description = "Set PWM sysfs permissions for pifinder"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + # Export PWM channel 1 (GPIO 13) if not already exported + if [ ! -d /sys/class/pwm/pwmchip0/pwm1 ]; then + echo 1 > /sys/class/pwm/pwmchip0/export || true + sleep 0.5 + fi + # sysfs doesn't support chgrp, so make files world-writable + chmod 0666 /sys/class/pwm/pwmchip0/export /sys/class/pwm/pwmchip0/unexport + if [ -d /sys/class/pwm/pwmchip0/pwm1 ]; then + chmod 0666 /sys/class/pwm/pwmchip0/pwm1/{enable,period,duty_cycle,polarity} + fi + ''; + }; + + # --------------------------------------------------------------------------- + # Nix DB registration (first boot after migration) + # --------------------------------------------------------------------------- + # The migration tarball includes /nix-path-registration with store path data. + # Load it into the Nix DB so nix-store and nixos-rebuild work correctly. + systemd.services.nix-path-registration = { + description = "Load Nix store path registration from migration"; + after = [ "local-fs.target" ]; + before = [ "nix-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = "/nix-path-registration"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ nix coreutils ]; + script = '' + nix-store --load-db < /nix-path-registration + rm /nix-path-registration + ''; + }; + + # --------------------------------------------------------------------------- + # PiFinder source + data directory setup + # --------------------------------------------------------------------------- + system.activationScripts.pifinder-home = lib.stringAfter [ "users" ] '' + # Create writable data directory + mkdir -p /home/pifinder/PiFinder_data + chown pifinder:users /home/pifinder/PiFinder_data + + # Symlink immutable source tree from Nix store + # Database is opened read-only, so no need for writable copy + PFHOME=/home/pifinder/PiFinder + + # Remove existing directory (not symlink) to allow symlink creation + if [ -e "$PFHOME" ] && [ ! -L "$PFHOME" ]; then + rm -rf "$PFHOME" + fi + + # Create symlink to immutable Nix store path + ln -sfT ${pifinder-src} "$PFHOME" + ''; + + # --------------------------------------------------------------------------- + # Sudoers — pifinder user can start upgrade and restart services + # --------------------------------------------------------------------------- + # Polkit rules for pifinder user (D-Bus hostname changes, NetworkManager) + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if (subject.user == "pifinder") { + // Allow hostname changes via systemd-hostnamed + if (action.id == "org.freedesktop.hostname1.set-static-hostname" || + action.id == "org.freedesktop.hostname1.set-hostname") { + return polkit.Result.YES; + } + // Allow NetworkManager control + if (action.id.indexOf("org.freedesktop.NetworkManager") == 0) { + return polkit.Result.YES; + } + // Allow reboot/shutdown via D-Bus (logind) + if (action.id == "org.freedesktop.login1.reboot" || + action.id == "org.freedesktop.login1.reboot-multiple-sessions" || + action.id == "org.freedesktop.login1.power-off" || + action.id == "org.freedesktop.login1.power-off-multiple-sessions") { + return polkit.Result.YES; + } + } + }); + ''; + + security.sudo.extraRules = [{ + users = [ "pifinder" ]; + commands = [ + { command = "/run/current-system/sw/bin/systemctl start --no-block pifinder-upgrade.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl start pifinder-upgrade.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl reset-failed pifinder-upgrade.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart pifinder.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl stop pifinder.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl start pifinder.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart avahi-daemon.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/avahi-set-host-name *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/shutdown *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/chpasswd"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/dmesg"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/hostnamectl *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/hostname *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/pifinder-switch-camera *"; options = [ "NOPASSWD" ]; } + ]; + }]; + + # --------------------------------------------------------------------------- + # Cedar Detect star detection gRPC server + # --------------------------------------------------------------------------- + systemd.services.cedar-detect = { + description = "Cedar Detect Star Detection Server"; + after = [ "basic.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "idle"; + User = "pifinder"; + ExecStart = "${cedar-detect}/bin/cedar-detect-server --port 50551"; + Restart = "on-failure"; + RestartSec = 5; + }; + }; + + # --------------------------------------------------------------------------- + # Early boot splash — show static welcome image, pifinder overwrites when ready + # --------------------------------------------------------------------------- + systemd.services.boot-splash = { + description = "Early boot splash screen"; + wantedBy = [ "sysinit.target" ]; + after = [ "systemd-modules-load.service" ]; + wants = [ "systemd-modules-load.service" ]; + unitConfig.DefaultDependencies = false; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "boot-splash-wait" '' + for i in $(seq 1 40); do + [ -e /dev/spidev0.0 ] && exec ${boot-splash}/bin/boot-splash --static + sleep 0.25 + done + echo "SPI device never appeared" >&2 + exit 1 + ''; + }; + }; + + # --------------------------------------------------------------------------- + # Main PiFinder application + # --------------------------------------------------------------------------- + systemd.services.pifinder = { + description = "PiFinder"; + after = [ "basic.target" "cedar-detect.service" "gpsd.socket" ]; + wants = [ "cedar-detect.service" "gpsd.socket" ]; + wantedBy = [ "multi-user.target" ]; + path = let + # Runtime paths not in the nix store — symlinks resolve at boot, not build time + wrapperBins = pkgs.runCommand "wrapper-bins" {} '' + mkdir -p $out + ln -s /run/wrappers/bin $out/bin + ''; + systemBins = pkgs.runCommand "system-bins" {} '' + mkdir -p $out + ln -s /run/current-system/sw/bin $out/bin + ''; + in [ wrapperBins systemBins pkgs.gpsd ]; + environment = { + PIFINDER_HOME = "/home/pifinder/PiFinder"; + PIFINDER_DATA = "/home/pifinder/PiFinder_data"; + GI_TYPELIB_PATH = lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.networkmanager + pkgs.glib.out # Use .out to get the main package with typelibs, not glib-bin + pkgs.gobject-introspection + ]; + # libcamera Python bindings for picamera2 + PYTHONPATH = "${pkgs.libcamera}/lib/python3.13/site-packages"; + # libcamera IPA modules path + LIBCAMERA_IPA_MODULE_PATH = "${pkgs.libcamera}/lib/libcamera"; + }; + serviceConfig = { + Type = "simple"; + User = "pifinder"; + Group = "users"; + WorkingDirectory = "/home/pifinder/PiFinder/python"; + ExecStart = "${pifinderPythonEnv}/bin/python -m PiFinder.main"; + # Allow binding to privileged ports (80 for web UI) + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + Restart = "on-failure"; + RestartSec = 5; + }; + }; + + # --------------------------------------------------------------------------- + # PiFinder NixOS Upgrade + # --------------------------------------------------------------------------- + # Downloads from binary caches, sets profile, updates bootloader, reboots. + # No live switch-to-configuration — avoids killing running services. + # The pifinder-watchdog handles rollback if the new generation fails to boot. + systemd.services.pifinder-upgrade = { + description = "PiFinder NixOS Upgrade"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + TimeoutStartSec = "10min"; + }; + path = with pkgs; [ nix systemd coreutils ]; + script = '' + set -euo pipefail + STORE_PATH=$(cat /run/pifinder/upgrade-ref 2>/dev/null || true) + if [ -z "$STORE_PATH" ] || [[ "$STORE_PATH" != /nix/store/* ]]; then + echo "ERROR: Invalid store path: $STORE_PATH" + exit 1 + fi + + STATUS_FILE=/run/pifinder/upgrade-status + + # Pre-flight: check disk space (need at least 500MB) + AVAIL=$(df --output=avail /nix/store | tail -1) + if [ "$AVAIL" -lt 524288 ]; then + echo "ERROR: Less than 500MB free on /nix/store" + echo "failed" > "$STATUS_FILE" + exit 1 + fi + + echo "Upgrading to $STORE_PATH" + + # Count paths to download for progress reporting + DRY_RUN=$(nix build "$STORE_PATH" --max-jobs 0 --dry-run 2>&1 || true) + PATHS_FILE=$(mktemp) + echo "$DRY_RUN" | grep '^\s*/nix/store/' | sed 's/^\s*//' > "$PATHS_FILE" || true + TOTAL=$(wc -l < "$PATHS_FILE") + + echo "downloading 0/$TOTAL" > "$STATUS_FILE" + + # Download with progress monitoring + nix build "$STORE_PATH" --max-jobs 0 & + BUILD_PID=$! + + while kill -0 "$BUILD_PID" 2>/dev/null; do + DONE=0 + while IFS= read -r p; do + [ -n "$p" ] && [ -e "$p" ] && DONE=$((DONE + 1)) + done < "$PATHS_FILE" + echo "downloading $DONE/$TOTAL" > "$STATUS_FILE" + sleep 2 + done + + if ! wait "$BUILD_PID"; then + echo "failed" > "$STATUS_FILE" + rm -f "$PATHS_FILE" + exit 1 + fi + rm -f "$PATHS_FILE" + echo "downloading $TOTAL/$TOTAL" > "$STATUS_FILE" + + echo "activating" > "$STATUS_FILE" + + nix-env -p /nix/var/nix/profiles/system --set "$STORE_PATH" + + # Restore camera specialisation if not default + CAM=$(cat /var/lib/pifinder/camera-type 2>/dev/null || echo "${cfg.cameraType}") + if [ "$CAM" != "${cfg.cameraType}" ]; then + SPEC="$STORE_PATH/specialisation/$CAM" + if [ -d "$SPEC" ]; then + echo "Setting boot to camera specialisation: $CAM" + "$SPEC/bin/switch-to-configuration" boot + else + "$STORE_PATH/bin/switch-to-configuration" boot + fi + else + "$STORE_PATH/bin/switch-to-configuration" boot + fi + + echo "rebooting" > "$STATUS_FILE" + + # Cleanup old generations before reboot + nix-env --delete-generations +2 -p /nix/var/nix/profiles/system || true + nix-collect-garbage || true + + echo "Rebooting into new generation..." + systemctl reboot + ''; + }; + + # --------------------------------------------------------------------------- + # PiFinder Boot Health Watchdog + # --------------------------------------------------------------------------- + systemd.services.pifinder-watchdog = { + description = "PiFinder Boot Health Watchdog"; + after = [ "multi-user.target" "pifinder.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ nix systemd coreutils ]; + script = '' + set -euo pipefail + REBOOT_MARKER="/var/tmp/pifinder-watchdog-rebooted" + + if [ -f "$REBOOT_MARKER" ]; then + echo "Watchdog already rebooted once. Not retrying." + rm -f "$REBOOT_MARKER" + exit 0 + fi + + echo "Watchdog: waiting up to 120s for pifinder.service..." + for i in $(seq 1 24); do + if systemctl is-active --quiet pifinder.service; then + # Verify it stays running (not crash-looping) + UPTIME=$(systemctl show pifinder.service --property=ExecMainStartTimestamp --value) + START_EPOCH=$(date -d "$UPTIME" +%s 2>/dev/null || echo 0) + NOW_EPOCH=$(date +%s) + RUNNING_FOR=$((NOW_EPOCH - START_EPOCH)) + if [ "$RUNNING_FOR" -ge 15 ]; then + echo "pifinder.service healthy (running ''${RUNNING_FOR}s)" + exit 0 + fi + fi + sleep 5 + done + + echo "ERROR: pifinder.service failed. Rolling back..." + touch "$REBOOT_MARKER" + PREV_GEN=$(ls -d /nix/var/nix/profiles/system-*-link 2>/dev/null | sort -t- -k2 -n | tail -2 | head -1) + if [ -n "$PREV_GEN" ]; then + # Reset profile so the rolled-back generation becomes the current one + nix-env -p /nix/var/nix/profiles/system --set "$(readlink -f "$PREV_GEN")" + "$PREV_GEN/bin/switch-to-configuration" switch || true + fi + systemctl reboot + ''; + }; + + # --------------------------------------------------------------------------- + # GPSD for GPS receiver - full USB hotplug support + # --------------------------------------------------------------------------- + # Don't use services.gpsd module - it doesn't support hotplug. + # Instead, use gpsd's own systemd units with socket activation. + + # Install gpsd's udev rules (25-gpsd.rules) for USB GPS auto-detection + # Includes u-blox 5/6/7/8/9 and many other GPS receivers + services.udev.packages = [ pkgs.gpsd ]; + + # Install gpsd's systemd units (gpsd.service, gpsd.socket, gpsdctl@.service) + systemd.packages = [ pkgs.gpsd ]; + + # Enable socket activation - gpsd starts when something connects to port 2947 + systemd.sockets.gpsd = { + wantedBy = [ "sockets.target" ]; + }; + + # Configure USBAUTO for gpsdctl (triggered by udev when USB GPS plugs in) + environment.etc."default/gpsd".text = '' + USBAUTO="true" + GPSD_SOCKET="/var/run/gpsd.sock" + ''; + + # Ensure gpsd user/group exist (normally created by services.gpsd module) + users.users.gpsd = { + isSystemUser = true; + group = "gpsd"; + description = "GPSD daemon user"; + }; + users.groups.gpsd = {}; + + # Add UART GPS on boot (ttyAMA3 from uart3 overlay, not auto-detected by udev) + # This runs after gpsd.socket is ready, adding the UART device to gpsd + systemd.services.gpsd-add-uart = { + description = "Add UART GPS to gpsd"; + after = [ "gpsd.socket" "dev-ttyAMA3.device" ]; + requires = [ "gpsd.socket" ]; + wantedBy = [ "multi-user.target" ]; + # BindsTo ensures this stops if ttyAMA3 disappears (though it shouldn't) + bindsTo = [ "dev-ttyAMA3.device" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${pkgs.gpsd}/sbin/gpsdctl add /dev/ttyAMA3"; + ExecStop = "${pkgs.gpsd}/sbin/gpsdctl remove /dev/ttyAMA3"; + }; + }; + + # --------------------------------------------------------------------------- + # PAM service for PiFinder web UI password verification + # --------------------------------------------------------------------------- + security.pam.services.pifinder = { + # Auth-only: no account/session management (avoids setuid and pam_lastlog2 errors) + allowNullPassword = false; + unixAuth = true; + setLoginUid = false; + updateWtmp = false; + }; + + # --------------------------------------------------------------------------- + # Samba for file sharing (observation data, backups) + # --------------------------------------------------------------------------- + system.stateVersion = "24.11"; + + # --------------------------------------------------------------------------- + # SSH access + # --------------------------------------------------------------------------- + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = true; + PermitRootLogin = "yes"; + }; + }; + + # --------------------------------------------------------------------------- + # Avahi/mDNS for hostname discovery (pifinder.local) + # --------------------------------------------------------------------------- + services.avahi = { + enable = true; + nssmdns4 = true; + publish = { + enable = true; + addresses = true; + domain = true; + workstation = true; + }; + }; + + # Clean stale PID file so avahi restarts cleanly during switch-to-configuration + systemd.services.avahi-daemon.serviceConfig.ExecStartPre = + "${pkgs.coreutils}/bin/rm -f /run/avahi-daemon/pid"; + + # Apply user-chosen hostname from PiFinder_data (survives NixOS rebuilds) + systemd.services.pifinder-hostname = { + description = "Apply PiFinder custom hostname"; + after = [ "avahi-daemon.service" ]; + wants = [ "avahi-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "apply-hostname" '' + f=/home/pifinder/PiFinder_data/hostname + [ -f "$f" ] || exit 0 + name=$(cat "$f") + [ -n "$name" ] || exit 0 + /run/current-system/sw/bin/hostname "$name" + /run/current-system/sw/bin/avahi-set-host-name "$name" || \ + /run/current-system/sw/bin/systemctl restart avahi-daemon.service + ''; + }; + }; + + # Don't block boot waiting for network — NM still works, just async + systemd.services.NetworkManager-wait-online.enable = false; + + services.samba = { + enable = true; + openFirewall = true; + settings = { + global = { + workgroup = "WORKGROUP"; + security = "user"; + "map to guest" = "never"; + }; + PiFinder_data = { + path = "/home/pifinder/PiFinder_data"; + browseable = "yes"; + "read only" = "no"; + "valid users" = "pifinder"; + }; + }; + }; + }; # config +} diff --git a/pifinder-build.json b/pifinder-build.json new file mode 100644 index 000000000..5c8fb0e08 --- /dev/null +++ b/pifinder-build.json @@ -0,0 +1,4 @@ +{ + "store_path": "/nix/store/v3xq11spf624a8f4i7jby2fwyamq5kz0-nixos-system-pifinder-25.11.20260209.2db38e0", + "version": "deepchart-nixos-b1e1e11" +} diff --git a/python/DEPENDENCIES.md b/python/DEPENDENCIES.md new file mode 100644 index 000000000..4cd998f32 --- /dev/null +++ b/python/DEPENDENCIES.md @@ -0,0 +1,105 @@ +> **Auto-generated** from the Nix development shell on 2026-02-13. +> Do not edit manually — regenerate with: +> ``` +> nix develop --command ./scripts/generate-dependencies-md.sh +> ``` + +> **Note:** These dependencies are managed by Nix (`nixos/pkgs/python-packages.nix`). +> The versions listed here reflect the nixpkgs pin used by the flake and are +> **not necessarily installable via pip**. Some packages require system libraries +> or hardware (SPI, I2C, GPIO) only available on the Raspberry Pi. + +# Python Dependencies + +Python 3.13.11 + +## Runtime + +| Package | Version | +|---------|---------| +| aiofiles | 24.1.0 | +| attrs | 25.3.0 | +| av | 16.0.1 | +| bottle | 0.13.4 | +| cbor2 | 5.7.0 | +| certifi | 2025.7.14 | +| cffi | 2.0.0 | +| charset-normalizer | 3.4.3 | +| cheroot | 10.0.1 | +| dataclasses-json | 0.6.7 | +| dbus-python | 1.4.0 | +| Deprecated | 1.2.18 | +| evdev | 1.9.2 | +| flatbuffers | 25.9.23 | +| gpsdclient | 1.3.2 | +| grpcio | 1.76.0 | +| h3 | 4.3.1 | +| idna | 3.11 | +| jaraco.functools | 4.2.1 | +| joblib | 1.5.1 | +| jplephem | 2.23 | +| json5 | 0.12.0 | +| jsonpath-ng | 1.7.0 | +| jsonschema | 4.25.0 | +| jsonschema-specifications | 2025.4.1 | +| libarchive-c | 5.3 | +| luma.core | 2.4.2 | +| luma.lcd | 2.11.0 | +| luma.oled | 3.13.0 | +| lz4 | 4.4.4 | +| marshmallow | 3.26.2 | +| more-itertools | 10.7.0 | +| numpy | 2.3.4 | +| pandas | 2.3.1 | +| pillow | 12.1.0 | +| ply | 3.11 | +| protobuf | 6.33.1 | +| psutil | 7.1.2 | +| pycairo | 1.28.0 | +| pycparser | 2.23 | +| pydeepskylog | 1.6 | +| pyftdi | 0.57.1 | +| Pygments | 2.19.2 | +| PyGObject | 3.54.5 | +| PyJWT | 2.10.1 | +| pyserial | 3.5 | +| python-dateutil | 2.9.0.post0 | +| python-libinput | 0.3.0a0 | +| python-pam | 2.0.2 | +| pytz | 2025.2 | +| pyusb | 1.3.1 | +| referencing | 0.36.2 | +| requests | 2.32.5 | +| rpds-py | 0.25.0 | +| scikit-learn | 1.7.1 | +| scipy | 1.16.3 | +| sgp4 | 2.25 | +| sh | 1.14.3 | +| six | 1.17.0 | +| skyfield | 1.53 | +| smbus2 | 0.5.0 | +| spidev | 3.8 | +| threadpoolctl | 3.6.0 | +| timezonefinder | 8.1.0 | +| tqdm | 4.67.1 | +| typing_extensions | 4.15.0 | +| typing_inspect | 0.9.0 | +| tzdata | 2025.2 | +| urllib3 | 2.5.0 | +| wrapt | 1.17.2 | + +## Development only + +| Package | Version | +|---------|---------| +| iniconfig | 2.1.0 | +| luma.emulator | 1.5.0 | +| mypy | 1.17.1 | +| mypy_extensions | 1.1.0 | +| pathspec | 0.12.1 | +| pluggy | 1.6.0 | +| pygame | 2.6.1 | +| PyHotKey | 1.5.2 | +| pynput | 1.8.1 | +| pytest | 8.4.2 | +| python-xlib | 0.33 | diff --git a/python/PiFinder/audit_images.py b/python/PiFinder/audit_images.py index ef37fdb70..1e67742ea 100644 --- a/python/PiFinder/audit_images.py +++ b/python/PiFinder/audit_images.py @@ -6,11 +6,12 @@ images from AWS """ -import requests import sqlite3 + +import requests from tqdm import tqdm -from PiFinder import cat_images +from PiFinder.object_images.poss_provider import POSSImageProvider def get_catalog_objects(): @@ -44,8 +45,8 @@ def check_object_image(catalog_object): aka_rec = conn.execute( f""" SELECT common_name from names - where catalog = "{catalog_object['catalog']}" - and sequence = "{catalog_object['sequence']}" + where catalog = "{catalog_object["catalog"]}" + and sequence = "{catalog_object["sequence"]}" and common_name like "NGC%" """ ).fetchone() @@ -59,7 +60,7 @@ def check_object_image(catalog_object): if aka_sequence: catalog_object = {"catalog": "NGC", "sequence": aka_sequence} - object_image_path = cat_images.resolve_image_name(catalog_object, "POSS") + object_image_path = POSSImageProvider()._resolve_image_name(catalog_object, "POSS") # POSS image_name = object_image_path.split("/")[-1] seq_ones = image_name.split("_")[0][-1] diff --git a/python/PiFinder/auto_exposure.py b/python/PiFinder/auto_exposure.py index ea6d05afc..6c45084ba 100644 --- a/python/PiFinder/auto_exposure.py +++ b/python/PiFinder/auto_exposure.py @@ -121,10 +121,10 @@ def __init__( self._repeat_count = 0 # Sweep pattern: exposure values in microseconds - # Uses doubling pattern (2x each step) + # Start at 400ms (reasonable middle), sweep up, then try shorter exposures # Note: This is intentionally NOT using generate_exposure_sweep() because - # it uses a specific doubling pattern - self._exposures = [25000, 50000, 100000, 200000, 400000, 800000, 1000000] + # it uses a specific pattern optimized for recovery + self._exposures = [400000, 800000, 1000000, 200000, 100000, 50000, 25000] self._repeats_per_exposure = 2 # Try each exposure 2 times logger.info( @@ -534,7 +534,7 @@ def handle( self._sweep_results = [] logger.debug( f"Histogram handler activated: starting {self._sweep_steps}-step histogram sweep " - f"from {self._sweep_exposures[0]/1000:.1f}ms to {self._sweep_exposures[-1]/1000:.1f}ms" + f"from {self._sweep_exposures[0] / 1000:.1f}ms to {self._sweep_exposures[-1] / 1000:.1f}ms" ) return self._sweep_exposures[0] @@ -548,7 +548,7 @@ def handle( self._sweep_results.append((sweep_exposure, viable, metrics)) logger.debug( - f"Histogram analysis for {sweep_exposure/1000:.1f}ms: " + f"Histogram analysis for {sweep_exposure / 1000:.1f}ms: " f"viable={'YES' if viable else 'NO'}, " f"mean={metrics['mean']:.1f}, std={metrics['std']:.1f}, sat={metrics['saturation_pct']:.1f}%" ) @@ -556,8 +556,8 @@ def handle( # Track viable exposures but continue sweep to find best option if viable: logger.debug( - f"Histogram handler: found viable exposure {sweep_exposure/1000:.1f}ms " - f"(step {self._sweep_index+1}/{self._sweep_steps}), continuing sweep" + f"Histogram handler: found viable exposure {sweep_exposure / 1000:.1f}ms " + f"(step {self._sweep_index + 1}/{self._sweep_steps}), continuing sweep" ) # If we've completed the sweep, settle on target exposure @@ -574,14 +574,14 @@ def handle( # Use highest viable exposure for best star detection self._target_exposure = max(viable_exposures) logger.debug( - f"Histogram handler: settling on highest viable exposure {self._target_exposure/1000:.1f}ms" + f"Histogram handler: settling on highest viable exposure {self._target_exposure / 1000:.1f}ms" ) else: # No viable exposures - use highest from sweep highest_exp = self._sweep_results[-1][0] self._target_exposure = highest_exp logger.debug( - f"Histogram handler: no viable exposure found, using highest {highest_exp/1000:.1f}ms" + f"Histogram handler: no viable exposure found, using highest {highest_exp / 1000:.1f}ms" ) else: # Fallback to middle exposure @@ -589,7 +589,7 @@ def handle( middle_exp = self._sweep_exposures[middle_idx] self._target_exposure = middle_exp logger.debug( - f"Histogram handler: no analysis data, using middle {middle_exp/1000:.1f}ms" + f"Histogram handler: no analysis data, using middle {middle_exp / 1000:.1f}ms" ) # Hold at target @@ -602,7 +602,7 @@ def handle( if self._sweep_index < len(self._sweep_exposures): next_exp = self._sweep_exposures[self._sweep_index] logger.debug( - f"Histogram handler: sweep step {self._sweep_index+1}/{self._sweep_steps} → {next_exp/1000:.1f}ms" + f"Histogram handler: sweep step {self._sweep_index + 1}/{self._sweep_steps} → {next_exp / 1000:.1f}ms" ) return next_exp else: @@ -619,6 +619,183 @@ def reset(self) -> None: logger.debug("HistogramZeroStarHandler reset") +class ExposureSNRController: + """ + SNR-based auto exposure for SQM measurements. + + Targets a minimum background SNR and exposure time instead of star count. + This provides more stable, longer exposures that are better for accurate + SQM measurements compared to the histogram-based approach. + + Strategy: + - Target specific background level above noise floor + - Derive thresholds from camera profile (bit depth, bias offset) + - Slower adjustments for stability + """ + + def __init__( + self, + min_exposure: int = 10000, # 10ms minimum + max_exposure: int = 1000000, # 1.0s maximum + target_background: int = 30, # Target background level in ADU + min_background: int = 15, # Minimum acceptable background + max_background: int = 100, # Maximum before saturating + adjustment_factor: float = 1.3, # Gentle adjustments (30% steps) + ): + """ + Initialize SNR-based auto exposure. + + Args: + min_exposure: Minimum exposure in microseconds (default 10ms) + max_exposure: Maximum exposure in microseconds (default 1000ms) + target_background: Target median background level in ADU + min_background: Minimum acceptable background (increase if below) + max_background: Maximum acceptable background (decrease if above) + adjustment_factor: Multiplicative adjustment step (default 1.3 = 30%) + """ + self.min_exposure = min_exposure + self.max_exposure = max_exposure + self.target_background = target_background + self.min_background = min_background + self.max_background = max_background + self.adjustment_factor = adjustment_factor + + logger.info( + f"AutoExposure SNR: target_bg={target_background}, " + f"range=[{min_background}, {max_background}] ADU, " + f"exp_range=[{min_exposure / 1000:.0f}, {max_exposure / 1000:.0f}]ms, " + f"adjustment={adjustment_factor}x" + ) + + @classmethod + def from_camera_profile( + cls, + camera_type: str, + min_exposure: int = 10000, + max_exposure: int = 1000000, + adjustment_factor: float = 1.3, + ) -> "ExposureSNRController": + """ + Create controller with thresholds derived from camera profile. + + Calculates min/target/max background based on bit depth and bias offset. + + Args: + camera_type: Camera type (e.g., "imx296_processed", "imx462_processed") + min_exposure: Minimum exposure in microseconds + max_exposure: Maximum exposure in microseconds + adjustment_factor: Multiplicative adjustment step + + Returns: + ExposureSNRController configured for the camera + """ + from PiFinder.sqm.camera_profiles import get_camera_profile + + profile = get_camera_profile(camera_type) + + # Derive thresholds from camera specs + max_adu = (2**profile.bit_depth) - 1 + bias = profile.bias_offset + + # min_background: bias + margin (2x bias or bias + 8, whichever larger) + min_background = int(max(bias * 2, bias + 8)) + + # max_background: ~40% of full range (avoid saturation/nonlinearity) + max_background = int(max_adu * 0.4) + + # target_background: just above min (lower = shorter exposure = more linear response) + target_background = min_background + 2 + + logger.info( + f"SNR controller from {camera_type}: " + f"bit_depth={profile.bit_depth}, bias={bias:.0f}, " + f"thresholds=[{min_background}, {target_background}, {max_background}]" + ) + + return cls( + min_exposure=min_exposure, + max_exposure=max_exposure, + target_background=target_background, + min_background=min_background, + max_background=max_background, + adjustment_factor=adjustment_factor, + ) + + def update( + self, + current_exposure: int, + image: Image.Image, + noise_floor: Optional[float] = None, + **kwargs, # Ignore other params (matched_stars, etc.) + ) -> Optional[int]: + """ + Update exposure based on background level. + + Args: + current_exposure: Current exposure in microseconds + image: Current image for analysis + noise_floor: Adaptive noise floor from SQM calculator (if available) + **kwargs: Ignored (for compatibility with PID interface) + + Returns: + New exposure in microseconds, or None if no change needed + """ + # Use adaptive noise floor if available, otherwise fall back to static config + # Need margin above noise floor so background_corrected isn't near zero + if noise_floor is not None: + min_bg = noise_floor + 2 + else: + min_bg = self.min_background + + # Analyze image + if image.mode != "L": + image = image.convert("L") + img_array = np.asarray(image, dtype=np.float32) + + # Use 10th percentile as background estimate (dark pixels) + background = float(np.percentile(img_array, 10)) + + logger.debug( + f"SNR AE: bg={background:.1f}, min={min_bg:.1f} ADU, exp={current_exposure / 1000:.0f}ms" + ) + + # Determine adjustment + new_exposure = None + + if background < min_bg: + # Too dark - increase exposure + new_exposure = int(current_exposure * self.adjustment_factor) + logger.info( + f"SNR AE: Background too low ({background:.1f} < {min_bg:.1f}), " + f"increasing exposure {current_exposure / 1000:.0f}ms → {new_exposure / 1000:.0f}ms" + ) + elif background > self.max_background: + # Too bright - decrease exposure + new_exposure = int(current_exposure / self.adjustment_factor) + logger.info( + f"SNR AE: Background too high ({background:.1f} > {self.max_background}), " + f"decreasing exposure {current_exposure / 1000:.0f}ms → {new_exposure / 1000:.0f}ms" + ) + else: + # Background is in acceptable range + logger.debug(f"SNR AE: OK (bg={background:.1f} ADU)") + return None + + # Clamp to limits + new_exposure = max(self.min_exposure, min(self.max_exposure, new_exposure)) + return new_exposure + + def get_status(self) -> dict: + return { + "mode": "SNR", + "target_background": self.target_background, + "min_background": self.min_background, + "max_background": self.max_background, + "min_exposure": self.min_exposure, + "max_exposure": self.max_exposure, + } + + class ExposurePIDController: """ PID controller for automatic camera exposure adjustment. @@ -664,6 +841,7 @@ def __init__( self._integral = 0.0 self._last_error: Optional[float] = None self._zero_star_count = 0 + self._nonzero_star_count = 0 # Hysteresis: consecutive non-zero solves self._last_adjustment_time = 0.0 self._zero_star_handler = zero_star_handler or SweepZeroStarHandler( min_exposure=min_exposure, max_exposure=max_exposure @@ -680,6 +858,7 @@ def reset(self) -> None: self._integral = 0.0 self._last_error = None self._zero_star_count = 0 + self._nonzero_star_count = 0 self._last_adjustment_time = 0.0 self._zero_star_handler.reset() logger.debug("PID controller reset") @@ -725,6 +904,18 @@ def _update_pid(self, matched_stars: int, current_exposure: int) -> Optional[int # Select gains: conservative when decreasing, aggressive when increasing kp, ki, kd = self.gains_decrease if error < 0 else self.gains_increase + # Reset integral when error changes sign to prevent accumulated integral + # from crashing exposure when conditions change suddenly + # (e.g., going from too many stars to too few stars) + if self._last_error is not None: + if (error > 0 and self._last_error < 0) or ( + error < 0 and self._last_error > 0 + ): + logger.debug( + f"PID: Error sign changed ({self._last_error:.0f} → {error:.0f}), resetting integral" + ) + self._integral = 0.0 + # PID calculation p_term = kp * error diff --git a/python/PiFinder/camera_debug.py b/python/PiFinder/camera_debug.py index 5ae1c6ed1..d1222a3dc 100644 --- a/python/PiFinder/camera_debug.py +++ b/python/PiFinder/camera_debug.py @@ -49,7 +49,7 @@ def setup_debug_images(self) -> None: self.images = list(zip(range(1, len(images) + 1), images)) self.image_cycle = cycle(self.images) self.last_image_time: float = time.time() - self.current_image_num, self.last_image = self.images[0] + self.current_image_num, self.last_image = self.images[1] # Use darker sky image def initialize(self) -> None: self._camera_started = True @@ -63,13 +63,15 @@ def stop_camera(self) -> None: def capture(self) -> Image.Image: sleep_time = self.exposure_time / 1000000 time.sleep(sleep_time) + # FOR TESTING: Keep using the same image (first image - solves, brighter sky) + # Comment out image cycling to maintain consistent roll/orientation # Change images every 10 seconds - elapsed = time.time() - self.last_image_time - if elapsed > 10: - self.current_image_num, self.last_image = next(self.image_cycle) - logger.debug( - f"Debug camera switched to test image #{self.current_image_num}" - ) + # elapsed = time.time() - self.last_image_time + # if elapsed > 10: + # self.current_image_num, self.last_image = next(self.image_cycle) + # logger.debug( + # f"Debug camera switched to test image #{self.current_image_num}" + # ) return self.last_image def capture_bias(self): diff --git a/python/PiFinder/camera_interface.py b/python/PiFinder/camera_interface.py index 009fc6579..c88f18300 100644 --- a/python/PiFinder/camera_interface.py +++ b/python/PiFinder/camera_interface.py @@ -13,6 +13,7 @@ import logging import os import queue +import threading import time from typing import Tuple, Optional @@ -21,12 +22,14 @@ from PiFinder import state_utils, utils from PiFinder.auto_exposure import ( ExposurePIDController, + ExposureSNRController, SweepZeroStarHandler, ExponentialSweepZeroStarHandler, ResetZeroStarHandler, HistogramZeroStarHandler, generate_exposure_sweep, ) +from PiFinder.sqm.camera_profiles import detect_camera_type logger = logging.getLogger("Camera.Interface") @@ -35,10 +38,16 @@ class CameraInterface: """The CameraInterface interface.""" _camera_started = False + _debug = False _save_next_to = None # Filename to save next capture to (None = don't save) _auto_exposure_enabled = False + _auto_exposure_mode = "pid" # "pid" or "snr" _auto_exposure_pid: Optional[ExposurePIDController] = None + _auto_exposure_snr: Optional[ExposureSNRController] = None _last_solve_time: Optional[float] = None + _command_queue: Optional[queue.Queue] = None + _console_queue: Optional[queue.Queue] = None + _cfg = None def initialize(self) -> None: pass @@ -75,12 +84,302 @@ def start_camera(self) -> None: def stop_camera(self) -> None: pass + def _capture_with_timeout(self, timeout=10) -> Optional[Image.Image]: + """Attempt capture with timeout. + + Returns the captured image, or None if capture hung (V4L2 stuck). + Uses a daemon thread so a hung capture doesn't block shutdown. + """ + result = [None] + exc = [None] + + def _do_capture(): + try: + result[0] = self.capture() + except Exception as e: + exc[0] = e + + t = threading.Thread(target=_do_capture, daemon=True) + t.start() + t.join(timeout) + + if t.is_alive(): + return None + if exc[0]: + raise exc[0] + return result[0] + + def _process_pending_commands(self): + """Drain and process all pending commands from the queue. + + Called at the top of each camera loop iteration so commands are + handled even when capture() blocks on V4L2 hardware. + """ + while True: + try: + command = self._command_queue.get_nowait() + except queue.Empty: + break + + try: + if command == "debug": + self._debug = not self._debug + + elif command.startswith("set_exp"): + exp_value = command.split(":")[1] + if exp_value == "auto": + self._auto_exposure_enabled = True + self._last_solve_time = None + if self._auto_exposure_pid is None: + self._auto_exposure_pid = ExposurePIDController() + else: + self._auto_exposure_pid.reset() + self._console_queue.put("CAM: Auto-Exposure Enabled") + logger.info("Auto-exposure mode enabled") + else: + self._auto_exposure_enabled = False + self.exposure_time = int(exp_value) + self.set_camera_config(self.exposure_time, self.gain) + self._cfg.set_option("camera_exp", self.exposure_time) + self._console_queue.put("CAM: Exp=" + str(self.exposure_time)) + logger.info(f"Manual exposure set: {self.exposure_time}µs") + + elif command.startswith("set_gain"): + old_gain = self.gain + self.gain = int(command.split(":")[1]) + self.exposure_time, self.gain = self.set_camera_config( + self.exposure_time, self.gain + ) + self._console_queue.put("CAM: Gain=" + str(self.gain)) + logger.info(f"Gain changed: {old_gain}x → {self.gain}x") + + elif command.startswith("set_ae_handler"): + handler_type = command.split(":")[1] + if self._auto_exposure_pid is not None: + new_handler = None + if handler_type == "sweep": + new_handler = SweepZeroStarHandler( + min_exposure=self._auto_exposure_pid.min_exposure, + max_exposure=self._auto_exposure_pid.max_exposure, + ) + elif handler_type == "exponential": + new_handler = ExponentialSweepZeroStarHandler( + min_exposure=self._auto_exposure_pid.min_exposure, + max_exposure=self._auto_exposure_pid.max_exposure, + ) + elif handler_type == "reset": + new_handler = ResetZeroStarHandler(reset_exposure=400000) + elif handler_type == "histogram": + new_handler = HistogramZeroStarHandler( + min_exposure=self._auto_exposure_pid.min_exposure, + max_exposure=self._auto_exposure_pid.max_exposure, + ) + else: + logger.warning( + f"Unknown zero-star handler type: {handler_type}" + ) + + if new_handler is not None: + self._auto_exposure_pid._zero_star_handler = new_handler + self._console_queue.put(f"CAM: AE Handler={handler_type}") + logger.info( + f"Auto-exposure zero-star handler changed to: {handler_type}" + ) + else: + logger.warning( + "Cannot set AE handler: auto-exposure not initialized" + ) + + elif command.startswith("set_ae_mode"): + mode = command.split(":")[1] + if mode in ["pid", "snr"]: + self._auto_exposure_mode = mode + self._console_queue.put(f"CAM: AE Mode={mode.upper()}") + logger.info(f"Auto-exposure mode changed to: {mode.upper()}") + else: + logger.warning( + f"Unknown auto-exposure mode: {mode} (valid: pid, snr)" + ) + + elif command == "exp_up" or command == "exp_dn": + self._auto_exposure_enabled = False + if command == "exp_up": + self.exposure_time = int(self.exposure_time * 1.25) + else: + self.exposure_time = int(self.exposure_time * 0.75) + self.set_camera_config(self.exposure_time, self.gain) + self._console_queue.put("CAM: Exp=" + str(self.exposure_time)) + + elif command == "exp_save": + self._auto_exposure_enabled = False + self._cfg.set_option("camera_exp", self.exposure_time) + self._cfg.set_option("camera_gain", int(self.gain)) + self._console_queue.put(f"CAM: Exp Saved ({self.exposure_time}µs)") + logger.info( + f"Exposure saved and auto-exposure disabled: {self.exposure_time}µs" + ) + + elif command.startswith("save"): + self._save_next_to = command.split(":")[1] + self._console_queue.put("CAM: Save flag set") + + elif command.startswith("capture") and command != "capture_exp_sweep": + captured_image = self.capture() + self._camera_image.paste(captured_image) + + if self._save_next_to: + filename = f"{utils.data_dir}/captures/{self._save_next_to}" + if not filename.endswith(".png"): + filename += ".png" + self.capture_file(filename) + + raw_filename = filename.replace(".png", ".tiff") + if not raw_filename.endswith(".tiff"): + raw_filename += ".tiff" + self.capture_raw_file(raw_filename) + + self._console_queue.put("CAM: Captured + Saved") + self._save_next_to = None + else: + self._console_queue.put("CAM: Captured") + + elif command.startswith("capture_exp_sweep"): + self._run_exposure_sweep(command) + + elif command.startswith("stop"): + self.stop_camera() + self._console_queue.put("CAM: Stopped camera") + + elif command.startswith("start"): + self.start_camera() + self._console_queue.put("CAM: Started camera") + + except ValueError as e: + logger.error(f"Error processing camera command '{command}': {str(e)}") + + def _run_exposure_sweep(self, command): + """Capture exposure sweep for SQM testing.""" + reference_sqm = None + if ":" in command: + try: + reference_sqm = float(command.split(":")[1]) + logger.info(f"Reference SQM: {reference_sqm:.2f}") + except (ValueError, IndexError): + logger.warning("Invalid reference SQM in command") + + logger.info("Starting exposure sweep capture (100 image pairs)") + self._console_queue.put("CAM: Starting sweep...") + + original_exposure = self.exposure_time + original_gain = self.gain + original_ae_enabled = self._auto_exposure_enabled + + self._auto_exposure_enabled = False + + min_exp = 25000 + max_exp = 1000000 + num_images = 20 + + sweep_exposures = generate_exposure_sweep(min_exp, max_exp, num_images) + + gps_time = self.shared_state.datetime() + if gps_time: + timestamp = gps_time.strftime("%Y%m%d_%H%M%S") + else: + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + logger.warning( + "GPS time not available, using Pi system time for sweep directory name" + ) + + from pathlib import Path + + sweep_dir = Path(f"{utils.data_dir}/captures/sweep_{timestamp}") + sweep_dir.mkdir(parents=True, exist_ok=True) + + logger.info(f"Saving sweep to: {sweep_dir}") + self._console_queue.put("CAM: Starting sweep...") + + for i, exp_us in enumerate(sweep_exposures, 1): + self._console_queue.put(f"CAM: Sweep {i}/{num_images}") + + self.exposure_time = exp_us + self.set_camera_config(self.exposure_time, self.gain) + + logger.debug(f"Flushing camera buffer for {exp_us}µs exposure") + _ = self.capture() + _ = self.capture() + + exp_ms = exp_us / 1000 + + processed_filename = sweep_dir / f"img_{i:03d}_{exp_ms:.2f}ms_processed.png" + processed_img = self.capture() + processed_img.save(str(processed_filename)) + + raw_filename = sweep_dir / f"img_{i:03d}_{exp_ms:.2f}ms_raw.tiff" + self.capture_raw_file(str(raw_filename)) + + logger.debug( + f"Captured sweep images {i}/{num_images}: {exp_ms:.2f}ms (PNG+TIFF)" + ) + + self.exposure_time = original_exposure + self.gain = original_gain + self._auto_exposure_enabled = original_ae_enabled + self.set_camera_config(self.exposure_time, self.gain) + + try: + from PiFinder.sqm.save_sweep_metadata import save_sweep_metadata + + gps_datetime = self.shared_state.datetime() + location = self.shared_state.location() + + solve_state = self.shared_state.solution() + ra_deg = None + dec_deg = None + altitude_deg = None + azimuth_deg = None + + if solve_state is not None: + ra_deg = solve_state.get("RA") + dec_deg = solve_state.get("Dec") + altitude_deg = solve_state.get("Alt") + azimuth_deg = solve_state.get("Az") + + save_sweep_metadata( + sweep_dir=sweep_dir, + observer_lat=location.lat, + observer_lon=location.lon, + observer_altitude_m=location.altitude, + gps_datetime=gps_datetime.isoformat() if gps_datetime else None, + reference_sqm=reference_sqm, + ra_deg=ra_deg, + dec_deg=dec_deg, + altitude_deg=altitude_deg, + azimuth_deg=azimuth_deg, + notes=f"Exposure sweep: {num_images} images, {min_exp / 1000:.1f}-{max_exp / 1000:.1f}ms", + ) + logger.info( + f"Successfully saved sweep metadata to {sweep_dir}/sweep_metadata.json" + ) + except Exception as e: + logger.error(f"Failed to save sweep metadata: {e}", exc_info=True) + + self._console_queue.put("CAM: Sweep done!") + logger.info( + f"Exposure sweep completed: {num_images} image pairs in {sweep_dir}" + ) + def get_image_loop( self, shared_state, camera_image, command_queue, console_queue, cfg ): try: - # Store shared_state for access by capture() methods + # Store refs for access by _process_pending_commands and helpers self.shared_state = shared_state + self._camera_image = camera_image + self._command_queue = command_queue + self._console_queue = console_queue + self._cfg = cfg + self._debug = False # Store camera type in shared state for SQM calibration camera_type_str = self.get_cam_type() # e.g., "PI imx296", "PI hq" @@ -90,8 +389,6 @@ def get_image_loop( shared_state.set_camera_type(camera_type) logger.info(f"Camera type set to: {camera_type}") - debug = False - # Check if auto-exposure was previously enabled in config config_exp = cfg.get_option("camera_exp") if config_exp == "auto": @@ -114,13 +411,20 @@ def get_image_loop( root_dir, "test_images", "pifinder_debug_02.png" ) - # 60 half-second cycles + # 60 half-second cycles (30 seconds between captures in sleep mode) sleep_delay = 60 + was_sleeping = False while True: + self._process_pending_commands() + sleeping = state_utils.sleep_for_framerate( shared_state, limit_framerate=False ) if sleeping: + # Log transition to sleep mode + if not was_sleeping: + logger.info("Camera entering low-power sleep mode") + was_sleeping = True # Even in sleep mode, we want to take photos every # so often to update positions sleep_delay -= 1 @@ -128,12 +432,22 @@ def get_image_loop( continue else: sleep_delay = 60 + logger.debug("Sleep mode: waking for periodic capture") + elif was_sleeping: + logger.info("Camera exiting low-power sleep mode") + was_sleeping = False imu_start = shared_state.imu() image_start_time = time.time() if self._camera_started: - if not debug: - base_image = self.capture() + if not self._debug: + base_image = self._capture_with_timeout() + if base_image is None: + logger.warning( + "Camera capture timed out — switching to test mode" + ) + self._debug = True + continue base_image = base_image.convert("L") rotate_amount = 0 @@ -213,11 +527,42 @@ def get_image_loop( f"RMSE: {rmse_str}, Current exposure: {self.exposure_time}µs" ) - # Call PID update (now handles zero stars with recovery mode) - # Pass base_image for histogram analysis in zero-star handler - new_exposure = self._auto_exposure_pid.update( - matched_stars, self.exposure_time, base_image - ) + # Call auto-exposure update based on current mode + if self._auto_exposure_mode == "snr": + # SNR mode: use background-based controller (for SQM measurements) + if self._auto_exposure_snr is None: + # Use camera profile to derive thresholds + try: + cam_type = detect_camera_type( + self.get_cam_type() + ) + cam_type = f"{cam_type}_processed" + self._auto_exposure_snr = ExposureSNRController.from_camera_profile( + cam_type + ) + except ValueError as e: + # Unknown camera, use defaults + logger.warning( + f"Camera detection failed: {e}, using default SNR thresholds" + ) + self._auto_exposure_snr = ( + ExposureSNRController() + ) + # Get adaptive noise floor from shared state + adaptive_noise_floor = ( + self.shared_state.noise_floor() + ) + new_exposure = self._auto_exposure_snr.update( + self.exposure_time, + base_image, + noise_floor=adaptive_noise_floor, + ) + else: + # PID mode: use star-count based controller (default) + # Pass base_image for histogram analysis in zero-star handler + new_exposure = self._auto_exposure_pid.update( + matched_stars, self.exposure_time, base_image + ) if ( new_exposure is not None @@ -239,354 +584,6 @@ def get_image_loop( ) self._last_solve_time = solve_attempt_time - # Loop over any pending commands - # There may be more than one! - command = True - while command: - try: - command = command_queue.get(block=True, timeout=0.1) - except queue.Empty: - command = "" - continue - except Exception as e: - logger.error(f"CameraInterface: Command error: {e}") - - try: - if command == "debug": - if debug: - debug = False - else: - debug = True - - if command.startswith("set_exp"): - exp_value = command.split(":")[1] - if exp_value == "auto": - # Enable auto-exposure mode - self._auto_exposure_enabled = True - self._last_solve_time = None # Reset solve tracking - if self._auto_exposure_pid is None: - self._auto_exposure_pid = ExposurePIDController() - else: - self._auto_exposure_pid.reset() - console_queue.put("CAM: Auto-Exposure Enabled") - logger.info("Auto-exposure mode enabled") - else: - # Disable auto-exposure and set manual exposure - self._auto_exposure_enabled = False - self.exposure_time = int(exp_value) - self.set_camera_config(self.exposure_time, self.gain) - # Update config to reflect manual exposure value - cfg.set_option("camera_exp", self.exposure_time) - console_queue.put("CAM: Exp=" + str(self.exposure_time)) - logger.info( - f"Manual exposure set: {self.exposure_time}µs" - ) - - if command.startswith("set_gain"): - old_gain = self.gain - self.gain = int(command.split(":")[1]) - self.exposure_time, self.gain = self.set_camera_config( - self.exposure_time, self.gain - ) - console_queue.put("CAM: Gain=" + str(self.gain)) - logger.info(f"Gain changed: {old_gain}x → {self.gain}x") - - if command.startswith("set_ae_handler"): - handler_type = command.split(":")[1] - if self._auto_exposure_pid is not None: - new_handler = None - if handler_type == "sweep": - new_handler = SweepZeroStarHandler( - min_exposure=self._auto_exposure_pid.min_exposure, - max_exposure=self._auto_exposure_pid.max_exposure, - ) - elif handler_type == "exponential": - new_handler = ExponentialSweepZeroStarHandler( - min_exposure=self._auto_exposure_pid.min_exposure, - max_exposure=self._auto_exposure_pid.max_exposure, - ) - elif handler_type == "reset": - new_handler = ResetZeroStarHandler( - reset_exposure=400000 # 0.4s - ) - elif handler_type == "histogram": - new_handler = HistogramZeroStarHandler( - min_exposure=self._auto_exposure_pid.min_exposure, - max_exposure=self._auto_exposure_pid.max_exposure, - ) - else: - logger.warning( - f"Unknown zero-star handler type: {handler_type}" - ) - - if new_handler is not None: - self._auto_exposure_pid._zero_star_handler = ( - new_handler - ) - console_queue.put(f"CAM: AE Handler={handler_type}") - logger.info( - f"Auto-exposure zero-star handler changed to: {handler_type}" - ) - else: - logger.warning( - "Cannot set AE handler: auto-exposure not initialized" - ) - - if command == "exp_up" or command == "exp_dn": - # Manual exposure adjustments disable auto-exposure - self._auto_exposure_enabled = False - if command == "exp_up": - self.exposure_time = int(self.exposure_time * 1.25) - else: - self.exposure_time = int(self.exposure_time * 0.75) - self.set_camera_config(self.exposure_time, self.gain) - console_queue.put("CAM: Exp=" + str(self.exposure_time)) - if command == "exp_save": - # Saving exposure disables auto-exposure and locks to current value - self._auto_exposure_enabled = False - cfg.set_option("camera_exp", self.exposure_time) - cfg.set_option("camera_gain", int(self.gain)) - console_queue.put( - f"CAM: Exp Saved ({self.exposure_time}µs)" - ) - logger.info( - f"Exposure saved and auto-exposure disabled: {self.exposure_time}µs" - ) - - if command.startswith("save"): - # Set flag to save next capture to this file - self._save_next_to = command.split(":")[1] - console_queue.put("CAM: Save flag set") - - if ( - command.startswith("capture") - and command != "capture_exp_sweep" - ): - # Capture single frame and update shared state - # This is used by SQM calibration for precise exposure control - captured_image = self.capture() - camera_image.paste(captured_image) - - # If save flag is set, save to disk - if self._save_next_to: - # Build full path - filename = ( - f"{utils.data_dir}/captures/{self._save_next_to}" - ) - if not filename.endswith(".png"): - filename += ".png" - self.capture_file(filename) - - # Also save raw as TIFF - raw_filename = filename.replace(".png", ".tiff") - if not raw_filename.endswith(".tiff"): - raw_filename += ".tiff" - self.capture_raw_file(raw_filename) - - console_queue.put("CAM: Captured + Saved") - self._save_next_to = None # Clear flag - else: - console_queue.put("CAM: Captured") - - if command.startswith("capture_exp_sweep"): - # Capture exposure sweep - save both RAW and processed images - # at different exposures for SQM testing - # RAW: 16-bit TIFF to preserve full sensor bit depth - # Processed: 8-bit PNG from normal camera.capture() pipeline - - # Parse reference SQM if provided - reference_sqm = None - if ":" in command: - try: - reference_sqm = float(command.split(":")[1]) - logger.info(f"Reference SQM: {reference_sqm:.2f}") - except (ValueError, IndexError): - logger.warning("Invalid reference SQM in command") - - logger.info( - "Starting exposure sweep capture (100 image pairs)" - ) - console_queue.put("CAM: Starting sweep...") - - # Save current settings - original_exposure = self.exposure_time - original_gain = self.gain - original_ae_enabled = self._auto_exposure_enabled - - # Disable auto-exposure during sweep - self._auto_exposure_enabled = False - - # Generate 100 exposure values with logarithmic spacing - # from 25ms (25000µs) to 1s (1000000µs) - min_exp = 25000 # 25ms - max_exp = 1000000 # 1s - num_images = 100 - - # Generate logarithmic sweep using shared utility - sweep_exposures = generate_exposure_sweep( - min_exp, max_exp, num_images - ) - - # Generate timestamp for this sweep session using GPS time - gps_time = shared_state.datetime() - if gps_time: - timestamp = gps_time.strftime("%Y%m%d_%H%M%S") - else: - # Fallback to Pi time if GPS not available - timestamp = datetime.datetime.now().strftime( - "%Y%m%d_%H%M%S" - ) - logger.warning( - "GPS time not available, using Pi system time for sweep directory name" - ) - - # Create sweep directory - from pathlib import Path - - sweep_dir = Path( - f"{utils.data_dir}/captures/sweep_{timestamp}" - ) - sweep_dir.mkdir(parents=True, exist_ok=True) - - logger.info(f"Saving sweep to: {sweep_dir}") - console_queue.put("CAM: Starting sweep...") - - for i, exp_us in enumerate(sweep_exposures, 1): - # Update progress at start of each capture - console_queue.put(f"CAM: Sweep {i}/{num_images}") - - # Set exposure - self.exposure_time = exp_us - self.set_camera_config(self.exposure_time, self.gain) - - # Flush camera buffer - discard pre-buffered frames with old exposure - # Picamera2 maintains a frame queue, need to flush frames captured - # before the new exposure setting was applied - logger.debug( - f"Flushing camera buffer for {exp_us}µs exposure" - ) - _ = self.capture() # Discard buffered frame 1 - _ = self.capture() # Discard buffered frame 2 - - # Now capture both processed and RAW images with correct exposure - exp_ms = exp_us / 1000 - - # Save processed 8-bit PNG (same as production capture() method) - processed_filename = ( - sweep_dir - / f"img_{i:03d}_{exp_ms:.2f}ms_processed.png" - ) - processed_img = ( - self.capture() - ) # Returns 8-bit PIL Image - processed_img.save(str(processed_filename)) - - # Save RAW TIFF (16-bit, from camera.capture_raw_file()) - raw_filename = ( - sweep_dir / f"img_{i:03d}_{exp_ms:.2f}ms_raw.tiff" - ) - self.capture_raw_file(str(raw_filename)) - - logger.debug( - f"Captured sweep images {i}/{num_images}: {exp_ms:.2f}ms (PNG+TIFF)" - ) - - # Restore original settings - self.exposure_time = original_exposure - self.gain = original_gain - self._auto_exposure_enabled = original_ae_enabled - self.set_camera_config(self.exposure_time, self.gain) - - # Save sweep metadata (GPS time, location, altitude) - logger.info("Starting sweep metadata save...") - try: - from PiFinder.sqm.save_sweep_metadata import ( - save_sweep_metadata, - ) - - # Get GPS datetime (not Pi time) - gps_datetime = shared_state.datetime() - logger.debug(f"GPS datetime: {gps_datetime}") - - # Get observer location - location = shared_state.location() - logger.debug( - f"Location: lat={location.lat}, lon={location.lon}, alt={location.altitude}" - ) - - # Get current solve with RA/Dec/Alt/Az - solve_state = shared_state.solution() - ra_deg = None - dec_deg = None - altitude_deg = None - azimuth_deg = None - - if solve_state is not None: - ra_deg = solve_state.get("RA") - dec_deg = solve_state.get("Dec") - altitude_deg = solve_state.get("Alt") - azimuth_deg = solve_state.get("Az") - logger.debug( - f"Solve: RA={ra_deg}, Dec={dec_deg}, Alt={altitude_deg}, Az={azimuth_deg}" - ) - - # Save metadata - logger.info( - f"Calling save_sweep_metadata for {sweep_dir}" - ) - save_sweep_metadata( - sweep_dir=sweep_dir, - observer_lat=location.lat, - observer_lon=location.lon, - observer_altitude_m=location.altitude, - gps_datetime=gps_datetime.isoformat() - if gps_datetime - else None, - reference_sqm=reference_sqm, - ra_deg=ra_deg, - dec_deg=dec_deg, - altitude_deg=altitude_deg, - azimuth_deg=azimuth_deg, - notes=f"Exposure sweep: {num_images} images, {min_exp/1000:.1f}-{max_exp/1000:.1f}ms", - ) - logger.info( - f"Successfully saved sweep metadata to {sweep_dir}/sweep_metadata.json" - ) - except Exception as e: - logger.error( - f"Failed to save sweep metadata: {e}", exc_info=True - ) - - console_queue.put("CAM: Sweep done!") - logger.info( - f"Exposure sweep completed: {num_images} image pairs in {sweep_dir}" - ) - - if command.startswith("stop"): - self.stop_camera() - console_queue.put("CAM: Stopped camera") - if command.startswith("start"): - self.start_camera() - console_queue.put("CAM: Started camera") - except ValueError as e: - logger.error( - f"Error processing camera command '{command}': {str(e)}" - ) - console_queue.put( - f"CAM ERROR: Invalid command format - {str(e)}" - ) - except AttributeError as e: - logger.error( - f"Camera component not initialized for command '{command}': {str(e)}" - ) - console_queue.put("CAM ERROR: Camera not properly initialized") - except Exception as e: - logger.error( - f"Unexpected error processing camera command '{command}': {str(e)}" - ) - console_queue.put(f"CAM ERROR: {str(e)}") - logger.info( - f"CameraInterface: Camera loop exited with command: '{command}'" - ) + logger.info("CameraInterface: Camera loop exited") except (BrokenPipeError, EOFError, FileNotFoundError): logger.exception("Error in Camera Loop") diff --git a/python/PiFinder/cat_images.py b/python/PiFinder/cat_images.py deleted file mode 100644 index ae4a9b967..000000000 --- a/python/PiFinder/cat_images.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/python -# -*- coding:utf-8 -*- -""" -This module is used at runtime -to handle catalog image loading -""" - -import os -from PIL import Image, ImageChops, ImageDraw -from PiFinder import image_util -from PiFinder import utils -import PiFinder.ui.ui_utils as ui_utils -import logging - -BASE_IMAGE_PATH = f"{utils.data_dir}/catalog_images" -CATALOG_PATH = f"{utils.astro_data_dir}/pifinder_objects.db" - - -logger = logging.getLogger("Catalog.Images") - - -def get_display_image( - catalog_object, - eyepiece_text, - fov, - roll, - display_class, - burn_in=True, - magnification=None, -): - """ - Returns a 128x128 image buffer for - the catalog object/source - Resizing/cropping as needed to achieve FOV - in degrees - fov: 1-.125 - roll: - degrees - """ - - object_image_path = resolve_image_name(catalog_object, source="POSS") - logger.debug("object_image_path = %s", object_image_path) - if not os.path.exists(object_image_path): - return_image = Image.new("RGB", display_class.resolution) - ri_draw = ImageDraw.Draw(return_image) - if burn_in: - ri_draw.text( - (30, 50), - _("No Image"), - font=display_class.fonts.large.font, - fill=display_class.colors.get(128), - ) - else: - return_image = Image.open(object_image_path) - - # rotate for roll / newtonian orientation - image_rotate = 180 - if roll is not None: - image_rotate += roll - - return_image = return_image.rotate(image_rotate) - - # FOV - fov_size = int(1024 * fov / 2) - return_image = return_image.crop( - ( - 512 - fov_size, - 512 - fov_size, - 512 + fov_size, - 512 + fov_size, - ) - ) - return_image = return_image.resize( - (display_class.fov_res, display_class.fov_res), Image.LANCZOS - ) - - # RED - return_image = image_util.make_red(return_image, display_class.colors) - - if burn_in: - # circle - _circle_dim = Image.new( - "RGB", - (display_class.fov_res, display_class.fov_res), - display_class.colors.get(127), - ) - _circle_draw = ImageDraw.Draw(_circle_dim) - _circle_draw.ellipse( - [2, 2, display_class.fov_res - 2, display_class.fov_res - 2], - fill=display_class.colors.get(255), - ) - return_image = ImageChops.multiply(return_image, _circle_dim) - - ri_draw = ImageDraw.Draw(return_image) - ri_draw.ellipse( - [2, 2, display_class.fov_res - 2, display_class.fov_res - 2], - outline=display_class.colors.get(64), - width=1, - ) - - # Pad out image if needed - if display_class.fov_res != display_class.resX: - pad_image = Image.new("RGB", display_class.resolution) - pad_image.paste( - return_image, - ( - int((display_class.resX - display_class.fov_res) / 2), - 0, - ), - ) - return_image = pad_image - ri_draw = ImageDraw.Draw(return_image) - if display_class.fov_res != display_class.resY: - pad_image = Image.new("RGB", display_class.resolution) - pad_image.paste( - return_image, - ( - 0, - int((display_class.resY - display_class.fov_res) / 2), - ), - ) - return_image = pad_image - ri_draw = ImageDraw.Draw(return_image) - - if burn_in: - # Top text - FOV on left, magnification on right - ui_utils.shadow_outline_text( - ri_draw, - (1, display_class.titlebar_height - 1), - f"{fov:0.2f}°", - font=display_class.fonts.base, - align="left", - fill=display_class.colors.get(254), - shadow_color=display_class.colors.get(0), - outline=2, - ) - - magnification_text = ( - f"{magnification:.0f}x" if magnification and magnification > 0 else "?x" - ) - ui_utils.shadow_outline_text( - ri_draw, - ( - display_class.resX - (display_class.fonts.base.width * 4), - display_class.titlebar_height - 1, - ), - magnification_text, - font=display_class.fonts.base, - align="right", - fill=display_class.colors.get(254), - shadow_color=display_class.colors.get(0), - outline=2, - ) - - # Bottom text - only eyepiece information - ui_utils.shadow_outline_text( - ri_draw, - (1, display_class.resY - (display_class.fonts.base.height * 1.1)), - eyepiece_text, - font=display_class.fonts.base, - align="left", - fill=display_class.colors.get(128), - shadow_color=display_class.colors.get(0), - outline=2, - ) - - return return_image - - -def resolve_image_name(catalog_object, source): - """ - returns the image path for this object - """ - - def create_image_path(image_name): - last_char = str(image_name)[-1] - image = f"{BASE_IMAGE_PATH}/{last_char}/{image_name}_{source}.jpg" - exists = os.path.exists(image) - return exists, image - - # Try primary name - image_name = f"{catalog_object.catalog_code}{catalog_object.sequence}" - ok, image = create_image_path(image_name) - - if ok: - catalog_object.image_name = image - return image - - # Try alternatives - for name in catalog_object.names: - alt_image_name = f"{''.join(name.split())}" - ok, image = create_image_path(alt_image_name) - if ok: - catalog_object.image_name = image - return image - - return "" - - -def create_catalog_image_dirs(): - """ - Checks for and creates catalog_image dirs - """ - if not os.path.exists(BASE_IMAGE_PATH): - os.makedirs(BASE_IMAGE_PATH) - - for i in range(0, 10): - _image_dir = f"{BASE_IMAGE_PATH}/{i}" - if not os.path.exists(_image_dir): - os.makedirs(_image_dir) diff --git a/python/PiFinder/catalog_base.py b/python/PiFinder/catalog_base.py index 6f07f3f47..12ad7d2ea 100644 --- a/python/PiFinder/catalog_base.py +++ b/python/PiFinder/catalog_base.py @@ -171,7 +171,7 @@ def assign_virtual_object_ids(catalog, low_id: int) -> int: class TimerMixin: """Provides timer functionality via composition""" - def __init__(self): + def __init__(self) -> None: self.timer: Optional[threading.Timer] = None self.is_running: bool = False self.time_delay_seconds: Union[int, Callable[[], int]] = ( diff --git a/python/PiFinder/catalog_imports/catalog_import_utils.py b/python/PiFinder/catalog_imports/catalog_import_utils.py index 279169df3..50fce5d12 100644 --- a/python/PiFinder/catalog_imports/catalog_import_utils.py +++ b/python/PiFinder/catalog_imports/catalog_import_utils.py @@ -213,7 +213,8 @@ def insert_catalog_max_sequence(catalog_name): if result: query = f""" update catalogs set max_sequence = { - dict(result)['MAX(sequence)']} where catalog_code = '{catalog_name}' + dict(result)["MAX(sequence)"] + } where catalog_code = '{catalog_name}' """ db_c.execute(query) conn.commit() @@ -322,7 +323,7 @@ def resolve_object_images(): ORDER BY {priority_case_sql} ) as priority_rank FROM catalog_objects co - WHERE co.catalog_code IN ({','.join(['?'] * len(catalog_priority))}) + WHERE co.catalog_code IN ({",".join(["?"] * len(catalog_priority))}) ) SELECT o.id as object_id, diff --git a/python/PiFinder/catalog_imports/main.py b/python/PiFinder/catalog_imports/main.py index c09a04931..7b6ce300f 100644 --- a/python/PiFinder/catalog_imports/main.py +++ b/python/PiFinder/catalog_imports/main.py @@ -120,6 +120,14 @@ def main(): resolve_object_images() print_database() + # Finalize database for read-only deployment (NixOS) + logging.info("Finalizing database for read-only deployment...") + conn, _ = objects_db.get_conn_cursor() + conn.execute("PRAGMA journal_mode = DELETE") # Required for read-only FS + conn.execute("VACUUM") # Compact database + conn.commit() + logging.info("Database finalization complete") + if __name__ == "__main__": main() diff --git a/python/PiFinder/catalog_imports/specialized_loaders.py b/python/PiFinder/catalog_imports/specialized_loaders.py index 5e29e7703..303d5df02 100644 --- a/python/PiFinder/catalog_imports/specialized_loaders.py +++ b/python/PiFinder/catalog_imports/specialized_loaders.py @@ -602,7 +602,7 @@ def expand(name): for additional in parts[1:]: if additional.isdigit(): # If the additional part is a number, add it directly - expanded_list.append(f"{base_part[:-len(additional)]}{additional}") + expanded_list.append(f"{base_part[: -len(additional)]}{additional}") else: expanded_list.append(additional) else: diff --git a/python/PiFinder/catalog_imports/wds_loader.py b/python/PiFinder/catalog_imports/wds_loader.py index 96d798630..58d0b9e61 100644 --- a/python/PiFinder/catalog_imports/wds_loader.py +++ b/python/PiFinder/catalog_imports/wds_loader.py @@ -263,7 +263,7 @@ def handle_multiples(key, values) -> dict: coord_2000 = entry["Coordinates_2000"] coord_arcsec = entry["Coordinates_Arcsec"] logging.error( - f"Empty or invalid RA/DEC detected for WDS object at line {i+1}" + f"Empty or invalid RA/DEC detected for WDS object at line {i + 1}" ) logging.error(f" Coordinates_2000: '{coord_2000}'") logging.error(f" Coordinates_Arcsec: '{coord_arcsec}'") @@ -273,7 +273,7 @@ def handle_multiples(key, values) -> dict: ) logging.error(f" Final RA: {entry['ra']}, DEC: {entry['dec']}") raise ValueError( - f"Invalid RA/DEC coordinates for WDS object at line {i+1}: RA={entry['ra']}, DEC={entry['dec']}" + f"Invalid RA/DEC coordinates for WDS object at line {i + 1}: RA={entry['ra']}, DEC={entry['dec']}" ) # make a dictionary of WDS objects to group duplicates diff --git a/python/PiFinder/catalogs.py b/python/PiFinder/catalogs.py index e8a527e35..e335cbe1b 100644 --- a/python/PiFinder/catalogs.py +++ b/python/PiFinder/catalogs.py @@ -478,6 +478,12 @@ def is_loading(self) -> bool: and self._background_loader._thread.is_alive() ) + def start_background_loading(self): + """Start deferred catalog loading in background thread. + Call after event loop is ready to avoid SD I/O contention during startup.""" + if hasattr(self, "_background_loader") and self._background_loader is not None: + self._background_loader.start() + def __repr__(self): return f"Catalogs(\n{pformat(self.get_catalogs(only_selected=False))})" @@ -624,21 +630,18 @@ class CatalogBackgroundLoader: def __init__( self, - deferred_catalog_objects: List[Dict], - objects: Dict[int, Dict], - common_names: Names, - obs_db: ObservationsDatabase, + deferred_catalog_objects: List[Dict] = None, + objects: Dict[int, Dict] = None, + common_names: Names = None, + obs_db: ObservationsDatabase = None, on_progress: Optional[callable] = None, on_complete: Optional[callable] = None, + priority_codes: tuple = None, ): """ - Args: - deferred_catalog_objects: List of catalog_object dicts to load - objects: Object data dict by ID - common_names: Names lookup instance - obs_db: Observations database instance - on_progress: Callback(loaded_count, total_count, catalog_code) - on_complete: Callback(loaded_objects: List[CompositeObject]) + Two modes: + 1. Pre-loaded data: pass deferred_catalog_objects, objects, common_names + 2. Self-loading: pass priority_codes (loader queries DB in background thread) """ self._deferred_data = deferred_catalog_objects self._objects = objects @@ -646,6 +649,7 @@ def __init__( self._obs_db = obs_db self._on_progress = on_progress self._on_complete = on_complete + self._priority_codes = priority_codes self._loaded_objects: List[CompositeObject] = [] self._lock = threading.Lock() @@ -653,8 +657,8 @@ def __init__( self._stop_flag = threading.Event() # Performance tuning - load in batches with CPU yielding - self.batch_size = 100 # Objects per batch before yielding CPU - self.yield_time = 0.05 # Seconds to sleep between batches (50ms) + self.batch_size = 25 # Objects per batch before yielding CPU + self.yield_time = 0.1 # Seconds to sleep between batches (100ms) def start(self) -> None: """Start background loading in daemon thread""" @@ -681,6 +685,23 @@ def get_loaded_objects(self) -> List[CompositeObject]: def _load_deferred_objects(self) -> None: """Background worker - loads objects in batches with CPU yielding""" try: + if self._deferred_data is None and self._priority_codes is not None: + # Self-loading mode: query DB for deferred catalog data + start = time.time() + db = ObjectsDatabase() + all_catalog_objects = [dict(row) for row in db.get_catalog_objects()] + self._deferred_data = [ + co + for co in all_catalog_objects + if co["catalog_code"] not in self._priority_codes + ] + self._objects = {row["id"]: dict(row) for row in db.get_objects()} + self._names = Names() + logger.info( + f"Background loader data load took {time.time() - start:.2f}s " + f"for {len(self._deferred_data)} deferred objects" + ) + total = len(self._deferred_data) batch = [] current_catalog = None @@ -778,15 +799,24 @@ def build(self, shared_state, ui_queue=None) -> Catalogs: db: Database = ObjectsDatabase() obs_db: Database = ObservationsDatabase() - # list of dicts, one dict for each entry in the catalog_objects table - catalog_objects: List[Dict] = [dict(row) for row in db.get_catalog_objects()] - objects = db.get_objects() - common_names = Names() + priority_codes = ("NGC", "IC", "M") + + # Fast path: single JOIN query for priority catalog data + start = time.time() + priority_rows = db.get_priority_catalog_joined(priority_codes) + priority_names = db.get_priority_names(priority_codes) catalogs_info = db.get_catalogs_dict() - objects = {row["id"]: dict(row) for row in objects} + logger.info(f"Priority data queries took {time.time() - start:.2f}s") - composite_objects: List[CompositeObject] = self._build_composite( - catalog_objects, objects, common_names, obs_db, ui_queue + # Build priority CompositeObjects directly from joined rows + start = time.time() + composite_objects = [] + for row in priority_rows: + obj = self._create_composite_from_row(row, priority_names, obs_db) + composite_objects.append(obj) + logger.info( + f"Priority object construction took {time.time() - start:.2f}s " + f"for {len(composite_objects)} objects" ) # This is used for caching catalog dicts @@ -794,15 +824,25 @@ def build(self, shared_state, ui_queue=None) -> Catalogs: self.catalog_dicts = {} logger.debug("Loaded %i objects from database", len(composite_objects)) + start = time.time() all_catalogs: Catalogs = self._get_catalogs(composite_objects, catalogs_info) + logger.info(f"_get_catalogs took {time.time() - start:.2f}s") # Store catalogs reference for background loader completion self._pending_catalogs_ref = all_catalogs - # Pass background loader reference to Catalogs instance so it can check loading status - # This is set in _build_composite() if there are deferred objects - if hasattr(self, "_background_loader") and self._background_loader is not None: - all_catalogs._background_loader = self._background_loader + # Create background loader for deferred catalogs (not started yet — + # call catalogs.start_background_loading() after event loop starts + # to avoid SD I/O contention during menu init) + loader = CatalogBackgroundLoader( + priority_codes=priority_codes, + obs_db=obs_db, + on_progress=self._on_loader_progress, + on_complete=lambda objs: self._on_loader_complete(objs, ui_queue), + ) + self._background_loader = loader + all_catalogs._background_loader = self._background_loader + # Initialize planet catalog with whatever date we have for now # This will be re-initialized on activation of Catalog ui module # if we have GPS lock @@ -812,59 +852,52 @@ def build(self, shared_state, ui_queue=None) -> Catalogs: ) all_catalogs.add(planet_catalog) - # Import CometCatalog locally to avoid circular import - from PiFinder.comet_catalog import CometCatalog + # Defer CometCatalog creation to background thread (3-4s init with + # network check + ephemeris calculation not needed at startup) + def _init_comet_catalog(): + try: + from PiFinder.comet_catalog import CometCatalog + + start = time.time() + comet_catalog: Catalog = CometCatalog( + datetime.datetime.now().replace(tzinfo=pytz.timezone("UTC")), + shared_state=shared_state, + ) + all_catalogs.add(comet_catalog) + logger.info(f"CometCatalog init took {time.time() - start:.2f}s") + except Exception as e: + logger.error(f"CometCatalog init failed: {e}") - comet_catalog: Catalog = CometCatalog( - datetime.datetime.now().replace(tzinfo=pytz.timezone("UTC")), - shared_state=shared_state, - ) - all_catalogs.add(comet_catalog) + threading.Thread( + target=_init_comet_catalog, daemon=True, name="CometCatalogInit" + ).start() assert self.check_catalogs_sequences(all_catalogs) is True return all_catalogs - def check_catalogs_sequences(self, catalogs: Catalogs): - for catalog in catalogs.get_catalogs(only_selected=False): - result = catalog.check_sequences() - if not result: - logger.error("Duplicate sequence catalog %s!", catalog.catalog_code) - return False - return True - - def _create_full_composite_object( - self, - catalog_obj: Dict, - objects: Dict[int, Dict], - common_names: Names, - obs_db: ObservationsDatabase, - ) -> CompositeObject: - """Create a composite object with all details populated""" - object_id = catalog_obj["object_id"] - obj_data = objects[object_id] - - # Create composite object with all details - composite_data = { - "id": catalog_obj["id"], - "object_id": object_id, - "ra": obj_data["ra"], - "dec": obj_data["dec"], - "obj_type": obj_data["obj_type"], - "catalog_code": catalog_obj["catalog_code"], - "sequence": catalog_obj["sequence"], - "description": catalog_obj.get("description", ""), - "const": obj_data.get("const", ""), - "size": obj_data.get("size", ""), - "surface_brightness": obj_data.get("surface_brightness", None), - } + def _create_composite_from_row(self, row, names_dict, obs_db): + """Build CompositeObject directly from a joined query row.""" + object_id = row["object_id"] + + composite_instance = CompositeObject( + id=row["id"], + object_id=object_id, + ra=row["ra"], + dec=row["dec"], + obj_type=row["obj_type"], + catalog_code=row["catalog_code"], + sequence=row["sequence"], + description=row["description"] or "", + const=row["const"] or "", + size=row["size"] or "", + surface_brightness=row["surface_brightness"], + ) - composite_instance = CompositeObject.from_dict(composite_data) - composite_instance.names = common_names.id_to_names.get(object_id, []) + composite_instance.names = names_dict.get(object_id, []) composite_instance.logged = obs_db.check_logged(composite_instance) - # Parse magnitude try: - mag = MagnitudeObject.from_json(obj_data.get("mag", "")) + mag = MagnitudeObject.from_json(row["mag"] or "") composite_instance.mag = mag composite_instance.mag_str = mag.calc_two_mag_representation() except Exception: @@ -874,63 +907,17 @@ def _create_full_composite_object( composite_instance._details_loaded = True return composite_instance - def _build_composite( - self, - catalog_objects: List[Dict], - objects: Dict[int, Dict], - common_names: Names, - obs_db: ObservationsDatabase, - ui_queue=None, - ) -> List[CompositeObject]: - """ - Build composite objects with priority loading. - Popular catalogs (M, NGC, IC) are loaded immediately. - Other catalogs (WDS, etc.) are loaded in background. - """ - # Separate high-priority catalogs from low-priority ones - priority_catalogs = {"NGC", "IC", "M"} # Most popular catalogs - - priority_objects = [] - deferred_objects = [] - - for catalog_obj in catalog_objects: - if catalog_obj["catalog_code"] in priority_catalogs: - priority_objects.append(catalog_obj) - else: - deferred_objects.append(catalog_obj) - - # Load priority catalogs synchronously (fast - ~13K objects) - composite_objects = [] - for catalog_obj in priority_objects: - obj = self._create_full_composite_object( - catalog_obj, objects, common_names, obs_db - ) - composite_objects.append(obj) - - # Store reference for background loader completion callback - self._pending_catalogs_ref = None - - # Start background loader for deferred objects - if deferred_objects: - loader = CatalogBackgroundLoader( - deferred_catalog_objects=deferred_objects, - objects=objects, - common_names=common_names, - obs_db=obs_db, - on_progress=self._on_loader_progress, - on_complete=lambda objs: self._on_loader_complete(objs, ui_queue), - ) - loader.start() - - # Store loader reference for potential stop/test access - self._background_loader = loader - - return composite_objects + def check_catalogs_sequences(self, catalogs: Catalogs): + for catalog in catalogs.get_catalogs(only_selected=False): + result = catalog.check_sequences() + if not result: + logger.error("Duplicate sequence catalog %s!", catalog.catalog_code) + return False + return True def _on_loader_progress(self, loaded: int, total: int, catalog: str) -> None: """Progress callback - log every 10K objects""" - if loaded % 10000 == 0 or loaded == total: - logger.info(f"Background loading: {loaded}/{total} ({catalog})") + pass # Muted to reduce log noise def _on_loader_complete( self, loaded_objects: List[CompositeObject], ui_queue diff --git a/python/PiFinder/comets.py b/python/PiFinder/comets.py index 9430094ff..f4ebec466 100644 --- a/python/PiFinder/comets.py +++ b/python/PiFinder/comets.py @@ -8,6 +8,7 @@ import os import logging import math +import time logger = logging.getLogger("Comets") @@ -212,8 +213,9 @@ def calc_comets( if result: comet_dict[result["name"]] = result - # Report progress + # Yield CPU to UI thread every comet processed += 1 + time.sleep(0.05) if progress_callback and total_comets > 0: progress = int((processed / total_comets) * 100) progress_callback(progress) diff --git a/python/PiFinder/db/objects_db.py b/python/PiFinder/db/objects_db.py index b52057f41..5156cfa9b 100644 --- a/python/PiFinder/db/objects_db.py +++ b/python/PiFinder/db/objects_db.py @@ -11,23 +11,7 @@ class ObjectsDatabase(Database): def __init__(self, db_path=utils.pifinder_db): conn, cursor = self.get_database(db_path) super().__init__(conn, cursor, db_path) - - # Performance optimizations for Pi/SD card environments - logging.info("Applying database performance optimizations...") - self.cursor.execute("PRAGMA foreign_keys = ON;") - self.cursor.execute("PRAGMA mmap_size = 268435456;") # 256MB memory mapping - self.cursor.execute("PRAGMA cache_size = -64000;") # 64MB cache (negative = KB) - self.cursor.execute("PRAGMA temp_store = MEMORY;") # Keep temporary data in RAM - self.cursor.execute( - "PRAGMA journal_mode = WAL;" - ) # Write-ahead logging for better concurrency - self.cursor.execute( - "PRAGMA synchronous = NORMAL;" - ) # Balanced safety/performance - logging.info("Database optimizations applied") - - self.conn.commit() - self.bulk_mode = False # Flag to disable commits during bulk operations + self.bulk_mode = False def create_tables(self): # Create objects table @@ -318,6 +302,53 @@ def get_catalog_objects(self): ) return results + def get_priority_catalog_joined(self, priority_codes=("NGC", "IC", "M")): + """Combined JOIN query: catalog_objects + objects for priority catalogs only.""" + start_time = time.time() + placeholders = ",".join("?" * len(priority_codes)) + self.cursor.execute( + f""" + SELECT co.id, co.object_id, co.catalog_code, co.sequence, co.description, + o.ra, o.dec, o.obj_type, o.const, o.size, o.mag, o.surface_brightness + FROM catalog_objects co + JOIN objects o ON co.object_id = o.id + WHERE co.catalog_code IN ({placeholders}) + """, + priority_codes, + ) + rows = self.cursor.fetchall() + elapsed = time.time() - start_time + logging.info( + f"get_priority_catalog_joined took {elapsed:.2f}s, returned {len(rows)} rows" + ) + return rows + + def get_priority_names(self, priority_codes=("NGC", "IC", "M")): + """Get names only for objects in priority catalogs (much smaller than full names table).""" + start_time = time.time() + placeholders = ",".join("?" * len(priority_codes)) + self.cursor.execute( + f""" + SELECT n.object_id, n.common_name FROM names n + WHERE n.object_id IN ( + SELECT DISTINCT co.object_id FROM catalog_objects co + WHERE co.catalog_code IN ({placeholders}) + ) + """, + priority_codes, + ) + results = self.cursor.fetchall() + name_dict = defaultdict(list) + for object_id, common_name in results: + name_dict[object_id].append(common_name.strip()) + for object_id in name_dict: + name_dict[object_id] = list(set(name_dict[object_id])) + elapsed = time.time() - start_time + logging.info( + f"get_priority_names took {elapsed:.2f}s, {len(results)} rows for {len(name_dict)} objects" + ) + return name_dict + # ---- IMAGES_OBJECTS methods ---- def insert_image_object(self, object_id, image_name): self.cursor.execute( diff --git a/python/PiFinder/displays.py b/python/PiFinder/displays.py index 9b53551b4..e41020e1e 100644 --- a/python/PiFinder/displays.py +++ b/python/PiFinder/displays.py @@ -1,4 +1,5 @@ import functools +import logging from collections import namedtuple import numpy as np @@ -10,7 +11,9 @@ from luma.lcd.device import st7789 from PiFinder.ui.fonts import Fonts +from PiFinder.keyboard_interface import KeyboardInterface +logger = logging.getLogger("Display") ColorMask = namedtuple("ColorMask", ["mask", "mode"]) RED_RGB: ColorMask = ColorMask(np.array([1, 0, 0]), "RGB") @@ -64,14 +67,91 @@ def __init__(self): def set_brightness(self, brightness: int) -> None: return None + def set_keyboard_queue(self, q) -> None: + pass + + +# Pygame key → PiFinder keycode mapping (mirrors keyboard_local.py) +_PYGAME_KEY_MAP: dict[int, int] = {} + + +def _build_key_map(pg) -> dict[int, int]: + if _PYGAME_KEY_MAP: + return _PYGAME_KEY_MAP + KI = KeyboardInterface + m = { + pg.K_LEFT: KI.LEFT, + pg.K_UP: KI.UP, + pg.K_DOWN: KI.DOWN, + pg.K_RIGHT: KI.RIGHT, + pg.K_q: KI.PLUS, + pg.K_a: KI.MINUS, + pg.K_z: KI.SQUARE, + pg.K_w: KI.ALT_PLUS, + pg.K_s: KI.ALT_MINUS, + pg.K_d: KI.ALT_LEFT, + pg.K_r: KI.ALT_UP, + pg.K_f: KI.ALT_DOWN, + pg.K_g: KI.ALT_RIGHT, + pg.K_e: KI.ALT_0, + pg.K_j: KI.LNG_LEFT, + pg.K_i: KI.LNG_UP, + pg.K_k: KI.LNG_DOWN, + pg.K_l: KI.LNG_RIGHT, + pg.K_m: KI.LNG_SQUARE, + pg.K_0: 0, + pg.K_1: 1, + pg.K_2: 2, + pg.K_3: 3, + pg.K_4: 4, + pg.K_5: 5, + pg.K_6: 6, + pg.K_7: 7, + pg.K_8: 8, + pg.K_9: 9, + } + _PYGAME_KEY_MAP.update(m) + return _PYGAME_KEY_MAP + + +def _patch_pygame_keyboard(display_obj): + """Replace luma's _abort on the pygame device to capture keyboard events.""" + device = display_obj.device + pg = device._pygame + key_map = _build_key_map(pg) + + def _abort_with_keys(): + for event in pg.event.get(): + if event.type == pg.QUIT: + return True + if event.type == pg.KEYDOWN: + if event.key == pg.K_ESCAPE: + return True + q = display_obj._keyboard_queue + if q is not None: + keycode = key_map.get(event.key) + if keycode is not None: + q.put(keycode) + return False + + device._abort = _abort_with_keys + class DisplayPygame_128(DisplayBase): resolution = (128, 128) def __init__(self): from luma.emulator.device import pygame + import pygame as pg + from pathlib import Path - # init display (SPI hardware) + # Set window icon to welcome splash screen before creating display + icon_path = Path(__file__).parent.parent.parent / "images" / "welcome.png" + if icon_path.exists(): + icon = pg.image.load(str(icon_path)) + pg.display.set_icon(icon) + + self._keyboard_queue = None pygame = pygame( width=128, height=128, @@ -82,16 +162,28 @@ def __init__(self): frame_rate=60, ) self.device = pygame + _patch_pygame_keyboard(self) super().__init__() + def set_keyboard_queue(self, q) -> None: + self._keyboard_queue = q + class DisplayPygame_320(DisplayBase): resolution = (320, 240) def __init__(self): from luma.emulator.device import pygame + import pygame as pg + from pathlib import Path - # init display (SPI hardware) + # Set window icon to welcome splash screen before creating display + icon_path = Path(__file__).parent.parent.parent / "images" / "welcome.png" + if icon_path.exists(): + icon = pg.image.load(str(icon_path)) + pg.display.set_icon(icon) + + self._keyboard_queue = None pygame = pygame( width=320, height=240, @@ -100,8 +192,12 @@ def __init__(self): frame_rate=60, ) self.device = pygame + _patch_pygame_keyboard(self) super().__init__() + def set_keyboard_queue(self, q) -> None: + self._keyboard_queue = q + class DisplaySSD1351(DisplayBase): resolution = (128, 128) diff --git a/python/PiFinder/get_images.py b/python/PiFinder/get_images.py index 8bedfec7b..f3012873e 100644 --- a/python/PiFinder/get_images.py +++ b/python/PiFinder/get_images.py @@ -5,14 +5,18 @@ images from AWS """ -import requests import os -from tqdm import tqdm from concurrent.futures import ThreadPoolExecutor, as_completed from typing import List, Tuple -from PiFinder import cat_images +import requests +from tqdm import tqdm + from PiFinder.db.objects_db import ObjectsDatabase +from PiFinder.object_images.poss_provider import ( + BASE_IMAGE_PATH, + create_catalog_image_dirs, +) def check_missing_images() -> List[str]: @@ -34,9 +38,7 @@ def check_missing_images() -> List[str]: missing_images = [] for image_name in tqdm(image_names, desc="Checking existing images"): # Check if POSS image exists (primary check) - poss_path = ( - f"{cat_images.BASE_IMAGE_PATH}/{image_name[-1]}/{image_name}_POSS.jpg" - ) + poss_path = f"{BASE_IMAGE_PATH}/{image_name[-1]}/{image_name}_POSS.jpg" if not os.path.exists(poss_path): missing_images.append(image_name) @@ -79,7 +81,7 @@ def fetch_images_for_object( # Download POSS image poss_filename = f"{image_name}_POSS.jpg" - poss_path = f"{cat_images.BASE_IMAGE_PATH}/{seq_ones}/{poss_filename}" + poss_path = f"{BASE_IMAGE_PATH}/{seq_ones}/{poss_filename}" poss_url = f"https://ddbeeedxfpnp0.cloudfront.net/catalog_images/{seq_ones}/{poss_filename}" poss_success, poss_error = download_image_from_url(session, poss_url, poss_path) @@ -88,7 +90,7 @@ def fetch_images_for_object( # Download SDSS image sdss_filename = f"{image_name}_SDSS.jpg" - sdss_path = f"{cat_images.BASE_IMAGE_PATH}/{seq_ones}/{sdss_filename}" + sdss_path = f"{BASE_IMAGE_PATH}/{seq_ones}/{sdss_filename}" sdss_url = f"https://ddbeeedxfpnp0.cloudfront.net/catalog_images/{seq_ones}/{sdss_filename}" sdss_success, sdss_error = download_image_from_url(session, sdss_url, sdss_path) @@ -154,7 +156,7 @@ def main(): """ Main function to check for and download missing catalog images. """ - cat_images.create_catalog_image_dirs() + create_catalog_image_dirs() print("Checking for missing images...") missing_images = check_missing_images() diff --git a/python/PiFinder/gps_gpsd.py b/python/PiFinder/gps_gpsd.py index 12f29c7fb..96df6c435 100644 --- a/python/PiFinder/gps_gpsd.py +++ b/python/PiFinder/gps_gpsd.py @@ -4,7 +4,6 @@ This module is for GPS related functions """ -import asyncio from PiFinder.multiproclogging import MultiprocLogging from gpsdclient import GPSDClient import logging @@ -43,66 +42,8 @@ def is_tpv_accurate(tpv_dict): return False -async def aiter_wrapper(sync_iter): - """Wrap a synchronous iterable into an asynchronous one.""" - for item in sync_iter: - yield item - await asyncio.sleep(0) # Yield control to the event loop - - -async def process_sky_messages(client, gps_queue): - sky_stream = client.dict_stream(filter=["SKY"]) - global error_2d, error_3d - async for result in aiter_wrapper(sky_stream): - logger.debug("GPS: SKY: %s", result) - if result["class"] == "SKY": - error_2d = result.get("hdop", 999) - error_3d = result.get("pdop", 999) - if result["class"] == "SKY" and "nSat" in result: - sats_seen = result["nSat"] - sats_used = result["uSat"] - num_sats = (sats_seen, sats_used) - msg = ("satellites", num_sats) - logger.debug("Number of sats seen: %i", sats_seen) - gps_queue.put(msg) - await asyncio.sleep(0) # Yield control to the event loop - - -async def process_reading_messages(client, gps_queue, console_queue, gps_locked): - global error_in_m - tpv_stream = client.dict_stream(convert_datetime=True, filter=["TPV"]) - async for result in aiter_wrapper(tpv_stream): - if is_tpv_accurate(result): - # if True: - logger.debug("last reading is %s", result) - if result.get("lat") and result.get("lon") and result.get("altHAE"): - if not gps_locked: - gps_locked = True - console_queue.put("GPS: Locked") - logger.debug("GPS locked") - msg = ( - "fix", - { - "lat": result.get("lat"), - "lon": result.get("lon"), - "altitude": result.get("altHAE"), - "source": "GPS", - "lock": True, - "lock_type": result.get("mode", 0), - "error_in_m": error_in_m, - }, - ) - logger.debug("GPS fix: %s", msg) - gps_queue.put(msg) - - if result.get("time"): - msg = ("time", result.get("time")) - logger.debug("Setting time to %s", result.get("time")) - gps_queue.put(msg) - await asyncio.sleep(0) # Yield control to the event loop - - -async def gps_main(gps_queue, console_queue, log_queue): +def gps_main(gps_queue, console_queue, log_queue): + global error_2d, error_3d, error_in_m MultiprocLogging.configurer(log_queue) logger.info("Using GPSD GPS code") gps_locked = False @@ -110,24 +51,57 @@ async def gps_main(gps_queue, console_queue, log_queue): while True: try: with GPSDClient(host="127.0.0.1") as client: - while True: - logger.debug("GPS waking") - - # Run both functions concurrently - await asyncio.gather( - process_sky_messages(client, gps_queue), - process_reading_messages( - client, gps_queue, console_queue, gps_locked - ), - ) - - logger.debug("GPS sleeping now for 7s") - await asyncio.sleep(7) + for result in client.dict_stream( + convert_datetime=True, filter=["TPV", "SKY"] + ): + if result["class"] == "TPV" and is_tpv_accurate(result): + logger.debug("last reading is %s", result) + if ( + result.get("lat") + and result.get("lon") + and result.get("altHAE") + ): + if not gps_locked: + gps_locked = True + console_queue.put("GPS: Locked") + logger.debug("GPS locked") + msg = ( + "fix", + { + "lat": result.get("lat"), + "lon": result.get("lon"), + "altitude": result.get("altHAE"), + "source": "GPS", + "lock": True, + "lock_type": result.get("mode", 0), + "error_in_m": error_in_m, + }, + ) + logger.debug("GPS fix: %s", msg) + gps_queue.put(msg) + + if result.get("time"): + msg = ("time", result.get("time")) + logger.debug("Setting time to %s", result.get("time")) + gps_queue.put(msg) + + if result["class"] == "SKY": + logger.debug("GPS: SKY: %s", result) + print("GPS: SKY: %s", result) + if result["class"] == "SKY": + error_2d = result.get("hdop", 999) + error_3d = result.get("pdop", 999) + if result["class"] == "SKY" and "nSat" in result: + sats_seen = result["nSat"] + sats_used = result["uSat"] + num_sats = (sats_seen, sats_used) + msg = ("satellites", num_sats) + logger.debug("Number of sats seen: %i", sats_seen) + gps_queue.put(msg) except Exception as e: logger.error(f"Error in GPS monitor: {e}") - await asyncio.sleep(5) # Wait before attempting to reconnect # To run the GPS monitor def gps_monitor(gps_queue, console_queue, log_queue): - asyncio.run(gps_main(gps_queue, console_queue, log_queue)) + gps_main(gps_queue, console_queue, log_queue) diff --git a/python/PiFinder/gps_ubx_parser.py b/python/PiFinder/gps_ubx_parser.py index 5627b1af6..36bdb3847 100644 --- a/python/PiFinder/gps_ubx_parser.py +++ b/python/PiFinder/gps_ubx_parser.py @@ -159,7 +159,7 @@ async def connect(cls, log_queue, host="127.0.0.1", port=2947, max_attempts=5): async def from_file(cls, file_path: str): """Create a UBXParser instance from a file.""" f = await aiofiles.open(file_path, "rb") - return cls(log_queue=None, reader=f, file_path=file_path) # type:ignore[arg-type] + return cls(log_queue=None, reader=f, file_path=file_path) async def close(self): """Clean up resources and close the connection.""" diff --git a/python/PiFinder/image_util.py b/python/PiFinder/image_util.py index 7580c3e01..83815f93f 100644 --- a/python/PiFinder/image_util.py +++ b/python/PiFinder/image_util.py @@ -10,7 +10,6 @@ from PIL import Image, ImageChops import numpy as np -import scipy.ndimage def make_red(in_image, colors): @@ -37,6 +36,8 @@ def gamma_correct(in_value, gamma): def subtract_background(image, percent=1): + import scipy.ndimage + image = np.asarray(image, dtype=np.float32) if image.ndim == 3: assert image.shape[2] in (1, 3), "Colour image must have 1 or 3 colour channels" diff --git a/python/PiFinder/imu_pi.py b/python/PiFinder/imu_pi.py index e1d7744ad..4e475cbb8 100644 --- a/python/PiFinder/imu_pi.py +++ b/python/PiFinder/imu_pi.py @@ -11,8 +11,6 @@ import adafruit_bno055 import logging -from scipy.spatial.transform import Rotation - from PiFinder import config logger = logging.getLogger("IMU.pi") @@ -81,6 +79,8 @@ def __init__(self): ) def quat_to_euler(self, quat): + from scipy.spatial.transform import Rotation + if quat[0] + quat[1] + quat[2] + quat[3] == 0: return 0, 0, 0 rot = Rotation.from_quat(quat) diff --git a/python/PiFinder/keyboard_local.py b/python/PiFinder/keyboard_local.py index 3f6028367..6ba3b61fe 100644 --- a/python/PiFinder/keyboard_local.py +++ b/python/PiFinder/keyboard_local.py @@ -31,10 +31,16 @@ class KeyboardLocal(KeyboardInterface): def __init__(self, q): try: from PyHotKey import Key, keyboard + + logger.info("PyHotKey imported successfully") except ModuleNotFoundError: logger.error("pyhotkey not supported on pi hardware") return + except Exception as e: + logger.error(f"Failed to import PyHotKey: {e}", exc_info=True) + return try: + logger.info("Setting up keyboard bindings...") self.q = q # Configure unmodified keys keyboard.set_magickey_on_release(Key.left, self.callback, self.LEFT) @@ -79,10 +85,11 @@ def __init__(self, q): keyboard.set_magickey_on_release("i", self.callback, self.LNG_UP) keyboard.set_magickey_on_release("k", self.callback, self.LNG_DOWN) keyboard.set_magickey_on_release("l", self.callback, self.LNG_RIGHT) + logger.info("Keyboard bindings set up successfully") except Exception as e: - logger.error("KeyboardLocal.__init__: {}".format(e)) + logger.error("KeyboardLocal.__init__ failed: {}".format(e), exc_info=True) # keyboard.logger = True - logger.debug("KeyboardLocal.__init__") + logger.info("KeyboardLocal.__init__ complete") def callback(self, key): self.q.put(key) @@ -90,9 +97,79 @@ def callback(self, key): def run_keyboard(q, shared_state, log_queue, bloom_remap=False): MultiprocLogging.configurer(log_queue) - KeyboardLocal(q) - while True: - # the KeyboardLocal class has callbacks to handle - # keypresses. We just need to not terminate here - time.sleep(1) + logger.info("Keyboard process starting...") + + # Try pynput directly first (more reliable on macOS) + try: + from pynput import keyboard as pynput_keyboard # type: ignore[import-untyped] + + logger.info("Using pynput for keyboard handling") + + # Key mapping + key_map = { + pynput_keyboard.Key.left: KeyboardInterface.LEFT, + pynput_keyboard.Key.up: KeyboardInterface.UP, + pynput_keyboard.Key.down: KeyboardInterface.DOWN, + pynput_keyboard.Key.right: KeyboardInterface.RIGHT, + "q": KeyboardInterface.PLUS, + "a": KeyboardInterface.MINUS, + "z": KeyboardInterface.SQUARE, + "m": KeyboardInterface.LNG_SQUARE, + "0": 0, + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "6": 6, + "7": 7, + "8": 8, + "9": 9, + "w": KeyboardInterface.ALT_PLUS, + "s": KeyboardInterface.ALT_MINUS, + "d": KeyboardInterface.ALT_LEFT, + "r": KeyboardInterface.ALT_UP, + "f": KeyboardInterface.ALT_DOWN, + "g": KeyboardInterface.ALT_RIGHT, + "e": KeyboardInterface.ALT_0, + "j": KeyboardInterface.LNG_LEFT, + "i": KeyboardInterface.LNG_UP, + "k": KeyboardInterface.LNG_DOWN, + "l": KeyboardInterface.LNG_RIGHT, + } + + def on_release(key): + try: + # Handle special keys + if key in key_map: + q.put(key_map[key]) + logger.debug(f"Key released: {key} -> {key_map[key]}") + # Handle character keys + elif hasattr(key, "char") and key.char in key_map: + q.put(key_map[key.char]) + logger.debug(f"Key released: {key.char} -> {key_map[key.char]}") + except Exception as e: + logger.error(f"Error handling key: {e}") + + # Start listener + listener = pynput_keyboard.Listener(on_release=on_release) + listener.start() + logger.info("pynput keyboard listener started") + + while True: + time.sleep(1) + + except Exception as e: + logger.error(f"pynput failed, falling back to PyHotKey: {e}", exc_info=True) + + # Fallback to PyHotKey + try: + KeyboardLocal(q) + logger.info("KeyboardLocal initialized successfully") + except Exception as e2: + logger.error(f"Failed to initialize KeyboardLocal: {e2}", exc_info=True) + return + + while True: + time.sleep(1) diff --git a/python/PiFinder/keyboard_none.py b/python/PiFinder/keyboard_none.py index 96d433077..e65efa662 100644 --- a/python/PiFinder/keyboard_none.py +++ b/python/PiFinder/keyboard_none.py @@ -20,7 +20,7 @@ def callback(self, key): self.q.put(key) -def run_keyboard(q, shared_state, log_queue): +def run_keyboard(q, shared_state, log_queue, bloom_remap=False): MultiprocLogging.configurer(log_queue) KeyboardNone(q) diff --git a/python/PiFinder/main.py b/python/PiFinder/main.py index 28ba07de4..3ec2b0e58 100644 --- a/python/PiFinder/main.py +++ b/python/PiFinder/main.py @@ -239,53 +239,11 @@ def sleep_screen(self): self.display_device.device.show() -def start_profiling(): - """Start profiling for performance analysis""" - import cProfile - - profiler = cProfile.Profile() - profiler.enable() - startup_profile_start = time.time() - return profiler, startup_profile_start - - -def stop_profiling(profiler, startup_profile_start): - """Stop profiling and save results""" - import pstats - - profiler.disable() - startup_profile_time = time.time() - startup_profile_start - profile_path = utils.data_dir / "startup_profile.prof" - profiler.dump_stats(str(profile_path)) - - logger = logging.getLogger("Main.Profiling") - logger.info(f"=== Startup Profiling Complete ({startup_profile_time:.2f}s) ===") - logger.info(f"Profile saved to: {profile_path}") - logger.info("To analyze, run:") - logger.info( - f" python -c \"import pstats; p = pstats.Stats('{profile_path}'); p.sort_stats('cumulative').print_stats(30)\"" - ) - - summary_path = utils.data_dir / "startup_profile.txt" - with open(summary_path, "w") as f: - ps = pstats.Stats(profiler, stream=f) - f.write(f"=== STARTUP PROFILING ({startup_profile_time:.2f}s) ===\n\n") - f.write("Top 30 functions by cumulative time:\n") - f.write("=" * 80 + "\n") - ps.sort_stats("cumulative").print_stats(30) - f.write("\n" + "=" * 80 + "\n") - f.write("Top 30 functions by internal time:\n") - f.write("=" * 80 + "\n") - ps.sort_stats("time").print_stats(30) - logger.info(f"Text summary saved to: {summary_path}") - - def main( log_helper: MultiprocLogging, script_name=None, show_fps=False, verbose=False, - profile_startup=False, ) -> None: """ Get this show on the road! @@ -302,6 +260,7 @@ def main( # init queues console_queue: Queue = Queue() keyboard_queue: Queue = Queue() + display_device.set_keyboard_queue(keyboard_queue) gps_queue: Queue = Queue() camera_command_queue: Queue = Queue() solver_queue: Queue = Queue() @@ -505,14 +464,10 @@ def main( ) posserver_process.start() - # Initialize Catalogs console.write(" Catalogs") logger.info(" Catalogs") console.update() - # Start profiling (uncomment to enable performance analysis) - # profiler, startup_profile_start = start_profiling() - # Initialize Catalogs (pass ui_queue for background loading completion signal) catalogs: Catalogs = CatalogBuilder().build(shared_state, ui_queue) @@ -520,6 +475,18 @@ def main( _new_filter = CatalogFilter(shared_state=shared_state) _new_filter.load_from_config(cfg) catalogs.set_catalog_filter(_new_filter) + + # Initialize Gaia chart generator in background to avoid first-use delay + console.write(" Gaia Charts") + console.update() + logger.info(" Initializing Gaia chart generator...") + from PiFinder.object_images.gaia_chart import get_gaia_chart_generator + + chart_gen = get_gaia_chart_generator(cfg, shared_state) + # Trigger background loading so catalog is ready when needed + chart_gen.ensure_catalog_loading() + logger.info(" Gaia chart background loading started") + console.write(" Menus") console.update() @@ -536,18 +503,71 @@ def main( # Initialize power manager power_manager = PowerManager(cfg, shared_state, display_device) - # Start main event loop - console.write(" Event Loop") - logger.info(" Event Loop") + # Startup complete — clear welcome backdrop + console.write(" Ready") console.update() + console.finish_startup() - # Stop profiling (uncomment to analyze startup performance) - # stop_profiling(profiler, startup_profile_start) + # Start deferred catalog loading now that UI is ready + logger.info(" Event Loop") + catalogs.start_background_loading() log_time = True + + # Set up Pygame event handling if using Pygame display + pygame_events_enabled = display_hardware in ["pg_128", "pg_320"] + if pygame_events_enabled: + import pygame + from PiFinder.keyboard_interface import KeyboardInterface + + logger.info("Pygame event polling enabled for keyboard input") + + # Key mapping for Pygame + pygame_key_map = { + pygame.K_LEFT: KeyboardInterface.LEFT, + pygame.K_UP: KeyboardInterface.UP, + pygame.K_DOWN: KeyboardInterface.DOWN, + pygame.K_RIGHT: KeyboardInterface.RIGHT, + pygame.K_q: KeyboardInterface.PLUS, + pygame.K_a: KeyboardInterface.MINUS, + pygame.K_z: KeyboardInterface.SQUARE, + pygame.K_m: KeyboardInterface.LNG_SQUARE, + pygame.K_0: 0, + pygame.K_1: 1, + pygame.K_2: 2, + pygame.K_3: 3, + pygame.K_4: 4, + pygame.K_5: 5, + pygame.K_6: 6, + pygame.K_7: 7, + pygame.K_8: 8, + pygame.K_9: 9, + pygame.K_w: KeyboardInterface.ALT_PLUS, + pygame.K_s: KeyboardInterface.ALT_MINUS, + pygame.K_d: KeyboardInterface.ALT_LEFT, + pygame.K_r: KeyboardInterface.ALT_UP, + pygame.K_f: KeyboardInterface.ALT_DOWN, + pygame.K_g: KeyboardInterface.ALT_RIGHT, + pygame.K_e: KeyboardInterface.ALT_0, + pygame.K_j: KeyboardInterface.LNG_LEFT, + pygame.K_i: KeyboardInterface.LNG_UP, + pygame.K_k: KeyboardInterface.LNG_DOWN, + pygame.K_l: KeyboardInterface.LNG_RIGHT, + } + # Start of main except handler / loop try: while True: + # Poll Pygame events if using Pygame display + if pygame_events_enabled: + for event in pygame.event.get(): + if event.type == pygame.KEYDOWN: + if event.key in pygame_key_map: + keyboard_queue.put(pygame_key_map[event.key]) + elif event.type == pygame.QUIT: + logger.info("Pygame window closed, exiting...") + raise KeyboardInterrupt + # Console try: console_msg = console_queue.get(block=False) @@ -629,6 +649,9 @@ def main( except queue.Empty: pass + # Gaia catalog loading removed - now lazy-loads on first chart view + # (object_images triggers loading when needed) + # ui queue try: ui_command = ui_queue.get(block=False) @@ -953,13 +976,6 @@ def main( help="Force user interface language (iso2 code). Changes configuration", type=str, ) - parser.add_argument( - "--profile-startup", - help="Profile startup performance (catalog/menu loading)", - default=False, - action="store_true", - required=False, - ) args = parser.parse_args() # add the handlers to the logger if args.verbose: @@ -982,8 +998,7 @@ def main( # verify and sync GPSD baud rate try: - from PiFinder import sys_utils - + sys_utils = utils.get_sys_utils() baud_rate = cfg.get_option( "gps_baud_rate", 9600 ) # Default to 9600 if not set @@ -1013,16 +1028,26 @@ def main( rlogger.warn("not using camera") from PiFinder import camera_none as camera # type: ignore[no-redef] - if args.keyboard.lower() == "pi": - from PiFinder import keyboard_pi as keyboard + # When using Pygame display, use built-in event polling (no keyboard subprocess needed) + if display_hardware in ["pg_128", "pg_320"]: + from PiFinder import keyboard_none as keyboard + + rlogger.info("using pygame built-in keyboard (no subprocess)") + elif args.keyboard.lower() == "pi": + from PiFinder import keyboard_pi as keyboard # type: ignore[no-redef] rlogger.info("using pi keyboard hat") elif args.keyboard.lower() == "local": - from PiFinder import keyboard_local as keyboard # type: ignore[no-redef] + if display_hardware.startswith("pg_"): + from PiFinder import keyboard_none as keyboard + + rlogger.info("using pygame keyboard (display captures keys)") + else: + from PiFinder import keyboard_local as keyboard # type: ignore[no-redef] - rlogger.info("using local keyboard") + rlogger.info("using local keyboard") elif args.keyboard.lower() == "none": - from PiFinder import keyboard_none as keyboard # type: ignore[no-redef] + from PiFinder import keyboard_none as keyboard rlogger.warning("using no keyboard") @@ -1033,7 +1058,7 @@ def main( config.Config().set_option("language", args.lang) try: - main(log_helper, args.script, args.fps, args.verbose, args.profile_startup) + main(log_helper, args.script, args.fps, args.verbose) except Exception: rlogger.exception("Exception in main(). Aborting program.") os._exit(1) diff --git a/python/PiFinder/multiproclogging.py b/python/PiFinder/multiproclogging.py index 92d36dccd..2f8bee958 100644 --- a/python/PiFinder/multiproclogging.py +++ b/python/PiFinder/multiproclogging.py @@ -10,7 +10,6 @@ import multiprocessing.queues from pathlib import Path from multiprocessing import Queue, Process -import multiprocessing from queue import Empty from time import sleep from typing import TextIO, List, Optional @@ -83,9 +82,9 @@ def apply_config(self): def start(self, initial_queue: Optional[Queue] = None): assert self._proc is None, "You should only start once!" - assert ( - len(self._queues) >= 1 - ), "No queues in use. You should have requested at least one queue." + assert len(self._queues) >= 1, ( + "No queues in use. You should have requested at least one queue." + ) self._proc = Process( target=self._run_sink, @@ -170,9 +169,9 @@ def configurer(queue: Queue): log messages. """ assert queue is not None, "You passed a None to configurer! You cannot do that" - assert isinstance( - queue, multiprocessing.queues.Queue - ), "That's not a Queue! You have to pass a queue" + assert isinstance(queue, multiprocessing.queues.Queue), ( + "That's not a Queue! You have to pass a queue" + ) log_conf_file = Path("pifinder_logconf.json") with open(log_conf_file, "r") as logconf: diff --git a/python/PiFinder/nearby.py b/python/PiFinder/nearby.py index f2ac7448d..d285b1fbc 100644 --- a/python/PiFinder/nearby.py +++ b/python/PiFinder/nearby.py @@ -2,7 +2,6 @@ from typing import List import time import numpy as np -from sklearn.neighbors import BallTree import logging logger = logging.getLogger("Catalog.Nearby") @@ -74,6 +73,8 @@ def calculate_objects_balltree(self, objects: list[CompositeObject]) -> None: object_radecs = np.array( [[np.deg2rad(x.ra), np.deg2rad(x.dec)] for x in deduplicated_objects] ) + from sklearn.neighbors import BallTree + self._objects = np.array(deduplicated_objects) self._objects_balltree = BallTree( object_radecs, leaf_size=20, metric="haversine" diff --git a/python/PiFinder/object_images/__init__.py b/python/PiFinder/object_images/__init__.py new file mode 100644 index 000000000..690593c5f --- /dev/null +++ b/python/PiFinder/object_images/__init__.py @@ -0,0 +1,71 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Object image providers for catalog objects + +Provides POSS survey images and generated Gaia star charts +""" + +from typing import Union, Generator +from PIL import Image +from .poss_provider import POSSImageProvider +from .chart_provider import ChartImageProvider +from .image_base import ImageProvider + + +def get_display_image( + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=True, + force_chart=False, + **kwargs, +) -> Union[Image.Image, Generator]: + """ + Get display image for catalog object + + Returns POSS image if available, otherwise generated Gaia chart. + Use force_chart=True to prefer chart even if POSS exists. + + Args: + catalog_object: The astronomical object to image + eyepiece_text: Eyepiece description for overlay + fov: Field of view in degrees + roll: Rotation angle in degrees + display_class: Display configuration object + burn_in: Whether to add overlays (FOV, mag, etc.) + force_chart: Force Gaia chart even if POSS exists + **kwargs: Additional provider-specific parameters + + Returns: + PIL.Image for POSS images + Generator yielding progressive images for Gaia charts + """ + provider: ImageProvider + if force_chart: + provider = ChartImageProvider( + kwargs.get("config_object"), kwargs.get("shared_state") + ) + else: + poss = POSSImageProvider() + if poss.can_provide(catalog_object): + provider = poss + else: + provider = ChartImageProvider( + kwargs.get("config_object"), kwargs.get("shared_state") + ) + + return provider.get_image( + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=burn_in, + **kwargs, + ) + + +__all__ = ["get_display_image", "POSSImageProvider", "ChartImageProvider"] diff --git a/python/PiFinder/object_images/chart_provider.py b/python/PiFinder/object_images/chart_provider.py new file mode 100644 index 000000000..624d90181 --- /dev/null +++ b/python/PiFinder/object_images/chart_provider.py @@ -0,0 +1,132 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Gaia chart provider - generates star charts from Gaia catalog +""" + +from pathlib import Path +from typing import Generator +from PIL import ImageChops +from PiFinder import utils +from .image_base import ImageProvider, ImageType +import logging + +logger = logging.getLogger("PiFinder.ChartProvider") + + +class ChartImageProvider(ImageProvider): + """ + Provides dynamically generated Gaia star charts + + Uses the GaiaChartGenerator to create on-demand star charts + from the HEALPix-indexed Gaia star catalog. Returns a generator + that yields progressive updates as magnitude bands load. + """ + + def __init__(self, config_object, shared_state): + """ + Initialize chart provider + + Args: + config_object: PiFinder config object + shared_state: Shared state object + """ + self.config_object = config_object + self.shared_state = shared_state + self._chart_generator = None + + def can_provide(self, catalog_object, **kwargs) -> bool: + """ + Check if Gaia chart can be generated + + Returns True if Gaia star catalog exists + """ + gaia_catalog_path = Path(utils.data_dir, "gaia_stars", "metadata.json") + return gaia_catalog_path.exists() + + def get_image( + self, + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=True, + magnification=None, + config_object=None, + shared_state=None, + **kwargs, + ) -> Generator: + """ + Generate Gaia star chart + + Yields progressive chart updates as magnitude bands load. + Each yielded image has an `is_loading_placeholder` attribute + indicating whether it's a loading screen or actual chart. + + Returns: + Generator yielding PIL.Image objects + """ + from .image_utils import create_loading_image, create_no_image_placeholder + + # Get chart generator (singleton) + if self._chart_generator is None: + from .gaia_chart import get_gaia_chart_generator + + self._chart_generator = get_gaia_chart_generator( + self.config_object, self.shared_state + ) + + gaia_catalog_path = Path(utils.data_dir, "gaia_stars", "metadata.json") + + if not gaia_catalog_path.exists(): + logger.warning(f"Gaia star catalog not found at {gaia_catalog_path}") + placeholder = create_no_image_placeholder(display_class, burn_in=burn_in) + yield placeholder + return + + try: + # Ensure catalog loading started + logger.debug("Calling chart_generator.ensure_catalog_loading()...") + self._chart_generator.ensure_catalog_loading() + logger.debug(f"Catalog state: {self._chart_generator.get_catalog_state()}") + + # Create generator that yields converted images + for image in self._chart_generator.generate_chart( + catalog_object, + (display_class.fov_res, display_class.fov_res), + burn_in=burn_in, + display_class=display_class, + roll=roll, + ): + if image is None: + # Catalog not ready yet, show "Loading..." with progress + if self._chart_generator.catalog: + progress_text = self._chart_generator.catalog.load_progress + progress_percent = self._chart_generator.catalog.load_percent + else: + progress_text = "Initializing..." + progress_percent = 0 + + loading_image = create_loading_image( + display_class, + message="Loading...", + progress_text=progress_text, + progress_percent=progress_percent, + ) + loading_image.image_type = ImageType.LOADING + yield loading_image + else: + # Convert chart to red and yield it + red_image = ImageChops.multiply( + image.convert("RGB"), display_class.colors.red_image + ) + # Mark as Gaia chart image + red_image.image_type = ImageType.GAIA_CHART # type: ignore[attr-defined] + yield red_image + + except Exception as e: + logger.error(f"Gaia chart generation failed: {e}", exc_info=True) + placeholder = create_no_image_placeholder(display_class, burn_in=burn_in) + placeholder.image_type = ImageType.ERROR + yield placeholder diff --git a/python/PiFinder/object_images/gaia_chart.py b/python/PiFinder/object_images/gaia_chart.py new file mode 100644 index 000000000..09e244134 --- /dev/null +++ b/python/PiFinder/object_images/gaia_chart.py @@ -0,0 +1,1085 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Gaia star chart generator for objects without DSS/POSS images + +Generates on-demand star charts using HEALPix-indexed Gaia star catalog. +Features: +- Equipment-aware FOV and magnitude limits +- Stereographic projection (matching chart.py) +- Center marker for target object +- Info overlays (FOV, magnification, eyepiece) +- Caching for performance +""" + +import logging +from pathlib import Path +from typing import Generator, Optional, Tuple + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from PiFinder import utils +from PiFinder.object_images.star_catalog import CatalogState, GaiaStarCatalog +from PiFinder.object_images.image_utils import ( + pad_to_display_resolution, + add_image_overlays, +) + +logger = logging.getLogger("PiFinder.GaiaChart") + +# Global singleton instance to ensure same catalog across all uses +_gaia_chart_generator_instance = None + + +def get_gaia_chart_generator(config, shared_state): + """Get or create the global chart generator singleton""" + global _gaia_chart_generator_instance + logger.debug( + f">>> get_gaia_chart_generator() called, instance exists: {_gaia_chart_generator_instance is not None}" + ) + if _gaia_chart_generator_instance is None: + logger.info(">>> Creating new GaiaChartGenerator instance...") + _gaia_chart_generator_instance = GaiaChartGenerator(config, shared_state) + logger.info( + f">>> GaiaChartGenerator created, state: {_gaia_chart_generator_instance.get_catalog_state()}" + ) + else: + logger.debug( + f">>> Returning existing instance, state: {_gaia_chart_generator_instance.get_catalog_state()}" + ) + return _gaia_chart_generator_instance + + +class GaiaChartGenerator: + """ + Generate on-demand star charts with equipment-aware settings + + Usage: + gen = GaiaChartGenerator(config, shared_state) + image = gen.generate_chart(catalog_object, (128, 128), burn_in=True) + """ + + def __init__(self, config, shared_state): + """ + Initialize chart generator + + Args: + config: PiFinder config object + shared_state: Shared state object + """ + logger.info(">>> GaiaChartGenerator.__init__() called") + self.config = config + self.shared_state = shared_state + self.catalog = None + self.chart_cache = {} + self._lm_cache = None # Cache (sqm, eyepiece_id, lm) to avoid recalculation + + # Initialize font for text overlays + font_path = Path(Path.cwd(), "../fonts/RobotoMonoNerdFontMono-Bold.ttf") + try: + self.small_font = ImageFont.truetype(str(font_path), 8) + except Exception as e: + logger.warning(f"Failed to load font {font_path}: {e}, using default") + self.small_font = ImageFont.load_default() + + def get_catalog_state(self) -> CatalogState: + """Get current catalog loading state""" + if self.catalog is None: + return CatalogState.NOT_LOADED + return self.catalog.state + + def ensure_catalog_loading(self): + """ + Ensure catalog is loading or loaded + Triggers background load if needed + """ + logger.debug( + f">>> ensure_catalog_loading() called, catalog is None: {self.catalog is None}" + ) + + if self.catalog is None: + logger.info(">>> Calling initialize_catalog()...") + self.initialize_catalog() + logger.info(f">>> initialize_catalog() done, state: {self.catalog.state}") + + if self.catalog.state == CatalogState.NOT_LOADED: + # Trigger background load + location = self.shared_state.location() + sqm = self.shared_state.sqm() + + observer_lat = location.lat if location and location.lock else None + limiting_mag = self.get_limiting_magnitude(sqm) + + logger.info( + f">>> Starting background catalog load: lat={observer_lat}, mag_limit={limiting_mag:.1f}" + ) + self.catalog.start_background_load(observer_lat, limiting_mag) + logger.info( + f">>> start_background_load() called, new state: {self.catalog.state}" + ) + + def initialize_catalog(self): + """Create catalog instance (doesn't load data yet)""" + catalog_path = Path(utils.data_dir, "gaia_stars") + logger.info(f">>> initialize_catalog() - catalog_path: {catalog_path}") + + # Check if catalog exists before initializing + metadata_file = catalog_path / "metadata.json" + if not metadata_file.exists(): + logger.warning(f"Gaia star catalog not found at {catalog_path}") + logger.warning( + "To build catalog, run: python -m PiFinder.catalog_tools.gaia_downloader --mag-limit 12 --output /tmp/gaia.csv" + ) + logger.warning( + "Then: python -m PiFinder.catalog_tools.healpix_builder --input /tmp/gaia.csv --output {}/astro_data/gaia_stars".format( + Path.home() / "PiFinder" + ) + ) + + logger.info(">>> Creating GaiaStarCatalog instance...") + import time + + t0 = time.time() + self.catalog = GaiaStarCatalog(str(catalog_path)) + t_init = (time.time() - t0) * 1000 + logger.info(f">>> GaiaStarCatalog.__init__() took {t_init:.1f}ms") + logger.info( + f">>> Catalog initialized: {catalog_path}, state: {self.catalog.state}" + ) + + def generate_chart( + self, + catalog_object, + resolution: Tuple[int, int], + burn_in: bool = True, + display_class=None, + roll=None, + ) -> Generator[Optional[Image.Image], None, None]: + """ + Generate chart for object at current equipment settings + + Args: + catalog_object: CompositeObject with RA/Dec + resolution: (width, height) tuple + burn_in: Add FOV/mag/eyepiece overlays + + Returns: + PIL Image in RGB (red colorspace), or None if catalog not ready + """ + logger.info(f">>> generate_chart() ENTRY: object={catalog_object.display_name}") + + # Ensure catalog is loading + self.ensure_catalog_loading() + + # Check state + if self.catalog.state != CatalogState.READY: + logger.info( + f">>> Chart generation skipped: catalog state = {self.catalog.state}" + ) + yield None + return + + logger.info(">>> Catalog state is READY, proceeding...") + + # Check cache + cache_key = self.get_cache_key(catalog_object) + if cache_key in self.chart_cache: + # Return cached base image, adding overlays if needed + # Crosshair will be added by add_pulsating_crosshair() each frame + logger.debug(f"Chart cache HIT for {cache_key}") + cached_image = self.chart_cache[cache_key] + + # Make a copy to avoid modifying cached image + image = cached_image.copy() + + # ALWAYS pad to display resolution when display_class is provided + if display_class is not None: + image = pad_to_display_resolution(image, display_class) + + # Add overlays if burn_in requested + if burn_in and display_class is not None: + # Add FOV circle + draw = ImageDraw.Draw(image) + width, height = display_class.resolution + cx, cy = width / 2.0, height / 2.0 + radius = min(width, height) / 2.0 - 2 + marker_color = display_class.colors.get(64) + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + draw.ellipse(bbox, outline=marker_color, width=1) + + # Add text overlays + sqm = self.shared_state.sqm() + mag_limit_calculated = self.get_limiting_magnitude(sqm) + equipment = self.config.equipment + fov = equipment.calc_tfov() + mag = equipment.calc_magnification() + + image = add_image_overlays( + image, + display_class, + fov, + mag, + equipment.active_eyepiece, + burn_in=True, + limiting_magnitude=mag_limit_calculated, + ) + + yield image + return + + # Get equipment settings + equipment = self.config.equipment + fov = equipment.calc_tfov() + if fov <= 0: + fov = 10.0 # Default fallback + + mag = equipment.calc_magnification() + if mag <= 0: + mag = 50.0 # Default fallback + + logger.info( + f">>> Chart Generation: object={catalog_object.display_name}, center=({catalog_object.ra:.4f}, {catalog_object.dec:.4f}), fov={fov:.4f}°, mag={mag:.1f}x, eyepiece={equipment.active_eyepiece}" + ) + + sqm = self.shared_state.sqm() + mag_limit_calculated = self.get_limiting_magnitude(sqm) + # For query, cap at catalog max + mag_limit_query = min(mag_limit_calculated, 17.0) + + logger.info( + f">>> Mag Limit: calculated={mag_limit_calculated:.2f}, query={mag_limit_query:.2f}, sqm={sqm.value if sqm else 'None'}" + ) + + # Query stars PROGRESSIVELY (bright to faint) + # This is a generator that yields partial results as each magnitude band loads + import time + + t0 = time.time() + + logger.info( + f"Chart for {catalog_object.catalog_code}{catalog_object.sequence}: " + f"Center RA={catalog_object.ra:.4f}° Dec={catalog_object.dec:.4f}°, " + f"FOV={fov:.4f}°, Roll={roll if roll is not None else 0:.1f}°, " + f"Starting PROGRESSIVE loading (mag_limit={mag_limit_query:.1f})" + ) + + # Use progressive loading to show bright stars first + stars_generator = self.catalog.get_stars_for_fov_progressive( + ra_deg=catalog_object.ra, + dec_deg=catalog_object.dec, + fov_deg=fov, + mag_limit=mag_limit_query, + ) + + # Calculate rotation angle for roll / telescope orientation + # Reflectors (Newtonian, SCT) invert the image 180° + # Refractors typically don't invert (depends on eyepiece design) + # Use obstruction as heuristic: obstruction > 0 = reflector + telescope = equipment.active_telescope + if telescope and telescope.obstruction_perc > 0: + # Reflector telescope (Newtonian, SCT) - inverts image + image_rotate = 180 + else: + # Refractor or unknown - no base rotation + image_rotate = 0 + + if roll is not None: + image_rotate += roll + + # Get flip/flop settings from telescope config + flip_image = telescope.flip_image if telescope else False + flop_image = telescope.flop_image if telescope else False + + # Progressive rendering: Yield image after each magnitude band loads + # Re-render all stars each time (simple, correct, fast enough) + final_image = None + iteration_count = 0 + + logger.info(">>> Starting star generator loop...") + for stars, is_complete in stars_generator: + iteration_count += 1 + logger.info( + f">>> Star generator iteration {iteration_count}: got {len(stars)} stars, complete={is_complete}" + ) + t_render_start = time.time() + + # Render ALL stars from scratch (base image without overlays) + base_image = self.render_chart( + stars, + catalog_object.ra, + catalog_object.dec, + fov, + resolution, + mag, + image_rotate, + mag_limit_query, + flip_image=flip_image, + flop_image=flop_image, + ) + + # Store base image for caching (without overlays) + final_base_image = base_image + + # Make a copy for display (don't modify the base image) + display_image = base_image.copy() + + # ALWAYS pad to display resolution when display_class is provided + if display_class is not None: + display_image = pad_to_display_resolution(display_image, display_class) + + # Add overlays if burn_in requested + if burn_in and display_class is not None: + # Add FOV circle BEFORE text overlays so it appears behind them + draw = ImageDraw.Draw(display_image) + width, height = display_class.resolution + cx, cy = width / 2.0, height / 2.0 + radius = min(width, height) / 2.0 - 2 # Leave 2 pixel margin + marker_color = display_class.colors.get(64) # Subtle but visible + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + draw.ellipse(bbox, outline=marker_color, width=1) + + # Add text overlays (using shared utility) + display_image = add_image_overlays( + display_image, + display_class, + fov, + mag, + equipment.active_eyepiece, + burn_in=True, + limiting_magnitude=mag_limit_calculated, # Pass uncapped value for display + ) + + t_render_end = time.time() + logger.info( + f"PROGRESSIVE: Total render time {(t_render_end - t_render_start) * 1000:.1f}ms " + f"(complete={is_complete}, total_stars={len(stars)})" + ) + + # Yield display image (with or without overlays) + if not is_complete: + yield display_image + # If complete, will yield final image after loop + + # Final yield with complete image + t1 = time.time() + logger.info( + f">>> Star generator loop complete: {iteration_count} iterations, {(t1 - t0) * 1000:.1f}ms total" + ) + + if iteration_count == 0: + logger.warning( + f">>> WARNING: Star generator yielded NO results! FOV={fov:.4f}°, center=({catalog_object.ra:.4f}, {catalog_object.dec:.4f})" + ) + # Generate blank chart (no stars) - this is the base image + final_base_image = self.render_chart( + np.array([]).reshape(0, 3), # Empty star array + catalog_object.ra, + catalog_object.dec, + fov, + resolution, + mag, + image_rotate, + mag_limit_query, + flip_image=flip_image, + flop_image=flop_image, + ) + + # Cache base image (without overlays) so it can be reused + if "final_base_image" in locals() and final_base_image is not None: + self.chart_cache[cache_key] = final_base_image + if len(self.chart_cache) > 10: + # Remove oldest + oldest = next(iter(self.chart_cache)) + del self.chart_cache[oldest] + + # Create final display image + final_display_image = final_base_image.copy() + + # ALWAYS pad to display resolution when display_class is provided + if display_class is not None: + final_display_image = pad_to_display_resolution( + final_display_image, display_class + ) + + # Add overlays if burn_in requested + if burn_in and display_class is not None: + # Add FOV circle + draw = ImageDraw.Draw(final_display_image) + width, height = display_class.resolution + cx, cy = width / 2.0, height / 2.0 + radius = min(width, height) / 2.0 - 2 + marker_color = display_class.colors.get(64) + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + draw.ellipse(bbox, outline=marker_color, width=1) + + # Add overlays + final_display_image = add_image_overlays( + final_display_image, + display_class, + fov, + mag, + equipment.active_eyepiece, + burn_in=True, + limiting_magnitude=mag_limit_calculated, + ) + + yield final_display_image + else: + yield None + + def render_chart( + self, + stars: np.ndarray, + center_ra: float, + center_dec: float, + fov: float, + resolution: Tuple[int, int], + magnification: float = 50.0, + rotation: float = 0.0, + mag_limit: float = 17.0, + flip_image: bool = False, + flop_image: bool = False, + ) -> Image.Image: + """ + Render stars to PIL Image with center crosshair + Uses fast vectorized stereographic projection + + Args: + stars: Numpy array (N, 3) of (ra, dec, mag) + center_ra: Center RA in degrees + center_dec: Center Dec in degrees + fov: Field of view in degrees + resolution: (width, height) tuple + magnification: Magnification factor + rotation: Rotation angle in degrees (applied to coordinates) + + Returns: + PIL Image in RGB (black background, red stars) + """ + import time + + t_start = time.time() + + width, height = resolution + # Use NumPy array for fast pixel operations + image_array = np.zeros((height, width, 3), dtype=np.uint8) + image = Image.new("RGB", (width, height), (0, 0, 0)) + ImageDraw.Draw(image) + + logger.info( + f"Render Chart: {len(stars)} stars input, center=({center_ra:.4f}, {center_dec:.4f}), fov={fov:.4f}, res={resolution}" + ) + + # stars is already a numpy array (N, 3) + stars_array = stars + ra_arr = stars_array[:, 0] + dec_arr = stars_array[:, 1] + mag_arr = stars_array[:, 2] + t2 = time.time() + # logger.debug(f" Array conversion: {(t2-t1)*1000:.1f}ms") + + # Fast stereographic projection (vectorized) + # Convert degrees to radians + center_ra_rad = np.radians(center_ra) + center_dec_rad = np.radians(center_dec) + ra_rad = np.radians(ra_arr) + dec_rad = np.radians(dec_arr) + + # Use simple tangent plane projection (like POSS images) + # This gives linear scaling: pixels_per_degree is constant + # x = tan(ra - ra0) * cos(dec0) + # y = (tan(dec) - tan(dec0)) / cos(ra - ra0) + # Simplified for small angles: x ≈ (ra - ra0), y ≈ (dec - dec0) + + # Tangent plane projection (matches POSS images) + # For small FOV (< 10°), linear approximation works well + # IMPORTANT: Scale RA by CENTER declination, not individual star declinations + cos_center_dec = np.cos(center_dec_rad) + + dra = ra_rad - center_ra_rad + # Handle RA wrapping at 0°/360° + dra = np.where(dra > np.pi, dra - 2 * np.pi, dra) + dra = np.where(dra < -np.pi, dra + 2 * np.pi, dra) + ddec = dec_rad - center_dec_rad + + # Project onto tangent plane + # X: RA offset scaled by CENTER declination (matches POSS projection) + # Y: Dec offset (linear) + x_proj = dra * cos_center_dec + y_proj = ddec + + # Simple linear pixel scale (matches POSS behavior) + # fov degrees should map to width pixels + pixel_scale = width / np.radians(fov) + + if fov < 0.2: # Debug small FOVs + logger.info( + f">>> SMALL FOV DEBUG: fov={fov:.4f}°, pixel_scale={pixel_scale:.1f} px/rad" + ) + if len(stars) > 0: + logger.info( + f">>> Star RA range: [{np.min(ra_arr):.4f}, {np.max(ra_arr):.4f}]" + ) + logger.info( + f">>> Star Dec range: [{np.min(dec_arr):.4f}, {np.max(dec_arr):.4f}]" + ) + logger.info(f">>> Center: RA={center_ra:.4f}, Dec={center_dec:.4f}") + + # Convert to screen coordinates FIRST + # Center of field should always be at width/2, height/2 + # IMPORTANT: Flip X-axis to match POSS image orientation + # RA increases EASTWARD, which is to the LEFT when facing south + # So positive RA offset should go to the LEFT (subtract from center) + x_screen = width / 2.0 - x_proj * pixel_scale # FLIPPED: RA increases to LEFT + y_screen = height / 2.0 - y_proj * pixel_scale + + # Apply rotation to SCREEN coordinates (after scaling) + # This avoids magnifying small numerical errors + if rotation != 0: + rot_rad = np.radians(rotation) + cos_rot = np.cos(rot_rad) + sin_rot = np.sin(rot_rad) + + # Rotate around center + center_x = width / 2.0 + center_y = height / 2.0 + x_rel = x_screen - center_x + y_rel = y_screen - center_y + + x_rotated = x_rel * cos_rot - y_rel * sin_rot + y_rotated = x_rel * sin_rot + y_rel * cos_rot + + x_screen = x_rotated + center_x + y_screen = y_rotated + center_y + + # Filter stars within screen bounds only (no circular mask) + mask = ( + (x_screen >= 0) & (x_screen < width) & (y_screen >= 0) & (y_screen < height) + ) + + x_visible = x_screen[mask] + y_visible = y_screen[mask] + mag_visible = mag_arr[mask] + ra_arr[mask] + dec_arr[mask] + + logger.info( + f"Render Chart: {len(x_visible)} stars visible on screen (of {len(stars)} total)" + ) + + # Scale brightness based on FIXED magnitude range + # Use brightest visible star and LIMITING MAGNITUDE (not faintest loaded star) + # This ensures consistent intensity scaling across progressive renders + + if len(mag_visible) == 0: + intensities = np.array([]) + else: + brightest_mag = np.min(mag_visible) + faintest_mag = mag_limit # Use limiting magnitude, not max(mag_visible) + + # Always use proper magnitude scaling + # Linear scaling from brightest (255) to limiting magnitude (50) + # Note: Lower magnitude = brighter star + mag_range = faintest_mag - brightest_mag + if mag_range < 0.01: + mag_range = 0.01 # Avoid division by zero + + intensities = 255 - ((mag_visible - brightest_mag) / mag_range * 205) + intensities = np.clip(intensities, 50, 255).astype(int) + + # Render stars: crosses for bright ones, single pixels for faint + t3 = time.time() + ix = np.round(x_visible).astype(int) + iy = np.round(y_visible).astype(int) + t4 = time.time() + logger.debug(f" Star projection: {(t3 - t2) * 1000:.1f}ms") + + for i in range(len(ix)): + px = ix[i] + py = iy[i] + intensity = intensities[i] + + # Draw all stars as single pixels (no crosses) + if 0 <= px < width and 0 <= py < height: + # Use max to avoid bright blobs from overlapping stars + image_array[py, px, 0] = max(image_array[py, px, 0], intensity) + + np.clip(image_array[:, :, 0], 0, 255, out=image_array[:, :, 0]) + t5 = time.time() + logger.debug(f" Star drawing loop: {(t5 - t4) * 1000:.1f}ms ({len(ix)} stars)") + + # Convert NumPy array back to PIL Image + image = Image.fromarray(image_array, mode="RGB") + t6 = time.time() + logger.debug(f" Image conversion: {(t6 - t5) * 1000:.1f}ms") + + # Apply telescope flip/flop transformations + # flip_image = vertical flip (mirror top to bottom) + # flop_image = horizontal flip (mirror left to right) + if flip_image: + image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + if flop_image: + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + + # Note: Limiting magnitude display added by add_image_overlays() in generate_chart() + # Note: Pulsating crosshair added separately via add_pulsating_crosshair() + # so base chart can be cached + + t_end = time.time() + logger.debug(f" Total render time: {(t_end - t_start) * 1000:.1f}ms") + + # Tag image as a Gaia chart (not a loading placeholder) + # This enables the correct marking menu in UIObjectDetails + image.is_loading_placeholder = False # type: ignore[attr-defined] + + return image + + def render_chart_incremental( + self, + new_stars: np.ndarray, + base_image: Optional[Image.Image], + center_ra: float, + center_dec: float, + fov: float, + resolution: Tuple[int, int], + magnification: float = 50.0, + rotation: float = 0.0, + mag_limit: float = 17.0, + fixed_brightest_mag: Optional[float] = None, + fixed_faintest_mag: Optional[float] = None, + ) -> Image.Image: + """ + Incrementally render new stars onto existing base image. + Uses FIXED intensity scaling to maintain consistent brightness across bands. + + Args: + new_stars: Only the new stars to render + base_image: Existing image to draw onto (None for first render) + center_ra: Center RA in degrees + center_dec: Center Dec in degrees + fov: Field of view in degrees + resolution: (width, height) tuple + magnification: Magnification factor + rotation: Rotation angle in degrees + mag_limit: Limiting magnitude + fixed_brightest_mag: Brightest magnitude for intensity scaling (from first band) + fixed_faintest_mag: Faintest magnitude for intensity scaling (limiting mag) + + Returns: + PIL Image with new stars added + """ + import time + + t_start = time.time() + + width, height = resolution + + # Start with base image or create new blank one + if base_image is None: + image_array = np.zeros((height, width, 3), dtype=np.uint8) + else: + image_array = np.array(base_image) + + logger.info(f"Render Chart INCREMENTAL: {len(new_stars)} new stars") + + if len(new_stars) == 0: + return Image.fromarray(image_array, mode="RGB") + + # Use FIXED intensity scaling (established from first band + limiting mag) + if fixed_brightest_mag is None or fixed_faintest_mag is None: + # Fallback: calculate from new stars only + new_mags = new_stars[:, 2] + brightest_mag = np.min(new_mags) + faintest_mag = np.max(new_mags) + logger.warning( + f"INCREMENTAL: No fixed scale provided, using fallback: {brightest_mag:.2f} to {faintest_mag:.2f}" + ) + else: + brightest_mag = fixed_brightest_mag + faintest_mag = fixed_faintest_mag + + # Convert new stars to numpy arrays + ra_arr = new_stars[:, 0] + dec_arr = new_stars[:, 1] + mag_arr = new_stars[:, 2] + + # Projection (same as render_chart) + center_ra_rad = np.radians(center_ra) + center_dec_rad = np.radians(center_dec) + ra_rad = np.radians(ra_arr) + dec_rad = np.radians(dec_arr) + + cos_center_dec = np.cos(center_dec_rad) + + dra = ra_rad - center_ra_rad + dra = np.where(dra > np.pi, dra - 2 * np.pi, dra) + dra = np.where(dra < -np.pi, dra + 2 * np.pi, dra) + ddec = dec_rad - center_dec_rad + + x_proj = dra * cos_center_dec + y_proj = ddec + + pixel_scale = width / np.radians(fov) + + x_screen = width / 2.0 - x_proj * pixel_scale + y_screen = height / 2.0 - y_proj * pixel_scale + + # Apply rotation + if rotation != 0: + rot_rad = np.radians(rotation) + cos_rot = np.cos(rot_rad) + sin_rot = np.sin(rot_rad) + + center_x = width / 2.0 + center_y = height / 2.0 + x_rel = x_screen - center_x + y_rel = y_screen - center_y + + x_rotated = x_rel * cos_rot - y_rel * sin_rot + y_rotated = x_rel * sin_rot + y_rel * cos_rot + + x_screen = x_rotated + center_x + y_screen = y_rotated + center_y + + # Filter visible stars + mask = ( + (x_screen >= 0) & (x_screen < width) & (y_screen >= 0) & (y_screen < height) + ) + + x_visible = x_screen[mask] + y_visible = y_screen[mask] + mag_visible = mag_arr[mask] + + logger.info( + f"Render Chart INCREMENTAL: {len(x_visible)} of {len(new_stars)} new stars visible" + ) + + # Calculate intensities using GLOBAL magnitude range (from all_stars) + if len(mag_visible) == 0: + intensities = np.array([]) + elif faintest_mag - brightest_mag < 0.1: + intensities = np.full_like(mag_visible, 255, dtype=int) + else: + # Use global magnitude range for consistent scaling + intensities = 255 - ( + (mag_visible - brightest_mag) / (faintest_mag - brightest_mag) * 205 + ) + intensities = intensities.astype(int) + + # Draw new stars + ix = np.round(x_visible).astype(int) + iy = np.round(y_visible).astype(int) + + for i in range(len(ix)): + px = ix[i] + py = iy[i] + intensity = intensities[i] + + if 0 <= px < width and 0 <= py < height: + # Use max instead of add to avoid bright blobs from overlapping stars + image_array[py, px, 0] = max(image_array[py, px, 0], intensity) + + np.clip(image_array[:, :, 0], 0, 255, out=image_array[:, :, 0]) + + image = Image.fromarray(image_array, mode="RGB") + + # Tag as Gaia chart + image.is_loading_placeholder = False # type: ignore[attr-defined] + + t_end = time.time() + logger.debug(f" Incremental render time: {(t_end - t_start) * 1000:.1f}ms") + + return image + + def _draw_star_antialiased_fast(self, image_array, ix, iy, fx, fy, intensity): + """ + Draw star with bilinear anti-aliasing using fast NumPy operations + + Args: + image_array: NumPy array (height, width, 3) + ix, iy: Integer pixel coordinates (top-left) + fx, fy: Fractional offsets (0-1) + intensity: Peak intensity (0-255) + """ + # Bilinear interpolation weights + w00 = (1 - fx) * (1 - fy) # Top-left + w10 = fx * (1 - fy) # Top-right + w01 = (1 - fx) * fy # Bottom-left + w11 = fx * fy # Bottom-right + + # Apply to 2x2 region using NumPy (much faster than getpixel/putpixel) + # Red channel only (index 0) + if w00 > 0.01: + image_array[iy, ix, 0] = min( + 255, image_array[iy, ix, 0] + int(intensity * w00) + ) + if w10 > 0.01: + image_array[iy, ix + 1, 0] = min( + 255, image_array[iy, ix + 1, 0] + int(intensity * w10) + ) + if w01 > 0.01: + image_array[iy + 1, ix, 0] = min( + 255, image_array[iy + 1, ix, 0] + int(intensity * w01) + ) + if w11 > 0.01: + image_array[iy + 1, ix + 1, 0] = min( + 255, image_array[iy + 1, ix + 1, 0] + int(intensity * w11) + ) + + def mag_to_intensity(self, mag: float) -> int: + """ + Convert magnitude to red pixel intensity (0-255) + + Args: + mag: Stellar magnitude + + Returns: + Red pixel value (0-255) + """ + if mag < 3: + return 255 + elif mag < 6: + return 200 + elif mag < 9: + return 150 + elif mag < 12: + return 100 + elif mag < 14: + return 75 + else: + return 50 + + @staticmethod + def sqm_to_nelm(sqm: float) -> float: + """ + Convert SQM reading (sky brightness) to NELM (naked eye limiting magnitude) + + Formula: NELM ≈ (SQM - 8.89) / 2 + 0.5 + + Reference: https://www.unihedron.com/projects/darksky/faq.php + Unihedron manufacturer formula for SQM-L devices + + Args: + sqm: Sky Quality Meter reading in mag/arcsec² + + Returns: + Naked Eye Limiting Magnitude + + Examples: + SQM 22.0 (pristine dark sky) → NELM 7.1 + SQM 21.0 (good dark sky) → NELM 6.6 + SQM 20.0 (rural sky) → NELM 6.1 + SQM 19.0 (suburban) → NELM 5.6 + SQM 18.0 (suburban/urban) → NELM 5.1 + SQM 17.0 (urban) → NELM 4.6 + """ + nelm = (sqm - 8.89) / 2.0 + 0.5 + return nelm + + @staticmethod + def feijth_comello_limiting_magnitude( + mv: float, D: float, d: float, M: float, t: float + ) -> float: + """ + Calculate limiting magnitude using Feijth & Comello formula + + Formula: mg = mv - 2 + 2.5 × log₁₀(√(D² - d²) × M × t) + + Where: + - mv = naked eye limiting magnitude + - D = telescope aperture [cm] + - d = central obstruction diameter [cm] (0 for unobstructed) + - M = magnification + - t = transmission (100% = 1.0, typically 0.5-0.9) + + This practical formula is based on over 100,000 observations by Henk Feijth + and Georg Comello (mid-1990s). Unlike simple aperture formulas, it accounts + for obstruction, magnification, and transmission. + + References: + - https://astrobasics.de/en/basics/physical-quantities/limiting-magnitude/ + - https://www.y-auriga.de/astro/formeln.html (section 14) + - https://fr.wikipedia.org/wiki/Magnitude_limite_visuelle + + Args: + mv: Naked eye limiting magnitude + D: Aperture in cm + d: Central obstruction diameter in cm + M: Magnification + t: Transmission (0-1) + + Returns: + Telescopic limiting magnitude + + Example: + With mv=6.04, D=25cm, d=4cm, M=400, t=0.54 → mg=13.36 + """ + from math import log10, sqrt + + # Effective aperture accounting for central obstruction + # Only the (D² - d²) term is under the square root + effective_aperture = sqrt(D**2 - d**2) + + # Complete formula: mg = mv - 2 + 2.5 × log₁₀(√(D² - d²) × M × t) + mg = mv - 2.0 + 2.5 * log10(effective_aperture * M * t) + return mg + + def get_limiting_magnitude(self, sqm) -> float: + """ + Get limiting magnitude based on config mode (auto or fixed) + + Args: + sqm: SQM state object for sky brightness + + Returns: + Limiting magnitude value + """ + # Build cache key from sqm, telescope, and eyepiece focal lengths + # Round SQM to 1 decimal to avoid floating point comparison issues + equipment = self.config.equipment + telescope = equipment.active_telescope + eyepiece = equipment.active_eyepiece + + # Cache key includes all factors that affect LM calculation + telescope_fl = telescope.focal_length_mm if telescope else None + telescope_aperture = telescope.aperture_mm if telescope else None + eyepiece_fl = eyepiece.focal_length_mm if eyepiece else None + sqm_value = ( + round(sqm.value, 1) if sqm and hasattr(sqm, "value") and sqm.value else None + ) + + # Include config mode and fixed value in cache key to handle mode switching + lm_mode = self.config.get_option("obj_chart_lm_mode") + lm_fixed = self.config.get_option("obj_chart_lm_fixed") + + cache_key = ( + sqm_value, + telescope_aperture, + telescope_fl, + eyepiece_fl, + lm_mode, + lm_fixed, + ) + + # Check cache - return cached value without logging + if self._lm_cache is not None and self._lm_cache[0] == cache_key: + return self._lm_cache[1] + + if lm_mode == "fixed": + # Use fixed limiting magnitude from config + lm = self.config.get_option("obj_chart_lm_fixed") + try: + lm = float(lm) + logger.info(f"Using fixed LM from config: {lm:.1f}") + self._lm_cache = (cache_key, lm) + return lm + except (ValueError, TypeError): + # Invalid fixed value, fall back to auto + logger.warning(f"Invalid fixed LM value: {lm}, falling back to auto") + lm = self.calculate_limiting_magnitude(sqm) + self._lm_cache = (cache_key, lm) + return lm + else: + # Auto mode: calculate based on equipment and sky brightness + lm = self.calculate_limiting_magnitude(sqm) + self._lm_cache = (cache_key, lm) + return lm + + def calculate_limiting_magnitude(self, sqm) -> float: + """ + Calculate limiting magnitude using Feijth & Comello formula + + Converts SQM to NELM, then applies Feijth & Comello formula accounting + for telescope aperture, obstruction, magnification, and transmission. + + Args: + sqm: SQM state object for sky brightness + + Returns: + Limiting magnitude (uncapped - caller caps for catalog queries) + """ + + equipment = self.config.equipment + telescope = equipment.active_telescope + eyepiece = equipment.active_eyepiece + + # Get naked eye limiting magnitude from SQM + if sqm and hasattr(sqm, "value") and sqm.value: + sqm_value = sqm.value + mv = self.sqm_to_nelm(sqm_value) + else: + sqm_value = 19.5 # Default suburban sky + mv = self.sqm_to_nelm(sqm_value) # ≈ 5.8 + + # Calculate telescopic limiting magnitude + if telescope and telescope.aperture_mm > 0 and eyepiece: + # Convert aperture from mm to cm for formula + D_cm = telescope.aperture_mm / 10.0 + + # Calculate magnification + magnification = telescope.focal_length_mm / eyepiece.focal_length_mm + exit_pupil_mm = telescope.aperture_mm / magnification + + # No obstruction assumed (we don't know the secondary mirror size) + d_cm = 0.0 + + # Transmission (typical value for good optics) + transmission = 0.85 + + # Apply Feijth & Comello formula directly + # The formula already accounts for magnification effects + lm = self.feijth_comello_limiting_magnitude( + mv, D_cm, d_cm, magnification, transmission + ) + + logger.info( + f"LM calculation: mv={mv:.1f} (SQM={sqm_value:.1f}), " + f"aperture={telescope.aperture_mm:.0f}mm, mag={magnification:.1f}x, " + f"exit_pupil={exit_pupil_mm:.1f}mm → LM={lm:.1f}" + ) + elif telescope and telescope.aperture_mm > 0: + # No eyepiece: assume minimum useful magnification (exit pupil = 7mm) + D_cm = telescope.aperture_mm / 10.0 + min_magnification = telescope.aperture_mm / 7.0 + transmission = 0.85 + + lm = self.feijth_comello_limiting_magnitude( + mv, D_cm, 0.0, min_magnification, transmission + ) + logger.info( + f"LM calculation: aperture={telescope.aperture_mm}mm (no eyepiece, min mag={min_magnification:.1f}x) → LM={lm:.1f}" + ) + else: + # No telescope: use naked eye + lm = mv + logger.info(f"LM calculation: no telescope, NELM={lm:.1f}") + + # Return uncapped value (caller will cap for queries if needed) + return lm + + def get_cache_key(self, catalog_object) -> str: + """ + Generate cache key for object + eyepiece + limiting magnitude combination + + Args: + catalog_object: CompositeObject + + Returns: + Cache key string + """ + obj_key = f"{catalog_object.catalog_code}{catalog_object.sequence}" + eyepiece = self.config.equipment.active_eyepiece + eyepiece_key = str(eyepiece) if eyepiece else "none" + + # Include limiting magnitude in cache key + sqm = self.shared_state.sqm() + lm = self.get_limiting_magnitude(sqm) + lm_key = f"{lm:.1f}" + + return f"{obj_key}_{eyepiece_key}_lm{lm_key}" + + def invalidate_cache(self): + """Clear chart cache (call when equipment changes)""" + self.chart_cache.clear() + logger.debug("Chart cache invalidated") diff --git a/python/PiFinder/object_images/image_base.py b/python/PiFinder/object_images/image_base.py new file mode 100644 index 000000000..94b715a72 --- /dev/null +++ b/python/PiFinder/object_images/image_base.py @@ -0,0 +1,73 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Abstract base class for object image providers +""" + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Union, Generator +from PIL import Image + + +class ImageType(Enum): + """Image type enumeration for object images""" + + POSS = "poss" # Survey image from disk + GAIA_CHART = "gaia_chart" # Generated star chart + LOADING = "loading" # Loading placeholder + ERROR = "error" # Error placeholder + + +class ImageProvider(ABC): + """ + Base class for object image providers + + Provides a common interface for different image sources: + - POSS/survey images from disk + - Generated Gaia star charts + - Future: SDSS, online images, etc. + """ + + @abstractmethod + def can_provide(self, catalog_object, **kwargs) -> bool: + """ + Check if this provider can supply an image for the given object + + Args: + catalog_object: The astronomical object to image + **kwargs: Additional parameters (config, paths, etc.) + + Returns: + True if this provider can supply an image + """ + pass + + @abstractmethod + def get_image( + self, + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=True, + **kwargs, + ) -> Union[Image.Image, Generator]: + """ + Get image for catalog object + + Args: + catalog_object: The astronomical object to image + eyepiece_text: Eyepiece description for overlay + fov: Field of view in degrees + roll: Rotation angle in degrees + display_class: Display configuration object + burn_in: Whether to add overlays (FOV, mag, etc.) + **kwargs: Provider-specific parameters + + Returns: + PIL.Image for static images (POSS) + Generator yielding progressive images (Gaia charts) + """ + pass diff --git a/python/PiFinder/object_images/image_utils.py b/python/PiFinder/object_images/image_utils.py new file mode 100644 index 000000000..188238327 --- /dev/null +++ b/python/PiFinder/object_images/image_utils.py @@ -0,0 +1,311 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Shared image utility functions for object images + +Provides common operations for: +- POSS survey images +- Generated Gaia star charts +""" + +from PIL import Image, ImageDraw, ImageChops + + +def add_image_overlays( + image, + display_class, + fov, + magnification, + eyepiece, + burn_in=True, + limiting_magnitude=None, +): + """ + Add FOV/magnification/eyepiece overlays to image + + This function is shared by: + - POSS image display (poss_provider.py) + - Generated Gaia star charts (chart_provider.py) + + Args: + image: PIL Image to modify + display_class: Display configuration object + fov: Field of view in degrees + magnification: Telescope magnification + eyepiece: Active eyepiece object + burn_in: Whether to add overlays (default True) + limiting_magnitude: Optional limiting magnitude to display (for generated charts) + + Returns: + Modified PIL Image with overlays added + """ + if not burn_in: + return image + + from PiFinder.ui import ui_utils + + draw = ImageDraw.Draw(image) + + # Top-left: FOV in degrees + ui_utils.shadow_outline_text( + draw, + (1, display_class.titlebar_height - 1), + f"{fov:0.2f}°", + font=display_class.fonts.base, + align="left", + fill=display_class.colors.get(254), + shadow_color=display_class.colors.get(0), + outline=2, + ) + + # Top-right: Magnification + mag_text = f"{magnification:.0f}x" if magnification and magnification > 0 else "?x" + ui_utils.shadow_outline_text( + draw, + ( + display_class.resX - (display_class.fonts.base.width * 4), + display_class.titlebar_height - 1, + ), + mag_text, + font=display_class.fonts.base, + align="right", + fill=display_class.colors.get(254), + shadow_color=display_class.colors.get(0), + outline=2, + ) + + # Top-center: Limiting magnitude (for generated charts) + if limiting_magnitude is not None: + # Show ">17" if exceeds catalog limit, otherwise show actual value + if limiting_magnitude > 17.0: + lm_text = "LM:>17" + else: + lm_text = f"LM:{limiting_magnitude:.1f}" + lm_bbox = draw.textbbox((0, 0), lm_text, font=display_class.fonts.base.font) + lm_width = lm_bbox[2] - lm_bbox[0] + lm_x = (display_class.resX - lm_width) // 2 + + ui_utils.shadow_outline_text( + draw, + (lm_x, display_class.titlebar_height - 1), + lm_text, + font=display_class.fonts.base, + align="left", + fill=display_class.colors.get(254), + shadow_color=display_class.colors.get(0), + outline=2, + ) + + # Bottom-left: Eyepiece name + if eyepiece: + eyepiece_text = f"{eyepiece.focal_length_mm:.0f}mm {eyepiece.name}" + ui_utils.shadow_outline_text( + draw, + (1, display_class.resY - (display_class.fonts.base.height * 1.1)), + eyepiece_text, + font=display_class.fonts.base, + align="left", + fill=display_class.colors.get(128), # Dimmer than FOV/mag + shadow_color=display_class.colors.get(0), + outline=2, + ) + + return image + + +def create_loading_image( + display_class, message="Loading...", progress_text=None, progress_percent=0 +): + """ + Create a placeholder image with loading message and optional progress + + Args: + display_class: Display configuration object + message: Main text to display (default "Loading...") + progress_text: Optional progress status text + progress_percent: Progress percentage (0-100) + + Returns: + PIL Image with centered message and progress + """ + image = Image.new("RGB", display_class.resolution, (0, 0, 0)) + draw = ImageDraw.Draw(image) + + # Use center of display for positioning + center_x = display_class.resolution[0] // 2 + center_y = display_class.resolution[1] // 2 + + # Draw main message + text_bbox = draw.textbbox((0, 0), message, font=display_class.fonts.large.font) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + x = center_x - (text_width // 2) + y = center_y - (text_height // 2) - 20 + + draw.text( + (x, y), + message, + font=display_class.fonts.large.font, + fill=(128, 0, 0), # Medium red for night vision + ) + + # Draw progress text if provided + if progress_text: + progress_bbox = draw.textbbox( + (0, 0), progress_text, font=display_class.fonts.base.font + ) + progress_width = progress_bbox[2] - progress_bbox[0] + + px = center_x - (progress_width // 2) + py = y + text_height + 8 + + draw.text( + (px, py), + progress_text, + font=display_class.fonts.base.font, + fill=(100, 0, 0), # Dimmer red + ) + + # Draw progress bar if percentage > 0 + if progress_percent > 0: + bar_width = int(display_class.resolution[0] * 0.8) + bar_height = 4 + bar_x = center_x - (bar_width // 2) + bar_y = display_class.resolution[1] - 25 + + # Background bar + draw.rectangle( + [bar_x, bar_y, bar_x + bar_width, bar_y + bar_height], + outline=(64, 0, 0), + fill=(32, 0, 0), + ) + + # Progress fill + fill_width = int(bar_width * (progress_percent / 100)) + if fill_width > 0: + draw.rectangle( + [bar_x, bar_y, bar_x + fill_width, bar_y + bar_height], fill=(128, 0, 0) + ) + + # Percentage text + percent_text = f"{progress_percent}%" + percent_bbox = draw.textbbox( + (0, 0), percent_text, font=display_class.fonts.base.font + ) + percent_width = percent_bbox[2] - percent_bbox[0] + + draw.text( + (center_x - (percent_width // 2), bar_y + bar_height + 4), + percent_text, + font=display_class.fonts.base.font, + fill=(100, 0, 0), + ) + + return image + + +def create_no_image_placeholder(display_class, burn_in=True): + """ + Create a "No Image" placeholder + + Used when neither POSS nor Gaia chart is available + + Args: + display_class: Display configuration object + burn_in: Whether to add text (default True) + + Returns: + PIL Image with "No Image" message + """ + image = Image.new("RGB", display_class.resolution) + if burn_in: + draw = ImageDraw.Draw(image) + draw.text( + (30, 50), + "No Image", + font=display_class.fonts.large.font, + fill=display_class.colors.get(128), + ) + return image + + +def apply_circular_vignette(image, display_class): + """ + Apply circular vignette to show eyepiece FOV boundary + + Creates a circular mask that dims everything outside + the eyepiece field of view, then adds a subtle outline. + + Args: + image: PIL Image to modify + display_class: Display configuration object + + Returns: + Modified PIL Image with circular vignette + """ + # Create dimming mask (circle is full brightness, outside is dimmed) + _circle_dim = Image.new( + "RGB", + (display_class.fov_res, display_class.fov_res), + display_class.colors.get(127), # Dim the outside + ) + _circle_draw = ImageDraw.Draw(_circle_dim) + _circle_draw.ellipse( + [2, 2, display_class.fov_res - 2, display_class.fov_res - 2], + fill=display_class.colors.get(255), # Full brightness inside + ) + + # Apply dimming by multiplying + image = ImageChops.multiply(image, _circle_dim) + + # Add subtle outline + draw = ImageDraw.Draw(image) + draw.ellipse( + [2, 2, display_class.fov_res - 2, display_class.fov_res - 2], + outline=display_class.colors.get(64), + width=1, + ) + + return image + + +def pad_to_display_resolution(image, display_class): + """ + Pad image to match display resolution + + If FOV resolution differs from display resolution, + centers the image and pads with black. + + Args: + image: PIL Image to pad + display_class: Display configuration object + + Returns: + Padded PIL Image at display resolution + """ + # Pad horizontally if needed + if display_class.fov_res != display_class.resX: + pad_image = Image.new("RGB", display_class.resolution) + pad_image.paste( + image, + ( + int((display_class.resX - display_class.fov_res) / 2), + 0, + ), + ) + image = pad_image + + # Pad vertically if needed + if display_class.fov_res != display_class.resY: + pad_image = Image.new("RGB", display_class.resolution) + pad_image.paste( + image, + ( + 0, + int((display_class.resY - display_class.fov_res) / 2), + ), + ) + image = pad_image + + return image diff --git a/python/PiFinder/object_images/poss_provider.py b/python/PiFinder/object_images/poss_provider.py new file mode 100644 index 000000000..4da8b632b --- /dev/null +++ b/python/PiFinder/object_images/poss_provider.py @@ -0,0 +1,197 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +POSS image provider - loads pre-downloaded survey images from disk +""" + +import os +from PIL import Image +from PiFinder import utils +from PiFinder import image_util +from .image_base import ImageProvider, ImageType +import logging + +logger = logging.getLogger("PiFinder.POSSProvider") + +BASE_IMAGE_PATH = f"{utils.data_dir}/catalog_images" + + +class POSSImageProvider(ImageProvider): + """ + Provides POSS (Palomar Observatory Sky Survey) images from disk + + POSS images are pre-downloaded 1024x1024 JPG files stored in + subdirectories by object ID. This provider: + - Loads image from disk + - Rotates for telescope orientation + - Crops to field of view + - Resizes to display resolution + - Converts to red + - Adds circular vignette (optional) + - Adds text overlays (optional) + """ + + def can_provide(self, catalog_object, **kwargs) -> bool: + """Check if POSS image exists on disk""" + image_path = self._resolve_image_name(catalog_object, source="POSS") + return os.path.exists(image_path) + + def get_image( + self, + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=True, + magnification=None, + config_object=None, + **kwargs, + ) -> Image.Image: + """ + Load and process POSS image + + Returns: + PIL.Image with POSS image processed and overlayed + """ + from .image_utils import ( + apply_circular_vignette, + pad_to_display_resolution, + add_image_overlays, + ) + + # Load image from disk + image_path = self._resolve_image_name(catalog_object, source="POSS") + return_image = Image.open(image_path) + + # Rotate for roll / telescope orientation + # Reflectors (Newtonian, SCT) invert the image 180° + # Refractors typically don't invert (depends on eyepiece design) + # Use obstruction as heuristic: obstruction > 0 = reflector + telescope = None + if config_object and hasattr(config_object, "equipment"): + telescope = config_object.equipment.active_telescope + + if telescope and telescope.obstruction_perc > 0: + # Reflector telescope (Newtonian, SCT) - inverts image + image_rotate = 180 + else: + # Refractor or unknown - no base rotation + image_rotate = 0 + + if roll is not None: + image_rotate += roll + return_image = return_image.rotate(image_rotate) # type: ignore[assignment] + + # Apply telescope flip/flop transformations + # flip_image = vertical flip (mirror top to bottom) + # flop_image = horizontal flip (mirror left to right) + if telescope: + if telescope.flip_image: + return_image = return_image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) # type: ignore[assignment] + if telescope.flop_image: + return_image = return_image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) # type: ignore[assignment] + + # Crop to FOV + fov_size = int(1024 * fov / 2) + return_image = return_image.crop( # type: ignore[assignment] + ( + 512 - fov_size, + 512 - fov_size, + 512 + fov_size, + 512 + fov_size, + ) + ) + + # Resize to display resolution + return_image = return_image.resize( # type: ignore[assignment] + (display_class.fov_res, display_class.fov_res), Image.Resampling.LANCZOS + ) + + # Convert to red + return_image = image_util.make_red(return_image, display_class.colors) + + # Add circular vignette if burn_in + if burn_in: + return_image = apply_circular_vignette(return_image, display_class) + + # Pad to display resolution if needed + return_image = pad_to_display_resolution(return_image, display_class) + + # Add text overlays if burn_in + if burn_in: + # Get eyepiece object for overlay + if config_object and hasattr(config_object, "equipment"): + eyepiece_obj = config_object.equipment.active_eyepiece + else: + # Create minimal eyepiece object from text + class FakeEyepiece: + def __init__(self, text): + self.focal_length_mm = 0 + self.name = text + + eyepiece_obj = FakeEyepiece(eyepiece_text) + + return_image = add_image_overlays( + return_image, + display_class, + fov, + magnification, + eyepiece_obj, + burn_in=True, + ) + + # Mark as POSS image + return_image.image_type = ImageType.POSS + return return_image + + def _resolve_image_name(self, catalog_object, source): + """ + Resolve image path for this object + + Checks primary name and alternatives + + Args: + catalog_object: Object to find image for + source: Image source ("POSS", "SDSS", etc.) + + Returns: + Path to image file, or empty string if not found + """ + + def create_image_path(image_name): + last_char = str(image_name)[-1] + image = f"{BASE_IMAGE_PATH}/{last_char}/{image_name}_{source}.jpg" + exists = os.path.exists(image) + return exists, image + + # Try primary name + image_name = f"{catalog_object.catalog_code}{catalog_object.sequence}" + ok, image = create_image_path(image_name) + + if ok: + catalog_object.image_name = image + return image + + # Try alternatives + for name in catalog_object.names: + alt_image_name = f"{''.join(name.split())}" + ok, image = create_image_path(alt_image_name) + if ok: + catalog_object.image_name = image + return image + + return "" + + +def create_catalog_image_dirs(): + """ + Checks for and creates catalog_image dirs + """ + if not os.path.exists(BASE_IMAGE_PATH): + os.makedirs(BASE_IMAGE_PATH) + + for i in range(0, 10): + _image_dir = f"{BASE_IMAGE_PATH}/{i}" + if not os.path.exists(_image_dir): + os.makedirs(_image_dir) diff --git a/python/PiFinder/object_images/star_catalog.py b/python/PiFinder/object_images/star_catalog.py new file mode 100644 index 000000000..1df3ab452 --- /dev/null +++ b/python/PiFinder/object_images/star_catalog.py @@ -0,0 +1,1564 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +HEALPix-indexed star catalog loader with background loading and CPU throttling + +This module provides efficient loading of Gaia star catalogs for chart generation. +Features: +- Background loading with thread safety +- CPU throttling to avoid blocking other processes +- LRU tile caching +- Hemisphere filtering for memory efficiency +- Proper motion corrections +""" + +import json +import logging +import mmap +import struct +import threading +import time +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +import numpy as np + +# Import healpy at module level to avoid first-use delay +# This ensures the slow import happens during initialization, not during first chart render +try: + import healpy as hp # type: ignore[import-not-found] + + _HEALPY_AVAILABLE = True +except ImportError: + hp = None + _HEALPY_AVAILABLE = False + +logger = logging.getLogger("PiFinder.StarCatalog") + +# Optimized tile format: header + star records (no redundant HEALPix per star) +TILE_HEADER_FORMAT = " Optional[Tuple[int, int]]: + """ + Get (offset, size) for a tile ID. + + Returns None if tile doesn't exist. + """ + # Binary search in run directory + left, right = 0, len(self.run_directory) - 1 + run_idx = -1 + + while left <= right: + mid = (left + right) // 2 + start_tile = self.run_directory[mid][0] + + # Check if tile is in this run + if mid < len(self.run_directory) - 1: + next_start = self.run_directory[mid + 1][0] + if start_tile <= tile_id < next_start: + run_idx = mid + break + else: + # Last run + if start_tile <= tile_id: + run_idx = mid + break + + if tile_id < start_tile: + right = mid - 1 + else: + left = mid + 1 + + if run_idx == -1: + return None + + # Read run data from mmap + start_tile, data_offset = self.run_directory[run_idx] + offset_in_run = tile_id - start_tile + + # Read run header + run_length, offset_base = struct.unpack_from("= run_length: + return None + + # Read sizes up to and including our tile + sizes_offset = data_offset + 10 # After length(2) + offset_base(8) + sizes_data = self._mm[sizes_offset : sizes_offset + (offset_in_run + 1) * 2] + sizes = struct.unpack(f"<{offset_in_run + 1}H", sizes_data) + + # Calculate tile offset and size + tile_offset = offset_base + sum(sizes[:-1]) + tile_size = sizes[-1] + + return (tile_offset, tile_size) + + def close(self): + """Close mmap and file""" + if self._mm: + self._mm.close() + if self._file: + self._file.close() + + def __del__(self): + """Cleanup on deletion""" + self.close() + + +class GaiaStarCatalog: + """ + HEALPix-indexed star catalog with background loading + + Usage: + catalog = GaiaStarCatalog("/path/to/gaia_stars") + catalog.start_background_load(observer_lat=40.0, limiting_mag=14.0) + # ... wait for catalog.state == CatalogState.READY ... + stars = catalog.get_stars_for_fov(ra=180.0, dec=45.0, fov=10.0, mag_limit=12.0) + """ + + def __init__(self, catalog_path: str): + """ + Initialize catalog (doesn't load data yet) + + Args: + catalog_path: Path to gaia_stars directory containing metadata.json + """ + logger.info(f">>> GaiaStarCatalog.__init__() called with path: {catalog_path}") + self.catalog_path = Path(catalog_path) + self.state = CatalogState.NOT_LOADED + self.metadata: Optional[Dict[str, Any]] = None + self.nside: Optional[int] = None + self.observer_lat: Optional[float] = None + self.limiting_magnitude: float = 12.0 + self.visible_tiles: Optional[Set[int]] = None + self.tile_cache: Dict[Tuple[int, float], np.ndarray] = {} + self.cache_lock = threading.Lock() + self.load_thread: Optional[threading.Thread] = None + self.load_progress: str = "" # Status message for UI + self.load_percent: int = 0 # Progress percentage (0-100) + self._index_cache: Dict[str, Any] = {} + # Cache of existing tile IDs per magnitude band to avoid scanning for non-existent tiles + self._existing_tiles_cache: Dict[str, Set[int]] = {} + logger.info(">>> GaiaStarCatalog.__init__() completed") + + def start_background_load( + self, observer_lat: Optional[float] = None, limiting_mag: float = 12.0 + ): + """ + Start loading catalog in background thread + + Args: + observer_lat: Observer latitude for hemisphere filtering (None = full sky) + limiting_mag: Magnitude limit for preloading bright stars + """ + logger.info(f">>> start_background_load() called, current state: {self.state}") + if self.state != CatalogState.NOT_LOADED: + logger.warning( + f">>> Catalog already loading or loaded (state={self.state}), skipping" + ) + return + + logger.info( + f">>> Starting background load: lat={observer_lat}, mag={limiting_mag}, path={self.catalog_path}" + ) + + self.state = CatalogState.LOADING + self.observer_lat = observer_lat + self.limiting_magnitude = limiting_mag + + # Start background thread + logger.info(">>> Creating background thread...") + self.load_thread = threading.Thread( + target=self._background_load_worker, daemon=True, name="CatalogLoader" + ) + self.load_thread.start() + logger.info( + f">>> Background thread started, thread alive: {self.load_thread.is_alive()}" + ) + + def _background_load_worker(self): + """Background worker - just loads metadata""" + logger.info(">>> _background_load_worker() started") + try: + # Load metadata + self.load_progress = "Loading..." + self.load_percent = 50 + logger.info(f">>> Loading catalog metadata from {self.catalog_path}") + + metadata_file = self.catalog_path / "metadata.json" + + if not metadata_file.exists(): + logger.error(f">>> Catalog metadata not found: {metadata_file}") + logger.error( + ">>> Please build catalog using: python -m PiFinder.catalog_tools.gaia_downloader" + ) + self.load_progress = "Error: catalog not built" + self.state = CatalogState.NOT_LOADED + return + + with open(metadata_file, "r") as f: + self.metadata = json.load(f) + logger.info(">>> metadata.json loaded") + + self.nside = self.metadata.get("nside", 512) + star_count = self.metadata.get("star_count", 0) + logger.info( + f">>> Catalog metadata ready: {star_count:,} stars, " + f"mag limit {self.metadata.get('mag_limit', 0):.1f}, nside={self.nside}" + ) + + # Log available bands + bands = self.metadata.get("mag_bands", []) + logger.info(f">>> Catalog mag bands: {json.dumps(bands)}") + + # Preload all compressed indices (run directories) into memory (~2-12 MB total) + # This eliminates first-query delays (70ms per band → 420ms total stuttering) + self._preload_compressed_indices() + + # Initialize empty structures (no preloading) + self.visible_tiles = None # Load full sky on-demand + + # Mark ready + self.load_progress = "Ready" + self.load_percent = 100 + self.state = CatalogState.READY + logger.info(f">>> _background_load_worker() completed, state: {self.state}") + + except Exception as e: + logger.error(f">>> Catalog loading failed: {e}", exc_info=True) + self.load_progress = f"Error: {str(e)}" + self.state = CatalogState.NOT_LOADED + + def _calc_visible_tiles(self, observer_lat: float) -> Optional[Set[int]]: + """ + Calculate HEALPix tiles visible from observer latitude + + DISABLED: Too slow (iterates 3M+ pixels) + TODO: Pre-compute hemisphere mask during catalog build + + Args: + observer_lat: Observer latitude in degrees + + Returns: + None (full sky always loaded for now) + """ + return None + + def _preload_mag_band(self, mag_min: float, mag_max: float): + """ + Preload all tiles for a magnitude band + + Args: + mag_min: Minimum magnitude + mag_max: Maximum magnitude + """ + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + if not band_dir.exists(): + return + + # Get all tile files in this band + tile_files = sorted(band_dir.glob("tile_*.bin")) + + for tile_file in tile_files: + # Extract tile ID from filename + tile_id = int(tile_file.stem.split("_")[1]) + + # Filter by hemisphere if applicable + if self.visible_tiles and tile_id not in self.visible_tiles: + continue + + # Load tile + self._load_tile_from_file(tile_file, mag_min, mag_max) + + # CPU throttle: 10ms pause between tiles + # (50ms was too conservative, slowing down loading significantly) + time.sleep(0.01) + + def get_stars_for_fov_progressive( + self, + ra_deg: float, + dec_deg: float, + fov_deg: float, + mag_limit: Optional[float] = None, + ): + """ + Query stars in field of view progressively (bright to faint) + + This is a generator that yields (stars, is_complete) tuples as each + magnitude band is loaded. This allows the UI to display bright stars + immediately while continuing to load fainter stars in the background. + + Uses background thread to load magnitude bands asynchronously, eliminating + UI event loop blocking. The UI consumes results at its own pace (~10 FPS) + while catalog loading continues uninterrupted. + + Blocks if state == LOADING (waits for load to complete) + Returns empty array if state == NOT_LOADED + + Args: + ra_deg: Center RA in degrees + dec_deg: Center Dec in degrees + fov_deg: Field of view in degrees + mag_limit: Limiting magnitude (uses catalog default if None) + + Yields: + (stars, is_complete) tuples where: + - stars: Numpy array (N, 3) of (ra, dec, mag) with proper motion corrected + - is_complete: True if this is the final yield with all stars + """ + if self.state == CatalogState.NOT_LOADED: + logger.warning("Catalog not loaded") + yield (np.empty((0, 3)), True) + return + + # Wait for catalog to be loaded + while self.state == CatalogState.LOADING: + import time + + time.sleep(0.1) + + if mag_limit is None: + mag_limit = self.metadata.get("mag_limit", 17.0) if self.metadata else 17.0 + + if not _HEALPY_AVAILABLE: + logger.error("healpy not available - cannot perform HEALPix queries") + yield (np.empty((0, 3)), True) + return + + # Calculate HEALPix tiles covering FOV + # fov_deg is the diagonal field width, query_disc expects radius + # For square FOV rotated arbitrarily, need circumscribed circle radius = diagonal/2 + # Add 10% margin to ensure edge tiles are fully covered + # Use inclusive=True to ensure boundary tiles are included (critical for small FOVs) + vec = hp.ang2vec(ra_deg, dec_deg, lonlat=True) + radius_rad = np.radians(fov_deg / 2 * 1.1) + tiles = hp.query_disc(self.nside, vec, radius_rad, inclusive=True) + logger.debug( + f"HEALPix query_disc: FOV={fov_deg:.4f}°, radius={np.degrees(radius_rad):.4f}°, nside={self.nside}, returned {len(tiles)} tiles" + ) + + # Filter by visible hemisphere + if self.visible_tiles: + tiles = [t for t in tiles if t in self.visible_tiles] + + if not self.metadata: + yield (np.empty((0, 3)), True) + return + + # Background loading using producer-consumer pattern + import queue + import threading + import time + + # Queue to pass star arrays from background thread to generator + result_queue: queue.Queue = queue.Queue( + maxsize=6 + ) # Buffer up to 6 magnitude bands + + def load_bands_background(): + """Background thread that loads magnitude bands continuously""" + try: + all_stars_list = [] + mag_bands = self.metadata.get("mag_bands", []) + + for i, mag_band_info in enumerate(mag_bands): + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + + # Skip bands fainter than limit + if mag_min >= mag_limit: + break + + logger.debug( + f">>> BACKGROUND: Loading mag band {mag_min}-{mag_max}, tiles={len(tiles)}" + ) + + # Load stars from this magnitude band only + band_stars = self._load_tiles_for_mag_band( + tiles, mag_band_info, mag_limit, ra_deg, dec_deg, fov_deg + ) + + # Add to cumulative list + if len(band_stars) > 0: + all_stars_list.append(band_stars) + + # Concatenate for this yield + if all_stars_list: + current_total = np.concatenate(all_stars_list) + else: + current_total = np.empty((0, 3)) + + is_last_band = mag_max >= mag_limit + + # Push to queue (blocks if queue is full - back-pressure) + result_queue.put((current_total, is_last_band, len(band_stars))) + + logger.info( + f">>> BACKGROUND: mag {mag_min}-{mag_max}: " + f"stars={len(band_stars)}, cumulative={len(current_total)}" + ) + + if is_last_band: + break + + except Exception as e: + logger.error(f">>> BACKGROUND: Error loading bands: {e}", exc_info=True) + # Push error marker + result_queue.put((None, True, 0)) + + # Start background loading thread + loader_thread = threading.Thread( + target=load_bands_background, daemon=True, name="StarCatalogLoader" + ) + loader_thread.start() + logger.info(">>> PROGRESSIVE: Background loading thread started") + + # Yield results as they become available + while True: + try: + # Get next result from queue + # Use timeout to avoid blocking forever if thread crashes + current_total, is_last_band, band_star_count = result_queue.get( + timeout=10.0 + ) + + if current_total is None: + # Error in background thread + logger.error(">>> PROGRESSIVE: Background thread encountered error") + yield (np.empty((0, 3)), True) + break + + # Yield to consumer (UI) + yield (current_total, is_last_band) + + logger.info( + f">>> PROGRESSIVE: stars_in_band={band_star_count}, cumulative={len(current_total)}" + ) + + if is_last_band: + logger.info( + f"PROGRESSIVE: Complete! Total {len(current_total)} stars loaded" + ) + break + + except queue.Empty: + logger.error(">>> PROGRESSIVE: Timeout waiting for background thread") + yield (np.empty((0, 3)), True) + break + + def get_stars_for_fov( + self, + ra_deg: float, + dec_deg: float, + fov_deg: float, + mag_limit: Optional[float] = None, + ) -> np.ndarray: + """ + Query stars in field of view + + Blocks if state == LOADING (waits for load to complete) + Returns empty array if state == NOT_LOADED + + Args: + ra_deg: Center RA in degrees + dec_deg: Center Dec in degrees + fov_deg: Field of view in degrees + mag_limit: Limiting magnitude (uses catalog default if None) + + Returns: + Numpy array (N, 3) of (ra, dec, mag) with proper motion corrected + """ + if self.state == CatalogState.NOT_LOADED: + logger.warning("Catalog not loaded") + return np.empty((0, 3)) + + if self.state == CatalogState.LOADING: + # Wait for loading to complete (with timeout) + logger.info("Waiting for catalog to finish loading...") + timeout = 30 # seconds + start = time.time() + while self.state == CatalogState.LOADING: + time.sleep(0.1) + if time.time() - start > timeout: + logger.error("Catalog loading timeout") + return np.empty((0, 3)) + + # State is READY - metadata must be loaded by now + assert self.metadata is not None, ( + "metadata should be loaded when state is READY" + ) + assert self.nside is not None, "nside should be set when state is READY" + + mag_limit = mag_limit or self.limiting_magnitude + + if not _HEALPY_AVAILABLE: + logger.error("healpy not installed") + return np.empty((0, 3)) + + # Calculate HEALPix tiles covering FOV + # fov_deg is the diagonal field width, query_disc expects radius + # For square FOV rotated arbitrarily, need circumscribed circle radius = diagonal/2 + # Add 10% margin to ensure edge tiles are fully covered + vec = hp.ang2vec(ra_deg, dec_deg, lonlat=True) + radius_rad = np.radians(fov_deg / 2 * 1.1) + tiles = hp.query_disc(self.nside, vec, radius_rad) + logger.debug( + f"HEALPix: Querying {len(tiles)} tiles for FOV={fov_deg:.2f}° (radius={np.degrees(radius_rad):.3f}°) at nside={self.nside}" + ) + + # Filter by visible hemisphere + if self.visible_tiles: + tiles = [t for t in tiles if t in self.visible_tiles] + + # Load stars from tiles (batch load for better performance) + stars: np.ndarray = np.empty((0, 3)) + tile_star_counts = {} + + # Try batch loading if catalog is compact format + # Only batch for moderate tile counts (10-50) to avoid UI blocking + is_compact = self.metadata.get("format") == "compact" + if is_compact and 10 < len(tiles) <= 50: + # Batch load is much faster for many tiles + # Note: batch loading returns PM-corrected (ra, dec, mag) tuples + logger.info(f"Using BATCH loading for {len(tiles)} tiles") + stars = self._load_tiles_batch(tiles, mag_limit) + logger.info(f"Batch load complete: {len(stars)} stars") + tile_star_counts = { + t: 0 for t in tiles + } # Don't track individual counts for batch + else: + # Load one by one (better for small queries or legacy format) + logger.info( + f"Using SINGLE-TILE loading for {len(tiles)} tiles (compact={is_compact})" + ) + stars_raw_list = [] + + # To prevent UI blocking, limit the number of tiles loaded at once + # For small FOVs (<1°), 20-30 tiles is more than enough + MAX_TILES = 25 + if len(tiles) > MAX_TILES: + logger.warning( + f"Large tile count ({len(tiles)}) detected! Limiting to {MAX_TILES} tiles to prevent UI freeze" + ) + # Tiles from query_disc are roughly ordered by distance from center + # Keep the first MAX_TILES which are closest to FOV center + tiles = tiles[:MAX_TILES] + + cache_hits = 0 + cache_misses = 0 + + for i, tile_id in enumerate(tiles): + # Check if this tile is cached (for performance tracking) + cache_key = (tile_id, mag_limit) + was_cached = cache_key in self.tile_cache + + # Returns (N, 5) array + tile_stars = self._load_tile_data(tile_id, mag_limit) + tile_star_counts[tile_id] = len(tile_stars) + + if len(tile_stars) > 0: + stars_raw_list.append(tile_stars) + + if was_cached: + cache_hits += 1 + else: + cache_misses += 1 + + # Log cache performance + logger.debug( + f"Tile cache: {cache_hits} hits, {cache_misses} misses ({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)" + ) + + total_raw = sum(len(x) for x in stars_raw_list) + logger.debug(f"Single-tile loading complete: {total_raw} stars") + + # Log tile loading stats + if tile_star_counts: + logger.debug( + f"Loaded from {len(tile_star_counts)} tiles: " + + f"min={min(tile_star_counts.values())} max={max(tile_star_counts.values())} " + + f"total={sum(tile_star_counts.values())}" + ) + + # Apply proper motion correction (for non-batch path only) + t_pm_start = time.time() + + if stars_raw_list: + stars_raw_combined = np.concatenate(stars_raw_list) + ras = stars_raw_combined[:, 0] + decs = stars_raw_combined[:, 1] + mags = stars_raw_combined[:, 2] + pmras = stars_raw_combined[:, 3] + pmdecs = stars_raw_combined[:, 4] + stars = self._apply_proper_motion((ras, decs, mags, pmras, pmdecs)) + else: + stars = np.empty((0, 3)) + + t_pm_end = time.time() + logger.debug( + f"Proper motion correction: {len(stars)} stars in {(t_pm_end - t_pm_start) * 1000:.1f}ms" + ) + + return stars + + def _load_tiles_for_mag_band( + self, + tile_ids: List[int], + mag_band_info: dict, + mag_limit: float, + ra_deg: float, + dec_deg: float, + fov_deg: float, + ) -> np.ndarray: + """ + Load tiles for a specific magnitude band (used by progressive loading) + + Args: + tile_ids: List of HEALPix tile IDs to load + mag_band_info: Magnitude band metadata dict with 'min', 'max' keys + mag_limit: Maximum magnitude to include + ra_deg: Center RA (for logging) + dec_deg: Center Dec (for logging) + fov_deg: Field of view (for logging) + + Returns: + Numpy array (N, 3) of (ra, dec, mag) with proper motion corrected + """ + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + + # logger.info(f">>> _load_tiles_for_mag_band: mag {mag_min}-{mag_max}, band_dir={band_dir}, tiles={len(tile_ids)}") + + # Check if this band directory exists + if not band_dir.exists(): + logger.warning(f">>> Magnitude band directory not found: {band_dir}") + return np.empty((0, 3)) + + # For compact format, use vectorized batch loading per band + assert self.metadata is not None, "metadata must be loaded" + is_compact = self.metadata.get("format") == "compact" + # logger.info(f">>> Format is_compact={is_compact}, calling _load_tiles_batch_single_band...") + if is_compact: + result = self._load_tiles_batch_single_band( + tile_ids, mag_band_info, mag_limit + ) + # logger.info(f">>> _load_tiles_batch_single_band returned {len(result)} stars") + return result + else: + # Legacy format - load tiles one by one (will load all bands for each tile) + # This is less efficient but legacy format doesn't support per-band loading + stars_raw_list = [] + for tile_id in tile_ids: + tile_stars = self._load_tile_data(tile_id, mag_limit) + # Filter to just this magnitude band + # tile_stars is (N, 5) + if len(tile_stars) > 0: + mags = tile_stars[:, 2] + mask = (mags >= mag_min) & (mags < mag_max) + if np.any(mask): + stars_raw_list.append(tile_stars[mask]) + + if stars_raw_list: + stars_raw_combined = np.concatenate(stars_raw_list) + ras = stars_raw_combined[:, 0] + decs = stars_raw_combined[:, 1] + mags = stars_raw_combined[:, 2] + pmras = stars_raw_combined[:, 3] + pmdecs = stars_raw_combined[:, 4] + return self._apply_proper_motion((ras, decs, mags, pmras, pmdecs)) + else: + return np.empty((0, 3)) + + def _load_tile_data(self, tile_id: int, mag_limit: float) -> np.ndarray: + """ + Load star data for a HEALPix tile + + Args: + tile_id: HEALPix tile ID + mag_limit: Maximum magnitude to load + + Returns: + Numpy array of shape (N, 5) containing (ra, dec, mag, pmra, pmdec) + """ + assert self.metadata is not None, ( + "metadata must be loaded before calling _load_tile_data" + ) + + cache_key = (tile_id, mag_limit) + + # Check cache + with self.cache_lock: + if cache_key in self.tile_cache: + return self.tile_cache[cache_key] + + # Load from disk + stars_list = [] + + # Check catalog format + is_compact = self.metadata.get("format") == "compact" + + # Determine which magnitude bands to load + for mag_band_info in self.metadata.get("mag_bands", []): + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + + if mag_min >= mag_limit: + continue # Band too faint + + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + + if is_compact: + # Compact format: read from consolidated file using index + ras, decs, mags, pmras, pmdecs = self._load_tile_compact( + band_dir, tile_id, mag_min, mag_max + ) + else: + # Legacy format: one file per tile + tile_file = band_dir / f"tile_{tile_id:06d}.bin" + if tile_file.exists(): + ras, decs, mags, pmras, pmdecs = self._load_tile_from_file( + tile_file, mag_min, mag_max + ) + else: + ras, decs, mags, pmras, pmdecs = ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + if len(ras) > 0: + # Filter by magnitude + mask = mags <= mag_limit + if np.any(mask): + # Stack into (N, 5) array for this band + band_stars = np.column_stack( + (ras[mask], decs[mask], mags[mask], pmras[mask], pmdecs[mask]) + ) + stars_list.append(band_stars) + logger.debug( + f" Tile {tile_id} Band {mag_min}-{mag_max}: {len(band_stars)} stars (file: {tile_file if not is_compact else 'compact'})" + ) + else: + logger.debug( + f" Tile {tile_id} Band {mag_min}-{mag_max}: 0 stars (mask empty)" + ) + + if not stars_list: + stars = np.empty((0, 5)) + else: + stars = np.concatenate(stars_list) + + # Cache result + with self.cache_lock: + self.tile_cache[cache_key] = stars + # Simple cache size management (keep last 100 tiles) + if len(self.tile_cache) > 100: + # Remove oldest (first) entry + oldest_key = next(iter(self.tile_cache)) + del self.tile_cache[oldest_key] + + return stars + + def _load_tile_from_file( + self, tile_file: Path, mag_min: float, mag_max: float + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Load stars from a tile file + + Args: + tile_file: Path to tile binary file + mag_min: Minimum magnitude in this band + mag_max: Maximum magnitude in this band + + Returns: + Tuple of (ras, decs, mags, pmras, pmdecs) arrays + """ + if not _HEALPY_AVAILABLE: + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + # Read entire file at once + with open(tile_file, "rb") as f: + data = f.read() + + return self._parse_records(data) + + def _load_tile_compact( + self, band_dir: Path, tile_id: int, mag_min: float, mag_max: float + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Load stars from compact format (consolidated tiles.bin + v3 compressed index) + + Args: + band_dir: Magnitude band directory + tile_id: HEALPix tile ID + mag_min: Minimum magnitude + mag_max: Maximum magnitude + + Returns: + Tuple of (ras, decs, mags, pmras, pmdecs) arrays + """ + if not _HEALPY_AVAILABLE: + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + index_file = band_dir / "index.bin" + tiles_file = band_dir / "tiles.bin" + + if not tiles_file.exists(): + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + if not index_file.exists(): + raise FileNotFoundError( + f"Compressed index not found: {index_file}\n" + f"This catalog requires v3 format. Please rebuild using healpix_builder_compact.py" + ) + + # Load index (cached per band) + cache_key = f"index_{mag_min}_{mag_max}" + if cache_key not in self._index_cache: + self._index_cache[cache_key] = CompressedIndex(index_file) + + index = self._index_cache[cache_key] + + # Get tile offset and size from compressed index + result = index.get(tile_id) + if result is None: + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + offset, size = result + + # Read tile data + with open(tiles_file, "rb") as f: + f.seek(offset) + data = f.read(size) + return self._parse_records(data) + + def _parse_records( + self, data: bytes + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Parse binary tile data into numpy arrays (VECTORIZED) + + New format: [Tile Header: 6 bytes][Star Records: 5 bytes each] + + Args: + data: Binary tile data (header + star records) + + Returns: + Tuple of (ras, decs, mags, pmras, pmdecs) as numpy arrays + """ + if len(data) < TILE_HEADER_SIZE: + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + # Parse tile header + healpix_pixel, num_stars = struct.unpack( + TILE_HEADER_FORMAT, data[:TILE_HEADER_SIZE] + ) + + # Extract star records + star_data = data[TILE_HEADER_SIZE:] + + if len(star_data) == 0: + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + # Verify data size matches expected + expected_size = num_stars * STAR_RECORD_SIZE + if len(star_data) != expected_size: + logger.warning( + f"Tile {healpix_pixel}: size mismatch. Expected {expected_size} bytes " + f"for {num_stars} stars, got {len(star_data)} bytes" + ) + # Truncate to valid records + num_stars = len(star_data) // STAR_RECORD_SIZE + + # Parse all star records using numpy + records = np.frombuffer(star_data, dtype=STAR_RECORD_DTYPE, count=num_stars) + + # Get pixel center (same for all stars in this tile) + pixel_ra, pixel_dec = hp.pix2ang(self.nside, healpix_pixel, lonlat=True) + + # Calculate pixel size once + pixel_size_deg = np.sqrt(hp.nside2pixarea(self.nside, degrees=True)) + max_offset_arcsec = pixel_size_deg * 3600.0 * 0.75 + + # Decode all offsets + ra_offset_arcsec = (records["ra_offset"] / 127.5 - 1.0) * max_offset_arcsec + dec_offset_arcsec = (records["dec_offset"] / 127.5 - 1.0) * max_offset_arcsec + + # Calculate final positions (broadcast pixel center to all stars) + decs = pixel_dec + dec_offset_arcsec / 3600.0 + ras = pixel_ra + ra_offset_arcsec / 3600.0 / np.cos(np.radians(decs)) + + # Decode magnitudes + mags = records["mag"] / 10.0 + + # v2.1: Proper motion has been pre-applied at build time + # Return empty arrays for backward compatibility + pmras = np.zeros(len(records)) + pmdecs = np.zeros(len(records)) + + return ras, decs, mags, pmras, pmdecs + + def _preload_compressed_indices(self) -> None: + """ + Preload all v3 compressed indices (run directories) into memory during startup. + + Loads compressed index run directories (~2-12 MB total) to eliminate first-query + delays during chart generation. Each compressed index loads its run directory + into RAM for fast binary search, while keeping run data in mmap. + + This runs in background thread during catalog startup and trades a one-time + ~200ms startup cost for eliminating 6 × 70ms = 420ms of stuttering during + first chart generation. + """ + if not self.metadata or "mag_bands" not in self.metadata: + logger.warning( + ">>> No metadata available, skipping compressed index preload" + ) + return + + t0_total = time.time() + bands_loaded = 0 + + logger.info(">>> Preloading v3 compressed indices for all magnitude bands...") + + for band_info in self.metadata["mag_bands"]: + mag_min = int(band_info["min"]) + mag_max = int(band_info["max"]) + cache_key = f"index_{mag_min}_{mag_max}" + + # Load compressed index (v3 format stored as index.bin) + index_file = ( + self.catalog_path / f"mag_{mag_min:02d}_{mag_max:02d}" / "index.bin" + ) + + if not index_file.exists(): + raise FileNotFoundError( + f"Compressed index not found: {index_file}\n" + f"This catalog requires v3 format. Please rebuild using healpix_builder_compact.py" + ) + + t0 = time.time() + + # Load compressed index (v3 only) + self._index_cache[cache_key] = CompressedIndex(index_file) + t_load = (time.time() - t0) * 1000 + + compressed_idx = self._index_cache[cache_key] + bands_loaded += 1 + + logger.info( + f">>> Loaded compressed index {cache_key}: " + f"{compressed_idx.num_tiles:,} tiles, {len(compressed_idx.run_directory):,} runs " + f"in {t_load:.1f}ms" + ) + + t_total = (time.time() - t0_total) * 1000 + logger.info( + f">>> Compressed index preload complete: {bands_loaded} indices " + f"in {t_total:.1f}ms" + ) + + def _load_existing_tiles_set(self, index_file: Path) -> Set[int]: + """ + Quickly load the set of all existing tile IDs from an index file. + This is much faster than scanning for specific tiles when we just need + to know "does this tile exist?" to avoid wasteful searches. + + Args: + index_file: Path to binary index file + + Returns: + Set of existing tile IDs (as integers) + """ + existing_tiles: set[int] = set() + + if not index_file.exists(): + return existing_tiles + + with open(index_file, "rb") as f: + # Read header + header = f.read(8) + if len(header) < 8: + return existing_tiles + + version, num_tiles = struct.unpack(" np.ndarray: + """ + Apply proper motion corrections from J2016.0 to current epoch (VECTORIZED) + + Args: + stars: Tuple of (ras, decs, mags, pmras, pmdecs) arrays + + Returns: + Numpy array of shape (N, 3) containing (ra, dec, mag) + """ + ras, decs, mags, pmras, pmdecs = stars + + if len(ras) == 0: + return np.empty((0, 3)) + + # Calculate years from J2016.0 to current date + current_year = datetime.now().year + ( + datetime.now().timetuple().tm_yday / 365.25 + ) + years_elapsed = current_year - 2016.0 + + # Apply proper motion forward to current epoch + # pmra is in mas/year and needs cos(dec) correction for RA + # Vectorized calculation + ra_corrections = ( + (pmras / 1000 / 3600) / np.cos(np.radians(decs)) * years_elapsed + ) + dec_corrections = (pmdecs / 1000 / 3600) * years_elapsed + + ra_corrected = ras + ra_corrections + dec_corrected = decs + dec_corrections + + # Keep dec in valid range + dec_corrected = np.clip(dec_corrected, -90, 90) + + # Stack into (N, 3) array + return np.column_stack((ra_corrected, dec_corrected, mags)) + + def _trim_index_cache(self, cache_key: str, protected_tile_ids: List[int]) -> None: + """ + Trim index cache to stay within MAX_INDEX_CACHE_SIZE limit. + + Strategy: Remove oldest tiles not in the current request (protected_tile_ids). + This ensures we keep tiles needed for the current chart while evicting others. + + Args: + cache_key: Cache key (e.g., "index_12_14") + protected_tile_ids: Tile IDs that must NOT be evicted (current FOV) + """ + index = self._index_cache.get(cache_key) + if not index: + return + + cache_size = len(index) + if cache_size <= MAX_INDEX_CACHE_SIZE: + return # Within limit, nothing to do + + # Calculate how many to remove + tiles_to_remove = cache_size - MAX_INDEX_CACHE_SIZE + logger.info( + f">>> Cache {cache_key} exceeds limit ({cache_size} > {MAX_INDEX_CACHE_SIZE}), removing {tiles_to_remove} tiles" + ) + + # Build set of protected tiles + protected_set = {str(tid) for tid in protected_tile_ids} + + # Find eviction candidates (tiles not in current request) + candidates = [ + tile_key for tile_key in index.keys() if tile_key not in protected_set + ] + + if len(candidates) < tiles_to_remove: + # Not enough non-protected tiles, just remove what we can + logger.warning( + f">>> Only {len(candidates)} evictable tiles, removing all of them" + ) + tiles_to_remove = len(candidates) + + # Remove the first N candidates (simple FIFO-ish eviction) + # Could enhance this with LRU tracking later + for i in range(tiles_to_remove): + tile_key = candidates[i] + del index[tile_key] + + logger.info(f">>> Cache trimmed: {cache_size} → {len(index)} tiles") + + def _load_tiles_batch_single_band( + self, + tile_ids: List[int], + mag_band_info: dict, + mag_limit: float, + ) -> np.ndarray: + """ + Batch load multiple tiles for a SINGLE magnitude band (compact format only) + Used by progressive loading to load one mag band at a time + + Args: + tile_ids: List of HEALPix tile IDs + mag_band_info: Magnitude band metadata dict + mag_limit: Maximum magnitude + + Returns: + Numpy array of shape (N, 3) containing (ra, dec, mag) + """ + if not _HEALPY_AVAILABLE: + return np.empty((0, 3)) + + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + index_file = band_dir / "index.bin" + tiles_file = band_dir / "tiles.bin" + + if not tiles_file.exists(): + return np.empty((0, 3)) + + if not index_file.exists(): + raise FileNotFoundError( + f"Compressed index not found: {index_file}\n" + f"This catalog requires v3 format. Please rebuild using healpix_builder_compact.py" + ) + + cache_key = f"index_{mag_min}_{mag_max}" + + # Load v3 compressed index (cached) + if not hasattr(self, "_index_cache"): + self._index_cache = {} + + t_index_start = time.time() + logger.debug(f"Checking index cache for {cache_key}") + if cache_key not in self._index_cache: + logger.info(f">>> Loading v3 compressed index from {index_file}") + t0 = time.time() + self._index_cache[cache_key] = CompressedIndex(index_file) + t_read_index = (time.time() - t0) * 1000 + logger.info(f">>> Compressed index loaded in {t_read_index:.1f}ms") + else: + logger.debug(f">>> Using cached index for {cache_key}") + + index = self._index_cache[cache_key] + t_index_total = (time.time() - t_index_start) * 1000 + logger.debug(f">>> Index cache operations took {t_index_total:.1f}ms") + + t_readops_start = time.time() + logger.debug(f"Building read_ops for {len(tile_ids)} tiles...") + + # Collect all tile read operations from v3 compressed index + read_ops: List[Tuple[int, Dict[str, int]]] = [] + missing_tiles = 0 + for tile_id in tile_ids: + # Ensure tile_id is a Python int (not numpy.int64) + tile_id_int = int(tile_id) + tile_tuple = index.get(tile_id_int) + if tile_tuple: + offset, size = tile_tuple + read_ops.append((tile_id_int, {"offset": offset, "size": size})) + else: + missing_tiles += 1 + + if missing_tiles > 0: + logger.debug( + f"{missing_tiles} of {len(tile_ids)} tiles missing from index for mag {mag_min}-{mag_max}" + ) + + if not read_ops: + logger.debug( + f"No tiles to load (all {len(tile_ids)} requested tiles are empty)" + ) + return np.empty((0, 3)) + + # Sort by offset to minimize seeks + read_ops.sort(key=lambda x: x[1]["offset"]) + t_readops = (time.time() - t_readops_start) * 1000 + logger.debug(f"Built {len(read_ops)} read_ops in {t_readops:.1f}ms") + + # Read data in larger sequential chunks when possible + MAX_GAP = 100 * 1024 # 100KB gap tolerance + + # Accumulate arrays + all_ras = [] + all_decs = [] + all_mags = [] + all_pmras = [] + all_pmdecs = [] + + t_io_start = time.time() + t_decode_total = 0.0 + bytes_read = 0 + logger.debug(f"Batch loading {len(read_ops)} tiles for mag {mag_min}-{mag_max}") + with open(tiles_file, "rb") as f: + i = 0 + chunk_num = 0 + while i < len(read_ops): + chunk_num += 1 + # logger.debug(f">>> Processing chunk {chunk_num}, tile {i+1}/{len(read_ops)}") + + tile_id, tile_info = read_ops[i] + offset = tile_info["offset"] + chunk_end = offset + tile_info["size"] + + # Find consecutive tiles for chunk reading + tiles_in_chunk: List[Tuple[int, Dict[str, int]]] = [ + (tile_id, tile_info) + ] + j = i + 1 + inner_iterations = 0 + while j < len(read_ops): + inner_iterations += 1 + if inner_iterations > 1000: + logger.error( + f">>> INFINITE LOOP DETECTED in chunk consolidation! j={j}, len={len(read_ops)}, i={i}" + ) + break # Safety break + + next_tile_id, next_tile_info = read_ops[j] + next_offset = next_tile_info["offset"] + if next_offset - chunk_end <= MAX_GAP: + chunk_end = next_offset + next_tile_info["size"] + tiles_in_chunk.append((next_tile_id, next_tile_info)) + j += 1 + else: + break + + # Read entire chunk + chunk_size = chunk_end - offset + # logger.debug(f">>> Reading chunk: {len(tiles_in_chunk)} tiles, size={chunk_size} bytes") + f.seek(offset) + chunk_data = f.read(chunk_size) + bytes_read += chunk_size + # logger.debug(f">>> Chunk read complete, processing tiles...") + + # Process each tile in chunk + for tile_idx, (tile_id, tile_info) in enumerate(tiles_in_chunk): + # logger.debug(f">>> Processing tile {tile_idx+1}/{len(tiles_in_chunk)} (id={tile_id})") + tile_offset = tile_info["offset"] - offset + size = tile_info["size"] + data = chunk_data[tile_offset : tile_offset + size] + + # Parse records using shared helper + t_decode_start = time.time() + ras, decs, mags, pmras, pmdecs = self._parse_records(data) + t_decode_total += time.time() - t_decode_start + + # Filter by magnitude + mask = mags <= mag_limit + + if np.any(mask): + all_ras.append(ras[mask]) + all_decs.append(decs[mask]) + all_mags.append(mags[mask]) + all_pmras.append(pmras[mask]) + all_pmdecs.append(pmdecs[mask]) + + i = j + + if not all_ras: + return np.empty((0, 3)) + + # Concatenate all arrays + t_concat_start = time.time() + ras_final = np.concatenate(all_ras) + decs_final = np.concatenate(all_decs) + mags_final = np.concatenate(all_mags) + pmras_final = np.concatenate(all_pmras) + pmdecs_final = np.concatenate(all_pmdecs) + (time.time() - t_concat_start) * 1000 + + # Apply proper motion + t_pm_start = time.time() + result = self._apply_proper_motion( + (ras_final, decs_final, mags_final, pmras_final, pmdecs_final) + ) + (time.time() - t_pm_start) * 1000 + + # Log performance breakdown + t_io_total = (time.time() - t_io_start) * 1000 + logger.debug( + f"Tile I/O for mag {mag_min}-{mag_max}: " + f"{t_io_total:.1f}ms, {len(result)} stars, {bytes_read / 1024:.1f}KB" + ) + + return result + + def _load_tiles_batch(self, tile_ids: List[int], mag_limit: float) -> np.ndarray: + """ + Batch load multiple tiles efficiently (compact format only) + Much faster than loading tiles one-by-one due to reduced I/O overhead + + Args: + tile_ids: List of HEALPix tile IDs + mag_limit: Maximum magnitude + + Returns: + Numpy array of shape (N, 3) containing (ra, dec, mag) + """ + assert self.metadata is not None, ( + "metadata must be loaded before calling _load_tiles_batch" + ) + + if not _HEALPY_AVAILABLE: + return np.empty((0, 3)) + + all_ras = [] + all_decs = [] + all_mags = [] + all_pmras = [] + all_pmdecs = [] + + logger.info(f"_load_tiles_batch: Starting batch load of {len(tile_ids)} tiles") + + # Process each magnitude band + for mag_band_info in self.metadata.get("mag_bands", []): + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + + if mag_min >= mag_limit: + continue # Skip faint bands + + logger.info(f"_load_tiles_batch: Processing mag band {mag_min}-{mag_max}") + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + index_file = band_dir / "index.bin" + tiles_file = band_dir / "tiles.bin" + + if not tiles_file.exists(): + continue + + if not index_file.exists(): + raise FileNotFoundError( + f"Compressed index not found: {index_file}\n" + f"This catalog requires v3 format. Please rebuild using healpix_builder_compact.py" + ) + + # Load v3 compressed index + cache_key = f"index_{mag_min}_{mag_max}" + if not hasattr(self, "_index_cache"): + self._index_cache = {} + + if cache_key not in self._index_cache: + self._index_cache[cache_key] = CompressedIndex(index_file) + + index = self._index_cache[cache_key] + + # Collect all tile read operations from v3 compressed index + read_ops = [] + for tile_id in tile_ids: + tile_tuple = index.get(tile_id) + if tile_tuple: + offset, size = tile_tuple + read_ops.append((tile_id, {"offset": offset, "size": size})) + + if not read_ops: + continue + + logger.info( + f"_load_tiles_batch: Found {len(read_ops)} tiles in mag band {mag_min}-{mag_max}" + ) + + # Sort by offset to minimize seeks + read_ops.sort(key=lambda x: x[1]["offset"]) + + # Optimize: Read data in larger sequential chunks when possible + # Group tiles that are close together (within 100KB) + MAX_GAP = 100 * 1024 # 100KB gap tolerance + + logger.info(f"_load_tiles_batch: Opening {tiles_file}") + # Open file once and read all tiles + with open(tiles_file, "rb") as f: + i = 0 + while i < len(read_ops): + tile_id, tile_info = read_ops[i] + offset = tile_info["offset"] + size = tile_info["size"] + + # Check if next tiles are sequential (within gap tolerance) + chunk_end = offset + size + tiles_in_chunk = [(tile_id, tile_info)] + + j = i + 1 + while j < len(read_ops): + next_tile_id, next_tile_info = read_ops[j] + next_offset = next_tile_info["offset"] + + # If next tile is within gap tolerance, include in chunk + if next_offset - chunk_end <= MAX_GAP: + tiles_in_chunk.append((next_tile_id, next_tile_info)) + next_size = next_tile_info["size"] + chunk_end = next_offset + next_size + j += 1 + else: + break + + # Read entire chunk at once + chunk_size = chunk_end - offset + logger.info( + f"_load_tiles_batch: Reading chunk at offset {offset}, size {chunk_size / 1024:.1f}KB with {len(tiles_in_chunk)} tiles" + ) + f.seek(offset) + chunk_data = f.read(chunk_size) + logger.info( + f"_load_tiles_batch: Read complete, processing {len(tiles_in_chunk)} tiles" + ) + + # Process each tile in the chunk using vectorized operations + for tile_id, tile_info in tiles_in_chunk: + tile_offset = ( + tile_info["offset"] - offset + ) # Relative offset in chunk + size = tile_info["size"] + data = chunk_data[tile_offset : tile_offset + size] + + # Parse records using shared helper + ras, decs, mags, pmras, pmdecs = self._parse_records(data) + + # Filter by magnitude + mask = mags <= mag_limit + + if np.any(mask): + all_ras.append(ras[mask]) + all_decs.append(decs[mask]) + all_mags.append(mags[mask]) + all_pmras.append(pmras[mask]) + all_pmdecs.append(pmdecs[mask]) + + # Move to next chunk + i = j + + logger.info( + f"_load_tiles_batch: Loaded {len(all_ras)} batches of stars, applying proper motion" + ) + + if not all_ras: + return np.empty((0, 3)) + + # Concatenate all arrays + ras_final = np.concatenate(all_ras) + decs_final = np.concatenate(all_decs) + mags_final = np.concatenate(all_mags) + pmras_final = np.concatenate(all_pmras) + pmdecs_final = np.concatenate(all_pmdecs) + + # Apply proper motion + result = self._apply_proper_motion( + (ras_final, decs_final, mags_final, pmras_final, pmdecs_final) + ) + logger.info(f"_load_tiles_batch: Complete, returning {len(result)} stars") + return result diff --git a/python/PiFinder/plot.py b/python/PiFinder/plot.py index 0e4c8a403..2a0596e61 100644 --- a/python/PiFinder/plot.py +++ b/python/PiFinder/plot.py @@ -8,7 +8,6 @@ import os import datetime import numpy as np -import pandas from pathlib import Path from PiFinder import utils from PIL import Image, ImageDraw, ImageChops @@ -133,6 +132,8 @@ def radec_to_xy(self, ra: float, dec: float) -> tuple[float, float]: """ Converts and RA/DEC to screen space x/y for the current projection """ + import pandas + markers = pandas.DataFrame( [(Angle(degrees=ra)._hours, dec)], columns=["ra_hours", "dec_degrees"] ) @@ -168,6 +169,8 @@ def plot_markers(self, marker_list): Marker list should be a list of (RA_Hours/DEC_degrees, symbol) tuples """ + import pandas + ret_image = Image.new("RGB", self.render_size) idraw = ImageDraw.Draw(ret_image) diff --git a/python/PiFinder/server.py b/python/PiFinder/server.py index 6f1bea592..3f095cbd4 100644 --- a/python/PiFinder/server.py +++ b/python/PiFinder/server.py @@ -57,7 +57,7 @@ def __init__( shared_state, is_debug=False, ): - self.version_txt = f"{utils.pifinder_dir}/version.txt" + self._software_version = utils.get_version() self.keyboard_queue = keyboard_queue self.ui_queue = ui_queue self.gps_queue = gps_queue @@ -115,13 +115,7 @@ def send_css(filename): @app.route("/") def home(): logger.debug("/ called") - # Get version info - software_version = "Unknown" - try: - with open(self.version_txt, "r") as ver_f: - software_version = ver_f.read() - except (FileNotFoundError, IOError) as e: - logger.warning(f"Could not read version file: {str(e)}") + software_version = self._software_version # Try to update GPS state try: @@ -423,7 +417,12 @@ def network_update(): self.network.set_wifi_mode(wifi_mode) self.network.set_ap_name(ap_name) self.network.set_host_name(host_name) - return template("restart") + return template( + "network", + net=self.network, + show_new_form=0, + status_message="Network settings updated. You may need to reconnect.", + ) @app.route("/tools/pwchange", method="post") @auth_required diff --git a/python/PiFinder/solver.py b/python/PiFinder/solver.py index 842cf683b..2bbe7e5ca 100644 --- a/python/PiFinder/solver.py +++ b/python/PiFinder/solver.py @@ -25,6 +25,7 @@ from PiFinder.state import SQM as SQMState sys.path.append(str(utils.tetra3_dir)) +sys.path.append(str(utils.tetra3_dir / "tetra3")) import tetra3 from tetra3 import cedar_detect_client @@ -35,40 +36,18 @@ def create_sqm_calculator(shared_state): - """Create a new SQM calculator instance for PROCESSED images with current calibration.""" - # Get camera type from shared state and use "_processed" profile - # since images are already processed 8-bit (not raw) + """Create a new SQM calculator instance with current calibration.""" camera_type = shared_state.camera_type() camera_type_processed = f"{camera_type}_processed" - logger.info( - f"Creating processed SQM calculator for camera: {camera_type_processed}" - ) - - return SQMCalculator( - camera_type=camera_type_processed, - use_adaptive_noise_floor=True, - ) - - -def create_sqm_calculator_raw(shared_state): - """Create a new SQM calculator instance for RAW 16-bit images with current calibration.""" - # Get camera type from shared state (raw profile, e.g., "imx296", "hq") - camera_type_raw = shared_state.camera_type() - - logger.info(f"Creating raw SQM calculator for camera: {camera_type_raw}") + logger.info(f"Creating SQM calculator for camera: {camera_type_processed}") - return SQMCalculator( - camera_type=camera_type_raw, - use_adaptive_noise_floor=True, - ) + return SQMCalculator(camera_type=camera_type_processed) -def update_sqm_dual_pipeline( +def update_sqm( shared_state, sqm_calculator, - sqm_calculator_raw, - camera_command_queue, centroids, solution, image_processed, @@ -80,22 +59,14 @@ def update_sqm_dual_pipeline( annulus_outer_radius=14, ): """ - Calculate SQM for BOTH processed (8-bit) and raw (16-bit) images. - - This function: - 1. Checks if enough time has passed since last update - 2. Calculates SQM from processed 8-bit image - 3. Captures a raw 16-bit frame, loads it, and calculates raw SQM - 4. Updates shared state with both values + Calculate SQM from image. Args: shared_state: SharedStateObj instance - sqm_calculator: SQM calculator for processed images - sqm_calculator_raw: SQM calculator for raw images - camera_command_queue: Queue to send raw capture command + sqm_calculator: SQM calculator instance centroids: List of detected star centroids solution: Tetra3 solve solution with matched stars - image_processed: Processed 8-bit image array + image_processed: Processed image array (numpy) exposure_sec: Exposure time in seconds altitude_deg: Altitude in degrees for extinction correction calculation_interval_seconds: Minimum time between calculations (default: 5.0) @@ -131,8 +102,8 @@ def update_sqm_dual_pipeline( return False try: - # ========== Calculate PROCESSED (8-bit) SQM ========== - sqm_value_processed, _ = sqm_calculator.calculate( + # Calculate SQM from image + sqm_value, details = sqm_calculator.calculate( centroids=centroids, solution=solution, image=image_processed, @@ -143,48 +114,35 @@ def update_sqm_dual_pipeline( annulus_outer_radius=annulus_outer_radius, ) - # ========== Calculate RAW (16-bit) SQM from shared state ========== - sqm_value_raw = None - - try: - # Get raw frame from shared state (already captured by camera) - raw_array = shared_state.cam_raw() - - if raw_array is not None: - raw_array = np.asarray(raw_array, dtype=np.float32) - - # Calculate raw SQM - sqm_value_raw, _ = sqm_calculator_raw.calculate( - centroids=centroids, - solution=solution, - image=raw_array, - exposure_sec=exposure_sec, - altitude_deg=altitude_deg, - aperture_radius=aperture_radius, - annulus_inner_radius=annulus_inner_radius, - annulus_outer_radius=annulus_outer_radius, - ) - - except Exception as e: - logger.warning(f"Failed to calculate raw SQM: {e}") - # Continue with just processed SQM + # Update noise floor in shared state (for SNR auto-exposure) + noise_floor_details = details.get("noise_floor_details") + if noise_floor_details and "noise_floor_adu" in noise_floor_details: + shared_state.set_noise_floor(noise_floor_details["noise_floor_adu"]) + + # Store SQM details (filter out large per-star arrays) + filtered_details = { + k: v + for k, v in details.items() + if k + not in ( + "star_centroids", + "star_mags", + "star_fluxes", + "star_local_backgrounds", + "star_mzeros", + ) + } + shared_state.set_sqm_details(filtered_details) - # ========== Update shared state with BOTH values ========== - if sqm_value_processed is not None: + # Update shared state + if sqm_value is not None: new_sqm_state = SQMState( - value=sqm_value_processed, - value_raw=sqm_value_raw, # May be None if raw failed + value=sqm_value, source="Calculated", last_update=datetime.now().isoformat(), ) shared_state.set_sqm(new_sqm_state) - - raw_str = ( - f", raw={sqm_value_raw:.2f}" - if sqm_value_raw is not None - else ", raw=N/A" - ) - logger.info(f"SQM updated: processed={sqm_value_processed:.2f}{raw_str}") + logger.info(f"SQM updated: {sqm_value:.2f} mag/arcsec²") return True except Exception as e: @@ -194,15 +152,39 @@ def update_sqm_dual_pipeline( return False +class CedarConnectionError(Exception): + """Raised when Cedar gRPC connection fails.""" + + pass + + class PFCedarDetectClient(cedar_detect_client.CedarDetectClient): def __init__(self, port=50551): - """Set up the client without spawning the server as we - run this as a service on the PiFinder + """Connect to cedar-detect-server. - Also changing this to a different default port + On the Pi the server runs as a systemd service. + In dev mode we spawn it as a subprocess (like upstream does). """ self._port = port - time.sleep(2) + self._subprocess = None + + # Check if the server is already listening (systemd service on Pi) + if not self._server_reachable(): + # Dev mode: spawn the server ourselves + import shutil + + binary = shutil.which("cedar-detect-server") + if binary is None: + raise FileNotFoundError("cedar-detect-server") + my_env = os.environ.copy() + my_env["RUST_BACKTRACE"] = "1" + import subprocess + + self._subprocess = subprocess.Popen( + [binary, "--port", str(self._port)], env=my_env + ) + time.sleep(1) + # Will initialize on first use. self._stub = None self._shmem = None @@ -210,6 +192,19 @@ def __init__(self, port=50551): # Try shared memory, fall back if an error occurs. self._use_shmem = True + def __del__(self): + if self._subprocess is not None: + self._subprocess.kill() + self._del_shmem() + + def _server_reachable(self): + """Quick check if cedar-detect-server is already listening.""" + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.2) + return s.connect_ex(("127.0.0.1", self._port)) == 0 + def _get_stub(self): if self._stub is None: channel = grpc.insecure_channel("127.0.0.1:%d" % self._port) @@ -218,8 +213,96 @@ def _get_stub(self): ) return self._stub - def __del__(self): - self._del_shmem() + def _alloc_shmem(self, size): + """Override to fix shared memory name (no leading / for Python's SharedMemory).""" + from multiprocessing import shared_memory + + if self._shmem is not None and size > self._shmem_size: + self._shmem.close() + self._shmem.unlink() + self._shmem = None + if self._shmem is None: + # Use name without leading / - Python's SharedMemory adds it automatically + self._shmem = shared_memory.SharedMemory( + "cedar_detect_image", create=True, size=size + ) + self._shmem_size = size + + def _del_shmem(self): + """Override to match _alloc_shmem naming.""" + if self._shmem is not None: + self._shmem.close() + try: + self._shmem.unlink() + except FileNotFoundError: + pass + self._shmem = None + + def extract_centroids( + self, image, sigma, max_size, use_binned, detect_hot_pixels=True + ): + """Override to raise CedarConnectionError on gRPC failure instead of returning empty list.""" + import numpy as np + from tetra3 import cedar_detect_pb2 + + np_image = np.asarray(image, dtype=np.uint8) + (height, width) = np_image.shape + centroids_result = None + + # Use shared memory path (same machine) + if self._use_shmem: + self._alloc_shmem(size=width * height) + shimg = np.ndarray( + np_image.shape, dtype=np_image.dtype, buffer=self._shmem.buf + ) + shimg[:] = np_image[:] + + im = cedar_detect_pb2.Image( + width=width, height=height, shmem_name=self._shmem.name + ) + req = cedar_detect_pb2.CentroidsRequest( + input_image=im, + sigma=sigma, + max_size=max_size, + return_binned=False, + use_binned_for_star_candidates=use_binned, + detect_hot_pixels=detect_hot_pixels, + ) + try: + centroids_result = self._get_stub().ExtractCentroids(req) + except grpc.RpcError as err: + if err.code() == grpc.StatusCode.INTERNAL: + # Shared memory issue, fall back to non-shmem + self._del_shmem() + self._use_shmem = False + else: + raise CedarConnectionError( + f"Cedar gRPC failed: {err.details()}" + ) from err + + if not self._use_shmem: + im = cedar_detect_pb2.Image( + width=width, height=height, image_data=np_image.tobytes() + ) + req = cedar_detect_pb2.CentroidsRequest( + input_image=im, + sigma=sigma, + max_size=max_size, + return_binned=False, + use_binned_for_star_candidates=use_binned, + ) + try: + centroids_result = self._get_stub().ExtractCentroids(req) + except grpc.RpcError as err: + raise CedarConnectionError( + f"Cedar gRPC failed: {err.details()}" + ) from err + + tetra_centroids = [] + if centroids_result is not None: + for sc in centroids_result.star_candidates: + tetra_centroids.append((sc.centroid_position.y, sc.centroid_position.x)) + return tetra_centroids def solver( @@ -272,13 +355,13 @@ def solver( centroids = [] log_no_stars_found = True - # Create SQM calculators (processed and raw) - can be reloaded via command queue + # Create SQM calculator - can be reloaded via command queue sqm_calculator = create_sqm_calculator(shared_state) - sqm_calculator_raw = create_sqm_calculator_raw(shared_state) while True: logger.info("Starting Solver Loop") - # Start cedar detect server + # Try to start cedar detect server, fall back to tetra3 centroider if unavailable + cedar_detect = None try: cedar_detect = PFCedarDetectClient() except FileNotFoundError as e: @@ -286,10 +369,8 @@ def solver( "Not using cedar_detect, as corresponding file '%s' could not be found", e.filename, ) - cedar_detect = None except ValueError: logger.exception("Not using cedar_detect") - cedar_detect = None try: while True: @@ -316,12 +397,9 @@ def solver( align_dec = 0 if command[0] == "reload_sqm_calibration": - logger.info( - "Reloading SQM calibration (both processed and raw)..." - ) + logger.info("Reloading SQM calibration...") sqm_calculator = create_sqm_calculator(shared_state) - sqm_calculator_raw = create_sqm_calculator_raw(shared_state) - logger.info("SQM calibration reloaded for both pipelines") + logger.info("SQM calibration reloaded") state_utils.sleep_for_framerate(shared_state) @@ -357,13 +435,20 @@ def solver( ] t0 = precision_timestamp() - if cedar_detect is None: - # Use old tetr3 centroider - centroids = tetra3.get_centroids_from_image(np_image) + if cedar_detect is not None: + # Try Cedar first + try: + centroids = cedar_detect.extract_centroids( + np_image, sigma=8, max_size=10, use_binned=True + ) + except CedarConnectionError as e: + logger.warning( + f"Cedar connection failed: {e}, falling back to tetra3" + ) + centroids = tetra3.get_centroids_from_image(np_image) else: - centroids = cedar_detect.extract_centroids( - np_image, sigma=8, max_size=10, use_binned=True - ) + # Cedar not available, use tetra3 + centroids = tetra3.get_centroids_from_image(np_image) t_extract = (precision_timestamp() - t0) * 1000 logger.debug( @@ -371,6 +456,9 @@ def solver( % ("camera", len(centroids), t_extract) ) + # Initialize solution to prevent UnboundLocalError + solution = {} + if len(centroids) == 0: if log_no_stars_found: logger.info( @@ -408,11 +496,9 @@ def solver( last_image_metadata["exposure_time"] / 1_000_000.0 ) - update_sqm_dual_pipeline( + update_sqm( shared_state=shared_state, sqm_calculator=sqm_calculator, - sqm_calculator_raw=sqm_calculator_raw, - camera_command_queue=camera_command_queue, centroids=centroids, solution=solution, image_processed=np_image, @@ -428,9 +514,9 @@ def solver( solution.pop("epoch_proper_motion", None) solution.pop("cache_hit_fraction", None) - solved |= solution + solved |= solution - if "T_solve" in solution: + if "T_solve" in solved: total_tetra_time = t_extract + solved["T_solve"] if total_tetra_time > 1000: console_queue.put(f"SLV: Long: {total_tetra_time}") @@ -460,7 +546,7 @@ def solver( # Mark successful solve - use same timestamp as last_solve_attempt for comparison solved["last_solve_success"] = solved["last_solve_attempt"] - logger.info( + logger.debug( f"Solve SUCCESS - {len(centroids)} centroids → " f"{solved.get('Matches', 0)} matches, " f"RMSE: {solved.get('RMSE', 0):.1f}px" @@ -484,7 +570,7 @@ def solver( else: # Centroids found but solve failed - clear Matches solved["Matches"] = 0 - logger.warning( + logger.debug( f"Solve FAILED - {len(centroids)} centroids detected but " f"pattern match failed (FOV est: 12.0°, max err: 4.0°)" ) diff --git a/python/PiFinder/splash.py b/python/PiFinder/splash.py index fc2a55ce8..a351e955f 100644 --- a/python/PiFinder/splash.py +++ b/python/PiFinder/splash.py @@ -33,8 +33,9 @@ def show_splash(): screen_draw = ImageDraw.Draw(welcome_image) # Display version and Wifi mode - with open(os.path.join(root_dir, "version.txt"), "r") as ver_f: - version = "v" + ver_f.read() + from PiFinder import utils + + version = utils.get_version() with open(os.path.join(root_dir, "wifi_status.txt"), "r") as wifi_f: wifi_mode = wifi_f.read() diff --git a/python/PiFinder/sqm/camera_profiles.py b/python/PiFinder/sqm/camera_profiles.py index 61e86e9cc..beeddab07 100644 --- a/python/PiFinder/sqm/camera_profiles.py +++ b/python/PiFinder/sqm/camera_profiles.py @@ -208,7 +208,7 @@ def __repr__(self) -> str: analog_gain=1.0, # Not applicable to processed images digital_gain=1.0, # Already applied during processing bit_depth=8, - bias_offset=8.0, # Conservative - below typical dark pixels (9-12) + bias_offset=6.0, # Calibrated against reference SQM meter read_noise_adu=1.5, # Quantization + residual noise in 8-bit dark_current_rate=0.0, # Negligible after processing thermal_coeff=0.0, diff --git a/python/PiFinder/sqm/noise_floor.py b/python/PiFinder/sqm/noise_floor.py index 056f6bc6d..526b95039 100644 --- a/python/PiFinder/sqm/noise_floor.py +++ b/python/PiFinder/sqm/noise_floor.py @@ -173,7 +173,7 @@ def estimate_noise_floor( logger.debug("Requesting zero-second calibration sample") if not is_valid: - logger.warning( + logger.debug( f"Noise floor estimate may be invalid: {reason} " f"(floor={noise_floor:.1f}, median={np.median(image):.1f})" ) @@ -251,7 +251,7 @@ def update_with_zero_sec_sample(self, zero_sec_image: np.ndarray) -> None: } ) - logger.info( + logger.debug( f"Zero-sec sample: bias={measured_bias:.1f} ADU, " f"read_noise={measured_std:.2f} ADU" ) @@ -279,7 +279,7 @@ def update_with_zero_sec_sample(self, zero_sec_image: np.ndarray) -> None: alpha * avg_read_noise + (1 - alpha) * self.profile.read_noise_adu ) - logger.info( + logger.debug( f"Updated camera profile: " f"bias {old_bias:.1f} → {self.profile.bias_offset:.1f}, " f"read_noise {old_noise:.2f} → {self.profile.read_noise_adu:.2f}" diff --git a/python/PiFinder/sqm/sqm.ipynb b/python/PiFinder/sqm/sqm.ipynb index facb0cfa7..cd956e870 100644 --- a/python/PiFinder/sqm/sqm.ipynb +++ b/python/PiFinder/sqm/sqm.ipynb @@ -32,9 +32,11 @@ "import logging as logger\n", "from pathlib import Path\n", "import matplotlib.pyplot as plt\n", + "\n", "%matplotlib inline\n", "import pprint\n", - "pp = pprint.PrettyPrinter(depth=5)\n" + "\n", + "pp = pprint.PrettyPrinter(depth=5)" ] }, { @@ -70,11 +72,11 @@ } ], "source": [ - "os.chdir('/Users/mike/dev/amateur_astro/myPiFinder/wt-sqm/python')\n", + "os.chdir(\"/Users/mike/dev/amateur_astro/myPiFinder/wt-sqm/python\")\n", "cwd = Path(os.getcwd())\n", "print(cwd)\n", "tetra3_path = cwd / \"PiFinder/tetra3/tetra3\"\n", - "root_path = cwd / '..'\n", + "root_path = cwd / \"..\"\n", "\n", "# Add it only once if it's not already there\n", "if str(tetra3_path) not in sys.path:\n", @@ -82,22 +84,21 @@ "\n", "# Silence tetra3 DEBUG output BEFORE importing tetra3\n", "import logging\n", + "\n", "logging.basicConfig(level=logging.WARNING)\n", - "logging.getLogger('tetra3.Tetra3').setLevel(logging.WARNING)\n", - "logging.getLogger('Solver').setLevel(logging.WARNING)\n", + "logging.getLogger(\"tetra3.Tetra3\").setLevel(logging.WARNING)\n", + "logging.getLogger(\"Solver\").setLevel(logging.WARNING)\n", "\n", "# Now try importing\n", - "from breadth_first_combinations import breadth_first_combinations\n", "\n", "\n", "import PiFinder.tetra3.tetra3 as tetra3\n", "from PiFinder.tetra3.tetra3 import cedar_detect_client\n", "from PiFinder import utils\n", + "\n", "os_detail, platform, arch = utils.get_os_info()\n", "\n", - "t3 = tetra3.Tetra3(\n", - " str(tetra3_path / \"data/default_database.npz\")\n", - ")\n", + "t3 = tetra3.Tetra3(str(tetra3_path / \"data/default_database.npz\"))\n", "\n", "logger.info(\"Starting Solver Loop\")\n", "# Start cedar detect server\n", @@ -161,26 +162,26 @@ "outputs": [], "source": [ "images = {\n", - " 'sqm1833.png': {'realsqm': 18.33},\n", - " 'sqm1837.png': {'realsqm': 18.37},\n", - " 'sqm1845.png': {'realsqm': 18.45},\n", - " 'sqm1855.png': {'realsqm': 18.55},\n", - " 'sqm1860.png': {'realsqm': 18.60},\n", - " 'sqm1870.png': {'realsqm': 18.70},\n", - " 'sqm1980.png': {'realsqm': 19.80},\n", - " 'sqm2000_0.8-4.png': {'realsqm': 20.00},\n", - " 'sqm2000_0.8-3.png': {'realsqm': 20.00},\n", - " 'sqm1818_raw_new_0.2.png': {'realsqm': 18.18}, \n", - " 'sqm1818_raw_new_1.png': {'realsqm': 18.18}\n", + " \"sqm1833.png\": {\"realsqm\": 18.33},\n", + " \"sqm1837.png\": {\"realsqm\": 18.37},\n", + " \"sqm1845.png\": {\"realsqm\": 18.45},\n", + " \"sqm1855.png\": {\"realsqm\": 18.55},\n", + " \"sqm1860.png\": {\"realsqm\": 18.60},\n", + " \"sqm1870.png\": {\"realsqm\": 18.70},\n", + " \"sqm1980.png\": {\"realsqm\": 19.80},\n", + " \"sqm2000_0.8-4.png\": {\"realsqm\": 20.00},\n", + " \"sqm2000_0.8-3.png\": {\"realsqm\": 20.00},\n", + " \"sqm1818_raw_new_0.2.png\": {\"realsqm\": 18.18},\n", + " \"sqm1818_raw_new_1.png\": {\"realsqm\": 18.18},\n", "}\n", "\n", "#\n", "# {\n", - "# 'sqmbla.png' : {'realsqm': 18.44, \n", + "# 'sqmbla.png' : {'realsqm': 18.44,\n", "#\n", "#\n", - "#images = {'sqm1833.png': images['sqm1833.png']}\n", - "#images = {'sqm1837.png': images['sqm1837.png']}" + "# images = {'sqm1833.png': images['sqm1833.png']}\n", + "# images = {'sqm1837.png': images['sqm1837.png']}" ] }, { @@ -198,7 +199,7 @@ "metadata": {}, "outputs": [], "source": [ - "def load_image(current_image, image_path = Path('../test_images/')):\n", + "def load_image(current_image, image_path=Path(\"../test_images/\")):\n", " img = Image.open(image_path / current_image)\n", " rgb_np_image = np.asarray(img, dtype=np.uint8)\n", " np_image = rgb_np_image[:, :, 0] # Takes just the red values\n", @@ -206,16 +207,18 @@ " # np_image = ((stretched - stretched.min()) * (255.0/(stretched.max() - stretched.min()))).astype(np.uint8)\n", " return np_image, img\n", "\n", + "\n", "def show_image(image):\n", - " plt.imshow(image, cmap='gray')\n", - " plt.title(f\"Test image\")\n", + " plt.imshow(image, cmap=\"gray\")\n", + " plt.title(\"Test image\")\n", " plt.colorbar()\n", - " plt.show() \n", + " plt.show()\n", + "\n", "\n", "# To use just one specific method:\n", "def percentile_stretch(image, name, low=5, high=99):\n", " p_low, p_high = np.percentile(image, (low, high))\n", - " plt.imshow(image, cmap='gray', vmin=p_low, vmax=p_high)\n", + " plt.imshow(image, cmap=\"gray\", vmin=p_low, vmax=p_high)\n", " plt.title(name)\n", " plt.colorbar()\n", " plt.show()" @@ -537,7 +540,7 @@ "for filename in images:\n", " print(f\"{filename}\")\n", " np_image, image = load_image(filename)\n", - " images[filename]['np_image'] = np_image\n", + " images[filename][\"np_image\"] = np_image\n", " show_image(np_image)\n", " percentile_stretch(np_image, filename)" ] @@ -593,10 +596,10 @@ " fov_max_error=4.0,\n", " match_max_error=0.005,\n", " return_matches=True,\n", - " target_pixel=(128,128),\n", + " target_pixel=(128, 128),\n", " solve_timeout=1000,\n", " )\n", - " \n", + "\n", " if \"matched_centroids\" in solution:\n", " # Don't clutter printed solution with these fields.\n", " # del solution['matched_centroids']\n", @@ -608,13 +611,16 @@ " del solution[\"cache_hit_fraction\"]\n", " return centroids, solution\n", "\n", - "for key, value in images.items(): \n", - " centroids, solution = detect(value['np_image'])\n", - " value['centroids'] = centroids # Store ALL detected centroids\n", - " value['matched_stars'] = solution['matched_stars']\n", - " value['matched_centroids'] = solution['matched_centroids']\n", - " value['fov'] = solution['FOV']\n", - " print(f\"For {key}, there are {len(value['matched_stars'])} matched_stars and {len(centroids)} total centroids\")" + "\n", + "for key, value in images.items():\n", + " centroids, solution = detect(value[\"np_image\"])\n", + " value[\"centroids\"] = centroids # Store ALL detected centroids\n", + " value[\"matched_stars\"] = solution[\"matched_stars\"]\n", + " value[\"matched_centroids\"] = solution[\"matched_centroids\"]\n", + " value[\"fov\"] = solution[\"FOV\"]\n", + " print(\n", + " f\"For {key}, there are {len(value['matched_stars'])} matched_stars and {len(centroids)} total centroids\"\n", + " )" ] }, { @@ -633,11 +639,11 @@ "outputs": [], "source": [ "def enhance_centroids(value: dict):\n", - " matched_centroids = value['matched_centroids']\n", - " matched_stars = value['matched_stars']\n", + " matched_centroids = value[\"matched_centroids\"]\n", + " matched_stars = value[\"matched_stars\"]\n", " xymags = []\n", " for centr, stars in zip(matched_centroids, matched_stars):\n", - " xymags.append([*centr,*stars])\n", + " xymags.append([*centr, *stars])\n", " xymags = np.array(xymags)\n", " xymags_sorted = xymags[xymags[:, 4].argsort()]\n", " # pixel_x, pixel_y - sorted\n", @@ -646,16 +652,16 @@ " matched_stars_s = [[x[2], x[3], x[4]] for x in xymags_sorted]\n", " # pixel_x, pixel_y, mag - sorted\n", " matched = [[x[0], x[1], x[4]] for x in xymags_sorted]\n", - " value['matched_centroids'] = matched_centroids_s\n", - " value['matched_stars'] = matched_stars_s\n", - " value['matched'] = matched\n", + " value[\"matched_centroids\"] = matched_centroids_s\n", + " value[\"matched_stars\"] = matched_stars_s\n", + " value[\"matched\"] = matched\n", " return value\n", - " \n", + "\n", + "\n", "for key, value in images.items():\n", " images[key] = enhance_centroids(value)\n", "\n", - "#pp.pprint(images)\n", - "\n" + "# pp.pprint(images)" ] }, { @@ -686,15 +692,16 @@ "source": [ "radius = 4\n", "plt.title(f\"circles with radius {radius}\")\n", - "plt.imshow(np.log1p(np_image), cmap='gray')\n", + "plt.imshow(np.log1p(np_image), cmap=\"gray\")\n", "plt.colorbar()\n", "# Add circles\n", "for i, (y, x) in enumerate(centroids):\n", - " circle = plt.Circle((x, y), radius, fill=False, color='red')\n", + " circle = plt.Circle((x, y), radius, fill=False, color=\"red\")\n", " plt.gca().add_artist(circle)\n", - " # Add number annotation\n", - " plt.annotate(str(i), (x, y), color='yellow', fontsize=8, \n", - " ha='right', va='top') # ha/va center the text on the point\n", + " # Add number annotation\n", + " plt.annotate(\n", + " str(i), (x, y), color=\"yellow\", fontsize=8, ha=\"right\", va=\"top\"\n", + " ) # ha/va center the text on the point\n", "plt.show()" ] }, @@ -730,31 +737,38 @@ "def histogram(image):\n", " # Method 1: Using PIL's built-in histogram\n", " hist = image.histogram()\n", - " \n", + "\n", " # Method 2: Better visualization with matplotlib\n", " np_image = np.array(image)\n", - " \n", + "\n", " plt.figure(figsize=(10, 6))\n", " plt.hist(np_image.ravel(), bins=256, range=(0, 256), density=True, alpha=0.75)\n", - " plt.xlabel('Pixel Value')\n", - " plt.ylabel('Frequency')\n", - " plt.title('Image Histogram')\n", + " plt.xlabel(\"Pixel Value\")\n", + " plt.ylabel(\"Frequency\")\n", + " plt.title(\"Image Histogram\")\n", " plt.grid(True, alpha=0.2)\n", - " \n", + "\n", " # Optional: Add vertical line for mean\n", " mean_val = np_image.mean()\n", - " plt.axvline(mean_val, color='r', linestyle='dashed', alpha=0.5, \n", - " label=f'Mean: {mean_val:.1f}')\n", + " plt.axvline(\n", + " mean_val,\n", + " color=\"r\",\n", + " linestyle=\"dashed\",\n", + " alpha=0.5,\n", + " label=f\"Mean: {mean_val:.1f}\",\n", + " )\n", " plt.legend()\n", - " \n", + "\n", " plt.show()\n", - " \n", + "\n", " # Print some statistics\n", " print(f\"Min: {np_image.min()}\")\n", " print(f\"Max: {np_image.max()}\")\n", " print(f\"Mean: {np_image.mean():.2f}\")\n", " print(f\"Median: {np.median(np_image):.2f}\")\n", " print(f\"Std Dev: {np_image.std():.2f}\")\n", + "\n", + "\n", "histogram(image)" ] }, @@ -796,19 +810,21 @@ "\n", "plt.subplot(121)\n", "plt.hist(np_array.ravel(), bins=256, range=(0, 256), density=True, alpha=0.75)\n", - "plt.title('Original Histogram')\n", - "plt.xlabel('Pixel Value')\n", - "plt.ylabel('Frequency')\n", + "plt.title(\"Original Histogram\")\n", + "plt.xlabel(\"Pixel Value\")\n", + "plt.ylabel(\"Frequency\")\n", "\n", "# Linear stretch (normalize to 0-255)\n", "stretched = np_array.astype(float)\n", - "stretched = ((stretched - stretched.min()) * (255.0/(stretched.max() - stretched.min()))).astype(np.uint8)\n", + "stretched = (\n", + " (stretched - stretched.min()) * (255.0 / (stretched.max() - stretched.min()))\n", + ").astype(np.uint8)\n", "\n", "plt.subplot(122)\n", "plt.hist(stretched.ravel(), bins=256, range=(0, 256), density=True, alpha=0.75)\n", - "plt.title('Stretched Histogram')\n", - "plt.xlabel('Pixel Value')\n", - "plt.ylabel('Frequency')\n", + "plt.title(\"Stretched Histogram\")\n", + "plt.xlabel(\"Pixel Value\")\n", + "plt.ylabel(\"Frequency\")\n", "\n", "plt.tight_layout()\n", "plt.show()\n", @@ -911,46 +927,52 @@ "\n", "# Parameters for local background measurement\n", "APERTURE_RADIUS = 5 # Star flux aperture (pixels)\n", - "ANNULUS_INNER = 6 # Inner radius of background annulus (pixels)\n", - "ANNULUS_OUTER = 14 # Outer radius of background annulus (pixels)\n", - "ALTITUDE = 90 # Zenith for now (no extinction correction until we have real altitude)\n", - "PEDESTAL = 0 # No pedestal correction for now\n", + "ANNULUS_INNER = 6 # Inner radius of background annulus (pixels)\n", + "ANNULUS_OUTER = 14 # Outer radius of background annulus (pixels)\n", + "ALTITUDE = 90 # Zenith for now (no extinction correction until we have real altitude)\n", + "PEDESTAL = 0 # No pedestal correction for now\n", "\n", "print(\"Production SQM Implementation Results (Local Annulus Backgrounds)\")\n", "print(\"=\" * 100)\n", - "print(f\"{'Image':<25} {'Expected':<12} {'Calculated':<12} {'Error':<12} {'Error %':<12}\")\n", + "print(\n", + " f\"{'Image':<25} {'Expected':<12} {'Calculated':<12} {'Error':<12} {'Error %':<12}\"\n", + ")\n", "print(\"-\" * 100)\n", "\n", "for key, value in images.items():\n", " # Build solution dict from the existing data\n", " solution = {\n", - " 'FOV': value['fov'],\n", - " 'matched_centroids': value['matched_centroids'],\n", - " 'matched_stars': value['matched_stars']\n", + " \"FOV\": value[\"fov\"],\n", + " \"matched_centroids\": value[\"matched_centroids\"],\n", + " \"matched_stars\": value[\"matched_stars\"],\n", " }\n", - " \n", + "\n", " # Calculate SQM using local annulus backgrounds\n", " sqm_val, details = sqm.calculate(\n", - " centroids=value['centroids'],\n", + " centroids=value[\"centroids\"],\n", " solution=solution,\n", - " image=value['np_image'], \n", + " image=value[\"np_image\"],\n", " altitude_deg=ALTITUDE,\n", " aperture_radius=APERTURE_RADIUS,\n", " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", - " pedestal=PEDESTAL\n", + " pedestal=PEDESTAL,\n", " )\n", - " \n", + "\n", " if sqm_val is not None:\n", - " value['sqm_calculated'] = sqm_val\n", - " value['sqm_details'] = details\n", - " \n", - " expected = value['realsqm']\n", + " value[\"sqm_calculated\"] = sqm_val\n", + " value[\"sqm_details\"] = details\n", + "\n", + " expected = value[\"realsqm\"]\n", " calc_err = sqm_val - expected\n", " err_pct = 100 * calc_err / expected\n", - " \n", - " print(f\"{key:<25} {expected:>10.2f} {sqm_val:>10.2f} {calc_err:>10.2f} {err_pct:>10.1f}%\")\n", - " print(f\"{'':>25} mzero={details['mzero']:>6.2f}, bg={details['background_per_pixel']:>6.1f} ADU/px, {details['n_matched_stars']} stars\")\n", + "\n", + " print(\n", + " f\"{key:<25} {expected:>10.2f} {sqm_val:>10.2f} {calc_err:>10.2f} {err_pct:>10.1f}%\"\n", + " )\n", + " print(\n", + " f\"{'':>25} mzero={details['mzero']:>6.2f}, bg={details['background_per_pixel']:>6.1f} ADU/px, {details['n_matched_stars']} stars\"\n", + " )\n", " else:\n", " print(f\"{key:<25} FAILED\")\n", "\n", @@ -1054,17 +1076,17 @@ "\n", "# Define all test images\n", "all_images = {\n", - " 'sqm1833.png': {'realsqm': 18.33},\n", - " 'sqm1837.png': {'realsqm': 18.37},\n", - " 'sqm1845.png': {'realsqm': 18.45},\n", - " 'sqm1855.png': {'realsqm': 18.55},\n", - " 'sqm1860.png': {'realsqm': 18.60},\n", - " 'sqm1870.png': {'realsqm': 18.70},\n", - " 'sqm1980.png': {'realsqm': 19.80},\n", - " 'sqm2000_0.8-4.png': {'realsqm': 20.00},\n", - " 'sqm2000_0.8-3.png': {'realsqm': 20.00},\n", - " 'sqm1818_raw_new_0.2.png': {'realsqm': 18.18}, \n", - " 'sqm1818_raw_new_1.png': {'realsqm': 18.18}\n", + " \"sqm1833.png\": {\"realsqm\": 18.33},\n", + " \"sqm1837.png\": {\"realsqm\": 18.37},\n", + " \"sqm1845.png\": {\"realsqm\": 18.45},\n", + " \"sqm1855.png\": {\"realsqm\": 18.55},\n", + " \"sqm1860.png\": {\"realsqm\": 18.60},\n", + " \"sqm1870.png\": {\"realsqm\": 18.70},\n", + " \"sqm1980.png\": {\"realsqm\": 19.80},\n", + " \"sqm2000_0.8-4.png\": {\"realsqm\": 20.00},\n", + " \"sqm2000_0.8-3.png\": {\"realsqm\": 20.00},\n", + " \"sqm1818_raw_new_0.2.png\": {\"realsqm\": 18.18},\n", + " \"sqm1818_raw_new_1.png\": {\"realsqm\": 18.18},\n", "}\n", "\n", "# Parameters for local annulus background\n", @@ -1082,25 +1104,27 @@ "\n", "for filename, info in all_images.items():\n", " print(f\"\\nProcessing {filename}...\")\n", - " \n", + "\n", " # Load image\n", " np_image, _ = load_image(filename)\n", - " \n", + "\n", " # Detect stars and solve\n", " centroids, solution = detect(np_image)\n", - " \n", + "\n", " # Check if solve succeeded\n", - " if 'matched_centroids' not in solution or len(solution['matched_centroids']) == 0:\n", - " print(f\" ❌ Failed to solve\")\n", - " results_summary.append({\n", - " 'filename': filename,\n", - " 'expected': info['realsqm'],\n", - " 'calculated': None,\n", - " 'error': None,\n", - " 'status': 'SOLVE_FAILED'\n", - " })\n", + " if \"matched_centroids\" not in solution or len(solution[\"matched_centroids\"]) == 0:\n", + " print(\" ❌ Failed to solve\")\n", + " results_summary.append(\n", + " {\n", + " \"filename\": filename,\n", + " \"expected\": info[\"realsqm\"],\n", + " \"calculated\": None,\n", + " \"error\": None,\n", + " \"status\": \"SOLVE_FAILED\",\n", + " }\n", + " )\n", " continue\n", - " \n", + "\n", " # Calculate SQM\n", " sqm_val, details = sqm.calculate(\n", " centroids=centroids,\n", @@ -1110,33 +1134,41 @@ " aperture_radius=APERTURE_RADIUS,\n", " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", - " pedestal=PEDESTAL\n", + " pedestal=PEDESTAL,\n", " )\n", - " \n", + "\n", " if sqm_val is not None:\n", - " error = sqm_val - info['realsqm']\n", - " print(f\" ✓ SQM: {sqm_val:.2f} (expected: {info['realsqm']:.2f}, error: {error:+.2f})\")\n", - " print(f\" mzero={details['mzero']:.2f}, stars={details['n_matched_stars']}, centroids={details['n_centroids']}\")\n", - " \n", - " results_summary.append({\n", - " 'filename': filename,\n", - " 'expected': info['realsqm'],\n", - " 'calculated': sqm_val,\n", - " 'error': error,\n", - " 'mzero': details['mzero'],\n", - " 'n_stars': details['n_matched_stars'],\n", - " 'n_centroids': details['n_centroids'],\n", - " 'status': 'OK'\n", - " })\n", + " error = sqm_val - info[\"realsqm\"]\n", + " print(\n", + " f\" ✓ SQM: {sqm_val:.2f} (expected: {info['realsqm']:.2f}, error: {error:+.2f})\"\n", + " )\n", + " print(\n", + " f\" mzero={details['mzero']:.2f}, stars={details['n_matched_stars']}, centroids={details['n_centroids']}\"\n", + " )\n", + "\n", + " results_summary.append(\n", + " {\n", + " \"filename\": filename,\n", + " \"expected\": info[\"realsqm\"],\n", + " \"calculated\": sqm_val,\n", + " \"error\": error,\n", + " \"mzero\": details[\"mzero\"],\n", + " \"n_stars\": details[\"n_matched_stars\"],\n", + " \"n_centroids\": details[\"n_centroids\"],\n", + " \"status\": \"OK\",\n", + " }\n", + " )\n", " else:\n", - " print(f\" ❌ Failed to calculate SQM\")\n", - " results_summary.append({\n", - " 'filename': filename,\n", - " 'expected': info['realsqm'],\n", - " 'calculated': None,\n", - " 'error': None,\n", - " 'status': 'CALC_FAILED'\n", - " })\n", + " print(\" ❌ Failed to calculate SQM\")\n", + " results_summary.append(\n", + " {\n", + " \"filename\": filename,\n", + " \"expected\": info[\"realsqm\"],\n", + " \"calculated\": None,\n", + " \"error\": None,\n", + " \"status\": \"CALC_FAILED\",\n", + " }\n", + " )\n", "\n", "print(\"\\n\" + \"=\" * 100)\n", "print(\"SUMMARY\")\n", @@ -1145,22 +1177,28 @@ "print(\"-\" * 100)\n", "\n", "for result in results_summary:\n", - " if result['status'] == 'OK':\n", - " print(f\"{result['filename']:<30} {result['expected']:>10.2f} {result['calculated']:>10.2f} {result['error']:>10.2f} {result['status']:<15}\")\n", + " if result[\"status\"] == \"OK\":\n", + " print(\n", + " f\"{result['filename']:<30} {result['expected']:>10.2f} {result['calculated']:>10.2f} {result['error']:>10.2f} {result['status']:<15}\"\n", + " )\n", " else:\n", - " print(f\"{result['filename']:<30} {result['expected']:>10.2f} {'---':>10} {'---':>10} {result['status']:<15}\")\n", + " print(\n", + " f\"{result['filename']:<30} {result['expected']:>10.2f} {'---':>10} {'---':>10} {result['status']:<15}\"\n", + " )\n", "\n", "# Calculate statistics for successful measurements\n", - "successful = [r for r in results_summary if r['status'] == 'OK']\n", + "successful = [r for r in results_summary if r[\"status\"] == \"OK\"]\n", "if successful:\n", - " errors = [r['error'] for r in successful]\n", + " errors = [r[\"error\"] for r in successful]\n", " print(\"\\n\" + \"=\" * 100)\n", " print(\"STATISTICS\")\n", " print(\"=\" * 100)\n", " print(f\"Successful measurements: {len(successful)}/{len(results_summary)}\")\n", " print(f\"Mean error: {np.mean(errors):+.2f} mag/arcsec²\")\n", " print(f\"Std dev: {np.std(errors):.2f} mag/arcsec²\")\n", - " print(f\"RMS error: {np.sqrt(np.mean(np.array(errors)**2)):.2f} mag/arcsec²\")\n", + " print(\n", + " f\"RMS error: {np.sqrt(np.mean(np.array(errors) ** 2)):.2f} mag/arcsec²\"\n", + " )\n", " print(f\"Max error: {np.max(np.abs(errors)):.2f} mag/arcsec²\")" ] }, @@ -1330,36 +1368,40 @@ "from matplotlib.gridspec import GridSpec\n", "from scipy import stats\n", "\n", + "\n", "def sigma_clip_mean(data, sigma=2.0, max_iter=3):\n", " \"\"\"Calculate mean after sigma clipping outliers. Returns mean, std, and mask matching input size.\"\"\"\n", " data = np.array(data)\n", " original_indices = np.arange(len(data))\n", " mask = np.ones(len(data), dtype=bool)\n", - " \n", + "\n", " current_data = data.copy()\n", " current_indices = original_indices.copy()\n", - " \n", + "\n", " for _ in range(max_iter):\n", " mean = np.mean(current_data)\n", " std = np.std(current_data)\n", " keep = np.abs(current_data - mean) < sigma * std\n", - " \n", + "\n", " if np.sum(keep) == len(current_data):\n", " break\n", - " \n", + "\n", " current_data = current_data[keep]\n", " current_indices = current_indices[keep]\n", - " \n", + "\n", " # Create mask for original array\n", " final_mask = np.zeros(len(data), dtype=bool)\n", " final_mask[current_indices] = True\n", - " \n", + "\n", " return np.mean(current_data), np.std(current_data), final_mask\n", "\n", - "def detect_aperture_overlaps(star_centroids, aperture_radius, annulus_inner, annulus_outer):\n", + "\n", + "def detect_aperture_overlaps(\n", + " star_centroids, aperture_radius, annulus_inner, annulus_outer\n", + "):\n", " \"\"\"\n", " Detect overlapping apertures and annuli between star pairs.\n", - " \n", + "\n", " Returns list of overlaps with format:\n", " {\n", " 'star1_idx': int,\n", @@ -1371,61 +1413,68 @@ " \"\"\"\n", " overlaps = []\n", " n_stars = len(star_centroids)\n", - " \n", + "\n", " for i in range(n_stars):\n", - " for j in range(i+1, n_stars):\n", + " for j in range(i + 1, n_stars):\n", " x1, y1 = star_centroids[i]\n", " x2, y2 = star_centroids[j]\n", - " distance = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)\n", - " \n", + " distance = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)\n", + "\n", " # Check different overlap types\n", " if distance < 2 * aperture_radius:\n", " # CRITICAL: Aperture-aperture overlap (star flux contamination)\n", - " overlaps.append({\n", - " 'star1_idx': i,\n", - " 'star2_idx': j,\n", - " 'distance': distance,\n", - " 'type': 'CRITICAL',\n", - " 'description': f'Aperture overlap (d={distance:.1f}px < {2*aperture_radius}px)'\n", - " })\n", + " overlaps.append(\n", + " {\n", + " \"star1_idx\": i,\n", + " \"star2_idx\": j,\n", + " \"distance\": distance,\n", + " \"type\": \"CRITICAL\",\n", + " \"description\": f\"Aperture overlap (d={distance:.1f}px < {2 * aperture_radius}px)\",\n", + " }\n", + " )\n", " elif distance < aperture_radius + annulus_outer:\n", " # HIGH: Aperture inside another star's annulus (background contamination)\n", - " overlaps.append({\n", - " 'star1_idx': i,\n", - " 'star2_idx': j,\n", - " 'distance': distance,\n", - " 'type': 'HIGH',\n", - " 'description': f'Aperture-annulus overlap (d={distance:.1f}px < {aperture_radius + annulus_outer}px)'\n", - " })\n", + " overlaps.append(\n", + " {\n", + " \"star1_idx\": i,\n", + " \"star2_idx\": j,\n", + " \"distance\": distance,\n", + " \"type\": \"HIGH\",\n", + " \"description\": f\"Aperture-annulus overlap (d={distance:.1f}px < {aperture_radius + annulus_outer}px)\",\n", + " }\n", + " )\n", " elif distance < 2 * annulus_outer:\n", " # MEDIUM: Annulus-annulus overlap (less critical)\n", - " overlaps.append({\n", - " 'star1_idx': i,\n", - " 'star2_idx': j,\n", - " 'distance': distance,\n", - " 'type': 'MEDIUM',\n", - " 'description': f'Annulus overlap (d={distance:.1f}px < {2*annulus_outer}px)'\n", - " })\n", - " \n", + " overlaps.append(\n", + " {\n", + " \"star1_idx\": i,\n", + " \"star2_idx\": j,\n", + " \"distance\": distance,\n", + " \"type\": \"MEDIUM\",\n", + " \"description\": f\"Annulus overlap (d={distance:.1f}px < {2 * annulus_outer}px)\",\n", + " }\n", + " )\n", + "\n", " return overlaps\n", "\n", + "\n", "# Process each image with full diagnostics\n", "for filename, info in all_images.items():\n", - " print(f\"\\n{'='*100}\")\n", + " print(f\"\\n{'=' * 100}\")\n", " print(f\"Processing: {filename}\")\n", " print(f\"Expected SQM: {info['realsqm']:.2f} mag/arcsec²\")\n", - " print(f\"{'='*100}\\n\")\n", - " \n", + " print(f\"{'=' * 100}\\n\")\n", + "\n", " # Load image\n", " np_image, _ = load_image(filename)\n", - " \n", + "\n", " # Detect stars and solve\n", " centroids, solution = detect(np_image)\n", - " \n", - " if 'matched_centroids' not in solution or len(solution['matched_centroids']) == 0:\n", + "\n", + " if \"matched_centroids\" not in solution or len(solution[\"matched_centroids\"]) == 0:\n", " print(f\"❌ Failed to solve {filename}\\n\")\n", " continue\n", - " \n", + "\n", " # Calculate SQM WITHOUT overlap correction\n", " sqm_val, details = sqm.calculate(\n", " centroids=centroids,\n", @@ -1436,9 +1485,9 @@ " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", " pedestal=PEDESTAL,\n", - " correct_overlaps=False\n", + " correct_overlaps=False,\n", " )\n", - " \n", + "\n", " # Calculate SQM WITH overlap correction\n", " sqm_val_corrected, details_corrected = sqm.calculate(\n", " centroids=centroids,\n", @@ -1449,90 +1498,102 @@ " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", " pedestal=PEDESTAL,\n", - " correct_overlaps=True\n", + " correct_overlaps=True,\n", " )\n", - " \n", + "\n", " if sqm_val is None:\n", " print(f\"❌ Failed to calculate SQM for {filename}\\n\")\n", " continue\n", - " \n", + "\n", " # Extract details (use non-corrected for visualization, but we have both)\n", - " star_centroids = np.array(details['star_centroids'])\n", - " star_mags = details['star_mags']\n", - " star_fluxes = details['star_fluxes']\n", - " star_mzeros = details['star_mzeros']\n", - " star_local_bgs = details.get('star_local_backgrounds', [None] * len(star_mags))\n", - " \n", + " star_centroids = np.array(details[\"star_centroids\"])\n", + " star_mags = details[\"star_mags\"]\n", + " star_fluxes = details[\"star_fluxes\"]\n", + " star_mzeros = details[\"star_mzeros\"]\n", + " star_local_bgs = details.get(\"star_local_backgrounds\", [None] * len(star_mags))\n", + "\n", " # ========== APERTURE OVERLAP DETECTION ==========\n", - " overlaps = detect_aperture_overlaps(star_centroids, APERTURE_RADIUS, ANNULUS_INNER, ANNULUS_OUTER)\n", - " \n", + " overlaps = detect_aperture_overlaps(\n", + " star_centroids, APERTURE_RADIUS, ANNULUS_INNER, ANNULUS_OUTER\n", + " )\n", + "\n", " # Build set of stars affected by overlaps\n", " overlapping_stars = set()\n", " for overlap in overlaps:\n", - " overlapping_stars.add(overlap['star1_idx'])\n", - " overlapping_stars.add(overlap['star2_idx'])\n", - " \n", + " overlapping_stars.add(overlap[\"star1_idx\"])\n", + " overlapping_stars.add(overlap[\"star2_idx\"])\n", + "\n", " # Categorize overlaps by severity\n", - " critical_overlaps = [o for o in overlaps if o['type'] == 'CRITICAL']\n", - " high_overlaps = [o for o in overlaps if o['type'] == 'HIGH']\n", - " medium_overlaps = [o for o in overlaps if o['type'] == 'MEDIUM']\n", - " \n", + " critical_overlaps = [o for o in overlaps if o[\"type\"] == \"CRITICAL\"]\n", + " high_overlaps = [o for o in overlaps if o[\"type\"] == \"HIGH\"]\n", + " medium_overlaps = [o for o in overlaps if o[\"type\"] == \"MEDIUM\"]\n", + "\n", " # Print overlap summary\n", " if overlaps:\n", " print(f\"⚠️ OVERLAPS DETECTED: {len(overlaps)} total\")\n", " print(f\" CRITICAL (aperture-aperture): {len(critical_overlaps)}\")\n", " print(f\" HIGH (aperture-annulus): {len(high_overlaps)}\")\n", " print(f\" MEDIUM (annulus-annulus): {len(medium_overlaps)}\")\n", - " print(f\" Stars affected: {len(overlapping_stars)}/{len(star_centroids)} ({100*len(overlapping_stars)/len(star_centroids):.0f}%)\")\n", + " print(\n", + " f\" Stars affected: {len(overlapping_stars)}/{len(star_centroids)} ({100 * len(overlapping_stars) / len(star_centroids):.0f}%)\"\n", + " )\n", " print()\n", " else:\n", - " print(f\"✓ No aperture overlaps detected\\n\")\n", - " \n", + " print(\"✓ No aperture overlaps detected\\n\")\n", + "\n", " # Calculate alternative mzero methods - filter for valid stars (flux > 0 and mzero not None)\n", - " valid_indices = [i for i in range(len(star_fluxes)) \n", - " if star_fluxes[i] > 0 and star_mzeros[i] is not None]\n", + " valid_indices = [\n", + " i\n", + " for i in range(len(star_fluxes))\n", + " if star_fluxes[i] > 0 and star_mzeros[i] is not None\n", + " ]\n", " valid_mzeros = np.array([star_mzeros[i] for i in valid_indices])\n", " valid_mags = np.array([star_mags[i] for i in valid_indices])\n", " valid_fluxes = np.array([star_fluxes[i] for i in valid_indices])\n", - " \n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " mzero_mean = np.mean(valid_mzeros)\n", " mzero_median = np.median(valid_mzeros)\n", " mzero_std = np.std(valid_mzeros)\n", - " \n", + "\n", " # Sigma clipping\n", " if len(valid_mzeros) >= 3:\n", - " mzero_sigclip, mzero_sigclip_std, sigclip_mask = sigma_clip_mean(valid_mzeros, sigma=2.0)\n", + " mzero_sigclip, mzero_sigclip_std, sigclip_mask = sigma_clip_mean(\n", + " valid_mzeros, sigma=2.0\n", + " )\n", " n_clipped = len(valid_mzeros) - np.sum(sigclip_mask)\n", " else:\n", " mzero_sigclip = mzero_mean\n", " mzero_sigclip_std = mzero_std\n", " n_clipped = 0\n", " sigclip_mask = np.ones(len(valid_mzeros), dtype=bool)\n", - " \n", + "\n", " # Trendline correction methods\n", " if len(valid_mzeros) >= 3:\n", " # Method 1: Trendline on all valid stars\n", - " slope_all, intercept_all, r_value_all, _, _ = stats.linregress(valid_mags, valid_mzeros)\n", + " slope_all, intercept_all, r_value_all, _, _ = stats.linregress(\n", + " valid_mags, valid_mzeros\n", + " )\n", " # Evaluate trend at median magnitude\n", " median_mag = np.median(valid_mags)\n", " mzero_trend = slope_all * median_mag + intercept_all\n", - " \n", + "\n", " # Calculate residuals for quality metric\n", " predicted_all = slope_all * valid_mags + intercept_all\n", " residuals_all = valid_mzeros - predicted_all\n", " trend_rms_all = np.sqrt(np.mean(residuals_all**2))\n", - " \n", + "\n", " # Method 2: Sigma clip THEN fit trendline\n", " clipped_mags = valid_mags[sigclip_mask]\n", " clipped_mzeros = valid_mzeros[sigclip_mask]\n", - " \n", + "\n", " if len(clipped_mzeros) >= 3:\n", - " slope_clip, intercept_clip, r_value_clip, _, _ = stats.linregress(clipped_mags, clipped_mzeros)\n", + " slope_clip, intercept_clip, r_value_clip, _, _ = stats.linregress(\n", + " clipped_mags, clipped_mzeros\n", + " )\n", " median_mag_clip = np.median(clipped_mags)\n", " mzero_trend_sigclip = slope_clip * median_mag_clip + intercept_clip\n", - " \n", + "\n", " predicted_clip = slope_clip * clipped_mags + intercept_clip\n", " residuals_clip = clipped_mzeros - predicted_clip\n", " trend_rms_clip = np.sqrt(np.mean(residuals_clip**2))\n", @@ -1553,398 +1614,632 @@ " r_value_clip = 0\n", " trend_rms_all = mzero_std\n", " trend_rms_clip = mzero_sigclip_std\n", - " \n", + "\n", " # Calculate SQM with alternative methods\n", - " bg_flux_density = details['background_flux_density']\n", - " extinction = details['extinction_correction']\n", - " \n", + " bg_flux_density = details[\"background_flux_density\"]\n", + " extinction = details[\"extinction_correction\"]\n", + "\n", " sqm_median = mzero_median - 2.5 * np.log10(bg_flux_density) + extinction\n", " sqm_sigclip = mzero_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", " sqm_trend = mzero_trend - 2.5 * np.log10(bg_flux_density) + extinction\n", - " sqm_trend_sigclip = mzero_trend_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", + " sqm_trend_sigclip = (\n", + " mzero_trend_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", + " )\n", " else:\n", - " mzero_mean = mzero_median = mzero_sigclip = mzero_trend = mzero_trend_sigclip = None\n", + " mzero_mean = mzero_median = mzero_sigclip = mzero_trend = (\n", + " mzero_trend_sigclip\n", + " ) = None\n", " sqm_median = sqm_sigclip = sqm_trend = sqm_trend_sigclip = None\n", " n_clipped = 0\n", " slope_all = slope_clip = 0\n", " r_value_all = r_value_clip = 0\n", - " \n", + "\n", " # Create comprehensive figure with 4x3 grid\n", " fig = plt.figure(figsize=(24, 16))\n", " gs = GridSpec(4, 3, figure=fig, hspace=0.35, wspace=0.3)\n", - " \n", + "\n", " # ========== Panel 1: Image with apertures (spans 2x2) ==========\n", " ax1 = fig.add_subplot(gs[0:2, 0:2])\n", - " \n", + "\n", " # Display image with log stretch\n", " vmin, vmax = np.percentile(np_image, [1, 99.5])\n", - " im = ax1.imshow(np_image, cmap='gray', vmin=vmin, vmax=vmax, origin='lower')\n", - " \n", + " im = ax1.imshow(np_image, cmap=\"gray\", vmin=vmin, vmax=vmax, origin=\"lower\")\n", + "\n", " # Draw connecting lines for overlaps FIRST (so they appear behind circles)\n", " for overlap in overlaps:\n", - " x1, y1 = star_centroids[overlap['star1_idx']]\n", - " x2, y2 = star_centroids[overlap['star2_idx']]\n", - " \n", + " x1, y1 = star_centroids[overlap[\"star1_idx\"]]\n", + " x2, y2 = star_centroids[overlap[\"star2_idx\"]]\n", + "\n", " # Color by severity\n", - " if overlap['type'] == 'CRITICAL':\n", - " line_color = 'red'\n", - " elif overlap['type'] == 'HIGH':\n", - " line_color = 'orange'\n", + " if overlap[\"type\"] == \"CRITICAL\":\n", + " line_color = \"red\"\n", + " elif overlap[\"type\"] == \"HIGH\":\n", + " line_color = \"orange\"\n", " else:\n", - " line_color = 'yellow'\n", - " \n", - " ax1.plot([x1, x2], [y1, y2], color=line_color, linestyle=':', linewidth=2, alpha=0.7)\n", - " \n", + " line_color = \"yellow\"\n", + "\n", + " ax1.plot(\n", + " [x1, x2], [y1, y2], color=line_color, linestyle=\":\", linewidth=2, alpha=0.7\n", + " )\n", + "\n", " # Draw apertures on matched stars\n", - " for i, (centroid, flux, mag, local_bg) in enumerate(zip(star_centroids, star_fluxes, star_mags, star_local_bgs)):\n", + " for i, (centroid, flux, mag, local_bg) in enumerate(\n", + " zip(star_centroids, star_fluxes, star_mags, star_local_bgs)\n", + " ):\n", " x, y = centroid\n", - " \n", + "\n", " # Color code by flux status, outlier detection, and overlap\n", " is_outlier = False\n", " if flux > 0 and mzero_mean is not None and len(star_mzeros) > i:\n", " mzero_val = star_mzeros[i]\n", " is_outlier = abs(mzero_val - mzero_mean) > 2.0 * mzero_std\n", - " \n", + "\n", " is_overlapping = i in overlapping_stars\n", - " \n", + "\n", " if flux <= 0:\n", - " color = 'red'\n", + " color = \"red\"\n", " alpha = 0.8\n", " elif is_overlapping:\n", - " color = 'magenta' # Magenta for overlapping stars\n", + " color = \"magenta\" # Magenta for overlapping stars\n", " alpha = 0.8\n", " elif is_outlier:\n", - " color = 'orange'\n", + " color = \"orange\"\n", " alpha = 0.7\n", " else:\n", - " color = 'lime'\n", + " color = \"lime\"\n", " alpha = 0.6\n", - " \n", + "\n", " # Draw aperture circle (solid)\n", - " circle = mpatches.Circle((x, y), APERTURE_RADIUS, \n", - " fill=False, edgecolor=color, linewidth=2, alpha=alpha)\n", + " circle = mpatches.Circle(\n", + " (x, y),\n", + " APERTURE_RADIUS,\n", + " fill=False,\n", + " edgecolor=color,\n", + " linewidth=2,\n", + " alpha=alpha,\n", + " )\n", " ax1.add_patch(circle)\n", - " \n", + "\n", " # Draw annulus inner (dashed)\n", - " annulus_inner = mpatches.Circle((x, y), ANNULUS_INNER,\n", - " fill=False, edgecolor=color, linewidth=1, \n", - " linestyle='--', alpha=0.4)\n", + " annulus_inner = mpatches.Circle(\n", + " (x, y),\n", + " ANNULUS_INNER,\n", + " fill=False,\n", + " edgecolor=color,\n", + " linewidth=1,\n", + " linestyle=\"--\",\n", + " alpha=0.4,\n", + " )\n", " ax1.add_patch(annulus_inner)\n", - " \n", + "\n", " # Draw annulus outer (dashed)\n", - " annulus_outer_circle = mpatches.Circle((x, y), ANNULUS_OUTER,\n", - " fill=False, edgecolor=color, linewidth=1, \n", - " linestyle='--', alpha=0.4)\n", + " annulus_outer_circle = mpatches.Circle(\n", + " (x, y),\n", + " ANNULUS_OUTER,\n", + " fill=False,\n", + " edgecolor=color,\n", + " linewidth=1,\n", + " linestyle=\"--\",\n", + " alpha=0.4,\n", + " )\n", " ax1.add_patch(annulus_outer_circle)\n", - " \n", + "\n", " # Label star\n", - " label_text = f'{i}\\nm={mag:.1f}\\nf={flux:.0f}\\nbg={local_bg:.0f}' if local_bg else f'{i}\\nm={mag:.1f}'\n", - " ax1.text(x + ANNULUS_OUTER + 3, y, label_text,\n", - " color=color, fontsize=7, va='center', weight='bold',\n", - " bbox=dict(boxstyle='round,pad=0.3', facecolor='black', alpha=0.5))\n", - " \n", - " ax1.set_title(f'{filename}\\nSQM: {sqm_val:.2f} (expected: {info[\"realsqm\"]:.2f}, error: {sqm_val - info[\"realsqm\"]:+.2f})',\n", - " fontsize=14, weight='bold')\n", - " ax1.set_xlabel('X (pixels)', fontsize=11)\n", - " ax1.set_ylabel('Y (pixels)', fontsize=11)\n", - " plt.colorbar(im, ax=ax1, label='ADU')\n", - " \n", + " label_text = (\n", + " f\"{i}\\nm={mag:.1f}\\nf={flux:.0f}\\nbg={local_bg:.0f}\"\n", + " if local_bg\n", + " else f\"{i}\\nm={mag:.1f}\"\n", + " )\n", + " ax1.text(\n", + " x + ANNULUS_OUTER + 3,\n", + " y,\n", + " label_text,\n", + " color=color,\n", + " fontsize=7,\n", + " va=\"center\",\n", + " weight=\"bold\",\n", + " bbox=dict(boxstyle=\"round,pad=0.3\", facecolor=\"black\", alpha=0.5),\n", + " )\n", + "\n", + " ax1.set_title(\n", + " f\"{filename}\\nSQM: {sqm_val:.2f} (expected: {info['realsqm']:.2f}, error: {sqm_val - info['realsqm']:+.2f})\",\n", + " fontsize=14,\n", + " weight=\"bold\",\n", + " )\n", + " ax1.set_xlabel(\"X (pixels)\", fontsize=11)\n", + " ax1.set_ylabel(\"Y (pixels)\", fontsize=11)\n", + " plt.colorbar(im, ax=ax1, label=\"ADU\")\n", + "\n", " # Legend\n", " legend_elements = [\n", - " mpatches.Patch(color='lime', label='Valid star'),\n", - " mpatches.Patch(color='magenta', label='Overlapping'),\n", - " mpatches.Patch(color='orange', label='Outlier (|Δmzero| > 2σ)'),\n", - " mpatches.Patch(color='red', label='Bad flux (≤ 0)'),\n", - " mpatches.Circle((0, 0), 1, fill=False, edgecolor='white', linewidth=2, label=f'Aperture (r={APERTURE_RADIUS}px)'),\n", - " mpatches.Circle((0, 0), 1, fill=False, edgecolor='white', linewidth=1, linestyle='--', label=f'Annulus ({ANNULUS_INNER}-{ANNULUS_OUTER}px)')\n", + " mpatches.Patch(color=\"lime\", label=\"Valid star\"),\n", + " mpatches.Patch(color=\"magenta\", label=\"Overlapping\"),\n", + " mpatches.Patch(color=\"orange\", label=\"Outlier (|Δmzero| > 2σ)\"),\n", + " mpatches.Patch(color=\"red\", label=\"Bad flux (≤ 0)\"),\n", + " mpatches.Circle(\n", + " (0, 0),\n", + " 1,\n", + " fill=False,\n", + " edgecolor=\"white\",\n", + " linewidth=2,\n", + " label=f\"Aperture (r={APERTURE_RADIUS}px)\",\n", + " ),\n", + " mpatches.Circle(\n", + " (0, 0),\n", + " 1,\n", + " fill=False,\n", + " edgecolor=\"white\",\n", + " linewidth=1,\n", + " linestyle=\"--\",\n", + " label=f\"Annulus ({ANNULUS_INNER}-{ANNULUS_OUTER}px)\",\n", + " ),\n", " ]\n", - " ax1.legend(handles=legend_elements, loc='upper right', fontsize=9)\n", - " \n", + " ax1.legend(handles=legend_elements, loc=\"upper right\", fontsize=9)\n", + "\n", " # ========== Panel 2: Per-Star Statistics Table ==========\n", " ax2 = fig.add_subplot(gs[0:2, 2])\n", - " ax2.axis('off')\n", - " \n", + " ax2.axis(\"off\")\n", + "\n", " # Build table data\n", - " table_data = [['#', 'Mag', 'Flux\\n(ADU)', 'Bg\\n(ADU)', 'mzero', 'Δmz', 'OK']]\n", - " for i, (mag, flux, local_bg, mzero) in enumerate(zip(star_mags, star_fluxes, star_local_bgs, star_mzeros)):\n", - " status = '✓' if flux > 0 else '✗'\n", + " table_data = [[\"#\", \"Mag\", \"Flux\\n(ADU)\", \"Bg\\n(ADU)\", \"mzero\", \"Δmz\", \"OK\"]]\n", + " for i, (mag, flux, local_bg, mzero) in enumerate(\n", + " zip(star_mags, star_fluxes, star_local_bgs, star_mzeros)\n", + " ):\n", + " status = \"✓\" if flux > 0 else \"✗\"\n", " delta_mzero = (mzero - mzero_mean) if (flux > 0 and mzero_mean) else None\n", - " \n", - " table_data.append([\n", - " f'{i}',\n", - " f'{mag:.2f}',\n", - " f'{flux:.0f}',\n", - " f'{local_bg:.0f}' if local_bg is not None else 'N/A',\n", - " f'{mzero:.2f}' if flux > 0 else 'N/A',\n", - " f'{delta_mzero:+.2f}' if delta_mzero is not None else 'N/A',\n", - " status\n", - " ])\n", - " \n", + "\n", + " table_data.append(\n", + " [\n", + " f\"{i}\",\n", + " f\"{mag:.2f}\",\n", + " f\"{flux:.0f}\",\n", + " f\"{local_bg:.0f}\" if local_bg is not None else \"N/A\",\n", + " f\"{mzero:.2f}\" if flux > 0 else \"N/A\",\n", + " f\"{delta_mzero:+.2f}\" if delta_mzero is not None else \"N/A\",\n", + " status,\n", + " ]\n", + " )\n", + "\n", " # Create table\n", - " table = ax2.table(cellText=table_data, cellLoc='center', loc='center',\n", - " colWidths=[0.08, 0.12, 0.15, 0.12, 0.12, 0.10, 0.08])\n", + " table = ax2.table(\n", + " cellText=table_data,\n", + " cellLoc=\"center\",\n", + " loc=\"center\",\n", + " colWidths=[0.08, 0.12, 0.15, 0.12, 0.12, 0.10, 0.08],\n", + " )\n", " table.auto_set_font_size(False)\n", " table.set_fontsize(7)\n", " table.scale(1, 1.8)\n", - " \n", + "\n", " # Style header row\n", " for i in range(7):\n", - " table[(0, i)].set_facecolor('#4CAF50')\n", - " table[(0, i)].set_text_props(weight='bold', color='white')\n", - " \n", + " table[(0, i)].set_facecolor(\"#4CAF50\")\n", + " table[(0, i)].set_text_props(weight=\"bold\", color=\"white\")\n", + "\n", " # Color code rows\n", " for i in range(1, len(table_data)):\n", - " flux = star_fluxes[i-1]\n", - " is_overlapping = (i-1) in overlapping_stars\n", - " \n", + " flux = star_fluxes[i - 1]\n", + " is_overlapping = (i - 1) in overlapping_stars\n", + "\n", " if flux <= 0:\n", - " color = '#FFCDD2' # Red\n", + " color = \"#FFCDD2\" # Red\n", " elif is_overlapping:\n", - " color = '#F8BBD0' # Magenta/pink\n", - " elif i-1 < len(star_mzeros) and mzero_mean is not None:\n", - " delta = abs(star_mzeros[i-1] - mzero_mean)\n", + " color = \"#F8BBD0\" # Magenta/pink\n", + " elif i - 1 < len(star_mzeros) and mzero_mean is not None:\n", + " delta = abs(star_mzeros[i - 1] - mzero_mean)\n", " if delta > 2.0 * mzero_std:\n", - " color = '#FFE0B2' # Orange\n", + " color = \"#FFE0B2\" # Orange\n", " elif delta > 1.0 * mzero_std:\n", - " color = '#FFF9C4' # Yellow\n", + " color = \"#FFF9C4\" # Yellow\n", " else:\n", - " color = '#E8F5E9' # Green\n", + " color = \"#E8F5E9\" # Green\n", " else:\n", - " color = 'white'\n", - " \n", + " color = \"white\"\n", + "\n", " for j in range(7):\n", " table[(i, j)].set_facecolor(color)\n", - " \n", - " ax2.set_title('Per-Star Breakdown\\n(Δmz = deviation from mean)', fontsize=11, weight='bold', pad=20)\n", - " \n", + "\n", + " ax2.set_title(\n", + " \"Per-Star Breakdown\\n(Δmz = deviation from mean)\",\n", + " fontsize=11,\n", + " weight=\"bold\",\n", + " pad=20,\n", + " )\n", + "\n", " # ========== Panel 3: mzero Values vs Magnitude with Trendlines ==========\n", " ax3 = fig.add_subplot(gs[2, 0])\n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " # Scatter plot of individual mzero values\n", - " colors = ['red' if not sigclip_mask[i] else 'blue' for i in range(len(valid_mzeros))]\n", - " ax3.scatter(valid_mags, valid_mzeros, s=80, alpha=0.7, c=colors, edgecolors='black', linewidths=1, label='Stars', zorder=3)\n", - " \n", + " colors = [\n", + " \"red\" if not sigclip_mask[i] else \"blue\" for i in range(len(valid_mzeros))\n", + " ]\n", + " ax3.scatter(\n", + " valid_mags,\n", + " valid_mzeros,\n", + " s=80,\n", + " alpha=0.7,\n", + " c=colors,\n", + " edgecolors=\"black\",\n", + " linewidths=1,\n", + " label=\"Stars\",\n", + " zorder=3,\n", + " )\n", + "\n", " # Horizontal lines for different methods\n", - " ax3.axhline(mzero_mean, color='blue', linestyle='-', linewidth=2, label=f'Mean: {mzero_mean:.3f}', alpha=0.6)\n", - " ax3.axhline(mzero_median, color='green', linestyle='--', linewidth=2, label=f'Median: {mzero_median:.3f}', alpha=0.6)\n", - " \n", + " ax3.axhline(\n", + " mzero_mean,\n", + " color=\"blue\",\n", + " linestyle=\"-\",\n", + " linewidth=2,\n", + " label=f\"Mean: {mzero_mean:.3f}\",\n", + " alpha=0.6,\n", + " )\n", + " ax3.axhline(\n", + " mzero_median,\n", + " color=\"green\",\n", + " linestyle=\"--\",\n", + " linewidth=2,\n", + " label=f\"Median: {mzero_median:.3f}\",\n", + " alpha=0.6,\n", + " )\n", + "\n", " # Trendlines\n", " if len(valid_mzeros) >= 3:\n", " mag_range = np.array([valid_mags.min(), valid_mags.max()])\n", - " \n", + "\n", " # All stars trend\n", " trend_line_all = slope_all * mag_range + intercept_all\n", - " ax3.plot(mag_range, trend_line_all, 'purple', linestyle='-.', linewidth=2.5, \n", - " label=f'Trend (all): R²={r_value_all**2:.3f}', zorder=2)\n", - " \n", + " ax3.plot(\n", + " mag_range,\n", + " trend_line_all,\n", + " \"purple\",\n", + " linestyle=\"-.\",\n", + " linewidth=2.5,\n", + " label=f\"Trend (all): R²={r_value_all**2:.3f}\",\n", + " zorder=2,\n", + " )\n", + "\n", " # Sigma-clipped trend\n", " if n_clipped > 0:\n", " trend_line_clip = slope_clip * mag_range + intercept_clip\n", - " ax3.plot(mag_range, trend_line_clip, 'red', linestyle=':', linewidth=2.5, \n", - " label=f'Trend (σ-clip): R²={r_value_clip**2:.3f}', zorder=2)\n", - " \n", + " ax3.plot(\n", + " mag_range,\n", + " trend_line_clip,\n", + " \"red\",\n", + " linestyle=\":\",\n", + " linewidth=2.5,\n", + " label=f\"Trend (σ-clip): R²={r_value_clip**2:.3f}\",\n", + " zorder=2,\n", + " )\n", + "\n", " # Mark median magnitude\n", - " ax3.axvline(np.median(valid_mags), color='gray', linestyle='--', linewidth=1, alpha=0.5, zorder=1)\n", - " \n", + " ax3.axvline(\n", + " np.median(valid_mags),\n", + " color=\"gray\",\n", + " linestyle=\"--\",\n", + " linewidth=1,\n", + " alpha=0.5,\n", + " zorder=1,\n", + " )\n", + "\n", " # Std deviation bands\n", - " ax3.axhspan(mzero_mean - mzero_std, mzero_mean + mzero_std, alpha=0.15, color='blue', zorder=0)\n", - " \n", - " ax3.set_xlabel('Catalog Magnitude', fontsize=10)\n", - " ax3.set_ylabel('mzero', fontsize=10)\n", - " ax3.set_title(f'mzero vs Magnitude\\nσ = {mzero_std:.3f}, Trend slope = {slope_all:.4f}', fontsize=10, weight='bold')\n", - " ax3.legend(fontsize=7, loc='best')\n", + " ax3.axhspan(\n", + " mzero_mean - mzero_std,\n", + " mzero_mean + mzero_std,\n", + " alpha=0.15,\n", + " color=\"blue\",\n", + " zorder=0,\n", + " )\n", + "\n", + " ax3.set_xlabel(\"Catalog Magnitude\", fontsize=10)\n", + " ax3.set_ylabel(\"mzero\", fontsize=10)\n", + " ax3.set_title(\n", + " f\"mzero vs Magnitude\\nσ = {mzero_std:.3f}, Trend slope = {slope_all:.4f}\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", + " ax3.legend(fontsize=7, loc=\"best\")\n", " ax3.grid(True, alpha=0.3)\n", " ax3.invert_xaxis() # Brighter stars on right\n", " else:\n", - " ax3.text(0.5, 0.5, 'No valid stars', transform=ax3.transAxes, ha='center', va='center')\n", - " \n", + " ax3.text(\n", + " 0.5,\n", + " 0.5,\n", + " \"No valid stars\",\n", + " transform=ax3.transAxes,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " )\n", + "\n", " # ========== Panel 4: mzero Values vs Flux ==========\n", " ax4 = fig.add_subplot(gs[2, 1])\n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " # Scatter plot of mzero vs log(flux)\n", " log_fluxes = np.log10(valid_fluxes)\n", - " colors = ['red' if not sigclip_mask[i] else 'blue' for i in range(len(valid_mzeros))]\n", - " ax4.scatter(log_fluxes, valid_mzeros, s=80, alpha=0.7, c=colors, edgecolors='black', linewidths=1)\n", - " \n", + " colors = [\n", + " \"red\" if not sigclip_mask[i] else \"blue\" for i in range(len(valid_mzeros))\n", + " ]\n", + " ax4.scatter(\n", + " log_fluxes,\n", + " valid_mzeros,\n", + " s=80,\n", + " alpha=0.7,\n", + " c=colors,\n", + " edgecolors=\"black\",\n", + " linewidths=1,\n", + " )\n", + "\n", " # Horizontal lines\n", - " ax4.axhline(mzero_mean, color='blue', linestyle='-', linewidth=2, label=f'Mean', alpha=0.6)\n", - " ax4.axhline(mzero_median, color='green', linestyle='--', linewidth=2, label=f'Median', alpha=0.6)\n", - " \n", + " ax4.axhline(\n", + " mzero_mean,\n", + " color=\"blue\",\n", + " linestyle=\"-\",\n", + " linewidth=2,\n", + " label=\"Mean\",\n", + " alpha=0.6,\n", + " )\n", + " ax4.axhline(\n", + " mzero_median,\n", + " color=\"green\",\n", + " linestyle=\"--\",\n", + " linewidth=2,\n", + " label=\"Median\",\n", + " alpha=0.6,\n", + " )\n", + "\n", " # Check for trend with flux\n", " if len(valid_mzeros) >= 3:\n", - " slope_flux, intercept_flux, r_value_flux, _, _ = stats.linregress(log_fluxes, valid_mzeros)\n", + " slope_flux, intercept_flux, r_value_flux, _, _ = stats.linregress(\n", + " log_fluxes, valid_mzeros\n", + " )\n", " if abs(r_value_flux) > 0.3: # Significant correlation\n", " x_fit = np.array([log_fluxes.min(), log_fluxes.max()])\n", " y_fit = slope_flux * x_fit + intercept_flux\n", - " ax4.plot(x_fit, y_fit, 'orange', linestyle=':', linewidth=2, \n", - " label=f'Flux trend: R²={r_value_flux**2:.3f}')\n", - " \n", - " ax4.set_xlabel('log₁₀(Flux [ADU])', fontsize=10)\n", - " ax4.set_ylabel('mzero', fontsize=10)\n", - " ax4.set_title(f'mzero vs Flux\\n(Should be flat if aperture correct)', fontsize=10, weight='bold')\n", - " ax4.legend(fontsize=8, loc='best')\n", + " ax4.plot(\n", + " x_fit,\n", + " y_fit,\n", + " \"orange\",\n", + " linestyle=\":\",\n", + " linewidth=2,\n", + " label=f\"Flux trend: R²={r_value_flux**2:.3f}\",\n", + " )\n", + "\n", + " ax4.set_xlabel(\"log₁₀(Flux [ADU])\", fontsize=10)\n", + " ax4.set_ylabel(\"mzero\", fontsize=10)\n", + " ax4.set_title(\n", + " \"mzero vs Flux\\n(Should be flat if aperture correct)\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", + " ax4.legend(fontsize=8, loc=\"best\")\n", " ax4.grid(True, alpha=0.3)\n", " else:\n", - " ax4.text(0.5, 0.5, 'No valid stars', transform=ax4.transAxes, ha='center', va='center')\n", - " \n", + " ax4.text(\n", + " 0.5,\n", + " 0.5,\n", + " \"No valid stars\",\n", + " transform=ax4.transAxes,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " )\n", + "\n", " # ========== Panel 5: mzero Distribution Histogram ==========\n", " ax5 = fig.add_subplot(gs[2, 2])\n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " # Histogram\n", - " ax5.hist(valid_mzeros, bins=min(15, len(valid_mzeros)), \n", - " color='steelblue', alpha=0.7, edgecolor='black')\n", - " \n", + " ax5.hist(\n", + " valid_mzeros,\n", + " bins=min(15, len(valid_mzeros)),\n", + " color=\"steelblue\",\n", + " alpha=0.7,\n", + " edgecolor=\"black\",\n", + " )\n", + "\n", " # Mark different estimators\n", - " ax5.axvline(mzero_mean, color='blue', linestyle='-', linewidth=2, label=f'Mean')\n", - " ax5.axvline(mzero_median, color='green', linestyle='--', linewidth=2, label=f'Median')\n", + " ax5.axvline(mzero_mean, color=\"blue\", linestyle=\"-\", linewidth=2, label=\"Mean\")\n", + " ax5.axvline(\n", + " mzero_median, color=\"green\", linestyle=\"--\", linewidth=2, label=\"Median\"\n", + " )\n", " if n_clipped > 0:\n", - " ax5.axvline(mzero_sigclip, color='red', linestyle='-.', linewidth=2, label=f'σ-clip')\n", + " ax5.axvline(\n", + " mzero_sigclip, color=\"red\", linestyle=\"-.\", linewidth=2, label=\"σ-clip\"\n", + " )\n", " if len(valid_mzeros) >= 3:\n", - " ax5.axvline(mzero_trend, color='purple', linestyle=':', linewidth=2, label=f'Trend')\n", - " \n", - " ax5.set_xlabel('mzero', fontsize=10)\n", - " ax5.set_ylabel('Count', fontsize=10)\n", - " ax5.set_title(f'mzero Distribution\\nRange: [{np.min(valid_mzeros):.2f}, {np.max(valid_mzeros):.2f}]', \n", - " fontsize=10, weight='bold')\n", + " ax5.axvline(\n", + " mzero_trend, color=\"purple\", linestyle=\":\", linewidth=2, label=\"Trend\"\n", + " )\n", + "\n", + " ax5.set_xlabel(\"mzero\", fontsize=10)\n", + " ax5.set_ylabel(\"Count\", fontsize=10)\n", + " ax5.set_title(\n", + " f\"mzero Distribution\\nRange: [{np.min(valid_mzeros):.2f}, {np.max(valid_mzeros):.2f}]\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", " ax5.legend(fontsize=8)\n", - " ax5.grid(True, alpha=0.3, axis='y')\n", + " ax5.grid(True, alpha=0.3, axis=\"y\")\n", " else:\n", - " ax5.text(0.5, 0.5, 'No valid stars', transform=ax5.transAxes, ha='center', va='center')\n", - " \n", + " ax5.text(\n", + " 0.5,\n", + " 0.5,\n", + " \"No valid stars\",\n", + " transform=ax5.transAxes,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " )\n", + "\n", " # ========== Panel 6: SQM Comparison Table with Overlap Correction ==========\n", " ax6 = fig.add_subplot(gs[3, 0])\n", - " ax6.axis('off')\n", - " \n", + " ax6.axis(\"off\")\n", + "\n", " # Compare different methods INCLUDING overlap-corrected\n", - " comparison_data = [['Method', 'mzero', 'SQM', 'Error', 'Note']]\n", - " \n", + " comparison_data = [[\"Method\", \"mzero\", \"SQM\", \"Error\", \"Note\"]]\n", + "\n", " if mzero_mean is not None:\n", - " comparison_data.append([\n", - " 'Mean',\n", - " f'{mzero_mean:.3f}',\n", - " f'{sqm_val:.2f}',\n", - " f'{sqm_val - info[\"realsqm\"]:+.2f}',\n", - " '← Current'\n", - " ])\n", - " comparison_data.append([\n", - " 'Median',\n", - " f'{mzero_median:.3f}',\n", - " f'{sqm_median:.2f}',\n", - " f'{sqm_median - info[\"realsqm\"]:+.2f}',\n", - " ''\n", - " ])\n", + " comparison_data.append(\n", + " [\n", + " \"Mean\",\n", + " f\"{mzero_mean:.3f}\",\n", + " f\"{sqm_val:.2f}\",\n", + " f\"{sqm_val - info['realsqm']:+.2f}\",\n", + " \"← Current\",\n", + " ]\n", + " )\n", + " comparison_data.append(\n", + " [\n", + " \"Median\",\n", + " f\"{mzero_median:.3f}\",\n", + " f\"{sqm_median:.2f}\",\n", + " f\"{sqm_median - info['realsqm']:+.2f}\",\n", + " \"\",\n", + " ]\n", + " )\n", " if n_clipped > 0:\n", - " comparison_data.append([\n", - " 'σ-clipped',\n", - " f'{mzero_sigclip:.3f}',\n", - " f'{sqm_sigclip:.2f}',\n", - " f'{sqm_sigclip - info[\"realsqm\"]:+.2f}',\n", - " f'-{n_clipped} star'\n", - " ])\n", + " comparison_data.append(\n", + " [\n", + " \"σ-clipped\",\n", + " f\"{mzero_sigclip:.3f}\",\n", + " f\"{sqm_sigclip:.2f}\",\n", + " f\"{sqm_sigclip - info['realsqm']:+.2f}\",\n", + " f\"-{n_clipped} star\",\n", + " ]\n", + " )\n", " if len(valid_mzeros) >= 3:\n", - " comparison_data.append([\n", - " 'Trend (all)',\n", - " f'{mzero_trend:.3f}',\n", - " f'{sqm_trend:.2f}',\n", - " f'{sqm_trend - info[\"realsqm\"]:+.2f}',\n", - " f'R²={r_value_all**2:.2f}'\n", - " ])\n", + " comparison_data.append(\n", + " [\n", + " \"Trend (all)\",\n", + " f\"{mzero_trend:.3f}\",\n", + " f\"{sqm_trend:.2f}\",\n", + " f\"{sqm_trend - info['realsqm']:+.2f}\",\n", + " f\"R²={r_value_all**2:.2f}\",\n", + " ]\n", + " )\n", " if n_clipped > 0:\n", - " comparison_data.append([\n", - " 'Trend+σ-clip',\n", - " f'{mzero_trend_sigclip:.3f}',\n", - " f'{sqm_trend_sigclip:.2f}',\n", - " f'{sqm_trend_sigclip - info[\"realsqm\"]:+.2f}',\n", - " f'R²={r_value_clip**2:.2f}'\n", - " ])\n", - " \n", + " comparison_data.append(\n", + " [\n", + " \"Trend+σ-clip\",\n", + " f\"{mzero_trend_sigclip:.3f}\",\n", + " f\"{sqm_trend_sigclip:.2f}\",\n", + " f\"{sqm_trend_sigclip - info['realsqm']:+.2f}\",\n", + " f\"R²={r_value_clip**2:.2f}\",\n", + " ]\n", + " )\n", + "\n", " # Add overlap-corrected result\n", - " if sqm_val_corrected is not None and details_corrected.get('n_stars_excluded_overlaps', 0) > 0:\n", - " n_excl = details_corrected['n_stars_excluded_overlaps']\n", - " comparison_data.append([\n", - " 'Overlap-corrected',\n", - " f'{details_corrected[\"mzero\"]:.3f}',\n", - " f'{sqm_val_corrected:.2f}',\n", - " f'{sqm_val_corrected - info[\"realsqm\"]:+.2f}',\n", - " f'-{n_excl} overlap'\n", - " ])\n", - " \n", - " comparison_data.append([\n", - " 'Expected',\n", - " '—',\n", - " f'{info[\"realsqm\"]:.2f}',\n", - " '0.00',\n", - " 'Target'\n", - " ])\n", - " \n", - " comp_table = ax6.table(cellText=comparison_data, cellLoc='center', loc='center',\n", - " colWidths=[0.23, 0.18, 0.15, 0.15, 0.29])\n", + " if (\n", + " sqm_val_corrected is not None\n", + " and details_corrected.get(\"n_stars_excluded_overlaps\", 0) > 0\n", + " ):\n", + " n_excl = details_corrected[\"n_stars_excluded_overlaps\"]\n", + " comparison_data.append(\n", + " [\n", + " \"Overlap-corrected\",\n", + " f\"{details_corrected['mzero']:.3f}\",\n", + " f\"{sqm_val_corrected:.2f}\",\n", + " f\"{sqm_val_corrected - info['realsqm']:+.2f}\",\n", + " f\"-{n_excl} overlap\",\n", + " ]\n", + " )\n", + "\n", + " comparison_data.append(\n", + " [\"Expected\", \"—\", f\"{info['realsqm']:.2f}\", \"0.00\", \"Target\"]\n", + " )\n", + "\n", + " comp_table = ax6.table(\n", + " cellText=comparison_data,\n", + " cellLoc=\"center\",\n", + " loc=\"center\",\n", + " colWidths=[0.23, 0.18, 0.15, 0.15, 0.29],\n", + " )\n", " comp_table.auto_set_font_size(False)\n", " comp_table.set_fontsize(8)\n", " comp_table.scale(1, 2.2)\n", - " \n", + "\n", " # Style header\n", " for i in range(5):\n", - " comp_table[(0, i)].set_facecolor('#2196F3')\n", - " comp_table[(0, i)].set_text_props(weight='bold', color='white')\n", - " \n", + " comp_table[(0, i)].set_facecolor(\"#2196F3\")\n", + " comp_table[(0, i)].set_text_props(weight=\"bold\", color=\"white\")\n", + "\n", " # Highlight best method\n", " if len(comparison_data) > 2:\n", " errors = [abs(float(row[3])) for row in comparison_data[1:-1]]\n", " best_idx = np.argmin(errors) + 1\n", " for j in range(5):\n", - " comp_table[(best_idx, j)].set_facecolor('#C8E6C9')\n", - " \n", - " ax6.set_title('mzero Method Comparison', fontsize=11, weight='bold', pad=20)\n", - " \n", + " comp_table[(best_idx, j)].set_facecolor(\"#C8E6C9\")\n", + "\n", + " ax6.set_title(\"mzero Method Comparison\", fontsize=11, weight=\"bold\", pad=20)\n", + "\n", " # ========== Panel 7: Background Annuli ==========\n", " ax7 = fig.add_subplot(gs[3, 1])\n", - " \n", + "\n", " # Create visualization showing annulus regions\n", " height, width = np_image.shape\n", " y, x = np.ogrid[:height, :width]\n", " annulus_mask_img = np.zeros((height, width), dtype=bool)\n", " for centroid in star_centroids:\n", " cx, cy = centroid\n", - " dist_sq = (x - cx)**2 + (y - cy)**2\n", + " dist_sq = (x - cx) ** 2 + (y - cy) ** 2\n", " star_annulus = (dist_sq > ANNULUS_INNER**2) & (dist_sq <= ANNULUS_OUTER**2)\n", " annulus_mask_img |= star_annulus\n", - " \n", + "\n", " annulus_display = np.where(annulus_mask_img, np_image, np.nan)\n", - " ax7.imshow(annulus_display, cmap='viridis', vmin=vmin, vmax=vmax, origin='lower')\n", - " ax7.set_title(f'Background Annuli\\n(median={details[\"background_per_pixel\"]:.1f} ADU)', \n", - " fontsize=10, weight='bold')\n", - " ax7.set_xlabel('X (pixels)', fontsize=9)\n", - " ax7.set_ylabel('Y (pixels)', fontsize=9)\n", - " \n", + " ax7.imshow(annulus_display, cmap=\"viridis\", vmin=vmin, vmax=vmax, origin=\"lower\")\n", + " ax7.set_title(\n", + " f\"Background Annuli\\n(median={details['background_per_pixel']:.1f} ADU)\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", + " ax7.set_xlabel(\"X (pixels)\", fontsize=9)\n", + " ax7.set_ylabel(\"Y (pixels)\", fontsize=9)\n", + "\n", " # ========== Panel 8: Calculation Summary with Overlap Info ==========\n", " ax8 = fig.add_subplot(gs[3, 2])\n", - " ax8.axis('off')\n", - " \n", + " ax8.axis(\"off\")\n", + "\n", " # Find best method\n", " if mzero_mean is not None:\n", - " methods = ['Mean', 'Median', 'σ-clip', 'Trend', 'Trend+σ-clip', 'Overlap-corrected']\n", - " sqm_values = [sqm_val, sqm_median, sqm_sigclip, sqm_trend, sqm_trend_sigclip, sqm_val_corrected]\n", - " errors = [abs(sqm - info['realsqm']) for sqm in sqm_values if sqm is not None]\n", + " methods = [\n", + " \"Mean\",\n", + " \"Median\",\n", + " \"σ-clip\",\n", + " \"Trend\",\n", + " \"Trend+σ-clip\",\n", + " \"Overlap-corrected\",\n", + " ]\n", + " sqm_values = [\n", + " sqm_val,\n", + " sqm_median,\n", + " sqm_sigclip,\n", + " sqm_trend,\n", + " sqm_trend_sigclip,\n", + " sqm_val_corrected,\n", + " ]\n", + " errors = [abs(sqm - info[\"realsqm\"]) for sqm in sqm_values if sqm is not None]\n", " valid_methods = [m for m, sqm in zip(methods, sqm_values) if sqm is not None]\n", " if errors:\n", " best_method = valid_methods[np.argmin(errors)]\n", " else:\n", - " best_method = 'Mean'\n", + " best_method = \"Mean\"\n", " else:\n", - " best_method = 'N/A'\n", - " \n", - " corrected_str = f\"{sqm_val_corrected:.2f}\" if sqm_val_corrected is not None else \"N/A\"\n", - " error_str = f\"{sqm_val_corrected - info['realsqm']:+.2f}\" if sqm_val_corrected is not None else \"N/A\"\n", + " best_method = \"N/A\"\n", + "\n", + " corrected_str = (\n", + " f\"{sqm_val_corrected:.2f}\" if sqm_val_corrected is not None else \"N/A\"\n", + " )\n", + " error_str = (\n", + " f\"{sqm_val_corrected - info['realsqm']:+.2f}\"\n", + " if sqm_val_corrected is not None\n", + " else \"N/A\"\n", + " )\n", "\n", " summary_text = f\"\"\"CALCULATION SUMMARY\n", - "{'='*35}\n", + "{\"=\" * 35}\n", "\n", - "Stars: {details['n_matched_stars']} matched\n", - " {details['n_centroids']} total centroids\n", + "Stars: {details[\"n_matched_stars\"]} matched\n", + " {details[\"n_centroids\"]} total centroids\n", "\n", "OVERLAPS: {len(overlaps)} total\n", " CRITICAL: {len(critical_overlaps)}\n", @@ -1955,7 +2250,7 @@ "Background: Local annuli\n", " Aperture: {APERTURE_RADIUS} px\n", " Annulus: {ANNULUS_INNER}-{ANNULUS_OUTER} px\n", - " Sky: {details['background_per_pixel']:.2f} ADU/px\n", + " Sky: {details[\"background_per_pixel\"]:.2f} ADU/px\n", "\n", "mzero Statistics:\n", " Mean: {mzero_mean:.3f} ± {mzero_std:.3f}\n", @@ -1968,60 +2263,86 @@ "Trend Analysis:\n", " Slope: {slope_all:.4f} mag/mag\n", " R²: {r_value_all**2:.4f}\n", - " Sig? {'YES' if abs(r_value_all) > 0.5 else 'NO'}\n", + " Sig? {\"YES\" if abs(r_value_all) > 0.5 else \"NO\"}\n", "\n", "SQM Results:\n", " Without overlap correction:\n", " Current: {sqm_val:.2f} mag/arcsec²\n", - " Error: {sqm_val - info['realsqm']:+.2f}\n", + " Error: {sqm_val - info[\"realsqm\"]:+.2f}\n", " \n", " With overlap correction:\n", " Corrected: {corrected_str} mag/arcsec²\n", " Error: {error_str}\n", - " Excluded: {details_corrected.get('n_stars_excluded_overlaps', 0)} stars\n", + " Excluded: {details_corrected.get(\"n_stars_excluded_overlaps\", 0)} stars\n", "\n", - "Expected: {info['realsqm']:.2f}\n", + "Expected: {info[\"realsqm\"]:.2f}\n", "\n", "Best Method: {best_method}\n", "\"\"\"\n", - " \n", - " ax8.text(0.05, 0.95, summary_text, \n", - " transform=ax8.transAxes, fontsize=7.5, \n", - " verticalalignment='top', fontfamily='monospace',\n", - " bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))\n", - " \n", + "\n", + " ax8.text(\n", + " 0.05,\n", + " 0.95,\n", + " summary_text,\n", + " transform=ax8.transAxes,\n", + " fontsize=7.5,\n", + " verticalalignment=\"top\",\n", + " fontfamily=\"monospace\",\n", + " bbox=dict(boxstyle=\"round\", facecolor=\"lightyellow\", alpha=0.8),\n", + " )\n", + "\n", " plt.tight_layout()\n", " plt.show()\n", - " \n", + "\n", " # Print detailed overlap information\n", " if overlaps:\n", - " print(f\"\\nDETAILED OVERLAP INFORMATION:\")\n", - " print(f\"{'='*100}\")\n", + " print(\"\\nDETAILED OVERLAP INFORMATION:\")\n", + " print(f\"{'=' * 100}\")\n", " for overlap in overlaps:\n", - " i, j = overlap['star1_idx'], overlap['star2_idx']\n", + " i, j = overlap[\"star1_idx\"], overlap[\"star2_idx\"]\n", " print(f\" [{overlap['type']:8}] Stars {i} ↔ {j}: {overlap['description']}\")\n", - " print(f\" Star {i}: mag={star_mags[i]:.2f}, flux={star_fluxes[i]:.0f} ADU\")\n", - " print(f\" Star {j}: mag={star_mags[j]:.2f}, flux={star_fluxes[j]:.0f} ADU\")\n", - " print(f\"{'='*100}\\n\")\n", - " \n", + " print(\n", + " f\" Star {i}: mag={star_mags[i]:.2f}, flux={star_fluxes[i]:.0f} ADU\"\n", + " )\n", + " print(\n", + " f\" Star {j}: mag={star_mags[j]:.2f}, flux={star_fluxes[j]:.0f} ADU\"\n", + " )\n", + " print(f\"{'=' * 100}\\n\")\n", + "\n", " # Print summary\n", " print(f\"\\n✓ Processed {filename}\")\n", - " print(f\"\\n WITHOUT overlap correction:\")\n", - " print(f\" SQM (mean): {sqm_val:.2f} (error: {sqm_val - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (median): {sqm_median:.2f} (error: {sqm_median - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (σ-clipped): {sqm_sigclip:.2f} (error: {sqm_sigclip - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (trend): {sqm_trend:.2f} (error: {sqm_trend - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (trend+σ-clip): {sqm_trend_sigclip:.2f} (error: {sqm_trend_sigclip - info['realsqm']:+.2f})\")\n", - " \n", + " print(\"\\n WITHOUT overlap correction:\")\n", + " print(\n", + " f\" SQM (mean): {sqm_val:.2f} (error: {sqm_val - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (median): {sqm_median:.2f} (error: {sqm_median - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (σ-clipped): {sqm_sigclip:.2f} (error: {sqm_sigclip - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (trend): {sqm_trend:.2f} (error: {sqm_trend - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (trend+σ-clip): {sqm_trend_sigclip:.2f} (error: {sqm_trend_sigclip - info['realsqm']:+.2f})\"\n", + " )\n", + "\n", " if sqm_val_corrected is not None:\n", - " print(f\"\\n WITH overlap correction:\")\n", - " print(f\" SQM (overlap-corr): {sqm_val_corrected:.2f} (error: {sqm_val_corrected - info['realsqm']:+.2f})\")\n", - " print(f\" Stars excluded: {details_corrected.get('n_stars_excluded_overlaps', 0)}/{details_corrected.get('n_matched_stars_original', 0)}\")\n", - " print(f\" Improvement: {(sqm_val_corrected - sqm_val):+.2f} mag/arcsec²\")\n", - " \n", + " print(\"\\n WITH overlap correction:\")\n", + " print(\n", + " f\" SQM (overlap-corr): {sqm_val_corrected:.2f} (error: {sqm_val_corrected - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" Stars excluded: {details_corrected.get('n_stars_excluded_overlaps', 0)}/{details_corrected.get('n_matched_stars_original', 0)}\"\n", + " )\n", + " print(\n", + " f\" Improvement: {(sqm_val_corrected - sqm_val):+.2f} mag/arcsec²\"\n", + " )\n", + "\n", " print(f\"\\n Trend: slope={slope_all:.4f}, R²={r_value_all**2:.4f}\")\n", " print(f\" Best method: {best_method}\")\n", - " \n", + "\n", " # Flag issues\n", " issues = []\n", " if any(f <= 0 for f in star_fluxes):\n", @@ -2031,19 +2352,23 @@ " issues.append(f\"⚠️ High mzero scatter: {mzero_std:.3f}\")\n", " if abs(r_value_all) > 0.5:\n", " issues.append(f\"⚠️ Significant magnitude trend: R²={r_value_all**2:.3f}\")\n", - " if abs(sqm_val - info['realsqm']) > 0.5:\n", - " issues.append(f\"⚠️ Large error (no corr): {sqm_val - info['realsqm']:+.2f} mag/arcsec²\")\n", + " if abs(sqm_val - info[\"realsqm\"]) > 0.5:\n", + " issues.append(\n", + " f\"⚠️ Large error (no corr): {sqm_val - info['realsqm']:+.2f} mag/arcsec²\"\n", + " )\n", " if n_clipped > 0:\n", " issues.append(f\"ℹ️ {n_clipped} outliers removed by σ-clipping\")\n", " if overlaps:\n", - " issues.append(f\"⚠️ {len(overlaps)} aperture overlaps detected ({len(overlapping_stars)} stars affected)\")\n", - " \n", + " issues.append(\n", + " f\"⚠️ {len(overlaps)} aperture overlaps detected ({len(overlapping_stars)} stars affected)\"\n", + " )\n", + "\n", " if issues:\n", - " print(f\"\\n Notes:\")\n", + " print(\"\\n Notes:\")\n", " for issue in issues:\n", " print(f\" {issue}\")\n", - " \n", - " print(\"\")\n" + "\n", + " print(\"\")" ] }, { diff --git a/python/PiFinder/sqm/sqm.py b/python/PiFinder/sqm/sqm.py index a1250ccbf..599d1d312 100644 --- a/python/PiFinder/sqm/sqm.py +++ b/python/PiFinder/sqm/sqm.py @@ -1,9 +1,6 @@ import numpy as np import logging -from typing import Tuple, Dict, Optional, Any -from datetime import datetime -import time -from PiFinder.state import SQM as SQMState +from typing import Tuple, Dict, Optional from .noise_floor import NoiseFloorEstimator logger = logging.getLogger("Solver") @@ -34,36 +31,22 @@ class SQM: def __init__( self, camera_type: str = "imx296", - pedestal_from_background: bool = False, - use_adaptive_noise_floor: bool = True, ): """ Initialize SQM calculator. Args: - camera_type: Camera model (imx296, imx462, imx290, hq) for noise estimation - pedestal_from_background: If True, automatically estimate pedestal from - median of local backgrounds. Default False (manual pedestal only). - use_adaptive_noise_floor: If True, use adaptive noise floor estimation. - If False, fall back to manual pedestal parameter. Default True. + camera_type: Camera model (imx296, imx462, imx290, hq) for noise estimation. + Use "_processed" suffix for 8-bit ISP-processed images. """ - super() - self.pedestal_from_background = pedestal_from_background - self.use_adaptive_noise_floor = use_adaptive_noise_floor - - # Initialize noise floor estimator if enabled - self.noise_estimator: Optional[NoiseFloorEstimator] = None - if use_adaptive_noise_floor: - self.noise_estimator = NoiseFloorEstimator( - camera_type=camera_type, - enable_zero_sec_sampling=True, - zero_sec_interval=300, # Every 5 minutes - ) - logger.info( - f"SQM initialized with adaptive noise floor estimation (camera: {camera_type})" - ) - else: - logger.info("SQM initialized with manual pedestal mode") + self.noise_estimator = NoiseFloorEstimator( + camera_type=camera_type, + enable_zero_sec_sampling=True, + zero_sec_interval=300, # Every 5 minutes + ) + logger.info( + f"SQM initialized with adaptive noise floor estimation (camera: {camera_type})" + ) def _calc_field_parameters(self, fov_degrees: float) -> None: """Calculate field of view parameters.""" @@ -72,43 +55,24 @@ def _calc_field_parameters(self, fov_degrees: float) -> None: self.pixels_total = 512**2 self.arcsec_squared_per_pixel = self.field_arcsec_squared / self.pixels_total - def _calculate_background( - self, image: np.ndarray, centroids: np.ndarray, exclusion_radius: int - ) -> float: + def _pickering_airmass(self, altitude_deg: float) -> float: """ - Calculate background from star-free regions using median. + Calculate airmass using Pickering (2002) formula. + + More accurate than simple 1/sin(alt) near the horizon. + Accounts for atmospheric refraction. + + Reference: Pickering, K.A. (2002), "The Southern Limits of the Ancient + Star Catalogs", DIO 12, 1-15. Args: - image: Image array - centroids: All detected centroids (for masking) - exclusion_radius: Radius around each star to exclude (pixels) + altitude_deg: Altitude in degrees (must be > 0) Returns: - Background level in ADU per pixel + Airmass value (1.0 at zenith, increases toward horizon) """ - height, width = image.shape - mask = np.ones((height, width), dtype=bool) - - # Create coordinate grids - y, x = np.ogrid[:height, :width] - - # Mask out regions around all stars - for cx, cy in centroids: - if 0 <= cx < width and 0 <= cy < height: - star_mask = (x - cx) ** 2 + (y - cy) ** 2 <= exclusion_radius**2 - mask &= ~star_mask - - # Calculate median background from unmasked regions - if np.sum(mask) > 100: # Need enough pixels for reliable median - background_per_pixel = np.median(image[mask]) - else: - # Fallback to percentile if too many stars - background_per_pixel = np.percentile(image, 10) - logger.warning( - f"Using 10th percentile for background (only {np.sum(mask)} unmasked pixels)" - ) - - return float(background_per_pixel) + h = altitude_deg + return 1.0 / np.sin(np.radians(h + 244.0 / (165.0 + 47.0 * h**1.1))) def _measure_star_flux_with_local_background( self, @@ -117,7 +81,8 @@ def _measure_star_flux_with_local_background( aperture_radius: int, annulus_inner_radius: int, annulus_outer_radius: int, - ) -> Tuple[list, list]: + saturation_threshold: int = 250, + ) -> Tuple[list, list, int]: """ Measure star flux with local background from annulus around each star. @@ -127,15 +92,20 @@ def _measure_star_flux_with_local_background( aperture_radius: Aperture radius for star flux in pixels annulus_inner_radius: Inner radius of background annulus in pixels annulus_outer_radius: Outer radius of background annulus in pixels + saturation_threshold: Pixel value threshold for saturation detection (default: 250) + Stars with any aperture pixel >= this value are marked saturated Returns: - Tuple of (star_fluxes, local_backgrounds) where: + Tuple of (star_fluxes, local_backgrounds, n_saturated) where: star_fluxes: Background-subtracted star fluxes (total ADU above local background) + Saturated stars have flux set to -1 to be excluded from mzero calculation local_backgrounds: Local background per pixel for each star (ADU/pixel) + n_saturated: Number of stars excluded due to saturation """ height, width = image.shape star_fluxes = [] local_backgrounds = [] + n_saturated = 0 # Pre-compute squared radii aperture_r2 = aperture_radius**2 @@ -177,8 +147,21 @@ def _measure_star_flux_with_local_background( f"Star at ({cx:.0f},{cy:.0f}) has no annulus pixels, using global median" ) + # Check for saturation in aperture + aperture_pixels = image_patch[aperture_mask] + max_aperture_pixel = ( + np.max(aperture_pixels) if len(aperture_pixels) > 0 else 0 + ) + + if max_aperture_pixel >= saturation_threshold: + # Mark saturated star with flux=-1 to be excluded from mzero calculation + star_fluxes.append(-1) + local_backgrounds.append(local_bg_per_pixel) + n_saturated += 1 + continue + # Total flux in aperture (includes background) - total_flux = np.sum(image_patch[aperture_mask]) + total_flux = np.sum(aperture_pixels) # Subtract background contribution aperture_area_pixels = np.sum(aperture_mask) @@ -188,50 +171,56 @@ def _measure_star_flux_with_local_background( star_fluxes.append(star_flux) local_backgrounds.append(local_bg_per_pixel) - return star_fluxes, local_backgrounds + return star_fluxes, local_backgrounds, n_saturated def _calculate_mzero( self, star_fluxes: list, star_mags: list ) -> Tuple[Optional[float], list]: """ - Calculate photometric zero point from calibrated stars. + Calculate photometric zero point from calibrated stars using flux-weighted mean. For point sources: mzero = catalog_mag + 2.5 × log10(total_flux_ADU) This zero point allows converting any ADU measurement to magnitudes: mag = mzero - 2.5 × log10(flux_ADU) + Uses flux-weighted mean: brighter stars have higher SNR so their + mzero estimates are more reliable. + Args: star_fluxes: Background-subtracted star fluxes (ADU) star_mags: Catalog magnitudes for matched stars Returns: - Tuple of (mean_mzero, list_of_individual_mzeros) + Tuple of (weighted_mean_mzero, list_of_individual_mzeros) Note: The mzeros list will contain None for stars with invalid flux """ mzeros: list[Optional[float]] = [] + valid_mzeros = [] + valid_fluxes = [] for flux, mag in zip(star_fluxes, star_mags): if flux <= 0: - logger.warning( - f"Skipping star with flux={flux:.1f} ADU (mag={mag:.2f})" - ) + logger.debug(f"Skipping star with flux={flux:.1f} ADU (mag={mag:.2f})") mzeros.append(None) # Keep array aligned continue # Calculate zero point: ZP = m + 2.5*log10(F) mzero = mag + 2.5 * np.log10(flux) mzeros.append(mzero) - - # Filter out None values for statistics calculation - valid_mzeros = [mz for mz in mzeros if mz is not None] + valid_mzeros.append(mzero) + valid_fluxes.append(flux) if len(valid_mzeros) == 0: logger.error("No valid stars for mzero calculation") return None, mzeros - # Return mean and the full mzeros list (which may contain None values) - return float(np.mean(valid_mzeros)), mzeros + # Flux-weighted mean: brighter stars contribute more + valid_mzeros_arr = np.array(valid_mzeros) + valid_fluxes_arr = np.array(valid_fluxes) + weighted_mzero = float(np.average(valid_mzeros_arr, weights=valid_fluxes_arr)) + + return weighted_mzero, mzeros def _detect_aperture_overlaps( self, @@ -272,7 +261,7 @@ def _detect_aperture_overlaps( excluded_stars.add(i) excluded_stars.add(j) logger.debug( - f"CRITICAL overlap: stars {i} and {j} (d={distance:.1f}px < {2*aperture_radius}px)" + f"CRITICAL overlap: stars {i} and {j} (d={distance:.1f}px < {2 * aperture_radius}px)" ) # HIGH: Aperture inside another star's annulus (background contamination) elif distance < aperture_radius + annulus_outer_radius: @@ -286,22 +275,20 @@ def _detect_aperture_overlaps( def _atmospheric_extinction(self, altitude_deg: float) -> float: """ - Calculate atmospheric extinction correction to above-atmosphere equivalent. + Calculate atmospheric extinction correction. - Uses simplified airmass model and typical V-band extinction coefficient. - - The atmosphere ALWAYS dims starlight - even at zenith there's 0.28 mag extinction. - This correction accounts for the total atmospheric extinction to estimate what - the sky brightness would be if measured from above the atmosphere. + Uses Pickering (2002) airmass formula for improved accuracy near horizon. + Zenith is the reference point (extinction=0), with additional extinction + added for lower altitudes. Args: altitude_deg: Altitude of field center in degrees Returns: Extinction correction in magnitudes (add to measured SQM) - - At zenith (90°): 0.28 mag (minimum) - - At 45°: ~0.40 mag - - At 30°: 0.56 mag + - At zenith (90°): 0.0 mag (reference point) + - At 45°: ~0.12 mag + - At 30°: ~0.28 mag """ if altitude_deg <= 0: logger.warning( @@ -309,29 +296,32 @@ def _atmospheric_extinction(self, altitude_deg: float) -> float: ) return 0.0 - # Simplified airmass calculation - altitude_rad = np.radians(altitude_deg) - airmass = 1.0 / np.sin(altitude_rad) + # Use Pickering (2002) airmass formula for better accuracy near horizon + airmass = self._pickering_airmass(altitude_deg) - # Typical V-band extinction: 0.28 mag/airmass at sea level - # Total extinction is always present (minimum 0.28 mag at zenith) - extinction_correction = 0.28 * airmass + # V-band extinction coefficient: 0.28 mag/airmass + # Following ASTAP convention: zenith is reference point (extinction=0 at zenith) + # Only the ADDITIONAL extinction below zenith is added: k * (airmass - 1) + extinction_correction = 0.28 * (airmass - 1) return extinction_correction + def _determine_pedestal_source(self) -> str: + """Determine the source of the pedestal value for diagnostics.""" + return "adaptive_noise_floor" + def calculate( self, centroids: list, solution: dict, image: np.ndarray, exposure_sec: float, - bias_image: Optional[np.ndarray] = None, altitude_deg: float = 90.0, aperture_radius: int = 5, annulus_inner_radius: int = 6, annulus_outer_radius: int = 14, - pedestal: float = 0.0, correct_overlaps: bool = False, + saturation_threshold: int = 250, ) -> Tuple[Optional[float], Dict]: """ Calculate SQM (Sky Quality Meter) value using local background annuli. @@ -340,17 +330,13 @@ def calculate( centroids: All detected centroids (unused, kept for compatibility) solution: Tetra3 solution dict with 'FOV', 'matched_centroids', 'matched_stars' image: Image array (uint8 or float) - exposure_sec: Exposure time in seconds (required for adaptive noise floor) - bias_image: Optional bias/dark frame for pedestal calculation (default: None) + exposure_sec: Exposure time in seconds (required for noise floor estimation) altitude_deg: Altitude of field center for extinction correction (default: 90 = zenith) aperture_radius: Radius for star photometry in pixels (default: 5) annulus_inner_radius: Inner radius of background annulus in pixels (default: 6) annulus_outer_radius: Outer radius of background annulus in pixels (default: 14) - pedestal: Bias/pedestal level to subtract from background (default: 0) - Only used if use_adaptive_noise_floor=False - If bias_image is provided and pedestal=0, pedestal is calculated from bias_image correct_overlaps: If True, exclude stars with overlapping apertures/annuli (default: False) - Excludes CRITICAL and HIGH overlaps to prevent contamination + saturation_threshold: Pixel value threshold for saturation detection (default: 250) Returns: Tuple of (sqm_value, details_dict) where: @@ -358,17 +344,12 @@ def calculate( details_dict: Dictionary with intermediate values for diagnostics Example: - # Using local annulus backgrounds (handles uneven backgrounds) sqm_value, details = sqm_calculator.calculate( centroids=all_centroids, solution=tetra3_solution, image=np_image, - bias_image=bias_frame, + exposure_sec=0.5, altitude_deg=45.0, - aperture_radius=5, - annulus_inner_radius=6, - annulus_outer_radius=14, - correct_overlaps=True # Exclude overlapping stars ) if sqm_value: @@ -423,7 +404,7 @@ def calculate( logger.info( f"Overlap correction: excluded {n_stars_excluded}/{n_stars_original} stars " - f"({n_stars_excluded*100//n_stars_original}%), using {len(valid_indices)} stars" + f"({n_stars_excluded * 100 // n_stars_original}%), using {len(valid_indices)} stars" ) if len(valid_indices) < 3: @@ -432,61 +413,52 @@ def calculate( ) return None, {} - # 0. Determine noise floor / pedestal - noise_floor_details: Dict[str, Any] = {} + # 0. Determine noise floor / pedestal using adaptive estimation + noise_floor, noise_floor_details = self.noise_estimator.estimate_noise_floor( + image=image, + exposure_sec=exposure_sec, + percentile=5.0, + ) + # Pedestal = bias_offset + dark_current_contribution + # - Bias offset: electronic pedestal, systematic offset - SUBTRACT + # - Dark current mean: thermal electrons, systematic offset - SUBTRACT + # - Read noise: random fluctuation around 0 - do NOT subtract + # For processed images, dark_current_contribution is ~0 (ISP handles it) + bias_offset = noise_floor_details.get("bias_offset", 0.0) + dark_current_contrib = noise_floor_details.get("dark_current_contribution", 0.0) + pedestal = bias_offset + dark_current_contrib - if self.use_adaptive_noise_floor and self.noise_estimator is not None: - # Use adaptive noise floor estimation - noise_floor, noise_floor_details = ( - self.noise_estimator.estimate_noise_floor( - image=image, - exposure_sec=exposure_sec, - percentile=5.0, - ) - ) - pedestal = noise_floor + logger.debug( + f"Adaptive noise floor: {noise_floor:.1f} ADU, " + f"pedestal={pedestal:.1f} (bias={bias_offset:.1f} + dark={dark_current_contrib:.1f}) " + f"(dark_px={noise_floor_details['dark_pixel_smoothed']:.1f}, " + f"theory={noise_floor_details['theoretical_floor']:.1f}, " + f"valid={noise_floor_details['is_valid']})" + ) + # Check if zero-sec sample requested + if noise_floor_details.get("request_zero_sec_sample"): logger.info( - f"Adaptive noise floor: {noise_floor:.1f} ADU " - f"(dark_px={noise_floor_details['dark_pixel_smoothed']:.1f}, " - f"theory={noise_floor_details['theoretical_floor']:.1f}, " - f"valid={noise_floor_details['is_valid']})" + "Zero-second calibration sample requested by noise estimator " + "(will be captured in next cycle)" ) - # Check if zero-sec sample requested - if noise_floor_details.get("request_zero_sec_sample"): - logger.info( - "Zero-second calibration sample requested by noise estimator " - "(will be captured in next cycle)" - ) - else: - # Use manual pedestal (legacy mode) - if bias_image is not None and pedestal == 0.0: - pedestal = float(np.median(bias_image)) - logger.debug(f"Pedestal from bias: {pedestal:.2f} ADU") - elif pedestal > 0: - logger.debug(f"Using manual pedestal: {pedestal:.2f} ADU") - else: - logger.debug("No pedestal applied") - # 1. Measure star fluxes with local background from annulus - star_fluxes, local_backgrounds = self._measure_star_flux_with_local_background( - image, - matched_centroids_arr, - aperture_radius, - annulus_inner_radius, - annulus_outer_radius, + star_fluxes, local_backgrounds, n_saturated = ( + self._measure_star_flux_with_local_background( + image, + matched_centroids_arr, + aperture_radius, + annulus_inner_radius, + annulus_outer_radius, + saturation_threshold, + ) ) - # 1a. Estimate pedestal from median local background if enabled and not already set - if ( - self.pedestal_from_background - and pedestal == 0.0 - and len(local_backgrounds) > 0 - ): - pedestal = float(np.median(local_backgrounds)) - logger.debug( - f"Pedestal estimated from median(local_backgrounds): {pedestal:.2f} ADU" + if n_saturated > 0: + logger.info( + f"Excluded {n_saturated}/{len(matched_centroids_arr)} saturated stars " + f"(threshold={saturation_threshold})" ) # 2. Calculate sky background from median of local backgrounds @@ -516,16 +488,25 @@ def calculate( # 5. Convert background to flux density (ADU per arcsec²) background_flux_density = background_corrected / self.arcsec_squared_per_pixel - # 6. Calculate raw SQM + # 6. Calculate SQM (before extinction correction) if background_flux_density <= 0: logger.error(f"Invalid background flux density: {background_flux_density}") return None, {} - sqm_raw = mzero - 2.5 * np.log10(background_flux_density) + sqm_uncorrected = mzero - 2.5 * np.log10(background_flux_density) - # 7. Apply atmospheric extinction correction - extinction_correction = self._atmospheric_extinction(altitude_deg) - sqm_final = sqm_raw + extinction_correction + # 7. Apply atmospheric extinction correction (ASTAP convention) + # Following ASTAP: zenith is reference point where extinction = 0 + # Only ADDITIONAL extinction below zenith is added: 0.28 * (airmass - 1) + # This allows comparing measurements at different altitudes + extinction_for_altitude = self._atmospheric_extinction( + altitude_deg + ) # 0.28*(airmass-1) + + # Main SQM value: no extinction correction (raw measurement) + sqm_final = sqm_uncorrected + # Altitude-corrected value: adds extinction for altitude comparison + sqm_altitude_corrected = sqm_uncorrected + extinction_for_altitude # Filter out None values for statistics in diagnostics valid_mzeros_for_stats = [mz for mz in mzeros if mz is not None] @@ -540,24 +521,12 @@ def calculate( "n_matched_stars_original": n_stars_original, "overlap_correction_enabled": correct_overlaps, "n_stars_excluded_overlaps": n_stars_excluded, + "n_stars_excluded_saturation": n_saturated, + "saturation_threshold": saturation_threshold, "background_per_pixel": background_per_pixel, "background_method": "local_annulus", "pedestal": pedestal, - "pedestal_source": ( - "adaptive_noise_floor" - if self.use_adaptive_noise_floor and self.noise_estimator is not None - else ( - "bias_image" - if bias_image is not None - else ( - "median_local_backgrounds" - if pedestal > 0 - and bias_image is None - and self.pedestal_from_background - else ("manual" if pedestal > 0 else "none") - ) - ) - ), + "pedestal_source": self._determine_pedestal_source(), "noise_floor_details": noise_floor_details if noise_floor_details else None, "exposure_sec": exposure_sec, "background_corrected": background_corrected, @@ -572,10 +541,11 @@ def calculate( float(np.min(valid_mzeros_for_stats)), float(np.max(valid_mzeros_for_stats)), ), - "sqm_raw": sqm_raw, + "sqm_uncorrected": sqm_uncorrected, "altitude_deg": altitude_deg, - "extinction_correction": extinction_correction, + "extinction_for_altitude": extinction_for_altitude, "sqm_final": sqm_final, + "sqm_altitude_corrected": sqm_altitude_corrected, # Per-star details for diagnostics "star_centroids": matched_centroids_arr.tolist(), "star_mags": star_mags, @@ -587,102 +557,8 @@ def calculate( logger.debug( f"SQM: mzero={mzero:.2f}±{np.std(valid_mzeros_for_stats):.2f}, " f"bg={background_flux_density:.6f} ADU/arcsec², pedestal={pedestal:.2f}, " - f"raw={sqm_raw:.2f}, extinction={extinction_correction:.2f}, final={sqm_final:.2f}" + f"raw={sqm_uncorrected:.2f}, ext_alt={extinction_for_altitude:.2f}, " + f"final={sqm_final:.2f}, alt_corr={sqm_altitude_corrected:.2f}" ) return sqm_final, details - - -def update_sqm_if_needed( - shared_state, - sqm_calculator: SQM, - centroids: list, - solution: dict, - image: np.ndarray, - exposure_sec: float, - altitude_deg: float, - calculation_interval_seconds: float = 5.0, - aperture_radius: int = 5, - annulus_inner_radius: int = 6, - annulus_outer_radius: int = 14, -) -> bool: - """ - Check if SQM needs updating and calculate/store new value if needed. - - This function encapsulates all the logic for time-based SQM updates: - - Checks if enough time has passed since last update - - Calculates new SQM value if needed - - Updates shared state with new SQM object - - Handles all timestamp conversions and error cases - - Args: - shared_state: SharedStateObj instance to read/write SQM state - sqm_calculator: SQM calculator instance - centroids: List of detected star centroids - solution: Tetra3 solve solution with matched stars - image: Raw image array - exposure_sec: Exposure time in seconds (required for adaptive noise floor) - altitude_deg: Altitude in degrees for extinction correction - calculation_interval_seconds: Minimum time between calculations (default: 5.0) - aperture_radius: Aperture radius for photometry (default: 5) - annulus_inner_radius: Inner annulus radius (default: 6) - annulus_outer_radius: Outer annulus radius (default: 14) - - Returns: - bool: True if SQM was calculated and updated, False otherwise - """ - # Get current SQM state from shared state - current_sqm = shared_state.sqm() - current_time = time.time() - - # Check if we should calculate SQM: - # - No previous calculation (last_update is None), OR - # - Enough time has passed since last update - should_calculate = current_sqm.last_update is None - - if current_sqm.last_update is not None: - try: - last_update_time = datetime.fromisoformat( - current_sqm.last_update - ).timestamp() - should_calculate = ( - current_time - last_update_time - ) >= calculation_interval_seconds - except (ValueError, AttributeError): - # If timestamp parsing fails, recalculate - logger.warning("Failed to parse SQM timestamp, recalculating") - should_calculate = True - - if not should_calculate: - return False - - # Calculate new SQM value - try: - sqm_value, _ = sqm_calculator.calculate( - centroids=centroids, - solution=solution, - image=image, - exposure_sec=exposure_sec, - altitude_deg=altitude_deg, - aperture_radius=aperture_radius, - annulus_inner_radius=annulus_inner_radius, - annulus_outer_radius=annulus_outer_radius, - ) - - if sqm_value is not None: - # Create new SQM state object - new_sqm_state = SQMState( - value=sqm_value, - source="Calculated", - last_update=datetime.now().isoformat(), - ) - shared_state.set_sqm(new_sqm_state) - logger.debug(f"SQM: {sqm_value:.2f} mag/arcsec²") - return True - else: - logger.warning("SQM calculation returned None") - return False - - except Exception as e: - logger.error(f"SQM calculation failed: {e}", exc_info=True) - return False diff --git a/python/PiFinder/state.py b/python/PiFinder/state.py index 03a979855..b854dc5a0 100644 --- a/python/PiFinder/state.py +++ b/python/PiFinder/state.py @@ -16,7 +16,6 @@ from typing import Optional from dataclasses import dataclass, asdict import json -from timezonefinder import TimezoneFinder logger = logging.getLogger("SharedState") @@ -146,19 +145,13 @@ class SQM: Sky Quality Meter - represents the sky brightness measurement. """ - value: float = ( - 20.15 # mag/arcsec² - default typical dark sky value (processed 8-bit) - ) - value_raw: Optional[float] = ( - None # mag/arcsec² - from raw 16-bit pipeline (more accurate) - ) + value: float = 20.15 # mag/arcsec² - default typical dark sky value source: str = "None" # "None", "Calculated", "Manual", etc. last_update: Optional[str] = None # ISO timestamp of last update def __str__(self): - raw_str = f", raw={self.value_raw:.2f}" if self.value_raw is not None else "" return ( - f"SQM(value={self.value:.2f} mag/arcsec²{raw_str}, " + f"SQM(value={self.value:.2f} mag/arcsec², " f"source={self.source}, " f"last_update={self.last_update or 'Never'})" ) @@ -244,7 +237,7 @@ def from_json(cls, json_str): class SharedStateObj: - def __init__(self): + def __init__(self) -> None: self.__power_state = 1 # self.__solve_state # None = No solve attempted yet @@ -264,6 +257,10 @@ def __init__(self): self.__imu = None self.__location: Location = Location() self.__sqm: SQM = SQM() + self.__noise_floor: float = ( + 10.0 # Adaptive noise floor in ADU (default fallback) + ) + self.__sqm_details: dict = {} # Full SQM calculation details for calibration self.__datetime = None self.__datetime_time = None self.__screen = None @@ -274,7 +271,7 @@ def __init__(self): self.__cam_raw = None # Are we prepared to do alt/az math # We need gps lock and datetime - self.__tz_finder = TimezoneFinder() + self.__tz_finder = None def serialize(self, output_file): with open(output_file, "wb") as f: @@ -352,6 +349,10 @@ def set_location(self, v): # if value is not none, set the timezone # before saving the value if v: + if self.__tz_finder is None: + from timezonefinder import TimezoneFinder + + self.__tz_finder = TimezoneFinder() v.timezone = self.__tz_finder.timezone_at(lat=v.lat, lng=v.lon) self.__location = v @@ -363,6 +364,22 @@ def set_sqm(self, sqm: SQM): """Update the SQM value""" self.__sqm = sqm + def noise_floor(self) -> float: + """Return the adaptive noise floor in ADU""" + return self.__noise_floor + + def set_noise_floor(self, v: float): + """Update the adaptive noise floor (from SQM calculator)""" + self.__noise_floor = v + + def sqm_details(self) -> dict: + """Return the full SQM calculation details""" + return self.__sqm_details + + def set_sqm_details(self, v: dict): + """Update the SQM calculation details""" + self.__sqm_details = v + def get_sky_brightness(self): """Return just the numeric SQM value for convenience""" return self.__sqm.value diff --git a/python/PiFinder/sys_utils.py b/python/PiFinder/sys_utils.py index 1760edc38..37f1970a5 100644 --- a/python/PiFinder/sys_utils.py +++ b/python/PiFinder/sys_utils.py @@ -1,411 +1,592 @@ -import glob +""" +NixOS system utilities for PiFinder. + +Uses: +- NetworkManager GLib bindings (gi.repository.NM) for WiFi management +- python-pam for password verification +- D-Bus for hostname/reboot/shutdown +- stdlib zipfile for backup/restore +- NixOS specialisations for camera switching +- systemd service for software updates +""" + +import os import re -from typing import Dict, Any +import subprocess +import logging +from pathlib import Path +from typing import Optional -import sh -from sh import wpa_cli, unzip, su, passwd +import dbus +import pam +import gi -import socket -from PiFinder import utils -import logging +gi.require_version("NM", "1.0") +from gi.repository import GLib, NM # noqa: E402 -BACKUP_PATH = "/home/pifinder/PiFinder_data/PiFinder_backup.zip" +from PiFinder.sys_utils_base import ( # noqa: E402 + NetworkBase, + BACKUP_PATH, # noqa: F401 + remove_backup, # noqa: F401 + backup_userdata, # noqa: F401 + restore_userdata, # noqa: F401 + restart_pifinder, # noqa: F401 +) -logger = logging.getLogger("SysUtils") +AP_CONNECTION_NAME = "PiFinder-AP" +logger = logging.getLogger("SysUtils.NixOS") -class Network: - """ - Provides wifi network info - """ - def __init__(self): - self.wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" - with open(self.wifi_txt, "r") as wifi_f: - self._wifi_mode = wifi_f.read() +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- - self.populate_wifi_networks() - def populate_wifi_networks(self) -> None: - wpa_supplicant_path = "/etc/wpa_supplicant/wpa_supplicant.conf" - self._wifi_networks = [] - try: - with open(wpa_supplicant_path, "r") as wpa_conf: - contents = wpa_conf.readlines() - except IOError as e: - logger.error(f"Error reading wpa_supplicant.conf: {e}") - return +def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + """Run a command, logging failures.""" + result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) + if result.returncode != 0: + logger.error( + "Command %s failed (rc=%d): %s", + cmd, + result.returncode, + result.stderr.strip(), + ) + return result - self._wifi_networks = Network._parse_wpa_supplicant(contents) - @staticmethod - def _parse_wpa_supplicant(contents: list[str]) -> list: - """ - Parses wpa_supplicant.conf to get current config - """ - wifi_networks = [] - network_dict: Dict[str, Any] = {} - network_id = 0 - in_network_block = False - for line in contents: - line = line.strip() - if line.startswith("network={"): - in_network_block = True - network_dict = { - "id": network_id, - "ssid": None, - "psk": None, - "key_mgmt": None, - } +def _nm_client() -> NM.Client: + """Create a NetworkManager client (synchronous).""" + return NM.Client.new(None) - elif line == "}" and in_network_block: - in_network_block = False - wifi_networks.append(network_dict) - network_id += 1 - elif in_network_block: - match = re.match(r"(\w+)=(.+)", line) - if match: - key, value = match.groups() - if key in network_dict: - network_dict[key] = value.strip('"') +def _nm_run_async(async_fn, *args): + """ + Run an async NM operation synchronously by spinning a local GLib MainLoop. + """ + loop = GLib.MainLoop.new(None, False) + state = {"result": None, "error": None} - return wifi_networks + def callback(source, async_result, _user_data): + try: + method_name = async_fn.__name__.replace("_async", "_finish") + finish_fn = getattr(source, method_name) + state["result"] = finish_fn(async_result) + except Exception as e: + state["error"] = e + finally: + loop.quit() - def get_wifi_networks(self): - return self._wifi_networks + async_fn(*args, callback, None) + loop.run() - def delete_wifi_network(self, network_id): - """ - Immediately deletes a wifi network - """ - self._wifi_networks.pop(network_id) + if state["error"]: + raise state["error"] + return state["result"] - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "r") as wpa_conf: - wpa_contents = list(wpa_conf) - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "w") as wpa_conf: - in_networks = False - for line in wpa_contents: - if not in_networks: - if line.startswith("network={"): - in_networks = True - else: - wpa_conf.write(line) +def _get_system_bus() -> dbus.SystemBus: + return dbus.SystemBus() - for network in self._wifi_networks: - ssid = network["ssid"] - key_mgmt = network["key_mgmt"] - psk = network["psk"] - wpa_conf.write("\nnetwork={\n") - wpa_conf.write(f'\tssid="{ssid}"\n') - if key_mgmt == "WPA-PSK": - wpa_conf.write(f'\tpsk="{psk}"\n') - wpa_conf.write(f"\tkey_mgmt={key_mgmt}\n") +# --------------------------------------------------------------------------- +# Network class — WiFi management via NM GLib bindings +# --------------------------------------------------------------------------- - wpa_conf.write("}\n") +class Network(NetworkBase): + """ + Provides wifi network info via NetworkManager GLib bindings (libnm). + """ + + def __init__(self): + self._client = _nm_client() + self._wifi_networks: list[dict] = [] + self._wifi_mode = self._detect_wifi_mode() + self.populate_wifi_networks() + + def _detect_wifi_mode(self) -> str: + """Detect whether we're in AP or Client mode.""" + for ac in self._client.get_active_connections(): + if ac.get_id() == AP_CONNECTION_NAME: + return "AP" + return "Client" + + def populate_wifi_networks(self) -> None: + """Get saved WiFi connections from NetworkManager.""" + self._wifi_networks = [] + network_id = 0 + for conn in self._client.get_connections(): + s_wifi = conn.get_setting_wireless() + if s_wifi is None: + continue + if conn.get_id() == AP_CONNECTION_NAME: + continue + ssid_bytes = s_wifi.get_ssid() + ssid = ssid_bytes.get_data().decode("utf-8") if ssid_bytes else "" + self._wifi_networks.append( + { + "id": network_id, + "ssid": ssid, + "psk": None, + "key_mgmt": "WPA-PSK", + } + ) + network_id += 1 + + def delete_wifi_network(self, network_id): + """Delete a saved WiFi connection.""" + if network_id < 0 or network_id >= len(self._wifi_networks): + logger.error("Invalid network_id: %d", network_id) + return + ssid = self._wifi_networks[network_id]["ssid"] + for conn in self._client.get_connections(): + if conn.get_id() == ssid: + try: + _nm_run_async(conn.delete_async, None) + except Exception as e: + logger.error("Failed to delete connection '%s': %s", ssid, e) + break self.populate_wifi_networks() def add_wifi_network(self, ssid, key_mgmt, psk=None): - """ - Add a wifi network - """ - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "a") as wpa_conf: - wpa_conf.write("\nnetwork={\n") - wpa_conf.write(f'\tssid="{ssid}"\n') - if key_mgmt == "WPA-PSK": - wpa_conf.write(f'\tpsk="{psk}"\n') - wpa_conf.write(f"\tkey_mgmt={key_mgmt}\n") + """Add and connect to a WiFi network.""" + profile = NM.SimpleConnection.new() + + s_con = NM.SettingConnection.new() + s_con.set_property(NM.SETTING_CONNECTION_ID, ssid) + s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless") + s_con.set_property(NM.SETTING_CONNECTION_AUTOCONNECT, True) + profile.add_setting(s_con) + + s_wifi = NM.SettingWireless.new() + s_wifi.set_property( + NM.SETTING_WIRELESS_SSID, + GLib.Bytes.new(ssid.encode("utf-8")), + ) + s_wifi.set_property(NM.SETTING_WIRELESS_MODE, "infrastructure") + profile.add_setting(s_wifi) + + if key_mgmt == "WPA-PSK" and psk: + s_wsec = NM.SettingWirelessSecurity.new() + s_wsec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk") + s_wsec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, psk) + profile.add_setting(s_wsec) + + s_ip4 = NM.SettingIP4Config.new() + s_ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, "auto") + profile.add_setting(s_ip4) - wpa_conf.write("}\n") + try: + _nm_run_async( + self._client.add_and_activate_connection_async, + profile, + self._client.get_device_by_iface("wlan0"), + None, + None, + ) + except Exception as e: + logger.error("Failed to add WiFi network '%s': %s", ssid, e) self.populate_wifi_networks() - if self._wifi_mode == "Client": - # Restart the supplicant - wpa_cli("reconfigure") - - def get_ap_name(self): - with open("/etc/hostapd/hostapd.conf", "r") as conf: - for line in conf: - if line.startswith("ssid="): - return line[5:-1] - return "UNKN" - - def set_ap_name(self, ap_name): + + def get_ap_name(self) -> str: + """Get the current AP SSID from the PiFinder-AP profile.""" + for conn in self._client.get_connections(): + if conn.get_id() == AP_CONNECTION_NAME: + s_wifi = conn.get_setting_wireless() + if s_wifi: + ssid_bytes = s_wifi.get_ssid() + if ssid_bytes: + return ssid_bytes.get_data().decode("utf-8") + return "PiFinderAP" + + def set_ap_name(self, ap_name: str) -> None: + """Change the AP SSID.""" if ap_name == self.get_ap_name(): return - with open("/tmp/hostapd.conf", "w") as new_conf: - with open("/etc/hostapd/hostapd.conf", "r") as conf: - for line in conf: - if line.startswith("ssid="): - line = f"ssid={ap_name}\n" - new_conf.write(line) - sh.sudo("cp", "/tmp/hostapd.conf", "/etc/hostapd/hostapd.conf") - - def get_host_name(self): - return socket.gethostname() + for conn in self._client.get_connections(): + if conn.get_id() == AP_CONNECTION_NAME: + s_wifi = conn.get_setting_wireless() + if s_wifi: + s_wifi.set_property( + NM.SETTING_WIRELESS_SSID, + GLib.Bytes.new(ap_name.encode("utf-8")), + ) + try: + _nm_run_async(conn.commit_changes_async, True, None) + except Exception as e: + logger.error("Failed to update AP SSID: %s", e) + return def get_connected_ssid(self) -> str: - """ - Returns the SSID of the connected wifi network or - None if not connected or in AP mode - """ + """Returns the SSID of the connected wifi network.""" if self.wifi_mode() == "AP": return "" - # get output from iwgetid - try: - iwgetid = sh.Command("iwgetid") - _t = iwgetid(_ok_code=(0, 255)).strip() - return _t.split(":")[-1].strip('"') - except sh.CommandNotFound: - return "ssid_not_found" + device = self._client.get_device_by_iface("wlan0") + if device is None: + return "" + ac = device.get_active_connection() + if ac is None: + return "" + conn = ac.get_connection() + if conn is None: + return "" + s_wifi = conn.get_setting_wireless() + if s_wifi is None: + return "" + ssid_bytes = s_wifi.get_ssid() + if ssid_bytes is None: + return "" + return ssid_bytes.get_data().decode("utf-8") - def set_host_name(self, hostname) -> None: - if hostname == self.get_host_name(): - return - _result = sh.sudo("hostnamectl", "set-hostname", hostname) + _HOSTNAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$") - def wifi_mode(self): - return self._wifi_mode + def set_host_name(self, hostname: str) -> None: + """Set kernel hostname and update avahi mDNS announcement. - def set_wifi_mode(self, mode): - if mode == self._wifi_mode: + NixOS makes /etc/hostname read-only (nix store symlink), so we set + the kernel hostname directly and persist to a file that a boot + service reads on startup. + """ + hostname = hostname.strip() + if not self._HOSTNAME_RE.match(hostname): + logger.warning("Invalid hostname rejected: %r", hostname) return - if mode == "AP": - go_wifi_ap() - - if mode == "Client": - go_wifi_cli() - - def local_ip(self): - if self._wifi_mode == "AP": - return "10.10.10.1" - - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + if hostname == self.get_host_name(): + return + subprocess.run(["sudo", "hostname", hostname], check=False) + result = subprocess.run(["sudo", "avahi-set-host-name", hostname], check=False) + if result.returncode != 0: + logger.warning( + "avahi-set-host-name failed (rc=%d), restarting avahi-daemon", + result.returncode, + ) + subprocess.run( + ["sudo", "systemctl", "restart", "avahi-daemon.service"], + check=False, + ) + data_dir = Path(os.environ.get("PIFINDER_DATA", "/home/pifinder/PiFinder_data")) + (data_dir / "hostname").write_text(hostname) + + def _go_ap(self) -> None: + """Activate the AP connection.""" + self._activate_connection(AP_CONNECTION_NAME) + + def _go_client(self) -> None: + """Deactivate the AP connection (fall back to client).""" + self._deactivate_connection(AP_CONNECTION_NAME) + + def _activate_connection(self, name: str) -> None: + """Activate a saved connection by name.""" + conn = None + for c in self._client.get_connections(): + if c.get_id() == name: + conn = c + break + if conn is None: + logger.error("Connection '%s' not found", name) + return + device = self._client.get_device_by_iface("wlan0") try: - s.connect(("192.255.255.255", 1)) - ip = s.getsockname()[0] - except Exception: - ip = "NONE" - finally: - s.close() - return ip + _nm_run_async( + self._client.activate_connection_async, + conn, + device, + None, + None, + ) + except Exception as e: + logger.error("Failed to activate '%s': %s", name, e) + + def _deactivate_connection(self, name: str) -> None: + """Deactivate an active connection by name.""" + for ac in self._client.get_active_connections(): + if ac.get_id() == name: + try: + _nm_run_async( + self._client.deactivate_connection_async, + ac, + None, + ) + except Exception as e: + logger.error("Failed to deactivate '%s': %s", name, e) + return + logger.warning("No active connection named '%s' to deactivate", name) + + +# --------------------------------------------------------------------------- +# Module-level WiFi switching (called by callbacks.py and status.py) +# --------------------------------------------------------------------------- + +_network_instance: Optional[Network] = None + + +def _get_network() -> Network: + global _network_instance + if _network_instance is None: + _network_instance = Network() + return _network_instance def go_wifi_ap(): logger.info("SYS: Switching to AP") - sh.sudo("/home/pifinder/PiFinder/switch-ap.sh") + net = _get_network() + net.set_wifi_mode("AP") return True def go_wifi_cli(): logger.info("SYS: Switching to Client") - sh.sudo("/home/pifinder/PiFinder/switch-cli.sh") + net = _get_network() + net.set_wifi_mode("Client") return True -def remove_backup(): - """ - Removes backup file - """ - sh.sudo("rm", BACKUP_PATH, _ok_code=(0, 1)) +# --------------------------------------------------------------------------- +# System control (systemctl subprocess + D-Bus for reboot/shutdown) +# --------------------------------------------------------------------------- -def backup_userdata(): - """ - Back up userdata to a single zip file for later - restore. Returns the path to the zip file. +def restart_system() -> None: + """Restart the system via D-Bus to login1.""" + logger.info("SYS: Initiating System Restart") + try: + bus = _get_system_bus() + login1 = bus.get_object( + "org.freedesktop.login1", + "/org/freedesktop/login1", + ) + manager = dbus.Interface(login1, "org.freedesktop.login1.Manager") + manager.Reboot(False) + except dbus.DBusException as e: + logger.error("D-Bus reboot failed, falling back to subprocess: %s", e) + _run(["sudo", "shutdown", "-r", "now"]) - Backs up: - config.json - observations.db - obslist/* - """ - remove_backup() +def shutdown() -> None: + """Shut down the system via D-Bus to login1.""" + logger.info("SYS: Initiating Shutdown") + try: + bus = _get_system_bus() + login1 = bus.get_object( + "org.freedesktop.login1", + "/org/freedesktop/login1", + ) + manager = dbus.Interface(login1, "org.freedesktop.login1.Manager") + manager.PowerOff(False) + except dbus.DBusException as e: + logger.error("D-Bus shutdown failed, falling back to subprocess: %s", e) + _run(["sudo", "shutdown", "now"]) - _zip = sh.Command("zip") - _zip( - BACKUP_PATH, - "/home/pifinder/PiFinder_data/config.json", - "/home/pifinder/PiFinder_data/observations.db", - glob.glob("/home/pifinder/PiFinder_data/obslists/*"), - ) - return BACKUP_PATH +# --------------------------------------------------------------------------- +# Software updates — async upgrade via systemd service +# --------------------------------------------------------------------------- +UPGRADE_STATE_IDLE = "idle" +UPGRADE_STATE_RUNNING = "running" +UPGRADE_STATE_SUCCESS = "success" +UPGRADE_STATE_FAILED = "failed" -def restore_userdata(zip_path): - """ - Compliment to backup_userdata - restores userdata - OVERWRITES existing data! - """ - unzip("-d", "/", "-o", zip_path) +UPGRADE_REF_FILE = Path("/run/pifinder/upgrade-ref") +UPGRADE_STATUS_FILE = Path("/run/pifinder/upgrade-status") -def restart_pifinder() -> None: - """ - Uses systemctl to restart the PiFinder - service - """ - logger.info("SYS: Restarting PiFinder") - sh.sudo("systemctl", "restart", "pifinder") +def start_upgrade(ref: str = "release") -> bool: + """Start pifinder-upgrade.service with a specific git ref.""" + try: + UPGRADE_REF_FILE.write_text(ref) + except OSError as e: + logger.error("Failed to write upgrade ref file: %s", e) + return False + + # Clean stale status from previous run + UPGRADE_STATUS_FILE.unlink(missing_ok=True) + + _run(["sudo", "systemctl", "reset-failed", "pifinder-upgrade.service"]) + result = _run( + [ + "sudo", + "systemctl", + "start", + "--no-block", + "pifinder-upgrade.service", + ] + ) + return result.returncode == 0 -def restart_system() -> None: - """ - Restarts the system +def get_upgrade_state() -> str: + """Poll upgrade status file written by the upgrade service.""" + try: + status = UPGRADE_STATUS_FILE.read_text().strip() + except FileNotFoundError: + # Service hasn't written status yet — check if it's still starting + result = _run(["systemctl", "is-active", "pifinder-upgrade.service"]) + svc = result.stdout.strip() + if svc in ("activating", "active"): + return UPGRADE_STATE_RUNNING + if svc == "failed": + return UPGRADE_STATE_FAILED + return UPGRADE_STATE_IDLE + + if status == "success": + return UPGRADE_STATE_SUCCESS + elif status == "failed": + return UPGRADE_STATE_FAILED + elif status.startswith("downloading") or status in ("activating", "rebooting"): + return UPGRADE_STATE_RUNNING + return UPGRADE_STATE_IDLE + + +def get_upgrade_progress() -> dict: + """Return structured upgrade progress for UI display. + + Returns dict with keys: + phase: "downloading" | "activating" | "rebooting" | "success" | "failed" | "" + done: int (paths downloaded so far) + total: int (total paths to download) + percent: int (0-100) """ - logger.info("SYS: Initiating System Restart") - sh.sudo("shutdown", "-r", "now") + try: + raw = UPGRADE_STATUS_FILE.read_text().strip() + except FileNotFoundError: + return {"phase": "", "done": 0, "total": 0, "percent": 0} + # "downloading 5/42" format + if raw.startswith("downloading "): + parts = raw.split(" ", 1)[1].split("/") + try: + done, total = int(parts[0]), int(parts[1]) + pct = int(done * 100 / total) if total > 0 else 0 + return { + "phase": "downloading", + "done": done, + "total": total, + "percent": pct, + } + except (ValueError, IndexError): + return {"phase": "downloading", "done": 0, "total": 0, "percent": 0} + if raw == "activating": + return {"phase": "activating", "done": 0, "total": 0, "percent": 100} + if raw == "rebooting": + return {"phase": "rebooting", "done": 0, "total": 0, "percent": 100} + if raw == "success": + return {"phase": "success", "done": 0, "total": 0, "percent": 100} + if raw == "failed": + return {"phase": "failed", "done": 0, "total": 0, "percent": 0} + return {"phase": "", "done": 0, "total": 0, "percent": 0} + + +def get_upgrade_log_tail(lines: int = 3) -> str: + """Last N lines from upgrade journal for UI display.""" + result = _run( + [ + "journalctl", + "-u", + "pifinder-upgrade.service", + "-n", + str(lines), + "--no-pager", + "-o", + "cat", + ] + ) + return result.stdout.strip() if result.returncode == 0 else "" -def shutdown() -> None: - """ - shuts down the system - """ - logger.info("SYS: Initiating Shutdown") - sh.sudo("shutdown", "now") +def update_software(ref: str = "release") -> bool: + """Start the upgrade service (non-blocking). -def update_software(): - """ - Uses systemctl to git pull and then restart - service + The service downloads, sets the boot profile, and reboots. + UI should poll get_upgrade_progress() for status. """ - logger.info("SYS: Running update") - sh.bash("/home/pifinder/PiFinder/pifinder_update.sh") - return True + return start_upgrade(ref=ref) -def verify_password(username, password): - """ - Checks the provided password against the provided user - password - """ - result = su(username, "-c", "echo", _in=f"{password}\n", _ok_code=(0, 1)) - if result.exit_code == 0: - return True - else: +# --------------------------------------------------------------------------- +# Password management (python-pam + chpasswd) +# --------------------------------------------------------------------------- + + +def verify_password(username: str, password: str) -> bool: + """Verify a password against PAM.""" + p = pam.pam() + return p.authenticate(username, password, service="pifinder") + + +def change_password(username: str, current_password: str, new_password: str) -> bool: + """Change the user password via chpasswd.""" + if not verify_password(username, current_password): return False + result = subprocess.run( + ["sudo", "chpasswd"], + input=f"{username}:{new_password}\n", + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +# --------------------------------------------------------------------------- +# Camera switching (specialisations + reboot) +# --------------------------------------------------------------------------- +CAMERA_TYPE_FILE = "/var/lib/pifinder/camera-type" -def change_password(username, current_password, new_password): + +def switch_camera(cam_type: str) -> None: """ - Changes the PiFinder User password + Switch camera via NixOS specialisation. + Requires reboot (dtoverlay change). """ - result = passwd( - username, - _in=f"{current_password}\n{new_password}\n{new_password}\n", - _ok_code=(0, 10), - ) + logger.info("SYS: Switching camera to %s via specialisation", cam_type) + result = _run(["sudo", "pifinder-switch-camera", cam_type]) + if result.returncode != 0: + logger.error("SYS: Camera switch failed: %s", result.stderr) - if result.exit_code == 0: - return True - else: - return False + +def get_camera_type() -> list[str]: + try: + with open(CAMERA_TYPE_FILE) as f: + return [f.read().strip()] + except FileNotFoundError: + return ["imx462"] def switch_cam_imx477() -> None: logger.info("SYS: Switching cam to imx477") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx477") + switch_camera("imx477") def switch_cam_imx296() -> None: logger.info("SYS: Switching cam to imx296") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx296") + switch_camera("imx296") def switch_cam_imx462() -> None: logger.info("SYS: Switching cam to imx462") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx462") + switch_camera("imx462") -def check_and_sync_gpsd_config(baud_rate: int) -> bool: - """ - Checks if GPSD configuration matches the desired baud rate, - and updates it only if necessary. +# --------------------------------------------------------------------------- +# GPSD config (declarative on NixOS — no-ops) +# --------------------------------------------------------------------------- - Args: - baud_rate: The desired baud rate (9600 or 115200) - Returns: - True if configuration was updated, False if already correct +def check_and_sync_gpsd_config(baud_rate: int) -> bool: """ - logger.info(f"SYS: Checking GPSD config for baud rate {baud_rate}") - - try: - # Read current config - with open("/etc/default/gpsd", "r") as f: - content = f.read() - - # Determine expected GPSD_OPTIONS - if baud_rate == 115200: - # NOTE: the space before -s in the next line is really needed - expected_options = 'GPSD_OPTIONS=" -s 115200"' - else: - expected_options = 'GPSD_OPTIONS=""' - - # Check if update is needed - current_match = re.search(r"^GPSD_OPTIONS=.*$", content, re.MULTILINE) - if current_match: - current_options = current_match.group(0) - if current_options == expected_options: - logger.info("SYS: GPSD config already correct, no update needed") - return False - - # Update is needed - logger.info(f"SYS: GPSD config mismatch, updating to {expected_options}") - update_gpsd_config(baud_rate) - return True - - except Exception as e: - logger.error(f"SYS: Error checking/syncing GPSD config: {e}") - return False - - -def update_gpsd_config(baud_rate: int) -> None: + On NixOS, GPSD config is managed declaratively via services.nix. + This is a no-op. """ - Updates the GPSD configuration file with the specified baud rate - and restarts the GPSD service. + logger.info("SYS: GPSD baud rate %d — managed by NixOS configuration", baud_rate) + return False - Args: - baud_rate: The baud rate to configure (9600 or 115200) - """ - logger.info(f"SYS: Updating GPSD config with baud rate {baud_rate}") - try: - # Read the current config - with open("/etc/default/gpsd", "r") as f: - lines = f.readlines() - - # Update GPSD_OPTIONS line - updated_lines = [] - for line in lines: - if line.startswith("GPSD_OPTIONS="): - if baud_rate == 115200: - # NOTE: the space before -s in the next line is really needed - updated_lines.append('GPSD_OPTIONS=" -s 115200"\n') - else: - updated_lines.append('GPSD_OPTIONS=""\n') - else: - updated_lines.append(line) - - # Write the updated config to a temporary file - with open("/tmp/gpsd.conf", "w") as f: - f.writelines(updated_lines) - - # Copy the temp file to the actual location with sudo - sh.sudo("cp", "/tmp/gpsd.conf", "/etc/default/gpsd") - - # Restart GPSD service - sh.sudo("systemctl", "restart", "gpsd") - - logger.info("SYS: GPSD configuration updated and service restarted") - - except Exception as e: - logger.error(f"SYS: Error updating GPSD config: {e}") - raise +def update_gpsd_config(baud_rate: int) -> None: + """On NixOS, GPSD configuration is declarative. This is a no-op.""" + logger.info( + "SYS: GPSD config is managed declaratively on NixOS (baud=%d)", baud_rate + ) diff --git a/python/PiFinder/sys_utils_base.py b/python/PiFinder/sys_utils_base.py new file mode 100644 index 000000000..0366c13b3 --- /dev/null +++ b/python/PiFinder/sys_utils_base.py @@ -0,0 +1,149 @@ +""" +Abstract base for PiFinder system utilities. + +Defines the public API contract and shared implementations used by all +platform backends (Debian, NixOS, fake/testing). +""" + +import logging +import socket +import zipfile +from abc import ABC, abstractmethod +from pathlib import Path + +from PiFinder import utils + +BACKUP_PATH = str(utils.data_dir / "PiFinder_backup.zip") + +logger = logging.getLogger("SysUtils") + + +# --------------------------------------------------------------------------- +# Network ABC — shared + abstract methods +# --------------------------------------------------------------------------- + + +class NetworkBase(ABC): + """Base class for platform-specific Network implementations.""" + + _wifi_mode: str = "Client" + _wifi_networks: list = [] + + def get_host_name(self) -> str: + return socket.gethostname() + + def local_ip(self) -> str: + if self._wifi_mode == "AP": + return "10.10.10.1" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("192.255.255.255", 1)) + ip = s.getsockname()[0] + except Exception: + ip = "NONE" + finally: + s.close() + return ip + + def wifi_mode(self) -> str: + return self._wifi_mode + + def get_wifi_networks(self): + return self._wifi_networks + + def set_wifi_mode(self, mode: str) -> None: + if mode == self._wifi_mode: + return + if mode == "AP": + self._go_ap() + elif mode == "Client": + self._go_client() + self._wifi_mode = mode + + @abstractmethod + def _go_ap(self) -> None: ... + + @abstractmethod + def _go_client(self) -> None: ... + + @abstractmethod + def populate_wifi_networks(self) -> None: ... + + @abstractmethod + def delete_wifi_network(self, network_id) -> None: ... + + @abstractmethod + def add_wifi_network(self, ssid, key_mgmt, psk=None) -> None: ... + + @abstractmethod + def get_ap_name(self) -> str: ... + + @abstractmethod + def set_ap_name(self, ap_name: str) -> None: ... + + @abstractmethod + def get_connected_ssid(self) -> str: ... + + @abstractmethod + def set_host_name(self, hostname: str) -> None: ... + + +# --------------------------------------------------------------------------- +# Backup / restore (stdlib zipfile — portable across all platforms) +# --------------------------------------------------------------------------- + + +def remove_backup() -> None: + """Removes backup file.""" + path = Path(BACKUP_PATH) + if path.exists(): + path.unlink() + + +def backup_userdata() -> str: + """ + Back up userdata to a single zip file. + + Backs up: + config.json + observations.db + obslists/* + """ + remove_backup() + + files = [ + utils.data_dir / "config.json", + utils.data_dir / "observations.db", + ] + for p in utils.data_dir.glob("obslists/*"): + files.append(p) + + with zipfile.ZipFile(BACKUP_PATH, "w", zipfile.ZIP_DEFLATED) as zf: + for filepath in files: + filepath = Path(filepath) + if filepath.exists(): + zf.write(filepath, filepath.relative_to("/")) + + return BACKUP_PATH + + +def restore_userdata(zip_path: str) -> None: + """ + Restore userdata from a zip backup. + OVERWRITES existing data! + """ + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall("/") + + +# --------------------------------------------------------------------------- +# Service control (shared across Debian + NixOS) +# --------------------------------------------------------------------------- + + +def restart_pifinder() -> None: + """Restart the PiFinder service via systemctl.""" + import subprocess + + logger.info("SYS: Restarting PiFinder") + subprocess.run(["sudo", "systemctl", "restart", "pifinder"]) diff --git a/python/PiFinder/sys_utils_fake.py b/python/PiFinder/sys_utils_fake.py index efe6f1405..2afc27730 100644 --- a/python/PiFinder/sys_utils_fake.py +++ b/python/PiFinder/sys_utils_fake.py @@ -1,129 +1,80 @@ -import socket import logging -BACKUP_PATH = "/home/pifinder/PiFinder_data/PiFinder_backup.zip" +from PiFinder.sys_utils_base import ( + NetworkBase, + BACKUP_PATH, +) logger = logging.getLogger("SysUtils.Fake") -class Network: +class Network(NetworkBase): """ - Provides wifi network info + Fake network for testing/development. """ def __init__(self): - pass + self._wifi_mode = "Client" + self._wifi_networks: list = [] - def populate_wifi_networks(self): - """ - Parses wpa_supplicant.conf to get current config - """ + def populate_wifi_networks(self) -> None: pass - def get_wifi_networks(self): - return "" - - def delete_wifi_network(self, network_id): - """ - Immediately deletes a wifi network - """ + def delete_wifi_network(self, network_id) -> None: pass - def add_wifi_network(self, ssid, key_mgmt, psk=None): - """ - Add a wifi network - """ + def add_wifi_network(self, ssid, key_mgmt, psk=None) -> None: pass - def get_ap_name(self): + def get_ap_name(self) -> str: return "UNKN" - def set_ap_name(self, ap_name): + def set_ap_name(self, ap_name: str) -> None: pass - def get_host_name(self): - return socket.gethostname() - - def get_connected_ssid(self): - """ - Returns the SSID of the connected wifi network or - None if not connected or in AP mode - """ - return "UNKN" - - def set_host_name(self, hostname): - if hostname == self.get_host_name(): - return - - def wifi_mode(self): + def get_connected_ssid(self) -> str: return "UNKN" - def set_wifi_mode(self, mode): + def set_host_name(self, hostname: str) -> None: pass - def local_ip(self): - return "NONE" + def _go_ap(self) -> None: + logger.info("SYS: Fake switching to AP") + def _go_client(self) -> None: + logger.info("SYS: Fake switching to Client") -def remove_backup(): - """ - Removes backup file - """ - pass +def remove_backup() -> None: + pass -def backup_userdata(): - """ - Back up userdata to a single zip file for later - restore. Returns the path to the zip file. - Backs up: - config.json - observations.db - obslist/* - """ +def backup_userdata() -> str: return BACKUP_PATH -def restore_userdata(zip_path): - """ - Compliment to backup_userdata - restores userdata - OVERWRITES existing data! - """ +def restore_userdata(zip_path) -> None: pass -def shutdown(): - """ - shuts down the Pi - """ +def shutdown() -> None: logger.info("SYS: Initiating Shutdown") - return True -def update_software(): - """ - Uses systemctl to git pull and then restart - service - """ - logger.info("SYS: Running update") +def update_software(ref: str = "release"): + logger.info("SYS: Running update (ref=%s)", ref) return True -def restart_pifinder(): - """ - Uses systemctl to restart the PiFinder - service - """ +def get_upgrade_progress() -> dict: + return {"phase": "", "done": 0, "total": 0, "percent": 0} + + +def restart_pifinder() -> None: logger.info("SYS: Restarting PiFinder") - return True -def restart_system(): - """ - Restarts the system - """ +def restart_system() -> None: logger.info("SYS: Initiating System Restart") @@ -138,25 +89,33 @@ def go_wifi_cli(): def verify_password(username, password): - """ - Checks the provided password against the provided user - password - """ return True def change_password(username, current_password, new_password): - """ - Changes the PiFinder User password - """ return False +def get_camera_type() -> list[str]: + return ["imx462"] + + def switch_cam_imx477() -> None: logger.info("SYS: Switching cam to imx477") - logger.info('sh.sudo("python", "-m", "PiFinder.switch_camera", "imx477")') def switch_cam_imx296() -> None: logger.info("SYS: Switching cam to imx296") - logger.info('sh.sudo("python", "-m", "PiFinder.switch_camera", "imx296")') + + +def switch_cam_imx462() -> None: + logger.info("SYS: Switching cam to imx462") + + +def check_and_sync_gpsd_config(baud_rate: int) -> bool: + logger.info("SYS: Checking GPSD config for baud rate %d (fake)", baud_rate) + return False + + +def update_gpsd_config(baud_rate: int) -> None: + logger.info("SYS: Updating GPSD config with baud rate %d (fake)", baud_rate) diff --git a/python/PiFinder/ui/base.py b/python/PiFinder/ui/base.py index 53a0f0363..85ed1b83e 100644 --- a/python/PiFinder/ui/base.py +++ b/python/PiFinder/ui/base.py @@ -25,6 +25,69 @@ def _(a) -> Any: return a +class RotatingInfoDisplay: + """Alternates between constellation and SQM with cross-fade animation.""" + + def __init__(self, shared_state, interval=3.0, fade_speed=0.15): + self.shared_state = shared_state + self.interval = interval + self.fade_speed = fade_speed + self.show_sqm = False + self.last_switch = time.time() + self.progress = 1.0 # 1.0 = stable, <1.0 = transitioning + + def _get_text(self, use_sqm): + if use_sqm: + sqm = self.shared_state.sqm() + return f"{sqm.value:.1f}" if sqm and sqm.value else "---" + else: + sol = self.shared_state.solution() + return sol.get("constellation", "---") if sol else "---" + + def update(self): + """Update state, returns (current_text, previous_text, progress).""" + now = time.time() + if now - self.last_switch >= self.interval: + self.show_sqm = not self.show_sqm + self.last_switch = now + self.progress = 0.0 + if self.progress < 1.0: + self.progress = min(1.0, self.progress + self.fade_speed) + return ( + self._get_text(self.show_sqm), + self._get_text(not self.show_sqm), + self.progress, + ) + + def draw(self, draw, x, y, font, colors, max_brightness=255, inverted=False): + """Draw with cross-fade animation. inverted=True for dark text on light bg.""" + current, previous, progress = self.update() + if progress < 1.0: + fade_out = progress < 0.5 + t = progress * 2 if fade_out else (progress - 0.5) * 2 + if inverted: + brightness = ( + int(max_brightness * t) + if fade_out + else int(max_brightness * (1 - t)) + ) + else: + brightness = ( + int(max_brightness * (1 - t)) + if fade_out + else int(max_brightness * t) + ) + text = previous if fade_out else current + draw.text((x, y), text, font=font, fill=colors.get(brightness)) + else: + draw.text( + (x, y), + current, + font=font, + fill=colors.get(0 if inverted else max_brightness), + ) + + class UIModule: __title__ = "BASE" __help_name__ = "" @@ -97,6 +160,9 @@ def __init__( # anim timer stuff self.last_update_time = time.time() + # Rotating info: alternates between constellation and SQM value + self._rotating_display = RotatingInfoDisplay(self.shared_state) + def active(self): """ Called when a module becomes active @@ -196,6 +262,29 @@ def message(self, message, timeout: float = 2, size=(5, 44, 123, 84)): self.ui_state.set_message_timeout(timeout + time.time()) + def _draw_titlebar_rotating_info(self, x, y, fg): + """Draw rotating constellation/SQM in title bar (dark text on gray bg).""" + self._rotating_display.draw( + self.draw, + x, + y, + self.fonts.bold.font, + self.colors, + max_brightness=64, + inverted=True, + ) + + def draw_rotating_info(self, x=10, y=92, font=None): + """Draw rotating constellation/SQM display with cross-fade.""" + self._rotating_display.draw( + self.draw, + x, + y, + font or self.fonts.bold.font, + self.colors, + max_brightness=255, + ) + def screen_update(self, title_bar=True, button_hints=True) -> None: """ called to trigger UI updates @@ -267,13 +356,11 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: ) if len(self.title) < 9: - # draw the constellation - constellation = solution["constellation"] - self.draw.text( - (self.display_class.resX * 0.54, 1), - constellation, - font=self.fonts.bold.font, - fill=fg if self._unmoved else self.colors.get(32), + # Draw rotating constellation/SQM wheel (replaces static constellation) + self._draw_titlebar_rotating_info( + x=int(self.display_class.resX * 0.54), + y=1, + fg=fg if self._unmoved else self.colors.get(32), ) else: # no solve yet.... diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index 8c1840442..00eb426d5 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -201,21 +201,7 @@ def switch_cam_imx462(ui_module: UIModule) -> None: def get_camera_type(ui_module: UIModule) -> list[str]: - cam_id = "000" - - # read config.txt into a list - with open("/boot/config.txt", "r") as boot_in: - boot_lines = list(boot_in) - - # Look for the line without a comment... - for line in boot_lines: - if line.startswith("dtoverlay=imx"): - cam_id = line[10:16] - # imx462 uses imx290 driver - if cam_id == "imx290": - cam_id = "imx462" - - return [cam_id] + return sys_utils.get_camera_type() def switch_language(ui_module: UIModule) -> None: diff --git a/python/PiFinder/ui/console.py b/python/PiFinder/ui/console.py index 2c3471ecf..c349ee50a 100644 --- a/python/PiFinder/ui/console.py +++ b/python/PiFinder/ui/console.py @@ -35,7 +35,7 @@ def __init__(self, *args, **kwargs): self.dirty = True self.welcome = True - # load welcome image to screen + # Load welcome image as startup backdrop root_dir = os.path.realpath( os.path.join(os.path.dirname(__file__), "..", "..", "..") ) @@ -87,6 +87,13 @@ def write(self, line): self.scroll_offset = 0 self.dirty = True + def finish_startup(self): + """End the startup splash phase and clear the welcome backdrop.""" + self.welcome = False + self.clear_screen() + self.dirty = True + self.update() + def active(self): self.welcome = False self.dirty = True diff --git a/python/PiFinder/ui/exp_sweep.py b/python/PiFinder/ui/exp_sweep.py index 77115bda9..00ef7398a 100644 --- a/python/PiFinder/ui/exp_sweep.py +++ b/python/PiFinder/ui/exp_sweep.py @@ -45,8 +45,8 @@ def __init__(self, *args, **kwargs): self.start_time = None self.sweep_dir = None # Track the actual sweep directory self.initial_file_count = None # Files that existed before we started - self.total_images = 100 # Expected number of images - self.estimated_duration = 240 # 4 minutes estimated + self.total_images = 20 # Expected number of images + self.estimated_duration = 60 # ~1 minute estimated def active(self): """Called when module becomes active""" @@ -153,13 +153,13 @@ def _draw_confirm(self): self.draw.text( (10, 65), - "100 images", + "20 images", font=self.fonts.base.font, fill=self.colors.get(192), ) self.draw.text( (10, 77), - "~4 minutes", + "~1 minute", font=self.fonts.base.font, fill=self.colors.get(192), ) diff --git a/python/PiFinder/ui/fonts.py b/python/PiFinder/ui/fonts.py index 4ae93464a..664d2ef39 100644 --- a/python/PiFinder/ui/fonts.py +++ b/python/PiFinder/ui/fonts.py @@ -43,7 +43,7 @@ def __init__( huge_size=35, screen_width=128, ): - font_path = str(Path(Path.cwd(), "../fonts")) + font_path = str(Path(__file__).parent.parent.parent.parent / "fonts") boldttf = str(Path(font_path, "RobotoMonoNerdFontMono-Bold.ttf")) regularttf = str(Path(font_path, "RobotoMonoNerdFontMono-Regular.ttf")) diff --git a/python/PiFinder/ui/lm_entry.py b/python/PiFinder/ui/lm_entry.py new file mode 100644 index 000000000..71dbb5344 --- /dev/null +++ b/python/PiFinder/ui/lm_entry.py @@ -0,0 +1,208 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Limiting Magnitude Entry UI + +Allows user to enter a fixed limiting magnitude value (e.g., 14.5) +with one decimal place precision. +""" + +from PIL import Image, ImageDraw +from PiFinder.ui.base import UIModule + + +class UILMEntry(UIModule): + """ + UI for entering limiting magnitude value + + Controls: + - 0-9: Enter digits + - Up/Down: Move cursor left/right between digits + - -: Delete digit (backspace) + - Right: Accept (save and return) + - Left: Cancel (discard and return) + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.config_option = self.item_definition.get( + "config_option", "obj_chart_lm_fixed" + ) + + # Start with placeholder/blank value for user to fill in + # Store as string for editing: format is " . " (spaces for digits) + self.digits = [" ", " ", ".", " "] # Two digits, decimal, one digit + + # Cursor position (0-3 for "XX.X" format) + # Position 2 is the decimal point (not editable) + self.cursor_pos = 0 + + self.width = 128 + self.height = 128 + self.screen = Image.new("RGB", (self.width, self.height), "black") + + def update(self, force=False): + """Render the LM entry screen""" + self.screen = Image.new("RGB", (self.width, self.height), "black") + draw = ImageDraw.Draw(self.screen) + + # Title + title = "Set Limiting Mag" + title_bbox = draw.textbbox((0, 0), title, font=self.fonts.base.font) + title_width = title_bbox[2] - title_bbox[0] + title_x = (self.width - title_width) // 2 + draw.text( + (title_x, 5), title, font=self.fonts.base.font, fill=self.colors.get(128) + ) + + # Display current value with cursor + value_y = (self.height - self.fonts.large.height) // 2 - 10 + + # Use fixed-width spacing for consistent alignment + char_width = self.fonts.large.width # Fixed character width + total_width = char_width * len(self.digits) + + # Center the entire value + start_x = (self.width - total_width) // 2 + + # Draw each character + for i, char in enumerate(self.digits): + x_pos = start_x + (i * char_width) + + # Display character or underscore for empty + display_char = char if char != " " else "_" + + # Highlight cursor position (but not the decimal point) + if i == self.cursor_pos and char != ".": + # Draw filled rectangle background + draw.rectangle( + [ + x_pos - 2, + value_y - 2, + x_pos + char_width + 2, + value_y + self.fonts.large.height + 2, + ], + fill=self.colors.get(255), + outline=self.colors.get(255), + width=2, + ) + # Draw text in inverse color + text_color = self.colors.get(0) + else: + text_color = self.colors.get(255) + + draw.text( + (x_pos, value_y), + display_char, + font=self.fonts.large.font, + fill=text_color, + ) + + # Icons (matching radec_entry style) + arrow_icons = "󰹺" + left_icon = "" + right_icon = "" + + # Legends at bottom (two lines) + bar_y = self.height - (self.fonts.base.height * 2) - 4 + + # Draw separator line + draw.line( + [(2, bar_y), (self.width - 2, bar_y)], fill=self.colors.get(128), width=1 + ) + + # Line 1: Navigation + line1 = f"{arrow_icons}Nav" + draw.text( + (2, bar_y + 2), line1, font=self.fonts.base.font, fill=self.colors.get(128) + ) + + # Line 2: Actions + line2 = f"{left_icon}Cancel {right_icon}Save -Del" + draw.text( + (2, bar_y + 12), line2, font=self.fonts.base.font, fill=self.colors.get(128) + ) + + return self.screen, None + + def key_up(self): + """Move cursor left""" + if self.cursor_pos > 0: + self.cursor_pos -= 1 + # Skip over decimal point + if self.cursor_pos == 2: + self.cursor_pos = 1 + return True + + def key_down(self): + """Move cursor right""" + if self.cursor_pos < 3: + self.cursor_pos += 1 + # Skip over decimal point + if self.cursor_pos == 2: + self.cursor_pos = 3 + return True + + def key_number(self, number): + """Enter digit 0-9 at cursor position""" + if 0 <= number <= 9: + # Don't allow editing the decimal point + if self.cursor_pos == 2: + return False + + # Replace digit at cursor position + self.digits[self.cursor_pos] = str(number) + + # Move cursor to next position after entering digit + if self.cursor_pos < 3: + self.cursor_pos += 1 + # Skip over decimal point + if self.cursor_pos == 2: + self.cursor_pos = 3 + + return True + return False + + def key_minus(self): + """Delete digit at cursor position (replace with space)""" + if self.cursor_pos == 2: + # Can't delete decimal point + return False + + # Replace with space (blank) + self.digits[self.cursor_pos] = " " + return True + + def key_left(self): + """Cancel - return without saving""" + return True + + def key_right(self): + """Accept - save value and exit""" + try: + value_str = "".join(self.digits).strip() + + if value_str.replace(".", "").replace(" ", "") == "": + return False + + value_str = value_str.replace(" ", "0") + final_value = float(value_str) + + if final_value < 5.0 or final_value > 20.0: + return False + + self.config_object.set_option(self.config_option, final_value) + self.config_object.set_option("obj_chart_lm_mode", "fixed") + + # Exit: LM entry -> LM menu -> back to chart + if self.remove_from_stack: + self.remove_from_stack() + self.remove_from_stack() + return True + except ValueError: + return False + + def active(self): + """Called when screen becomes active""" + return False diff --git a/python/PiFinder/ui/log.py b/python/PiFinder/ui/log.py index a095aca17..e7e867f9f 100644 --- a/python/PiFinder/ui/log.py +++ b/python/PiFinder/ui/log.py @@ -6,7 +6,7 @@ """ -from PiFinder import cat_images +from PiFinder.object_images import get_display_image from PiFinder import obslog from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder.ui.base import UIModule @@ -47,8 +47,22 @@ def __init__(self, *args, **kwargs): roll = 0 if solution: roll = solution["Roll"] - self.object_image = cat_images.get_display_image( - self.object, "POSS", 1, roll, self.display_class, burn_in=False + + # Get chart generator singleton for Gaia chart support + from PiFinder.object_images.gaia_chart import get_gaia_chart_generator + + chart_gen = get_gaia_chart_generator(self.config_object, self.shared_state) + + self.object_image = get_display_image( + self.object, + "POSS", + 1, + roll, + self.display_class, + burn_in=False, + config_object=self.config_object, + shared_state=self.shared_state, + chart_generator=chart_gen, ) self.menu_index = 1 # Observability diff --git a/python/PiFinder/ui/marking_menus.py b/python/PiFinder/ui/marking_menus.py index 62481dcd9..19391cffc 100644 --- a/python/PiFinder/ui/marking_menus.py +++ b/python/PiFinder/ui/marking_menus.py @@ -11,7 +11,7 @@ from PIL import Image, ImageDraw, ImageChops from PiFinder.ui.fonts import Font -from dataclasses import dataclass +from dataclasses import dataclass, field from PiFinder.displays import DisplayBase @@ -35,7 +35,9 @@ class MarkingMenu: down: MarkingMenuOption left: MarkingMenuOption right: MarkingMenuOption - up: MarkingMenuOption = MarkingMenuOption(label="HELP") + up: MarkingMenuOption = field( + default_factory=lambda: MarkingMenuOption(label="HELP") + ) def select_none(self): self.up.selected = False diff --git a/python/PiFinder/ui/menu_manager.py b/python/PiFinder/ui/menu_manager.py index 8db58dc32..4e8fcc278 100644 --- a/python/PiFinder/ui/menu_manager.py +++ b/python/PiFinder/ui/menu_manager.py @@ -146,7 +146,7 @@ def __init__( def screengrab(self): self.ss_count += 1 - filename = f"{self.stack[-1].__uuid__}_{self.ss_count :0>3}_{self.stack[-1].title.replace('/','-')}" + filename = f"{self.stack[-1].__uuid__}_{self.ss_count:0>3}_{self.stack[-1].title.replace('/', '-')}" ss_imagepath = self.ss_path + f"/{filename}.png" ss = self.shared_state.screen().copy() ss.save(ss_imagepath) diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index 75dfecb0d..1be118896 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -14,6 +14,7 @@ from PiFinder.ui.equipment import UIEquipment from PiFinder.ui.location_list import UILocationList from PiFinder.ui.radec_entry import UIRADecEntry +from PiFinder.ui.lm_entry import UILMEntry import PiFinder.ui.callbacks as callbacks @@ -48,7 +49,6 @@ def _(key: str) -> Any: "name": _("Align"), "class": UIAlign, "stateful": True, - "preload": True, }, { "name": _("GPS Status"), @@ -60,7 +60,6 @@ def _(key: str) -> Any: "name": _("Chart"), "class": UIChart, "stateful": True, - "preload": True, }, { "name": _("Objects"), @@ -823,6 +822,117 @@ def _(key: str) -> Any: }, ], }, + { + "name": _("Obj Chart..."), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_settings", + "items": [ + { + "name": _("Crosshair"), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_crosshair", + "config_option": "obj_chart_crosshair", + "items": [ + { + "name": _("Off"), + "value": "off", + }, + { + "name": _("On"), + "value": "on", + }, + { + "name": _("Pulse"), + "value": "pulse", + }, + { + "name": _("Fade"), + "value": "fade", + }, + ], + }, + { + "name": _("Style"), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_style", + "config_option": "obj_chart_crosshair_style", + "items": [ + { + "name": _("Simple"), + "value": "simple", + }, + { + "name": _("Circle"), + "value": "circle", + }, + { + "name": _("Bullseye"), + "value": "bullseye", + }, + { + "name": _("Brackets"), + "value": "brackets", + }, + { + "name": _("Dots"), + "value": "dots", + }, + { + "name": _("Cross"), + "value": "cross", + }, + ], + }, + { + "name": _("Speed"), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_speed", + "config_option": "obj_chart_crosshair_speed", + "items": [ + { + "name": _("Fast (1s)"), + "value": "1.0", + }, + { + "name": _("Medium (2s)"), + "value": "2.0", + }, + { + "name": _("Slow (3s)"), + "value": "3.0", + }, + { + "name": _("Very Slow (4s)"), + "value": "4.0", + }, + ], + }, + { + "name": _("Set LM"), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_lm", + "config_option": "obj_chart_lm_mode", + "items": [ + { + "name": _("Auto"), + "value": "auto", + }, + { + "name": _("Fixed"), + "value": "fixed", + "class": UILMEntry, + "mode": "lm_entry", + "config_option": "obj_chart_lm_fixed", + }, + ], + }, + ], + }, { "name": _("Camera Exp"), "class": UITextMenu, diff --git a/python/PiFinder/ui/object_details.py b/python/PiFinder/ui/object_details.py index edf13f9d7..68b844b1a 100644 --- a/python/PiFinder/ui/object_details.py +++ b/python/PiFinder/ui/object_details.py @@ -6,7 +6,9 @@ """ -from PiFinder import cat_images +from PiFinder.object_images import get_display_image +from PiFinder.object_images.image_base import ImageType +from PiFinder.object_images.star_catalog import CatalogState from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder.obj_types import OBJ_TYPES from PiFinder.ui.align import align_on_radec @@ -23,15 +25,76 @@ import functools from PiFinder.db.observations_db import ObservationsDatabase +from PIL import Image, ImageDraw +import logging import numpy as np import time +logger = logging.getLogger("PiFinder.UIObjectDetails") # Constants for display modes DM_DESC = 0 # Display mode for description DM_LOCATE = 1 # Display mode for LOCATE -DM_POSS = 2 # Display mode for POSS -DM_SDSS = 3 # Display mode for SDSS +DM_IMAGE = 2 # Display mode for images (POSS or Gaia chart) + + +class EyepieceInput: + """ + Handles custom eyepiece focal length input (1-99mm) + """ + + def __init__(self): + self.focal_length_mm = 0 + self.digits = [] + self.last_input_time = 0 + + def append_digit(self, digit: int) -> bool: + """ + Append a digit to the input. + Returns True if input is complete (2 digits or auto-timeout) + """ + import time + + self.digits.append(digit) + self.last_input_time = time.time() + + # Update focal length + if len(self.digits) == 1: + self.focal_length_mm = digit + else: + self.focal_length_mm = self.digits[0] * 10 + self.digits[1] + + # Auto-complete after 2 digits + return len(self.digits) >= 2 + + def is_complete(self) -> bool: + """Check if input has timed out (1.5 seconds)""" + import time + + if len(self.digits) == 0: + return False + if len(self.digits) >= 2: + return True + return time.time() - self.last_input_time > 1.5 + + def reset(self): + """Clear the input""" + self.digits = [] + self.focal_length_mm = 0 + self.last_input_time = 0 + + def has_input(self) -> bool: + """Check if any digits have been entered""" + return len(self.digits) > 0 + + def __str__(self): + """Return display string for popup""" + if len(self.digits) == 0: + return "__" + elif len(self.digits) == 1: + return f"{self.digits[0]}_" + else: + return f"{self.digits[0]}{self.digits[1]}" class UIObjectDetails(UIModule): @@ -48,13 +111,24 @@ def __init__(self, *args, **kwargs): self.screen_direction = self.config_object.get_option("screen_direction") self.mount_type = self.config_object.get_option("mount_type") + self._chart_gen = None # Cached chart generator instance self.object = self.item_definition["object"] self.object_list = self.item_definition["object_list"] self.object_display_mode = DM_LOCATE self.object_image = None + self._chart_generator = None # Active generator for progressive chart updates + self._is_showing_loading_chart = ( + False # Track if showing "Loading..." for Gaia chart + ) + self._force_gaia_chart = ( + False # Toggle: force Gaia chart even if POSS image exists + ) + self.eyepiece_input = EyepieceInput() # Custom eyepiece input handler + self.eyepiece_input_display = False # Show eyepiece input popup + self._custom_eyepiece = None # Reference to custom eyepiece object in equipment list (None = not active) - # Marking Menu - Just default help for now - self.marking_menu = MarkingMenu( + # Default Marking Menu + self._default_marking_menu = MarkingMenu( left=MarkingMenuOption(), right=MarkingMenuOption(), down=MarkingMenuOption( @@ -68,6 +142,14 @@ def __init__(self, *args, **kwargs): ), ) + # Gaia Chart Marking Menu - Settings access + self._gaia_chart_marking_menu = MarkingMenu( + up=MarkingMenuOption(label=_("SETTINGS"), menu_jump="obj_chart_settings"), + right=MarkingMenuOption(label=_("CROSS"), menu_jump="obj_chart_crosshair"), + down=MarkingMenuOption(label=_("STYLE"), menu_jump="obj_chart_style"), + left=MarkingMenuOption(label=_("LM"), menu_jump="obj_chart_lm"), + ) + # Used for displaying obsevation counts self.observations_db = ObservationsDatabase() @@ -107,6 +189,15 @@ def __init__(self, *args, **kwargs): self.active() # fill in activation time self.update_object_info() + @property + def marking_menu(self): + """ + Return appropriate marking menu based on current view mode + """ + if self._is_gaia_chart: + return self._gaia_chart_marking_menu + return self._default_marking_menu + def _layout_designator(self): """ Generates designator layout object @@ -144,6 +235,13 @@ def update_object_info(self): """ Generates object text and loads object images """ + # logger.info(f">>> update_object_info() called for {self.object.display_name if self.object else 'None'}") + + # CRITICAL: Clear loading flag at START to prevent recursive update() calls + # during generator consumption. If we don't do this, calling self.update() + # while consuming yields will trigger update() -> update_object_info() recursion. + self._is_showing_loading_chart = False + # Title... self.title = self.object.display_name @@ -217,19 +315,85 @@ def update_object_info(self): if solution: roll = solution["Roll"] + # Calculate magnification and TFOV using current active eyepiece (custom or configured) magnification = self.config_object.equipment.calc_magnification() - self.object_image = cat_images.get_display_image( + tfov = self.config_object.equipment.calc_tfov() + eyepiece_text = str(self.config_object.equipment.active_eyepiece) + + if self._custom_eyepiece is not None: + logger.info( + f">>> Using custom eyepiece: {eyepiece_text}, tfov={tfov}, mag={magnification}" + ) + else: + logger.info( + f">>> Using configured eyepiece: {eyepiece_text}, tfov={tfov}, mag={magnification}" + ) + + # Get or create chart generator (owned by UI layer) + logger.info(">>> Getting chart generator...") + chart_gen = self._get_gaia_chart_generator() + logger.info( + f">>> Chart generator obtained, state: {chart_gen.get_catalog_state() if chart_gen else 'None'}" + ) + + logger.info( + f">>> Calling get_display_image with force_gaia_chart={self._force_gaia_chart}" + ) + + # get_display_image returns either an image directly (POSS) or a generator (Gaia chart) + result = get_display_image( self.object, - str(self.config_object.equipment.active_eyepiece), - self.config_object.equipment.calc_tfov(), + eyepiece_text, + tfov, roll, self.display_class, - burn_in=self.object_display_mode in [DM_POSS, DM_SDSS], + burn_in=self.object_display_mode == DM_IMAGE, magnification=magnification, + config_object=self.config_object, + shared_state=self.shared_state, + chart_generator=chart_gen, # Pass our chart generator to object_images + force_chart=self._force_gaia_chart, # Toggle state + ) + + # Check if it's a generator (progressive Gaia chart) or direct image (POSS) + if hasattr(result, "__iter__") and hasattr(result, "__next__"): + # It's a generator - store it for progressive consumption by update() + logger.info( + ">>> get_display_image returned GENERATOR, storing for progressive updates..." + ) + self._chart_generator = result + self.object_image = None # Will be set by first yield + else: + # Direct image (POSS) + logger.info(f">>> get_display_image returned direct image: {type(result)}") + self._chart_generator = None + self.object_image = result + + logger.info( + f">>> update_object_info() complete, self.object_image is now: {type(self.object_image)}" + ) + + # Track if we're showing a "Loading..." placeholder for chart + self._is_showing_loading_chart = ( + self.object_image is not None + and hasattr(self.object_image, "image_type") + and self.object_image.image_type == ImageType.LOADING + ) + + @property + def _is_gaia_chart(self): + """Check if currently displaying a Gaia chart""" + return ( + self.object_image is not None + and hasattr(self.object_image, "image_type") + and self.object_image.image_type == ImageType.GAIA_CHART ) def active(self): self.activation_time = time.time() + # Regenerate object info when returning to this screen + # This ensures config changes (like LM) are applied + self.update_object_info() def _check_catalog_initialized(self): code = self.object.catalog_code @@ -239,6 +403,320 @@ def _check_catalog_initialized(self): catalog = self.catalogs.get_catalog_by_code(code) return catalog and catalog.initialized + def _get_pulse_factor(self): + """ + Calculate current pulse factor for animations + Returns tuple: (pulse_factor, size_multiplier, color_intensity) + - pulse_factor: 0.0 to 1.0 sine wave + - size_multiplier: factor to multiply sizes by (0.6 to 1.0 for smoother animation) + - color_intensity: brightness value (48 to 128 for more visible change) + """ + import time + import numpy as np + + # Get pulse period from config (default 2.0 seconds) + pulse_period = float( + self.config_object.get_option("obj_chart_crosshair_speed", "2.0") + ) + + t = time.time() % pulse_period + # Sine wave for smooth pulsation (0.0 to 1.0 range) + pulse_factor = 0.5 + 0.5 * np.sin(2 * np.pi * t / pulse_period) + + # Size multiplier: 0.6 to 1.0 (smaller range, smoother looking) + size_multiplier = 0.6 + 0.4 * pulse_factor + + # Color intensity: 48 to 128 (brighter and more visible) + color_intensity = int(48 + 80 * pulse_factor) + + return pulse_factor, size_multiplier, color_intensity + + def _get_fade_factor(self): + """ + Calculate current fade factor for animations + Returns color_intensity that fades from 0 to 128 + - Crosshair stays at minimum size + - Only brightness changes + """ + import time + import numpy as np + + # Get fade period from config (default 2.0 seconds) + fade_period = float( + self.config_object.get_option("obj_chart_crosshair_speed", "2.0") + ) + + t = time.time() % fade_period + # Sine wave for smooth fading (0.0 to 1.0 range) + fade_factor = 0.5 + 0.5 * np.sin(2 * np.pi * t / fade_period) + + # Color intensity: 0 to 128 (fade from invisible to half brightness) + # Use round instead of int for better distribution + color_intensity = round(128 * fade_factor) + + return color_intensity + + def _draw_crosshair_simple(self, mode="off"): + """ + Draw simple crosshair with 4 lines and center gap using inverted pixels + + Args: + mode: Animation mode - "off", "pulse", or "fade" (fade not supported for inverted pixels) + """ + import numpy as np + + width, height = self.display_class.resolution + cx, cy = int(width / 2.0), int(height / 2.0) + + if mode == "pulse": + pulse_factor, _, _ = self._get_pulse_factor() + # Size pulsates from 7 down to 4 pixels (inverted - more steps) + outer = int( + 7.0 - (3.0 * pulse_factor) + ) # 7.0 down to 4.0 (smooth animation) + else: + # Fixed size (fade mode not supported for inverted pixels) + outer = 5 + + inner = 3 # Fixed gap (slightly larger center hole) + + # Get screen buffer as numpy array for pixel manipulation + pixels = np.array(self.screen) + + # Invert crosshair pixels (red channel only) for visibility + # Horizontal lines (left and right of center) + for x in range(max(0, cx - outer), max(0, cx - inner)): + if 0 <= x < width and 0 <= cy < height: + pixels[cy, x, 0] = 255 - pixels[cy, x, 0] + for x in range(min(width, cx + inner), min(width, cx + outer)): + if 0 <= x < width and 0 <= cy < height: + pixels[cy, x, 0] = 255 - pixels[cy, x, 0] + + # Vertical lines (top and bottom of center) + for y in range(max(0, cy - outer), max(0, cy - inner)): + if 0 <= y < height and 0 <= cx < width: + pixels[y, cx, 0] = 255 - pixels[y, cx, 0] + for y in range(min(height, cy + inner), min(height, cy + outer)): + if 0 <= y < height and 0 <= cx < width: + pixels[y, cx, 0] = 255 - pixels[y, cx, 0] + + # Update screen buffer with inverted pixels + self.screen = Image.fromarray(pixels, mode="RGB") + # Re-create draw object since we replaced the image + self.draw = ImageDraw.Draw(self.screen) + + def _draw_crosshair_circle(self, mode="off"): + """ + Draw circle reticle + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + if mode == "pulse": + pulse_factor, _, color_intensity = self._get_pulse_factor() + radius = 8.0 - (4.0 * pulse_factor) # 8.0 down to 4.0 (smooth animation) + elif mode == "fade": + color_intensity = self._get_fade_factor() + radius = 4 # Fixed minimum size + else: + color_intensity = 64 + radius = 4 # Smaller fixed size + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + self.draw.ellipse(bbox, outline=marker_color, width=1) + + def _draw_crosshair_bullseye(self, mode="off"): + """ + Draw concentric circles (bullseye) + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + if mode == "pulse": + pulse_factor, _, color_intensity = self._get_pulse_factor() + # Pulsate from larger to smaller (smooth animation) + radii = [ + 4.0 - (2.0 * pulse_factor), + 8.0 - (4.0 * pulse_factor), + 12.0 - (6.0 * pulse_factor), + ] # 4→2, 8→4, 12→6 + elif mode == "fade": + color_intensity = self._get_fade_factor() + radii = [2, 4, 6] # Fixed minimum radii + else: + color_intensity = 64 + radii = [2, 4, 6] # Smaller fixed radii + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + for radius in radii: + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + self.draw.ellipse(bbox, outline=marker_color, width=1) + + def _draw_crosshair_brackets(self, mode="off"): + """ + Draw corner brackets (frame corners) + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = int(width / 2.0), int(height / 2.0) + + if mode == "pulse": + pulse_factor, _, color_intensity = self._get_pulse_factor() + size = int(8.0 - (4.0 * pulse_factor)) # 8.0 down to 4.0 (smooth animation) + length = int( + 5.0 - (2.0 * pulse_factor) + ) # 5.0 down to 3.0 (smooth animation) + elif mode == "fade": + color_intensity = self._get_fade_factor() + size = 4 # Fixed minimum size + length = 3 # Fixed minimum length + else: + color_intensity = 64 + size = 4 # Smaller distance from center to bracket corner + length = 3 # Shorter bracket arms + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + + # Top-left bracket + self.draw.line( + [cx - size, cy - size, cx - size + length, cy - size], + fill=marker_color, + width=1, + ) + self.draw.line( + [cx - size, cy - size, cx - size, cy - size + length], + fill=marker_color, + width=1, + ) + + # Top-right bracket + self.draw.line( + [cx + size - length, cy - size, cx + size, cy - size], + fill=marker_color, + width=1, + ) + self.draw.line( + [cx + size, cy - size, cx + size, cy - size + length], + fill=marker_color, + width=1, + ) + + # Bottom-left bracket + self.draw.line( + [cx - size, cy + size, cx - size + length, cy + size], + fill=marker_color, + width=1, + ) + self.draw.line( + [cx - size, cy + size - length, cx - size, cy + size], + fill=marker_color, + width=1, + ) + + # Bottom-right bracket + self.draw.line( + [cx + size - length, cy + size, cx + size, cy + size], + fill=marker_color, + width=1, + ) + self.draw.line( + [cx + size, cy + size - length, cx + size, cy + size], + fill=marker_color, + width=1, + ) + + def _draw_crosshair_dots(self, mode="off"): + """ + Draw four corner dots + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + if mode == "pulse": + pulse_factor, _, color_intensity = self._get_pulse_factor() + distance = 8.0 - (4.0 * pulse_factor) # 8 down to 4 (smooth animation) + dot_size = 3.0 - (1.5 * pulse_factor) # 3 down to 1 (smooth animation) + elif mode == "fade": + color_intensity = self._get_fade_factor() + distance = 4 # Fixed minimum distance + dot_size = 1 # Fixed minimum size + else: + color_intensity = 64 + distance = 4 # Smaller distance from center to dots + dot_size = 1 # Smaller dot radius + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + + # Four corner dots + positions = [ + (cx - distance, cy - distance), # Top-left + (cx + distance, cy - distance), # Top-right + (cx - distance, cy + distance), # Bottom-left + (cx + distance, cy + distance), # Bottom-right + ] + + for x, y in positions: + bbox = [x - dot_size, y - dot_size, x + dot_size, y + dot_size] + self.draw.ellipse(bbox, fill=marker_color) + + def _draw_crosshair_cross(self, mode="off"): + """ + Draw full cross (lines extend across entire screen) + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + if mode == "pulse": + pulse_factor, _, color_intensity = self._get_pulse_factor() + elif mode == "fade": + color_intensity = self._get_fade_factor() + else: + color_intensity = 64 + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + + # Horizontal line + self.draw.line([0, cy, width, cy], fill=marker_color, width=1) + # Vertical line + self.draw.line([cx, 0, cx, height], fill=marker_color, width=1) + + def _draw_fov_circle(self): + """ + Draw FOV circle to show eyepiece field of view boundary + Matches the POSS view circular crop + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + # Use slightly smaller than screen to show the boundary + # Screen is typically 128x128, so use radius that fits within screen + radius = min(width, height) / 2.0 - 2 # Leave 2 pixel margin + + # Draw subtle circle + marker_color = self.colors.get(32) # Very dim, just to show boundary + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + self.draw.ellipse(bbox, outline=marker_color, width=1) + def _render_pointing_instructions(self): # Pointing Instructions if self.shared_state.solution() is None: @@ -354,14 +832,14 @@ def _render_pointing_instructions(self): if point_az < 1: self.draw.text( self.az_anchor, - f"{az_arrow}{point_az : >5.2f}", + f"{az_arrow}{point_az: >5.2f}", font=self.fonts.huge.font, fill=self.colors.get(indicator_color), ) else: self.draw.text( self.az_anchor, - f"{az_arrow}{point_az : >5.1f}", + f"{az_arrow}{point_az: >5.1f}", font=self.fonts.huge.font, fill=self.colors.get(indicator_color), ) @@ -382,32 +860,166 @@ def _render_pointing_instructions(self): if point_alt < 1: self.draw.text( self.alt_anchor, - f"{alt_arrow}{point_alt : >5.2f}", + f"{alt_arrow}{point_alt: >5.2f}", font=self.fonts.huge.font, fill=self.colors.get(indicator_color), ) else: self.draw.text( self.alt_anchor, - f"{alt_arrow}{point_alt : >5.1f}", + f"{alt_arrow}{point_alt: >5.1f}", font=self.fonts.huge.font, fill=self.colors.get(indicator_color), ) + def _get_gaia_chart_generator(self): + """Get the global chart generator singleton""" + from PiFinder.object_images.gaia_chart import get_gaia_chart_generator + import logging + + logger = logging.getLogger("ObjectDetails") + + chart_gen = get_gaia_chart_generator(self.config_object, self.shared_state) + logger.info(f">>> _get_gaia_chart_generator returning: {chart_gen}") + return chart_gen + + def _apply_custom_eyepiece(self): + """Apply the custom eyepiece focal length and update display""" + from PiFinder.equipment import Eyepiece + + # Capture the focal length before resetting + focal_length = self.eyepiece_input.focal_length_mm + + # Reset input state FIRST to prevent recursion in update() + self.eyepiece_input.reset() + self.eyepiece_input_display = False + + # Apply the custom eyepiece + if focal_length > 0: + logger.info(f">>> Applying custom eyepiece: {focal_length}mm") + + # Remove old custom eyepiece if it exists + if ( + self._custom_eyepiece is not None + and self._custom_eyepiece in self.config_object.equipment.eyepieces + ): + logger.info( + f">>> Removing old custom eyepiece: {self._custom_eyepiece}" + ) + self.config_object.equipment.eyepieces.remove(self._custom_eyepiece) + + # Create and add new custom eyepiece + self._custom_eyepiece = Eyepiece( + make="Custom", + name=f"{focal_length}mm", + focal_length_mm=focal_length, + afov=50, # Default AFOV for custom eyepiece + field_stop=0, + ) + self.config_object.equipment.eyepieces.append(self._custom_eyepiece) + self.config_object.equipment.active_eyepiece_index = ( + len(self.config_object.equipment.eyepieces) - 1 + ) + logger.info( + f">>> Added custom eyepiece to equipment list: {self._custom_eyepiece}" + ) + + self.update_object_info() + self.update() + else: + logger.warning(f">>> Invalid focal length: {focal_length}mm, not applying") + def update(self, force=True): - # Clear Screen - self.clear_screen() + import logging + + logger = logging.getLogger("ObjectDetails") + + # Check for eyepiece input timeout + if self.eyepiece_input_display and self.eyepiece_input.is_complete(): + # Auto-complete the input + self._apply_custom_eyepiece() + + # If we have a chart generator, consume one yield to get the next progressive update + if hasattr(self, "_chart_generator") and self._chart_generator is not None: + try: + next_image = next(self._chart_generator) + # logger.debug(f">>> update(): Consumed next chart yield: {type(next_image)}") + self.object_image = next_image + + except StopIteration: + logger.info(">>> update(): Chart generator exhausted") + self._chart_generator = None # Generator exhausted + + # Update loading flag based on current image + if self.object_image is not None: + self._is_showing_loading_chart = ( + hasattr(self.object_image, "image_type") + and self.object_image.image_type == ImageType.LOADING + ) - # paste image - if self.object_display_mode in [DM_POSS, DM_SDSS]: + # Check if we're showing "Loading..." for a Gaia chart + # and if catalog is now ready, regenerate the image + if self._is_showing_loading_chart: + try: + # Use cached chart generator to preserve catalog state + chart_gen = self._get_gaia_chart_generator() + state = chart_gen.get_catalog_state() + # logger.debug(f">>> Update check: catalog state = {state}") + + if state == CatalogState.READY: + # Catalog ready! Regenerate display + # logger.info(">>> Catalog READY! Regenerating image...") + self._is_showing_loading_chart = False + self.update_object_info() + except Exception as e: + logger.error(f">>> Update check failed: {e}", exc_info=True) + pass + # Clear screen + self.draw.rectangle( + [0, 0, self.display_class.resX, self.display_class.resY], + fill=self.colors.get(0), + ) + + if self.object_display_mode == DM_IMAGE and self.object_image: self.screen.paste(self.object_image) + # If showing Gaia chart, draw crosshair based on config + is_chart = ( + self.object_image is not None + and hasattr(self.object_image, "image_type") + and self.object_image.image_type == ImageType.GAIA_CHART + ) + if is_chart: + crosshair_mode = self.config_object.get_option("obj_chart_crosshair") + crosshair_style = self.config_object.get_option( + "obj_chart_crosshair_style" + ) + + if crosshair_mode != "off": + style_methods = { + "simple": self._draw_crosshair_simple, + "circle": self._draw_crosshair_circle, + "bullseye": self._draw_crosshair_bullseye, + "brackets": self._draw_crosshair_brackets, + "dots": self._draw_crosshair_dots, + "cross": self._draw_crosshair_cross, + } + + draw_method = style_methods.get( + crosshair_style, self._draw_crosshair_simple + ) + draw_method(mode=crosshair_mode) + + if crosshair_mode in ["pulse", "fade"]: + pass + if self.object_display_mode == DM_DESC or self.object_display_mode == DM_LOCATE: # catalog and entry field i.e. NGC-311 self.refresh_designator() desc_available_lines = 4 - desig = self.texts["designator"] - desig.draw((0, 20)) + desig = self.texts.get("designator") + if desig: + desig.draw((0, 20)) # Object TYPE and Constellation i.e. 'Galaxy PER' typeconst = self.texts.get("type-const") @@ -452,7 +1064,16 @@ def update(self, force=True): desc.set_available_lines(desc_available_lines) desc.draw((0, posy)) - return self.screen_update() + # Display eyepiece input popup if active + if self.eyepiece_input_display: + self.message( + f"{str(self.eyepiece_input)}mm", + 0.1, + [30, 10, 93, 40], + ) + + result = self.screen_update() + return result def cycle_display_mode(self): """ @@ -494,6 +1115,40 @@ def mm_cancel(self, _marking_menu, _menu_item) -> bool: """ return True + def mm_toggle_crosshair(self, _marking_menu, _menu_item) -> bool: + """ + Cycle through crosshair modes: off -> on -> pulse -> off + """ + current_mode = self.config_object.get_option("obj_chart_crosshair") + modes = ["off", "on", "pulse"] + current_index = modes.index(current_mode) if current_mode in modes else 0 + next_index = (current_index + 1) % len(modes) + self.config_object.set_option("obj_chart_crosshair", modes[next_index]) + return False # Don't exit, just update + + def mm_cycle_style(self, _marking_menu, _menu_item) -> bool: + """ + Cycle through crosshair styles + """ + current_style = self.config_object.get_option("obj_chart_crosshair_style") + styles = ["simple", "circle", "bullseye", "brackets", "dots", "cross"] + current_index = styles.index(current_style) if current_style in styles else 0 + next_index = (current_index + 1) % len(styles) + self.config_object.set_option("obj_chart_crosshair_style", styles[next_index]) + return False # Don't exit, just update + + def mm_toggle_lm_mode(self, _marking_menu, _menu_item) -> bool: + """ + Toggle between auto and fixed LM mode + """ + current_mode = self.config_object.get_option("obj_chart_lm_mode") + new_mode = "fixed" if current_mode == "auto" else "auto" + self.config_object.set_option("obj_chart_lm_mode", new_mode) + # If switching to auto, regenerate the chart with new calculation + if new_mode == "auto": + self.update_object_info() + return False # Don't exit, just update + def mm_align(self, _marking_menu, _menu_item) -> bool: """ Called from marking menu to align on curent object @@ -526,9 +1181,14 @@ def key_left(self): def key_right(self): """ - When right is pressed, move to - logging screen + When right is pressed, move to logging screen + Or, if eyepiece input is active, complete the input """ + # If eyepiece input is active, complete it + if self.eyepiece_input_display: + self._apply_custom_eyepiece() + return True + self.maybe_add_to_recents() if self.shared_state.solution() is None: return @@ -540,7 +1200,66 @@ def key_right(self): self.add_to_stack(object_item_definition) def change_fov(self, direction): - self.config_object.equipment.cycle_eyepieces(direction) + """ + Change field of view by cycling eyepieces. + If a custom eyepiece is active, jump to the nearest configured eyepiece and remove custom. + """ + if self._custom_eyepiece is not None: + # Custom eyepiece is active - remove it and find nearest configured eyepiece + logger.info(">>> Custom eyepiece active, switching to configured eyepieces") + custom_focal_length = self._custom_eyepiece.focal_length_mm + + # Remove custom eyepiece from equipment list + if self._custom_eyepiece in self.config_object.equipment.eyepieces: + self.config_object.equipment.eyepieces.remove(self._custom_eyepiece) + self._custom_eyepiece = None + + # Get configured eyepieces (now that custom is removed) + eyepieces = self.config_object.equipment.eyepieces + if not eyepieces: + return + + # Sort eyepieces by focal length + sorted_eyepieces = sorted(eyepieces, key=lambda e: e.focal_length_mm) + + if direction > 0: + # Find next larger eyepiece (smaller magnification) + for ep in sorted_eyepieces: + if ep.focal_length_mm > custom_focal_length: + self.config_object.equipment.active_eyepiece_index = ( + eyepieces.index(ep) + ) + logger.info(f">>> Jumped to next larger: {ep}") + break + else: + # No larger eyepiece found, wrap to smallest + self.config_object.equipment.active_eyepiece_index = ( + eyepieces.index(sorted_eyepieces[0]) + ) + logger.info(f">>> Wrapped to smallest: {sorted_eyepieces[0]}") + else: + # Find next smaller eyepiece (larger magnification) + for i in range(len(sorted_eyepieces) - 1, -1, -1): + ep = sorted_eyepieces[i] + if ep.focal_length_mm < custom_focal_length: + self.config_object.equipment.active_eyepiece_index = ( + eyepieces.index(ep) + ) + logger.info(f">>> Jumped to next smaller: {ep}") + break + else: + # No smaller eyepiece found, wrap to largest + self.config_object.equipment.active_eyepiece_index = ( + eyepieces.index(sorted_eyepieces[-1]) + ) + logger.info(f">>> Wrapped to largest: {sorted_eyepieces[-1]}") + else: + # Normal eyepiece cycling + self.config_object.equipment.cycle_eyepieces(direction) + logger.info( + f">>> Normal cycle to: {self.config_object.equipment.active_eyepiece}" + ) + self.update_object_info() self.update() @@ -561,3 +1280,60 @@ def key_minus(self): typeconst.next() else: self.change_fov(-1) + + def key_number(self, number): + """ + Handle number key presses + When viewing image (DM_IMAGE): + - 0: Toggle between POSS image and Gaia chart (only if no input active) + - 1-9: Start custom eyepiece input + - After first digit, 0-9 adds second digit or completes input + """ + logger.info(f">>> key_number({number}) called") + + # Only handle custom eyepiece input in image display modes + if self.object_display_mode != DM_IMAGE: + return + + # Special case: 0 when no input is active toggles POSS/chart + if number == 0 and not self.eyepiece_input_display: + logger.info( + f">>> Toggling _force_gaia_chart (was: {self._force_gaia_chart})" + ) + # Toggle the flag + self._force_gaia_chart = not self._force_gaia_chart + logger.info(f">>> _force_gaia_chart now: {self._force_gaia_chart}") + + # Reload image with new setting + logger.info(">>> Calling update_object_info()...") + self.update_object_info() + logger.info( + f">>> After update_object_info(), self.object_image type: {type(self.object_image)}, size: {self.object_image.size if self.object_image else None}" + ) + logger.info(">>> Calling update()...") + update_result = self.update() + logger.info(f">>> update() returned: {type(update_result)}") + logger.info(">>> key_number(0) complete") + return True + + # Handle custom eyepiece input (1-9 to start, 0-9 for second digit) + if number >= 1 or (number == 0 and self.eyepiece_input_display): + logger.info(f">>> Adding digit {number} to eyepiece input") + is_complete = self.eyepiece_input.append_digit(number) + self.eyepiece_input_display = True + logger.info( + f">>> After adding digit: focal_length={self.eyepiece_input.focal_length_mm}mm, complete={is_complete}, display='{self.eyepiece_input}'" + ) + + if is_complete: + # Two digits entered, apply immediately + logger.info( + f">>> Input complete, applying {self.eyepiece_input.focal_length_mm}mm" + ) + self._apply_custom_eyepiece() + else: + # Show popup with current input + logger.info(">>> Input incomplete, showing popup") + self.update() + + return True diff --git a/python/PiFinder/ui/preview.py b/python/PiFinder/ui/preview.py index bac4ce300..d47689664 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -20,6 +20,7 @@ from PiFinder.ui.ui_utils import outline_text sys.path.append(str(utils.tetra3_dir)) +sys.path.append(str(utils.tetra3_dir / "tetra3")) class UIPreview(UIModule): diff --git a/python/PiFinder/ui/software.py b/python/PiFinder/ui/software.py index b0892889b..76c27552a 100644 --- a/python/PiFinder/ui/software.py +++ b/python/PiFinder/ui/software.py @@ -1,239 +1,960 @@ #!/usr/bin/python # -*- coding:utf-8 -*- """ -This module contains all the UI Module classes +UI modules for software updates, channel selection, and release notes. +Channels: + - stable: GitHub Releases (non-prerelease, >= MIN_NIXOS_VERSION) + - beta: GitHub Pre-releases (>= MIN_NIXOS_VERSION) + - unstable: main branch + open PRs labeled 'testable' """ -import time +import logging +import re +from typing import Dict, List, Optional + import requests from PiFinder import utils from PiFinder.ui.base import UIModule +from PiFinder.ui.ui_utils import TextLayouter, TextLayouterScroll sys_utils = utils.get_sys_utils() +logger = logging.getLogger("UISoftware") + +GITHUB_REPO = "brickbots/PiFinder" +GITHUB_RELEASES_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases" +GITHUB_PULLS_URL = f"https://api.github.com/repos/{GITHUB_REPO}/pulls" +GITHUB_RAW_URL = f"https://raw.githubusercontent.com/{GITHUB_REPO}" +MIN_NIXOS_VERSION = "2.5.0" +REQUEST_TIMEOUT = 10 +_PR_VERSION_RE = re.compile(r"^PR#(\d+)-") -def update_needed(current_version: str, repo_version: str) -> bool: +def _parse_version(version_str: str) -> tuple: """ - Returns true if an update is available + Parse a version string like '2.4.0' or '2.5.0-beta.1' + into a comparable tuple. Pre-release tags sort below + the same numeric version (2.5.0-beta.1 < 2.5.0). + """ + version_str = version_str.strip() + if "-" in version_str: + numeric_part, pre_release = version_str.split("-", 1) + else: + numeric_part = version_str + pre_release = None + + parts = numeric_part.split(".") + major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) - Update is available if semvar of repo_version is > current_version - Also returns True on error to allow be biased towards allowing - updates if issues + if pre_release is None: + return (major, minor, patch, 1, "") + else: + return (major, minor, patch, 0, pre_release) + + +def _meets_min_version(version_str: str) -> bool: + """Check if a version string is >= MIN_NIXOS_VERSION.""" + try: + ver = _parse_version(version_str) + minimum = _parse_version(MIN_NIXOS_VERSION) + return ver >= minimum + except Exception: + return False + + +def _version_from_tag(tag: str) -> str: + """Strip leading 'v' from a tag name to get the version string.""" + return tag.lstrip("v") + + +def _fetch_build_json(ref: str) -> Optional[dict]: + """ + Fetch pifinder-build.json for a given git ref (sha or tag). + Returns dict with 'store_path' and 'version', or None if unavailable. """ + url = f"{GITHUB_RAW_URL}/{ref}/pifinder-build.json" try: - _tmp_split = current_version.split(".") - current_version_compare = ( - int(_tmp_split[0]), - int(_tmp_split[1]), - int(_tmp_split[2]), - ) + res = requests.get(url, timeout=REQUEST_TIMEOUT) + if res.status_code == 200: + data = res.json() + if data.get("store_path"): + return data + except (requests.exceptions.RequestException, ValueError): + pass + return None + - _tmp_split = repo_version.split(".") - repo_version_compare = ( - int(_tmp_split[0]), - int(_tmp_split[1]), - int(_tmp_split[2]), +def _fetch_github_releases() -> tuple[list[dict], list[dict]]: + """ + Fetch releases from GitHub API. + Returns (stable_entries, beta_entries) sorted newest-first. + Only includes entries that have a pifinder-build.json with a store path. + """ + stable: list[dict] = [] + beta: list[dict] = [] + try: + res = requests.get( + GITHUB_RELEASES_URL, + timeout=REQUEST_TIMEOUT, + headers={"Accept": "application/vnd.github.v3+json"}, ) + if res.status_code != 200: + logger.warning("GitHub releases API returned %d", res.status_code) + return stable, beta + + for release in res.json(): + if release.get("draft"): + continue + tag = release.get("tag_name", "") + version = _version_from_tag(tag) + if not _meets_min_version(version): + continue + + build = _fetch_build_json(tag) + if build is None: + continue + + entry = { + "label": tag, + "ref": build["store_path"], + "notes": release.get("body") or None, + "version": build.get("version", version), + "subtitle": release.get("name", tag), + } + + if release.get("prerelease"): + beta.append(entry) + else: + stable.append(entry) + + except requests.exceptions.RequestException as e: + logger.warning("Could not fetch GitHub releases: %s", e) + + return stable, beta + + +def _fetch_testable_prs() -> list[dict]: + """ + Fetch open PRs with the 'testable' label. + Returns list of unstable entries (main branch prepended by caller). + Only includes PRs that have a pifinder-build.json with a store path. + """ + entries: list[dict] = [] + try: + res = requests.get( + GITHUB_PULLS_URL, + params={"state": "open", "labels": "testable"}, + timeout=REQUEST_TIMEOUT, + headers={"Accept": "application/vnd.github.v3+json"}, + ) + if res.status_code != 200: + logger.warning("GitHub pulls API returned %d", res.status_code) + return entries + + for pr in res.json(): + labels = [lbl.get("name", "") for lbl in pr.get("labels", [])] + if "testable" not in labels: + continue + number = pr.get("number", 0) + title = pr.get("title", "") + sha = pr.get("head", {}).get("sha", "") + body = pr.get("body") or None + + build = _fetch_build_json(sha) + if build is None: + continue + + short_sha = sha[:7] + entries.append( + { + "label": f"PR#{number}-{short_sha}", + "ref": build["store_path"], + "notes": body, + "version": build.get("version"), + "subtitle": title, + } + ) - # tuples compare in significance from first to last element - return repo_version_compare > current_version_compare + except requests.exceptions.RequestException as e: + logger.warning("Could not fetch testable PRs: %s", e) + + return entries - except Exception: - return True + +def _fetch_main_entry() -> Optional[dict]: + """ + Fetch pifinder-build.json for the main branch. + Returns an entry dict or None if unavailable. + """ + build = _fetch_build_json("main") + if build is None: + return None + return { + "label": build.get("version") or "main", + "ref": build["store_path"], + "notes": None, + "version": build.get("version"), + "subtitle": "main branch", + } + + +def _fetch_pr_title(pr_number: int) -> Optional[str]: + """Fetch the title of a single PR by number.""" + url = f"https://api.github.com/repos/{GITHUB_REPO}/pulls/{pr_number}" + try: + res = requests.get( + url, + timeout=REQUEST_TIMEOUT, + headers={"Accept": "application/vnd.github.v3+json"}, + ) + if res.status_code == 200: + return res.json().get("title") + except requests.exceptions.RequestException: + pass + return None class UISoftware(UIModule): """ - UI for updating software versions + Software update UI. + + Phases: + loading - animated "Checking for updates..." + browse - header (version + channel selector) + scrollable version list + confirm - selected version details + Install / Notes / Cancel + upgrading - progress bar with download progress, then reboot + failed - update failed + Retry / Cancel """ __title__ = "SOFTWARE" + MAX_VISIBLE = 4 def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self.version_txt = f"{utils.pifinder_dir}/version.txt" self.wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" - with open(self.wifi_txt, "r") as wfs: - self._wifi_mode = wfs.read() - with open(self.version_txt, "r") as ver: - self._software_version = ver.read() + with open(self.wifi_txt, "r") as f: + self._wifi_mode = f.read().strip() + self._software_version = utils.get_version() + self._software_subtitle: Optional[str] = None + + self._channels: Dict[str, List[dict]] = {} + self._channel_names: List[str] = [] + self._channel_index = 0 + + self._version_list: List[dict] = [] + self._list_index = 0 + self._scroll_offset = 0 - self._release_version = "-.-.-" + self._phase = "loading" + self._focus = "channel" # "channel" or "list" (browse phase) self._elipsis_count = 0 - self._go_for_update = False - self._option_select = "Update" - def get_release_version(self): - """ - Fetches current release version from - github, sets class variable if found + self._selected_version: Optional[dict] = None + self._confirm_options: List[str] = [] + self._confirm_index = 0 + + self._fail_option = "Retry" + self._unstable_unlocked = self.config_object.get_option( + "software_unstable_unlocked" + ) + self._unstable_entries: List[dict] = [] + self._square_count = 0 + + self._scrollers: Dict[str, TextLayouterScroll] = {} + self._scroller_phase: Optional[str] = None + self._scroller_index: Optional[int] = None + + def active(self): + super().active() + self._phase = "loading" + self._elipsis_count = 0 + self._focus = "channel" + self._channel_index = 0 + self._list_index = 0 + self._scroll_offset = 0 + self._selected_version = None + self._scrollers = {} + self._scroller_phase = None + self._scroller_index = None + + # ------------------------------------------------------------------ + # Data + # ------------------------------------------------------------------ + + def _fetch_channels(self): + stable, beta = _fetch_github_releases() + + self._channels = { + "stable": stable, + "beta": beta, + } + + if self._unstable_unlocked: + self._unstable_entries = self._fetch_unstable_entries() + self._channels["unstable"] = self._unstable_entries + + # Try to find subtitle for current version from fetched entries + self._software_subtitle = self._find_current_subtitle() + + self._channel_names = list(self._channels.keys()) + self._channel_index = 0 + self._refresh_version_list() + self._phase = "browse" + + def _find_current_subtitle(self) -> Optional[str]: + """Find a subtitle for the current version. + + Checks fetched channel entries first, then falls back to + a direct PR title fetch for PR builds. """ - try: - res = requests.get( - "https://raw.githubusercontent.com/brickbots/PiFinder/release/version.txt" - ) - except requests.exceptions.ConnectionError: - print("Could not connect to github") - self._release_version = "Unknown" + for entries in self._channels.values(): + for entry in entries: + if entry.get("version") == self._software_version: + return entry.get("subtitle") + + m = _PR_VERSION_RE.match(self._software_version) + if m: + return _fetch_pr_title(int(m.group(1))) + + return None + + def _fetch_unstable_entries(self) -> list[dict]: + unstable: list[dict] = [] + main_entry = _fetch_main_entry() + if main_entry: + unstable.append(main_entry) + unstable.extend(_fetch_testable_prs()) + return unstable + + def _refresh_version_list(self): + if not self._channel_names: + self._version_list = [] return + channel = self._channel_names[self._channel_index] + entries = self._channels.get(channel, []) + self._version_list = [ + e for e in entries if e.get("version") != self._software_version + ] + self._list_index = 0 + self._scroll_offset = 0 + self._scrollers = {} + self._scroller_phase = None + self._scroller_index = None + + def _get_scrollspeed_config(self): + scroll_dict = { + "Off": 0, + "Fast": TextLayouterScroll.FAST, + "Med": TextLayouterScroll.MEDIUM, + "Slow": TextLayouterScroll.SLOW, + } + scrollspeed = self.config_object.get_option("text_scroll_speed", "Med") + return scroll_dict[scrollspeed] + + def _get_scroller(self, key: str, text: str, font, color, width: int): + """Get or create a cached scroller, reset cache on phase/index change.""" + phase_index = (self._phase, self._list_index) + if (self._scroller_phase, self._scroller_index) != phase_index: + self._scrollers = {} + self._scroller_phase = self._phase + self._scroller_index = self._list_index + + if key not in self._scrollers: + self._scrollers[key] = TextLayouterScroll( + text, + draw=self.draw, + color=color, + font=font, + width=width, + scrollspeed=self._get_scrollspeed_config(), + ) + return self._scrollers[key] + + # ------------------------------------------------------------------ + # Drawing helpers + # ------------------------------------------------------------------ + + def _draw_separator(self, y): + self.draw.line([(0, y), (127, y)], fill=self.colors.get(64)) + + def _draw_loading(self): + y = self.display_class.titlebar_height + 2 + ver_scroller = self._get_scroller( + "loading_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) + dots = "." * (self._elipsis_count // 10) + self.draw.text( + (10, 90), + _("Checking for"), + font=self.fonts.large.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, 105), + _("updates{elipsis}").format(elipsis=dots), + font=self.fonts.large.font, + fill=self.colors.get(255), + ) + self._elipsis_count += 1 + if self._elipsis_count > 39: + self._elipsis_count = 0 + + def _draw_wifi_warning(self): + y = self.display_class.titlebar_height + 2 + ver_scroller = self._get_scroller( + "wifi_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) + self.draw.text( + (10, 90), + _("WiFi must be"), + font=self.fonts.large.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, 105), + _("client mode"), + font=self.fonts.large.font, + fill=self.colors.get(255), + ) - if res.status_code == 200: - self._release_version = res.text[:-1] + def _draw_browse(self): + y = self.display_class.titlebar_height + 2 + + # Current version + ver_scroller = self._get_scroller( + "browse_cur_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) + y += 12 + if self._software_subtitle: + sub_scroller = self._get_scroller( + "browse_cur_sub", + self._software_subtitle, + self.fonts.base, + self.colors.get(128), + self.fonts.base.line_length, + ) + sub_scroller.draw((0, y)) + y += 12 else: - self._release_version = "Unknown" + y += 2 - def update_software(self): - self.message(_("Updating..."), 10) - if sys_utils.update_software(): - self.message(_("Ok! Restarting"), 10) - sys_utils.restart_system() + # Channel selector + channel_name = ( + self._channel_names[self._channel_index].capitalize() + if self._channel_names + else "---" + ) + if self._focus == "channel": + self.draw.text( + (0, y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, y), + channel_name, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) else: - self.message(_("Error on Upd"), 3) + self.draw.text( + (10, y), + channel_name, + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + y += 14 + + self._draw_separator(y) + y += 4 + + # Version list + if not self._version_list: + self.draw.text( + (10, y + 10), + _("No versions"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + self.draw.text( + (10, y + 22), + _("available"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + return + + label_width = self.fonts.base.line_length - 2 + current_y = y + for i in range(len(self._version_list)): + idx = self._scroll_offset + i + if idx >= len(self._version_list): + break + entry = self._version_list[idx] + label = entry["label"] + subtitle = entry.get("subtitle", "") + + if self._focus == "list" and idx == self._list_index: + if current_y + 24 > 128: + break + self.draw.text( + (0, current_y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + scroller = self._get_scroller( + "browse_label", + label, + self.fonts.bold, + self.colors.get(255), + label_width, + ) + scroller.draw((10, current_y)) + current_y += 12 + if subtitle: + sub_scroller = self._get_scroller( + "browse_sub", + subtitle, + self.fonts.base, + self.colors.get(128), + label_width, + ) + sub_scroller.draw((10, current_y)) + current_y += 12 + else: + if current_y + 12 > 128: + break + self.draw.text( + (10, current_y), + label[:label_width], + font=self.fonts.base.font, + fill=self.colors.get(192), + ) + current_y += 12 + + def _draw_confirm(self): + y = self.display_class.titlebar_height + 2 - def update(self, force=False): - time.sleep(1 / 30) - self.clear_screen() - draw_pos = self.display_class.titlebar_height + 2 self.draw.text( - (0, draw_pos), - _("Wifi Mode: {}").format(self._wifi_mode), + (0, y), + _("Update to:"), font=self.fonts.base.font, fill=self.colors.get(128), ) - draw_pos += 15 + y += 14 - self.draw.text( - (0, draw_pos), - _("Current Version"), - font=self.fonts.bold.font, - fill=self.colors.get(128), + label_width = self.fonts.base.line_length + version_label = ( + self._selected_version.get("version") or self._selected_version["label"] ) - draw_pos += 10 - + scroller = self._get_scroller( + "confirm_label", + version_label, + self.fonts.bold, + self.colors.get(255), + label_width, + ) + scroller.draw((0, y)) + y += 12 + + subtitle = self._selected_version.get("subtitle", "") + if subtitle: + sub_scroller = self._get_scroller( + "confirm_sub", + subtitle, + self.fonts.base, + self.colors.get(128), + label_width, + ) + sub_scroller.draw((0, y)) + y += 14 + + self._draw_separator(y) + y += 4 + + for i, opt in enumerate(self._confirm_options): + item_y = y + i * 12 + if i == self._confirm_index: + self.draw.text( + (0, item_y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, item_y), + _(opt), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + else: + self.draw.text( + (10, item_y), + _(opt), + font=self.fonts.base.font, + fill=self.colors.get(192), + ) + + def _draw_failed(self): + y = self.display_class.titlebar_height + 20 self.draw.text( - (10, draw_pos), - f"{self._software_version}", + (10, y), + _("Update failed!"), font=self.fonts.bold.font, - fill=self.colors.get(192), + fill=self.colors.get(255), ) - draw_pos += 16 + y += 20 + for label in ("Retry", "Cancel"): + if self._fail_option == label: + self.draw.text( + (0, y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, y), + _(label), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += 12 + + # ------------------------------------------------------------------ + # Main update loop + # ------------------------------------------------------------------ + + def update(self, force=False): + self.clear_screen() + + if self._phase == "upgrading": + self._draw_upgrading() + return self.screen_update() + + if self._phase == "failed": + self._draw_failed() + return self.screen_update() + + if self._wifi_mode != "Client": + self._draw_wifi_warning() + return self.screen_update() + + if self._phase == "loading": + if self._elipsis_count > 30: + self._fetch_channels() + # phase is now "browse", fall through + else: + self._draw_loading() + return self.screen_update() + + if self._phase == "browse": + self._draw_browse() + elif self._phase == "confirm": + self._draw_confirm() + + return self.screen_update() + + # ------------------------------------------------------------------ + # Key handlers + # ------------------------------------------------------------------ + + def _reset_unlock(self): + self._square_count = 0 + + def key_up(self): + self._reset_unlock() + if self._phase == "upgrading": + return + if self._phase == "failed": + self._fail_option = "Cancel" if self._fail_option == "Retry" else "Retry" + elif self._phase == "browse": + if self._focus == "list": + if self._list_index == 0: + self._focus = "channel" + else: + self._list_index -= 1 + if self._list_index < self._scroll_offset: + self._scroll_offset = self._list_index + elif self._phase == "confirm": + if self._confirm_index > 0: + self._confirm_index -= 1 + + def key_down(self): + self._reset_unlock() + if self._phase == "upgrading": + return + if self._phase == "failed": + self._fail_option = "Cancel" if self._fail_option == "Retry" else "Retry" + elif self._phase == "browse": + if self._focus == "channel": + if self._version_list: + self._focus = "list" + self._list_index = 0 + self._scroll_offset = 0 + elif self._focus == "list": + if self._list_index < len(self._version_list) - 1: + self._list_index += 1 + if self._list_index >= self._scroll_offset + self.MAX_VISIBLE: + self._scroll_offset = self._list_index - self.MAX_VISIBLE + 1 + elif self._phase == "confirm": + if self._confirm_index < len(self._confirm_options) - 1: + self._confirm_index += 1 + + def key_right(self): + self._reset_unlock() + if self._phase == "upgrading": + return + if self._phase == "failed": + if self._fail_option == "Retry": + self._phase = "confirm" + self.update_software() + else: + self.remove_from_stack() + elif self._phase == "browse": + if self._focus == "channel" and self._channel_names: + self._channel_index = (self._channel_index + 1) % len( + self._channel_names + ) + self._refresh_version_list() + elif self._focus == "list" and self._version_list: + self._selected_version = self._version_list[self._list_index] + self._confirm_options = ["Install"] + if self._selected_version.get("notes"): + self._confirm_options.append("Notes") + self._confirm_options.append("Cancel") + self._confirm_index = 0 + self._phase = "confirm" + elif self._phase == "confirm": + opt = self._confirm_options[self._confirm_index] + if opt == "Install": + self.update_software() + elif opt == "Notes": + notes = self._selected_version.get("notes") + if notes: + self.add_to_stack({"class": UIReleaseNotes, "notes_text": notes}) + elif opt == "Cancel": + self._phase = "browse" + + def key_left(self): + self._reset_unlock() + if self._phase == "upgrading": + return False + if self._phase == "confirm": + self._phase = "browse" + return False + return True + + def key_square(self): + self._square_count += 1 + if self._square_count >= 7 and not self._unstable_unlocked: + self._unstable_unlocked = True + self.config_object.set_option("software_unstable_unlocked", True) + self._unstable_entries = self._fetch_unstable_entries() + self._channels["unstable"] = self._unstable_entries + self._channel_names = list(self._channels.keys()) + self.message(_("Unstable\nunlocked"), 1) + + def key_number(self, number): + self._square_count = 0 + + # ------------------------------------------------------------------ + # Update action + # ------------------------------------------------------------------ + + def update_software(self): + if not self._selected_version: + return + self._phase = "upgrading" + self.clear_screen() + self._draw_upgrading() + self.screen_update() + + ref = self._selected_version.get("ref") or "release" + if not sys_utils.update_software(ref=ref): + self._phase = "failed" + self._fail_option = "Retry" + + def _draw_upgrading(self): + y = self.display_class.titlebar_height + 2 + + progress = sys_utils.get_upgrade_progress() + phase = progress["phase"] + pct = progress["percent"] + done = progress["done"] + total = progress["total"] + + if phase == "failed": + self._phase = "failed" + self._fail_option = "Retry" + return + + # Title + if phase == "rebooting": + label = _("Rebooting...") + elif phase == "activating": + label = _("Activating...") + else: + label = _("Downloading...") self.draw.text( - (0, draw_pos), - _("Release Version"), + (0, y), + label, font=self.fonts.bold.font, - fill=self.colors.get(128), + fill=self.colors.get(255), + ) + y += 20 + + # Progress bar + bar_x, bar_w, bar_h = 4, 120, 12 + # Background fill so bar is always visible + self.draw.rectangle( + [bar_x, y, bar_x + bar_w, y + bar_h], + fill=self.colors.get(48), + outline=self.colors.get(128), ) - draw_pos += 10 + fill_w = int(bar_w * pct / 100) + if fill_w > 0: + self.draw.rectangle( + [bar_x + 1, y + 1, bar_x + fill_w, y + bar_h - 1], + fill=self.colors.get(255), + ) + # Percentage centered on bar + pct_text = f"{pct}%" + pct_bbox = self.fonts.base.font.getbbox(pct_text) + pct_w = pct_bbox[2] - pct_bbox[0] + pct_h = pct_bbox[3] - pct_bbox[1] + pct_x = bar_x + (bar_w - pct_w) // 2 + pct_y = y + (bar_h - pct_h) // 2 - pct_bbox[1] self.draw.text( - (10, draw_pos), - f"{self._release_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), + (pct_x, pct_y), + pct_text, + font=self.fonts.base.font, + fill=self.colors.get(0) if pct > 45 else self.colors.get(192), ) + y += bar_h + 6 - if self._wifi_mode != "Client": + # Path count below bar + if phase == "downloading" and total > 0: + path_text = f"{done}/{total} paths" self.draw.text( - (10, 90), - _("WiFi must be"), - font=self.fonts.large.font, - fill=self.colors.get(255), + (4, y), + path_text, + font=self.fonts.base.font, + fill=self.colors.get(128), ) - self.draw.text( - (10, 105), - _("client mode"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - return self.screen_update() - if self._release_version == "-.-.-": - # check elipsis count here... if we are at >30 check for - # release versions - if self._elipsis_count > 30: - self.get_release_version() + +class UIReleaseNotes(UIModule): + """ + Scrollable release notes viewer. + Accepts markdown text directly via notes_text in item_definition. + """ + + __title__ = "NOTES" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._notes_text = self.item_definition.get("notes_text", "") + self._loaded = False + self._text_layout = TextLayouter( + "", + draw=self.draw, + color=self.colors.get(255), + colors=self.colors, + font=self.fonts.base, + available_lines=9, + ) + + def active(self): + super().active() + if not self._loaded: + self._load_notes() + + def _load_notes(self): + """Process notes text for display.""" + if self._notes_text: + text = _strip_markdown(self._notes_text) + self._text_layout.set_text(text) + self._loaded = True + else: + self._loaded = True + + def update(self, force=False): + self.clear_screen() + draw_pos = self.display_class.titlebar_height + 2 + + if not self._notes_text: self.draw.text( - (10, 90), - _("Checking for"), + (10, draw_pos + 20), + _("No release notes"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( - (10, 105), - _("updates{elipsis}").format( - elipsis="." * int(self._elipsis_count / 10) - ), + (10, draw_pos + 35), + _("available"), font=self.fonts.large.font, fill=self.colors.get(255), ) - self._elipsis_count += 1 - if self._elipsis_count > 39: - self._elipsis_count = 0 return self.screen_update() - if not update_needed( - self._software_version.strip(), self._release_version.strip() - ): + if not self._loaded: self.draw.text( - (10, 90), - _("No Update"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - self.draw.text( - (10, 105), - _("needed"), + (10, draw_pos + 20), + _("Loading..."), font=self.fonts.large.font, fill=self.colors.get(255), ) return self.screen_update() - # If we are here, go for update! - self._go_for_update = True - self.draw.text( - (10, 90), - _("Update Now"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - self.draw.text( - (10, 105), - _("Cancel"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - if self._option_select == "Update": - ind_pos = 90 - else: - ind_pos = 105 - self.draw.text( - (0, ind_pos), - self._RIGHT_ARROW, - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - + self._text_layout.draw((0, draw_pos)) return self.screen_update() - def toggle_option(self): - if not self._go_for_update: - return - if self._option_select == "Update": - self._option_select = "Cancel" - else: - self._option_select = "Update" + def key_down(self): + self._text_layout.next() def key_up(self): - self.toggle_option() + self._text_layout.previous() - def key_down(self): - self.toggle_option() + def key_left(self): + return True - def key_right(self): - if self._option_select == "Cancel": - self.remove_from_stack() - else: - self.update_software() + +def _strip_markdown(text: str) -> str: + """ + Minimal markdown stripping for plain-text display on OLED. + Removes common markdown syntax while keeping readable text. + """ + lines = [] + for line in text.splitlines(): + stripped = line.lstrip("#").strip() + stripped = stripped.replace("**", "").replace("__", "") + stripped = stripped.replace("*", "").replace("_", "") + while "[" in stripped and "](" in stripped: + start = stripped.index("[") + mid = stripped.index("](", start) + end = stripped.index(")", mid) + link_text = stripped[start + 1 : mid] + stripped = stripped[:start] + link_text + stripped[end + 1 :] + stripped = stripped.replace("`", "") + lines.append(stripped) + return "\n".join(lines) diff --git a/python/PiFinder/ui/sqm.py b/python/PiFinder/ui/sqm.py index 2cd0115f4..fcd55b773 100644 --- a/python/PiFinder/ui/sqm.py +++ b/python/PiFinder/ui/sqm.py @@ -5,6 +5,7 @@ from PiFinder.ui.ui_utils import TextLayouter from PiFinder.image_util import gamma_correct_med, subtract_background import time +from pathlib import Path from typing import Any, TYPE_CHECKING from PIL import Image, ImageDraw, ImageChops, ImageOps @@ -156,26 +157,38 @@ def update(self, force=False): fill=self.colors.get(64), ) - self.draw.text( - (10, 30), - f"{sqm:.2f}", - font=self.fonts.huge.font, - fill=self.colors.get(192), - ) - # Raw SQM value (if available) in smaller text next to main value - if sqm_state.value_raw is not None: + # Show star count and exposure time (right side) + sqm_details = self.shared_state.sqm_details() + if sqm_details: + n_stars = sqm_details.get("n_matched_stars", 0) self.draw.text( - (95, 50), - f"{sqm_state.value_raw:.2f}", + (60, 20), + f"{n_stars}★", font=self.fonts.base.font, - fill=self.colors.get(128), + fill=self.colors.get(64), ) + + image_metadata = self.shared_state.last_image_metadata() + if image_metadata and "exposure_time" in image_metadata: + exp_ms = image_metadata["exposure_time"] / 1000 # Convert µs to ms + if exp_ms >= 1000: + exp_str = f"{exp_ms / 1000:.2f}s" + else: + exp_str = f"{exp_ms:.0f}ms" self.draw.text( - (95, 62), - "raw", - font=self.fonts.small.font, + (95, 20), + exp_str, + font=self.fonts.base.font, fill=self.colors.get(64), ) + + self.draw.text( + (10, 30), + f"{sqm:.2f}", + font=self.fonts.huge.font, + fill=self.colors.get(192), + ) + # Units in small, subtle text self.draw.text( (12, 68), @@ -183,18 +196,42 @@ def update(self, force=False): font=self.fonts.base.font, fill=self.colors.get(64), ) - self.draw.text( - (10, 82), - f"{details['title']}", - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - self.draw.text( - (10, 92), - _("Bortle {bc}").format(bc=details["bortle_class"]), - font=self.fonts.bold.font, - fill=self.colors.get(128), - ) + + # Calibration indicator (right side of units line) + if self._is_calibrated(): + self.draw.text( + (105, 68), + "CAL", + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + else: + self.draw.text( + (98, 68), + "!CAL", + font=self.fonts.base.font, + fill=self.colors.get(64), + ) + + # Show altitude-corrected SQM (scientific value) if available + if sqm_details: + sqm_alt = sqm_details.get("sqm_altitude_corrected") + if sqm_alt: + self.draw.text( + (12, 80), + f"alt: {sqm_alt:.2f}", + font=self.fonts.base.font, + fill=self.colors.get(64), + ) + + # Bortle class + if details: + self.draw.text( + (10, 92), + _("Bortle {bc}").format(bc=details["bortle_class"]), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) # Legend details_text = _("DETAILS") @@ -223,11 +260,32 @@ def key_minus(self): if self.show_description: self.text_layout.previous() + def _is_calibrated(self) -> bool: + """Check if SQM calibration file exists for current camera.""" + camera_type = self.shared_state.camera_type() + camera_type_processed = f"{camera_type}_processed" + calibration_file = ( + Path.home() + / "PiFinder_data" + / f"sqm_calibration_{camera_type_processed}.json" + ) + return calibration_file.exists() + def active(self): """ Called when a module becomes active i.e. foreground controlling display """ + # Switch to SNR auto-exposure mode for stable longer exposures + self.command_queues["camera"].put("set_ae_mode:snr") + + def inactive(self): + """ + Called when a module becomes inactive + i.e. leaving the SQM screen + """ + # Switch back to PID auto-exposure mode + self.command_queues["camera"].put("set_ae_mode:pid") def _launch_calibration(self, marking_menu, selected_item): """Launch the SQM calibration wizard""" diff --git a/python/PiFinder/ui/sqm_calibration.py b/python/PiFinder/ui/sqm_calibration.py index 5966cc311..a12e299bd 100644 --- a/python/PiFinder/ui/sqm_calibration.py +++ b/python/PiFinder/ui/sqm_calibration.py @@ -52,7 +52,7 @@ class UISQMCalibration(UIModule): __title__ = "SQM CAL" __help_name__ = "" - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) # Wizard state machine @@ -692,8 +692,13 @@ def _analyze_calibration(self): bias_stack = np.array(self.bias_frames, dtype=np.float32) self.bias_offset = float(np.median(bias_stack)) - # 2. Compute read noise (std of all pixels in all bias frames) - self.read_noise = float(np.std(bias_stack)) + # 2. Compute read noise using temporal variance (not spatial) + # Spatial std includes fixed pattern noise (PRNU), which is wrong. + # Temporal variance at each pixel measures true read noise. + temporal_variance = np.var( + bias_stack, axis=0 + ) # variance across frames per pixel + self.read_noise = float(np.sqrt(np.mean(temporal_variance))) # 3. Compute dark current rate dark_stack = np.array(self.dark_frames, dtype=np.float32) @@ -710,8 +715,9 @@ def _analyze_calibration(self): bias_stack_raw = np.array(self.bias_frames_raw, dtype=np.float32) self.bias_offset_raw = float(np.median(bias_stack_raw)) - # 2. Compute read noise from raw frames - self.read_noise_raw = float(np.std(bias_stack_raw)) + # 2. Compute read noise using temporal variance (not spatial) + temporal_variance_raw = np.var(bias_stack_raw, axis=0) + self.read_noise_raw = float(np.sqrt(np.mean(temporal_variance_raw))) # 3. Compute dark current rate from raw frames dark_stack_raw = np.array(self.dark_frames_raw, dtype=np.float32) @@ -764,9 +770,7 @@ def _calculate_sky_sqm(self, exposure_sec: float): # Create SQM calculator with the newly measured calibration # Use PROCESSED (8-bit) pipeline camera_type_processed = f"{self.shared_state.camera_type()}_processed" - sqm_calc = SQM( - camera_type=camera_type_processed, use_adaptive_noise_floor=True - ) + sqm_calc = SQM(camera_type=camera_type_processed) # Manually set the calibration values we just measured if ( diff --git a/python/PiFinder/ui/sqm_correction.py b/python/PiFinder/ui/sqm_correction.py index aa27f436a..ab88f8301 100644 --- a/python/PiFinder/ui/sqm_correction.py +++ b/python/PiFinder/ui/sqm_correction.py @@ -5,6 +5,7 @@ import os import json +import time import zipfile import logging from datetime import datetime @@ -137,7 +138,7 @@ def update(self, force=False): (0, message_y), self.error_message, font=self.fonts.base.font, - fill=self.colors.get(255, red=True), + fill=self.colors.get(255), ) else: self.error_message = None @@ -205,6 +206,11 @@ def key_right(self): self.message_time = datetime.now() return + # Show saving message and force screen update + self.success_message = _("Saving...") + self.message_time = datetime.now() + self.update(force=True) + # Create correction package try: zip_path = self._create_correction_package(corrected_sqm) @@ -218,14 +224,65 @@ def key_right(self): self.error_message = _("Save failed") self.message_time = datetime.now() + def _wait_for_exposure(self, target_exp_us: int, timeout: float = 5.0) -> bool: + """Wait for camera to capture image at target exposure.""" + start = time.time() + while time.time() - start < timeout: + metadata = self.shared_state.last_image_metadata() + if ( + metadata + and abs(metadata.get("exposure_time", 0) - target_exp_us) < 1000 + ): + # Wait one more frame for image to be ready + time.sleep(0.3) + return True + time.sleep(0.1) + return False + + def _capture_at_exposure( + self, exp_us: int, corrections_dir: Path, timestamp: str, label: str + ) -> Dict[str, Any]: + """Capture image at specific exposure and return file info.""" + # Set exposure + self.command_queues["camera"].put(f"set_exp:{exp_us}") + + # Wait for image at this exposure + if not self._wait_for_exposure(exp_us): + logger.warning(f"Timeout waiting for exposure {exp_us}µs") + return {} + + # Capture images + camera_image = self.camera_image.copy() + raw_image = self.shared_state.cam_raw() + + files: Dict[str, Any] = {} + + # Save processed PNG + processed_path = f"correction_{timestamp}_{label}_processed.png" + camera_image.save(str(corrections_dir / processed_path)) + files["processed"] = processed_path + + # Save raw TIFF if available + if raw_image is not None: + raw_path = f"correction_{timestamp}_{label}_raw.tiff" + raw_image_pil = Image.fromarray(np.asarray(raw_image, dtype=np.uint16)) + raw_image_pil.save(str(corrections_dir / raw_path)) + files["raw"] = raw_path + + files["exposure_us"] = exp_us + return files + def _create_correction_package(self, corrected_sqm: float) -> str: """ - Create a zip file containing correction data. + Create a zip file containing correction data with bracketed exposures. + + Captures 3 exposures: current, +1 stop (2x), -1 stop (0.5x) + If +1 stop exceeds max (1s), captures -2 stops instead. Returns: Path to created zip file """ - # Create corrections directory (consistent with captures/ and solver_debug_dumps/) + # Create corrections directory corrections_dir = Path(utils.data_dir) / "captures" / "sqm_corrections" corrections_dir.mkdir(parents=True, exist_ok=True) @@ -234,37 +291,80 @@ def _create_correction_package(self, corrected_sqm: float) -> str: zip_filename = f"sqm_correction_{timestamp}.zip" zip_path = corrections_dir / zip_filename - # Capture current camera image - camera_image = self.camera_image.copy() - - # Get raw image from shared state - raw_image = self.shared_state.cam_raw() + # Get current exposure + image_metadata = self.shared_state.last_image_metadata() + current_exp = ( + image_metadata.get("exposure_time", 500000) if image_metadata else 500000 + ) - # Collect metadata + # Calculate bracket exposures (in microseconds) + max_exp = 1000000 # 1 second max + min_exp = 10000 # 10ms min + + exp_above = min(current_exp * 2, max_exp) + exp_below = max(current_exp // 2, min_exp) + exp_below2 = max(current_exp // 4, min_exp) + + # Determine which 3 exposures to capture + if exp_above <= current_exp: + # Can't go above, use current and two below + exposures = [ + (current_exp, "base"), + (exp_below, "minus1"), + (exp_below2, "minus2"), + ] + else: + # Normal bracket: below, current, above + exposures = [ + (exp_below, "minus1"), + (current_exp, "base"), + (exp_above, "plus1"), + ] + + # Collect metadata first (before changing exposure) metadata = self._collect_metadata(corrected_sqm) + metadata["bracket_exposures"] = [] + + # Capture all bracketed exposures + all_files = [] + for exp_us, label in exposures: + # Update saving message + self.success_message = _("Saving {label}...").format(label=label) + self.update(force=True) + + files = self._capture_at_exposure(exp_us, corrections_dir, timestamp, label) + if files: + all_files.append(files) + metadata["bracket_exposures"].append( + { + "label": label, + "exposure_us": exp_us, + "exposure_sec": exp_us / 1_000_000.0, + } + ) + + # Re-enable auto-exposure + self.command_queues["camera"].put("set_exp:auto") # Create zip file with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - # Add processed 8-bit PNG - processed_path = f"correction_{timestamp}_processed.png" - camera_image.save(str(corrections_dir / processed_path)) - zf.write(corrections_dir / processed_path, arcname=processed_path) - (corrections_dir / processed_path).unlink() # Clean up temp file - - # Add raw 16-bit TIFF if available - if raw_image is not None: - raw_path = f"correction_{timestamp}_raw.tiff" - raw_image_pil = Image.fromarray(np.asarray(raw_image, dtype=np.uint16)) - raw_image_pil.save(str(corrections_dir / raw_path)) - zf.write(corrections_dir / raw_path, arcname=raw_path) - (corrections_dir / raw_path).unlink() # Clean up temp file + # Add all captured images + for files in all_files: + if "processed" in files: + zf.write( + corrections_dir / files["processed"], arcname=files["processed"] + ) + (corrections_dir / files["processed"]).unlink() + if "raw" in files: + zf.write(corrections_dir / files["raw"], arcname=files["raw"]) + (corrections_dir / files["raw"]).unlink() # Add metadata JSON metadata_path = f"correction_{timestamp}_metadata.json" with open(corrections_dir / metadata_path, "w") as f: json.dump(metadata, f, indent=2, default=str) zf.write(corrections_dir / metadata_path, arcname=metadata_path) - (corrections_dir / metadata_path).unlink() # Clean up temp file + (corrections_dir / metadata_path).unlink() return str(zip_path) @@ -279,10 +379,10 @@ def _collect_metadata(self, corrected_sqm: float) -> Dict[str, Any]: }, } - # Get SQM state for raw value + # Get SQM source sqm_state = self.shared_state.sqm() - if sqm_state.value_raw is not None: - metadata["sqm"]["original_raw"] = sqm_state.value_raw + if sqm_state.source: + metadata["sqm"]["source"] = sqm_state.source # Get GPS location location = self.shared_state.location() @@ -319,11 +419,20 @@ def _collect_metadata(self, corrected_sqm: float) -> Dict[str, Any]: "exposure_us": image_metadata.get("exposure_time"), "exposure_sec": image_metadata.get("exposure_time", 0) / 1_000_000.0, "gain": image_metadata.get("gain"), + "imu_delta": image_metadata.get("imu_delta"), } # Get camera type metadata["camera_type"] = self.shared_state.camera_type() + # Get adaptive noise floor (used for auto-exposure and SQM pedestal) + metadata["noise_floor_adu"] = self.shared_state.noise_floor() + + # Get full SQM calculation details (mzero, background, extinction, etc.) + sqm_details = self.shared_state.sqm_details() + if sqm_details: + metadata["sqm_calculation"] = sqm_details + return metadata def active(self): diff --git a/python/PiFinder/ui/status.py b/python/PiFinder/ui/status.py index 2f5324690..f45a2a998 100644 --- a/python/PiFinder/ui/status.py +++ b/python/PiFinder/ui/status.py @@ -81,13 +81,11 @@ class UIStatus(UIModule): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.version_txt = f"{utils.pifinder_dir}/version.txt" self.wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" self._draw_pos = (0, self.display_class.titlebar_height) with open(self.wifi_txt, "r") as wfs: self._config_options["WiFi Mode"]["value"] = wfs.read() - with open(self.version_txt, "r") as ver: - self._config_options["Software"]["value"] = ver.read() + self._config_options["Software"]["value"] = utils.get_version() self.spacecalc = SpaceCalculatorFixed(self.fonts.base.line_length) self.status_dict = { "LST SLV": "--", @@ -144,14 +142,11 @@ def __init__(self, *args, **kwargs): def update_software(self, option): if option == "CANCEL": - with open(self.version_txt, "r") as ver: - self._config_options["Software"]["value"] = ver.read() + self._config_options["Software"]["value"] = utils.get_version() return False - self.message("Updating...", 10) if sys_utils.update_software(): - self.message("Ok! Restarting", 10) - sys_utils.restart_pifinder() + self.message("Updating...", 10) else: self.message("Error on Upd", 3) @@ -246,13 +241,11 @@ def update_status_dict(self): + f" {stars_matched: >2}" ) hh, mm, _ = calc_utils.ra_to_hms(solution["RA"]) - self.status_dict["RA/DEC"] = ( - f"{hh:02.0f}h{mm:02.0f}m/{solution['Dec'] :.2f}" - ) + self.status_dict["RA/DEC"] = f"{hh:02.0f}h{mm:02.0f}m/{solution['Dec']:.2f}" if solution["Az"]: self.status_dict["AZ/ALT"] = ( - f"{solution['Az'] : >6.2f}/{solution['Alt'] : >6.2f}" + f"{solution['Az']: >6.2f}/{solution['Alt']: >6.2f}" ) imu = self.shared_state.imu() @@ -262,9 +255,9 @@ def update_status_dict(self): mtext = "Moving" else: mtext = "Static" - self.status_dict["IMU"] = f"{mtext : >11}" + " " + str(imu["status"]) + self.status_dict["IMU"] = f"{mtext: >11}" + " " + str(imu["status"]) self.status_dict["IMU PS"] = ( - f"{imu['pos'][0] : >6.1f}/{imu['pos'][2] : >6.1f}" + f"{imu['pos'][0]: >6.1f}/{imu['pos'][2]: >6.1f}" ) location = self.shared_state.location() sats = self.shared_state.sats() @@ -290,7 +283,7 @@ def update_status_dict(self): try: with open("/sys/class/thermal/thermal_zone0/temp", "r") as f: raw_temp = int(f.read().strip()) - self.status_dict["CPU TMP"] = f"{raw_temp / 1000 : >13.1f}" + self.status_dict["CPU TMP"] = f"{raw_temp / 1000: >13.1f}" except FileNotFoundError: self.status_dict["CPU TMP"] = "Error" @@ -306,7 +299,10 @@ def update_status_dict(self): def update(self, force=False): time.sleep(1 / 30) self.update_status_dict() - self.draw.rectangle([0, 0, 128, 128], fill=self.colors.get(0)) + self.draw.rectangle( + [0, 0, self.display_class.resX, self.display_class.resY], + fill=self.colors.get(0), + ) lines = [] # Insert IP address here... for k, v in self.status_dict.items(): diff --git a/python/PiFinder/ui/ui_utils.py b/python/PiFinder/ui/ui_utils.py index 37e860537..647b98a77 100644 --- a/python/PiFinder/ui/ui_utils.py +++ b/python/PiFinder/ui/ui_utils.py @@ -314,7 +314,7 @@ def format_number(num: float, width=5): return f"{num:{width}d}" elif num < 1000000: decimal_places = max(0, width - 3) # 'K' and at least one digit - return f"{num/1000:{width}.{decimal_places}f}K" + return f"{num / 1000:{width}.{decimal_places}f}K" else: decimal_places = max(0, width - 3) # 'M' and at least one digit - return f"{num/1000000:{width}.{decimal_places}f}M" + return f"{num / 1000000:{width}.{decimal_places}f}M" diff --git a/python/PiFinder/utils.py b/python/PiFinder/utils.py index 951bc25b9..411cb2a45 100644 --- a/python/PiFinder/utils.py +++ b/python/PiFinder/utils.py @@ -10,12 +10,23 @@ cwd_dir = Path.cwd() pifinder_dir = Path("..") astro_data_dir = cwd_dir / pifinder_dir / "astro_data" -tetra3_dir = pifinder_dir / "python/PiFinder/tetra3/tetra3" +tetra3_dir = pifinder_dir / "python/PiFinder/tetra3" data_dir = Path(Path.home(), "PiFinder_data") pifinder_db = astro_data_dir / "pifinder_objects.db" observations_db = data_dir / "observations.db" +build_json = pifinder_dir / "pifinder-build.json" + + +def get_version() -> str: + try: + with open(build_json, "r") as f: + return json.load(f).get("version", "Unknown") + except (FileNotFoundError, IOError, json.JSONDecodeError): + return "Unknown" + + debug_dump_dir = data_dir / "solver_debug_dumps" -comet_file = astro_data_dir / Path("comets.txt") +comet_file = data_dir / "comets.txt" def create_dir(adir: str): @@ -46,11 +57,9 @@ def serialize_solution(solution: dict) -> str: def get_sys_utils(): try: - # Attempt to import the real sys_utils - sys_utils = importlib.import_module("PiFinder.sys_utils") - except ImportError: - sys_utils = importlib.import_module("PiFinder.sys_utils_fake") - return sys_utils + return importlib.import_module("PiFinder.sys_utils") + except Exception: + return importlib.import_module("PiFinder.sys_utils_fake") def get_os_info(): diff --git a/python/noxfile.py b/python/noxfile.py deleted file mode 100644 index e407e8724..000000000 --- a/python/noxfile.py +++ /dev/null @@ -1,102 +0,0 @@ -import nox - -nox.options.sessions = ["lint", "format", "type_hints", "smoke_tests"] - - -@nox.session(reuse_venv=True, python="3.9") -def lint(session: nox.Session) -> None: - """ - Lint the project's codebase. - - This session installs necessary dependencies for linting and then runs the linter to check for - stylistic errors and coding standards compliance across the project's codebase. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("ruff==0.4.8") - session.run("ruff", "check", "--fix", "--config", "builtins=['_']") - - -@nox.session(reuse_venv=True, python="3.9") -def format(session: nox.Session) -> None: - """ - Format the project's codebase. - - This session installs necessary dependencies for code formatting and runs the formatter - to check (and optionally correct) the code format according to the project's style guide. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("ruff==0.4.8") - session.run("ruff", "format") - - -@nox.session(reuse_venv=True, python="3.9") -def type_hints(session: nox.Session) -> None: - """ - Check type hints in the project's codebase. - - This session installs necessary dependencies for type checking and runs a static type checker - to validate the type hints throughout the project's codebase, ensuring they are correct and consistent. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - session.run("mypy", "--install-types", "--non-interactive", ".") - - -@nox.session(reuse_venv=True, python="3.9") -def unit_tests(session: nox.Session) -> None: - """ - Run the project's unit tests. - - This session installs the necessary dependencies and runs the project's unit tests. - It is focused on testing the functionality of individual units of code in isolation. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - session.run("pytest", "-m", "unit") - - -@nox.session(reuse_venv=True, python="3.9") -def smoke_tests(session: nox.Session) -> None: - """ - Run the project's smoke tests. - nox - This session installs the necessary dependencies and runs a subset of tests designed to quickly - check the most important functions of the program, often as a prelude to more thorough testing. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - session.run("pytest", "-m", "smoke") - - -@nox.session(reuse_venv=True, python="3.9") -def babel(session: nox.Session) -> None: - """ - Run the I18N toolchain - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - - session.run( - "pybabel", - "extract", - "-c", - "TRANSLATORS", - "-o", - "locale/messages.pot", - "./PiFinder", - ) - session.run("pybabel", "update", "-i", "locale/messages.pot", "-d", "locale") - session.run("pybabel", "compile", "-d", "locale") diff --git a/python/pyproject.toml b/python/pyproject.toml index 5617b6ec3..9f420f299 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,6 +4,8 @@ mapping = [ ] [tool.ruff] +builtins = ["_"] + # Exclude a variety of commonly ignored directories. exclude = [ ".bzr", @@ -39,8 +41,8 @@ exclude = [ line-length = 88 indent-width = 4 -# Assume Python 3.9 -target-version = "py39" +# Assume Python 3.13 +target-version = "py313" [tool.ruff.lint] # Enable preview mode, allow os.env changes before imports @@ -59,6 +61,9 @@ unfixable = [] # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +[tool.ruff.lint.per-file-ignores] +"*.ipynb" = ["E402", "F841"] + [tool.ruff.format] # Like Black, use double quotes for strings. quote-style = "double" @@ -87,7 +92,7 @@ docstring-code-format = false docstring-code-line-length = "dynamic" [tool.mypy] -exclude = "venv|tetra3" +exclude = "venv|tetra3|noxfile\\.py" # Start off with these warn_unused_configs = true warn_redundant_casts = true @@ -135,6 +140,19 @@ module = [ 'picamera2', 'bottle', 'libinput', + 'pytz', + 'aiofiles', + 'requests', + 'tqdm', + 'pandas', + 'rpi_hardware_pwm', + 'gpsdclient', + 'timezonefinder', + 'pydeepskylog.*', + 'dbus', + 'pam', + 'gi', + 'gi.*', ] ignore_missing_imports = true ignore_errors = true diff --git a/python/requirements.txt b/python/requirements.txt deleted file mode 100644 index 5f92091ab..000000000 --- a/python/requirements.txt +++ /dev/null @@ -1,27 +0,0 @@ -adafruit-blinka==8.12.0 -adafruit-circuitpython-bno055 -bottle==0.12.25 -cheroot==10.0.0 -dataclasses_json==0.6.7 -gpsdclient==1.3.2 -grpcio==1.64.1 -json5==0.9.25 -luma.oled==3.12.0 -luma.lcd==2.11.0 -pillow==10.4.0 -numpy==1.26.2 -pandas==1.5.3 -pydeepskylog==1.3.2 -pyjwt==2.8.0 -python-libinput==0.3.0a0 -pytz==2022.7.1 -requests==2.28.2 -rpi-hardware-pwm==0.1.4 -scipy -scikit-learn==1.2.2 -sh==1.14.3 -skyfield==1.45 -timezonefinder==6.1.9 -tqdm==4.65.0 -protobuf==4.25.2 -aiofiles==24.1.0 diff --git a/python/requirements_dev.txt b/python/requirements_dev.txt deleted file mode 100644 index 5264c55d7..000000000 --- a/python/requirements_dev.txt +++ /dev/null @@ -1,13 +0,0 @@ -# dev requirements -luma.emulator==1.5.0 -PyHotKey==1.5.2 -ruff==0.4.8 -nox==2024.4.15 -mypy==1.10.0 -pytest==8.2.2 -pygame==2.6.1 -pre-commit==3.7.1 -Babel==2.16.0 -xlrd==2.0.2 -# Pin to avoid pyobjc 12.0 which has macOS 15 build issues -pyobjc-framework-Quartz==11.1; sys_platform == "darwin" diff --git a/python/tests/test_auto_exposure.py b/python/tests/test_auto_exposure.py index 5f4c24a57..bd9e539e2 100644 --- a/python/tests/test_auto_exposure.py +++ b/python/tests/test_auto_exposure.py @@ -77,14 +77,15 @@ def test_initialization(self): handler = SweepZeroStarHandler() assert not handler.is_active() assert handler._trigger_count == 2 + # Sweep starts at 400ms, goes up, then tries shorter exposures assert handler._exposures == [ - 25000, - 50000, - 100000, - 200000, 400000, 800000, 1000000, + 200000, + 100000, + 50000, + 25000, ] assert handler._repeats_per_exposure == 2 @@ -104,78 +105,74 @@ def test_trigger_delay(self): assert result is None assert not handler.is_active() - # Second zero - should activate and return first exposure + # Second zero - should activate and return first exposure (400ms) result = handler.handle(50000, 2) - assert result == 25000 + assert result == 400000 assert handler.is_active() - def test_sweep_pattern_short_exposures(self): - """Sweep repeats each short exposure 2 times.""" + def test_sweep_pattern_start_at_400ms(self): + """Sweep starts at 400ms and repeats each exposure 2 times.""" handler = SweepZeroStarHandler(trigger_count=1) - # Activate sweep + # Activate sweep - starts at 400ms result = handler.handle(50000, 1) - assert result == 25000 # First attempt at 25ms + assert result == 400000 # First attempt at 400ms - # Second attempt at 25ms + # Second attempt at 400ms result = handler.handle(50000, 2) - assert result == 25000 + assert result == 400000 - # Move to 50ms + # Move to 800ms result = handler.handle(50000, 3) - assert result == 50000 + assert result == 800000 - # Second attempt at 50ms + # Second attempt at 800ms result = handler.handle(50000, 4) - assert result == 50000 + assert result == 800000 - # Move to 100ms + # Move to 1000ms result = handler.handle(50000, 5) - assert result == 100000 + assert result == 1000000 - def test_400ms_normal_behavior(self): - """Sweep treats 400ms like other exposures (2 attempts).""" + def test_sweep_continues_to_shorter_exposures(self): + """After longer exposures, sweep tries shorter ones.""" handler = SweepZeroStarHandler(trigger_count=1) - # Skip to 400ms by advancing through earlier exposures - # 25ms: 2 times - handler.handle(50000, 1) # 25ms attempt 1 - handler.handle(50000, 2) # 25ms attempt 2 - # 50ms: 2 times - handler.handle(50000, 3) # 50ms attempt 1 - handler.handle(50000, 4) # 50ms attempt 2 - # 100ms: 2 times - handler.handle(50000, 5) # 100ms attempt 1 - handler.handle(50000, 6) # 100ms attempt 2 - # 200ms: 2 times - handler.handle(50000, 7) # 200ms attempt 1 - handler.handle(50000, 8) # 200ms attempt 2 - - # Now at 400ms - should repeat 2 times like other exposures - result = handler.handle(50000, 9) - assert result == 400000 # Attempt 1 + # 400ms: 2 times (first in sweep) + handler.handle(50000, 1) # 400ms attempt 1 + handler.handle(50000, 2) # 400ms attempt 2 + # 800ms: 2 times + handler.handle(50000, 3) # 800ms attempt 1 + handler.handle(50000, 4) # 800ms attempt 2 + # 1000ms: 2 times + handler.handle(50000, 5) # 1000ms attempt 1 + handler.handle(50000, 6) # 1000ms attempt 2 + + # Now at 200ms - sweep continues with shorter exposures + result = handler.handle(50000, 7) + assert result == 200000 # Attempt 1 - result = handler.handle(50000, 10) - assert result == 400000 # Attempt 2 + result = handler.handle(50000, 8) + assert result == 200000 # Attempt 2 - # After 2 cycles, move to 800ms - result = handler.handle(50000, 11) - assert result == 800000 + # Move to 100ms + result = handler.handle(50000, 9) + assert result == 100000 def test_sweep_wraps_around(self): - """Sweep wraps back to minimum after reaching maximum.""" + """Sweep wraps back to start (400ms) after completing all exposures.""" handler = SweepZeroStarHandler(trigger_count=1) # Fast-forward through entire sweep - # 25ms (2×), 50ms (2×), 100ms (2×), 200ms (2×), 400ms (2×), 800ms (2×), 1000ms (2×) + # 400ms (2×), 800ms (2×), 1000ms (2×), 200ms (2×), 100ms (2×), 50ms (2×), 25ms (2×) total_cycles = 2 + 2 + 2 + 2 + 2 + 2 + 2 # = 14 for i in range(1, total_cycles + 1): handler.handle(50000, i) - # Next cycle should wrap to 25ms + # Next cycle should wrap to 400ms (start of sweep) result = handler.handle(50000, total_cycles + 1) - assert result == 25000 + assert result == 400000 def test_reset(self): """Reset clears handler state.""" @@ -509,18 +506,18 @@ def test_full_zero_star_recovery_cycle(self): result = pid.update(0, current_exposure) assert result is None # Trigger count = 2 - # Zero stars (second time) - sweep activates + # Zero stars (second time) - sweep activates at 400ms result = pid.update(0, current_exposure) - assert result == 25000 # Sweep starts at 25ms + assert result == 400000 # Sweep starts at 400ms assert pid._zero_star_handler.is_active() # Continue sweep result = pid.update(0, current_exposure) - assert result == 25000 # Second attempt at 25ms + assert result == 400000 # Second attempt at 400ms # Still zero stars, sweep continues result = pid.update(0, current_exposure) - assert result == 50000 # Move to 50ms + assert result == 800000 # Move to 800ms # Find stars again - return to PID result = pid.update(15, 50000) diff --git a/python/tests/test_catalog_data.py b/python/tests/test_catalog_data.py index 845cc6752..ee6628d51 100644 --- a/python/tests/test_catalog_data.py +++ b/python/tests/test_catalog_data.py @@ -41,9 +41,9 @@ def test_object_counts(): expected_catalogs = list(catalog_counts.keys()) missing_catalogs = set(expected_catalogs) - set(actual_catalogs) extra_catalogs = set(actual_catalogs) - set(expected_catalogs) - assert ( - not missing_catalogs and not extra_catalogs - ), f"Catalog mismatch. Missing catalogs: {sorted(missing_catalogs)}. Extra catalogs: {sorted(extra_catalogs)}" + assert not missing_catalogs and not extra_catalogs, ( + f"Catalog mismatch. Missing catalogs: {sorted(missing_catalogs)}. Extra catalogs: {sorted(extra_catalogs)}" + ) # Catalog Counts for catalog_code, count in catalog_counts.items(): @@ -93,20 +93,20 @@ def check_messier_objects(): # Validate M45 coordinates (Pleiades) # Expected: RA=56.85°, Dec=+24.117° - assert coords_are_close( - m45_obj["ra"], 56.85 - ), f"M45 RA should be ~56.85°, got {m45_obj['ra']}" - assert coords_are_close( - m45_obj["dec"], 24.117 - ), f"M45 Dec should be ~24.117°, got {m45_obj['dec']}" + assert coords_are_close(m45_obj["ra"], 56.85), ( + f"M45 RA should be ~56.85°, got {m45_obj['ra']}" + ) + assert coords_are_close(m45_obj["dec"], 24.117), ( + f"M45 Dec should be ~24.117°, got {m45_obj['dec']}" + ) # Validate M45 object type and constellation - assert ( - m45_obj["obj_type"] == "OC" - ), f"M45 should be type 'OC' (open cluster), got '{m45_obj['obj_type']}'" - assert ( - m45_obj["const"] == "Tau" - ), f"M45 should be in Taurus (Tau), got '{m45_obj['const']}'" + assert m45_obj["obj_type"] == "OC", ( + f"M45 should be type 'OC' (open cluster), got '{m45_obj['obj_type']}'" + ) + assert m45_obj["const"] == "Tau", ( + f"M45 should be in Taurus (Tau), got '{m45_obj['const']}'" + ) # Test M40 - Winnecke 4 (should have been added by post-processing) m40_catalog_obj = db.get_catalog_object_by_sequence("M", 40) @@ -117,20 +117,20 @@ def check_messier_objects(): # Validate M40 coordinates (Winnecke 4) # Expected: RA=185.552°, Dec=+58.083° - assert coords_are_close( - m40_obj["ra"], 185.552 - ), f"M40 RA should be ~185.552°, got {m40_obj['ra']}" - assert coords_are_close( - m40_obj["dec"], 58.083 - ), f"M40 Dec should be ~58.083°, got {m40_obj['dec']}" + assert coords_are_close(m40_obj["ra"], 185.552), ( + f"M40 RA should be ~185.552°, got {m40_obj['ra']}" + ) + assert coords_are_close(m40_obj["dec"], 58.083), ( + f"M40 Dec should be ~58.083°, got {m40_obj['dec']}" + ) # Validate M40 object type and constellation - assert ( - m40_obj["obj_type"] == "D*" - ), f"M40 should be type 'D*' (double star), got '{m40_obj['obj_type']}'" - assert ( - m40_obj["const"] == "UMa" - ), f"M40 should be in Ursa Major (UMa), got '{m40_obj['const']}'" + assert m40_obj["obj_type"] == "D*", ( + f"M40 should be type 'D*' (double star), got '{m40_obj['obj_type']}'" + ) + assert m40_obj["const"] == "UMa", ( + f"M40 should be in Ursa Major (UMa), got '{m40_obj['const']}'" + ) def check_ngc_objects(): @@ -190,32 +190,32 @@ def check_ngc_objects(): # Get object from database catalog_obj = db.get_catalog_object_by_sequence("NGC", ngc_num) - assert ( - catalog_obj is not None - ), f"NGC {ngc_num} ({name}) should exist in catalog" + assert catalog_obj is not None, ( + f"NGC {ngc_num} ({name}) should exist in catalog" + ) obj = db.get_object_by_id(catalog_obj["object_id"]) assert obj is not None, f"NGC {ngc_num} ({name}) object should exist" # Check coordinates (allow 0.1 degree tolerance for coordinate precision) - assert coords_are_close( - obj["ra"], test_obj["ra"], tolerance=0.1 - ), f"NGC {ngc_num} ({name}) RA should be ~{test_obj['ra']}°, got {obj['ra']}°" + assert coords_are_close(obj["ra"], test_obj["ra"], tolerance=0.1), ( + f"NGC {ngc_num} ({name}) RA should be ~{test_obj['ra']}°, got {obj['ra']}°" + ) - assert coords_are_close( - obj["dec"], test_obj["dec"], tolerance=0.1 - ), f"NGC {ngc_num} ({name}) Dec should be ~{test_obj['dec']}°, got {obj['dec']}°" + assert coords_are_close(obj["dec"], test_obj["dec"], tolerance=0.1), ( + f"NGC {ngc_num} ({name}) Dec should be ~{test_obj['dec']}°, got {obj['dec']}°" + ) # Check object type - assert ( - obj["obj_type"] == test_obj["obj_type"] - ), f"NGC {ngc_num} ({name}) should be type '{test_obj['obj_type']}', got '{obj['obj_type']}'" + assert obj["obj_type"] == test_obj["obj_type"], ( + f"NGC {ngc_num} ({name}) should be type '{test_obj['obj_type']}', got '{obj['obj_type']}'" + ) # Check constellation (if provided) if test_obj["const"]: - assert ( - obj["const"] == test_obj["const"] - ), f"NGC {ngc_num} ({name}) should be in {test_obj['const']}, got '{obj['const']}'" + assert obj["const"] == test_obj["const"], ( + f"NGC {ngc_num} ({name}) should be in {test_obj['const']}, got '{obj['const']}'" + ) print( f"✓ NGC {ngc_num} ({name}): RA={obj['ra']:.3f}°, Dec={obj['dec']:.3f}°, Type={obj['obj_type']}, Const={obj['const']}" @@ -286,24 +286,24 @@ def check_ic_objects(): assert obj is not None, f"IC {ic_num} ({name}) object should exist" # Check coordinates (allow 0.1 degree tolerance for coordinate precision) - assert coords_are_close( - obj["ra"], test_obj["ra"], tolerance=0.1 - ), f"IC {ic_num} ({name}) RA should be ~{test_obj['ra']}°, got {obj['ra']}°" + assert coords_are_close(obj["ra"], test_obj["ra"], tolerance=0.1), ( + f"IC {ic_num} ({name}) RA should be ~{test_obj['ra']}°, got {obj['ra']}°" + ) - assert coords_are_close( - obj["dec"], test_obj["dec"], tolerance=0.1 - ), f"IC {ic_num} ({name}) Dec should be ~{test_obj['dec']}°, got {obj['dec']}°" + assert coords_are_close(obj["dec"], test_obj["dec"], tolerance=0.1), ( + f"IC {ic_num} ({name}) Dec should be ~{test_obj['dec']}°, got {obj['dec']}°" + ) # Check object type - assert ( - obj["obj_type"] == test_obj["obj_type"] - ), f"IC {ic_num} ({name}) should be type '{test_obj['obj_type']}', got '{obj['obj_type']}'" + assert obj["obj_type"] == test_obj["obj_type"], ( + f"IC {ic_num} ({name}) should be type '{test_obj['obj_type']}', got '{obj['obj_type']}'" + ) # Check constellation (if provided) if test_obj["const"]: - assert ( - obj["const"] == test_obj["const"] - ), f"IC {ic_num} ({name}) should be in {test_obj['const']}, got '{obj['const']}'" + assert obj["const"] == test_obj["const"], ( + f"IC {ic_num} ({name}) should be in {test_obj['const']}, got '{obj['const']}'" + ) print( f"✓ IC {ic_num} ({name}): RA={obj['ra']:.3f}°, Dec={obj['dec']:.3f}°, Type={obj['obj_type']}, Const={obj['const']}" @@ -382,9 +382,9 @@ def on_complete(objects): # Verify results assert loaded_count == 100, f"Expected 100 objects, got {loaded_count}" - assert ( - len(loaded_objects) == 100 - ), f"Expected 100 loaded objects, got {len(loaded_objects)}" + assert len(loaded_objects) == 100, ( + f"Expected 100 loaded objects, got {len(loaded_objects)}" + ) # Verify objects have details loaded for obj in loaded_objects[:10]: # Check first 10 diff --git a/python/tests/test_limiting_magnitude.py b/python/tests/test_limiting_magnitude.py new file mode 100644 index 000000000..21641038f --- /dev/null +++ b/python/tests/test_limiting_magnitude.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +Unit tests for limiting magnitude calculations using Feijth & Comello formula +""" + +import pytest +from PiFinder.object_images.gaia_chart import GaiaChartGenerator + + +class TestFeijthComelloFormula: + """Test the Feijth & Comello limiting magnitude formula""" + + def test_reference_calculation(self): + """ + Test with Schaefer's reference values + + Reference from astrobasics.de: + If Schaefer's result is used with mv = 6.04, D = 25, d = 4, M = 400 + and t = 0.54 the following limiting magnitude results: 13.36 + + Formula: mg = mv - 2 + 2.5 × log₁₀(√(D² - d²) × M × t) + """ + mv = 6.04 # Naked eye limiting magnitude + D = 25.0 # Aperture in cm + d = 4.0 # Obstruction diameter in cm + M = 400.0 # Magnification + t = 0.54 # Transmission + + result = GaiaChartGenerator.feijth_comello_limiting_magnitude(mv, D, d, M, t) + + # Should be 13.36 according to reference (allow 0.1 mag tolerance) + assert abs(result - 13.36) < 0.1, f"Expected ~13.36, got {result:.2f}" + + def test_unobstructed_telescope(self): + """Test with no central obstruction (refractor/unobstructed Newtonian)""" + mv = 6.0 + D = 20.0 # 200mm aperture + d = 0.0 # No obstruction + M = 100.0 + t = 0.85 + + result = GaiaChartGenerator.feijth_comello_limiting_magnitude(mv, D, d, M, t) + + # Should give reasonable result (12-14 range for 200mm scope) + assert 10.0 < result < 15.0, f"Result {result:.2f} outside expected range" + + def test_higher_magnification_improves_lm(self): + """ + Test that higher magnification improves limiting magnitude + (darkens sky background, improving contrast) + """ + mv = 6.0 + D = 20.0 + d = 0.0 + t = 0.85 + + lm_40x = GaiaChartGenerator.feijth_comello_limiting_magnitude(mv, D, d, 40.0, t) + lm_100x = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, d, 100.0, t + ) + lm_200x = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, d, 200.0, t + ) + + # Higher magnification should give better (larger number) limiting magnitude + assert lm_100x > lm_40x, f"100x ({lm_100x:.2f}) should be > 40x ({lm_40x:.2f})" + assert lm_200x > lm_100x, ( + f"200x ({lm_200x:.2f}) should be > 100x ({lm_100x:.2f})" + ) + + def test_larger_aperture_improves_lm(self): + """Test that larger aperture improves limiting magnitude""" + mv = 6.0 + d = 0.0 + M = 100.0 + t = 0.85 + + lm_80mm = GaiaChartGenerator.feijth_comello_limiting_magnitude(mv, 8.0, d, M, t) + lm_150mm = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, 15.0, d, M, t + ) + lm_250mm = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, 25.0, d, M, t + ) + + # Larger aperture should give better limiting magnitude + assert lm_150mm > lm_80mm, ( + f"150mm ({lm_150mm:.2f}) should be > 80mm ({lm_80mm:.2f})" + ) + assert lm_250mm > lm_150mm, ( + f"250mm ({lm_250mm:.2f}) should be > 150mm ({lm_150mm:.2f})" + ) + + def test_obstruction_reduces_lm(self): + """Test that central obstruction reduces limiting magnitude""" + mv = 6.0 + D = 20.0 + M = 100.0 + t = 0.85 + + lm_no_obstruction = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, 0.0, M, t + ) + lm_with_obstruction = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, 5.0, M, t + ) + + # Obstruction should reduce limiting magnitude + assert lm_no_obstruction > lm_with_obstruction, ( + f"Unobstructed ({lm_no_obstruction:.2f}) should be > obstructed ({lm_with_obstruction:.2f})" + ) + + def test_better_transmission_improves_lm(self): + """Test that better transmission improves limiting magnitude""" + mv = 6.0 + D = 20.0 + d = 0.0 + M = 100.0 + + lm_poor_transmission = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, d, M, 0.50 + ) + lm_good_transmission = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, d, M, 0.85 + ) + + # Better transmission should give better limiting magnitude + assert lm_good_transmission > lm_poor_transmission, ( + f"Good transmission ({lm_good_transmission:.2f}) should be > poor ({lm_poor_transmission:.2f})" + ) + + def test_darker_sky_improves_naked_eye_lm(self): + """ + Test that darker sky (higher mv) improves telescopic limiting magnitude + Since telescopic LM builds on naked eye LM + """ + D = 20.0 + d = 0.0 + M = 100.0 + t = 0.85 + + lm_bright_sky = GaiaChartGenerator.feijth_comello_limiting_magnitude( + 5.0, D, d, M, t + ) + lm_dark_sky = GaiaChartGenerator.feijth_comello_limiting_magnitude( + 6.5, D, d, M, t + ) + + # Darker sky should give better limiting magnitude + assert lm_dark_sky > lm_bright_sky, ( + f"Dark sky ({lm_dark_sky:.2f}) should be > bright sky ({lm_bright_sky:.2f})" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/tests/test_software.py b/python/tests/test_software.py new file mode 100644 index 000000000..c24c6a987 --- /dev/null +++ b/python/tests/test_software.py @@ -0,0 +1,503 @@ +import pytest +from unittest.mock import patch, MagicMock + +from PiFinder.ui.software import ( + _parse_version, + _strip_markdown, + _meets_min_version, + _version_from_tag, + _fetch_github_releases, + _fetch_testable_prs, + _fetch_build_json, + GITHUB_RAW_URL, +) + + +# --------------------------------------------------------------------------- +# Version parsing +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestParseVersion: + def test_simple_version(self): + assert _parse_version("2.4.0") == (2, 4, 0, 1, "") + + def test_prerelease_version(self): + result = _parse_version("2.5.0-beta.1") + assert result == (2, 5, 0, 0, "beta.1") + + def test_prerelease_sorts_below_release(self): + assert _parse_version("2.5.0-beta.1") < _parse_version("2.5.0") + + def test_whitespace_stripped(self): + assert _parse_version(" 2.4.0\n") == (2, 4, 0, 1, "") + + +# --------------------------------------------------------------------------- +# Markdown stripping +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestStripMarkdown: + def test_removes_headings(self): + assert _strip_markdown("# Hello") == "Hello" + assert _strip_markdown("## Sub") == "Sub" + + def test_removes_bold(self): + assert _strip_markdown("**bold**") == "bold" + + def test_removes_italic(self): + assert _strip_markdown("*italic*") == "italic" + + def test_removes_links(self): + assert _strip_markdown("[text](http://example.com)") == "text" + + def test_removes_backticks(self): + assert _strip_markdown("`code`") == "code" + + def test_preserves_plain_text(self): + assert _strip_markdown("Hello world") == "Hello world" + + def test_multiline(self): + md = "# Title\n\nSome **bold** text.\n- item" + result = _strip_markdown(md) + assert "Title" in result + assert "bold" in result + assert "**" not in result + + +# --------------------------------------------------------------------------- +# Min version cutoff +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestMeetsMinVersion: + def test_exact_min_version(self): + assert _meets_min_version("2.5.0") is True + + def test_above_min_version(self): + assert _meets_min_version("2.6.0") is True + + def test_below_min_version(self): + assert _meets_min_version("2.4.0") is False + + def test_prerelease_at_min(self): + # 2.5.0-beta.1 < 2.5.0, so below minimum + assert _meets_min_version("2.5.0-beta.1") is False + + def test_prerelease_above_min(self): + assert _meets_min_version("2.6.0-beta.1") is True + + def test_garbage_returns_false(self): + assert _meets_min_version("garbage") is False + + def test_old_major_version(self): + assert _meets_min_version("1.0.0") is False + + +@pytest.mark.unit +class TestVersionFromTag: + def test_strips_v_prefix(self): + assert _version_from_tag("v2.5.0") == "2.5.0" + + def test_no_prefix(self): + assert _version_from_tag("2.5.0") == "2.5.0" + + def test_prerelease_tag(self): + assert _version_from_tag("v2.6.0-beta.1") == "2.6.0-beta.1" + + +# --------------------------------------------------------------------------- +# Build JSON fetching +# --------------------------------------------------------------------------- + +MOCK_BUILD_JSON = { + "store_path": "/nix/store/abc123-nixos-system-pifinder", + "version": "2.6.0", +} + + +@pytest.mark.unit +class TestFetchBuildJson: + @patch("PiFinder.ui.software.requests.get") + def test_returns_data_on_success(self, mock_get): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = MOCK_BUILD_JSON + mock_get.return_value = mock_resp + + result = _fetch_build_json("v2.6.0") + + assert result == MOCK_BUILD_JSON + mock_get.assert_called_once_with( + f"{GITHUB_RAW_URL}/v2.6.0/pifinder-build.json", + timeout=10, + ) + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_on_404(self, mock_get): + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_get.return_value = mock_resp + + assert _fetch_build_json("v1.0.0") is None + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_on_missing_store_path(self, mock_get): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"version": "2.6.0"} + mock_get.return_value = mock_resp + + assert _fetch_build_json("v2.6.0") is None + + @patch("PiFinder.ui.software.requests.get") + def test_returns_none_on_network_error(self, mock_get): + import requests as req + + mock_get.side_effect = req.exceptions.ConnectionError("no network") + + assert _fetch_build_json("v2.6.0") is None + + +# --------------------------------------------------------------------------- +# GitHub releases API parsing +# --------------------------------------------------------------------------- + +MOCK_RELEASES = [ + { + "tag_name": "v2.6.0", + "prerelease": False, + "draft": False, + "body": "## v2.6.0\n- Feature A", + }, + { + "tag_name": "v2.5.1", + "prerelease": False, + "draft": False, + "body": "Bugfix release", + }, + { + "tag_name": "v2.6.0-beta.1", + "prerelease": True, + "draft": False, + "body": "Beta changelog", + }, + { + "tag_name": "v2.5.0-beta.2", + "prerelease": True, + "draft": False, + "body": "Old beta", + }, + { + "tag_name": "v2.4.0", + "prerelease": False, + "draft": False, + "body": "Pre-NixOS release", + }, + { + "tag_name": "v2.3.0", + "prerelease": False, + "draft": True, + "body": "Draft release", + }, +] + +BUILD_JSONS = { + "v2.6.0": { + "store_path": "/nix/store/aaa-nixos-system-pifinder", + "version": "2.6.0", + }, + "v2.5.1": { + "store_path": "/nix/store/bbb-nixos-system-pifinder", + "version": "2.5.1", + }, + "v2.6.0-beta.1": { + "store_path": "/nix/store/ccc-nixos-system-pifinder", + "version": "2.6.0-beta.1", + }, +} + + +def _make_build_json_mock(build_jsons): + """Create a _fetch_build_json mock that returns data from a dict.""" + + def _mock(ref): + return build_jsons.get(ref) + + return _mock + + +@pytest.mark.unit +class TestFetchGitHubReleases: + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_partitions_stable_and_beta(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = MOCK_RELEASES + mock_get.return_value = mock_resp + mock_build.side_effect = _make_build_json_mock(BUILD_JSONS) + + stable, beta = _fetch_github_releases() + + stable_versions = [e["version"] for e in stable] + beta_versions = [e["version"] for e in beta] + + assert "2.6.0" in stable_versions + assert "2.5.1" in stable_versions + assert "2.6.0-beta.1" in beta_versions + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_filters_below_min_version(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = MOCK_RELEASES + mock_get.return_value = mock_resp + mock_build.side_effect = _make_build_json_mock(BUILD_JSONS) + + stable, beta = _fetch_github_releases() + + all_versions = [e["version"] for e in stable + beta] + assert "2.4.0" not in all_versions + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_excludes_drafts(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = MOCK_RELEASES + mock_get.return_value = mock_resp + mock_build.side_effect = _make_build_json_mock(BUILD_JSONS) + + stable, beta = _fetch_github_releases() + + all_labels = [e["label"] for e in stable + beta] + assert "v2.3.0" not in all_labels + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_ref_is_store_path(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [MOCK_RELEASES[0]] + mock_get.return_value = mock_resp + mock_build.return_value = BUILD_JSONS["v2.6.0"] + + stable, _ = _fetch_github_releases() + + assert stable[0]["ref"] == "/nix/store/aaa-nixos-system-pifinder" + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_preserves_changelog_body(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [MOCK_RELEASES[0]] + mock_get.return_value = mock_resp + mock_build.return_value = BUILD_JSONS["v2.6.0"] + + stable, _ = _fetch_github_releases() + + assert stable[0]["notes"] == "## v2.6.0\n- Feature A" + + @patch("PiFinder.ui.software.requests.get") + def test_api_failure_returns_empty(self, mock_get): + mock_resp = MagicMock() + mock_resp.status_code = 500 + mock_get.return_value = mock_resp + + stable, beta = _fetch_github_releases() + + assert stable == [] + assert beta == [] + + @patch("PiFinder.ui.software.requests.get") + def test_network_error_returns_empty(self, mock_get): + import requests as req + + mock_get.side_effect = req.exceptions.ConnectionError("no network") + + stable, beta = _fetch_github_releases() + + assert stable == [] + assert beta == [] + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_prerelease_at_min_filtered(self, mock_get, mock_build): + """2.5.0-beta.2 is below 2.5.0 minimum, should be excluded.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = MOCK_RELEASES + mock_get.return_value = mock_resp + mock_build.side_effect = _make_build_json_mock(BUILD_JSONS) + + _, beta = _fetch_github_releases() + + beta_versions = [e["version"] for e in beta] + assert "2.5.0-beta.2" not in beta_versions + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_skips_entries_without_build_json(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [MOCK_RELEASES[0]] + mock_get.return_value = mock_resp + mock_build.return_value = None + + stable, beta = _fetch_github_releases() + + assert stable == [] + assert beta == [] + + +# --------------------------------------------------------------------------- +# Testable PRs +# --------------------------------------------------------------------------- + +MOCK_PRS = [ + { + "number": 42, + "title": "Fix star matching algorithm", + "head": {"sha": "abc123def456"}, + "user": {"login": "contributor1"}, + "body": "This PR fixes the star matching.", + "labels": [{"name": "testable"}], + }, + { + "number": 99, + "title": "Add dark mode support", + "head": {"sha": "789xyz000111"}, + "user": {"login": "contributor2"}, + "body": None, + "labels": [{"name": "testable"}], + }, +] + +PR_BUILD_JSONS = { + "abc123def456": { + "store_path": "/nix/store/pr42-nixos-system-pifinder", + "version": "2.6.0-dev", + }, + "789xyz000111": { + "store_path": "/nix/store/pr99-nixos-system-pifinder", + "version": "2.6.0-dev", + }, +} + + +@pytest.mark.unit +class TestFetchTestablePRs: + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_builds_pr_entries(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = MOCK_PRS + mock_get.return_value = mock_resp + mock_build.side_effect = _make_build_json_mock(PR_BUILD_JSONS) + + entries = _fetch_testable_prs() + + assert len(entries) == 2 + assert entries[0]["label"] == "PR#42-abc123d" + assert entries[0]["subtitle"] == "Fix star matching algorithm" + assert entries[1]["label"] == "PR#99-789xyz0" + assert entries[1]["subtitle"] == "Add dark mode support" + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_pr_ref_is_store_path(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [MOCK_PRS[0]] + mock_get.return_value = mock_resp + mock_build.return_value = PR_BUILD_JSONS["abc123def456"] + + entries = _fetch_testable_prs() + + assert entries[0]["ref"] == "/nix/store/pr42-nixos-system-pifinder" + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_pr_version_from_build_json(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [MOCK_PRS[0]] + mock_get.return_value = mock_resp + mock_build.return_value = PR_BUILD_JSONS["abc123def456"] + + entries = _fetch_testable_prs() + + assert entries[0]["version"] == "2.6.0-dev" + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_pr_notes_from_body(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = MOCK_PRS + mock_get.return_value = mock_resp + mock_build.side_effect = _make_build_json_mock(PR_BUILD_JSONS) + + entries = _fetch_testable_prs() + + assert entries[0]["notes"] == "This PR fixes the star matching." + assert entries[1]["notes"] is None + + @patch("PiFinder.ui.software.requests.get") + def test_api_failure_returns_empty(self, mock_get): + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_get.return_value = mock_resp + + entries = _fetch_testable_prs() + + assert entries == [] + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_long_title_in_subtitle(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [ + { + "number": 7, + "title": "A very long PR title that exceeds twenty characters", + "head": {"sha": "aaa"}, + "user": {"login": "x"}, + "body": None, + "labels": [{"name": "testable"}], + } + ] + mock_get.return_value = mock_resp + mock_build.return_value = { + "store_path": "/nix/store/pr7-nixos", + "version": "2.6.0-dev", + } + + entries = _fetch_testable_prs() + + assert entries[0]["label"] == "PR#7-aaa" + assert entries[0]["subtitle"] == ( + "A very long PR title that exceeds twenty characters" + ) + + @patch("PiFinder.ui.software._fetch_build_json") + @patch("PiFinder.ui.software.requests.get") + def test_skips_prs_without_build_json(self, mock_get, mock_build): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = MOCK_PRS + mock_get.return_value = mock_resp + mock_build.return_value = None + + entries = _fetch_testable_prs() + + assert entries == [] diff --git a/python/tests/test_sqm.py b/python/tests/test_sqm.py index c689ca0cd..ea33ba887 100644 --- a/python/tests/test_sqm.py +++ b/python/tests/test_sqm.py @@ -11,23 +11,30 @@ class TestSQMExtinction: """ def test_extinction_at_zenith(self): - """Test that extinction at zenith is exactly 0.28 mag (1.0 airmass)""" + """Test that extinction at zenith is 0.0 mag (ASTAP convention: zenith is reference)""" sqm = SQM() extinction = sqm._atmospheric_extinction(90.0) - assert extinction == pytest.approx(0.28, abs=0.0001) + assert extinction == pytest.approx(0.0, abs=0.0001) def test_extinction_at_45_degrees(self): - """Test extinction at 45° altitude (airmass ≈ 1.414)""" + """Test extinction at 45° altitude using Pickering airmass""" sqm = SQM() extinction = sqm._atmospheric_extinction(45.0) - expected = 0.28 * 1.414213562373095 # 0.28 * sqrt(2) + # Pickering (2002) airmass at 45° ≈ 1.4124 + # ASTAP convention: 0.28 * (airmass - 1) + pickering_airmass = sqm._pickering_airmass(45.0) + expected = 0.28 * (pickering_airmass - 1) assert extinction == pytest.approx(expected, abs=0.001) def test_extinction_at_30_degrees(self): - """Test extinction at 30° altitude (airmass = 2.0)""" + """Test extinction at 30° altitude using Pickering airmass""" sqm = SQM() extinction = sqm._atmospheric_extinction(30.0) - assert extinction == pytest.approx(0.56, abs=0.001) # 0.28 * 2.0 + # Pickering (2002) airmass at 30° ≈ 1.995 + # ASTAP convention: 0.28 * (airmass - 1) ≈ 0.279 + pickering_airmass = sqm._pickering_airmass(30.0) + expected = 0.28 * (pickering_airmass - 1) + assert extinction == pytest.approx(expected, abs=0.001) def test_extinction_increases_toward_horizon(self): """Test that extinction increases as altitude decreases""" @@ -37,15 +44,18 @@ def test_extinction_increases_toward_horizon(self): # Extinction should increase monotonically as altitude decreases for i in range(len(extinctions) - 1): - assert ( - extinctions[i] < extinctions[i + 1] - ), f"Extinction at {altitudes[i]}° should be less than at {altitudes[i+1]}°" + assert extinctions[i] < extinctions[i + 1], ( + f"Extinction at {altitudes[i]}° should be less than at {altitudes[i + 1]}°" + ) def test_extinction_minimum_is_at_zenith(self): - """Test that zenith (90°) has the minimum possible extinction""" + """Test that zenith (90°) has zero extinction (ASTAP convention)""" sqm = SQM() zenith_extinction = sqm._atmospheric_extinction(90.0) + # Zenith should have exactly zero extinction + assert zenith_extinction == pytest.approx(0.0, abs=0.0001) + # Test various altitudes - all should have more extinction than zenith test_altitudes = [89, 80, 70, 60, 50, 40, 30, 20, 10] for alt in test_altitudes: @@ -63,27 +73,76 @@ def test_extinction_invalid_altitude(self): assert sqm._atmospheric_extinction(-90.0) == 0.0 def test_extinction_airmass_relationship(self): - """Test the airmass formula: airmass = 1 / sin(altitude)""" + """Test the Pickering airmass formula: extinction = 0.28 * (airmass - 1)""" sqm = SQM() - # At 90°: airmass should be 1.0 + # At 90°: airmass ≈ 1.0, extinction ≈ 0 altitude = 90.0 - airmass = 1.0 / np.sin(np.radians(altitude)) + airmass = sqm._pickering_airmass(altitude) extinction = sqm._atmospheric_extinction(altitude) - assert extinction == pytest.approx(0.28 * airmass, abs=0.0001) + assert extinction == pytest.approx(0.28 * (airmass - 1), abs=0.0001) + assert extinction == pytest.approx(0.0, abs=0.0001) - # At 30°: airmass should be 2.0 + # At 30°: Pickering airmass ≈ 1.995 altitude = 30.0 - airmass = 1.0 / np.sin(np.radians(altitude)) + airmass = sqm._pickering_airmass(altitude) extinction = sqm._atmospheric_extinction(altitude) - assert extinction == pytest.approx(0.28 * airmass, abs=0.0001) - assert airmass == pytest.approx(2.0, abs=0.001) + assert extinction == pytest.approx(0.28 * (airmass - 1), abs=0.0001) + assert airmass == pytest.approx(1.995, abs=0.01) - # At 6°: airmass ≈ 9.6 (very close to horizon) + # At 6°: Pickering airmass is more accurate near horizon than simple formula altitude = 6.0 - airmass = 1.0 / np.sin(np.radians(altitude)) + airmass = sqm._pickering_airmass(altitude) extinction = sqm._atmospheric_extinction(altitude) - assert extinction == pytest.approx(0.28 * airmass, abs=0.001) + assert extinction == pytest.approx(0.28 * (airmass - 1), abs=0.001) + + +@pytest.mark.unit +class TestPickeringAirmass: + """ + Unit tests for Pickering (2002) airmass formula. + """ + + def test_airmass_at_zenith(self): + """Test airmass at zenith is 1.0""" + sqm = SQM() + airmass = sqm._pickering_airmass(90.0) + assert airmass == pytest.approx(1.0, abs=0.001) + + def test_airmass_at_45_degrees(self): + """Test airmass at 45° altitude""" + sqm = SQM() + airmass = sqm._pickering_airmass(45.0) + # Pickering airmass at 45° ≈ 1.413 (slightly less than simple 1/sin(45°) = 1.414) + assert airmass == pytest.approx(1.413, abs=0.01) + + def test_airmass_at_30_degrees(self): + """Test airmass at 30° altitude""" + sqm = SQM() + airmass = sqm._pickering_airmass(30.0) + # Pickering airmass at 30° ≈ 1.995 (slightly less than simple 1/sin(30°) = 2.0) + assert airmass == pytest.approx(1.995, abs=0.01) + + def test_airmass_at_10_degrees(self): + """Test airmass at 10° altitude shows Pickering correction""" + sqm = SQM() + pickering = sqm._pickering_airmass(10.0) + simple = 1.0 / np.sin(np.radians(10.0)) + # Pickering gives ~5.60, simple gives ~5.76 (3% difference) + assert pickering == pytest.approx(5.60, abs=0.05) + assert simple > pickering # Simple overestimates at low altitudes + + def test_airmass_increases_toward_horizon(self): + """Test airmass increases monotonically toward horizon""" + sqm = SQM() + altitudes = [90, 60, 45, 30, 20, 10, 5] + airmasses = [sqm._pickering_airmass(alt) for alt in altitudes] + + for i in range(len(airmasses) - 1): + assert airmasses[i] < airmasses[i + 1], ( + f"Airmass at {altitudes[i]}° ({airmasses[i]:.3f}) should be less than " + f"at {altitudes[i + 1]}° ({airmasses[i + 1]:.3f})" + ) @pytest.mark.unit @@ -133,7 +192,7 @@ def test_calculate_returns_tuple(self): assert isinstance(result[1], dict) def test_calculate_extinction_applied(self): - """Test that extinction correction is applied to final SQM value""" + """Test that extinction correction follows ASTAP convention""" sqm = SQM() solution = { @@ -153,39 +212,51 @@ def test_calculate_extinction_applied(self): image[y - 2 : y + 3, x - 2 : x + 3] += 5000 # Calculate at zenith - sqm_zenith, details_zenith = sqm.calculate( + # Note: Use high saturation threshold for uint16 test images + _sqm_zenith, details_zenith = sqm.calculate( centroids=centroids, solution=solution, image=image, exposure_sec=0.5, altitude_deg=90.0, + saturation_threshold=65000, ) # Calculate at 30° (2× airmass) - sqm_30deg, details_30deg = sqm.calculate( + _sqm_30deg, details_30deg = sqm.calculate( centroids=centroids, solution=solution, image=image, exposure_sec=0.5, altitude_deg=30.0, + saturation_threshold=65000, ) - # Check extinction values - assert details_zenith["extinction_correction"] == pytest.approx(0.28, abs=0.001) - assert details_30deg["extinction_correction"] == pytest.approx(0.56, abs=0.001) + # Check extinction values (ASTAP convention: 0 at zenith) + # Pickering airmass at 30° ≈ 1.995, so extinction ≈ 0.28 * 0.995 ≈ 0.279 + assert details_zenith["extinction_for_altitude"] == pytest.approx( + 0.0, abs=0.001 + ) + expected_ext_30 = 0.28 * (sqm._pickering_airmass(30.0) - 1) + assert details_30deg["extinction_for_altitude"] == pytest.approx( + expected_ext_30, abs=0.001 + ) + + # Uncorrected SQM should be the same (same image, same stars) + assert details_zenith["sqm_uncorrected"] == pytest.approx( + details_30deg["sqm_uncorrected"], abs=0.001 + ) - # Raw SQM should be the same (same image, same stars) - assert details_zenith["sqm_raw"] == pytest.approx( - details_30deg["sqm_raw"], abs=0.001 + # sqm_final is raw (no extinction), sqm_altitude_corrected adds extinction + # At zenith: sqm_final == sqm_altitude_corrected (extinction is 0) + assert details_zenith["sqm_final"] == pytest.approx( + details_zenith["sqm_altitude_corrected"], abs=0.001 ) - # Final SQM should differ by extinction difference - extinction_diff = ( - details_30deg["extinction_correction"] - - details_zenith["extinction_correction"] + # At 30°: sqm_altitude_corrected = sqm_final + extinction + assert details_30deg["sqm_altitude_corrected"] == pytest.approx( + details_30deg["sqm_final"] + expected_ext_30, abs=0.001 ) - sqm_diff = sqm_30deg - sqm_zenith - assert sqm_diff == pytest.approx(extinction_diff, abs=0.001) def test_calculate_missing_fov(self): """Test that calculate() returns None when FOV is missing""" @@ -276,3 +347,341 @@ def test_field_parameters_different_fov(self): assert sqm.arcsec_squared_per_pixel == pytest.approx( expected_arcsec_sq_per_pixel, abs=0.01 ), f"Failed for FOV={fov}" + + +@pytest.mark.unit +class TestMzeroCalculation: + """Unit tests for photometric zero point calculation.""" + + def test_mzero_single_star(self): + """Test mzero calculation with a single star.""" + sqm = SQM() + # mzero = mag + 2.5 * log10(flux) + # For flux=1000, mag=5.0: mzero = 5.0 + 2.5*log10(1000) = 5.0 + 7.5 = 12.5 + fluxes = [1000.0] + mags = [5.0] + + mzero, mzeros = sqm._calculate_mzero(fluxes, mags) + + expected = 5.0 + 2.5 * np.log10(1000) + assert mzero == pytest.approx(expected, abs=0.001) + assert len(mzeros) == 1 + assert mzeros[0] == pytest.approx(expected, abs=0.001) + + def test_mzero_flux_weighted_mean(self): + """Test that mzero uses flux-weighted mean (brighter stars weighted more).""" + sqm = SQM() + # Two stars: one bright (high flux), one dim (low flux) + # The bright star's mzero should dominate + fluxes = [10000.0, 100.0] # 100x difference + mags = [4.0, 8.0] + + mzero, mzeros = sqm._calculate_mzero(fluxes, mags) + + # Individual mzeros + mzero_bright = 4.0 + 2.5 * np.log10(10000) # = 14.0 + mzero_dim = 8.0 + 2.5 * np.log10(100) # = 13.0 + + # Flux-weighted: (14.0*10000 + 13.0*100) / (10000+100) ≈ 13.99 + expected_weighted = (mzero_bright * 10000 + mzero_dim * 100) / (10000 + 100) + + assert mzero == pytest.approx(expected_weighted, abs=0.001) + assert mzeros[0] == pytest.approx(mzero_bright, abs=0.001) + assert mzeros[1] == pytest.approx(mzero_dim, abs=0.001) + + def test_mzero_skips_negative_flux(self): + """Test that stars with negative/zero flux are skipped.""" + sqm = SQM() + fluxes = [1000.0, -1.0, 500.0] # Middle star is saturated (flux=-1) + mags = [5.0, 6.0, 5.5] + + mzero, mzeros = sqm._calculate_mzero(fluxes, mags) + + # Should only use stars 0 and 2 + assert mzero is not None + assert mzeros[0] is not None + assert mzeros[1] is None # Skipped + assert mzeros[2] is not None + + def test_mzero_all_invalid_returns_none(self): + """Test that all invalid fluxes returns None.""" + sqm = SQM() + fluxes = [-1.0, 0.0, -5.0] + mags = [5.0, 6.0, 7.0] + + mzero, mzeros = sqm._calculate_mzero(fluxes, mags) + + assert mzero is None + assert all(m is None for m in mzeros) + + +@pytest.mark.unit +class TestApertureOverlapDetection: + """Unit tests for aperture overlap detection.""" + + def test_no_overlap_far_apart(self): + """Test that far apart stars have no overlap.""" + sqm = SQM() + # Two stars 100 pixels apart + centroids = np.array([[100.0, 100.0], [200.0, 100.0]]) + aperture_radius = 5 + annulus_inner = 6 + annulus_outer = 14 + + excluded = sqm._detect_aperture_overlaps( + centroids, aperture_radius, annulus_inner, annulus_outer + ) + + assert len(excluded) == 0 + + def test_critical_overlap_apertures_touch(self): + """Test CRITICAL overlap when apertures overlap (distance < 2*aperture_radius).""" + sqm = SQM() + # Two stars 8 pixels apart, aperture_radius=5, so 2*5=10 > 8 + centroids = np.array([[100.0, 100.0], [108.0, 100.0]]) + aperture_radius = 5 + annulus_inner = 6 + annulus_outer = 14 + + excluded = sqm._detect_aperture_overlaps( + centroids, aperture_radius, annulus_inner, annulus_outer + ) + + # Both stars should be excluded + assert 0 in excluded + assert 1 in excluded + + def test_high_overlap_aperture_in_annulus(self): + """Test HIGH overlap when aperture enters another star's annulus.""" + sqm = SQM() + # Stars 15 pixels apart: aperture(5) + annulus_outer(14) = 19 > 15 + # This means star 1's aperture is inside star 0's annulus + centroids = np.array([[100.0, 100.0], [115.0, 100.0]]) + aperture_radius = 5 + annulus_inner = 6 + annulus_outer = 14 + + excluded = sqm._detect_aperture_overlaps( + centroids, aperture_radius, annulus_inner, annulus_outer + ) + + # Both should be excluded due to HIGH overlap + assert 0 in excluded + assert 1 in excluded + + def test_no_overlap_at_threshold(self): + """Test no overlap when exactly at safe distance.""" + sqm = SQM() + # Stars 20 pixels apart: aperture(5) + annulus_outer(14) = 19 < 20 + centroids = np.array([[100.0, 100.0], [120.0, 100.0]]) + aperture_radius = 5 + annulus_inner = 6 + annulus_outer = 14 + + excluded = sqm._detect_aperture_overlaps( + centroids, aperture_radius, annulus_inner, annulus_outer + ) + + assert len(excluded) == 0 + + +@pytest.mark.unit +class TestNoiseFloorEstimation: + """Unit tests for adaptive noise floor estimation.""" + + def test_temporal_noise_calculation(self): + """Test temporal noise = read_noise + dark_current * exposure.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + # imx296_processed has read_noise=1.5, dark_current=0.0 + + noise = estimator._estimate_temporal_noise(exposure_sec=1.0) + + # With dark_current=0, temporal_noise = read_noise = 1.5 + assert noise == pytest.approx(1.5, abs=0.01) + + def test_noise_floor_uses_theory_when_dark_pixels_below_bias(self): + """Test that theory is used when dark pixels are impossibly low.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + # bias_offset for imx296_processed is 6.0 + + # Create image with all pixels below bias offset (impossible in reality) + image = np.full((100, 100), 3.0, dtype=np.float32) + + noise_floor, _ = estimator.estimate_noise_floor(image, exposure_sec=0.5) + + # Should use theoretical floor since dark pixels (3.0) < bias (6.0) + assert noise_floor >= estimator.profile.bias_offset + + def test_noise_floor_uses_measured_when_valid(self): + """Test that measured dark pixels are used when valid.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + # bias_offset=6.0, read_noise=1.5, dark_current=0.0 + # theoretical_floor = 6.0 + 1.5 = 7.5 + + # Create image with 5th percentile around 8.0 (valid, above bias) + # Most pixels higher, some low pixels around 8 + image = np.random.uniform(10, 50, (100, 100)).astype(np.float32) + image[0:5, 0:5] = 8.0 # Dark corner + + noise_floor, _ = estimator.estimate_noise_floor(image, exposure_sec=0.5) + + # Should use min(measured, theoretical) + # 5th percentile should be around 8, theoretical is 7.5 + # So should use theoretical 7.5 + assert noise_floor == pytest.approx(7.5, abs=0.5) + + def test_noise_floor_clamped_to_bias_offset(self): + """Test that noise floor is never below bias offset.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + + # Even with weird inputs, should never go below bias + image = np.full((100, 100), 100.0, dtype=np.float32) + + noise_floor, _ = estimator.estimate_noise_floor(image, exposure_sec=0.5) + + assert noise_floor >= estimator.profile.bias_offset + + def test_history_smoothing_after_multiple_estimates(self): + """Test that history smoothing kicks in after 5+ estimates.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + + # First 4 estimates - no smoothing yet + for i in range(4): + image = np.full((100, 100), 10.0 + i, dtype=np.float32) + _, details = estimator.estimate_noise_floor(image, exposure_sec=0.5) + assert details["n_history_samples"] == i + 1 + + # 5th estimate - smoothing should kick in + image = np.full((100, 100), 15.0, dtype=np.float32) + _, details = estimator.estimate_noise_floor(image, exposure_sec=0.5) + assert details["n_history_samples"] == 5 + # dark_pixel_smoothed should be median of history, not raw value + # History: [10, 11, 12, 13, 15] -> median = 12 + assert details["dark_pixel_smoothed"] == pytest.approx(12.0, abs=0.1) + + def test_update_with_zero_sec_sample(self): + """Test zero-second sample updates profile gradually.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + original_bias = estimator.profile.bias_offset + + # Need 3 samples before profile updates + for i in range(3): + # Zero-sec image with bias around 10 + zero_sec = np.random.normal(10.0, 1.5, (100, 100)).astype(np.float32) + estimator.update_with_zero_sec_sample(zero_sec) + + # After 3 samples, profile should update with EMA (alpha=0.2) + # new_bias = 0.2 * measured + 0.8 * original + expected_bias = 0.2 * 10.0 + 0.8 * original_bias + assert estimator.profile.bias_offset == pytest.approx(expected_bias, abs=0.5) + + def test_validate_estimate_too_close_to_median(self): + """Test validation fails when noise floor is too close to image median.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + + # Image where darkest pixels are close to median (uniform image) + # This simulates a situation with no stars/sky gradient + image = np.full((100, 100), 10.0, dtype=np.float32) + + estimator.estimate_noise_floor(image, exposure_sec=0.5) + + # Should be invalid because noise floor (theoretical ~7.5) is close to median (10) + # Actually 7.5 is not > 10 * 0.8 = 8, so let's use a different test + # Need noise floor > median * 0.8 to trigger this + # Create image where dark pixels are above theoretical floor + image2 = np.full((100, 100), 8.0, dtype=np.float32) + estimator2 = NoiseFloorEstimator(camera_type="imx296_processed") + + _, details2 = estimator2.estimate_noise_floor(image2, exposure_sec=0.5) + + # noise_floor will be min(8.0, 7.5) = 7.5 + # median = 8.0, threshold = 8.0 * 0.8 = 6.4 + # 7.5 > 6.4, so should be invalid + assert details2["is_valid"] is False + assert "median" in details2["validation_reason"].lower() + + def test_get_statistics(self): + """Test get_statistics returns expected data.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + + # Do a few estimates + for _ in range(3): + image = np.random.uniform(10, 50, (100, 100)).astype(np.float32) + estimator.estimate_noise_floor(image, exposure_sec=0.5) + + stats = estimator.get_statistics() + + assert stats["camera_type"] == "imx296_processed" + assert stats["n_estimates"] == 3 + assert stats["n_history_samples"] == 3 + assert "dark_pixel_mean" in stats + assert "dark_pixel_std" in stats + assert "dark_pixel_median" in stats + + def test_reset_clears_state(self): + """Test reset clears all history and statistics.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + + # Build up some state + for _ in range(5): + image = np.random.uniform(10, 50, (100, 100)).astype(np.float32) + estimator.estimate_noise_floor(image, exposure_sec=0.5) + + assert estimator.n_estimates == 5 + assert len(estimator.dark_pixel_history) == 5 + + # Reset + estimator.reset() + + assert estimator.n_estimates == 0 + assert len(estimator.dark_pixel_history) == 0 + assert len(estimator.zero_sec_history) == 0 + + def test_save_and_load_calibration(self, tmp_path, monkeypatch): + """Test calibration save/load round-trip.""" + from PiFinder.sqm.noise_floor import NoiseFloorEstimator + + # Redirect Path.home() to tmp_path + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + # Create PiFinder_data directory + (tmp_path / "PiFinder_data").mkdir() + + estimator = NoiseFloorEstimator(camera_type="imx296_processed") + + # Save calibration with new values + result = estimator.save_calibration( + bias_offset=25.0, read_noise=3.5, dark_current_rate=0.5 + ) + assert result is True + + # Verify profile was updated + assert estimator.profile.bias_offset == 25.0 + assert estimator.profile.read_noise_adu == 3.5 + assert estimator.profile.dark_current_rate == 0.5 + + # Create new estimator - should load the saved calibration + estimator2 = NoiseFloorEstimator(camera_type="imx296_processed") + + # Should have loaded the saved values (not the defaults) + assert estimator2.profile.bias_offset == 25.0 + assert estimator2.profile.read_noise_adu == 3.5 + assert estimator2.profile.dark_current_rate == 0.5 diff --git a/python/tests/test_star_catalog.py b/python/tests/test_star_catalog.py new file mode 100644 index 000000000..b9ae9c43f --- /dev/null +++ b/python/tests/test_star_catalog.py @@ -0,0 +1,137 @@ +import unittest +import numpy as np +import struct +import tempfile +import shutil +from pathlib import Path +from unittest.mock import patch + +from PiFinder.object_images.star_catalog import GaiaStarCatalog + + +class TestGaiaStarCatalog(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.catalog_path = Path(self.test_dir) + self.catalog = GaiaStarCatalog(str(self.catalog_path)) + self.catalog.nside = 512 + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def test_parse_records(self): + # Create a fake star record + # Format:
Network Settings
+ % if defined("status_message"): +

{{status_message}}

+ % end
diff --git a/scripts/generate-dependencies-md.sh b/scripts/generate-dependencies-md.sh new file mode 100755 index 000000000..8942f1cb3 --- /dev/null +++ b/scripts/generate-dependencies-md.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Generates python/DEPENDENCIES.md from the nix devShell environment. +# Run from repo root: nix develop --command ./scripts/generate-dependencies-md.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT="$REPO_ROOT/python/DEPENDENCIES.md" + +python3 << 'PYEOF' > "$OUTPUT" +import importlib.metadata +from datetime import date + +pkgs = sorted( + ((d.name, d.version) for d in importlib.metadata.distributions()), + key=lambda x: x[0].lower(), +) + +# Dev-only packages (from python-packages.nix devPackages) +dev_only = {"pytest", "mypy", "mypy_extensions", "luma.emulator", "PyHotKey", + "pynput", "python-xlib", "pygame", "pathspec", "pluggy", "iniconfig"} + +# Build/infra packages not relevant to PiFinder +infra = {"pip", "flit_core", "virtualenv", "distlib", "filelock", "platformdirs", + "packaging", "setuptools"} + +prod = [(n, v) for n, v in pkgs if n not in dev_only and n not in infra] +dev = [(n, v) for n, v in pkgs if n in dev_only] + +print(f"""\ +> **Auto-generated** from the Nix development shell on {date.today()}. +> Do not edit manually — regenerate with: +> ``` +> nix develop --command ./scripts/generate-dependencies-md.sh +> ``` + +> **Note:** These dependencies are managed by Nix (`nixos/pkgs/python-packages.nix`). +> The versions listed here reflect the nixpkgs pin used by the flake and are +> **not necessarily installable via pip**. Some packages require system libraries +> or hardware (SPI, I2C, GPIO) only available on the Raspberry Pi. + +# Python Dependencies + +Python {'.'.join(str(x) for x in __import__('sys').version_info[:3])} + +## Runtime + +| Package | Version | +|---------|---------|""") + +for name, ver in prod: + print(f"| {name} | {ver} |") + +print(f""" +## Development only + +| Package | Version | +|---------|---------|""") + +for name, ver in dev: + print(f"| {name} | {ver} |") +PYEOF + +echo "Generated $OUTPUT" diff --git a/version.txt b/version.txt deleted file mode 100644 index 197c4d5c2..000000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -2.4.0