Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a8187138f |
+76
-27
@@ -18,7 +18,9 @@ import numpy as np
|
|||||||
import scipy.fft as scipy_fft
|
import scipy.fft as scipy_fft
|
||||||
import scipy.ndimage as scipy_ndimage
|
import scipy.ndimage as scipy_ndimage
|
||||||
|
|
||||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile, adc_to_mv
|
from sras_format import (
|
||||||
|
CH1_IDX, CH3_IDX, CH4_IDX, PAD_FACTOR_MAX, SrasFile, adc_to_mv,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# FFT backend
|
# FFT backend
|
||||||
@@ -420,6 +422,56 @@ def dc_image_mv(sras: SrasFile, angle_idx: int, ch_idx: int,
|
|||||||
*sras.cal(ch_idx))
|
*sras.cal(ch_idx))
|
||||||
|
|
||||||
|
|
||||||
|
def cached_rf_image(sras: SrasFile, angle_idx: int,
|
||||||
|
dc_threshold_mv: float | None,
|
||||||
|
apply_bg_sub: bool = True,
|
||||||
|
n_fft: int | None = None,
|
||||||
|
dc4_mv: np.ndarray | None = None,
|
||||||
|
allow_dc_recompute: bool = True) -> np.ndarray | None:
|
||||||
|
"""The stored peak-frequency image for one angle (v5 PREC or v7 CACH),
|
||||||
|
masked and ready to display — or None if no stored image can serve these
|
||||||
|
settings and a real FFT is needed.
|
||||||
|
|
||||||
|
Stored images are unmasked, so they serve any threshold — but they carry
|
||||||
|
one bg-sub state and one zero-padding, and a view asking for either of the
|
||||||
|
others is asking for different numbers, not a different rendering of the
|
||||||
|
same ones. Padding is the sharp edge: a pad-10 view resolves peaks on a
|
||||||
|
10x finer bin grid, so handing back a pad-1 image would quietly undo the
|
||||||
|
setting. v5 PREC and CACH v1 tails record no pad and are natural
|
||||||
|
resolution by construction.
|
||||||
|
|
||||||
|
Cheap enough to call on the GUI thread — a copy plus a comparison —
|
||||||
|
*except* when the CH4 mask has to be read from disk to build it. Pass
|
||||||
|
*allow_dc_recompute* False to return None in that case instead, and leave
|
||||||
|
the whole-channel read to a worker.
|
||||||
|
"""
|
||||||
|
cached_freq = sras.precomputed_freq_mhz[angle_idx]
|
||||||
|
# n_fft None means "no padding", i.e. exactly samples_per_frame points.
|
||||||
|
want_n_fft = n_fft if n_fft is not None else sras.samples_per_frame
|
||||||
|
if (cached_freq is None
|
||||||
|
or want_n_fft != sras.precomputed_pad_factor * sras.samples_per_frame
|
||||||
|
or sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if dc_threshold_mv is None:
|
||||||
|
return cached_freq.copy()
|
||||||
|
|
||||||
|
# DC4 mask, in priority order: stored DC block, caller-supplied image,
|
||||||
|
# or a fresh (cheap — no FFT) recompute.
|
||||||
|
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
|
||||||
|
if dc4_img is None:
|
||||||
|
dc4_img = dc4_mv
|
||||||
|
if dc4_img is None:
|
||||||
|
if not allow_dc_recompute:
|
||||||
|
return None
|
||||||
|
dc4_img = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX),
|
||||||
|
*sras.cal(CH4_IDX))
|
||||||
|
|
||||||
|
freq_img = cached_freq.copy()
|
||||||
|
freq_img[dc4_img < dc_threshold_mv] = 0.0
|
||||||
|
return freq_img
|
||||||
|
|
||||||
|
|
||||||
def compute_rf_image(sras: SrasFile, angle_idx: int,
|
def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||||||
dc_threshold_mv: float | None,
|
dc_threshold_mv: float | None,
|
||||||
apply_bg_sub: bool = True,
|
apply_bg_sub: bool = True,
|
||||||
@@ -455,28 +507,20 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
|||||||
|
|
||||||
Fast path: if the file has a precomputed peak-frequency image for this
|
Fast path: if the file has a precomputed peak-frequency image for this
|
||||||
angle (v5 PREC or v7 CACH), zero-padding is off, and the bg-sub flag
|
angle (v5 PREC or v7 CACH), zero-padding is off, and the bg-sub flag
|
||||||
matches, the stored image is used directly — no FFT is run.
|
matches, the stored image is used directly — no FFT is run. That test is
|
||||||
|
cached_rf_image, which the viewer also applies before it ever dispatches
|
||||||
|
a compute at all.
|
||||||
"""
|
"""
|
||||||
n_rows, n_frames = sras.image_shape(angle_idx)
|
|
||||||
data = sras.data[angle_idx]
|
|
||||||
|
|
||||||
# ---- Fast path: precomputed image (v5 PREC or v7 CACH) ----------------
|
# ---- Fast path: precomputed image (v5 PREC or v7 CACH) ----------------
|
||||||
cached_freq = sras.precomputed_freq_mhz[angle_idx]
|
cached = cached_rf_image(sras, angle_idx, dc_threshold_mv,
|
||||||
if (cached_freq is not None
|
apply_bg_sub=apply_bg_sub, n_fft=n_fft,
|
||||||
and n_fft is None # no custom zero-padding
|
dc4_mv=dc4_mv)
|
||||||
and sras.precomputed_bg_sub == (apply_bg_sub and sras.background is not None)):
|
if cached is not None:
|
||||||
freq_img = cached_freq.copy()
|
return cached
|
||||||
if dc_threshold_mv is not None:
|
|
||||||
# DC4 mask, in priority order: already-cached DC block, caller-
|
|
||||||
# supplied image, or a fresh (cheap — no FFT) recompute.
|
|
||||||
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
|
|
||||||
if dc4_img is None:
|
|
||||||
dc4_img = dc4_mv if dc4_mv is not None else adc_to_mv(
|
|
||||||
compute_dc_image(sras, angle_idx, CH4_IDX), *sras.cal(CH4_IDX))
|
|
||||||
freq_img[dc4_img < dc_threshold_mv] = 0.0
|
|
||||||
return freq_img
|
|
||||||
|
|
||||||
# ---- Chunked FFT path --------------------------------------------------
|
# ---- Chunked FFT path --------------------------------------------------
|
||||||
|
n_rows, n_frames = sras.image_shape(angle_idx)
|
||||||
|
data = sras.data[angle_idx]
|
||||||
exact = exact or _FFT_EXACT_ENV
|
exact = exact or _FFT_EXACT_ENV
|
||||||
spf = sras.samples_per_frame
|
spf = sras.samples_per_frame
|
||||||
n_len = n_fft if n_fft is not None else spf
|
n_len = n_fft if n_fft is not None else spf
|
||||||
@@ -589,17 +633,19 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||||
fft_backend: str = "scipy", max_workers: int = 0) -> str:
|
fft_backend: str = "scipy", max_workers: int = 0,
|
||||||
|
pad_factor: int = 1) -> str:
|
||||||
"""Compute and store DC or FFT images for every angle of one file,
|
"""Compute and store DC or FFT images for every angle of one file,
|
||||||
converting v6 → v7 in place. Returns "" on success or an error message.
|
converting v6 → v7 in place. Returns "" on success or an error message.
|
||||||
|
|
||||||
Module-level and picklable so it can run in a ProcessPoolExecutor. The
|
Module-level and picklable so it can run in a ProcessPoolExecutor. The
|
||||||
FFT backend and worker cap are passed explicitly because module globals
|
FFT backend, worker cap and pad factor are passed explicitly because
|
||||||
do not survive a spawn.
|
module globals do not survive a spawn.
|
||||||
|
|
||||||
The stored FFT cache is always natural-resolution (pad 1): the v7 SFFT
|
*pad_factor* is stored alongside the images: a cache is only usable by a
|
||||||
block records no pad factor, and padded views compute live fast enough
|
view asking for the same padding, so caching at the viewer's own setting
|
||||||
(see _peak_bins_zoom) that caching them is not worth a format change.
|
is the difference between a batch that pays off and one that can never be
|
||||||
|
read back (see cached_rf_image).
|
||||||
"""
|
"""
|
||||||
global _MAX_WORKERS
|
global _MAX_WORKERS
|
||||||
try:
|
try:
|
||||||
@@ -630,15 +676,18 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
|||||||
sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4)
|
sras.write_v7_cache(new_dc3_mv=dc3, new_dc4_mv=dc4)
|
||||||
else:
|
else:
|
||||||
effective_bg = apply_bg_sub and sras.background is not None
|
effective_bg = apply_bg_sub and sras.background is not None
|
||||||
|
pad = max(1, min(PAD_FACTOR_MAX, int(pad_factor)))
|
||||||
|
n_fft = None if pad <= 1 else sras.samples_per_frame * pad
|
||||||
# dc_threshold_mv=None: store unmasked images and mask at display
|
# dc_threshold_mv=None: store unmasked images and mask at display
|
||||||
# time (same convention as v5's PREC block). Skipping the mask
|
# time (same convention as v5's PREC block). Skipping the mask
|
||||||
# also skips reading CH4 entirely. The FFT path parallelises
|
# also skips reading CH4 entirely. The FFT path parallelises
|
||||||
# internally over blocks, so angles run one at a time with the
|
# internally over blocks, so angles run one at a time with the
|
||||||
# full budget.
|
# full budget.
|
||||||
freq = [compute_rf_image(sras, a, dc_threshold_mv=None,
|
freq = [compute_rf_image(sras, a, dc_threshold_mv=None,
|
||||||
apply_bg_sub=effective_bg)
|
apply_bg_sub=effective_bg, n_fft=n_fft)
|
||||||
for a in range(n)]
|
for a in range(n)]
|
||||||
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg)
|
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg,
|
||||||
|
new_pad_factor=pad)
|
||||||
return ""
|
return ""
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return str(exc)
|
return str(exc)
|
||||||
|
|||||||
+42
-13
@@ -60,7 +60,12 @@ PREC_FLAG_BG_SUB = 0x01
|
|||||||
CACH_MAGIC = b"CACH"
|
CACH_MAGIC = b"CACH"
|
||||||
CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags
|
CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags
|
||||||
CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT)
|
CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT)
|
||||||
CACH_VERSION = 1
|
# v2 added the SFFT pad factor. Writers always emit v2; readers still accept
|
||||||
|
# v1, whose FFT block is natural-resolution by construction. An older reader
|
||||||
|
# meeting a v2 tail rejects the whole section on the version check and
|
||||||
|
# recomputes — no cache, but no misreading either.
|
||||||
|
CACH_VERSION = 2
|
||||||
|
CACH_VERSION_MIN = 1
|
||||||
CACH_FLAG_DC = 0x01
|
CACH_FLAG_DC = 0x01
|
||||||
CACH_FLAG_FFT = 0x02
|
CACH_FLAG_FFT = 0x02
|
||||||
|
|
||||||
@@ -69,9 +74,12 @@ SDCB_HDR_FMT = ">4sBH" # magic, reserved, n_stored
|
|||||||
SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
|
SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
|
||||||
|
|
||||||
SFFT_MAGIC = b"SFFT"
|
SFFT_MAGIC = b"SFFT"
|
||||||
SFFT_HDR_FMT = ">4sBH" # magic, flags, n_stored
|
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored
|
||||||
|
SFFT_HDR_FMT = ">4sBHH" # magic, flags, n_stored, pad_factor
|
||||||
SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT)
|
SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT)
|
||||||
SFFT_FLAG_BG_SUB = 0x01
|
SFFT_FLAG_BG_SUB = 0x01
|
||||||
|
# The viewer clamps its pad factor to this, and the field is a uint16.
|
||||||
|
PAD_FACTOR_MAX = 256
|
||||||
|
|
||||||
# Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC)
|
# Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC)
|
||||||
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
|
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
|
||||||
@@ -169,7 +177,9 @@ class SrasFile:
|
|||||||
``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``,
|
``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``,
|
||||||
always as ragged per-angle lists (``list[np.ndarray | None]``, one entry
|
always as ragged per-angle lists (``list[np.ndarray | None]``, one entry
|
||||||
per angle, ``None`` where that angle was never stored) regardless of
|
per angle, ``None`` where that angle was never stored) regardless of
|
||||||
source version.
|
source version. The settings the FFT images were computed under travel
|
||||||
|
with them as ``precomputed_bg_sub`` / ``precomputed_pad_factor``, since a
|
||||||
|
stored image is only usable by a view asking for the same two.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, path: str):
|
def __init__(self, path: str):
|
||||||
@@ -226,6 +236,9 @@ class SrasFile:
|
|||||||
self.precomputed_dc4_mv: list[np.ndarray | None] = [None] * n_angles
|
self.precomputed_dc4_mv: list[np.ndarray | None] = [None] * n_angles
|
||||||
self.precomputed_dc3_mv: list[np.ndarray | None] = [None] * n_angles
|
self.precomputed_dc3_mv: list[np.ndarray | None] = [None] * n_angles
|
||||||
self.precomputed_bg_sub: bool = False
|
self.precomputed_bg_sub: bool = False
|
||||||
|
# Zero-padding the stored FFT images were computed at; 1 = natural
|
||||||
|
# resolution, which is all a v5 PREC or CACH v1 tail can hold.
|
||||||
|
self.precomputed_pad_factor: int = 1
|
||||||
|
|
||||||
def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
|
def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
|
||||||
"""A stored DC image (already in mV) for (angle, channel), or None."""
|
"""A stored DC image (already in mV) for (angle, channel), or None."""
|
||||||
@@ -496,16 +509,18 @@ class SrasFile:
|
|||||||
return end
|
return end
|
||||||
|
|
||||||
def _read_cache_block(self, f, hdr_fmt: str, magic: bytes,
|
def _read_cache_block(self, f, hdr_fmt: str, magic: bytes,
|
||||||
stores: list[list]) -> int | None:
|
stores: list[list]) -> tuple | None:
|
||||||
"""Read one CACH sub-block header, then its per-angle image entries
|
"""Read one CACH sub-block header, then its per-angle image entries
|
||||||
into *stores* (one list per image the block stores per angle).
|
into *stores* (one list per image the block stores per angle).
|
||||||
|
|
||||||
Returns the header's flags byte, or None if the block is malformed.
|
Every sub-block header is (magic, flags, n_stored, *extras). Returns
|
||||||
|
everything after the magic, so a caller that has extras knows how to
|
||||||
|
read them, or None if the block is malformed.
|
||||||
"""
|
"""
|
||||||
raw = f.read(struct.calcsize(hdr_fmt))
|
raw = f.read(struct.calcsize(hdr_fmt))
|
||||||
if len(raw) < struct.calcsize(hdr_fmt):
|
if len(raw) < struct.calcsize(hdr_fmt):
|
||||||
return None
|
return None
|
||||||
block_magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
|
block_magic, flags, n_stored, *extras = struct.unpack(hdr_fmt, raw)
|
||||||
if block_magic != magic:
|
if block_magic != magic:
|
||||||
return None
|
return None
|
||||||
for _ in range(n_stored):
|
for _ in range(n_stored):
|
||||||
@@ -515,7 +530,7 @@ class SrasFile:
|
|||||||
shape = self.image_shape(angle_idx)
|
shape = self.image_shape(angle_idx)
|
||||||
for store in stores:
|
for store in stores:
|
||||||
store[angle_idx] = _read_f32_image(f, shape)
|
store[angle_idx] = _read_f32_image(f, shape)
|
||||||
return flags
|
return (flags, *extras)
|
||||||
|
|
||||||
def _parse_cach_section(self, offset: int):
|
def _parse_cach_section(self, offset: int):
|
||||||
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
|
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
|
||||||
@@ -525,7 +540,8 @@ class SrasFile:
|
|||||||
if len(header_raw) < CACH_HDR_SIZE:
|
if len(header_raw) < CACH_HDR_SIZE:
|
||||||
return
|
return
|
||||||
magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw)
|
magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw)
|
||||||
if magic != CACH_MAGIC or cach_version != CACH_VERSION:
|
if (magic != CACH_MAGIC
|
||||||
|
or not CACH_VERSION_MIN <= cach_version <= CACH_VERSION):
|
||||||
return
|
return
|
||||||
|
|
||||||
if block_flags & CACH_FLAG_DC:
|
if block_flags & CACH_FLAG_DC:
|
||||||
@@ -535,17 +551,23 @@ class SrasFile:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if block_flags & CACH_FLAG_FFT:
|
if block_flags & CACH_FLAG_FFT:
|
||||||
flags = self._read_cache_block(
|
# v1 has no pad field: those images are natural-resolution.
|
||||||
f, SFFT_HDR_FMT, SFFT_MAGIC, [self.precomputed_freq_mhz])
|
hdr_fmt = SFFT_HDR_FMT if cach_version >= 2 else SFFT_HDR_FMT_V1
|
||||||
if flags is None:
|
header = self._read_cache_block(
|
||||||
|
f, hdr_fmt, SFFT_MAGIC, [self.precomputed_freq_mhz])
|
||||||
|
if header is None:
|
||||||
return
|
return
|
||||||
|
flags, *extras = header
|
||||||
self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB)
|
self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB)
|
||||||
|
self.precomputed_pad_factor = (
|
||||||
|
min(max(1, extras[0]), PAD_FACTOR_MAX) if extras else 1)
|
||||||
|
|
||||||
def write_v7_cache(self, *,
|
def write_v7_cache(self, *,
|
||||||
new_dc3_mv: list[np.ndarray | None] | None = None,
|
new_dc3_mv: list[np.ndarray | None] | None = None,
|
||||||
new_dc4_mv: list[np.ndarray | None] | None = None,
|
new_dc4_mv: list[np.ndarray | None] | None = None,
|
||||||
new_freq_mhz: list[np.ndarray | None] | None = None,
|
new_freq_mhz: list[np.ndarray | None] | None = None,
|
||||||
new_bg_sub: bool | None = None):
|
new_bg_sub: bool | None = None,
|
||||||
|
new_pad_factor: int | None = None):
|
||||||
"""Store computed DC and/or FFT images into this file's CACH tail,
|
"""Store computed DC and/or FFT images into this file's CACH tail,
|
||||||
in place, converting a v6 source to v7 (or updating an existing v7
|
in place, converting a v6 source to v7 (or updating an existing v7
|
||||||
file). Only the block(s) passed in are recomputed; whichever block
|
file). Only the block(s) passed in are recomputed; whichever block
|
||||||
@@ -565,6 +587,11 @@ class SrasFile:
|
|||||||
final_dc4 = new_dc4_mv if new_dc4_mv is not None else self.precomputed_dc4_mv
|
final_dc4 = new_dc4_mv if new_dc4_mv is not None else self.precomputed_dc4_mv
|
||||||
final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz
|
final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz
|
||||||
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
|
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
|
||||||
|
final_pad = (new_pad_factor if new_pad_factor is not None
|
||||||
|
else self.precomputed_pad_factor)
|
||||||
|
if not 1 <= final_pad <= PAD_FACTOR_MAX:
|
||||||
|
raise ValueError(
|
||||||
|
f"pad factor {final_pad} outside 1..{PAD_FACTOR_MAX}")
|
||||||
|
|
||||||
dc_entries = [a for a in range(self.n_angles) if final_dc3[a] is not None]
|
dc_entries = [a for a in range(self.n_angles) if final_dc3[a] is not None]
|
||||||
fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None]
|
fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None]
|
||||||
@@ -584,7 +611,8 @@ class SrasFile:
|
|||||||
|
|
||||||
if fft_entries:
|
if fft_entries:
|
||||||
fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0
|
fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0
|
||||||
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, len(fft_entries))
|
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags,
|
||||||
|
len(fft_entries), final_pad)
|
||||||
for a in fft_entries:
|
for a in fft_entries:
|
||||||
payload += struct.pack(">H", a)
|
payload += struct.pack(">H", a)
|
||||||
payload += final_freq[a].astype(">f4").tobytes()
|
payload += final_freq[a].astype(">f4").tobytes()
|
||||||
@@ -612,6 +640,7 @@ class SrasFile:
|
|||||||
self.precomputed_dc4_mv = final_dc4
|
self.precomputed_dc4_mv = final_dc4
|
||||||
self.precomputed_freq_mhz = final_freq
|
self.precomputed_freq_mhz = final_freq
|
||||||
self.precomputed_bg_sub = final_bg_sub
|
self.precomputed_bg_sub = final_bg_sub
|
||||||
|
self.precomputed_pad_factor = final_pad
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Axes helpers
|
# Axes helpers
|
||||||
|
|||||||
+18
-1
@@ -48,7 +48,8 @@ class FftOptionsDialog(QDialog):
|
|||||||
current_pad_factor: int,
|
current_pad_factor: int,
|
||||||
samples_per_frame: int | None,
|
samples_per_frame: int | None,
|
||||||
sample_rate_hz: float | None,
|
sample_rate_hz: float | None,
|
||||||
grating_um: float):
|
grating_um: float,
|
||||||
|
cached_pad_factor: int | None = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("FFT Options")
|
self.setWindowTitle("FFT Options")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
@@ -57,6 +58,10 @@ class FftOptionsDialog(QDialog):
|
|||||||
self._samples_per_frame = samples_per_frame
|
self._samples_per_frame = samples_per_frame
|
||||||
self._sample_rate_hz = sample_rate_hz
|
self._sample_rate_hz = sample_rate_hz
|
||||||
self._grating_um = grating_um
|
self._grating_um = grating_um
|
||||||
|
# The pad the open file's stored FFT cache was computed at, if it has
|
||||||
|
# one — picking anything else here makes that cache unreadable, which
|
||||||
|
# is worth saying before Apply rather than after.
|
||||||
|
self._cached_pad_factor = cached_pad_factor
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
@@ -109,6 +114,11 @@ class FftOptionsDialog(QDialog):
|
|||||||
lbl.setStyleSheet(_CSS_HINT)
|
lbl.setStyleSheet(_CSS_HINT)
|
||||||
zl.addWidget(lbl)
|
zl.addWidget(lbl)
|
||||||
|
|
||||||
|
self._lbl_cache = QLabel()
|
||||||
|
self._lbl_cache.setWordWrap(True)
|
||||||
|
self._lbl_cache.setStyleSheet(_CSS_WARN)
|
||||||
|
zl.addWidget(self._lbl_cache)
|
||||||
|
|
||||||
layout.addWidget(grp_zp)
|
layout.addWidget(grp_zp)
|
||||||
|
|
||||||
# ---- Buttons ---------------------------------------------------
|
# ---- Buttons ---------------------------------------------------
|
||||||
@@ -126,6 +136,13 @@ class FftOptionsDialog(QDialog):
|
|||||||
sr = self._sample_rate_hz
|
sr = self._sample_rate_hz
|
||||||
pad = self._spin_pad.value()
|
pad = self._spin_pad.value()
|
||||||
|
|
||||||
|
self._lbl_cache.setText(
|
||||||
|
"" if self._cached_pad_factor in (None, pad) else
|
||||||
|
f"! This file's stored FFT cache was computed at pad "
|
||||||
|
f"{self._cached_pad_factor}x, so at {pad}x it can't be used and "
|
||||||
|
f"CH1/Velocity will recompute. Re-run Convert -> Batch Compute FFT "
|
||||||
|
f"to cache at {pad}x.")
|
||||||
|
|
||||||
if spf is None or sr is None:
|
if spf is None or sr is None:
|
||||||
self._lbl_nfft.setText("Load a file to preview FFT parameters.")
|
self._lbl_nfft.setText("Load a file to preview FFT parameters.")
|
||||||
self._lbl_freq_res.setText("")
|
self._lbl_freq_res.setText("")
|
||||||
|
|||||||
+101
-9
@@ -79,11 +79,15 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
|
|
||||||
# Display-only settings (colormap, grating) never trigger a
|
# Display-only settings (colormap, grating) never trigger a
|
||||||
# recompute — they're applied to cached data on redraw. DC images
|
# recompute — they're applied to cached data on redraw. DC images
|
||||||
# (CH3/CH4) are cheap and precomputed for every angle in the
|
# (CH3/CH4) are cheap: they come straight from the file's stored
|
||||||
# background right after load. CH1/Velocity FFT images are
|
# cache when Batch Compute DC has been run for it (see
|
||||||
# computed lazily (with a progress popup) the first time an
|
# _stored_dc_image), and are otherwise precomputed for every angle
|
||||||
# angle/threshold combination is viewed — using the cached DC4
|
# in the background right after load. CH1/Velocity FFT images come
|
||||||
# image to skip the FFT entirely for masked-out pixels — and
|
# from the file's own stored cache when Batch Compute FFT has been
|
||||||
|
# run for it (see _stored_fft_image), and are otherwise computed
|
||||||
|
# lazily (with a progress popup) the first time an angle/threshold
|
||||||
|
# combination is viewed — using the cached DC4 image to skip the
|
||||||
|
# FFT entirely for masked-out pixels. Either way the result is
|
||||||
# cached per (angle, bg_sub, n_fft, threshold) so revisiting the
|
# cached per (angle, bg_sub, n_fft, threshold) so revisiting the
|
||||||
# same combination is free.
|
# same combination is free.
|
||||||
self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
|
self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
|
||||||
@@ -219,7 +223,14 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self.spin_angle.setRange(0, 0)
|
self.spin_angle.setRange(0, 0)
|
||||||
self.spin_angle.setEnabled(False)
|
self.spin_angle.setEnabled(False)
|
||||||
self.spin_angle.setMinimumWidth(64)
|
self.spin_angle.setMinimumWidth(64)
|
||||||
self.spin_angle.editingFinished.connect(self._on_view_changed)
|
# Keyboard tracking off is what makes valueChanged safe to act on: it
|
||||||
|
# stops the signal firing per keystroke, so typing "12" is one change
|
||||||
|
# to angle 12 (on Return or focus-out) rather than a trip to angle 1
|
||||||
|
# first. Arrow clicks and the wheel still emit immediately, which
|
||||||
|
# editingFinished alone did not — that is why stepping the angle used
|
||||||
|
# to leave the plot on the previous one until the box lost focus.
|
||||||
|
self.spin_angle.setKeyboardTracking(False)
|
||||||
|
self.spin_angle.valueChanged.connect(self._on_view_changed)
|
||||||
self.lbl_angle_deg = QLabel("—")
|
self.lbl_angle_deg = QLabel("—")
|
||||||
angle_field = QWidget()
|
angle_field = QWidget()
|
||||||
ar = QHBoxLayout(angle_field)
|
ar = QHBoxLayout(angle_field)
|
||||||
@@ -598,12 +609,32 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
n_fft = sum(1 for x in s.precomputed_freq_mhz if x is not None)
|
n_fft = sum(1 for x in s.precomputed_freq_mhz if x is not None)
|
||||||
if n_dc or n_fft:
|
if n_dc or n_fft:
|
||||||
bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)"
|
bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)"
|
||||||
|
pad_note = (f", pad {s.precomputed_pad_factor}x"
|
||||||
|
if n_fft and s.precomputed_pad_factor > 1 else "")
|
||||||
notes.append(
|
notes.append(
|
||||||
f"Cached images: DC {n_dc}/{s.n_angles} angles, "
|
f"Cached images: DC {n_dc}/{s.n_angles} angles, "
|
||||||
f"FFT {n_fft}/{s.n_angles} angles{bg_note if n_fft else ''} "
|
f"FFT {n_fft}/{s.n_angles} angles"
|
||||||
|
f"{bg_note + pad_note if n_fft else ''} "
|
||||||
"— display is instant for cached angles")
|
"— display is instant for cached angles")
|
||||||
elif s.version == 7:
|
elif s.version == 7:
|
||||||
notes.append("v7 format: no cache blocks stored yet")
|
notes.append("v7 format: no cache blocks stored yet")
|
||||||
|
|
||||||
|
# A stored FFT cache the current settings can't read back is the one
|
||||||
|
# failure here with no other symptom: the images are right there in
|
||||||
|
# the file and every angle still recomputes. Say why.
|
||||||
|
if n_fft:
|
||||||
|
reasons = []
|
||||||
|
if s.precomputed_pad_factor != self._fft_pad_factor:
|
||||||
|
reasons.append(f"stored at pad {s.precomputed_pad_factor}x, "
|
||||||
|
f"FFT Options is set to {self._fft_pad_factor}x")
|
||||||
|
if s.precomputed_bg_sub != (self.chk_bg_sub.isChecked()
|
||||||
|
and s.background is not None):
|
||||||
|
reasons.append("stored with bg-sub "
|
||||||
|
f"{'on' if s.precomputed_bg_sub else 'off'}")
|
||||||
|
if reasons:
|
||||||
|
notes.append(f"! Cached FFT unusable as configured ({'; '.join(reasons)})"
|
||||||
|
" — CH1/Velocity will recompute. Re-run Convert -> "
|
||||||
|
"Batch Compute FFT to cache at the current settings.")
|
||||||
self.lbl_frame_warn.setText("\n".join(notes))
|
self.lbl_frame_warn.setText("\n".join(notes))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -656,6 +687,9 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
# Background subtraction changes the FFT input, so it genuinely
|
# Background subtraction changes the FFT input, so it genuinely
|
||||||
# invalidates the cached raw FFT (the cache key includes it) —
|
# invalidates the cached raw FFT (the cache key includes it) —
|
||||||
# _refresh_display() recomputes only on a miss for the new state.
|
# _refresh_display() recomputes only on a miss for the new state.
|
||||||
|
# It can also decide whether the file's stored cache is readable, so
|
||||||
|
# the info panel's verdict on that has to be redrawn with it.
|
||||||
|
self._update_scan_info_labels()
|
||||||
if self._is_fft_mode():
|
if self._is_fft_mode():
|
||||||
self._refresh_display()
|
self._refresh_display()
|
||||||
|
|
||||||
@@ -838,6 +872,14 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
return None
|
return None
|
||||||
return self._sras.samples_per_frame * self._fft_pad_factor
|
return self._sras.samples_per_frame * self._fft_pad_factor
|
||||||
|
|
||||||
|
def _cached_fft_pad_factor(self) -> int | None:
|
||||||
|
"""The pad factor the open file's stored FFT images were computed at,
|
||||||
|
or None if it has none stored."""
|
||||||
|
if self._sras is None or not any(x is not None
|
||||||
|
for x in self._sras.precomputed_freq_mhz):
|
||||||
|
return None
|
||||||
|
return self._sras.precomputed_pad_factor
|
||||||
|
|
||||||
def _is_fft_mode(self) -> bool:
|
def _is_fft_mode(self) -> bool:
|
||||||
"""Is the selected channel an FFT-derived (CH1/Velocity) mode?"""
|
"""Is the selected channel an FFT-derived (CH1/Velocity) mode?"""
|
||||||
return (self._sras is not None
|
return (self._sras is not None
|
||||||
@@ -877,6 +919,45 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._aligned_cache[key] = cached
|
self._aligned_cache[key] = cached
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
def _stored_fft_image(self, angle_idx: int) -> np.ndarray | None:
|
||||||
|
"""The file's own batch-computed FFT image for this angle, masked for
|
||||||
|
the current threshold — or None if it can't serve the current settings.
|
||||||
|
|
||||||
|
Batch Compute FFT stores a peak-frequency image per angle in the file
|
||||||
|
itself, and the whole point of paying for that once is that display is
|
||||||
|
then instant. Without this check the viewer only ever found the stored
|
||||||
|
image deep inside ComputeWorker, so every angle change after a batch
|
||||||
|
still dispatched a background job behind a "Computing FFT…" popup for
|
||||||
|
an image that was already sitting on disk.
|
||||||
|
"""
|
||||||
|
img = compute.cached_rf_image(
|
||||||
|
self._sras, angle_idx,
|
||||||
|
dc_threshold_mv=self.spin_threshold_mv.value(),
|
||||||
|
apply_bg_sub=self.chk_bg_sub.isChecked(),
|
||||||
|
n_fft=self._current_n_fft(),
|
||||||
|
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
|
||||||
|
# Masking is a copy plus a comparison, but *reading* CH4 to build
|
||||||
|
# the mask is a whole-channel read — that case belongs on a worker,
|
||||||
|
# so it returns None here and falls through to _start_compute.
|
||||||
|
allow_dc_recompute=False)
|
||||||
|
if img is not None:
|
||||||
|
self._fft_cache[self._fft_cache_key(angle_idx)] = img
|
||||||
|
return img
|
||||||
|
|
||||||
|
def _stored_dc_image(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
|
||||||
|
"""The file's own batch-computed DC image for (angle, channel), if it
|
||||||
|
has one — the CH3/CH4 half of _stored_fft_image.
|
||||||
|
|
||||||
|
The worker this saves would have handed back the very same stored
|
||||||
|
array (dc_image_mv prefers it over recomputing), so all it ever cost
|
||||||
|
was a thread and a progress popup — but that is exactly what made
|
||||||
|
"Batch Compute DC and Store" look like it had done nothing.
|
||||||
|
"""
|
||||||
|
img = self._sras.cached_dc_mv(angle_idx, ch_idx)
|
||||||
|
if img is not None:
|
||||||
|
self._dc_cache[(angle_idx, ch_idx)] = img
|
||||||
|
return img
|
||||||
|
|
||||||
def _refresh_display(self):
|
def _refresh_display(self):
|
||||||
"""Show the image for the current angle/channel/threshold, using
|
"""Show the image for the current angle/channel/threshold, using
|
||||||
cached data whenever possible and only falling back to a background
|
cached data whenever possible and only falling back to a background
|
||||||
@@ -888,12 +969,16 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
|
|
||||||
if ch_idx in CH1_DERIVED_MODES:
|
if ch_idx in CH1_DERIVED_MODES:
|
||||||
raw = self._fft_cache.get(self._fft_cache_key(angle_idx))
|
raw = self._fft_cache.get(self._fft_cache_key(angle_idx))
|
||||||
|
if raw is None:
|
||||||
|
raw = self._stored_fft_image(angle_idx)
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
self._show_image_now(self._scale_for_display(raw, ch_idx),
|
self._show_image_now(self._scale_for_display(raw, ch_idx),
|
||||||
angle_idx, ch_idx)
|
angle_idx, ch_idx)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
cached = self._dc_cache.get((angle_idx, ch_idx))
|
cached = self._dc_cache.get((angle_idx, ch_idx))
|
||||||
|
if cached is None:
|
||||||
|
cached = self._stored_dc_image(angle_idx, ch_idx)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
self._show_image_now(cached, angle_idx, ch_idx)
|
self._show_image_now(cached, angle_idx, ch_idx)
|
||||||
return
|
return
|
||||||
@@ -1163,7 +1248,11 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._batch_errors = []
|
self._batch_errors = []
|
||||||
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked())
|
# Cache at the pad factor currently configured, not a fixed pad 1 —
|
||||||
|
# a cache stored at any other padding is one this viewer can never
|
||||||
|
# read back (see _stored_fft_image).
|
||||||
|
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked(),
|
||||||
|
self._fft_pad_factor)
|
||||||
started = self._run_worker(
|
started = self._run_worker(
|
||||||
Jobs.BATCH, worker,
|
Jobs.BATCH, worker,
|
||||||
connect=(
|
connect=(
|
||||||
@@ -1338,6 +1427,7 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
samples_per_frame=self._sras.samples_per_frame if self._sras else None,
|
samples_per_frame=self._sras.samples_per_frame if self._sras else None,
|
||||||
sample_rate_hz=self._sras.sample_rate_hz if self._sras else None,
|
sample_rate_hz=self._sras.sample_rate_hz if self._sras else None,
|
||||||
grating_um=self.spin_grating_um.value(),
|
grating_um=self.spin_grating_um.value(),
|
||||||
|
cached_pad_factor=self._cached_fft_pad_factor(),
|
||||||
)
|
)
|
||||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
@@ -1347,7 +1437,9 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._settings.setValue("fft/pad_factor", self._fft_pad_factor)
|
self._settings.setValue("fft/pad_factor", self._fft_pad_factor)
|
||||||
# Pad factor changes the FFT bin count, so it genuinely invalidates
|
# Pad factor changes the FFT bin count, so it genuinely invalidates
|
||||||
# the cached raw FFT (part of the cache key) — _refresh_display()
|
# the cached raw FFT (part of the cache key) — _refresh_display()
|
||||||
# recomputes only on a cache miss.
|
# recomputes only on a cache miss. It also decides whether the file's
|
||||||
|
# stored cache can be read back at all, which the info panel reports.
|
||||||
|
self._update_scan_info_labels()
|
||||||
if self._is_fft_mode():
|
if self._is_fft_mode():
|
||||||
self._refresh_display()
|
self._refresh_display()
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -189,7 +189,10 @@ class BatchCacheWorker(QObject):
|
|||||||
|
|
||||||
*mode* is ``"dc"`` (CH3/CH4 mean images) or ``"fft"`` (CH1 peak-frequency
|
*mode* is ``"dc"`` (CH3/CH4 mean images) or ``"fft"`` (CH1 peak-frequency
|
||||||
images, unmasked — masking is applied at display time, same as v5's PREC
|
images, unmasked — masking is applied at display time, same as v5's PREC
|
||||||
convention).
|
convention). FFT images are computed and stored at *pad_factor*, the
|
||||||
|
viewer's own zero-padding setting: a stored image only serves a view
|
||||||
|
asking for the same padding, so caching at any other one produces a file
|
||||||
|
the viewer will never read back.
|
||||||
|
|
||||||
Files are processed one per subprocess: they are fully independent, each
|
Files are processed one per subprocess: they are fully independent, each
|
||||||
opens its own memmap and writes only its own bytes, and only path strings
|
opens its own memmap and writes only its own bytes, and only path strings
|
||||||
@@ -201,11 +204,13 @@ class BatchCacheWorker(QObject):
|
|||||||
file_done = pyqtSignal(str, str)
|
file_done = pyqtSignal(str, str)
|
||||||
finished = pyqtSignal()
|
finished = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool):
|
def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool,
|
||||||
|
pad_factor: int = 1):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._paths = paths
|
self._paths = paths
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self._apply_bg_sub = apply_bg_sub
|
self._apply_bg_sub = apply_bg_sub
|
||||||
|
self._pad_factor = pad_factor
|
||||||
|
|
||||||
def _report(self, path: str, err: str, done: int, total: int):
|
def _report(self, path: str, err: str, done: int, total: int):
|
||||||
self.file_done.emit(path, err)
|
self.file_done.emit(path, err)
|
||||||
@@ -230,7 +235,8 @@ class BatchCacheWorker(QObject):
|
|||||||
with ProcessPoolExecutor(max_workers=n_procs) as executor:
|
with ProcessPoolExecutor(max_workers=n_procs) as executor:
|
||||||
futures = {
|
futures = {
|
||||||
executor.submit(cache_file, p, self._mode, self._apply_bg_sub,
|
executor.submit(cache_file, p, self._mode, self._apply_bg_sub,
|
||||||
compute.get_fft_backend(), per_proc_workers): p
|
compute.get_fft_backend(), per_proc_workers,
|
||||||
|
self._pad_factor): p
|
||||||
for p in paths
|
for p in paths
|
||||||
}
|
}
|
||||||
for fut in as_completed(futures):
|
for fut in as_completed(futures):
|
||||||
@@ -254,7 +260,8 @@ class BatchCacheWorker(QObject):
|
|||||||
try:
|
try:
|
||||||
err = cache_file(path, self._mode, self._apply_bg_sub,
|
err = cache_file(path, self._mode, self._apply_bg_sub,
|
||||||
compute.get_fft_backend(),
|
compute.get_fft_backend(),
|
||||||
compute.default_max_workers())
|
compute.default_max_workers(),
|
||||||
|
self._pad_factor)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
err = str(exc)
|
err = str(exc)
|
||||||
done += 1
|
done += 1
|
||||||
|
|||||||
+46
-1
@@ -94,11 +94,18 @@ def test_dc_precompute_all_angles(ctx):
|
|||||||
|
|
||||||
|
|
||||||
def test_angle_switching_from_cache(ctx):
|
def test_angle_switching_from_cache(ctx):
|
||||||
|
"""Setting the angle must redraw on its own — no nudge from the test.
|
||||||
|
|
||||||
|
The spinbox used to be wired to editingFinished alone, so stepping it with
|
||||||
|
the arrows changed the number and left the plot on the previous angle until
|
||||||
|
the box happened to lose focus.
|
||||||
|
"""
|
||||||
win, s = ctx.win, ctx.s
|
win, s = ctx.win, ctx.s
|
||||||
for a in range(s.n_angles):
|
for a in range(s.n_angles):
|
||||||
win.spin_angle.setValue(a)
|
win.spin_angle.setValue(a)
|
||||||
win._on_view_changed()
|
|
||||||
pump(60)
|
pump(60)
|
||||||
|
assert win._current_angle == a, \
|
||||||
|
f"angle {a} redrawn from the spinbox alone, showing {win._current_angle}"
|
||||||
expected = win._sras.image_shape(a)
|
expected = win._sras.image_shape(a)
|
||||||
assert win._current_image.shape == expected, \
|
assert win._current_image.shape == expected, \
|
||||||
f"angle {a} shows its own geometry {expected}, got {win._current_image.shape}"
|
f"angle {a} shows its own geometry {expected}, got {win._current_image.shape}"
|
||||||
@@ -106,6 +113,44 @@ def test_angle_switching_from_cache(ctx):
|
|||||||
"no compute job needed for cached DC angles"
|
"no compute job needed for cached DC angles"
|
||||||
|
|
||||||
|
|
||||||
|
def test_angle_steps_and_typing(ctx):
|
||||||
|
"""The arrows step and redraw one angle at a time; typing commits once,
|
||||||
|
without a stop at every intermediate value."""
|
||||||
|
win, s = ctx.win, ctx.s
|
||||||
|
assert s.n_angles > 2, "fixture needs several angles"
|
||||||
|
win.spin_angle.setValue(0)
|
||||||
|
pump(60)
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
win.spin_angle.valueChanged.connect(seen.append)
|
||||||
|
try:
|
||||||
|
win.spin_angle.stepUp()
|
||||||
|
pump(60)
|
||||||
|
assert win._current_angle == 1, f"arrow stepped to 1, showing {win._current_angle}"
|
||||||
|
win.spin_angle.stepDown()
|
||||||
|
pump(60)
|
||||||
|
assert win._current_angle == 0, f"arrow stepped to 0, showing {win._current_angle}"
|
||||||
|
|
||||||
|
# Typing must not redraw per keystroke — that is what editingFinished
|
||||||
|
# bought and what keyboard tracking has to keep buying.
|
||||||
|
seen.clear()
|
||||||
|
last = s.n_angles - 1
|
||||||
|
win.spin_angle.lineEdit().selectAll()
|
||||||
|
QTest.keyClicks(win.spin_angle.lineEdit(), str(last))
|
||||||
|
pump(30)
|
||||||
|
assert seen == [] and win._current_angle == 0, \
|
||||||
|
f"half-typed text must not redraw ({seen}, showing {win._current_angle})"
|
||||||
|
QTest.keyClick(win.spin_angle, Qt.Key.Key_Return)
|
||||||
|
pump(60)
|
||||||
|
assert seen == [last], f"committed exactly once on Return: {seen}"
|
||||||
|
assert win._current_angle == last
|
||||||
|
finally:
|
||||||
|
win.spin_angle.valueChanged.disconnect(seen.append)
|
||||||
|
win.spin_angle.clearFocus()
|
||||||
|
win.spin_angle.setValue(0)
|
||||||
|
pump(60)
|
||||||
|
|
||||||
|
|
||||||
def test_channel_switching(ctx):
|
def test_channel_switching(ctx):
|
||||||
win = ctx.win
|
win = ctx.win
|
||||||
win.spin_angle.setValue(0)
|
win.spin_angle.setValue(0)
|
||||||
|
|||||||
Reference in New Issue
Block a user