Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/actions/install-arcticpy/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Install arcticpy
description: >-
Build and install arcticpy, the C++ arctic clocking code that `import autocti`
hard-requires. The canonical recipe for the whole organism — every CTI repo's
CI consumes this action rather than carrying its own copy.

# WHY THIS EXISTS
#
# arcticpy is deliberately NOT a pip dependency of autocti:
#
# * its PyPI distribution is a source-only C++ sdist — it needs libgsl-dev
# headers and a toolchain to build, and there is no wheel;
# * its own requirements downgrade numpy below 2.0, which breaks a modern
# PyAuto stack.
#
# So it is built with --no-build-isolation (reusing the numpy already present
# instead of resolving its own) and --no-deps (so it cannot drag numpy back
# down). Those two flags are what make the recipe fiddly, and each one costs
# something that has to be paid back explicitly:
#
# --no-build-isolation pip does NOT create an isolated build environment and
# does NOT read pyproject's build-system.requires, so
# every BUILD dependency must already be installed.
# arcticpy declares none of them.
# --no-deps pip installs no RUNTIME dependencies either, so the
# packages arcticpy imports at import time must also be
# installed by hand.
#
# Both sets are installed below, and the step ends by proving the result
# actually imports.
#
# Verified 2026-08-24 in a clean container against a bare venv: each missing
# build dependency failed the build naming the next one. Without setuptools the
# build dies at `BackendUnavailable: Cannot import 'setuptools.build_meta'` —
# and Python 3.12+ venvs no longer ship setuptools by default, which is why the
# recipe that omitted it was a real hazard rather than a style nit.
#
# This step is intentionally SELF-CONTAINED: it upgrades pip/setuptools/wheel
# itself rather than relying on a caller having done so in a preceding step.
# The previous arrangement had autocti_workspace_test's smoke_install.sh
# depending on a `pip install --upgrade pip setuptools wheel` line living in a
# different repository's workflow, with nothing stating the dependency.

inputs:
version:
description: "arcticpy version to build. THE single pin for the organism — bump it here."
required: false
default: "2.6"
sudo:
description: >-
Use sudo for the apt-get leg. GitHub-hosted runners need it; a container
already running as root does not have it. Set to 'false' there.
required: false
default: "true"
install-gsl:
description: >-
Install libgsl-dev via apt. Set to 'false' when GSL headers are already
present (a prepared image, or a no-root local extraction).
required: false
default: "true"

outputs:
version:
description: "The arcticpy version actually installed, per importlib.metadata."
value: ${{ steps.verify.outputs.version }}

runs:
using: composite
steps:
- name: Install GSL headers
if: ${{ inputs.install-gsl == 'true' }}
shell: bash
run: |
set -euo pipefail
SUDO=""
if [ "${{ inputs.sudo }}" = "true" ]; then SUDO="sudo"; fi
$SUDO apt-get update
$SUDO apt-get install -y libgsl-dev

- name: Install arcticpy build dependencies
shell: bash
run: |
set -euo pipefail
# --no-build-isolation reads NOTHING from arcticpy's build-system
# requires, so these must be present before the build starts.
python -m pip install --upgrade pip setuptools wheel
python -m pip install numpy cython

- name: Install arcticpy runtime dependencies
shell: bash
run: |
set -euo pipefail
# --no-deps suppresses these, but arcticpy/read_noise.py imports both at
# import time (`from scipy.optimize import curve_fit`, `import
# matplotlib as mpl`) and __init__.py imports read_noise. So `import
# arcticpy` fails without them — which is why the verify step below,
# and every downstream `import autocti`, needs them present.
#
# The CTI stack installs scipy and matplotlib anyway as ordinary
# dependencies; naming them here is what makes this step verifiable on
# its own rather than only inside an already-built stack.
python -m pip install scipy matplotlib

- name: Build and install arcticpy ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
python -m pip install "arcticpy==${{ inputs.version }}" \
--no-build-isolation --no-deps

