diff --git a/.github/workflows/ci-scripts.yml b/.github/workflows/ci-scripts.yml index afb3298e..8fa6cf4f 100644 --- a/.github/workflows/ci-scripts.yml +++ b/.github/workflows/ci-scripts.yml @@ -106,6 +106,7 @@ jobs: if: ${{ matrix.run_validation }} run: | python3 test/test_generate_dependency_report.py + python3 test/generate_tpx3_4x2_demo_calibration.py --check python3 test/verify_coordinate_map.py python3 test/validate_bob_xml.py python3 test/validate_bob_prefix_macros.py diff --git a/README.md b/README.md index 69fa9ba4..33114b3a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Notes: * ADCore, ADSupport, and other EPICS module versions are supplied by the surrounding EPICS release configuration (`configure/RELEASE_LIBS_INCLUDE` and local overrides); this repository does not lock them independently. * **Preview Images**: Uses TCP streaming (jsonimage format) for preview images. GraphicsMagick HTTP method has been removed. For backward compatibility, the GraphicsMagick implementation is preserved in the `preserve/graphicsmagick-preview` branch. * This has only been developed/tested on ubuntu 22.04, 20.04, 18.04, RHEL 7.9, RHEL 9.6 Linux 64-bit machines. -* Layout support in driver and OPI is most complete for **1 chip** and **2×2 quad**; **8-chip** (e.g. 2×4 mosaic, two SPIDR boards) has IOC/DB/driver support documented in [documentation/8chip-migration.md](documentation/8chip-migration.md)—validate BPC/mask mapping and screens on your hardware. +* Layout support in driver and OPI is most complete for **1 chip** and **2×2 quad**; **8-chip** (a 4×2 mosaic assembled from two quads) has IOC/DB/driver support documented in [documentation/8chip-migration.md](documentation/8chip-migration.md)—validate BPC/mask mapping and screens on your hardware. The single-board full-rate SpidrTurbo layout remains a distinct, unqualified mapping. * **Serval versions**: The master branch supports both Serval 4.x.x and 3.x.x and is recommended (no need to use the 3.3.2 branch). The "dual image" issue was resolved in Serval 4.1.5; **Serval 4.1.5 remains the recommended stable version** for 4.x. R1-7-4 acquisition-lifecycle qualification also passed with **Serval 4.1.6-EXPERIMENTAL build 1760** on the TPX3 and MPX3 emulators. Serval and emulator versions must match. Data replay has been tested and is currently supported only with older Serval (3.3.2). Serval 2.x.y is in a separate branch and is not under active development. * The driver has been developed using the TimePix3 Emulator and real detectors (quad-chip and single-chip). diff --git a/RELEASE.md b/RELEASE.md index b328595b..0cd031f4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -79,13 +79,13 @@ R1-8-0 (in progress) * Keep accumulated preview-histogram Y autoscaling enabled as frame updates arrive. No histogram data, axis PV, or acquisition behavior changes. * SNS Phoebus qualification passes for 256x256 and 512x512 mask displays and - preview-histogram accumulation. The 1024x512 mask-display check remains - pending. + preview-histogram accumulation. The 1024x512 mask display also passes with + the two-quad TPX3 emulator; physical-detector qualification remains pending. ### Eight-chip IOC waveform capacity * Raise the TPX3 NDStats profile capacity to 1024x512 so the horizontal cursor - profile is not truncated at 512 pixels for an eight-chip 2x4 mosaic. Smaller + profile is not truncated at 512 pixels for an eight-chip 4x2 mosaic. Smaller detector geometries continue to publish their actual profile lengths. * Select `MASK_BPC_NELEMENTS=524288` to reserve 1024x512 record capacity for mask/BPC, PixelConfig-difference, and image-accumulation waveforms. This is a @@ -95,12 +95,30 @@ R1-8-0 (in progress) `MASK_BPC_NELEMENTS`. * SNS Phoebus qualification passes for the full-width 1024-pixel horizontal profile, 1024x512 image accumulation, and preview-histogram accumulation. -* The 1024x512 mask display remains unqualified. The available quad BPC - provides 262144 samples rather than 524288, and the current rectangular - geometry helper derives a 128-pixel chip (`16384` PixelConfig bytes) from - the 4x2 layout instead of the required 256-pixel chip (`65536` bytes). - Eight-chip calibration composition and rectangular mask geometry are - deferred to a separate change. +* Add an opt-in synthetic 4x2 demo calibration pair: a deterministic + 524288-byte BPC and a DACS file with unique `[Chip0]` through `[Chip7]` + sections, both reproducibly composed from the existing quad demo files. +* Correct the rectangular eight-chip geometry using captured Serval metadata: + `RowLen=4` chips across, `NumberOfRows=512`, `NumberOfChips=8`, and + `PixCount=524288`. The isolated eight-chip path derives a 1024x512 raster, + two chip rows, and a 256-pixel chip edge (`65536` PixelConfig bytes per + chip), while preserving the established one- and four-chip geometry path. + Eight-chip mask mapping requires two distinct TPX3 quad board IDs and fails + closed for the separate single-board SpidrTurbo layout. +* Replace stale global PixelConfig errors with a completion message after each + refresh; per-chip status fields continue to report matches and failures. +* Map two adjacent quads as `2,1,4,7 / 3,0,5,6`, with the right module rotated + 180 degrees, and add compiled full-grid round-trip and portable coordinate + tests. The distinct single-board SpidrTurbo mapping remains deferred. +* Qualify the opt-in 4x2 files and mask workflow with the two-quad TPX3 + emulator on 2026-10-04: BPC and DACS uploads returned HTTP 200; all eight + 65536-byte PixelConfig responses matched their BPC slices with zero + mismatches; and a rectangular mask propagated through the 1024x512 mask + preview and masked-pixel JSON export. The 1024x512 image-accumulation and + histogram views continued to operate during the same qualification. +* Physical 1024x512 mask qualification and detector-specific eight-chip + equalization remain pending; the checked-in calibration is explicitly + synthetic and must not be used as qualified hardware calibration. ### Bounded rolling accumulation @@ -183,12 +201,12 @@ R1-8-0 (in progress) ### Validation * Full IOC build passed. -* 346/346 deterministic protocol, lifecycle, rolling-window, numeric-range, +* 358/358 deterministic protocol, lifecycle, rolling-window, numeric-range, path-containment, atomic-write, and destination-policy checks passed. -* Coordinate-map validation passed for 11 implemented cases; five planned - physical SpIDR 2x4 cases remain deferred. +* Coordinate-map validation passed for 13 implemented cases; five planned + physical SpIDR 4x2 cases remain deferred. * All Phoebus BOB files passed XML validation. -* REUSE validation passed for 372/372 files. +* REUSE validation passed for 398/398 files. * TPX3 emulator runtime qualification passed at 10 Hz for Img and PrvHst windows of 10 and 100 frames: requested capacity equaled current fill, memory plateaued at the expected bounded values, per-frame publication operated, and @@ -315,7 +333,7 @@ Driver / user-visible version **1.7.3** (see `ADTIMEPIX_*` in `ADTimePix.h`). * MPX3 emulator qualification with Serval 4.1.6 completed six finite acquisitions with frame preview, integrated preview, and full image enabled. All three TCP streams closed normally after every measurement, every subsequent acquisition began from Idle, and Serval processed 139 frames with zero drops and no TCP sender errors. * **Release qualification and deferred scope**: - * R1-7-3 is software- and emulator-qualified. Physical post-equalization MPX3 mask/PixelConfig confirmation, broader physical reconnect testing, and 2x4 SpIDR placement/rotation/BPC ordering remain controlled-hardware follow-up work; a Serval PixelConfig match is not proof of detector-register state. + * R1-7-3 is software- and emulator-qualified. Physical post-equalization MPX3 mask/PixelConfig confirmation, broader physical reconnect testing, and 4x2 SpIDR placement/rotation/BPC ordering remain controlled-hardware follow-up work; a Serval PixelConfig match is not proof of detector-register state. * Standard acquisition start/stop and reconnect paths are covered, but exhaustive transactional partial-start and injected-fault coordination remain future work. * Large retained image/histogram accumulation workloads do not yet have a geometry-aware memory-capacity or unsigned-to-signed overflow contract. Validate site workloads and monitor resource use. * File and destination configuration assume a trusted deployment boundary; canonical writable-root confinement, destination allowlists, access control, and credential policy remain deployment-hardening work. diff --git a/documentation/8chip-migration.md b/documentation/8chip-migration.md index 1af8ba28..afb2ef2c 100644 --- a/documentation/8chip-migration.md +++ b/documentation/8chip-migration.md @@ -1,4 +1,4 @@ -# Eight-chip (2×4 / dual SPIDR) support +# Eight-chip 4×2 (two-quad / dual-SPIDR) support This document tracks IOC and driver changes for detectors with up to eight TimePix3 chips (e.g. two SPIDR boards × four chips). @@ -12,15 +12,26 @@ This document tracks IOC and driver changes for detectors with up to eight TimeP - **Boards:** `TPX3_BOARDS2_ID`, `TPX3_BOARDS2_IP`, `TPX3_BOARDS_CH5`…`CH8` from `Info.Boards[1]` when available (`CH5`…`CH8` are that board’s four chip JSON objects). - **Database:** `ADTimePix3.template` adds `BChBoard2Id_RBV`, `IpAddr2_RBV`, `Chip5_RBV`…`Chip8_RBV`. - **Mask / BPC indexing (`mask_io.cpp`):** For `numChips == 8` and - **DetectorOrientation UP (0)** only, `pelIndex` and `bpc2ImgIndex` contain a - linear rectangular-mosaic mapping. Live 4x2 mask operation is not yet - qualified because `rowsCols()` still derives square-layout dimensions; see - [Known two-quad mask limitations](#known-two-quad-mask-limitations). + **DetectorOrientation UP (0)** only, `pelIndex` and `bpc2ImgIndex` implement + the two-quad 4x2 placement `2,1,4,7 / 3,0,5,6`. The right-hand quad is + rotated 180 degrees relative to the left-hand quad. The distinct single-board + full-rate SpidrTurbo mapping remains unimplemented pending golden hardware + vectors. +- **Geometry:** the captured TPX3 Serval response reports `Info.RowLen=4` + chips across, `NumberOfRows=512`, `NumberOfChips=8`, and + `PixCount=524288`. The eight-chip-only geometry path derives a 4x2 grid of + 256x256 chips and a 1024x512 raster. The established one- and four-chip path + is unchanged. Runtime mask mapping also requires two distinct `41...` TPX3 + quad board IDs; the different single-board `84...` SpidrTurbo layout fails + closed. +- **Synthetic calibration fixture:** `vendor/tpx3/4x2/` contains a reproducible + 524288-byte BPC and a DACS file with unique `[Chip0]` through `[Chip7]` + sections for software and emulator testing. It is not hardware equalization. ## NDStats profile dimensions `profiles/tpx3/unique.cmd` sets the NDStats waveform capacities to -`XSIZE=1024` and `YSIZE=512`, matching the largest supported 2x4 mosaic. +`XSIZE=1024` and `YSIZE=512`, matching the largest supported 4x2 mosaic. The Phoebus horizontal cursor profile reads `Stats1:ProfileCursorX_RBV`, whose record `NELM` is set from `XSIZE`; a value of 512 therefore truncates a 1024-pixel row profile. Smaller detector geometries remain supported because @@ -41,42 +52,47 @@ eight-chip capacity by default: |-------|----------------|------------|----------------------| | 1 | 1×1 | 256×256 | 65536 | | 4 | 2×2 | 512×512 | 262144 | -| 8 | 2×4 | 1024×512 | 524288 | +| 8 | 4×2 | 1024×512 | 524288 | `envPaths` does **not** set this macro; it only notes that `profiles/tpx3/unique.cmd` must define it. The configured value is record capacity only. It prevents EPICS waveform truncation but does not create a matching calibration or validate the mapping. -## Known two-quad mask limitations - -Runtime qualification with a 1024x512 two-quad detector passed the full-width -horizontal profile, image-accumulation displays, and preview histogram. Mask -qualification remains blocked by two independent issues: - -1. The available TPX3 calibration is a four-chip, 262144-byte BPC. Serval - accepts one selected pixel-configuration file, so an eight-chip detector - needs one validated 524288-byte BPC containing the chip blocks in final - Serval chip order. The DACS file likewise needs unique `[Chip0]` through - `[Chip7]` sections; two unchanged quad DACS files cannot simply be appended. -2. `rowsCols()` currently calculates `chipPelWidth = NumberOfRows / RowLen`. - For 512 rows and four chips per row this produces 128, causing - `RefreshPixelConfig` to expect 16384 bytes per chip instead of 65536. The - rectangular geometry calculation and compiled tests require a separate - driver change. - -Do not use the eight-chip mask, BPC comparison, or PixelConfig-difference -results until both the geometry fix and a detector-specific combined -calibration have been validated. Calibration files are hardware-specific and -should remain site-local; a repository change may provide deterministic -composition and validation tooling without publishing facility calibration. +## Two-quad calibration and qualification boundary + +Runtime qualification on 2026-10-04 with the 1024x512 two-quad TPX3 emulator +passed the full-width horizontal profile, image-accumulation displays, preview +histogram, and mask workflow. The synthetic BPC and DACS uploads both returned +HTTP 200. All eight PixelConfig responses decoded to 65536 bytes and matched +their BPC slices with zero mismatches. A rectangular mask propagated through +the 1024x512 mask preview and the masked-pixel JSON export. The geometry and +deterministic two-quad composition prerequisites are also covered by compiled +and portable tests, but physical-detector mask qualification remains pending. + +The checked-in 4x2 BPC repeats the four source chip blocks for chips 4-7. The +DACS file repeats each source chip's values under a unique destination section +name. These files verify file sizing, upload selection, per-chip offsets, and +software/emulator behavior only. Real calibration files are hardware-specific +and should remain site-local unless redistribution is explicitly appropriate. + +Do not claim the eight-chip mask, BPC comparison, or PixelConfig-difference +path as hardware-qualified until a detector-specific BPC/DACS pair and an +asymmetric mask have confirmed all eight chip placements. ## Verify on hardware -- **BPC ↔ image mapping:** The 8-chip branch assumes **linear** chip order (`chip = Y_CHIP * xChips + X_CHIP`) on a uniform grid. **Full-rate SpIDR 2×4** (single board, `chipboardId` `84…`) uses a different mosaic and per-chip rotations — see **[COORDINATE_MAP.md](COORDINATE_MAP.md)** (*SpIDR 2×4 — planned*). Confirm masks and PixelConfigDiff on real data before relying on 8-chip mask paths on that hardware. +- **BPC ↔ image mapping:** The implemented 8-chip path is specifically the + **two-quad** placement `2,1,4,7 / 3,0,5,6`. **Full-rate SpidrTurbo 4×2** + (single board, `chipboardId` `84…`) uses a different mosaic and per-chip + rotations—see [COORDINATE_MAP.md](COORDINATE_MAP.md). Do not use the + two-quad mapping on that hardware. - **Phoebus:** `profiles/tpx3/TimePix3Detector.bob` embeds eight chip panels in a **4×2** grid (geometry maintained in the BOB). See also `profiles/tpx3/Acquire/DetectorConfig.bob` (Vth), `common/Mask/` and `common/Acquire/ImgAccumulation.bob` for **1024×512** at `PixCount_RBV == 524288`, and **`common/Detector/TimePixDetectorVoltages.bob`** (Display Builder) or **`TimePixDetectorVoltages.opi`** (legacy BOY under **`op/opi/Detector`**) for **Pwr3–Pwr5**. **`common/Detector/TimePixDetectorHealth.bob`** summarizes health and cross-links to voltages. - **Large images:** For 8-chip, keep `MASK_BPC_NELEMENTS` at `524288` (or larger if `PixCount` is greater), `XSIZE` at least `1024`, and `YSIZE` at least `512`. Raise `EPICS_CA_MAX_ARRAY_BYTES` and other ND plugin `NELEMENTS` values if waveforms exceed their configured limits. ## Calibration -Per-chip / per-geometry calibration policy is outside this IOC change; use naming or paths that include chip count and layout id. +The repository's `vendor/tpx3/4x2/` pair is deliberately named `synthetic` and +is opt-in. For physical operation, use names or site-local paths that identify +the detector and layout, with independently generated values for all eight +chips. diff --git a/documentation/CONTRIBUTING.md b/documentation/CONTRIBUTING.md index e04d424d..af8bcf56 100644 --- a/documentation/CONTRIBUTING.md +++ b/documentation/CONTRIBUTING.md @@ -99,6 +99,7 @@ The command should finish with **REUSE Specification 3.0** compliance. If you ad ```bash make -j make -C test runtests +python3 test/generate_tpx3_4x2_demo_calibration.py --check python3 test/verify_coordinate_map.py python3 test/validate_bob_xml.py reuse lint diff --git a/documentation/COORDINATE_MAP.md b/documentation/COORDINATE_MAP.md index 3cbc8e07..cdbfcafb 100644 --- a/documentation/COORDINATE_MAP.md +++ b/documentation/COORDINATE_MAP.md @@ -31,18 +31,27 @@ Offline TPX3 tools under `maskTpx3/xyChip` (e.g. **`check_bit.c`**) use the same | Symbol | PV / source | Meaning | |--------|-------------|---------| -| `rows` | `TPX3_NUM_ROWS` | Total image rows | -| `cols` | `yChips * chipPelWidth` | Total image columns | -| `xChips` | `TPX3_ROWLEN` | Chips per row in the mosaic | -| `yChips` | `numChips / xChips` | Chip rows | -| `w` | `chipPelWidth` = `rows / xChips` | Pixels per chip edge (often 256) | - -**Known 4x2 limitation:** the current `rowsCols()` formulas only produce the -correct chip width and image columns for square chip grids. With 512 rows and -`RowLen=4`, they derive `w=128` and `cols=256` instead of `w=256` and -`cols=1024`. The mapping formulas below describe the intended linear layout, -but the live eight-chip mask path must not be considered qualified until the -geometry helper is corrected and tested. +| `rows` | `TPX3_NUM_ROWS` | Total image height in pixels | +| `xChips` | `TPX3_ROWLEN` | TPX3 chips across the mosaic | +| `cols` | `xChips * w` | Total image width in pixels | +| `w` | `sqrt(PixCount / NumberOfChips)` | Pixels per square chip edge (normally 256) | +| `yChips` | `NumberOfChips / xChips` | Chips down the mosaic | + +For the captured two-quad 4x2 detector, Serval reports `RowLen=4`, +`NumberOfRows=512`, `NumberOfChips=8`, and `PixCount=524288`. The validated +eight-chip geometry helper derives `w=256`, `xChips=4`, `yChips=2`, and +`cols=1024`, and rejects inconsistent or non-square inputs. This rectangular +correction is isolated from the established one- and four-chip geometry path. +The eight-chip mask path additionally requires two distinct TPX3 quad board +IDs beginning with `41`; the single-board SpidrTurbo layout fails closed. + +Fresh TPX3 emulator captures on 2026-10-04 confirm that `Info.RowLen` is the +number of chips across the mosaic, not its width in pixels: + +| TPX3 capture | `PixCount` | `RowLen` | `NumberOfChips` | `NumberOfRows` | `Layout.Original` | +|--------------|------------|----------|-----------------|----------------|-------------------| +| One quad | 262144 | 2 | 4 | 512 | 512×512 | +| Two adjacent quads | 524288 | 4 | 8 | 512 | 1024×512 | **BPC file layout (Timepix3):** chip `c` occupies bytes `[c * w*w, (c+1) * w*w)`. Chip id in file order: @@ -186,6 +195,16 @@ Unknown orientation strings continue to fail closed. ### Adding TPX4 or another family +Future geometry unification must not assume a square ASIC matrix. The Timepix4 +readout ASIC has square 55×55 µm pixels arranged as a non-square 448×512 pixel +matrix. A generic model therefore needs independent chip width and height, +assembled-image width and height, and orientation transforms that swap the +occupied chip dimensions for 90-degree rotations. `RowLen` should be validated +against captured metadata rather than used as the sole source of raster width. +Perform that unification only after representative Timepix4 Serval `Info` and +`Layout` captures are available; do not extend the current square-chip +`sqrt(PixCount / NumberOfChips)` assumption to Timepix4. + Add the new family here only after recording all of the following from Serval and validating them against an acquisition: @@ -227,7 +246,7 @@ These are inverses. Chip `c` is derived from `k` as above. Each orientation maps **intra-chip** `(lx, ly)` from `local = k - c*w*w` to global `(i,j)` with tile offsets; see `mask_io.cpp` branches. -**UP (0):** chip 0 BPC origin `k=0` maps to image column `i=w`, row `j=2*w-1` (upper-right tile in the 2×2 mosaic for the driver's tile numbering). +**UP (0):** chip 0 BPC origin `k=0` maps to image column `i=w`, row `j=2*w-1` (bottom-right tile in the 2×2 mosaic for the driver's tile numbering). **LEFT (3):** chip 0 `k=0` maps to `(i,j) = (2*w-1, 0)` via `bpc2ImgIndex`, but **`pelIndex(2*w-1, 0)` is not 0** — use **`pelIndex`** for mask operations, not `bpc2ImgIndex`, on this path. @@ -235,37 +254,50 @@ These formulas describe the legacy TPX3 mapping. MPX3 `PixelConfigDiff` does not ## Eight-chip mosaic (`numChips == 8`) -### Linear 2×4 grid — mapping implemented; live geometry unqualified (UP only) +### Two adjacent quads in a 4×2 grid — implemented for UP -**Current driver behavior** (`mask_io.cpp`): assumes chip IDs are laid out in a simple raster over the mosaic tiles: - -```text -X_CHIP = chip % xChips -Y_CHIP = chip / xChips -lx = local % w -ly = local / w -i = X_CHIP * w + lx -j = Y_CHIP * w + (w - 1 - ly) -img_linear = i + (xChips * w) * j -``` - -`pelIndex` uses the same mosaic with `k = chip * w*w + lx + (w - 1 - ly) * w`. - -With `xChips = 4`, `yChips = 2`, image tile `(0,0)` is **chip 0**, `(1,0)` is chip 1, … `(3,1)` is chip 7: +The supported eight-chip layout consists of two 2x2 TPX3 quads placed side by +side. The right-hand quad is rotated 180 degrees relative to the left-hand +quad. BPC storage remains eight consecutive 65536-byte chip blocks in chip-ID +order, while image placement is: ```text +---+---+---+---+ -| 0 | 1 | 2 | 3 | +| 2 | 1 | 4 | 7 | +---+---+---+---+ -| 4 | 5 | 6 | 7 | +| 3 | 0 | 5 | 6 | +---+---+---+---+ ``` -Other global orientations for 8-chip: not implemented (`WARN`, returns `-1`). +The 2026-10-04 TPX3 two-quad capture records the following raw +`Layout.Original.Chips` entries. `Image tile` converts Serval's bottom-origin +tile Y to the top-left image convention used by areaDetector; for this +512-pixel-high image, `yChip = (512 - 256 - Y) / 256`. + +| Chip | Serval `X` | Serval `Y` | Serval orientation | Image tile `(xChip,yChip)` | +|------|------------|------------|--------------------|----------------------------| +| 0 | 256 | 0 | `LtRBtT` | (1,1) | +| 1 | 256 | 256 | `RtLTtB` | (1,0) | +| 2 | 0 | 256 | `RtLTtB` | (0,0) | +| 3 | 0 | 0 | `LtRBtT` | (0,1) | +| 4 | 512 | 256 | `RtLTtB` | (2,0) | +| 5 | 512 | 0 | `LtRBtT` | (2,1) | +| 6 | 768 | 0 | `LtRBtT` | (3,1) | +| 7 | 768 | 256 | `RtLTtB` | (3,0) | + +The one-quad TPX3 capture contains the identical entries for chips 0–3. This +evidence is family-specific: it must not be replaced by the different MPX3 +chip assignment shown above. + +`tpx3_dual_quad_mapping.cpp` composes the established TPX3 quad-UP transform +for each module and applies the module-level 180-degree rotation on the right. +`pelIndex` and `bpc2ImgIndex` use the same checked helper, and compact compiled +tests round-trip every pixel. Other global orientations remain unimplemented +(`WARN`, returns `-1`). -### Full-rate SpIDR 2×4 — **planned** (not in `mask_io.cpp` yet) +### Full-rate SpidrTurbo 4×2 — **planned** (not in `mask_io.cpp` yet) -ASI **single-board** 8-chip SpidrTurbo (`chipboardId` prefix **`84`**) uses a **different** stitched mosaic than the linear grid above. It also differs from the older **“2 × quad”** eight-chip diagram (two 4-chip modules). Reference: TPX3 emulator IOC README, section *Full-rate SpIDR 2×4 layout* (on many sites: `iocs/emulator/docs/TPX3_EMULATOR_IOC_README.md` under the EPICS `iocs` tree, outside this module). +ASI **single-board** 8-chip SpidrTurbo (`chipboardId` prefix **`84`**) uses a **different** stitched mosaic than the implemented two-quad grid above. Reference: TPX3 emulator IOC README, section *Full-rate SpIDR 2x4 layout* (the emulator document's historical label; geometrically four across by two down). **Physical mosaic** (chip ID in each tile; `xChip` left→right, `yChip` top→bottom in image coordinates): @@ -312,7 +344,9 @@ Planned lookup (conceptual — not implemented): 2. Find tile `(xChip, yChip)` where `chip_at[yChip][xChip] == chip`. 3. Apply that chip’s rotation to map local `(lx, ly)` → offset within the `w×w` tile, then `i = xChip*w + …`, `j = yChip*w + …`. -**Do not use** the linear 8-chip formulas on SpIDR 2×4 data until this layout is implemented; masks, `PixelConfigDiff`, and masked-pels JSON will be misaligned. +**Do not use** the two-quad 8-chip formulas on full-rate SpidrTurbo data until +this separate layout is implemented; masks, `PixelConfigDiff`, and masked-pels +JSON will be misaligned. **Inputs still needed before implementation** (fill golden vectors in `test/coordinate_map_vectors.json` → `planned_cases`): @@ -329,7 +363,9 @@ Planned lookup (conceptual — not implemented): | Chip 2 origin | 32 | (0,0) | (0, 3) | | Chip 7 origin | 112 | (3,0) | (12, 3) | -Compare to **current** linear 8-chip driver: `k=0` → `(0,3)`, `k=112` → `(12,7)` (see existing `8chip_up_*` cases in JSON). +These draft points describe the single-board SpidrTurbo layout, not the +implemented two-quad mapping. Keep them skipped until emulator or hardware +evidence confirms the placement and rotations. ## Test vectors @@ -347,7 +383,7 @@ python3 test/verify_coordinate_map.py The script reimplements the mapping rules above; it does **not** link the EPICS driver. When changing `mask_io.cpp`, update the JSON and script together. -**SpIDR 2×4:** placeholder cases live under `planned_cases` in the same JSON file. They are **skipped** by `verify_coordinate_map.py` until `status` is removed and expectations are verified on emulator/hardware. When implementing SpIDR mapping, move cases into `cases`, extend the Python reference, then update `mask_io.cpp`. +**SpidrTurbo 4×2:** placeholder cases live under `planned_cases` in the same JSON file. They are **skipped** by `verify_coordinate_map.py` until `status` is removed and expectations are verified on emulator/hardware. When implementing SpidrTurbo mapping, move cases into `cases`, extend the Python reference, then update `mask_io.cpp`. ## API summary (`ADTimePix` in `mask_io.cpp`) diff --git a/iocs/tpx3IOC/iocBoot/iocTimePix/profiles/tpx3/init/paths.cmd b/iocs/tpx3IOC/iocBoot/iocTimePix/profiles/tpx3/init/paths.cmd index 63ae21b8..2e7990c1 100644 --- a/iocs/tpx3IOC/iocBoot/iocTimePix/profiles/tpx3/init/paths.cmd +++ b/iocs/tpx3IOC/iocBoot/iocTimePix/profiles/tpx3/init/paths.cmd @@ -71,6 +71,12 @@ dbpf("$(PREFIX)cam1:WritePrvHst","0") # Select histogram stream #dbpf("$(PREFIX)cam1:BPCFileName","eq.bpc") # load BPC calibration #dbpf("$(PREFIX)cam1:DACSFilePath","$(ADTIMEPIX)/vendor/tpx3/1x1/") #dbpf("$(PREFIX)cam1:DACSFileName","eq.dacs") # load DACS calibration +# Eight-chip two-quad demo (1024x512). Synthetic software/emulator fixture only; +# uncomment this block and comment out the four-chip defaults below. +#dbpf("$(PREFIX)cam1:BPCFilePath","$(ADTIMEPIX)/vendor/tpx3/4x2/") +#dbpf("$(PREFIX)cam1:BPCFileName","tpx3-demo-4x2-synthetic.bpc") +#dbpf("$(PREFIX)cam1:DACSFilePath","$(ADTIMEPIX)/vendor/tpx3/4x2/") +#dbpf("$(PREFIX)cam1:DACSFileName","tpx3-demo-4x2-synthetic.dacs") # Four chip (512×512) — default demo calibration dbpf("$(PREFIX)cam1:BPCFilePath","$(ADTIMEPIX)/vendor/tpx3/2x2/") dbpf("$(PREFIX)cam1:BPCFileName","tpx3-demo.bpc") # load BPC calibration diff --git a/iocs/tpx3IOC/iocBoot/iocTimePix/profiles/tpx3/unique.cmd b/iocs/tpx3IOC/iocBoot/iocTimePix/profiles/tpx3/unique.cmd index 85d0a82b..520b390b 100644 --- a/iocs/tpx3IOC/iocBoot/iocTimePix/profiles/tpx3/unique.cmd +++ b/iocs/tpx3IOC/iocBoot/iocTimePix/profiles/tpx3/unique.cmd @@ -54,7 +54,7 @@ epicsEnvSet("PREFIX", "TPX3-TEST:") # ----- -------------- ---------- ----------------------- # 1 1 × 1 256 × 256 65536 # 4 2 × 2 512 × 512 262144 -# 8 2 × 4 1024 × 512 524288 +# 8 4 × 2 1024 × 512 524288 # # Pick exactly one active line for your detector (comment the others): #epicsEnvSet("MASK_BPC_NELEMENTS", "65536") # 1 chip 256×256 diff --git a/test/Makefile b/test/Makefile index 6a9b9b4f..316ca2ac 100644 --- a/test/Makefile +++ b/test/Makefile @@ -18,6 +18,7 @@ servalProtocolFixtureTest_SRCS += bpc_file_io.cpp servalProtocolFixtureTest_SRCS += calibration_path_policy.cpp servalProtocolFixtureTest_SRCS += destination_policy.cpp servalProtocolFixtureTest_SRCS += bpc_mask_semantics.cpp +servalProtocolFixtureTest_SRCS += detector_geometry.cpp servalProtocolFixtureTest_SRCS += detector_family.cpp servalProtocolFixtureTest_SRCS += mask_geometry.cpp servalProtocolFixtureTest_SRCS += network_client.cpp @@ -36,6 +37,7 @@ servalProtocolFixtureTest_SRCS += serval_reconnect.cpp servalProtocolFixtureTest_SRCS += serval_stream_framing.cpp servalProtocolFixtureTest_SRCS += serval_stream_validation.cpp servalProtocolFixtureTest_SRCS += stream_worker_state.cpp +servalProtocolFixtureTest_SRCS += tpx3_dual_quad_mapping.cpp servalProtocolFixtureTest_LIBS += Com servalProtocolFixtureTest_SYS_LIBS += cpr curl z diff --git a/test/README.md b/test/README.md index 93dcaef5..89385bfd 100644 --- a/test/README.md +++ b/test/README.md @@ -49,6 +49,16 @@ that a partial refresh is reported only after every required readback is tried. The test product also exercises the production rectangular mask-geometry helper. It verifies width-based row-major indexing, clipped rectangle/circle drawing, non-mask-bit preservation, and rejection of undersized waveforms. +It also validates Serval raster geometry for 1x1, 2x2, and 4x2 detectors and +round-trips every pixel in a compact representation of the TPX3 two-quad +`2,1,4,7 / 3,0,5,6` mapping. + +Verify that the checked-in synthetic 4x2 BPC and DACS files remain exact, +deterministic compositions of the existing 2x2 demo calibration: + +```sh +python3 test/generate_tpx3_4x2_demo_calibration.py --check +``` ## Live IOC PixelConfig validation @@ -70,7 +80,8 @@ intentionally left to the caller's environment. ## Live Serval layout capture -Capture the complete `Layout` object for all eight detector orientations with: +Capture the complete detector `Info` and `Layout` objects for all eight +detector orientations with: ```sh test/capture_mpx3_layouts.sh @@ -83,6 +94,11 @@ orientation on exit. Optional positional arguments select the PV prefix, Serval URL, and output directory. The captures are local evidence and are not part of the normal test fixtures. +The script writes the orientation PV. Run it only against an emulator or an +idle detector. If multiple Channel Access servers publish the same prefix, set +`EPICS_CA_ADDR_LIST` and `EPICS_CA_AUTO_ADDR_LIST=NO` before starting so the PV +writes and the Serval HTTP endpoint refer to the intended system. + ## ASI MPX3 reference fixture `fixtures/mpx3/eq-02/` contains corresponding ASI-provided BPC and Serval chip diff --git a/test/capture_serval_layouts.sh b/test/capture_serval_layouts.sh index 9fdd3b3e..44d591cf 100755 --- a/test/capture_serval_layouts.sh +++ b/test/capture_serval_layouts.sh @@ -21,7 +21,7 @@ Usage: capture_serval_layouts.sh FAMILY PV_PREFIX [SERVAL_URL] [OUTPUT_DIR] FAMILY must be MPX3 or TPX3. The script writes one JSON file per detector -orientation, containing only the Layout object returned by GET /detector. +orientation, containing the Info and Layout objects returned by GET /detector. Examples: capture_serval_layouts.sh MPX3 MPX3-TEST:cam1: @@ -127,12 +127,17 @@ capture_layout() { local expected_name=$1 local destination=$2 local raw_file="${temp_dir}/detector.json" - local layout_file="${temp_dir}/layout.json" + local capture_file="${temp_dir}/capture.json" local attempt for ((attempt = 1; attempt <= 20; ++attempt)); do if wget --quiet --timeout=10 --tries=1 -O "${raw_file}" "${endpoint}" && jq -e --arg orientation "${expected_name}" --arg family "${family}" ' + (.Info | type) == "object" and + (.Info.PixCount | type) == "number" and .Info.PixCount > 0 and + (.Info.RowLen | type) == "number" and .Info.RowLen > 0 and + (.Info.NumberOfChips | type) == "number" and .Info.NumberOfChips > 0 and + (.Info.NumberOfRows | type) == "number" and .Info.NumberOfRows > 0 and (.Layout | type) == "object" and .Layout.DetectorOrientation == $orientation and .Layout.Original.ChipType == $family and @@ -140,10 +145,13 @@ capture_layout() { ((.Layout.Original.Chips | type) == "array") and ((.Layout.Rotated.Chips | type) == "array") and (.Layout.Original.Chips | length) > 0 and - (.Layout.Rotated.Chips | length) > 0 + (.Layout.Rotated.Chips | length) == .Info.NumberOfChips and + (.Layout.Original.Chips | length) == .Info.NumberOfChips and + (.Layout.Original.Width * .Layout.Original.Height) == .Info.PixCount and + (.Layout.Rotated.Width * .Layout.Rotated.Height) == .Info.PixCount ' "${raw_file}" >/dev/null; then - jq '{Layout: .Layout}' "${raw_file}" >"${layout_file}" - mv -f -- "${layout_file}" "${destination}" + jq '{Info: .Info, Layout: .Layout}' "${raw_file}" >"${capture_file}" + mv -f -- "${capture_file}" "${destination}" return 0 fi sleep 0.25 diff --git a/test/coordinate_map_vectors.json b/test/coordinate_map_vectors.json index b2bfbf1f..9d5d3084 100644 --- a/test/coordinate_map_vectors.json +++ b/test/coordinate_map_vectors.json @@ -87,25 +87,47 @@ "note": "LEFT quad: pelIndex(0,0) is chip1 tile (k=16); not the same (i,j) as bpc2ImgIndex(k=0)->(7,0)" }, { - "name": "8chip_up_bpc_chip0_origin", + "name": "8chip_dual_quad_up_bpc_chip0_origin", "num_chips": 8, "pel_width": 4, "x_chips": 4, "orientation": 0, "bpc_index": 0, - "expect_bpc2img": { "i": 0, "j": 3, "img_linear": 48 }, + "expect_bpc2img": { "i": 4, "j": 7, "img_linear": 116 }, "expect_pel_index_at_ij": 0 }, { - "name": "8chip_up_bpc_chip7_origin", + "name": "8chip_dual_quad_up_bpc_chip7_origin", "num_chips": 8, "pel_width": 4, "x_chips": 4, "orientation": 0, "bpc_index": 112, - "expect_bpc2img": { "i": 12, "j": 7, "img_linear": 124 }, + "expect_bpc2img": { "i": 15, "j": 0, "img_linear": 15 }, "expect_pel_index_at_ij": 112 }, + { + "name": "8chip_dual_quad_up_bpc_chip4_origin", + "num_chips": 8, + "pel_width": 4, + "x_chips": 4, + "orientation": 0, + "bpc_index": 64, + "expect_bpc2img": { "i": 11, "j": 0, "img_linear": 11 }, + "expect_pel_index_at_ij": 64, + "note": "The right-hand quad is rotated 180 degrees relative to the left-hand quad" + }, + { + "name": "8chip_dual_quad_up_right_quad_roundtrip", + "num_chips": 8, + "pel_width": 4, + "x_chips": 4, + "orientation": 0, + "i": 9, + "j": 5, + "expect_pel_index": 89, + "expect_bpc_index_from_pel": 89 + }, { "name": "chip_from_bpc_index", "num_chips": 4, @@ -132,9 +154,9 @@ ], "planned_cases": [ { - "name": "spidr_2x4_chip0_origin", + "name": "spidr_4x2_chip0_origin", "status": "planned", - "layout": "spidr_2x4", + "layout": "spidr_4x2", "num_chips": 8, "pel_width": 4, "x_chips": 4, @@ -145,13 +167,13 @@ "tile": { "x_chip": 1, "y_chip": 1 }, "draft_expect_bpc2img": { "i": 4, "j": 7, "img_linear": 116 }, "draft_expect_pel_index_at_ij": 0, - "current_linear_8chip_bpc2img": { "i": 0, "j": 3, "img_linear": 48 }, + "current_two_quad_bpc2img": { "i": 4, "j": 7, "img_linear": 116 }, "note": "TBD: verify on emulator (chipboardId 84…) and Serval Layout; ROT90 for chips 5,7" }, { - "name": "spidr_2x4_chip2_origin", + "name": "spidr_4x2_chip2_origin", "status": "planned", - "layout": "spidr_2x4", + "layout": "spidr_4x2", "num_chips": 8, "pel_width": 4, "x_chips": 4, @@ -160,13 +182,13 @@ "chip_id": 2, "tile": { "x_chip": 0, "y_chip": 0 }, "draft_expect_bpc2img": { "i": 0, "j": 3, "img_linear": 48 }, - "current_linear_8chip_bpc2img": { "i": 8, "j": 3, "img_linear": 56 }, + "current_two_quad_bpc2img": { "i": 3, "j": 0, "img_linear": 3 }, "note": "TBD: fill expect_* after hardware check" }, { - "name": "spidr_2x4_chip5_origin", + "name": "spidr_4x2_chip5_origin", "status": "planned", - "layout": "spidr_2x4", + "layout": "spidr_4x2", "num_chips": 8, "pel_width": 4, "x_chips": 4, @@ -177,12 +199,13 @@ "tile": { "x_chip": 3, "y_chip": 1 }, "draft_expect_bpc2img": null, "draft_expect_pel_index_at_ij": null, + "current_two_quad_bpc2img": { "i": 8, "j": 7, "img_linear": 120 }, "note": "TBD: ROT90 intra-chip; corner (lx,ly)=(0,0) — measure on hardware" }, { - "name": "spidr_2x4_chip7_origin", + "name": "spidr_4x2_chip7_origin", "status": "planned", - "layout": "spidr_2x4", + "layout": "spidr_4x2", "num_chips": 8, "pel_width": 4, "x_chips": 4, @@ -191,13 +214,13 @@ "chip_id": 7, "tile": { "x_chip": 3, "y_chip": 0 }, "draft_expect_bpc2img": { "i": 12, "j": 3, "img_linear": 60 }, - "current_linear_8chip_bpc2img": { "i": 12, "j": 7, "img_linear": 124 }, + "current_two_quad_bpc2img": { "i": 15, "j": 0, "img_linear": 15 }, "note": "TBD: ROT90 intra-chip for chip 7" }, { - "name": "spidr_2x4_pelindex_roundtrip_chip0", + "name": "spidr_4x2_pelindex_roundtrip_chip0", "status": "planned", - "layout": "spidr_2x4", + "layout": "spidr_4x2", "num_chips": 8, "pel_width": 4, "x_chips": 4, diff --git a/test/generate_tpx3_4x2_demo_calibration.py b/test/generate_tpx3_4x2_demo_calibration.py new file mode 100644 index 00000000..b0aaed23 --- /dev/null +++ b/test/generate_tpx3_4x2_demo_calibration.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Generate or verify the synthetic TPX3 4x2 demo BPC/DACS pair. + +The eight-chip fixture repeats the four per-chip BPC blocks and DACS values +from vendor/tpx3/2x2. It is suitable for deterministic software and emulator +tests, not as detector-specific equalization data. +""" + +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import argparse +import hashlib +import re +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SOURCE_DIR = REPO_ROOT / "vendor" / "tpx3" / "2x2" +OUTPUT_DIR = REPO_ROOT / "vendor" / "tpx3" / "4x2" +SOURCE_BPC = SOURCE_DIR / "tpx3-demo.bpc" +SOURCE_DACS = SOURCE_DIR / "tpx3-demo.dacs" +OUTPUT_BPC = OUTPUT_DIR / "tpx3-demo-4x2-synthetic.bpc" +OUTPUT_DACS = OUTPUT_DIR / "tpx3-demo-4x2-synthetic.dacs" +CHIP_BYTES = 256 * 256 +EXPECTED_SOURCE_BPC_SHA256 = ( + "7c065b252dce0fc56aa991957eadaebfadb8c9e068072b17530dbb3775ef338b" +) +EXPECTED_SOURCE_DACS_SHA256 = ( + "df3fba01f99d4080df518c6953ec4ebdfbf2974a6cb9a92d41adaf16687969a1" +) +EXPECTED_OUTPUT_BPC_SHA256 = ( + "6cd7f48d3833dc29934e0048ed611f4665efceed68c543dcf762a762277d474c" +) +EXPECTED_OUTPUT_DACS_SHA256 = ( + "00f08da4d06a876bf5d7d6286fd618da3d90810eeb552c29ba65e1c5f40aeec9" +) + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def require_sha256(path: Path, data: bytes, expected: str) -> None: + actual = sha256(data) + if actual != expected: + raise ValueError( + f"{path.relative_to(REPO_ROOT)} sha256 is {actual}; expected {expected}" + ) + + +def compose_bpc() -> bytes: + source = SOURCE_BPC.read_bytes() + require_sha256(SOURCE_BPC, source, EXPECTED_SOURCE_BPC_SHA256) + expected_size = 4 * CHIP_BYTES + if len(source) != expected_size: + raise ValueError( + f"{SOURCE_BPC.relative_to(REPO_ROOT)} is {len(source)} bytes; " + f"expected {expected_size}" + ) + return source + source + + +def compose_dacs() -> bytes: + source_bytes = SOURCE_DACS.read_bytes() + require_sha256(SOURCE_DACS, source_bytes, EXPECTED_SOURCE_DACS_SHA256) + source = source_bytes.decode("utf-8") + headings = re.findall(r"^\[Chip(\d+)\]$", source, flags=re.MULTILINE) + if headings != ["0", "1", "2", "3"]: + raise ValueError( + f"{SOURCE_DACS.relative_to(REPO_ROOT)} sections are {headings}; " + "expected Chip0 through Chip3 exactly once and in order" + ) + second_quad = re.sub( + r"^\[Chip([0-3])\]$", + lambda match: f"[Chip{int(match.group(1)) + 4}]", + source, + flags=re.MULTILINE, + ) + result = (source + second_quad).encode("utf-8") + result_headings = re.findall( + rb"^\[Chip(\d+)\]$", result, flags=re.MULTILINE + ) + if result_headings != [str(index).encode() for index in range(8)]: + raise ValueError("generated DACS sections are not Chip0 through Chip7") + return result + + +def verify_file(path: Path, expected: bytes) -> bool: + if not path.is_file(): + print(f"ERROR: missing {path.relative_to(REPO_ROOT)}", file=sys.stderr) + return False + actual = path.read_bytes() + if actual != expected: + print( + f"ERROR: {path.relative_to(REPO_ROOT)} differs from deterministic output", + file=sys.stderr, + ) + return False + return True + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + action="store_true", + help="verify checked-in outputs without changing them", + ) + args = parser.parse_args() + + try: + bpc = compose_bpc() + dacs = compose_dacs() + require_sha256(OUTPUT_BPC, bpc, EXPECTED_OUTPUT_BPC_SHA256) + require_sha256(OUTPUT_DACS, dacs, EXPECTED_OUTPUT_DACS_SHA256) + except (OSError, UnicodeError, ValueError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + if args.check: + if not verify_file(OUTPUT_BPC, bpc) or not verify_file(OUTPUT_DACS, dacs): + return 1 + action = "Validated" + else: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + OUTPUT_BPC.write_bytes(bpc) + OUTPUT_DACS.write_bytes(dacs) + action = "Generated" + + print( + f"{action} TPX3 4x2 demo calibration: " + f"BPC={len(bpc)} bytes sha256={sha256(bpc)}, " + f"DACS={len(dacs)} bytes sha256={sha256(dacs)}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/servalProtocolFixtureTest.cpp b/test/servalProtocolFixtureTest.cpp index aff23976..e71d1999 100644 --- a/test/servalProtocolFixtureTest.cpp +++ b/test/servalProtocolFixtureTest.cpp @@ -11,6 +11,7 @@ #include "calibration_path_policy.h" #include "destination_policy.h" #include "bpc_mask_semantics.h" +#include "detector_geometry.h" #include "mask_geometry.h" #include "network_client.h" #include "numeric_range.h" @@ -29,6 +30,7 @@ #include "serval_stream_framing.h" #include "serval_stream_validation.h" #include "stream_worker_state.h" +#include "tpx3_dual_quad_mapping.h" #include #include @@ -1084,12 +1086,12 @@ void testDetectorResponseValidation() using ADTimePix3ServalDetector::Snapshot; const std::string valid = - "{\"Info\":{\"PixCount\":262144,\"RowLen\":512,\"NumberOfChips\":4," + "{\"Info\":{\"PixCount\":262144,\"RowLen\":2,\"NumberOfChips\":4," "\"NumberOfRows\":512,\"MpxType\":3},\"Config\":{\"BiasEnabled\":false}," "\"Health\":[]}"; Snapshot snapshot; testOk(ADTimePix3ServalDetector::parseResponse(valid, snapshot) == ParseError::None && - snapshot.pixelCount == 262144 && snapshot.rowLength == 512 && + snapshot.pixelCount == 262144 && snapshot.rowLength == 2 && snapshot.numberOfChips == 4 && snapshot.numberOfRows == 512 && snapshot.mpxType == 3 && snapshot.response["Config"]["BiasEnabled"] == false, "detector parser accepts a complete response and extracts bounded geometry"); @@ -1946,6 +1948,123 @@ void testMaskGeometry() "mask operations reject a null waveform buffer"); } +void testDetectorGeometry() +{ + using ADTimePix3DetectorGeometry::Geometry; + using ADTimePix3DetectorGeometry::Status; + + Geometry geometry{}; + testOk(ADTimePix3DetectorGeometry::derive( + 65536, 1, 1, 256, geometry) == Status::Ok && + geometry.rows == 256 && geometry.cols == 256 && + geometry.xChips == 1 && geometry.yChips == 1 && + geometry.chipWidth == 256, + "detector geometry derives a 256 by 256 single-chip raster"); + + testOk(ADTimePix3DetectorGeometry::derive( + 262144, 2, 4, 512, geometry) == Status::Ok && + geometry.rows == 512 && geometry.cols == 512 && + geometry.xChips == 2 && geometry.yChips == 2 && + geometry.chipWidth == 256, + "detector geometry derives a two-chip-wide TPX3 quad"); + + testOk(ADTimePix3DetectorGeometry::derive( + 524288, 4, 8, 512, geometry) == Status::Ok && + geometry.rows == 512 && geometry.cols == 1024 && + geometry.xChips == 4 && geometry.yChips == 2 && + geometry.chipWidth == 256, + "detector geometry matches the captured four-across TPX3 metadata"); + + testOk(ADTimePix3DetectorGeometry::derive( + 524288, 1024, 8, 512, geometry) == Status::PixelCountMismatch, + "detector geometry rejects treating TPX3 RowLen as a pixel width"); + + testOk(ADTimePix3DetectorGeometry::derive( + 524287, 1024, 8, 512, geometry) == Status::PixelCountMismatch, + "detector geometry rejects a raster whose pixel count disagrees"); + + testOk(ADTimePix3DetectorGeometry::derive( + 80, 4, 8, 8, geometry) == Status::NonSquareChip, + "detector geometry rejects a non-square per-chip pixel count"); + + testOk(ADTimePix3DetectorGeometry::isTwoQuadLayout( + "41000024", "4100003a") && + !ADTimePix3DetectorGeometry::isTwoQuadLayout( + "84000018", "") && + !ADTimePix3DetectorGeometry::isTwoQuadLayout( + "41000024", "41000024"), + "eight-chip mask qualification accepts two distinct quad boards only"); +} + +void testTpx3DualQuadMapping() +{ + constexpr int width = 4; + constexpr int chipPixels = width * width; + const int expectedTileX[] = {1, 1, 0, 0, 2, 2, 3, 3}; + const int expectedTileY[] = {1, 0, 0, 1, 0, 1, 1, 0}; + bool originsMatch = true; + bool originsRoundTrip = true; + for (int chip = 0; chip < 8; ++chip) { + const int bpc = chip * chipPixels; + int imageX = -1; + int imageY = -1; + int roundTrip = -1; + originsMatch = originsMatch && + ADTimePix3Tpx3DualQuad::bpcToImage( + bpc, width, imageX, imageY) && + imageX / width == expectedTileX[chip] && + imageY / width == expectedTileY[chip]; + originsRoundTrip = originsRoundTrip && + ADTimePix3Tpx3DualQuad::imageToBpc( + imageX, imageY, width, roundTrip) && + roundTrip == bpc; + } + testOk(originsMatch, + "TPX3 two-quad chip origins map to rows 2,1,4,7 and 3,0,5,6"); + testOk(originsRoundTrip, + "TPX3 two-quad chip origins round-trip between BPC and image coordinates"); + + bool everyPixelRoundTrips = true; + for (int bpc = 0; bpc < 8 * chipPixels; ++bpc) { + int imageX = -1; + int imageY = -1; + int roundTrip = -1; + everyPixelRoundTrips = everyPixelRoundTrips && + ADTimePix3Tpx3DualQuad::bpcToImage( + bpc, width, imageX, imageY) && + ADTimePix3Tpx3DualQuad::imageToBpc( + imageX, imageY, width, roundTrip) && + roundTrip == bpc; + } + testOk(everyPixelRoundTrips, + "TPX3 two-quad mapping round-trips every pixel in a compact test geometry"); + + constexpr int capturedWidth = 256; + bool capturedGeometryRoundTrips = true; + for (int bpc = 0; bpc < 8 * capturedWidth * capturedWidth; ++bpc) { + int imageX = -1; + int imageY = -1; + int roundTrip = -1; + capturedGeometryRoundTrips = capturedGeometryRoundTrips && + ADTimePix3Tpx3DualQuad::bpcToImage( + bpc, capturedWidth, imageX, imageY) && + imageX >= 0 && imageX < 1024 && imageY >= 0 && imageY < 512 && + ADTimePix3Tpx3DualQuad::imageToBpc( + imageX, imageY, capturedWidth, roundTrip) && + roundTrip == bpc; + } + testOk(capturedGeometryRoundTrips, + "TPX3 two-quad mapping round-trips all 524288 captured-layout pixels"); + + int unused = 0; + testOk(!ADTimePix3Tpx3DualQuad::bpcToImage(-1, width, unused, unused) && + !ADTimePix3Tpx3DualQuad::bpcToImage( + 8 * chipPixels, width, unused, unused) && + !ADTimePix3Tpx3DualQuad::imageToBpc( + 4 * width, 0, width, unused), + "TPX3 two-quad mapping rejects out-of-range indices and coordinates"); +} + void testOneShotActions() { const ADTimePix3Action::OneShotDecision zero = @@ -2138,7 +2257,7 @@ void testRollingWindowSum() MAIN(servalProtocolFixtureTest) { - testPlan(346); + testPlan(358); testTcpScript(); testTcpSilenceIsBounded(); testProductionNetworkClient(); @@ -2162,6 +2281,8 @@ MAIN(servalProtocolFixtureTest) testBpcFileBounds(); testBpcMaskSemantics(); testMaskGeometry(); + testDetectorGeometry(); + testTpx3DualQuadMapping(); testOneShotActions(); testRollingWindowSum(); testNumericRangeContract(); diff --git a/test/verify_coordinate_map.py b/test/verify_coordinate_map.py index e0caef34..65fad8ad 100644 --- a/test/verify_coordinate_map.py +++ b/test/verify_coordinate_map.py @@ -38,6 +38,32 @@ def image_cols(num_chips: int, pel_width: int, x_chips: int) -> int: return y_chips * w +def quad_up_bpc_to_image(chip: int, lx: int, ly: int, w: int) -> tuple[int, int]: + """Map one BPC-local pixel through the established TPX3 quad UP layout.""" + if chip == 0: + return w + lx, 2 * w - 1 - ly + if chip == 1: + return 2 * w - 1 - lx, ly + if chip == 2: + return w - 1 - lx, ly + if chip == 3: + return lx, 2 * w - 1 - ly + return -1, -1 + + +def quad_up_image_to_bpc(i: int, j: int, w: int) -> int: + """Inverse of quad_up_bpc_to_image for one 2x2 quad.""" + if not (0 <= i < 2 * w and 0 <= j < 2 * w): + return -1 + if i >= w and j >= w: + return (i - w) + (2 * w - 1 - j) * w + if i >= w: + return w * w + (2 * w - 1 - i) + j * w + if j < w: + return 2 * w * w + (w - 1 - i) + j * w + return 3 * w * w + i + (2 * w - 1 - j) * w + + def bpc2img_index( bpc_index: int, pel_width: int, num_chips: int, x_chips: int, orientation: int ) -> int: @@ -115,15 +141,17 @@ def bpc2img_index( if orientation != 0: return -1 y_chips = num_chips // x_chips - if x_chips * y_chips != 8: + if x_chips != 4 or y_chips != 2: return -1 local = bpc_index - chip * chip_pel_count lx = local % w ly = local // w - x_chip = chip % x_chips - y_chip = chip // x_chips - i = x_chip * w + lx - j = y_chip * w + (w - 1 - ly) + quad_i, quad_j = quad_up_bpc_to_image(chip % 4, lx, ly, w) + if chip < 4: + i, j = quad_i, quad_j + else: + i = 4 * w - 1 - quad_i + j = 2 * w - 1 - quad_j return i + (x_chips * w) * j return -1 @@ -187,12 +215,14 @@ def pel_index( if orientation != 0: return -1 y_chips = num_chips // x_chips - if x_chips * y_chips != 8 or x_chip >= x_chips or y_chip >= y_chips: + if x_chips != 4 or y_chips != 2 or x_chip >= x_chips or y_chip >= y_chips: return -1 - lx = i - x_chip * w - ly = j - y_chip * w - chip_idx = y_chip * x_chips + x_chip - return chip_idx * w * w + lx + ((w - 1) - ly) * w + if i < 2 * w: + return quad_up_image_to_bpc(i, j, w) + quad_i = 4 * w - 1 - i + quad_j = 2 * w - 1 - j + local = quad_up_image_to_bpc(quad_i, quad_j, w) + return -1 if local < 0 else 4 * w * w + local return -1 @@ -285,7 +315,7 @@ def main() -> int: n_planned = len(planned) print(f"OK: {n_cases} coordinate_map_vectors cases passed", end="") if n_planned: - print(f"; {n_planned} planned case(s) skipped (SpIDR 2×4 — see COORDINATE_MAP.md)") + print(f"; {n_planned} planned case(s) skipped (SpIDR 4×2 — see COORDINATE_MAP.md)") else: print() return 0 diff --git a/tpx3App/src/ADTimePix.h b/tpx3App/src/ADTimePix.h index bd7ca9c1..7bd6770e 100644 --- a/tpx3App/src/ADTimePix.h +++ b/tpx3App/src/ADTimePix.h @@ -79,7 +79,7 @@ #define ADTimePixSW_versionString "TPX3_SW_VER" // (asynOctet, r) SW_version #define ADTimePixFW_versionString "TPX3_FW_VER" // (asynOctet, r) FW_version #define ADTimePixPixCountString "TPX3_PEL_CNT" // (asynInt32, r) PixCount -#define ADTimePixRowLenString "TPX3_ROWLEN" // (asynInt32, r) RowLen +#define ADTimePixRowLenString "TPX3_ROWLEN" // (asynInt32, r) Serval RowLen (TPX3 chips per detector row) #define ADTimePixNumberOfChipsString "TPX3_NUM_CHIPS" // (asynInt32, r) NumberOfChip #define ADTimePixNumberOfRowsString "TPX3_NUM_ROWS" // (asynInt32, r) NumberOfRows #define ADTimePixMpxTypeString "TPX3_MPX_TYPE" // (asynInt32, r) MpxType diff --git a/tpx3App/src/Makefile b/tpx3App/src/Makefile index 486b13b3..5fe18109 100644 --- a/tpx3App/src/Makefile +++ b/tpx3App/src/Makefile @@ -20,6 +20,7 @@ INC += bpc_file_io.h INC += calibration_path_policy.h INC += destination_policy.h INC += bpc_mask_semantics.h +INC += detector_geometry.h INC += detector_family.h INC += mask_geometry.h INC += network_client.h @@ -39,6 +40,7 @@ INC += serval_reconnect.h INC += serval_stream_framing.h INC += stream_worker_state.h INC += serval_stream_validation.h +INC += tpx3_dual_quad_mapping.h # GraphicsMagick removed - TCP streaming is used instead # GraphicsMagick implementation preserved in preserve/graphicsmagick-preview branch @@ -51,6 +53,7 @@ LIB_SRCS += bpc_file_io.cpp LIB_SRCS += calibration_path_policy.cpp LIB_SRCS += destination_policy.cpp LIB_SRCS += bpc_mask_semantics.cpp +LIB_SRCS += detector_geometry.cpp LIB_SRCS += detector_family.cpp LIB_SRCS += mask_geometry.cpp LIB_SRCS += mask_io.cpp @@ -74,6 +77,7 @@ LIB_SRCS += stream_worker_state.cpp LIB_SRCS += serval_stream_validation.cpp LIB_SRCS += serval_stream.cpp LIB_SRCS += serval_http.cpp +LIB_SRCS += tpx3_dual_quad_mapping.cpp LIB_SRCS += acquire.cpp LIB_SYS_LIBS += cpr curl z diff --git a/tpx3App/src/detector_geometry.cpp b/tpx3App/src/detector_geometry.cpp new file mode 100644 index 00000000..becbe7bb --- /dev/null +++ b/tpx3App/src/detector_geometry.cpp @@ -0,0 +1,81 @@ +/* + * ADTimePix3 - validated detector raster and chip-grid geometry + * + * Copyright (c) 2026 UT-Battelle, LLC, Oak Ridge National Laboratory + * + * SPDX-License-Identifier: MIT + */ + +#include "detector_geometry.h" + +#include +#include +#include + +namespace ADTimePix3DetectorGeometry { + +Status derive(int pixelCount, int rowLength, int numberOfChips, + int numberOfRows, Geometry& geometry) +{ + geometry = Geometry{0, 0, 0, 0, 0}; + if (pixelCount <= 0 || rowLength <= 0 || numberOfChips <= 0 || + numberOfRows <= 0) { + return Status::InvalidArgument; + } + + if (numberOfChips % rowLength != 0 || pixelCount % numberOfChips != 0) { + return Status::PixelCountMismatch; + } + + const int pixelsPerChip = pixelCount / numberOfChips; + const int chipWidth = static_cast(std::sqrt( + static_cast(pixelsPerChip))); + if (chipWidth <= 0 || + static_cast(chipWidth) * chipWidth != pixelsPerChip) { + return Status::NonSquareChip; + } + const int xChips = rowLength; + const int yChips = numberOfChips / xChips; + const std::int64_t rows = + static_cast(yChips) * chipWidth; + const std::int64_t cols = + static_cast(xChips) * chipWidth; + if (xChips <= 0 || yChips <= 0 || rows != numberOfRows || + rows * cols != pixelCount || + rows > std::numeric_limits::max() || + cols > std::numeric_limits::max()) { + return Status::NonIntegralChipGrid; + } + + geometry = Geometry{static_cast(rows), static_cast(cols), + xChips, yChips, chipWidth}; + return Status::Ok; +} + +bool isTwoQuadLayout(const std::string& firstChipboardId, + const std::string& secondChipboardId) +{ + return firstChipboardId.size() >= 2 && secondChipboardId.size() >= 2 && + firstChipboardId.compare(0, 2, "41") == 0 && + secondChipboardId.compare(0, 2, "41") == 0 && + firstChipboardId != secondChipboardId; +} + +const char* statusMessage(Status status) +{ + switch (status) { + case Status::Ok: + return "ok"; + case Status::InvalidArgument: + return "invalid detector geometry argument"; + case Status::PixelCountMismatch: + return "detector raster and pixel count disagree"; + case Status::NonSquareChip: + return "per-chip pixel count is not a square"; + case Status::NonIntegralChipGrid: + return "detector raster does not form an integral chip grid"; + } + return "unknown detector geometry error"; +} + +} // namespace ADTimePix3DetectorGeometry diff --git a/tpx3App/src/detector_geometry.h b/tpx3App/src/detector_geometry.h new file mode 100644 index 00000000..71908965 --- /dev/null +++ b/tpx3App/src/detector_geometry.h @@ -0,0 +1,49 @@ +/* + * ADTimePix3 - validated detector raster and chip-grid geometry + * + * Copyright (c) 2026 UT-Battelle, LLC, Oak Ridge National Laboratory + * + * SPDX-License-Identifier: MIT + */ + +#ifndef ADTIMEPIX3_DETECTOR_GEOMETRY_H +#define ADTIMEPIX3_DETECTOR_GEOMETRY_H + +#include + +namespace ADTimePix3DetectorGeometry { + +struct Geometry { + int rows; + int cols; + int xChips; + int yChips; + int chipWidth; +}; + +enum class Status { + Ok, + InvalidArgument, + PixelCountMismatch, + NonSquareChip, + NonIntegralChipGrid +}; + +/* + * TPX3 Serval Info.RowLen is the number of chips across the detector and + * Info.NumberOfRows is the assembled image height in pixels. Derive the + * square-chip width and assembled raster, rejecting inconsistent metadata. + */ +Status derive(int pixelCount, int rowLength, int numberOfChips, + int numberOfRows, Geometry& geometry); + +/* The qualified eight-chip mask layout is two distinct TPX3 quad boards. + * Single-board SpidrTurbo detectors use a different chip placement. */ +bool isTwoQuadLayout(const std::string& firstChipboardId, + const std::string& secondChipboardId); + +const char* statusMessage(Status status); + +} // namespace ADTimePix3DetectorGeometry + +#endif diff --git a/tpx3App/src/mask_io.cpp b/tpx3App/src/mask_io.cpp index eed8ade6..673f3cda 100644 --- a/tpx3App/src/mask_io.cpp +++ b/tpx3App/src/mask_io.cpp @@ -23,8 +23,10 @@ #include "ADTimePixLog.h" #include "bpc_file_io.h" #include "bpc_mask_semantics.h" +#include "detector_geometry.h" #include "mask_geometry.h" #include "serval_pixel_config.h" +#include "tpx3_dual_quad_mapping.h" extern const char* driverName; // defined in ADTimePix.cpp (same as histogram_io.cpp) @@ -254,14 +256,63 @@ asynStatus ADTimePix::rowsCols(int *rows, int *cols, int *xChips, int *yChips, i getIntegerParam(ADTimePixNumberOfChips, &numChips); getIntegerParam(ADTimePixNumberOfRows, &numRows); + if (numChips == 8) { + std::string firstChipboardId; + std::string secondChipboardId; + getStringParam(ADTimePixBoardsID, firstChipboardId); + getStringParam(ADTimePixBoards2ID, secondChipboardId); + if (detectorFamily_ != DetectorFamily::TPX3 || + !ADTimePix3DetectorGeometry::isTwoQuadLayout( + firstChipboardId, secondChipboardId)) { + *rows = 0; + *cols = 0; + *xChips = 0; + *yChips = 0; + *chipPelWidth = 0; + setStringParam(ADTimePixWriteMsg, + "Unsupported eight-chip mask layout: require two TPX3 quad boards"); + WARN_ARGS("rowsCols: unsupported eight-chip mask layout " + "(family=%s board0=%s board1=%s)", + detectorFamilyName(detectorFamily_), firstChipboardId.c_str(), + secondChipboardId.c_str()); + return asynError; + } + + ADTimePix3DetectorGeometry::Geometry geometry; + const ADTimePix3DetectorGeometry::Status status = + ADTimePix3DetectorGeometry::derive( + pixCount, rowLength, numChips, numRows, geometry); + if (status != ADTimePix3DetectorGeometry::Status::Ok) { + *rows = 0; + *cols = 0; + *xChips = 0; + *yChips = 0; + *chipPelWidth = 0; + setStringParam(ADTimePixWriteMsg, + ADTimePix3DetectorGeometry::statusMessage(status)); + WARN_ARGS("rowsCols: %s (pixels=%d rowLength=%d chips=%d rows=%d)", + ADTimePix3DetectorGeometry::statusMessage(status), pixCount, + rowLength, numChips, numRows); + return asynError; + } + + *rows = geometry.rows; + *cols = geometry.cols; + *xChips = geometry.xChips; + *yChips = geometry.yChips; + *chipPelWidth = geometry.chipWidth; + return asynSuccess; + } + + /* Preserve the established one-chip and four-chip geometry path. The + * eight-chip rectangular correction above is deliberately isolated from + * the production quad detector. */ + if (rowLength <= 0) return asynError; *xChips = rowLength; *yChips = numChips / rowLength; *chipPelWidth = numRows / rowLength; *rows = numRows; *cols = (*yChips) * (*chipPelWidth); - -// printf("rows=%d, cols=%d, xChips=%d, yChips=%d, chipPelWidth=%d\n\n", *rows, *cols, *xChips, *yChips, *chipPelWidth); - return asynSuccess; } @@ -682,7 +733,13 @@ asynStatus ADTimePix::writeBPCfile(const std::vector& data) { */ asynStatus ADTimePix::findChip(int x, int y, int *xChip, int *yChip, int *width) { int ROWS = 0, COLS = 0, xCHIPS = 0, yCHIPS = 0, PelWidth = 0; - rowsCols(&ROWS, &COLS, &xCHIPS, &yCHIPS, &PelWidth); + if (rowsCols(&ROWS, &COLS, &xCHIPS, &yCHIPS, &PelWidth) != asynSuccess || + PelWidth <= 0 || x < 0 || x >= COLS || y < 0 || y >= ROWS) { + *xChip = 0; + *yChip = 0; + *width = 0; + return asynError; + } *xChip = x / PelWidth; *yChip = y / PelWidth; @@ -899,27 +956,24 @@ int ADTimePix::bpc2ImgIndex(int bpcIndexIn, int chipPelWidthIn) { } imgIndex = i + 2*chipPelWidth*j; } else if (numChips == 8) { - /* 2×4 (or 4×2) mosaic: uniform grid, BPC chip order chip = Y_CHIP * xChips + X_CHIP, - * intra-chip mapping matches single-chip UP (same convention as one tile of the 2×2 case). - * Only DetectorOrientation UP (0); other orientations need per-layout tables like 2×2. */ + /* Two adjacent 2x2 quads form a four-across, two-down image. Only + * DetectorOrientation UP (0); other orientations require qualified + * layout transforms. */ if (detOrientation != 0) { WARN("bpc2ImgIndex: 8-chip mapping only for DetectorOrientation UP (0)"); return -1; } int ROWS = 0, COLS = 0, xChips = 0, yChips = 0, w = 0; rowsCols(&ROWS, &COLS, &xChips, &yChips, &w); - if (xChips * yChips != 8 || chipPelCount <= 0 || chip < 0 || chip > 7) { - WARN("bpc2ImgIndex: 8-chip requires xChips*yChips==8 and chip index 0..7"); + if (xChips != 4 || yChips != 2 || chipPelCount <= 0 || chip < 0 || chip > 7) { + WARN("bpc2ImgIndex: two-quad mapping requires 4x2 chips and chip index 0..7"); return -1; } - int local = bpcIndex - chip * chipPelCount; - int lx = local % w; - int ly = local / w; - int X_CHIP = chip % xChips; - int Y_CHIP = chip / xChips; - i = X_CHIP * w + lx; - j = Y_CHIP * w + (w - 1 - ly); - imgIndex = i + (xChips * w) * j; + if (!ADTimePix3Tpx3DualQuad::bpcToImage(bpcIndex, w, i, j)) { + WARN("bpc2ImgIndex: invalid two-quad BPC index"); + return -1; + } + imgIndex = i + COLS * j; } else { WARN_ARGS("bpc2ImgIndex: chip count %d not supported (use 1, 4, or 8)", numChips); imgIndex = -1; @@ -939,9 +993,11 @@ int ADTimePix::pelIndex(int i, int j) { getIntegerParam(ADTimePixDetectorOrientation, &detOrientation); getIntegerParam(ADTimePixNumberOfChips, &numChips); - findChip(i, j, &X_CHIP, &Y_CHIP, &PelWidth); + if (findChip(i, j, &X_CHIP, &Y_CHIP, &PelWidth) != asynSuccess) { + WARN("pelIndex: image coordinate outside detector geometry"); + return -1; + } - // TODO: 2x4 chip detector // One-chip TimePix3 detector if (numChips == 1) { if (detOrientation == 0) { // UP detector orientation @@ -1100,14 +1156,14 @@ int ADTimePix::pelIndex(int i, int j) { } else { int ROWS = 0, COLS = 0, xChips = 0, yChips = 0, Pel = 0; rowsCols(&ROWS, &COLS, &xChips, &yChips, &Pel); - if (xChips * yChips != 8 || Pel <= 0 || X_CHIP >= xChips || Y_CHIP >= yChips) { - WARN("pelIndex: 8-chip geometry mismatch (expect xChips*yChips==8)"); + if (xChips != 4 || yChips != 2 || Pel <= 0 || + X_CHIP >= xChips || Y_CHIP >= yChips) { + WARN("pelIndex: two-quad mapping requires a 4x2 chip grid"); + index = -1; + } else if (!ADTimePix3Tpx3DualQuad::imageToBpc( + i, j, Pel, index)) { + WARN("pelIndex: invalid two-quad image coordinate"); index = -1; - } else { - int lx = i - X_CHIP * Pel; - int ly = j - Y_CHIP * Pel; - int chipIdx = Y_CHIP * xChips + X_CHIP; - index = chipIdx * Pel * Pel + lx + ((Pel - 1) - ly) * Pel; } } } else { diff --git a/tpx3App/src/serval_http.cpp b/tpx3App/src/serval_http.cpp index 4c308dec..6b093286 100644 --- a/tpx3App/src/serval_http.cpp +++ b/tpx3App/src/serval_http.cpp @@ -1277,6 +1277,16 @@ asynStatus ADTimePix::refreshPixelConfigFromServal() { setParamStatus(0, ADTimePixPixelConfigDiff, asynSuccess); doCallbacksInt32Array(pixelConfigDiff_.data(), ncb, ADTimePixPixelConfigDiff, 0); + if (status == asynSuccess) { + setStringParam(ADTimePixWriteMsg, + haveBpc ? "PixelConfig refresh complete." + : "PixelConfig refresh complete; BPC comparison skipped."); + } else { + setStringParam(ADTimePixWriteMsg, + "PixelConfig refresh completed with one or more chip errors."); + } + callParamCallbacks(); + return status; } diff --git a/tpx3App/src/tpx3_dual_quad_mapping.cpp b/tpx3App/src/tpx3_dual_quad_mapping.cpp new file mode 100644 index 00000000..6573e851 --- /dev/null +++ b/tpx3App/src/tpx3_dual_quad_mapping.cpp @@ -0,0 +1,125 @@ +/* + * ADTimePix3 - TPX3 two-quad 4x2 BPC/image mapping + * + * Copyright (c) 2026 UT-Battelle, LLC, Oak Ridge National Laboratory + * + * SPDX-License-Identifier: MIT + */ + +#include "tpx3_dual_quad_mapping.h" + +#include + +namespace ADTimePix3Tpx3DualQuad { +namespace { + +bool quadBpcToImage(int chip, int localX, int localY, int width, + int& imageX, int& imageY) +{ + switch (chip) { + case 0: + imageX = width + localX; + imageY = 2 * width - 1 - localY; + return true; + case 1: + imageX = 2 * width - 1 - localX; + imageY = localY; + return true; + case 2: + imageX = width - 1 - localX; + imageY = localY; + return true; + case 3: + imageX = localX; + imageY = 2 * width - 1 - localY; + return true; + default: + return false; + } +} + +bool quadImageToBpc(int imageX, int imageY, int width, int& localIndex) +{ + if (imageX < 0 || imageX >= 2 * width || + imageY < 0 || imageY >= 2 * width) { + return false; + } + + int chip = 0; + int localX = 0; + int localY = 0; + if (imageX >= width && imageY >= width) { + chip = 0; + localX = imageX - width; + localY = 2 * width - 1 - imageY; + } else if (imageX >= width) { + chip = 1; + localX = 2 * width - 1 - imageX; + localY = imageY; + } else if (imageY < width) { + chip = 2; + localX = width - 1 - imageX; + localY = imageY; + } else { + chip = 3; + localX = imageX; + localY = 2 * width - 1 - imageY; + } + localIndex = chip * width * width + localX + localY * width; + return true; +} + +} // namespace + +bool bpcToImage(int bpcIndex, int chipWidth, int& imageX, int& imageY) +{ + if (bpcIndex < 0 || chipWidth <= 0) return false; + const std::int64_t chipPixels = + static_cast(chipWidth) * chipWidth; + const std::int64_t totalPixels = 8 * chipPixels; + if (bpcIndex >= totalPixels) return false; + + const int chip = static_cast(bpcIndex / chipPixels); + const int local = static_cast(bpcIndex % chipPixels); + const int localX = local % chipWidth; + const int localY = local / chipWidth; + int quadX = 0; + int quadY = 0; + if (!quadBpcToImage(chip % 4, localX, localY, + chipWidth, quadX, quadY)) { + return false; + } + + if (chip < 4) { + imageX = quadX; + imageY = quadY; + } else { + imageX = 4 * chipWidth - 1 - quadX; + imageY = 2 * chipWidth - 1 - quadY; + } + return true; +} + +bool imageToBpc(int imageX, int imageY, int chipWidth, int& bpcIndex) +{ + if (chipWidth <= 0 || imageX < 0 || imageX >= 4 * chipWidth || + imageY < 0 || imageY >= 2 * chipWidth) { + return false; + } + + int quadX = imageX; + int quadY = imageY; + int chipOffset = 0; + if (imageX >= 2 * chipWidth) { + quadX = 4 * chipWidth - 1 - imageX; + quadY = 2 * chipWidth - 1 - imageY; + chipOffset = 4 * chipWidth * chipWidth; + } + + int localIndex = 0; + if (!quadImageToBpc(quadX, quadY, chipWidth, localIndex)) return false; + bpcIndex = chipOffset + localIndex; + return true; +} + +} // namespace ADTimePix3Tpx3DualQuad diff --git a/tpx3App/src/tpx3_dual_quad_mapping.h b/tpx3App/src/tpx3_dual_quad_mapping.h new file mode 100644 index 00000000..182a13f7 --- /dev/null +++ b/tpx3App/src/tpx3_dual_quad_mapping.h @@ -0,0 +1,27 @@ +/* + * ADTimePix3 - TPX3 two-quad 4x2 BPC/image mapping + * + * Copyright (c) 2026 UT-Battelle, LLC, Oak Ridge National Laboratory + * + * SPDX-License-Identifier: MIT + */ + +#ifndef ADTIMEPIX3_TPX3_DUAL_QUAD_MAPPING_H +#define ADTIMEPIX3_TPX3_DUAL_QUAD_MAPPING_H + +namespace ADTimePix3Tpx3DualQuad { + +/* + * Map two adjacent TPX3 2x2 quads into a four-across, two-down image: + * + * 2 1 | 4 7 + * 3 0 | 5 6 + * + * The right-hand quad is rotated 180 degrees relative to the left-hand quad. + */ +bool bpcToImage(int bpcIndex, int chipWidth, int& imageX, int& imageY); +bool imageToBpc(int imageX, int imageY, int chipWidth, int& bpcIndex); + +} // namespace ADTimePix3Tpx3DualQuad + +#endif diff --git a/vendor/tpx3/4x2/README.md b/vendor/tpx3/4x2/README.md new file mode 100644 index 00000000..59eacba6 --- /dev/null +++ b/vendor/tpx3/4x2/README.md @@ -0,0 +1,45 @@ +# TPX3 4x2 synthetic demo calibration + +This directory contains a deterministic **software/emulator fixture** for a +Timepix3 detector assembled from two adjacent 2x2 quads (four chips across and +two chips down, 1024x512 pixels). It is not detector-specific equalization data +and must not be treated as a qualified physical-hardware calibration. + +Files: + +- `tpx3-demo-4x2-synthetic.bpc`: 524288 bytes. Chips 0-3 are the four + 65536-byte blocks from `../2x2/tpx3-demo.bpc`; chips 4-7 repeat those blocks + in the same order. +- `tpx3-demo-4x2-synthetic.dacs`: unique `[Chip0]` through `[Chip7]` sections. + Chips 4-7 repeat the values from chips 0-3 respectively, with section names + changed to the final chip numbers. + +The two-quad UP image placement is: + +```text ++---+---+---+---+ +| 2 | 1 | 4 | 7 | ++---+---+---+---+ +| 3 | 0 | 5 | 6 | ++---+---+---+---+ +``` + +Generate the files or verify the checked-in copies from the repository root: + +```bash +python3 test/generate_tpx3_4x2_demo_calibration.py +python3 test/generate_tpx3_4x2_demo_calibration.py --check +``` + +Provenance and deterministic checksums: + +| File | SHA-256 | +|---|---| +| `../2x2/tpx3-demo.bpc` | `7c065b252dce0fc56aa991957eadaebfadb8c9e068072b17530dbb3775ef338b` | +| `../2x2/tpx3-demo.dacs` | `df3fba01f99d4080df518c6953ec4ebdfbf2974a6cb9a92d41adaf16687969a1` | +| `tpx3-demo-4x2-synthetic.bpc` | `6cd7f48d3833dc29934e0048ed611f4665efceed68c543dcf762a762277d474c` | +| `tpx3-demo-4x2-synthetic.dacs` | `00f08da4d06a876bf5d7d6286fd618da3d90810eeb552c29ba65e1c5f40aeec9` | + +Replace both synthetic files with calibration generated for the actual eight +chips before physical use. Confirm BPC chip order, image placement, and mask +alignment on hardware. diff --git a/vendor/tpx3/4x2/tpx3-demo-4x2-synthetic.bpc b/vendor/tpx3/4x2/tpx3-demo-4x2-synthetic.bpc new file mode 100644 index 00000000..d7f3cc3e Binary files /dev/null and b/vendor/tpx3/4x2/tpx3-demo-4x2-synthetic.bpc differ diff --git a/vendor/tpx3/4x2/tpx3-demo-4x2-synthetic.dacs b/vendor/tpx3/4x2/tpx3-demo-4x2-synthetic.dacs new file mode 100644 index 00000000..99d7d944 --- /dev/null +++ b/vendor/tpx3/4x2/tpx3-demo-4x2-synthetic.dacs @@ -0,0 +1,152 @@ +[Chip0] +Ibias_Preamp_ON:128 +Ibias_Preamp_OFF:8 +VPreamp_NCAS:128 +Ibias_Ikrum:10 +Vfbk:128 +Vthreshold_fine:216 +Vthreshold_coarse:6 +Ibias_DiscS1_ON:128 +Ibias_DiscS1_OFF:8 +Ibias_DiscS2_ON:128 +Ibias_DiscS2_OFF:8 +Ibias_PixelDAC:230 +Ibias_TPbufferIn:128 +Ibias_TPbufferOut:128 +VTP_coarse:128 +VTP_fine:256 +Ibias_CP_PLL:128 +PLL_Vcntrl:128 +[Chip1] +Ibias_Preamp_ON:128 +Ibias_Preamp_OFF:8 +VPreamp_NCAS:128 +Ibias_Ikrum:10 +Vfbk:128 +Vthreshold_fine:253 +Vthreshold_coarse:6 +Ibias_DiscS1_ON:128 +Ibias_DiscS1_OFF:8 +Ibias_DiscS2_ON:128 +Ibias_DiscS2_OFF:8 +Ibias_PixelDAC:247 +Ibias_TPbufferIn:128 +Ibias_TPbufferOut:128 +VTP_coarse:128 +VTP_fine:256 +Ibias_CP_PLL:128 +PLL_Vcntrl:128 +[Chip2] +Ibias_Preamp_ON:128 +Ibias_Preamp_OFF:8 +VPreamp_NCAS:128 +Ibias_Ikrum:10 +Vfbk:128 +Vthreshold_fine:221 +Vthreshold_coarse:6 +Ibias_DiscS1_ON:128 +Ibias_DiscS1_OFF:8 +Ibias_DiscS2_ON:128 +Ibias_DiscS2_OFF:8 +Ibias_PixelDAC:251 +Ibias_TPbufferIn:128 +Ibias_TPbufferOut:128 +VTP_coarse:128 +VTP_fine:256 +Ibias_CP_PLL:128 +PLL_Vcntrl:128 +[Chip3] +Ibias_Preamp_ON:128 +Ibias_Preamp_OFF:8 +VPreamp_NCAS:128 +Ibias_Ikrum:10 +Vfbk:128 +Vthreshold_fine:210 +Vthreshold_coarse:6 +Ibias_DiscS1_ON:128 +Ibias_DiscS1_OFF:8 +Ibias_DiscS2_ON:128 +Ibias_DiscS2_OFF:8 +Ibias_PixelDAC:215 +Ibias_TPbufferIn:128 +Ibias_TPbufferOut:128 +VTP_coarse:128 +VTP_fine:256 +Ibias_CP_PLL:128 +PLL_Vcntrl:128 +[Chip4] +Ibias_Preamp_ON:128 +Ibias_Preamp_OFF:8 +VPreamp_NCAS:128 +Ibias_Ikrum:10 +Vfbk:128 +Vthreshold_fine:216 +Vthreshold_coarse:6 +Ibias_DiscS1_ON:128 +Ibias_DiscS1_OFF:8 +Ibias_DiscS2_ON:128 +Ibias_DiscS2_OFF:8 +Ibias_PixelDAC:230 +Ibias_TPbufferIn:128 +Ibias_TPbufferOut:128 +VTP_coarse:128 +VTP_fine:256 +Ibias_CP_PLL:128 +PLL_Vcntrl:128 +[Chip5] +Ibias_Preamp_ON:128 +Ibias_Preamp_OFF:8 +VPreamp_NCAS:128 +Ibias_Ikrum:10 +Vfbk:128 +Vthreshold_fine:253 +Vthreshold_coarse:6 +Ibias_DiscS1_ON:128 +Ibias_DiscS1_OFF:8 +Ibias_DiscS2_ON:128 +Ibias_DiscS2_OFF:8 +Ibias_PixelDAC:247 +Ibias_TPbufferIn:128 +Ibias_TPbufferOut:128 +VTP_coarse:128 +VTP_fine:256 +Ibias_CP_PLL:128 +PLL_Vcntrl:128 +[Chip6] +Ibias_Preamp_ON:128 +Ibias_Preamp_OFF:8 +VPreamp_NCAS:128 +Ibias_Ikrum:10 +Vfbk:128 +Vthreshold_fine:221 +Vthreshold_coarse:6 +Ibias_DiscS1_ON:128 +Ibias_DiscS1_OFF:8 +Ibias_DiscS2_ON:128 +Ibias_DiscS2_OFF:8 +Ibias_PixelDAC:251 +Ibias_TPbufferIn:128 +Ibias_TPbufferOut:128 +VTP_coarse:128 +VTP_fine:256 +Ibias_CP_PLL:128 +PLL_Vcntrl:128 +[Chip7] +Ibias_Preamp_ON:128 +Ibias_Preamp_OFF:8 +VPreamp_NCAS:128 +Ibias_Ikrum:10 +Vfbk:128 +Vthreshold_fine:210 +Vthreshold_coarse:6 +Ibias_DiscS1_ON:128 +Ibias_DiscS1_OFF:8 +Ibias_DiscS2_ON:128 +Ibias_DiscS2_OFF:8 +Ibias_PixelDAC:215 +Ibias_TPbufferIn:128 +Ibias_TPbufferOut:128 +VTP_coarse:128 +VTP_fine:256 +Ibias_CP_PLL:128 +PLL_Vcntrl:128