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/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..b233e8cd9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,350 @@ +name: Build PiFinder NixOS + +on: + push: + branches: [main, nixos] + pull_request: + types: [labeled, synchronize, opened] + workflow_dispatch: + inputs: + migration_version: + description: "Migration tarball version tag (e.g. 3.0.0)" + required: false + default: "3.0.0" + type: string + +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 + + - name: Setup Attic substituter (cache.pifinder.eu) + # Self-hosted FastCDC-chunked binary cache — see ADR 0004. The + # `attic use` command writes the substituter + public key into + # nix.conf so the build below substitutes from cache.pifinder.eu + # before falling back to cache.nixos.org. + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + + - name: Build NixOS system closure + id: build + run: | + nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + -L --no-link + + - name: Push to Attic + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --json | jq -r '.[].outputs.out') + attic push pifinder:pifinder "$STORE_PATH" + 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: DeterminateSystems/nix-installer-action@main + with: + determinate: false + extra-conf: | + extra-platforms = aarch64-linux + extra-system-features = big-parallel + + - name: Setup Attic substituter (cache.pifinder.eu) + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + + - 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 Attic + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --system aarch64-linux \ + --json | jq -r '.[].outputs.out') + attic push pifinder:pifinder "$STORE_PATH" + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + # Commit pifinder-build.json with store path to the same branch. + # Only on push events — PR-event runs (in particular on brickbots/PiFinder + # as the base of PR #379 from mrosseel:nixos) can't write back to the PR + # head branch on the fork. Push-event runs on mrosseel:nixos remain the + # canonical stamper; PR runs only verify the build succeeds. + stamp-build: + needs: [build-native, build-emulated] + if: | + github.event_name == 'push' && + (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 + + # Build migration tarball — only on nixos branch pushes + build-migration-tarball: + needs: [build-native, build-emulated] + if: | + always() && + github.ref == 'refs/heads/nixos' && + (needs.build-native.result == 'success' || needs.build-emulated.result == 'success') + runs-on: [self-hosted, aarch64] + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Setup Attic substituter (cache.pifinder.eu) + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + + - name: Set version + run: | + if [ -n "${{ inputs.migration_version }}" ]; then + echo "VERSION=${{ inputs.migration_version }}" >> "$GITHUB_ENV" + else + echo "VERSION=3.0.0" >> "$GITHUB_ENV" + fi + + - name: Resolve full system store path + run: | + FULL_PATH="${{ needs.build-native.outputs.store_path || needs.build-emulated.outputs.store_path }}" + echo "FULL_PATH=$FULL_PATH" >> "$GITHUB_ENV" + echo "Full system toplevel: $FULL_PATH" + + - name: Build migration toplevel and boot firmware + run: | + MIGRATION_PATH=$(nix build \ + .#nixosConfigurations.pifinder-migration.config.system.build.toplevel \ + --json | jq -r '.[].outputs.out') + echo "MIGRATION_PATH=$MIGRATION_PATH" >> "$GITHUB_ENV" + echo "Migration toplevel: $MIGRATION_PATH" + + BOOT_FW=$(nix build .#packages.aarch64-linux.migration-boot-firmware \ + --json | jq -r '.[].outputs.out') + echo "BOOT_FW=$BOOT_FW" >> "$GITHUB_ENV" + echo "Boot firmware: $BOOT_FW" + + - name: Clean up previous runs + run: | + sudo rm -rf /tmp/tarball-staging + rm -f /tmp/pifinder-nixos-*.tar.zst /tmp/pifinder-nixos-*.tar.zst.sha256 + + - name: Assemble boot partition + run: | + set -euo pipefail + mkdir -p /tmp/tarball-staging/boot/{nixos,extlinux} + + # RPi firmware, u-boot, config.txt, firmware DTBs + cp "$BOOT_FW"/* /tmp/tarball-staging/boot/ + + # Kernel, initrd, dtbs from migration toplevel + KERNEL=$(readlink -f "$MIGRATION_PATH/kernel") + INITRD=$(readlink -f "$MIGRATION_PATH/initrd") + DTBS=$(readlink -f "$MIGRATION_PATH/dtbs") + + KERNEL_NAME=$(basename "$KERNEL") + INITRD_NAME=$(basename "$INITRD") + DTBS_NAME=$(basename "$DTBS") + + cp "$KERNEL" "/tmp/tarball-staging/boot/nixos/$KERNEL_NAME" + cp "$INITRD" "/tmp/tarball-staging/boot/nixos/$INITRD_NAME" + cp -r "$DTBS" "/tmp/tarball-staging/boot/nixos/$DTBS_NAME" + + # Generate extlinux.conf + KERNEL_PARAMS=$(cat "$MIGRATION_PATH/kernel-params") + cat > /tmp/tarball-staging/boot/extlinux/extlinux.conf < /tmp/tarball-staging/rootfs/nix-path-registration + + # First-boot target: full system to download from cache.pifinder.eu + mkdir -p /tmp/tarball-staging/rootfs/var/lib/pifinder + echo "$FULL_PATH" > /tmp/tarball-staging/rootfs/var/lib/pifinder/first-boot-target + + # PiFinder data directory + mkdir -p /tmp/tarball-staging/rootfs/home/pifinder/PiFinder_data + + echo "Rootfs assembled" + du -sh /tmp/tarball-staging/rootfs/ + + - name: Create migration tarball + run: | + TARBALL="pifinder-nixos-v${VERSION}.tar.zst" + + UNCOMPRESSED_MB=$(du -sm /tmp/tarball-staging | awk '{print $1}') + echo "Uncompressed staging: ${UNCOMPRESSED_MB} MB" + + tar -C /tmp/tarball-staging -cf - boot rootfs \ + | zstd -T0 -19 -o "/tmp/${TARBALL}" + sudo rm -rf /tmp/tarball-staging + + echo "TARBALL=/tmp/${TARBALL}" >> "$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" + + SIZE_MB=$(( $(stat -c%s "/tmp/${TARBALL}") / 1048576 )) + echo "SIZE_MB=${SIZE_MB}" >> "$GITHUB_ENV" + + echo "### Migration Tarball Built" >> "$GITHUB_STEP_SUMMARY" + echo "- **File:** ${TARBALL}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Uncompressed:** ${UNCOMPRESSED_MB} MB" >> "$GITHUB_STEP_SUMMARY" + echo "- **Compressed:** ${SIZE_MB} MB" >> "$GITHUB_STEP_SUMMARY" + echo "- **SHA256:** \`$(cat /tmp/${TARBALL}.sha256)\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Method:** direct closure assembly (no SD image)" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.TARBALL_NAME }} + path: | + ${{ env.TARBALL }} + ${{ env.TARBALL }}.sha256 + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ env.VERSION }}-migration + name: PiFinder NixOS Migration v${{ env.VERSION }} + body: | + NixOS migration tarball (boot + rootfs, no catalog images). + Assembled from nix store closure with first-boot deferred download. + + **Size:** ${{ env.SIZE_MB }} MB + **SHA256:** `${{ env.SHA256 }}` + files: | + ${{ env.TARBALL }} + ${{ env.TARBALL }}.sha256 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..c5a25450b --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,98 @@ +name: Lint & Test +on: [push, pull_request] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: false + - name: Setup Attic substituter (cache.pifinder.eu) + # Self-hosted FastCDC-chunked binary cache (ADR 0004), same as build.yml. + # Replaces the retired DeterminateSystems magic-nix-cache (HTTP 418 / + # GitHub Actions cache rate limits). Falls back to cache.nixos.org when + # the token is unavailable (e.g. fork PRs) so the job never hard-fails. + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + if [ -z "$ATTIC_TOKEN" ]; then + echo "ATTIC_TOKEN unavailable — using cache.nixos.org only" + exit 0 + fi + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + - 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@v6 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: false + - name: Setup Attic substituter (cache.pifinder.eu) + # Self-hosted FastCDC-chunked binary cache (ADR 0004), same as build.yml. + # Replaces the retired DeterminateSystems magic-nix-cache (HTTP 418 / + # GitHub Actions cache rate limits). Falls back to cache.nixos.org when + # the token is unavailable (e.g. fork PRs) so the job never hard-fails. + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + if [ -z "$ATTIC_TOKEN" ]; then + echo "ATTIC_TOKEN unavailable — using cache.nixos.org only" + exit 0 + fi + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + - 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@v6 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: false + - name: Setup Attic substituter (cache.pifinder.eu) + # Self-hosted FastCDC-chunked binary cache (ADR 0004), same as build.yml. + # Replaces the retired DeterminateSystems magic-nix-cache (HTTP 418 / + # GitHub Actions cache rate limits). Falls back to cache.nixos.org when + # the token is unavailable (e.g. fork PRs) so the job never hard-fails. + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + if [ -z "$ATTIC_TOKEN" ]; then + echo "ATTIC_TOKEN unavailable — using cache.nixos.org only" + exit 0 + fi + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + - 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 ab38d5621..000000000 --- a/.github/workflows/nox.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: nox -# Run on pushes to the long-lived branches and on pull requests. Restricting -# `push` to main/release means a push to a feature branch with an open PR only -# triggers the `pull_request` run (not also a `push` run), so a PR gets a single -# run per push instead of two. -on: - push: - branches: - - main - - release - pull_request: -jobs: - nox: - runs-on: ubuntu-latest - timeout-minutes: 20 - defaults: - run: - working-directory: ./python - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: wntrblm/nox@2024.04.15 - with: - python-versions: "3.9" - - run: nox -s lint format type_hints smoke_tests unit_tests ui_tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..1aac6e5c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,148 @@ +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: DeterminateSystems/nix-installer-action@main + with: + determinate: false + extra-conf: | + extra-platforms = aarch64-linux + extra-system-features = big-parallel + + - name: Setup Attic caches (cache.pifinder.eu) + # Self-hosted FastCDC-chunked binary cache — see ADR 0004. The release + # closure is published to the retained `pifinder-release` cache (push + # step below); build-time deps are substituted from the `pifinder` dev + # cache, which recent CI runs keep warm. + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + + - name: Register QEMU binfmt for aarch64 + run: sudo apt-get update && sudo apt-get install -y qemu-user-static + + - name: Compute tag and stamp version into pifinder-build.json + run: | + TAG="v${{ inputs.version }}" + [[ "${{ inputs.type }}" == "beta" ]] && TAG="${TAG}-beta" + echo "TAG=$TAG" >> $GITHUB_ENV + + VERSION="${{ inputs.version }}" + jq -n --arg sp "" --arg v "$VERSION" \ + '{store_path: $sp, version: $v}' > pifinder-build.json + + - name: Build and push release closure to Attic (pifinder-release) + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --system aarch64-linux -L --json | jq -r '.[].outputs.out') + # Publish the full closure to the retained release cache (GC-disabled, + # so a device upgrading months later still finds the path). ADR 0004. + attic push pifinder:pifinder-release "$STORE_PATH" + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + - 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: Re-stamp pifinder-build.json with store_path, commit, tag + run: | + 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" + + - name: Build migration tarball from SD image + run: | + IMAGE_FILE=$(find result-sd/sd-image -type f \( -name "*.img" -o -name "*.img.zst" \) | head -1) + + rm -f /tmp/pifinder-release.img + if [[ "${IMAGE_FILE}" == *.zst ]]; then + zstd -d "${IMAGE_FILE}" -o /tmp/pifinder-release.img + else + cp "${IMAGE_FILE}" /tmp/pifinder-release.img + fi + + LOOP=$(sudo losetup --find --show --partscan /tmp/pifinder-release.img) + 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 rm -rf /tmp/tarball-staging/rootfs/home/pifinder/PiFinder_data/catalog_images + + sudo umount /mnt/boot /mnt/root + sudo losetup -d "${LOOP}" + rm -f /tmp/pifinder-release.img + + sudo tar -C /tmp/tarball-staging -cf - \ + --exclude='*/lost+found' \ + boot rootfs | zstd -T0 -19 -o "release/pifinder-migration-${TAG}.tar.zst" + sudo rm -rf /tmp/tarball-staging + + - 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-*.zst diff --git a/.gitignore b/.gitignore index 7e9319394..b30c9a31f 100644 --- a/.gitignore +++ b/.gitignore @@ -152,6 +152,7 @@ case/my_printer *.db-shm *.db-wal .devenv/ +.direnv/ **/.claude/* !**/.claude/skills/ .serena/ 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 b0b0d15d2..69e78cb96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,3 +194,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..8c3061ef0 --- /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 +- **Attic** - cache.pifinder.eu self-hosted binary cache, split into `pifinder-release` (retained) + `pifinder` (dev); see ADR 0004 + +## 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/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/default_config.json b/default_config.json index 7de409688..eb28323fd 100644 --- a/default_config.json +++ b/default_config.json @@ -18,6 +18,11 @@ "image_nsew": true, "image_bbox": true, "chart_coord_sys": "horiz", + "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, "target_pixel": [256, 256], "gps_type": "ublox", "gps_baud_rate": 9600, @@ -179,6 +184,7 @@ "active_eyepiece_index": 0 }, "imu_threshold_scale": 1, + "software_unstable_unlocked": false, "telemetry_record": false, "telemetry_images": false, "telemetry_raw_imu": 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/adr/0004-attic-binary-cache.md b/docs/adr/0004-attic-binary-cache.md new file mode 100644 index 000000000..842350b0b --- /dev/null +++ b/docs/adr/0004-attic-binary-cache.md @@ -0,0 +1,85 @@ +# Self-hosted Attic for NixOS binary distribution + +A NixOS PiFinder runs from pre-built binaries in `/nix/store/`; updates work by +atomically swapping the running system for a new closure of pre-built binaries. +Distributing those binaries requires a **binary cache** — a server that hands +them out on demand, since recompiling from source on a Pi is not viable (Rust +crates alone take hours). We will self-host the [Attic](https://github.com/zhaofengli/attic) +binary cache at `cache.pifinder.eu`, backed by SQLite and local disk initially, +with Cloudflare R2 as the eventual chunk store. Attic is a small Rust server +that adds **content-defined chunking (FastCDC)** on top of the standard Nix +substituter protocol: every closure is sliced into variable-size chunks based +on byte content, identical chunks are stored exactly once, and update downloads +ship only the genuinely-new chunks. For a NixOS system image where 90–95% of +bytes are unchanged between releases, a 1.5 GB closure typically resolves to +~80 MB of new chunks per device per update — savings that compound across +releases and across the user fleet. + +## Considered Options + +- **Stay on cachix.org indefinitely.** Rejected: SaaS quota caps (storage tier, + push throttling) become a planning concern as the system closure grows; ships + full NARs per closure (no chunking), so every update transfers the full + closure even when 5% of bytes changed; per-cache pricing scales linearly with + closure count. +- **Magic Nix Cache (DetSys) alone.** Rejected: backed by GitHub Actions Cache + (~10 GB per repo, ephemeral, HTTP-418 rate-limited under sustained traffic — + already broke a `type-check` job once). Useful for CI runner-local caching, + not for distributing binaries to end-user devices, which it cannot do at all. +- **nix-casync.** Same content-defined-chunking idea, predates Attic, but + distributed as a standalone tool rather than a hosted server; would need to + assemble the server side ourselves. Attic delivers the same dedup story as a + complete package. +- **harmonia.** Newer self-hosted alternative; simpler than Attic but no + chunking. Loses the headline bandwidth-and-storage saving. + +## Consequences + +- **Operational ownership:** PiFinder takes on a small piece of infrastructure + (one VPS, one Rust binary, one SQLite file, one Caddy reverse-proxy with + Let's Encrypt). Sized at Hetzner CX22 / €4 month for the foreseeable future; + SQLite handles millions of chunks before PostgreSQL becomes necessary. Backup + story is "snapshot the SQLite file and the chunk directory" — same pattern as + a typical small VPS service. +- **Egress economics:** Cloudflare R2 charges zero egress, which matters when + distributing updates to a globally-dispersed PiFinder fleet. Self-hosted on a + Hetzner VPS the egress is also effectively free at typical hobby volumes. + Either way, the bandwidth question stops being a recurring concern. +- **CI publish step:** `build.yml`'s `cachix-action` step is replaced by an + `attic push` step using a long-lived JWT minted by `atticadm make-token + --push pifinder`, stored as `secrets.ATTIC_TOKEN`. Chunking happens on the + runner; the server only ingests new chunks. Push payload is proportional to + actual changes, not to closure size. +- **Device pull side:** `services.nix` declares `cache.pifinder.eu` as a + substituter alongside `cache.nixos.org`, with the Attic public key in + `trusted-public-keys`. The existing on-device upgrade flow + (`pifinder-upgrade.service`, `nix build "$STORE_PATH" --max-jobs 0`) is + unchanged — the new substituter is transparent. Users see the same + "downloading N/M" progress in the menu, just with smaller N. +- **Failure model:** Nix tries substituters in order and falls through. If + `cache.pifinder.eu` is unreachable, the device falls through to + `cache.nixos.org` for any path that exists there. The "Attic outage = bricked + PiFinders" scenario does not exist for paths nixpkgs already publishes; only + locally-built paths (kernel with our overlays, `cedar-detect-server`, Python + wheels) are at risk during an outage, and those are cached locally on devices + that previously updated successfully. +- **Migration tarball stays self-contained.** The boot-from-tarball path + (`pifinder-nixos-v3.0.0.tar.zst` on the GitHub release) is independent of the + cache and remains the way a stock Debian PiFinder bootstraps into NixOS. A + later refinement could ship a smaller tarball that pulls the bulk of the + closure from Attic on first boot, but that is out of scope for this ADR. +- **No retirement of cachix.org is mandated here.** Whether to keep cachix.org + as a fallback or drop it after Attic is proven is a separate operational + decision; the substituter list can carry both indefinitely with no penalty + beyond the cachix subscription cost. +- **Two caches, split by retention.** The server hosts two Attic caches: + `pifinder` (dev/nightly builds from `build.yml`, short retention — these churn + on every push) and `pifinder-release` (tagged release closures from + `release.yml`, garbage collection disabled). The split exists because Attic + retention is per-cache, not per-path: a device may upgrade to a release months + after it was cut, so its closure must never be GC'd, while dev builds should + not accumulate forever. Chunk dedup is global across caches on the same + server, so storing a release closure separately costs only its genuinely-new + chunks. Each cache has its own signing key; devices trust both, plus + `cache.nixos.org`. (Originally a single `pifinder` cache; this followed once + releases started flowing through Attic instead of cachix.) diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..27b08dcbc --- /dev/null +++ b/flake.lock @@ -0,0 +1,115 @@ +{ + "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" + } + }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1781807804, + "narHash": "sha256-04KFQME8sE1LSywNiYS1B6Ucf5rEiUD7/vxwFMgooXU=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "b84e03a7870c66033d309e0e00abd513e2299627", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1781812259, + "narHash": "sha256-uRqDouxg3b0EuOHQd1HhmFZouHebM7pz+H6EWAXd3FM=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "d9847acff422152a03764fd60c96ae0dd9f9fa73", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "nixos-hardware": "nixos-hardware", + "nixpkgs": "nixpkgs", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1781810314, + "narHash": "sha256-PQfvfKWaBvCysdHFUO5GewwvwIqI/WL6OcrJhDSUdbc=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "14aa44100859a44144878fe079f8089d3fa4dc4e", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..33d248842 --- /dev/null +++ b/flake.nix @@ -0,0 +1,406 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixos-hardware.url = "github:NixOS/nixos-hardware"; + + pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, nixos-hardware, pyproject-nix, uv2nix, pyproject-build-systems, ... }: let + # Flake inputs the python-env module needs, passed via specialArgs. + pythonInputs = { inherit pyproject-nix uv2nix pyproject-build-systems; }; + # Headless config shared by all profiles + headlessModule = { 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" ]; + }; + + # 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 + headlessModule + ]; + + # Migration profile — minimal bootable system, full config fetched on first boot + migrationModules = [ + nixos-hardware.nixosModules.raspberry-pi-4 + ./nixos/hardware.nix + ./nixos/networking.nix + ./nixos/migration.nix + headlessModule + ]; + + mkPifinderSystem = { includeSDImage ? false }: nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + specialArgs = pythonInputs; + 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 + 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"; + }; + 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 + ''; + 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"; + }; + }) + ]; + }; + + mkPifinderMigration = { includeSDImage ? false }: nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + modules = migrationModules ++ [ + { pifinder.devMode = false; } + ({ 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, ... }: { + sdImage.populateRootCommands = '' + mkdir -p ./files/home/pifinder/PiFinder_data + ''; + 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) [ + ({ 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"; + specialArgs = pythonInputs; + 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" + ''; + }; + + configTxt = pkgsAarch64.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 + ''; + + in { + nixosConfigurations = { + # SD card boot — camera baked into DT, switched via specialisations + pifinder = mkPifinderSystem {}; + # Migration — minimal bootable system, defers full system to first boot + pifinder-migration = mkPifinderMigration {}; + # NFS netboot — for development on proxnix + pifinder-netboot = mkPifinderNetboot; + }; + images = { + pifinder = (mkPifinderSystem { includeSDImage = true; }).config.system.build.sdImage; + pifinder-migration = (mkPifinderMigration { includeSDImage = true; }).config.system.build.sdImage; + }; + packages.aarch64-linux = { + uboot-sd = ubootSD; + uboot-netboot = ubootNetboot; + migration-boot-firmware = pkgsAarch64.runCommand "migration-boot-firmware" {} '' + mkdir -p $out + FW=${pkgsAarch64.raspberrypifw}/share/raspberrypi/boot + + # RPi firmware + cp $FW/bootcode.bin $FW/fixup*.dat $FW/start*.elf $out/ + + # Pi3 DTBs + cp $FW/bcm2710-rpi-2-b.dtb $FW/bcm2710-rpi-3-b.dtb $FW/bcm2710-rpi-3-b-plus.dtb $out/ + cp $FW/bcm2710-rpi-cm3.dtb $FW/bcm2710-rpi-zero-2.dtb $FW/bcm2710-rpi-zero-2-w.dtb $out/ + + # Pi4 DTBs + cp $FW/bcm2711-rpi-4-b.dtb $FW/bcm2711-rpi-400.dtb $FW/bcm2711-rpi-cm4.dtb $FW/bcm2711-rpi-cm4s.dtb $out/ + + # config.txt + cp ${configTxt} $out/config.txt + + # u-boot binaries + cp ${pkgsAarch64.ubootRaspberryPi3_64bit}/u-boot.bin $out/u-boot-rpi3.bin + cp ${ubootSD}/u-boot.bin $out/u-boot-rpi4.bin + + # armstub + cp ${pkgsAarch64.raspberrypi-armstubs}/armstub8-gic.bin $out/armstub8-gic.bin + ''; + }; + + devShells.x86_64-linux.default = let + pkgs = import nixpkgs { + system = "x86_64-linux"; + overlays = [(final: prev: { + libcamera = prev.libcamera.overrideAttrs (old: { + mesonFlags = (old.mesonFlags or []) ++ [ "-Dpycamera=enabled" ]; + buildInputs = (old.buildInputs or []) ++ [ + final.python313 + final.python313.pkgs.pybind11 + ]; + }); + })]; + }; + pyPkgs = import ./nixos/pkgs/uv-python.nix { + inherit pkgs pyproject-nix uv2nix pyproject-build-systems; + }; + cedar-detect = import ./nixos/pkgs/cedar-detect.nix { inherit pkgs; }; + in pkgs.mkShell { + packages = [ pyPkgs.devEnv pkgs.ruff pkgs.uv cedar-detect ]; + shellHook = '' + export PYTHONPATH="${pkgs.libcamera}/lib/python3.13/site-packages:$PYTHONPATH" + ''; + }; + }; +} diff --git a/move-to-brickbots.txt b/move-to-brickbots.txt new file mode 100644 index 000000000..be0264c61 --- /dev/null +++ b/move-to-brickbots.txt @@ -0,0 +1,28 @@ +Locations that reference mrosseel/PiFinder and need updating after merge to brickbots/PiFinder +============================================================================================= + +1. nixos/migration.nix + - BUILD_JSON_URL points to: https://raw.githubusercontent.com/mrosseel/PiFinder/nixos/pifinder-build.json + - Change to: https://raw.githubusercontent.com/brickbots/PiFinder//pifinder-build.json + - Also: branch name may change from "nixos" to something else + +2. nixos/migration.nix + - Cachix substituter: https://pifinder.cachix.org + - Cachix public key: pifinder.cachix.org-1:ALuxYs8tMU34zwSTWjenI2wpJA+AclmW6H5vyTgnTjc= + - May need new cachix cache under brickbots org, or keep shared + +3. nixos/services.nix + - Same cachix substituter + public key as above + +4. flake.nix + - catalog_images.tar.zst hosted at: https://files.miker.be/public/pifinder/catalog_images.tar.zst + - Move to brickbots-hosted URL or keep as-is + +5. CLAUDE.md + - References: github:mrosseel/PiFinder/nixos#nixosConfigurations... + - Change to: github:brickbots/PiFinder/#nixosConfigurations... + +6. .github/workflows/build.yml + - stamp-build job commits pifinder-build.json back to the repo + - No explicit mrosseel reference, but the repo context changes implicitly + - Migration tarball release tags: v2.5.0-migration — may need new versioning scheme diff --git a/nixos/RELEASE.md b/nixos/RELEASE.md new file mode 100644 index 000000000..f85e16f68 --- /dev/null +++ b/nixos/RELEASE.md @@ -0,0 +1,155 @@ +# NixOS Release Process + +How PiFinder NixOS builds are versioned, published, and updated on devices. + +> Not to be confused with the repo-root `RELEASE.md`, which is hand-written release notes for a specific version. This file documents the plumbing. + +## Single source of truth + +``` +pifinder-build.json (committed to the branch, repo root) + │ + ├─ "version": "3.0.0" ← what the device displays + └─ "store_path": "/nix/store/…" ← what the channel checker installs +``` + +Everything downstream reads this file. The Nix build copies it through verbatim. Nothing else writes it except CI (see below). + +At runtime, `python/PiFinder/utils.py::get_version()` reads the JSON from `/home/pifinder/PiFinder/pifinder-build.json` — a symlink (`nixos/services.nix`) to the `pifinder-src` Nix store path, which contains the same file the source tree had when it was built. + +## Artifacts + +| Artifact | Where | Purpose | +| ------------------------------ | --------------------------------- | -------------------------------------- | +| Release closure on Attic | `cache.pifinder.eu/pifinder-release` | What the device upgrade pulls (retained) | +| `pifinder-build.json` (git) | repo root, committed | Tells the channel checker what's live | +| Git tag `vX.Y.Z` | GitHub | Marks a release commit | +| GitHub Release | GitHub Releases | Carries the SD image + tarball | +| `pifinder-vX.Y.Z.img.zst` | GitHub Release asset | SD card image for fresh installs | +| `pifinder-migration-vX.Y.Z.tar.zst` | GitHub Release asset | Tarball for in-place migration | + +## Binary caches + +Two self-hosted Attic caches on `cache.pifinder.eu` (ADR 0004): + +| Cache | Pushed by | Retention | Holds | +| ------------------ | -------------------------- | ---------------- | ------------------------------ | +| `pifinder-release` | `release.yml` | never GC'd | tagged release closures | +| `pifinder` | `build.yml` | short (dev GC) | dev + nightly branch builds | + +Release closures go to `pifinder-release` so a device upgrading long after a +release still resolves the store path; dev builds churn through `pifinder`. +Devices subscribe to both (`nixos/services.nix`), release cache first, with +`cache.nixos.org` as the fall-through for upstream paths. `cachix.org` is no +longer used. + +Both caches are declared server-side in nixos-config +(`machines/general-server/attic-service.nix`). To prune the dev cache later, set +retention **per-cache** (`attic cache configure local:pifinder --retention-period +`), never globally — a global retention would also evict `pifinder-release`. + +## Who writes `pifinder-build.json` + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ CI dev build (.github/workflows/build.yml :: stamp-build) │ +│ After every successful build on any branch, commits: │ +│ PR → { "version": "PR#-", "store_path": "" } │ +│ else→ { "version": "-", "store_path": ""}│ +│ Creates a "chore: stamp build [skip ci]" commit on the branch. │ +├──────────────────────────────────────────────────────────────────┤ +│ Release workflow (.github/workflows/release.yml) │ +│ workflow_dispatch with `version: 3.0.0`. Stamps the JSON, │ +│ tags v3.0.0 (or v3.0.0-beta), publishes the GitHub Release. │ +└──────────────────────────────────────────────────────────────────┘ +``` + +That's it. The Nix derivation reads the file but never writes it. + +## Update channels + +`python/PiFinder/ui/software.py` (Software-update menu) discovers what to offer: + +| Channel | Source | +| ------- | ---------------------------------------------------------------------- | +| stable | GitHub Releases (non-prerelease, version ≥ `MIN_NIXOS_VERSION`) | +| beta | GitHub Pre-releases (version ≥ `MIN_NIXOS_VERSION`) | +| nightly | `raw.githubusercontent.com/...//pifinder-build.json` | + +For each candidate, it reads `version` (to display) and `store_path` (to install). `MIN_NIXOS_VERSION = "2.5.0"` is hard-coded in `software.py:29`. + +## Release flow + +``` + workflow_dispatch (Release) + inputs: version=3.0.0, type=stable|beta, source_branch=main, notes=… + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ 1. checkout source_branch │ + │ 2. write pifinder-build.json: │ + │ { "version": "3.0.0", "store_path": "" } │ + │ 3. nix build .#…toplevel → store path A │ + │ (JSON inside A: version=3.0.0, store_path="") │ + │ 4. nix build .#images.pifinder → SD image embedding A│ + │ 5. extract migration tarball from SD image │ + │ 6. attic push A → pifinder-release (retained) │ + │ 7. rewrite pifinder-build.json: │ + │ { "version": "3.0.0", "store_path": "A" } │ + │ 8. git commit + push + tag v3.0.0 (or v3.0.0-beta) │ + │ 9. create GitHub Release with SD image + tarball │ + └─────────────────────────────────────────────────────────────┘ +``` + +SD image, tarball, Attic (`pifinder-release`) closure, and committed JSON all agree on store path A. Devices display `3.0.0`. Channel checker sees `3.0.0` pointing at A. + +## Dev build flow + +``` + push to any branch + │ + ▼ + ┌─────────────────────────────────────────┐ + │ build.yml │ + │ 1. nix build closure (native + emulated) │ + │ 2. attic push → pifinder (dev cache) │ + │ 3. stamp-build job: │ + │ version = "-" │ + │ write + commit pifinder-build.json │ + │ "chore: stamp build [skip ci]" │ + │ 4. (nixos branch only) build migration tarball, │ + │ upload to GitHub Release │ + └─────────────────────────────────────────┘ +``` + +A device built at commit X reports the version from commit X-1's stamp (the previous CI run). One-commit lag for the display string. Channels see the freshest stamp. + +## Cutting a release + +1. Make sure `source_branch` (usually `main` or `nixos`) is at the commit you want to release. +2. GitHub → Actions → **Release** → Run workflow. +3. Inputs: + - `version`: semver only, no `v` prefix — e.g. `3.0.0` + - `notes`: markdown body for the GitHub Release + - `type`: `stable` or `beta` (beta tags as `vX.Y.Z-beta` and marks the release as prerelease) + - `source_branch`: branch to release from (default `main`) +4. Workflow runs end-to-end (~30–45 min). +5. Verify the GitHub Release has both `pifinder-vX.Y.Z.img.zst` and `pifinder-migration-vX.Y.Z.tar.zst`. +6. If the release should force-deprecate older clients, bump `MIN_NIXOS_VERSION` in `python/PiFinder/ui/software.py` in a follow-up commit. + +## Hotfix release + +Use `source_branch=release/X.Y` (long-lived hotfix branches). The release workflow stamps and tags on that branch, leaving `main` untouched. + +## Files of interest + +| File | Role | +| ------------------------------------- | ------------------------------------------ | +| `pifinder-build.json` | The single committed JSON, repo root | +| `python/PiFinder/utils.py` | `get_version()` reader | +| `python/PiFinder/ui/software.py` | Channel update UI + `MIN_NIXOS_VERSION` | +| `nixos/pkgs/pifinder-src.nix` | Copies the JSON through into the store path | +| `nixos/services.nix` | Symlinks `/home/pifinder/PiFinder` → store path | +| `nixos/migration.nix` | `BUILD_JSON_URL` for nightly channel check | +| `.github/workflows/build.yml` | Dev builds + nightly stamp | +| `.github/workflows/release.yml` | Manual release dispatcher | 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/migration.nix b/nixos/migration.nix new file mode 100644 index 000000000..d5a0df2c2 --- /dev/null +++ b/nixos/migration.nix @@ -0,0 +1,291 @@ +{ config, lib, pkgs, ... }: +let + boot-splash = import ./pkgs/boot-splash.nix { inherit pkgs; }; +in { + options.pifinder = { + devMode = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable development mode (NFS netboot support, etc.)"; + }; + }; + + config = { + # --------------------------------------------------------------------------- + # Minimal system packages for migration troubleshooting + # --------------------------------------------------------------------------- + environment.systemPackages = with pkgs; [ + vim + htop + e2fsprogs + dosfstools + parted + file + curl + ]; + + # --------------------------------------------------------------------------- + # Binary substituters — Pi downloads pre-built paths, never compiles. + # Two Attic caches on cache.pifinder.eu (ADR 0004): pifinder-release (retained + # release closures) and pifinder (dev/nightly). The first-boot download below + # pulls whichever closure pifinder-build.json points at. + # --------------------------------------------------------------------------- + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.pifinder.eu/pifinder-release" + "https://cache.pifinder.eu/pifinder" + "https://cache.nixos.org" + ]; + trusted-public-keys = [ + # TODO: replace placeholder with the real pifinder-release public key + # (same value as nixos/services.nix). + "pifinder-release:REPLACE_WITH_PIFINDER_RELEASE_PUBKEY=" + "pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE=" + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + ]; + }; + + # Don't pull nixpkgs source into closure (~186 MB) + nix.channel.enable = false; + nix.registry = lib.mkForce {}; + nix.nixPath = lib.mkForce []; + + # nixos-rebuild-ng pulls in Python 3.13 (~110 MB) — not needed for migration + system.disableInstallerTools = true; + + # Perl is included by default (~59 MB) — not needed for migration + environment.defaultPackages = lib.mkForce []; + + # Strip NetworkManager VPN plugins (openconnect/stoken/gtk3 deps) + networking.networkmanager.plugins = lib.mkForce []; + + # --------------------------------------------------------------------------- + # SD card optimizations + # --------------------------------------------------------------------------- + boot.loader.generic-extlinux-compatible.configurationLimit = 2; + + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 3d"; + }; + nix.settings.auto-optimise-store = true; + + 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" ]; + }; + + # --------------------------------------------------------------------------- + # Nix DB registration (first boot after migration) + # --------------------------------------------------------------------------- + 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 + ''; + }; + + # --------------------------------------------------------------------------- + # First boot: download full PiFinder system from the binary cache and switch + # --------------------------------------------------------------------------- + systemd.services.pifinder-first-boot = { + description = "Download full PiFinder NixOS system from the binary cache"; + after = [ "network-online.target" "nix-path-registration.service" "nix-daemon.service" ]; + requires = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = "/var/lib/pifinder/first-boot-target"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + TimeoutStartSec = "30min"; + }; + path = with pkgs; [ nix coreutils systemd curl jq ]; + script = '' + set -euo pipefail + + # Show scanner animation on OLED during download + ${boot-splash}/bin/boot-splash & + SPLASH_PID=$! + trap 'kill $SPLASH_PID 2>/dev/null || true' EXIT + + # Try fetching latest store path from GitHub, fall back to baked-in file + BUILD_JSON_URL="https://raw.githubusercontent.com/mrosseel/PiFinder/nixos/pifinder-build.json" + STORE_PATH="" + if REMOTE_JSON=$(curl -sf --max-time 15 "$BUILD_JSON_URL" 2>/dev/null); then + STORE_PATH=$(echo "$REMOTE_JSON" | jq -r '.store_path // empty') + if [ -n "$STORE_PATH" ]; then + echo "Using store path from GitHub: $STORE_PATH" + fi + fi + if [ -z "$STORE_PATH" ] || [[ "$STORE_PATH" != /nix/store/* ]]; then + echo "Remote fetch failed or invalid, falling back to baked-in target" + STORE_PATH=$(cat /var/lib/pifinder/first-boot-target) + fi + if [ -z "$STORE_PATH" ] || [[ "$STORE_PATH" != /nix/store/* ]]; then + echo "ERROR: No valid store path found" + exit 1 + fi + + echo "Downloading full PiFinder system: $STORE_PATH" + nix build "$STORE_PATH" --max-jobs 0 + + echo "Setting system profile..." + nix-env -p /nix/var/nix/profiles/system --set "$STORE_PATH" + + echo "Configuring bootloader..." + "$STORE_PATH/bin/switch-to-configuration" boot + + echo "Removing first-boot trigger..." + rm /var/lib/pifinder/first-boot-target + + echo "Cleaning up migration closure..." + nix-env --delete-generations +2 -p /nix/var/nix/profiles/system || true + nix-collect-garbage || true + + echo "Rebooting into full PiFinder system..." + systemctl reboot + ''; + }; + + # --------------------------------------------------------------------------- + # Polkit rules for NetworkManager control + # --------------------------------------------------------------------------- + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if (subject.user == "pifinder") { + if (action.id.indexOf("org.freedesktop.NetworkManager") == 0) { + return polkit.Result.YES; + } + 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; + } + } + }); + ''; + + # --------------------------------------------------------------------------- + # Sudoers — minimal for migration + # --------------------------------------------------------------------------- + security.sudo.extraRules = [{ + users = [ "pifinder" ]; + commands = [ + { command = "/run/current-system/sw/bin/shutdown *"; 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/avahi-set-host-name *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/dmesg"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart pifinder-first-boot.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart pifinder*"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl status *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; } + ]; + }]; + + # --------------------------------------------------------------------------- + # Early boot splash + # --------------------------------------------------------------------------- + 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 + ''; + }; + }; + + # --------------------------------------------------------------------------- + # 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; + }; + }; + + 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 + ''; + }; + }; + + # NetworkManager-wait-online adds ~10s to boot but is needed for + # pifinder-first-boot to have internet. The first-boot script also has + # its own connectivity retry loop as a fallback. + systemd.services.NetworkManager-wait-online.serviceConfig.TimeoutStartSec = "30s"; + + system.stateVersion = "24.11"; + }; # config +} diff --git a/nixos/networking.nix b/nixos/networking.nix new file mode 100644 index 000000000..6cf9ed6a8 --- /dev/null +++ b/nixos/networking.nix @@ -0,0 +1,59 @@ +{ 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"; + }; + + # 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..ee3dc9345 --- /dev/null +++ b/nixos/pkgs/pifinder-src.nix @@ -0,0 +1,60 @@ +{ pkgs, python ? pkgs.python313 }: +let + tetra3-src = pkgs.fetchFromGitHub { + owner = "smroid"; + repo = "cedar-solve"; + rev = "cded265ca1c41e4e526f91e06d3c7ef99bc37288"; + hash = "sha256-eJtBuBmsElEojXLYfYy3gQ/s2+8qjyvOYAqROe4sNO0="; + }; + + # Stable astro data — catalogs, star patterns, ephemeris (~193MB, rarely changes) + # hip_main.dat is now committed to astro_data/ upstream, so cp -r picks it up. + 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/ + ''; + }; + +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 + + # Pre-compile .pyc bytecode so Python skips compilation at runtime + chmod -R u+w $out/python + python3 -m compileall -q $out/python + ''; +} diff --git a/nixos/pkgs/uv-python.nix b/nixos/pkgs/uv-python.nix new file mode 100644 index 000000000..86643122d --- /dev/null +++ b/nixos/pkgs/uv-python.nix @@ -0,0 +1,77 @@ +{ pkgs, lib ? pkgs.lib, pyproject-nix, uv2nix, pyproject-build-systems }: +let + python = pkgs.python313; + + # The uv workspace lives at the repo root (python/pyproject.toml + uv.lock). + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ../../python; }; + + # Prefer prebuilt wheels; fall back to sdist where no wheel exists. + overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; + + # Native/C-extension packages that can't build from PyPI metadata alone. + # These mirror the patches the old hand-written python-packages.nix carried. + pyprojectOverrides = final: prev: { + python-libinput = prev.python-libinput.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [ pkgs.pkg-config ] + ++ final.resolveBuildSystem { setuptools = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.libinput pkgs.systemd ]; + postPatch = (old.postPatch or "") + '' + 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')" + ''; + }); + + python-prctl = prev.python-prctl.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.libcap ]; + }); + + python-pam = prev.python-pam.overrideAttrs (old: { + postPatch = (old.postPatch or "") + '' + 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"' + ''; + }); + + # dbus-python and PyGObject build from sdist with meson-python; meson needs + # the C libraries + pkg-config on the build inputs. + dbus-python = prev.dbus-python.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.pkg-config ]; + buildInputs = (old.buildInputs or []) ++ [ pkgs.dbus pkgs.glib ]; + }); + + pygobject = prev.pygobject.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.pkg-config ]; + buildInputs = + (old.buildInputs or []) + ++ [ pkgs.glib pkgs.gobject-introspection pkgs.cairo ]; + }); + }; + + pythonSet = + (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope + (lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + pyprojectOverrides + ]); +in { + inherit pythonSet; + # Runtime env: [project.dependencies] only. + pifinderEnv = pythonSet.mkVirtualEnv "pifinder-env" workspace.deps.default; + # Dev env: adds the [dependency-groups].dev set (pytest, mypy, selenium…). + devEnv = pythonSet.mkVirtualEnv "pifinder-dev-env" workspace.deps.all; +} 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..60b9b2113 --- /dev/null +++ b/nixos/python-env.nix @@ -0,0 +1,39 @@ +{ config, lib, pkgs, pyproject-nix, uv2nix, pyproject-build-systems, ... }: +let + env = (import ./pkgs/uv-python.nix { + inherit pkgs lib pyproject-nix uv2nix pyproject-build-systems; + }).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..99cf2f86b --- /dev/null +++ b/nixos/services.nix @@ -0,0 +1,609 @@ +{ config, lib, pkgs, pifinderPythonEnv, ... }: +let + cfg = config.pifinder; + cedar-detect = import ./pkgs/cedar-detect.nix { inherit pkgs; }; + pifinder-src = import ./pkgs/pifinder-src.nix { inherit pkgs; }; + 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 + ]; + + + + # --------------------------------------------------------------------------- + # Binary substituters — Pi downloads pre-built paths, never compiles. + # Two Attic caches on cache.pifinder.eu (ADR 0004): + # pifinder-release — tagged release closures, never garbage-collected, so a + # device upgrading long after a release still resolves it. + # pifinder — dev/nightly builds, short retention. + # cache.nixos.org serves everything not built locally. + # --------------------------------------------------------------------------- + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.pifinder.eu/pifinder-release" + "https://cache.pifinder.eu/pifinder" + "https://cache.nixos.org" + ]; + trusted-public-keys = [ + # TODO: replace placeholder with the real pifinder-release public key + # emitted by `attic cache create pifinder-release` on the server. + "pifinder-release:REPLACE_WITH_PIFINDER_RELEASE_PUBKEY=" + "pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE=" + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + ]; + }; + + # --------------------------------------------------------------------------- + # 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 gawk ]; + 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" + echo "downloading 0/0" > "$STATUS_FILE" + + # Progress via nix's internal JSON event stream — stable machine format, + # avoids scraping human-formatted --dry-run output. Each line is: + # @nix {"action":"start"|"stop"|...,"id":N,"type":N,...} + # type 100 = actCopyPath: one event per store path being substituted from + # the binary cache, with text "copying path '/nix/store/...' from '...'". + # We track start ids and increment DONE on the matching stop. --max-jobs 0 + # keeps this strictly a download path; if anything is missing from the + # binary cache nix errors instead of building locally. Enum source: nix + # src/libutil/logging.hh ActivityType (stable since Nix 2.4). + set +e + nix --log-format internal-json build "$STORE_PATH" --max-jobs 0 2>&1 \ + | gawk -v status="$STATUS_FILE" ' + /^@nix / { + line = substr($0, 6) + if (!match(line, /"id":[0-9]+/)) next + id = substr(line, RSTART + 5, RLENGTH - 5) + if (match(line, /"action":"start"/) && match(line, /"type":100/)) { + pending[id] = 1 + total++ + } else if (match(line, /"action":"stop"/) && (id in pending)) { + delete pending[id] + done++ + } else { + next + } + printf "downloading %d/%d\n", done, total > status + close(status) + } + ' + BUILD_RC=''${PIPESTATUS[0]} + set -e + if [ "$BUILD_RC" -ne 0 ]; then + echo "failed" > "$STATUS_FILE" + exit 1 + fi + + 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" ]; + }; + + # /etc/default/gpsd — kept identical to upstream pi_config_files/gpsd.conf so + # the Debian and NixOS images present the same operator-visible config. + # DEVICES opens the on-board UART GPS at startup; USBAUTO lets udev hotplug + # USB GPSes via gpsdctl. GPSD_SOCKET is intentionally omitted — gpsd's + # default (/var/run/gpsd.sock) is already what we want. + environment.etc."default/gpsd".text = '' + DEVICES="/dev/ttyAMA1" + GPSD_OPTIONS="" + USBAUTO="true" + ''; + + # 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 (ttyAMA1 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-ttyAMA1.device" ]; + requires = [ "gpsd.socket" ]; + wantedBy = [ "multi-user.target" ]; + # BindsTo ensures this stops if ttyAMA1 disappears (though it shouldn't) + bindsTo = [ "dev-ttyAMA1.device" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${pkgs.gpsd}/sbin/gpsdctl add /dev/ttyAMA1"; + ExecStop = "${pkgs.gpsd}/sbin/gpsdctl remove /dev/ttyAMA1"; + }; + }; + + # --------------------------------------------------------------------------- + # 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..bfc449adc --- /dev/null +++ b/pifinder-build.json @@ -0,0 +1,4 @@ +{ + "store_path": "/nix/store/yxwwg66bny0xcda6rb14f8yj5a0rs72b-nixos-system-pifinder-25.11.20260209.2db38e0", + "version": "nixos-82f26b0" +} 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/camera_debug.py b/python/PiFinder/camera_debug.py index 9e479836b..b2461affc 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 @@ -64,7 +64,6 @@ def capture(self) -> Image.Image: # Sleep for exposure time sleep_time = self.exposure_time / 1000000 time.sleep(sleep_time) - elapsed = time.time() - self.last_image_time # Swap every x seconds if elapsed > 10: diff --git a/python/PiFinder/cat_images.py b/python/PiFinder/cat_images.py deleted file mode 100644 index 1886c57e8..000000000 --- a/python/PiFinder/cat_images.py +++ /dev/null @@ -1,447 +0,0 @@ -#!/usr/bin/python -# -*- coding:utf-8 -*- -""" -This module is used at runtime -to handle catalog image loading -""" - -import math -import os -from typing import List, Optional, Tuple -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 rotation_radians(image_rotate: float) -> float: - """Image rotation as a y-down pixel-space angle, in radians. - - PIL's Image.rotate() turns the image counterclockwise, which in - y-down pixel coordinates is a rotation by the negated angle. - """ - return math.radians(-image_rotate) - - -def cardinal_vectors( - image_rotate: float, fx: int = 1, fy: int = 1 -) -> Tuple[Tuple[float, float], Tuple[float, float]]: - """Return (nx, ny), (ex, ey) unit vectors for North and East. - - image_rotate: degrees the POSS image was rotated (180 + roll). - fx, fy: -1 to mirror that axis (flip/flop), +1 otherwise. - """ - theta = rotation_radians(image_rotate) - n = (fx * math.sin(theta), fy * -math.cos(theta)) - e = (-fx * math.cos(theta), -fy * math.sin(theta)) - return n, e - - -def size_overlay_points( - extents: List[float], - pa: float, - image_rotate: float, - px_per_arcsec: float, - cx: float, - cy: float, - fx: int = 1, - fy: int = 1, -) -> Optional[List[Tuple[float, float]]]: - """Compute outline points for the size overlay. - - Returns a list of (x, y) tuples. - For 1 extent returns None (caller should use native ellipse). - """ - if not extents or len(extents) == 1: - return None - - theta = rotation_radians(image_rotate) - math.radians(pa + 90) - cos_t = math.cos(theta) - sin_t = math.sin(theta) - - points = [] - if len(extents) == 2: - rx = extents[0] * px_per_arcsec / 2 - ry = extents[1] * px_per_arcsec / 2 - for i in range(36): - t = 2 * math.pi * i / 36 - x = rx * math.cos(t) - y = ry * math.sin(t) - points.append( - (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) - ) - else: - step = 2 * math.pi / len(extents) - for i, ext in enumerate(extents): - angle = i * step - math.pi / 2 - r = ext * px_per_arcsec / 2 - x = r * math.cos(angle) - y = r * math.sin(angle) - points.append( - (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) - ) - return points - - -def vertex_overlay_points( - vertices: List[List[float]], - obj_ra: float, - obj_dec: float, - image_rotate: float, - px_per_arcsec: float, - cx: float, - cy: float, - fx: int = 1, - fy: int = 1, -) -> List[Tuple[float, float]]: - """Project RA/Dec vertex pairs to pixel coords via gnomonic projection. - - vertices: list of [ra, dec] pairs in degrees. - obj_ra, obj_dec: object center in degrees. - Returns list of (x, y) pixel tuples. - """ - theta = rotation_radians(image_rotate) - cos_t = math.cos(theta) - sin_t = math.sin(theta) - - ra0 = math.radians(obj_ra) - dec0 = math.radians(obj_dec) - cos_dec0 = math.cos(dec0) - sin_dec0 = math.sin(dec0) - - points = [] - for ra_deg, dec_deg in vertices: - ra = math.radians(ra_deg) - dec = math.radians(dec_deg) - cos_dec = math.cos(dec) - sin_dec = math.sin(dec) - dra = ra - ra0 - - cos_c = sin_dec0 * sin_dec + cos_dec0 * cos_dec * math.cos(dra) - if cos_c <= 0: - continue - # gnomonic: xi points East, eta points North (radians) - xi = (cos_dec * math.sin(dra)) / cos_c - eta = (cos_dec0 * sin_dec - sin_dec0 * cos_dec * math.cos(dra)) / cos_c - - # convert to arcsec offsets then pixels - dx_arcsec = -xi * 206264.806 # negate: East is left on POSS - dy_arcsec = -eta * 206264.806 # negate: North is up, pixel y is down - - dx_px = dx_arcsec * px_per_arcsec - dy_px = dy_arcsec * px_per_arcsec - - # apply image rotation - rx = dx_px * cos_t - dy_px * sin_t - ry = dx_px * sin_t + dy_px * cos_t - - points.append((cx + fx * rx, cy + fy * ry)) - return points - - -def _orient_image(return_image, roll, flip_image, flop_image): - """ - Orient a source survey image to match the eyepiece view. - - Applies the fixed 180° baseline rotation (plus the live solve roll), - then the active telescope's flip/flop mirrors: - flip_image -> top-to-bottom (vertical) mirror - flop_image -> left-to-right (horizontal) mirror - - Mirrors are applied AFTER the rotation so a mirrored optical train - (e.g. a refractor/SCT with a star diagonal) correctly reverses the - apparent sense of roll. See ADR 0003. - """ - # rotate for roll / newtonian orientation - image_rotate = 180 - if roll is not None: - image_rotate += roll - return_image = return_image.rotate(image_rotate) - - if flip_image: - return_image = return_image.transpose(Image.FLIP_TOP_BOTTOM) - if flop_image: - return_image = return_image.transpose(Image.FLIP_LEFT_RIGHT) - - return return_image - - -def get_display_image( - catalog_object, - eyepiece_text, - fov, - roll, - display_class, - burn_in=True, - magnification=None, - show_nsew=True, - show_bbox=True, - flip_image=False, - flop_image=False, -): - """ - 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) - - image_rotate = 180 - if roll is not None: - image_rotate += roll - - # Orient to match the eyepiece view (see ADR 0003) - return_image = _orient_image(return_image, roll, flip_image, flop_image) - - # 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, - ) - - cx = display_class.fov_res / 2 - cy = display_class.fov_res / 2 - fx = -1 if flop_image else 1 - fy = -1 if flip_image else 1 - - # NSEW cardinal labels — show the leftmost and rightmost of the - # four cardinals, out at the FOV ring. Clamped clear of the - # titlebar and footer text (drawn later, full brightness) so - # both letters always stay visible. - if show_nsew: - (nx, ny), (ex, ey) = cardinal_vectors(image_rotate, fx, fy) - label_font = display_class.fonts.base - label_color = display_class.colors.get(128) - r_label = display_class.fov_res / 2 - 2 - top_limit = display_class.titlebar_height + label_font.height - bottom_limit = display_class.fov_res - label_font.height * 2 - candidates = [ - ("N", nx, ny), - ("S", -nx, -ny), - ("E", ex, ey), - ("W", -ex, -ey), - ] - by_x = sorted(candidates, key=lambda c: c[1]) - for label, dx, dy in (by_x[0], by_x[-1]): - lx = cx + dx * r_label - label_font.width / 2 - ly = cy + dy * r_label - label_font.height / 2 - lx = max(0, min(lx, display_class.fov_res - label_font.width)) - ly = max(top_limit, min(ly, bottom_limit)) - ui_utils.shadow_outline_text( - ri_draw, - (lx, ly), - label, - font=label_font, - align="left", - fill=label_color, - shadow_color=display_class.colors.get(0), - outline=1, - ) - - # Size overlay - extents = catalog_object.size.extents - if show_bbox and extents and fov > 0: - px_per_arcsec = display_class.fov_res / (fov * 3600) - overlay_color = display_class.colors.get(100) - - if catalog_object.size.is_vertices: - points = vertex_overlay_points( - extents, - catalog_object.ra, - catalog_object.dec, - image_rotate, - px_per_arcsec, - cx, - cy, - fx, - fy, - ) - if len(points) >= 2: - ri_draw.line(points, fill=overlay_color, width=1) - elif len(extents) == 1: - r = extents[0] * px_per_arcsec / 2 - ri_draw.ellipse( - [cx - r, cy - r, cx + r, cy + r], - outline=overlay_color, - width=1, - ) - else: - points = size_overlay_points( - extents, - catalog_object.size.position_angle, - image_rotate, - px_per_arcsec, - cx, - cy, - fx, - fy, - ) - if points: - ri_draw.polygon(points, outline=overlay_color) - - # 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_imports/catalog_import_utils.py b/python/PiFinder/catalog_imports/catalog_import_utils.py index e828e37b2..b46a4e8f5 100644 --- a/python/PiFinder/catalog_imports/catalog_import_utils.py +++ b/python/PiFinder/catalog_imports/catalog_import_utils.py @@ -261,7 +261,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() @@ -411,7 +412,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 01abb4573..24030fa47 100644 --- a/python/PiFinder/catalog_imports/main.py +++ b/python/PiFinder/catalog_imports/main.py @@ -130,6 +130,14 @@ def main(): conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") conn.execute("PRAGMA journal_mode = DELETE") + # 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 e8d68aee1..61fbfdcd5 100644 --- a/python/PiFinder/catalog_imports/specialized_loaders.py +++ b/python/PiFinder/catalog_imports/specialized_loaders.py @@ -612,7 +612,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 395f35ec0..983f2f5e8 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 1b4acbb52..6fb80f373 100644 --- a/python/PiFinder/catalogs.py +++ b/python/PiFinder/catalogs.py @@ -1044,8 +1044,7 @@ def _build_composite( 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 56e90d0b4..fb57624b1 100644 --- a/python/PiFinder/comets.py +++ b/python/PiFinder/comets.py @@ -252,9 +252,13 @@ def _calc_comets_vectorized(comets_df: pd.DataFrame, dt) -> Dict[str, Any]: # builder), propagated in a single call -> heliocentric state, AU, # equatorial ICRF, relative to the Sun. kepler = mpc._comet_orbits(comets_df, sf_utils.ts, GM_SUN) - helio_pos = kepler._at(t)[0] - if helio_pos.ndim == 1: # propagate() squeezes a single comet to (3,) - helio_pos = helio_pos[:, np.newaxis] + # Skyfield's propagate() lays the result out as (3, #orbits, #times) but + # sets output_shape = (3,) + t1.shape, so a batched orbit only reshapes + # cleanly when the target time is itself shaped (#orbits, 1); a scalar time + # raises "cannot reshape array of size 3N into shape (3,)" (skyfield >= + # 1.46). Give every comet the same target time as an (N, 1) column. + t_batched = sf_utils.ts.tt_jd(np.full((len(comets_df), 1), t.tt)) + helio_pos = kepler._at(t_batched)[0][:, :, 0] # Sun and observer are single 3-vectors relative to the solar-system # barycentre; broadcast them across all comets. topocentric = observer diff --git a/python/PiFinder/db/objects_db.py b/python/PiFinder/db/objects_db.py index 95eaa3c2b..e2d947739 100644 --- a/python/PiFinder/db/objects_db.py +++ b/python/PiFinder/db/objects_db.py @@ -11,20 +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 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 @@ -315,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 9825e8fa1..a9e56c4aa 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 @@ -12,7 +13,9 @@ from PiFinder.ssd1333_device import ssd1333 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") @@ -69,14 +72,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, @@ -87,8 +167,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 Layout320: """Shared 320x240 layout profile for the ST7789 LCD. @@ -117,7 +201,16 @@ class DisplayPygame_320(Layout320, DisplayBase): def __init__(self): from luma.emulator.device import pygame + import pygame as pg + from pathlib import Path + + # 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=self.resolution[0], height=self.resolution[1], @@ -126,8 +219,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_ubx_parser.py b/python/PiFinder/gps_ubx_parser.py index f0bb7da40..35fbdb802 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 cf1d1b4fe..6768e82bd 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/keyboard_local.py b/python/PiFinder/keyboard_local.py index 7402f7885..73fe48f2a 100644 --- a/python/PiFinder/keyboard_local.py +++ b/python/PiFinder/keyboard_local.py @@ -31,9 +31,14 @@ 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 # pynput bug on macOS: KeyCode.__repr__ crashes with TypeError when vk is None. # PyHotKey calls repr(key) to look up hotkeys, so patch it to be safe. try: @@ -51,6 +56,7 @@ def _safe_repr(self): except Exception: pass try: + logger.info("Setting up keyboard bindings...") self.q = q # Configure unmodified keys keyboard.set_magickey_on_release(Key.left, self.callback, self.LEFT) @@ -95,10 +101,11 @@ def _safe_repr(self): 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) @@ -106,9 +113,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/main.py b/python/PiFinder/main.py index 3148f6a23..279b9ff11 100644 --- a/python/PiFinder/main.py +++ b/python/PiFinder/main.py @@ -588,6 +588,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() @@ -625,6 +637,7 @@ def main( pygame_key_map, pygame_ctrl_key_map = _build_pygame_keymaps() log_time = True + # Start of main except handler / loop try: while True: @@ -732,6 +745,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) @@ -973,11 +989,6 @@ def main( if __name__ == "__main__": import sys - # Ensure the active log config symlink exists, defaulting to logconf_default.json - _logconf_link = Path("pifinder_logconf.json") - if not _logconf_link.exists(): - _logconf_link.symlink_to("logconf_default.json") - debug_no_file_logs = "--debug-no-file-logs" in sys.argv if debug_no_file_logs: os.environ["PIFINDER_DEBUG_NO_FILE_LOGS"] = "1" @@ -988,13 +999,13 @@ def main( rlogger.setLevel(logging.DEBUG if debug_no_file_logs else logging.INFO) if debug_no_file_logs: - log_helper = MultiprocLogging(Path("pifinder_logconf.json"), console_only=True) + log_helper = MultiprocLogging(utils.active_logconf_path(), console_only=True) MultiprocLogging.configurer(log_helper.get_queue()) else: log_path = utils.data_dir / "pifinder.log" try: log_helper = MultiprocLogging( - Path("pifinder_logconf.json"), + utils.active_logconf_path(), log_path, ) MultiprocLogging.configurer(log_helper.get_queue()) @@ -1152,13 +1163,18 @@ 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": if display_hardware.startswith("pg_"): - from PiFinder import keyboard_none as keyboard # type: ignore[no-redef] + from PiFinder import keyboard_none as keyboard rlogger.info("using pygame keyboard (main loop captures keys)") else: @@ -1166,7 +1182,7 @@ def main( 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") diff --git a/python/PiFinder/multiproclogging.py b/python/PiFinder/multiproclogging.py index 46c780a12..77f065d7e 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 @@ -19,6 +18,8 @@ import logging.config import logging.handlers +from PiFinder import utils + class MultiprocLogging: """ @@ -87,9 +88,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." + ) # Create the main-process queue BEFORE starting the sink so the sink # receives it in its queue list and monitors it. @@ -186,11 +187,11 @@ def configurer(queue: Queue): import os 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") + log_conf_file = utils.active_logconf_path() with open(log_conf_file, "r") as logconf: config = json5.load(logconf) logging.config.dictConfig(config) diff --git a/python/PiFinder/nearby.py b/python/PiFinder/nearby.py index 47a117852..f37da8064 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") @@ -80,6 +79,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..ba2a36a00 --- /dev/null +++ b/python/PiFinder/object_images/image_utils.py @@ -0,0 +1,540 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Shared image utility functions for object images + +Provides common operations for: +- POSS survey images +- Generated Gaia star charts +""" + +import math +from typing import List, Optional, Tuple + +from PIL import Image, ImageDraw, ImageChops + +from PiFinder.ui import ui_utils + + +def rotation_radians(image_rotate: float) -> float: + """Image rotation as a y-down pixel-space angle, in radians. + + PIL's Image.rotate() turns the image counterclockwise, which in + y-down pixel coordinates is a rotation by the negated angle. + """ + return math.radians(-image_rotate) + + +def cardinal_vectors( + image_rotate: float, fx: int = 1, fy: int = 1 +) -> Tuple[Tuple[float, float], Tuple[float, float]]: + """Return (nx, ny), (ex, ey) unit vectors for North and East. + + image_rotate: degrees the field image was rotated. + fx, fy: -1 to mirror that axis (flip/flop), +1 otherwise. + """ + theta = rotation_radians(image_rotate) + n = (fx * math.sin(theta), fy * -math.cos(theta)) + e = (-fx * math.cos(theta), -fy * math.sin(theta)) + return n, e + + +def size_overlay_points( + extents: List[float], + pa: float, + image_rotate: float, + px_per_arcsec: float, + cx: float, + cy: float, + fx: int = 1, + fy: int = 1, +) -> Optional[List[Tuple[float, float]]]: + """Compute outline points for the size overlay. + + Returns a list of (x, y) tuples. + For 1 extent returns None (caller should use native ellipse). + """ + if not extents or len(extents) == 1: + return None + + theta = rotation_radians(image_rotate) - math.radians(pa + 90) + cos_t = math.cos(theta) + sin_t = math.sin(theta) + + points = [] + if len(extents) == 2: + rx = extents[0] * px_per_arcsec / 2 + ry = extents[1] * px_per_arcsec / 2 + for i in range(36): + t = 2 * math.pi * i / 36 + x = rx * math.cos(t) + y = ry * math.sin(t) + points.append( + (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) + ) + else: + step = 2 * math.pi / len(extents) + for i, ext in enumerate(extents): + angle = i * step - math.pi / 2 + r = ext * px_per_arcsec / 2 + x = r * math.cos(angle) + y = r * math.sin(angle) + points.append( + (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) + ) + return points + + +def vertex_overlay_points( + vertices: List[List[float]], + obj_ra: float, + obj_dec: float, + image_rotate: float, + px_per_arcsec: float, + cx: float, + cy: float, + fx: int = 1, + fy: int = 1, +) -> List[Tuple[float, float]]: + """Project RA/Dec vertex pairs to pixel coords via gnomonic projection. + + vertices: list of [ra, dec] pairs in degrees. + obj_ra, obj_dec: object center in degrees. + Returns list of (x, y) pixel tuples. + """ + theta = rotation_radians(image_rotate) + cos_t = math.cos(theta) + sin_t = math.sin(theta) + + ra0 = math.radians(obj_ra) + dec0 = math.radians(obj_dec) + cos_dec0 = math.cos(dec0) + sin_dec0 = math.sin(dec0) + + points = [] + for ra_deg, dec_deg in vertices: + ra = math.radians(ra_deg) + dec = math.radians(dec_deg) + cos_dec = math.cos(dec) + sin_dec = math.sin(dec) + dra = ra - ra0 + + cos_c = sin_dec0 * sin_dec + cos_dec0 * cos_dec * math.cos(dra) + if cos_c <= 0: + continue + # gnomonic: xi points East, eta points North (radians) + xi = (cos_dec * math.sin(dra)) / cos_c + eta = (cos_dec0 * sin_dec - sin_dec0 * cos_dec * math.cos(dra)) / cos_c + + # convert to arcsec offsets then pixels + dx_arcsec = -xi * 206264.806 # negate: East is left on the survey image + dy_arcsec = -eta * 206264.806 # negate: North is up, pixel y is down + + dx_px = dx_arcsec * px_per_arcsec + dy_px = dy_arcsec * px_per_arcsec + + # apply image rotation + rx = dx_px * cos_t - dy_px * sin_t + ry = dx_px * sin_t + dy_px * cos_t + + points.append((cx + fx * rx, cy + fy * ry)) + return points + + +def add_orientation_overlays( + image, + display_class, + catalog_object, + fov, + image_rotate, + fx=1, + fy=1, + show_nsew=True, + show_bbox=True, +): + """Draw NSEW cardinal labels and the object size box on a field image. + + Restores the image_nsew / image_bbox behaviour for the object_images + backend. Operates on the square field image (display_class.fov_res), + before any padding, with the centre at fov_res / 2. + """ + if not (show_nsew or show_bbox): + return image + + draw = ImageDraw.Draw(image) + cx = display_class.fov_res / 2 + cy = display_class.fov_res / 2 + + # NSEW cardinal labels — show the leftmost and rightmost of the four + # cardinals out at the FOV ring, clamped clear of the titlebar/footer. + if show_nsew: + (nx, ny), (ex, ey) = cardinal_vectors(image_rotate, fx, fy) + label_font = display_class.fonts.base + label_color = display_class.colors.get(128) + r_label = display_class.fov_res / 2 - 2 + top_limit = display_class.titlebar_height + label_font.height + bottom_limit = display_class.fov_res - label_font.height * 2 + candidates = [ + ("N", nx, ny), + ("S", -nx, -ny), + ("E", ex, ey), + ("W", -ex, -ey), + ] + by_x = sorted(candidates, key=lambda c: c[1]) + for label, dx, dy in (by_x[0], by_x[-1]): + lx = cx + dx * r_label - label_font.width / 2 + ly = cy + dy * r_label - label_font.height / 2 + lx = max(0, min(lx, display_class.fov_res - label_font.width)) + ly = max(top_limit, min(ly, bottom_limit)) + ui_utils.shadow_outline_text( + draw, + (lx, ly), + label, + font=label_font, + align="left", + fill=label_color, + shadow_color=display_class.colors.get(0), + outline=1, + ) + + # Size overlay + size = getattr(catalog_object, "size", None) + extents = size.extents if size else None + if show_bbox and extents and fov > 0: + px_per_arcsec = display_class.fov_res / (fov * 3600) + overlay_color = display_class.colors.get(100) + + if size.is_vertices: + points = vertex_overlay_points( + extents, + catalog_object.ra, + catalog_object.dec, + image_rotate, + px_per_arcsec, + cx, + cy, + fx, + fy, + ) + if len(points) >= 2: + draw.line(points, fill=overlay_color, width=1) + elif len(extents) == 1: + r = extents[0] * px_per_arcsec / 2 + draw.ellipse( + [cx - r, cy - r, cx + r, cy + r], + outline=overlay_color, + width=1, + ) + else: + points = size_overlay_points( + extents, + size.position_angle, + image_rotate, + px_per_arcsec, + cx, + cy, + fx, + fy, + ) + if points: + draw.polygon(points, outline=overlay_color) + + return image + + +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 + + 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..9c461401a --- /dev/null +++ b/python/PiFinder/object_images/poss_provider.py @@ -0,0 +1,214 @@ +#!/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 +from .image_utils import ( + apply_circular_vignette, + pad_to_display_resolution, + add_image_overlays, + add_orientation_overlays, +) +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 + """ + # 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) + + # NSEW labels + object size box (image_nsew / image_bbox settings). + # Drawn on the field image before padding, matching the orientation + # (image_rotate + flip/flop) the POSS image was rendered with. + fx = -1 if (telescope and telescope.flop_image) else 1 + fy = -1 if (telescope and telescope.flip_image) else 1 + return_image = add_orientation_overlays( + return_image, + display_class, + catalog_object, + fov, + image_rotate, + fx, + fy, + show_nsew=kwargs.get("show_nsew", True), + show_bbox=kwargs.get("show_bbox", True), + ) + + # 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..de63ba521 --- /dev/null +++ b/python/PiFinder/object_images/star_catalog.py @@ -0,0 +1,1566 @@ +#!/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 (idempotent)""" + if self._mm is not None: + self._mm.close() + self._mm = None + if self._file is not None: + self._file.close() + self._file = None + + 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/server.py b/python/PiFinder/server.py index d55d718b9..91476494e 100644 --- a/python/PiFinder/server.py +++ b/python/PiFinder/server.py @@ -99,7 +99,7 @@ def __init__( shared_state=None, is_debug=False, ): - self.version_txt = f"{utils.pifinder_dir}/version.txt" + self._software_version = utils.get_version() self.keyboard_queue = keyboard_queue or multiprocessing.Queue() self.ui_queue = ui_queue or multiprocessing.Queue() self.gps_queue = gps_queue or multiprocessing.Queue() @@ -199,12 +199,8 @@ def send_css(filename): 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: @@ -520,7 +516,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 app.jinja_env.get_template("restart.html").render(title=_("Restart")) + return app.jinja_env.get_template("network.html").render( + title=_("Network"), + net=self.network, + show_new_form=0, + status_message=_("Network settings updated. You may need to reconnect."), + ) @app.route("/tools/pwchange", methods=["POST"]) @auth_required @@ -678,7 +679,7 @@ def equipment_import(): try: cfg.equipment.eyepieces.index(new_eyepiece) except ValueError: - cfg.equipment.eyepieces.add_eyepiece(new_eyepiece) + cfg.equipment.eyepieces.append(new_eyepiece) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -725,13 +726,13 @@ def equipment_add_eyepiece(eyepiece_id: int): ) if eyepiece_id >= 0: - cfg.equipment.update_eyepiece(eyepiece_id, eyepiece) + cfg.equipment.eyepieces[eyepiece_id] = eyepiece else: try: index = cfg.equipment.telescopes.index(eyepiece) - cfg.equipment.update_eyepiece(index, eyepiece) + cfg.equipment.eyepieces[index] = eyepiece except ValueError: - cfg.equipment.add_eyepiece(eyepiece) + cfg.equipment.eyepieces.append(eyepiece) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -1013,23 +1014,16 @@ def remove_file(response): @app.route("/logs/configs") @auth_required def list_log_configs(): - """Return all available logconf_*.json files with display names.""" - import glob - + """Return all available logconf_*.json presets with display names.""" + active = utils.active_logconf_name() configs = [] - active = ( - os.path.realpath("pifinder_logconf.json") - if os.path.exists("pifinder_logconf.json") - else None - ) - for path in sorted(glob.glob("logconf_*.json")): - stem = path[len("logconf_") : -len(".json")] - display = stem.replace("_", " ").title() + for name in utils.available_logconfs(): + stem = name[len("logconf_") : -len(".json")] configs.append( { - "file": path, - "name": display, - "active": os.path.realpath(path) == active, + "file": name, + "name": stem.replace("_", " ").title(), + "active": name == active, } ) return jsonify({"configs": configs}) @@ -1037,29 +1031,15 @@ def list_log_configs(): @app.route("/logs/switch_config", methods=["POST"]) @auth_required def switch_log_config(): - """Atomically repoint pifinder_logconf.json to the chosen config, then restart.""" + """Persist the chosen log config to the data dir, then restart.""" logconf_file = request.form.get("logconf_file", "").strip() - if ( - not logconf_file - or not logconf_file.startswith("logconf_") - or not logconf_file.endswith(".json") - ): + try: + utils.set_active_logconf(logconf_file) + logger.info("Switched log config to %s", logconf_file) + except (ValueError, FileNotFoundError): return jsonify( {"status": "error", "message": "Invalid log config file name"} ) - if not os.path.exists(logconf_file): - return jsonify( - { - "status": "error", - "message": f"Log config file not found: {logconf_file}", - } - ) - try: - link = "pifinder_logconf.json" - tmp = link + ".tmp" - os.symlink(logconf_file, tmp) - os.replace(tmp, link) - logger.info("Switched log config to %s", logconf_file) except Exception as e: logger.error("Failed to switch log config: %s", e) return jsonify({"status": "error", "message": str(e)}) diff --git a/python/PiFinder/solver.py b/python/PiFinder/solver.py index 34f5c040a..9e6181677 100644 --- a/python/PiFinder/solver.py +++ b/python/PiFinder/solver.py @@ -437,7 +437,10 @@ def solver( ): MultiprocLogging.configurer(log_queue) logger.debug("Starting Solver") - t3 = tetra3.Tetra3(str(utils.tetra3_dir / "data" / "default_database.npz")) + # Load tetra3's bundled pattern database by name; tetra3 resolves it from + # its own package data dir, which avoids depending on the inner/outer + # tetra3_dir layout (the submodule nests the package at tetra3/tetra3). + t3 = tetra3.Tetra3("default_database") align_ra = 0 align_dec = 0 last_solve_attempt: float = 0.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/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 490d27c57..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,9 +84,10 @@ "\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", "\n", @@ -92,11 +95,10 @@ "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", @@ -160,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']}" ] }, { @@ -197,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", @@ -205,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.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()" @@ -536,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)" ] @@ -592,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", @@ -607,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", + " )" ] }, { @@ -632,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", @@ -645,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)" ] }, { @@ -685,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()" ] }, @@ -729,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)" ] }, @@ -795,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", @@ -910,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", @@ -1053,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", @@ -1081,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", + " if \"matched_centroids\" not in solution or len(solution[\"matched_centroids\"]) == 0:\n", " print(\" ❌ 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", + " 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", @@ -1109,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(\" ❌ 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", + " 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", @@ -1144,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²\")" ] }, @@ -1329,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", @@ -1370,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", @@ -1435,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", @@ -1448,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(\"✓ No aperture overlaps detected\\n\")\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", @@ -1552,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='Mean', alpha=0.6)\n", - " ax4.axhline(mzero_median, color='green', linestyle='--', linewidth=2, label='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('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='Mean')\n", - " ax5.axvline(mzero_median, color='green', linestyle='--', linewidth=2, label='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='σ-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='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", @@ -1954,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", @@ -1967,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(\"\\nDETAILED OVERLAP INFORMATION:\")\n", - " print(f\"{'='*100}\")\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(\"\\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", + " 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(\"\\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", + " 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", @@ -2030,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(\"\\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 d5426a020..c772d4bcc 100644 --- a/python/PiFinder/sqm/sqm.py +++ b/python/PiFinder/sqm/sqm.py @@ -247,9 +247,7 @@ def _calculate_mzero( 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 @@ -309,7 +307,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: @@ -457,7 +455,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: diff --git a/python/PiFinder/sys_utils.py b/python/PiFinder/sys_utils.py index 21b441892..37f1970a5 100644 --- a/python/PiFinder/sys_utils.py +++ b/python/PiFinder/sys_utils.py @@ -1,447 +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, 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} + + 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() - return wifi_networks + async_fn(*args, callback, None) + loop.run() - def get_wifi_networks(self): - return self._wifi_networks + if state["error"]: + raise state["error"] + return state["result"] - def delete_wifi_network(self, network_id): - """ - Immediately deletes a wifi network - """ - self._wifi_networks.pop(network_id) - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "r") as wpa_conf: - wpa_contents = list(wpa_conf) +def _get_system_bus() -> dbus.SystemBus: + return dbus.SystemBus() - 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) - for network in self._wifi_networks: - ssid = network["ssid"] - key_mgmt = network["key_mgmt"] - psk = network["psk"] +# --------------------------------------------------------------------------- +# Network class — WiFi management via NM GLib bindings +# --------------------------------------------------------------------------- - 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") - 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) - self._update_etc_hosts(hostname) + _HOSTNAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$") - @staticmethod - def _rewrite_hosts(contents: str, new_hostname: str) -> str: - """ - Rewrite the Debian-convention ``127.0.1.1`` line in /etc/hosts to point - at ``new_hostname``. Preserves indentation, the IP, and any trailing - aliases/comments. If no ``127.0.1.1`` line exists, appends one so that - ``sudo`` can still resolve the host. - """ - lines = contents.splitlines(keepends=True) - pattern = re.compile(r"^(\s*127\.0\.1\.1\s+)\S+(.*)$") - replaced = False - for i, line in enumerate(lines): - match = pattern.match(line) - if match: - eol = "\n" if line.endswith("\n") else "" - lines[i] = f"{match.group(1)}{new_hostname}{match.group(2)}{eol}" - replaced = True - break - if not replaced: - if lines and not lines[-1].endswith("\n"): - lines[-1] += "\n" - lines.append(f"127.0.1.1\t{new_hostname}\n") - return "".join(lines) + def set_host_name(self, hostname: str) -> None: + """Set kernel hostname and update avahi mDNS announcement. - def _update_etc_hosts(self, new_hostname: str) -> None: - try: - with open("/etc/hosts", "r") as hosts_f: - contents = hosts_f.read() - except IOError as e: - logger.error(f"Error reading /etc/hosts: {e}") + 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 - new_contents = Network._rewrite_hosts(contents, new_hostname) - with open("/tmp/hosts", "w") as new_hosts: - new_hosts.write(new_contents) - sh.sudo("cp", "/tmp/hosts", "/etc/hosts") - - def wifi_mode(self): - return self._wifi_mode - - def set_wifi_mode(self, mode): - if mode == self._wifi_mode: + if hostname == self.get_host_name(): 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) + 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 - """ +# --------------------------------------------------------------------------- +# 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 + - return p.authenticate(username, password) +# --------------------------------------------------------------------------- +# 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 6816860d0..2afc27730 100644 --- a/python/PiFinder/sys_utils_fake.py +++ b/python/PiFinder/sys_utils_fake.py @@ -1,244 +1,80 @@ -import socket import logging -import os -import zipfile -import tempfile - -# For testing, use a directory structure that mimics the production setup -# but in a writable location. The server serves from /home/pifinder/PiFinder_data -# so we need to create a backup file that can be served from there. -# Since we can't write to /home/pifinder as a regular user, we'll use the current -# user's directory structure that mirrors the production layout. -_pifinder_data_dir = os.path.expanduser("~/PiFinder_data") -os.makedirs(_pifinder_data_dir, exist_ok=True) -BACKUP_PATH = os.path.join(_pifinder_data_dir, "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 - """ + def get_connected_ssid(self) -> str: return "UNKN" - def set_host_name(self, hostname): - if hostname == self.get_host_name(): - return - - def wifi_mode(self): - 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 - """ - try: - if os.path.exists(BACKUP_PATH): - os.remove(BACKUP_PATH) - except OSError: - 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/* - """ - remove_backup() - - # Use actual files from ~/PiFinder_data directory - source_dir = _pifinder_data_dir - - # Create zip file with actual user data - with zipfile.ZipFile(BACKUP_PATH, "w", zipfile.ZIP_DEFLATED) as zipf: - # Add config.json if it exists - config_path = os.path.join(source_dir, "config.json") - if os.path.exists(config_path): - zipf.write(config_path, "home/pifinder/PiFinder_data/config.json") - - # Add observations.db if it exists - db_path = os.path.join(source_dir, "observations.db") - if os.path.exists(db_path): - zipf.write(db_path, "home/pifinder/PiFinder_data/observations.db") - - # Add all files from obslists directory if it exists - obslists_dir = os.path.join(source_dir, "obslists") - if os.path.exists(obslists_dir): - for filename in os.listdir(obslists_dir): - file_path = os.path.join(obslists_dir, filename) - if os.path.isfile(file_path): - zipf.write( - file_path, f"home/pifinder/PiFinder_data/obslists/{filename}" - ) +def backup_userdata() -> str: return BACKUP_PATH -def restore_userdata(zip_path): - """ - Compliment to backup_userdata - "restores" userdata +def restore_userdata(zip_path) -> None: + pass - For the fake version, this compares the zip contents - with the current ~/PiFinder_data contents and throws - an exception if they don't match. - """ - import zipfile - import filecmp - - if not os.path.exists(zip_path): - raise FileNotFoundError(f"Backup file not found: {zip_path}") - - # Extract zip to temporary directory for comparison - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(zip_path, "r") as zipf: - # Extract all files - zipf.extractall(temp_dir) - - # Compare extracted files with actual files in ~/PiFinder_data - extracted_base = os.path.join(temp_dir, "home", "pifinder", "PiFinder_data") - actual_base = _pifinder_data_dir - - if not os.path.exists(extracted_base): - raise ValueError( - "Invalid backup file: missing expected directory structure" - ) - - # Check each file that should exist - files_to_check = ["config.json", "observations.db"] - - for filename in files_to_check: - extracted_file = os.path.join(extracted_base, filename) - actual_file = os.path.join(actual_base, filename) - - # If file exists in backup but not in actual directory - if os.path.exists(extracted_file) and not os.path.exists(actual_file): - raise ValueError( - f"Backup contains {filename} but it doesn't exist in {actual_base}" - ) - - # If file exists in both, compare contents - if os.path.exists(extracted_file) and os.path.exists(actual_file): - if not filecmp.cmp(extracted_file, actual_file, shallow=False): - raise ValueError( - f"Backup file {filename} differs from current version in {actual_base}" - ) - - # Check obslists directory - extracted_obslists = os.path.join(extracted_base, "obslists") - actual_obslists = os.path.join(actual_base, "obslists") - - if os.path.exists(extracted_obslists): - if not os.path.exists(actual_obslists): - raise ValueError( - "Backup contains obslists directory but it doesn't exist in current data" - ) - - # Compare each file in obslists - for filename in os.listdir(extracted_obslists): - extracted_obslist = os.path.join(extracted_obslists, filename) - actual_obslist = os.path.join(actual_obslists, filename) - - if os.path.isfile(extracted_obslist): - if not os.path.exists(actual_obslist): - raise ValueError( - f"Backup contains obslist {filename} but it doesn't exist in current obslists" - ) - - if not filecmp.cmp( - extracted_obslist, actual_obslist, shallow=False - ): - raise ValueError( - f"Backup obslist {filename} differs from current version" - ) - - # If we get here, all files match - logger.info("Restore validation successful: backup contents match current data") - return True - - -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") @@ -253,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/tetra3 b/python/PiFinder/tetra3 index 38c3f48f5..cded265ca 160000 --- a/python/PiFinder/tetra3 +++ b/python/PiFinder/tetra3 @@ -1 +1 @@ -Subproject commit 38c3f48f57d1005e9b65cbb26136f9f13ec0a1b0 +Subproject commit cded265ca1c41e4e526f91e06d3c7ef99bc37288 diff --git a/python/PiFinder/ui/base.py b/python/PiFinder/ui/base.py index 9cfc93457..87d0db531 100644 --- a/python/PiFinder/ui/base.py +++ b/python/PiFinder/ui/base.py @@ -384,6 +384,8 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: if self.shared_state: if self.shared_state.solve_state(): solution = self.shared_state.solution() + if solution is None: + return cam_active = solution.is_camera_solve() # a fresh cam solve sets unmoved to True self._unmoved = True if cam_active else self._unmoved diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index 2fcf1c7d2..f11e1ca38 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -198,21 +198,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: @@ -224,9 +210,6 @@ def switch_language(ui_module: UIModule) -> None: ) lang.install() logger.info("Switch Language: %s", iso2_code) - if iso2_code == "zh": - # Chinese requires a new font, so we have to restart - restart_pifinder(ui_module) def go_wifi_ap(ui_module: UIModule) -> None: diff --git a/python/PiFinder/ui/console.py b/python/PiFinder/ui/console.py index fa25f0dad..d5ff0b20a 100644 --- a/python/PiFinder/ui/console.py +++ b/python/PiFinder/ui/console.py @@ -36,7 +36,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__), "..", "..", "..") ) @@ -94,6 +94,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/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/menu_manager.py b/python/PiFinder/ui/menu_manager.py index 12f155ce3..369bd1048 100644 --- a/python/PiFinder/ui/menu_manager.py +++ b/python/PiFinder/ui/menu_manager.py @@ -150,7 +150,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 880f804e7..7242c9da4 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -18,6 +18,7 @@ from PiFinder.ui.locationentry import UILocationEntry from PiFinder.ui.radec_entry import UIRADecEntry from PiFinder.ui.telemetry_list import UITelemetryList +from PiFinder.ui.lm_entry import UILMEntry import PiFinder.ui.callbacks as callbacks @@ -946,6 +947,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 0492d4e28..3cd3f874c 100644 --- a/python/PiFinder/ui/object_details.py +++ b/python/PiFinder/ui/object_details.py @@ -8,7 +8,9 @@ from pydeepskylog.exceptions import InvalidParameterError -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.composite_object import MagnitudeObject from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder.obj_types import OBJ_TYPES @@ -27,19 +29,82 @@ import functools from PiFinder.db.observations_db import ObservationsDatabase +from PIL import Image, ImageDraw +import logging import numpy as np import time import pydeepskylog as pds +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_IMAGE = 2 # Display mode for images (POSS or Gaia chart) +DM_POSS = 2 # Display mode for POSS (alias of DM_IMAGE) DM_SDSS = 3 # Display mode for SDSS DM_CONTRAST = 4 # Display mode for Contrast Reserve explanation +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): """ Shows details about an object @@ -55,13 +120,24 @@ def __init__(self, *args, **kwargs): self.contrast = None 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( @@ -75,6 +151,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 observation counts self.observations_db = ObservationsDatabase() @@ -117,6 +201,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 @@ -161,6 +254,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 @@ -329,26 +429,92 @@ def update_object_info(self): if solution and solution.has_pointing(): roll = solution.pointing.aligned.estimate.Roll + # Calculate magnification and TFOV using current active eyepiece (custom or configured) magnification = self.config_object.equipment.calc_magnification() + tfov = self.config_object.equipment.calc_tfov() + eyepiece_text = str(self.config_object.equipment.active_eyepiece) flip_image, flop_image = ( self.config_object.equipment.active_telescope_image_orientation() ) - self.object_image = cat_images.get_display_image( + + 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, show_nsew=self.config_object.get_option("image_nsew", True), show_bbox=self.config_object.get_option("image_bbox", True), flip_image=flip_image, flop_image=flop_image, + 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 @@ -358,6 +524,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 not self.shared_state.solution().has_pointing(): @@ -450,14 +930,147 @@ def _render_pointing_instructions(self): self, point_az, point_alt, indicator_color, self.mount_type ) + 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 + ) + + # 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), + ) - # paste image - if self.object_display_mode in [DM_POSS, DM_SDSS]: + 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() @@ -466,8 +1079,9 @@ def update(self, force=True): # large-font line below the designator (derived so they track res). desig_y = self.display_class.titlebar_height + 3 typeconst_y = desig_y + self.fonts.large.height - desig = self.texts["designator"] - desig.draw((0, desig_y)) + desig = self.texts.get("designator") + if desig: + desig.draw((0, desig_y)) # Object TYPE and Constellation i.e. 'Galaxy PER' typeconst = self.texts.get("type-const") @@ -578,7 +1192,16 @@ def update(self, force=True): ) y_pos += 11 - 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): """ @@ -626,6 +1249,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 @@ -658,9 +1315,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 not self.shared_state.solution().has_pointing(): return @@ -672,7 +1334,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() @@ -778,3 +1499,60 @@ def serialize_ui_state(self) -> dict: } except Exception as e: return {"error": f"Failed to serialize object details state: {str(e)}"} + + 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 5815f760a..b7e07b06e 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -22,6 +22,7 @@ from PiFinder.ui.ui_utils import outline_text sys.path.append(str(utils.tetra3_dir)) +sys.path.append(str(utils.tetra3_dir / "tetra3")) # Focus indicator tuning (see docs/ax/ui/CONTEXT.md "Focus indicator" and # docs/adr/0005-focus-hfd-self-contained-in-ui.md). Starting values -- adjust diff --git a/python/PiFinder/ui/software.py b/python/PiFinder/ui/software.py index c9be54aa0..2c4790720 100644 --- a/python/PiFinder/ui/software.py +++ b/python/PiFinder/ui/software.py @@ -1,243 +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 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 = "3.0.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): - 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 += self.fonts.base.height + 4 + 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 += self.fonts.bold.height - 3 - + 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 += self.fonts.bold.height + 3 + 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 += self.fonts.bold.height - 3 + 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 - # The two-line status / action message is anchored up from the bottom - # so it clears the (taller-font) info block on larger displays. - msg_pitch = self.fonts.large.height - msg_top = self.display_class.resY - 2 * msg_pitch - 6 - msg_bottom = msg_top + msg_pitch - - if self._wifi_mode != "Client": - self.draw.text( - (10, msg_top), - _("WiFi must be"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) + # Path count below bar + if phase == "downloading" and total > 0: + path_text = f"{done}/{total} paths" self.draw.text( - (10, msg_bottom), - _("client mode"), - font=self.fonts.large.font, - fill=self.colors.get(255), + (4, y), + path_text, + font=self.fonts.base.font, + fill=self.colors.get(128), ) - 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, msg_top), - _("Checking for"), + (10, draw_pos + 20), + _("No release notes"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( - (10, msg_bottom), - _("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() - ): - self.draw.text( - (10, msg_top), - _("No Update"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) + if not self._loaded: self.draw.text( - (10, msg_bottom), - _("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 = msg_top - else: - ind_pos = msg_bottom - 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 ba4042af5..f2203fa99 100644 --- a/python/PiFinder/ui/sqm.py +++ b/python/PiFinder/ui/sqm.py @@ -193,7 +193,7 @@ def update(self, force=False): 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" + exp_str = f"{exp_ms / 1000:.2f}s" else: exp_str = f"{exp_ms:.0f}ms" self.draw.text( diff --git a/python/PiFinder/ui/ui_utils.py b/python/PiFinder/ui/ui_utils.py index 7a65fcafa..0a8c8acec 100644 --- a/python/PiFinder/ui/ui_utils.py +++ b/python/PiFinder/ui/ui_utils.py @@ -318,10 +318,10 @@ 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" def pointing_arrows(ui, point_az, point_alt, mount_type=None): diff --git a/python/PiFinder/utils.py b/python/PiFinder/utils.py index 5b9bbdb9d..3c4442e92 100644 --- a/python/PiFinder/utils.py +++ b/python/PiFinder/utils.py @@ -15,12 +15,65 @@ pifinder_dir = Path(__file__).resolve().parents[2] assert (pifinder_dir / "astro_data").is_dir(), f"repo root not at {pifinder_dir}" astro_data_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" + +# Logging-config presets ship read-only in the source tree; the user's active +# selection is persisted in the writable data dir (like config.json), stored as +# a bare filename so it survives upgrades (no immutable store path is baked in). +logconf_dir = pifinder_dir / "python" +_active_logconf_file = data_dir / "log_config" +DEFAULT_LOGCONF = "logconf_default.json" + + +def _valid_logconf_name(name: str) -> bool: + return ( + name.startswith("logconf_") + and name.endswith(".json") + and (logconf_dir / name).is_file() + ) + + +def active_logconf_name() -> str: + """Name of the active logging-config preset (defaults to logconf_default.json).""" + try: + name = _active_logconf_file.read_text().strip() + except OSError: + return DEFAULT_LOGCONF + return name if _valid_logconf_name(name) else DEFAULT_LOGCONF + + +def active_logconf_path() -> Path: + """Absolute path to the active logging-config file in the source tree.""" + return logconf_dir / active_logconf_name() + + +def available_logconfs() -> list: + """Sorted bare filenames of the available logconf_*.json presets.""" + return sorted(p.name for p in logconf_dir.glob("logconf_*.json")) + + +def set_active_logconf(name: str) -> None: + """Persist the chosen logging-config preset name to the writable data dir.""" + if not _valid_logconf_name(name): + raise ValueError(f"Invalid log config: {name}") + _active_logconf_file.parent.mkdir(parents=True, exist_ok=True) + _active_logconf_file.write_text(name + "\n") def create_dir(adir: str): @@ -168,22 +221,10 @@ def serialize_solution(solution) -> str: def get_sys_utils(): - # Check if we should use fake sys_utils for local development - use_fake = os.environ.get("PIFINDER_USE_FAKE_SYS_UTILS", "").lower() in ( - "1", - "true", - "yes", - ) - - if use_fake: - sys_utils = importlib.import_module("PiFinder.sys_utils_fake") - else: - 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 + try: + 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 d5d17eed0..000000000 --- a/python/noxfile.py +++ /dev/null @@ -1,137 +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 web_tests(session: nox.Session) -> None: - """ - Run the project's test suite on the web interface. - - This session installs the necessary dependencies and tests the web interface using Selenium. - - 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", "web") - - -@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 ui_tests(session: nox.Session) -> None: - """ - Run the UI module smoke harness (tests/test_ui_modules.py). - - Constructs every UI screen through a real MenuManager and exercises its - key_* methods (crash-only smoke). Builds the real catalogs and, for - chart/align, may download hip_main.dat on first run. Heavier and more - network-dependent than the unit suite, so it lives in its own session. - - 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", "integration", "tests/test_ui_modules.py") - - -@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", - "-F", - "babel.cfg", - "-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 7430f3c0f..c6e09dbe9 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,4 +1,97 @@ +[project] +name = "pifinder" +version = "0.0.0" +description = "PiFinder runtime dependencies (resolved by uv, realized into the Nix store via uv2nix)" +requires-python = ">=3.13,<3.14" +dependencies = [ + # Scientific / astronomy core + "numpy", + "numpy-quaternion", + "pyerfa", + "scipy", + "scikit-learn", + "pillow", + "pandas", + "skyfield", + "timezonefinder", + "pytz", + # Web / RPC + "grpcio", + "protobuf", + "flask", + "flask-babel", + "waitress", + "requests", + "pyjwt", + "aiofiles", + "json5", + "jsonschema", + "libarchive-c", + "tqdm", + # System / IPC bindings + "pygobject", + "dbus-python", + "av", + "smbus2", + "spidev", + # sh 2.x changed the API; PiFinder targets the 1.x interface. + "sh>=1.14,<2", + "gpsdclient", + "dataclasses-json", + "pydeepskylog", + "python-pam", + # 0.3.0a0 (prerelease) is the version that builds on 3.13; its setup.py + # and .so lookups are patched in the uv2nix override (see uv-python.nix). + "python-libinput==0.3.0a0", + # Display + "luma-oled", + "luma-lcd", + # Hardware / camera + "rpi-gpio", + "rpi-hardware-pwm", + "adafruit-blinka", + "adafruit-circuitpython-bno055", + "picamera2", + "pidng", + "simplejpeg", + "python-prctl", + "videodev2", +] + +[dependency-groups] +dev = [ + "pytest", + "mypy", + "luma-emulator", + "pyhotkey", + "selenium", +] + +[tool.uv] +# Dependency-only workspace: the PiFinder source itself is deployed separately +# (pkgs/pifinder-src.nix), so uv must not try to build/install this root project. +package = false +# Deployment + dev are both Linux (aarch64 Pi, x86_64 dev); keep the lock focused. +environments = ["sys_platform == 'linux'"] + +# python-libinput's setup.py imports the removed `imp` module, so uv cannot +# execute it to discover metadata. Declare it statically; the actual build is +# patched in the uv2nix override. +[[tool.uv.dependency-metadata]] +name = "python-libinput" +version = "0.3.0a0" +requires-dist = ["cffi"] + +# python-prctl's setup.py aborts without libcap headers present; it has no +# runtime Python deps. libcap is supplied by the uv2nix build override. +[[tool.uv.dependency-metadata]] +name = "python-prctl" +version = "1.8.1" +requires-dist = [] + [tool.ruff] +builtins = ["_"] + # Exclude a variety of commonly ignored directories. exclude = [ ".bzr", @@ -26,19 +119,16 @@ exclude = [ "dist", "node_modules", "site-packages", - "venv", "tetra3", + "venv", ] # Same as Black. line-length = 88 indent-width = 4 -# Assume Python 3.9 -target-version = "py39" - -# _ is the i18n/gettext builtin injected at runtime -builtins = ["_"] +# Assume Python 3.13 +target-version = "py313" [tool.ruff.lint] # Enable preview mode, allow os.env changes before imports @@ -57,6 +147,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" @@ -124,10 +217,8 @@ module = [ 'skyfield.*', 'sh.*', 'sklearn.*', - 'pam.*', 'PyHotKey.*', 'PiFinder.tetra3.*', - 'quaternion', 'tetra3.*', 'grpc', 'ceder_detect_pb2', @@ -135,6 +226,21 @@ module = [ 'picamera2', 'bottle', 'libinput', + 'pytz', + 'aiofiles', + 'requests', + 'tqdm', + 'pandas', + 'rpi_hardware_pwm', + 'gpsdclient', + 'timezonefinder', + 'pydeepskylog.*', + 'dbus', + 'pam', + 'pam.*', + 'quaternion', + 'gi', + 'gi.*', ] ignore_missing_imports = true ignore_errors = true diff --git a/python/requirements.txt b/python/requirements.txt deleted file mode 100644 index 796057bdf..000000000 --- a/python/requirements.txt +++ /dev/null @@ -1,32 +0,0 @@ -adafruit-blinka==8.12.0 -adafruit-circuitpython-bno055 -cheroot==10.0.0 -Flask==3.0.3 -flask-babel==4.0.0 -waitress==3.0.1 -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 -numpy==1.26.4 -numpy-quaternion==2023.0.4 -pam==0.2.0 -pandas==2.0.3 -pillow==10.4.0 -pydeepskylog==1.6 -pyerfa==2.0.1.5 -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 a5822400f..000000000 --- a/python/requirements_dev.txt +++ /dev/null @@ -1,14 +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 -selenium==4.15.0 -# 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_cat_images.py b/python/tests/test_cat_images.py deleted file mode 100644 index df6901e1f..000000000 --- a/python/tests/test_cat_images.py +++ /dev/null @@ -1,297 +0,0 @@ -import math -import pytest -from PiFinder.cat_images import ( - cardinal_vectors, - size_overlay_points, - vertex_overlay_points, -) -from PiFinder.composite_object import SizeObject - - -def approx_pt(pt, abs=1e-6): - return pytest.approx(pt, abs=abs) - - -# --- cardinal_vectors --- - - -@pytest.mark.unit -class TestCardinalVectors: - def test_no_rotation(self): - """image_rotate=0: POSS north-up, east-left → N at (0, -1), E at (-1, 0).""" - (nx, ny), (ex, ey) = cardinal_vectors(0) - assert (nx, ny) == approx_pt((0, -1)) - assert (ex, ey) == approx_pt((-1, 0)) - - def test_180_rotation(self): - """image_rotate=180: N flips to (0, 1), E to (1, 0).""" - (nx, ny), (ex, ey) = cardinal_vectors(180) - assert (nx, ny) == approx_pt((0, 1)) - assert (ex, ey) == approx_pt((1, 0)) - - def test_90_rotation(self): - """image_rotate=90 turns the image CCW: N at (-1, 0), E at (0, 1).""" - (nx, ny), (ex, ey) = cardinal_vectors(90) - assert (nx, ny) == approx_pt((-1, 0)) - assert (ex, ey) == approx_pt((0, 1)) - - def test_flip_mirrors_x(self): - """flip negates x components of both vectors.""" - (nx, ny), (ex, ey) = cardinal_vectors(0, fx=-1) - assert (nx, ny) == approx_pt((0, -1)) - assert (ex, ey) == approx_pt((1, 0)) - - def test_flop_mirrors_y(self): - """flop negates y components of both vectors.""" - (nx, ny), (ex, ey) = cardinal_vectors(0, fy=-1) - assert (nx, ny) == approx_pt((0, 1)) - assert (ex, ey) == approx_pt((-1, 0)) - - def test_flip_and_flop(self): - """Both flip and flop: equivalent to 180° rotation of vectors.""" - (nx, ny), (ex, ey) = cardinal_vectors(0, fx=-1, fy=-1) - assert (nx, ny) == approx_pt((0, 1)) - assert (ex, ey) == approx_pt((1, 0)) - - def test_orthogonality(self): - """N and E should always be perpendicular.""" - for angle in [0, 45, 90, 135, 180, 270]: - for fx, fy in [(1, 1), (-1, 1), (1, -1), (-1, -1)]: - (nx, ny), (ex, ey) = cardinal_vectors(angle, fx, fy) - dot = nx * ex + ny * ey - assert dot == pytest.approx( - 0, abs=1e-10 - ), f"Not orthogonal at angle={angle}, fx={fx}, fy={fy}" - - def test_unit_length(self): - """N and E vectors should have unit length.""" - for angle in [0, 30, 45, 90, 180, 270]: - (nx, ny), (ex, ey) = cardinal_vectors(angle) - assert math.hypot(nx, ny) == pytest.approx(1) - assert math.hypot(ex, ey) == pytest.approx(1) - - -# --- size_overlay_points --- - - -@pytest.mark.unit -class TestSizeOverlayPoints: - def test_single_extent_returns_none(self): - """1 extent → None (caller uses native ellipse).""" - assert size_overlay_points([100], 0, 0, 1.0, 64, 64) is None - - def test_empty_returns_none(self): - assert size_overlay_points([], 0, 0, 1.0, 64, 64) is None - - def test_two_extents_point_count(self): - """2 extents → 36-point ellipse polygon.""" - pts = size_overlay_points([120, 60], 0, 0, 1.0, 64, 64) - assert len(pts) == 36 - - def test_two_extents_centered(self): - """Ellipse centroid should be at (cx, cy).""" - cx, cy = 64, 64 - pts = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) - avg_x = sum(p[0] for p in pts) / len(pts) - avg_y = sum(p[1] for p in pts) / len(pts) - assert avg_x == pytest.approx(cx, abs=0.1) - assert avg_y == pytest.approx(cy, abs=0.1) - - def test_two_extents_symmetry(self): - """No rotation, no PA: major axis aligned with North (vertical).""" - cx, cy = 64, 64 - pts = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) - xs = [p[0] - cx for p in pts] - ys = [p[1] - cy for p in pts] - # PA=0 → major axis along North → vertical - assert max(abs(x) for x in xs) == pytest.approx(30, abs=0.5) - assert max(abs(y) for y in ys) == pytest.approx(60, abs=0.5) - - def test_two_extents_rotation(self): - """90° image rotation moves major axis from vertical to horizontal.""" - cx, cy = 64, 64 - pts = size_overlay_points([120, 60], 0, 90, 1.0, cx, cy) - xs = [p[0] - cx for p in pts] - ys = [p[1] - cy for p in pts] - # 90° rotation: North moves to +X, major axis now horizontal - assert max(abs(x) for x in xs) == pytest.approx(60, abs=0.5) - assert max(abs(y) for y in ys) == pytest.approx(30, abs=0.5) - - def test_position_angle(self): - """PA=90 matches a 90° image rotation (both turn N toward E on screen).""" - cx, cy = 64, 64 - pts_rot = size_overlay_points([120, 60], 0, 90, 1.0, cx, cy) - pts_pa = size_overlay_points([120, 60], 90, 0, 1.0, cx, cy) - for a, b in zip(pts_rot, pts_pa): - assert a[0] == pytest.approx(b[0], abs=1e-6) - assert a[1] == pytest.approx(b[1], abs=1e-6) - - def test_pa90_aligns_with_east(self): - """PA=90° major axis must align with the East vector from cardinal_vectors.""" - cx, cy = 64, 64 - for rot in [0, 90, 180, 270]: - _, (ex, ey) = cardinal_vectors(rot) - pts = size_overlay_points([200, 40], 90, rot, 1.0, cx, cy) - dists = [(p[0] - cx, p[1] - cy) for p in pts] - farthest = max(dists, key=lambda d: math.hypot(*d)) - direction = ( - farthest[0] / math.hypot(*farthest), - farthest[1] / math.hypot(*farthest), - ) - dot = abs(direction[0] * ex + direction[1] * ey) - assert dot == pytest.approx( - 1.0, abs=0.02 - ), f"PA=90 major axis not along East at image_rotate={rot}" - - def test_pa0_aligns_with_north(self): - """PA=0 major axis must align with the North vector from cardinal_vectors.""" - cx, cy = 64, 64 - for rot in [0, 90, 180, 270]: - (nx, ny), _ = cardinal_vectors(rot) - pts = size_overlay_points([200, 40], 0, rot, 1.0, cx, cy) - # Find the point farthest from center — should be along North - dists = [(p[0] - cx, p[1] - cy) for p in pts] - farthest = max(dists, key=lambda d: math.hypot(*d)) - direction = ( - farthest[0] / math.hypot(*farthest), - farthest[1] / math.hypot(*farthest), - ) - # Should be parallel to North (same or opposite direction) - dot = abs(direction[0] * nx + direction[1] * ny) - assert dot == pytest.approx( - 1.0, abs=0.02 - ), f"PA=0 major axis not along North at image_rotate={rot}" - - def test_flip_mirrors_x(self): - """fx=-1 mirrors all points horizontally around cx.""" - cx, cy = 64, 64 - pts_normal = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy) - pts_flip = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy, fx=-1) - for a, b in zip(pts_normal, pts_flip): - assert a[0] - cx == pytest.approx(-(b[0] - cx), abs=1e-6) - assert a[1] == pytest.approx(b[1], abs=1e-6) - - def test_flop_mirrors_y(self): - """fy=-1 mirrors all points vertically around cy.""" - cx, cy = 64, 64 - pts_normal = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy) - pts_flop = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy, fy=-1) - for a, b in zip(pts_normal, pts_flop): - assert a[0] == pytest.approx(b[0], abs=1e-6) - assert a[1] - cy == pytest.approx(-(b[1] - cy), abs=1e-6) - - def test_three_extents_point_count(self): - """3+ extents → polygon with len(extents) points.""" - pts = size_overlay_points([100, 80, 60, 90], 0, 0, 1.0, 64, 64) - assert len(pts) == 4 - - def test_px_per_arcsec_scaling(self): - """Doubling px_per_arcsec doubles the distance from center.""" - cx, cy = 64, 64 - pts1 = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) - pts2 = size_overlay_points([120, 60], 0, 0, 2.0, cx, cy) - for a, b in zip(pts1, pts2): - assert (b[0] - cx) == pytest.approx(2 * (a[0] - cx), abs=1e-6) - assert (b[1] - cy) == pytest.approx(2 * (a[1] - cy), abs=1e-6) - - -# --- SizeObject vertex mode --- - - -@pytest.mark.unit -class TestSizeObjectVertices: - def test_from_vertices_stores_nested_pairs(self): - verts = [[10.0, 20.0], [10.1, 20.1], [10.2, 20.0]] - s = SizeObject.from_vertices(verts) - assert s.extents == verts - assert s.position_angle == 0.0 - - def test_is_vertices_true(self): - s = SizeObject.from_vertices([[10.0, 20.0], [10.1, 20.1]]) - assert s.is_vertices is True - - def test_is_vertices_false_for_numeric(self): - s = SizeObject.from_arcsec(100, 50) - assert s.is_vertices is False - - def test_is_vertices_false_for_empty(self): - s = SizeObject([]) - assert s.is_vertices is False - - def test_max_extent_arcsec_same_dec(self): - """Two points at same dec, 1° apart in RA at dec=0.""" - s = SizeObject.from_vertices([[10.0, 0.0], [11.0, 0.0]]) - expected = 3600.0 # 1 degree = 3600 arcsec - assert s.max_extent_arcsec == pytest.approx(expected, rel=1e-3) - - def test_max_extent_arcsec_same_ra(self): - """Two points at same RA, 0.5° apart in dec.""" - s = SizeObject.from_vertices([[10.0, 20.0], [10.0, 20.5]]) - expected = 1800.0 # 0.5 degree - assert s.max_extent_arcsec == pytest.approx(expected, rel=1e-3) - - def test_max_extent_arcsec_numeric_fallback(self): - s = SizeObject.from_arcsec(100, 200, 150) - assert s.max_extent_arcsec == 200 - - def test_to_display_string_vertices(self): - """Vertex mode shows ~span format.""" - s = SizeObject.from_vertices([[10.0, 20.0], [10.0, 20.5]]) - display = s.to_display_string() - assert display.startswith("~") - assert "'" in display # 1800 arcsec = 30 arcmin - - def test_json_roundtrip(self): - verts = [[10.0, 20.0], [10.1, 20.1]] - s = SizeObject.from_vertices(verts) - s2 = SizeObject.from_json(s.to_json()) - assert s2.is_vertices is True - assert s2.extents == verts - - -# --- vertex_overlay_points --- - - -@pytest.mark.unit -class TestVertexOverlayPoints: - def test_center_vertex_at_center(self): - """A vertex at the object center projects to (cx, cy).""" - pts = vertex_overlay_points([[10.0, 20.0]], 10.0, 20.0, 0, 1.0, 64, 64) - assert len(pts) == 1 - assert pts[0][0] == pytest.approx(64, abs=0.1) - assert pts[0][1] == pytest.approx(64, abs=0.1) - - def test_offset_vertex_north(self): - """A vertex 100" north of center should appear above center (lower y).""" - dec_offset = 100.0 / 3600.0 # 100 arcsec in degrees - pts = vertex_overlay_points( - [[10.0, 20.0 + dec_offset]], 10.0, 20.0, 0, 1.0, 64, 64 - ) - # image_rotate=0: POSS has N at top of raw image but after - # the 180+roll rotation in get_display_image, here we test - # raw projection - assert len(pts) == 1 - # With image_rotate=0 and no flip, north (positive dec) goes to negative dy - assert pts[0][1] < 64 - - def test_two_vertices_produce_two_points(self): - pts = vertex_overlay_points( - [[10.0, 20.0], [10.01, 20.01]], 10.0, 20.0, 0, 1.0, 64, 64 - ) - assert len(pts) == 2 - - def test_scaling(self): - """Doubling px_per_arcsec doubles offset from center.""" - dec_off = 100.0 / 3600.0 - pts1 = vertex_overlay_points( - [[10.0, 20.0 + dec_off]], 10.0, 20.0, 0, 1.0, 64, 64 - ) - pts2 = vertex_overlay_points( - [[10.0, 20.0 + dec_off]], 10.0, 20.0, 0, 2.0, 64, 64 - ) - dx1 = pts1[0][0] - 64 - dy1 = pts1[0][1] - 64 - dx2 = pts2[0][0] - 64 - dy2 = pts2[0][1] - 64 - assert dx2 == pytest.approx(2 * dx1, abs=0.1) - assert dy2 == pytest.approx(2 * dy1, abs=0.1) diff --git a/python/tests/test_catalog_data.py b/python/tests/test_catalog_data.py index 7478cef13..cba090571 100644 --- a/python/tests/test_catalog_data.py +++ b/python/tests/test_catalog_data.py @@ -43,9 +43,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(): @@ -104,20 +104,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) @@ -128,20 +128,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(): @@ -221,32 +221,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']}" @@ -317,24 +317,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']}" @@ -413,9 +413,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_equipment.py b/python/tests/test_equipment.py index 40cdd6059..ffe31506b 100644 --- a/python/tests/test_equipment.py +++ b/python/tests/test_equipment.py @@ -1,7 +1,5 @@ import pytest -from PIL import Image -from PiFinder import cat_images from PiFinder.equipment import Equipment, Telescope @@ -43,47 +41,3 @@ def test_flop_only(self): active_telescope_index=0, ) assert equipment.active_telescope_image_orientation() == (False, True) - - -def _marker_image() -> Image.Image: - """A small asymmetric image so every mirror actually moves a pixel.""" - img = Image.new("RGB", (4, 4), (0, 0, 0)) - img.putpixel((0, 0), (255, 255, 255)) - return img - - -def _data(img: Image.Image): - return list(img.getdata()) - - -@pytest.mark.unit -class TestOrientImage: - """cat_images._orient_image applies flip/flop after the baseline rotate.""" - - def test_flags_apply_the_right_transposes_after_baseline(self): - src = _marker_image() - # Baseline: 180 rotate only (no roll, no mirrors) - base = cat_images._orient_image(src, 0, False, False) - - flipped = cat_images._orient_image(src, 0, True, False) - flopped = cat_images._orient_image(src, 0, False, True) - both = cat_images._orient_image(src, 0, True, True) - - # flip == top-to-bottom mirror of the baseline - assert _data(flipped) == _data(base.transpose(Image.FLIP_TOP_BOTTOM)) - # flop == left-to-right mirror of the baseline - assert _data(flopped) == _data(base.transpose(Image.FLIP_LEFT_RIGHT)) - # both == flip + flop of the baseline - assert _data(both) == _data( - base.transpose(Image.FLIP_TOP_BOTTOM).transpose(Image.FLIP_LEFT_RIGHT) - ) - - def test_each_combo_is_distinct(self): - src = _marker_image() - results = [ - _data(cat_images._orient_image(src, 0, flip, flop)) - for flip in (False, True) - for flop in (False, True) - ] - # All four flag combinations move the marker to a different place. - assert len({tuple(r) for r in results}) == 4 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..e6268297b --- /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("3.0.0") is True + + def test_above_min_version(self): + assert _meets_min_version("3.1.0") is True + + def test_below_min_version(self): + assert _meets_min_version("2.4.0") is False + + def test_prerelease_at_min(self): + # 3.0.0-beta.1 < 3.0.0, so below minimum + assert _meets_min_version("3.0.0-beta.1") is False + + def test_prerelease_above_min(self): + assert _meets_min_version("3.1.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": "v3.1.0", + "prerelease": False, + "draft": False, + "body": "## v3.1.0\n- Feature A", + }, + { + "tag_name": "v3.0.1", + "prerelease": False, + "draft": False, + "body": "Bugfix release", + }, + { + "tag_name": "v3.1.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 = { + "v3.1.0": { + "store_path": "/nix/store/aaa-nixos-system-pifinder", + "version": "3.1.0", + }, + "v3.0.1": { + "store_path": "/nix/store/bbb-nixos-system-pifinder", + "version": "3.0.1", + }, + "v3.1.0-beta.1": { + "store_path": "/nix/store/ccc-nixos-system-pifinder", + "version": "3.1.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 "3.1.0" in stable_versions + assert "3.0.1" in stable_versions + assert "3.1.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["v3.1.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["v3.1.0"] + + stable, _ = _fetch_github_releases() + + assert stable[0]["notes"] == "## v3.1.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 7b9f610e9..bf0529d2f 100644 --- a/python/tests/test_sqm.py +++ b/python/tests/test_sqm.py @@ -53,9 +53,9 @@ 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 zero extinction (ASTAP convention)""" @@ -161,7 +161,7 @@ def test_airmass_increases_toward_horizon(self): 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})" + f"at {altitudes[i + 1]}° ({airmasses[i + 1]:.3f})" ) diff --git a/python/tests/test_star_catalog.py b/python/tests/test_star_catalog.py new file mode 100644 index 000000000..3adb3e5b3 --- /dev/null +++ b/python/tests/test_star_catalog.py @@ -0,0 +1,117 @@ +import struct +import shutil +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from PiFinder.object_images.star_catalog import CompressedIndex, GaiaStarCatalog + + +def build_v3_index(runs, version=3): + """Build a v3 run-length-encoded index file image. + + Format (see CompressedIndex): + header:
{{ _('Network Settings') }}
+ {% if status_message %} +

{{ status_message }}

+ {% endif %}
diff --git a/scripts/generate-dependencies-md.sh b/scripts/generate-dependencies-md.sh new file mode 100755 index 000000000..859d0a4da --- /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 pyproject.toml [dependency-groups].dev) +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 declared in `python/pyproject.toml`, pinned in +> `python/uv.lock`, and realized into the Nix store via uv2nix. 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 e70b4523a..000000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -2.6.0