Skip to content

fix: widen int / numpy scalar pixel_scales to a tuple #464

Description

@Jammy2211

Overview

geometry_util.convert_pixel_scales_1d and convert_pixel_scales_2d widen a
scalar pixel_scales to a tuple using type(pixel_scales) is float — an
exact-type test. Only a literal Python float is widened; an int, an
np.float64 or an np.int32 falls through unconverted, and the caller then
subscripts a scalar. Array2D.no_mask(values=np.ones((5,5)), pixel_scales=1)
fails with TypeError: 'int' object is not subscriptable, which names nothing
the caller passed.

This is pre-existing, not a regression. It was found while implementing
#333 and recorded in
#440's "Out of scope"
section as the follow-up that PR points at. np.float64 matters as much as
int: it is what indexing a numpy array or reading a FITS header gives you, so
pixel_scales=header["CD2_2"] hits this on a path that looks perfectly
reasonable. Both functions' docstrings promise the widening, and 16 call sites
across every Mask2D factory, Grid2D.uniform and the Array2D/Grid1D
constructors funnel through them, so the broken promise is repeated across the
public API.

Plan

  • Replace the exact-type test in both converters with validate.is_concrete_scalar
    (landed by fix: reject invalid constructor inputs (#333 — B5-B8, B13) #440), which accepts int, float, np.integer and np.floating
    and rejects bool, arrays, None and JAX tracers.
  • Keep the validate.validate_pixel_scales call ahead of the widening, so the
    fix: reject invalid constructor inputs (#333 — B5-B8, B13) #440 guards still reject 0, -1 and nan in scalar form.
  • Cast the widened value to Python float, so the functions deliver the
    Tuple[float, ...] their annotations and docstrings already promise.
  • Correct both docstrings, which say "float" where they mean "any real scalar".
  • Widen the ty.PixelScales alias so the type states what is actually accepted.
  • Change the 1D and 2D siblings together so they cannot drift apart.
  • Add converter tests and a regression test at the reported entry point, then
    run the full test_autoarray suite against a clean-main baseline.
Detailed implementation plan

Work Classification

Library — single-repo, library source only. No workspace changes.

Affected Repositories

  • PyAutoLabs/PyAutoArray (primary)

Branch Survey

Repository Current Branch Dirty?
PyAutoArray main clean

No worktree claim on PyAutoArray (worktree_check_conflict exit 0; only
PyAutoHands is claimed, by hands-hygiene-leftovers). No open PRs on the repo.

Suggested branch: claude/autoarray-pixel-scales-int-tuple-wfxlnj
Worktree root: ~/Code/PyAutoLabs-wt/autoarray-pixel-scales-int-tuple/ (created later by /start_library)

Implementation Steps

  1. autoarray/geometry/geometry_util.py, convert_pixel_scales_1d (line 58) —
    replace if type(pixel_scales) is float: with
    if validate.is_concrete_scalar(pixel_scales):, body
    pixel_scales = (float(pixel_scales),). validate is already imported at
    module top (line 5).
  2. Same file, convert_pixel_scales_2d (line 231) — same predicate, body
    pixel_scales = (float(pixel_scales),) * 2. Land both in one change.
  3. Update both docstrings: any concrete real scalar (int, float,
    np.integer, np.floating) is widened and normalised to Python float;
    tuples and JAX tracers pass through untouched; bool is deliberately not
    treated as a scalar.
  4. autoarray/type.py line 4 — widen the PixelScales alias to admit int.
  5. test_autoarray/geometry/test_geometry_util.py — new
    test__convert_pixel_scales_1d / test__convert_pixel_scales_2d:
    1, 1.0, np.float64(1), np.int32(1) all widen; type(result[0]) is float;
    tuple input returned unchanged; True not widened.
  6. test_autoarray/test_validate.py — extend the fix: reject invalid constructor inputs (#333 — B5-B8, B13) #440 guard tests so 0, -1
    and nan are covered in the int-scalar form as well as float and tuple.
    Add the tracer pass-through case using the JAX-guarded pattern already there.
  7. Regression test at the reported site:
    Array2D.no_mask(values=np.ones((5,5)), pixel_scales=1) builds with
    pixel_scales == (1.0, 1.0); a Mask2D factory with np.float64(1.0).
  8. Run the full test_autoarray suite. Baseline first on clean main — the 3
    test_transformer.py pynufft failures are known pre-existing (tracked by
    draft/bug/autoarray/pynufft_scipy_pinv2_dev_extra.md). Read any other
    failure rather than adjusting the test: something downstream may rely on a
    non-float passing through unconverted.

Key Files

  • autoarray/geometry/geometry_util.py — the two converters; the single
    chokepoint for all 16 call sites.
  • autoarray/validate.py — supplies is_concrete_scalar and
    validate_pixel_scales; unchanged by this task.
  • autoarray/type.py — the PixelScales alias.
  • test_autoarray/geometry/test_geometry_util.py, test_autoarray/test_validate.py — coverage.

Decisions pinned

  • Cast to Python float. 1 becomes (1.0, 1.0), not (1, 1). It matches
    the existing return annotation, keeps numpy scalars out of stored Mask2D
    geometry and out of JSON serialisation, and avoids int arithmetic and numba
    signature variants downstream.
  • bool stays rejected. is_concrete_scalar excludes it deliberately
    (fix: reject invalid constructor inputs (#333 — B5-B8, B13) #440's reasoning), so pixel_scales=True is still not widened. Pinned by a
    test so it is deliberate rather than incidental.

Out of scope (follow-ups)

  • Tuple entries are not normalised. pixel_scales=(1, 1) still returns ints.
    Deliberate: the prompt requires tuple input be returned unchanged. The
    consistency gap deserves its own prompt.
  • convert_shape_native_1d has the identical defecttype(shape_native) is int
    at line 27, so np.int64(5) is never widened either. Same exact-type mistake,
    different parameter; needs its own prompt.

Risk

Low. The change is two predicates plus a cast, at a chokepoint whose behaviour
for the currently-working input (float, tuple, tracer) is unchanged. The one
real risk is code downstream that relies on a non-float scalar passing through
unconverted — the full-suite run against a clean-main baseline is what
establishes that.

Original Prompt

Click to expand starting prompt

pixel_scales given as an int (or np.float64) is never widened to a tuple

Type: bug
Target: autoarray
Repos:

  • @PyAutoArray
    Difficulty: small
    Autonomy: supervised
    Priority: medium
    Status: draft

Why this exists

Found while implementing PyAutoArray#333 (the _validate_* constructor guards,
shipped 2026-08-09 as PyAutoArray#440 / f2f7a4f). Noted in that PR's "Out of
scope" section and in complete/2026/08/autoarray-input-validation-guards.md;
this prompt is the follow-up it points at. Pre-existing — not introduced by
that PR.

The defect

autoarray/geometry/geometry_util.py:

def convert_pixel_scales_2d(pixel_scales):
    if type(pixel_scales) is float:          # <-- exact-type check
        pixel_scales = (pixel_scales, pixel_scales)
    return pixel_scales

type(x) is float is an exact-type test, so only a literal Python float
is widened. Everything else falls through unconverted, and the caller then
subscripts a scalar.

Verified against main @ f2f7a4f (2026-08-09):

convert_pixel_scales_2d(1)              ->  1              # not (1.0, 1.0)
convert_pixel_scales_2d(1.0)            ->  (1.0, 1.0)     # OK
convert_pixel_scales_2d(np.float64(1))  ->  np.float64(1)  # not widened
convert_pixel_scales_1d(1)              ->  1              # same bug, 1D sibling

Array2D.no_mask(values=np.ones((5,5)), pixel_scales=1)
    ->  TypeError: 'int' object is not subscriptable

np.float64 matters as much as int here: it is what you get from indexing a
numpy array or reading a FITS header, so pixel_scales=header["CD2_2"] can hit
this on a path that looks perfectly reasonable.

Why it is worth fixing

The docstring promises the widening — "If this is input as a float, it is
converted to a (float, float) structure"
— and every Mask2D factory and
Grid2D.uniform funnel through this function, so the promise is repeated across
the public API. An int pixel scale is a natural thing for a user to type.

The resulting TypeError: 'int' object is not subscriptable names nothing the
caller passed — exactly the class of failure the #333 sweep was about, which is
why it is filed rather than fixed inline there (that task was scoped to the five
findings on #333).

Suggested fix

Widen the test to any concrete real scalar rather than the exact float type.
autoarray/validate.py (landed by #440) already has the predicate this needs:

from autoarray import validate

if validate.is_concrete_scalar(pixel_scales):
    pixel_scales = (pixel_scales, pixel_scales)

is_concrete_scalar accepts int, float, np.integer, np.floating and
rejects bool, arrays, None and JAX tracers — so this both fixes the bug and
keeps the function tracer-safe. Apply to convert_pixel_scales_1d (1-tuple) and
convert_pixel_scales_2d (2-tuple) together so the two do not drift.

Check before assuming this is purely additive: something downstream may rely
on a non-float passing through unconverted. Run the full test_autoarray suite
and read any failure rather than adjusting the test.

Verification

  • Array2D.no_mask(values=..., pixel_scales=1) builds, with
    pixel_scales == (1.0, 1.0).
  • Same for np.float64(1.0) and np.int32(1).
  • Tuple input is returned unchanged; a JAX tracer still passes through untouched.
  • The fix: reject invalid constructor inputs (#333 — B5-B8, B13) #440 validation guards still fire on 0, -1 and nan in both the
    scalar and tuple forms.
  • Decide and pin whether the widened value is cast to float or kept in its
    input type — (1, 1) vs (1.0, 1.0) — since downstream arithmetic differs.

Repro environment: PYAUTO_SKIP_WORKSPACE_VERSION_CHECK=1,
NUMBA_CACHE_DIR=/tmp/numba_cache, MPLCONFIGDIR=/tmp/matplotlib,
PYAUTO_DISABLE_JAX=1.

Provenance

  • Found during: complete/2026/08/autoarray-input-validation-guards.md
  • Sibling of, but NOT part of, the @rhayes777 audit campaign (planned.md §
    rhayes-audit-validation-phases-2-4) — this was not one of his 16 findings.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions