Skip to content

Commit f3c926a

Browse files
committed
test mean-power metrics on tied array resampled voltage stream
1 parent 2256f0e commit f3c926a

15 files changed

Lines changed: 270 additions & 29 deletions

File tree

.pre-commit-config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,15 @@ repos:
5050
'numpy==2.3.5',
5151
'pandas-stubs==2.3.3.251219',
5252
'prometheus-client==0.24.0',
53+
'prometheus-api-client==0.7.2',
5354
'pyparsing==3.3.1',
5455
'pytest==9.0.3',
5556
'pytest-asyncio==1.4.0',
5657
'redis==7.1.0', # Indirect dependency of katsdptelstate
5758
'spead2==4.4.1',
5859
'types-decorator==5.2.0.20251101',
5960
'types-docutils==0.21.0.20250809',
61+
'types-python-dateutil==2.9.0.20260716',
6062
'types-six==1.17.0.20251009',
6163
'typing-extensions==4.15.0',
6264
]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ doc = [
6363

6464
test = [
6565
"async-solipsism>=0.6",
66-
"baseband",
6766
"katsdpsigproc[CUDA]",
6867
"matplotlib",
68+
"prometheus-api-client>=0.7.0",
6969
"pytest>=8",
7070
"pytest-asyncio>=1.4.0",
7171
"pytest-check>=1.3,<2.2.3", # Upper bound due to https://github.com/okken/pytest-check/issues/173

qualification/cbf.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@
3737
if TYPE_CHECKING:
3838
# This is only imported for type checkers, because importing at runtime
3939
# would create a cyclic dependency.
40-
from .recv import BaselineCorrelationProductsReceiver, TiedArrayChannelisedVoltageReceiver
40+
from .recv import (
41+
BaselineCorrelationProductsReceiver,
42+
TiedArrayChannelisedVoltageReceiver,
43+
TiedArrayResampledVoltageReceiver,
44+
)
4145

4246
logger = logging.getLogger(__name__)
4347
DEFAULT_MAX_DELAY = 1000000 # Around 0.5-1ms, depending on band. Increase if necessary
@@ -75,6 +79,7 @@ class CBFRemoteControl(CBFBase):
7579
# These are filled in by conftest.py.
7680
baseline_correlation_products_receiver: "BaselineCorrelationProductsReceiver | None" = None
7781
tied_array_channelised_voltage_receiver: "TiedArrayChannelisedVoltageReceiver | None" = None
82+
tied_array_resampled_voltage_receiver: "TiedArrayResampledVoltageReceiver | None" = None
7883

7984
@property
8085
def init_sensors(self) -> aiokatcp.SensorSet:

qualification/conftest.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,22 @@
3535
from katgpucbf.pytest_plugins.reporter import Reporter, custom_report_log
3636

3737
from .cbf import CBFCache, CBFRemoteControl, FailedCBF
38-
from .recv import DEFAULT_TIMEOUT, BaselineCorrelationProductsReceiver, TiedArrayChannelisedVoltageReceiver
38+
from .recv import (
39+
DEFAULT_TIMEOUT,
40+
BaselineCorrelationProductsReceiver,
41+
TiedArrayChannelisedVoltageReceiver,
42+
TiedArrayResampledVoltageReceiver,
43+
)
3944

4045
pytest_plugins = ["katgpucbf.pytest_plugins.numpy_dump", "katgpucbf.pytest_plugins.reporter_plugin"]
4146
logger = logging.getLogger(__name__)
4247
FULL_ANTENNAS = [1, 4, 8, 20, 32, 40, 64, 80]
4348
MAX_PASS_FRACTION = 0.7 # Maximum fraction of total narrowband bandwidth to use as pass_bandwidth
44-
_CAPTURE_TYPES = {"gpucbf.baseline_correlation_products", "gpucbf.tied_array_channelised_voltage"}
49+
_CAPTURE_TYPES = {
50+
"gpucbf.baseline_correlation_products",
51+
"gpucbf.tied_array_channelised_voltage",
52+
"gpucbf.tied_array_resampled_voltage",
53+
}
4554

4655
# Storing ini options this way makes pytest.ini easier to validate up-front.
4756
IniOption = namedtuple("IniOption", ["name", "help", "type", "default"], defaults=[None])
@@ -528,6 +537,7 @@ async def cbf(
528537
core_allocator: CoreAllocator,
529538
capture_start_streams: list[str],
530539
tied_array_channelised_voltage_receive_streams: list[str],
540+
vlbi: bool,
531541
pdf_report: Reporter,
532542
) -> AsyncGenerator[CBFRemoteControl, None]:
533543
"""Set up a CBF for a single test.
@@ -564,6 +574,13 @@ async def cbf(
564574
interface_address=interface_address,
565575
use_ibv=use_ibv,
566576
)
577+
if cbf.tied_array_resampled_voltage_receiver is None and vlbi:
578+
logger.info("Subscribing to tied-array-resampled-voltage")
579+
cbf.tied_array_resampled_voltage_receiver = TiedArrayResampledVoltageReceiver(
580+
cbf=cbf,
581+
stream_names=["tied-array-resampled-voltage"],
582+
interface_address=interface_address,
583+
)
567584

568585
# Reset the CBF to default state
569586
pcc = cbf.product_controller_client
@@ -582,6 +599,8 @@ async def cbf(
582599
await pcc.request("beam-quant-gains", name, 1.0)
583600
await pcc.request("beam-delays", name, *(("0:0",) * n_inputs))
584601
await pcc.request("beam-weights", name, *((1.0,) * n_inputs))
602+
elif conf["type"] == "gpucbf.tied_array_resampled_voltage":
603+
await pcc.request("vlbi-delay", name, "0.0")
585604

586605
for name in capture_start_streams:
587606
await pcc.request("capture-start", name)
@@ -648,3 +667,12 @@ async def receive_tied_array_channelised_voltage(
648667
):
649668
await receiver.wait_complete_chunk(max_delay=0, timeout=3 * DEFAULT_TIMEOUT)
650669
return receiver
670+
671+
672+
@pytest.fixture
673+
async def receive_tied_array_resampled_voltage(
674+
cbf: CBFRemoteControl,
675+
) -> TiedArrayResampledVoltageReceiver | None:
676+
"""Get the receiver for ingesting the tied-array-resampled-voltage streams."""
677+
receiver = cbf.tied_array_resampled_voltage_receiver
678+
return receiver

qualification/recv.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import ast
2020
import asyncio
2121
import ctypes
22+
import json
2223
import logging
2324
import math
2425
import os
@@ -45,6 +46,8 @@
4546
from .cbf import DEFAULT_MAX_DELAY, CBFRemoteControl
4647

4748
DEFAULT_TIMEOUT = 10.0
49+
IP_MULTICAST_ALL = 49
50+
4851
logger = logging.getLogger(__name__)
4952

5053

@@ -642,3 +645,23 @@ def chunk_place(data_ptr, data_size, user_data_ptr):
642645
sink=stream_group,
643646
),
644647
)
648+
649+
650+
class TiedArrayResampledVoltageReceiver:
651+
"""Receive tied-array-resampled-voltage streams from the V-engines."""
652+
653+
def __init__(
654+
self,
655+
cbf: CBFRemoteControl,
656+
stream_names: Sequence[str],
657+
interface_address: str,
658+
) -> None:
659+
self.stream_names = stream_names
660+
self.n_chans = cbf.init_sensors[f"{stream_names[0]}.n-chans"].value
661+
self.pol_ordering = json.loads(cbf.init_sensors[f"{stream_names[0]}.pol-ordering"].value.decode())
662+
self.n_threads = self.n_chans * len(self.pol_ordering)
663+
self.veng_out_bits_per_sample = cbf.init_sensors[f"{stream_names[0]}.veng-out-bits-per-sample"].value
664+
self.scale_factor_timestamp = cbf.init_sensors[f"{stream_names[0]}.scale-factor-timestamp"].value
665+
self.power_int_time = cbf.init_sensors[f"{stream_names[0]}.power-int-time"].value
666+
self.bandwidth = cbf.init_sensors[f"{stream_names[0]}.bandwidth"].value
667+
self.sync_time: float = cbf.init_sensors[f"{stream_names[0]}.sync-time"].value

qualification/report/generate_pdf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
GROUP_NAMES = {
6969
"general": "General tests",
7070
"antenna_channelised_voltage": "Antenna channelised voltage tests",
71+
"tied_array_resampled_voltage": "Tied-array resampled voltage tests",
7172
"baseline_correlation_products": "Baseline correlation products tests",
7273
"tied_array_channelised_voltage": "Tied-array channelised voltage tests",
7374
"demo": "Report demonstration tests",

qualification/requirements.txt

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ dask==2025.12.0
9191
# -c qualification/../requirements-dev.txt
9292
# -c qualification/../requirements.txt
9393
# katgpucbf (pyproject.toml)
94-
dateparser==1.2.2
95-
# via prometheus-api-client
94+
dateparser==1.4.1
95+
# via
96+
# -c qualification/../requirements-dev.txt
97+
# prometheus-api-client
9698
decorator==5.2.1
9799
# via
98100
# -c qualification/../requirements-dev.txt
@@ -267,8 +269,10 @@ pluggy==1.6.0
267269
# via
268270
# -c qualification/../requirements-dev.txt
269271
# pytest
270-
prometheus-api-client==0.7.0
271-
# via katgpucbf (pyproject.toml)
272+
prometheus-api-client==0.7.2
273+
# via
274+
# -c qualification/../requirements-dev.txt
275+
# katgpucbf (pyproject.toml)
272276
prometheus-async==26.1.0
273277
# via
274278
# -c qualification/../requirements-dev.txt
@@ -362,8 +366,10 @@ redis==7.1.0
362366
# -c qualification/../requirements-dev.txt
363367
# -c qualification/../requirements.txt
364368
# katsdptelstate
365-
regex==2026.1.15
366-
# via dateparser
369+
regex==2026.7.19
370+
# via
371+
# -c qualification/../requirements-dev.txt
372+
# dateparser
367373
requests==2.33.0
368374
# via
369375
# -c qualification/../requirements-dev.txt
@@ -423,8 +429,10 @@ tzdata==2025.3
423429
# -c qualification/../requirements-dev.txt
424430
# -c qualification/../requirements.txt
425431
# pandas
426-
tzlocal==5.3.1
427-
# via dateparser
432+
tzlocal==5.4.4
433+
# via
434+
# -c qualification/../requirements-dev.txt
435+
# dateparser
428436
urllib3==2.7.0
429437
# via
430438
# -c qualification/../requirements-dev.txt
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# noqa: D104
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
################################################################################
2+
# Copyright (c) 2026, National Research Foundation (SARAO)
3+
#
4+
# Licensed under the BSD 3-Clause License (the "License"); you may not use
5+
# this file except in compliance with the License. You may obtain a copy
6+
# of the License at
7+
#
8+
# https://opensource.org/licenses/BSD-3-Clause
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
################################################################################
16+
17+
"""Fixtures and options for testing of tied-array-resampled-voltage streams."""
18+
19+
import pytest
20+
21+
_vlbi_only = pytest.mark.vlbi_only
22+
23+
24+
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
25+
"""Apply vlbi_only before parametrisation in the parent conftest."""
26+
metafunc.definition.add_marker(_vlbi_only)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
################################################################################
2+
# Copyright (c) 2026, National Research Foundation (SARAO)
3+
#
4+
# Licensed under the BSD 3-Clause License (the "License"); you may not use
5+
# this file except in compliance with the License. You may obtain a copy
6+
# of the License at
7+
#
8+
# https://opensource.org/licenses/BSD-3-Clause
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
################################################################################
16+
17+
"""Sample test for tied-array-resampled-voltage stream."""
18+
19+
import asyncio
20+
from collections.abc import AsyncGenerator
21+
22+
import aiokatcp
23+
import numpy as np
24+
import pytest
25+
from pytest_check import check
26+
27+
from katgpucbf.pytest_plugins.reporter import Reporter
28+
from katgpucbf.utils import TimeConverter
29+
from qualification.cbf import CBFRemoteControl
30+
31+
from ..recv import TiedArrayChannelisedVoltageReceiver, TiedArrayResampledVoltageReceiver
32+
33+
34+
@pytest.fixture
35+
async def sensor_watcher(cbf: CBFRemoteControl) -> AsyncGenerator[aiokatcp.SensorWatcher, None]:
36+
"""Establish a secondary connection to the product controller with a sensor watcher.
37+
38+
The yielded sensor watcher is not yet synchronised.
39+
"""
40+
# aiokatcp doesn't currently handle adding watchers after the connection
41+
# is already established; SensorWatcher is also somewhat expensive. So
42+
# instead we create a separate connection for monitoring sensors.
43+
secondary = aiokatcp.Client(*cbf.product_controller_endpoint)
44+
sensor_watcher = aiokatcp.SensorWatcher(secondary)
45+
secondary.add_sensor_watcher(sensor_watcher)
46+
47+
yield sensor_watcher
48+
49+
secondary.close()
50+
await secondary.wait_closed()
51+
52+
53+
@pytest.mark.name("VLBI mean power")
54+
async def test_mean_power(
55+
pdf_report: Reporter,
56+
receive_tied_array_resampled_voltage: TiedArrayResampledVoltageReceiver | None,
57+
receive_tied_array_channelised_voltage: TiedArrayChannelisedVoltageReceiver,
58+
cbf: CBFRemoteControl,
59+
sensor_watcher: aiokatcp.SensorWatcher,
60+
pass_channels: slice,
61+
) -> None:
62+
"""Test mean-power sensor values against tied-array channelised voltage.
63+
64+
Verification method
65+
-------------------
66+
Verified by means of test. Inject a white noise signal and set beam weights
67+
so that a single antenna contributes. Wait until each ``mean-power`` sensor
68+
timestamp is after the system steady-state timestamp (plus one
69+
``power-int-time`` so the averaging window is entirely post-steady-state).
70+
Measure mean power from the tied-array channelised voltage stream over the
71+
passband channels, and compare against each ``mean-power`` sensor.
72+
"""
73+
assert receive_tied_array_resampled_voltage is not None
74+
receiver = receive_tied_array_resampled_voltage
75+
pcc = cbf.product_controller_client
76+
77+
pdf_report.step("Setup signal generator and gains.")
78+
async with asyncio.TaskGroup() as tg:
79+
for i, name in enumerate(receive_tied_array_channelised_voltage.stream_names):
80+
gains = [0.0] * len(receive_tied_array_channelised_voltage.source_indices[i])
81+
gains[0] = 1.0
82+
tg.create_task(pcc.request("beam-weights", name, *gains))
83+
pdf_report.detail(f"Set beam weights to {gains}.")
84+
85+
async with asyncio.TaskGroup() as tg:
86+
for dsim_name in cbf.dsim_names:
87+
tg.create_task(pcc.request("dsim-signals", dsim_name, "common=wgn(0.02);common;common;"))
88+
pdf_report.detail("Set dsim signals white noise.")
89+
90+
pdf_report.step("Wait for mean-power sensors to reach steady state.")
91+
await sensor_watcher.synced.wait() # Implicitly waits for connection too
92+
time_converter = TimeConverter(receiver.sync_time, receiver.scale_factor_timestamp)
93+
steady_state_unix = time_converter.adc_to_unix(await cbf.steady_state_timestamp())
94+
# Require a full power-int-time of data after steady state so the sensor
95+
# average does not include pre-change samples.
96+
min_sensor_time = steady_state_unix + receiver.power_int_time
97+
98+
sensor_names = [
99+
f"{receiver.stream_names[0]}.{pol}{chan}.mean-power"
100+
for pol in receiver.pol_ordering
101+
for chan in range(receiver.n_chans)
102+
]
103+
104+
async def wait_mean_power_steady_state() -> None:
105+
while True:
106+
timestamps = [sensor_watcher.sensors[name].timestamp for name in sensor_names]
107+
earliest = min(timestamps)
108+
if earliest >= min_sensor_time:
109+
pdf_report.detail("Mean-power sensors reached steady state timestamp.")
110+
break
111+
await asyncio.sleep(0.5)
112+
113+
await asyncio.wait_for(asyncio.create_task(wait_mean_power_steady_state()), timeout=15.0)
114+
115+
pdf_report.step("Measure power from tied-array channelised voltage.")
116+
_, tacv_data = await receive_tied_array_channelised_voltage.next_complete_chunk()
117+
tacv_data = tacv_data.astype(np.float64).view(np.complex128)[..., 0] # Convert to complex128
118+
# Only use the pass channels for beam zero for the power calculation.
119+
tacv_data = tacv_data[0][pass_channels]
120+
tacv_power = (np.square(tacv_data.real) + np.square(tacv_data.imag)).mean()
121+
pdf_report.detail(f"Mean TACV power over passband channels: {tacv_power}.")
122+
# Test that we aren't accidentally testing zero values:
123+
assert tacv_power > 0.0
124+
125+
pdf_report.step("Compare mean-power sensors against TACV power.")
126+
for sensor_name in sensor_names:
127+
sensor = sensor_watcher.sensors[sensor_name]
128+
with check:
129+
assert sensor.timestamp >= min_sensor_time
130+
assert sensor.value == pytest.approx(tacv_power, rel=5e-3), (
131+
f"TACV power ^2: {tacv_power} does not match total theta^2: {sensor.value}"
132+
+ f" for sensor {sensor_name}"
133+
)
134+
pdf_report.detail("Power agrees to within 0.5%.")

0 commit comments

Comments
 (0)