- name: Verify arcticpy imports
id: verify
shell: bash
run: |
set -euo pipefail
# Assert here rather than letting a broken build surface much later as a
# confusing `import autocti` failure in an unrelated job.
#
# NOTE: arcticpy exposes no __version__ attribute — the obvious
# `import arcticpy; print(arcticpy.__version__)` raises AttributeError
# even on a perfectly good install. The distribution metadata is the
# supported way to ask.
VERSION="$(python -c 'import arcticpy; from importlib.metadata import version; print(version("arcticpy"))')"
echo "arcticpy $VERSION imported successfully"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
64 changes: 64 additions & 0 deletions .github/scripts/arcticpy_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Prove the arcticpy install actually clocks charge, not just that pip exited 0.

Run by .github/workflows/arcticpy-action.yml against the install-arcticpy
composite action. A build can succeed, import, and still be useless if the
compiled arctic extension is broken — so this clocks a single bright pixel and
checks the result has the four properties every CTI repo depends on.

Kept as a file rather than a heredoc inside the workflow so it can be run
locally against any environment:

python .github/scripts/arcticpy_smoke.py
"""

import numpy as np
import arcticpy as ac


def main() -> None:
# One bright pixel in an otherwise empty column. CTI drags charge out of it
# and releases it into the pixels clocked after it.
image = np.zeros((20, 1))
image[10, 0] = 1000.0

traps = [ac.TrapInstantCapture(density=10.0, release_timescale=2.0)]
# NOTE: add_cti wants a CCD, not a CCDPhase — a bare CCDPhase raises
# AttributeError on fraction_of_traps_per_phase. Likewise parallel_roe is
# not optional in practice: omitting it raises on roe.dwell_times.
ccd = ac.CCD(full_well_depth=1e5, well_notch_depth=0.0, well_fill_power=0.8)
roe = ac.ROE()

clocked = ac.add_cti(
image,
parallel_traps=traps,
parallel_ccd=ccd,
parallel_roe=roe,
parallel_express=5,
)

trail = clocked[11:, 0]
print(f"bright pixel : {image[10, 0]} -> {clocked[10, 0]}")
print(f"trail : {trail[:5]}")
print(f"total charge : {image.sum()} -> {clocked.sum()}")

# 1. Charge left the bright pixel.
assert clocked[10, 0] < image[10, 0], "no charge was trapped out of the bright pixel"

# 2. It reappeared behind it, in pixels that started empty.
assert trail[0] > 0, "no trail behind the bright pixel"

# 3. The trail decays — the signature of exponential trap release.
assert trail[0] > trail[1] > trail[2], f"trail is not decaying: {trail[:3]}"

# 4. Charge is conserved. arctic's express approximation is not exactly
# conserving (measured ~6e-4 relative at express=5), so this is a
# gross-error check, not a precision one.
assert np.isclose(clocked.sum(), image.sum(), rtol=5e-3), (
f"charge not conserved: {image.sum()} -> {clocked.sum()}"
)

print("OK: arcticpy produced a decaying CTI trail with charge conserved")


if __name__ == "__main__":
main()
59 changes: 59 additions & 0 deletions .github/workflows/arcticpy-action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: arcticpy Action Self-Test

# The install-arcticpy composite action is consumed cross-repo at
# `PyAutoLabs/PyAutoHeart/.github/actions/install-arcticpy@main`, so a change to
# the recipe only reaches its consumers once it is ON main — which means nothing
# would exercise it before merge, and a broken recipe would land red in four
# repos at once (PyAutoHeart lib-tests, PyAutoCTI, autocti_workspace_test smoke,
# autocti_assistant wiki-currency).
#
# This job closes that window. It references the action by LOCAL path, so it
# builds the version on the branch under review, and it does not stop at
# "pip exited 0": it clocks a single bright pixel through arctic and asserts the
# result is a real CTI trail. That is the property the CTI repos actually
# depend on.

on:
pull_request:
paths:
- ".github/actions/install-arcticpy/**"
- ".github/workflows/arcticpy-action.yml"
workflow_dispatch:
inputs:
version:
description: "arcticpy version to test (default: the action's own pin)"
required: false
type: string

concurrency:
group: arcticpy-action-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# The build depends on the interpreter (3.12+ venvs no longer ship
# setuptools), so both supported minors are exercised.
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install arcticpy (the action under test, from this branch)
id: arctic
uses: ./.github/actions/install-arcticpy
with:
version: ${{ inputs.version || '2.6' }}

- name: Report the version the action installed
run: echo "action reported arcticpy ${{ steps.arctic.outputs.version }}"

- name: Clock a bright pixel and assert a CTI trail
run: python .github/scripts/arcticpy_smoke.py
23 changes: 10 additions & 13 deletions .github/workflows/lib-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,13 @@ jobs:
python-version: ${{ matrix.python-version }}
cache: pip

# The canonical arcticpy install for the whole organism, including its
# version pin, lives in the action — not in this file and not in any
# consumer repo. Referenced at @main rather than by local path because
# this is a REUSABLE workflow: it runs in the caller's context.
- name: Install arcticpy (autocti only — source-only C++ sdist)
if: ${{ inputs.package == 'autocti' }}
run: |
# arcticpy needs GSL headers to compile, and installing it with its
# own requirements downgrades numpy below 2.0 — so numpy is installed
# first and arcticpy is built without deps or build isolation.
sudo apt-get update && sudo apt-get install -y libgsl-dev
pip install --upgrade pip setuptools wheel
pip install numpy cython
pip install arcticpy==2.6 --no-build-isolation --no-deps
uses: PyAutoLabs/PyAutoHeart/.github/actions/install-arcticpy@main

- name: Install (deps + package from source, [optional] extras)
run: |
Expand Down Expand Up @@ -177,13 +174,13 @@ jobs:
python-version: "3.13"
cache: pip

# The canonical arcticpy install for the whole organism, including its
# version pin, lives in the action — not in this file and not in any
# consumer repo. Referenced at @main rather than by local path because
# this is a REUSABLE workflow: it runs in the caller's context.
- name: Install arcticpy (autocti only — source-only C++ sdist)
if: ${{ inputs.package == 'autocti' }}
run: |
sudo apt-get update && sudo apt-get install -y libgsl-dev
pip install --upgrade pip setuptools wheel
pip install numpy cython
pip install arcticpy==2.6 --no-build-isolation --no-deps
uses: PyAutoLabs/PyAutoHeart/.github/actions/install-arcticpy@main

- name: Install ([optional] extras), then strip the jax package family
run: |
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/smoke-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ name: Smoke Tests (reusable)
# (.github/scripts/smoke_install.sh, receiving PYTHON_VERSION — extras,
# pins, version conditionals) and the smoke runner itself.
#
# One deliberate exception: `arcticpy: true`. arcticpy is a stack-level system
# dependency (source-only C++ sdist, GSL headers, a numpy-downgrade trap) whose
# recipe had drifted into four separate copies across the CTI repos. That is
# not workspace-specific variation, it is one recipe with no owner — so Heart
# owns it, in .github/actions/install-arcticpy, and the CTI workspace asks for
# it with a flag instead of carrying a copy in its epilogue.
#
# `runner` lets a caller point that same ceremony at a DIFFERENT workspace
# script — a re-timing harness, a one-off diagnostic sweep — instead of
# copying the chain-checkout/install steps into a second workflow. The copy is
Expand Down Expand Up @@ -61,6 +68,13 @@ on:
required: false
type: string
default: ""
arcticpy:
description: >-
Install arcticpy (the C++ arctic clocking code `import autocti`
requires) before the workspace epilogue. CTI workspaces only.
required: false
type: boolean
default: false

jobs:
# Docs-only gate: skip the matrix when the diff touches nothing but prose.
Expand Down Expand Up @@ -141,6 +155,15 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

# arcticpy is a stack-level system dependency with a fiddly, easy-to-drift
# build recipe — it belongs to Heart, not to each workspace's epilogue.
# Gated the same way lib-tests.yml gates its own arcticpy step, so every
# non-CTI caller is unaffected. This runs BEFORE the epilogue: the
# epilogue installs autocti, and `import autocti` needs arcticpy present.
- name: Install arcticpy (CTI workspaces only — source-only C++ sdist)
if: ${{ inputs.arcticpy }}
uses: PyAutoLabs/PyAutoHeart/.github/actions/install-arcticpy@main

- name: Install (base + the workspace's own epilogue)
env:
PYTHON_VERSION: ${{ matrix.python-version }}
Expand Down
Loading