diff --git a/README.md b/README.md
index 6dfd3762..9ac2ca7d 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,13 @@
-# PQN Stack
+# pqn-node
-**Software stack for Public Quantum Network (PQN) nodes**
+**FastAPI node service for the Public Quantum Network (PQN)**
[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
-A distributed node based approach to quantum networks. This repository hosts all the code necessary to make the backend of nodes of the PQN function. For the frontend, see [here](https://github.com/PublicQuantumNetwork/pqn-gui).
+Runs a PQN node: exposes the FastAPI routes used by the web UI, coordinates protocols between nodes, and orchestrates hardware through [`pqn-hardware`](https://github.com/PublicQuantumNetwork/pqn-hardware). Frontend lives in [pqn-gui](https://github.com/PublicQuantumNetwork/pqn-gui).
+Hardware drivers, ZMQ messaging, and instrument protocols were extracted into [`pqn-hardware`](https://github.com/PublicQuantumNetwork/pqn-hardware) so that hardware and node work can evolve independently; `pqn-node` pulls it in as a git-pinned dependency.
@@ -25,12 +26,12 @@ A distributed node based approach to quantum networks. This repository hosts all
PQN web interface for monitoring and controlling quantum network nodes
-Our Node is composed of multiple components. All components inside a node are part of an in internal intranet with no external world access except for quantum links to other hardware or the _Node API_.
+A Node's components share an internal intranet with no external access except for quantum links to other hardware or the _Node API_.
-* **Node API**: FastAPI based, handles communications with web-ui as well as Node to Node communication. Only component in a Node than can talk to other components and the outside world. Resides in [src/pqnstack/app/main.py](https://github.com/PublicQuantumNetwork/pqn-stack/blob/master/src/pqnstack/app/main.py). See the [FastAPI docs](https://fastapi.tiangolo.com/deployment/) for more options on how to run the API.
-* **Lightweight Web UI**: Designed for the general public to be able to interact with quantum networks. Resides in its own repository [here](https://github.com/PublicQuantumNetwork/pqn-gui).
-* **Router**: Routes messages between _Hardware Providers_, PQN developers and _Node APIs_. Uses ZMQ sockets to communicate between machines. Resides in [src/pqnstack/network/router.py](https://github.com/PublicQuantumNetwork/pqn-stack/blob/master/src/pqnstack/network/router.py).
-* **Hardware Provider**: Hosts hardware resources that are provided to whoever needs them inside a Node through the use of ProxyInstruments. Resides in [src/pqnstack/network/instrument_provider.py](https://github.com/PublicQuantumNetwork/pqn-stack/blob/master/src/pqnstack/network/instrument_provider.py).
+* **Node API** (this repo): FastAPI service that handles web-UI and node-to-node communication. The only component in a Node that can talk to other components and the outside world. Entry point: `src/pqn_node/main.py`. See the [FastAPI docs](https://fastapi.tiangolo.com/deployment/) for deployment options.
+* **Lightweight Web UI**: For the general public to interact with quantum networks. Lives at [pqn-gui](https://github.com/PublicQuantumNetwork/pqn-gui).
+* **Router** (in `pqn-hardware`): Routes ZMQ messages between _Hardware Providers_, developers, and _Node APIs_.
+* **Hardware Provider** (in `pqn-hardware`): Hosts hardware resources and exposes them through ProxyInstruments.
## Quick Start
@@ -45,40 +46,32 @@ Our Node is composed of multiple components. All components inside a node are pa
### Installation
-1. **Clone the repository**
-
- ```bash
- git clone https://github.com/PublicQuantumNetwork/pqn-stack.git
- cd pqn-stack
- ```
-
-2. **Install dependencies**
+```bash
+git clone https://github.com/PublicQuantumNetwork/pqn-node.git
+cd pqn-node
+uv sync
+```
- To run the fastapi backend for node operations, use:
- ```bash
- uv sync --extra webapp
- ```
+`uv sync` fetches `pqn-hardware` at the pinned commit from its GitHub repo.
### Start a Node
-To fully start a PQN Node, you need to initialize 4 different processes:
+To fully start a PQN Node, four processes are typically needed:
-* **PQN API**
-* **Router**
-* **Hardware provider** (optional)
+* **PQN API** (this repo)
+* **Router** (from `pqn-hardware`)
+* **Hardware provider** (from `pqn-hardware`, optional)
* **Web GUI** (optional)
### Set up the PQN API
-
-
#### Config file
-Before starting a Node API, you need to set up a configuration file for the Node:
+Before starting a Node API, set up a configuration file:
1. **Copy the example configuration:**
```bash
- cp configs/config_app_example.toml config.toml
+ cp configs/config_example.toml config.toml
```
> [!IMPORTANT]
@@ -87,55 +80,42 @@ Before starting a Node API, you need to set up a configuration file for the Node
2. **Edit the configuration:**
Open `config.toml` in your editor and replace the placeholder values with your actual settings (router addresses, instrument names, etc.).
-
### Configure Router and Hardware Provider
-For the first computer on the PQN, both a router and hardware provider are needed. For subsequent computers added to the same node, only a hardware provider is needed.
-
-Both the Router and Hardware Provider can be configured using a config file. (Alternatively you could use CLI flags for quick tests.)
-
-Create a TOML configuration file for the router and hardware provider (see example in [configs/config_messaging_example.toml](https://github.com/PublicQuantumNetwork/pqn-stack/blob/master/configs/config_messaging_example.toml)). The config file can contain settings for both router and provider:
-- Router settings go under `[router]`
-- Provider settings go under `[provider]` with instruments defined as `[[provider.instruments]]`
+Router and provider live in the `pqn-hardware` package. See [pqn-hardware's README](https://github.com/PublicQuantumNetwork/pqn-hardware#quick-start) for their config format. On the first computer on the PQN, both a router and a provider are needed; subsequent computers only need a provider.
Start the router:
```bash
-uv run pqn start-router --config configs/config_messaging_example.toml
+uv run pqn-hw start-router --config configs/router_provider.toml
```
-Start the Hardware Provider:
+Start the hardware provider:
```bash
-uv run pqn start-provider --config configs/config_messaging_example.toml
+uv run pqn-hw start-provider --config configs/router_provider.toml
```
-**Alternative method using CLI flags:** - Pass configuration directly as command-line arguments
+### Start the PQN API server
-Start the Router with CLI flags:
```bash
-uv run pqn start-router --name router1 --host localhost --port 5555
+uv run fastapi run src/pqn_node/main.py
```
-Start the Instrument Provider with CLI flags:
-```bash
-uv run pqn start-provider \
- --name provider1 \
- --router-name router1 \
- --instruments '{"dummy1": {"import": "pqnstack.pqn.drivers.dummies.DummyInstrument", "desc": "Test Instrument", "hw_address": "123456"}}'
-```
+Browse protocols at http://127.0.0.1:8000/docs.
-### Start the PQN API server
+### Daily report
+
+Run or schedule the Slack health-report digest:
```bash
-uv run fastapi run src/pqnstack/app/main.py
+uv run pqn-node daily-report run
+uv run pqn-node daily-report schedule
```
-To see the list of all the protocols that can be run, go to http://127.0.0.1:8000/docs.
-
### Install the Web GUI
-For instructions on how install and start the web GUI please see the repo where it lives at [https://github.com/PublicQuantumNetwork/pqn-gui](https://github.com/PublicQuantumNetwork/pqn-gui)
+See [pqn-gui](https://github.com/PublicQuantumNetwork/pqn-gui) for install and start instructions.
## Acknowledgements
@@ -143,4 +123,4 @@ The Public Quantum Network is supported in part by NSF Quantum Leap Challenge In
## Have questions?
-Feel free to contact the PQN team at publicquantumnetwork@gmail.com.
+Contact the PQN team at publicquantumnetwork@gmail.com.
diff --git a/configs/config_app_example.toml b/configs/config_example.toml
similarity index 100%
rename from configs/config_app_example.toml
rename to configs/config_example.toml
diff --git a/configs/config_messaging_example.toml b/configs/config_messaging_example.toml
deleted file mode 100644
index 53709a41..00000000
--- a/configs/config_messaging_example.toml
+++ /dev/null
@@ -1,23 +0,0 @@
-[router]
-name = "pqnstack-router"
-host = "localhost"
-port = 5556
-
-[provider]
-name = "pqnstack-provider"
-router_name = "pqnstack-router"
-host = "localhost"
-port = 5556
-beat_period = 2000
-
-[[provider.instruments]]
-name = "dummy1"
-import = "pqnstack.pqn.drivers.dummies.DummyInstrument"
-desc = "Dummy instrument1 for testing purposes"
-hw_address = "1234"
-
-[[provider.instruments]]
-name = "dummy2"
-import = "pqnstack.pqn.drivers.dummies.DummyInstrument"
-desc = "Dummy instrument2 for testing purposes"
-hw_address = "1234"
diff --git a/pyproject.toml b/pyproject.toml
index 9219e95f..9929745b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,9 +4,9 @@ build-backend = "uv_build"
[project]
-name = "pqnstack"
+name = "pqn-node"
version = "0.2.0"
-description = "Illinois Computes work on software stack for PQN."
+description = "FastAPI node service for the Public Quantum Network."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
@@ -16,19 +16,18 @@ authors = [
{ name = "Soroush Hoseini", email = "soroushhoseini0@gmail.com" },
]
dependencies = [
- "numpy>=2.3.5",
- "pyfirmata2>=2.5.0",
- "pyzmq>=26.2.0",
- "thorlabs-apt-device>=0.3.8",
- "tomli-w>=1.0.0",
- "typer>=0.15.1",
+ "pqn-hardware @ git+https://github.com/PublicQuantumNetwork/pqn-hardware.git@6440be2830fddccb09b461caea023c84b7c3bea1",
"fastapi[standard]>=0.115.14",
"httpx>=0.28.1",
+ "pydantic>=2.0",
"pydantic-settings>=2.10.1",
+ "pyserial>=3.5",
+ "tomli-w>=1.0.0",
+ "typer>=0.15.1",
]
[project.scripts]
-pqn = "pqnstack.cli:app"
+pqn-node = "pqn_node.cli:app"
[dependency-groups]
diff --git a/scripts/coincidence_gui b/scripts/coincidence_gui
deleted file mode 100755
index 4fa4aec0..00000000
--- a/scripts/coincidence_gui
+++ /dev/null
@@ -1,140 +0,0 @@
-#!/usr/bin/env python
-# /// script
-# requires-python = ">=3.13"
-# dependencies = [
-# "pqnstack",
-# "pyqt5",
-# "pyqtgraph",
-# ]
-#
-# [tool.uv.sources]
-# pqnstack = { path = "../" }
-# ///
-
-import sys
-from typing import Any
-
-import numpy as np
-import pyqtgraph as pg
-from PyQt5 import QtCore
-from PyQt5 import QtWidgets
-
-from pqnstack.base.instrument import Instrument
-from pqnstack.network.client import Client
-
-
-class Worker(QtCore.QObject):
- data_ready = QtCore.pyqtSignal(list, int)
-
- def __init__(self, device: Instrument, parent: Any = None) -> None:
- super().__init__(parent)
- self.device = device
- self.active_channels: list[int] = []
- self._channels_for_coincidence = 2
-
- @QtCore.pyqtSlot()
- def measure(self) -> None:
- channels = list(range(1, 11))
- singles = self.device.count_singles(channels, integration_time_s=5)
- for c in range(1, 11):
- if c not in self.active_channels:
- singles[c - 1] = 0
- coincidence = 0
- if len(self.active_channels) == self._channels_for_coincidence:
- coincidence = 100 * self.device.measure_correlation(
- self.active_channels[0], self.active_channels[1], integration_time_s=5, binwidth_ps=500
- )
- self.data_ready.emit(singles, coincidence)
-
-
-class DataViewerGUI(QtWidgets.QWidget):
- def __init__(self, device: Instrument, parent: Any = None):
- super().__init__(parent)
- self.device = device
- self.main_layout = QtWidgets.QHBoxLayout(self)
- self.coin_plot = pg.PlotWidget()
- self.coin_plot.showGrid(x=True, y=True)
- self.coin_plot.setLabel("left", "Count Rate")
- self.coin_plot.setLabel("bottom", "Time Steps (oldest on the left, newest on the right)")
- self.coin_plot.addLegend()
- self.main_layout.addWidget(self.coin_plot, stretch=5)
- self.control_widget = QtWidgets.QWidget()
- self.control_layout = QtWidgets.QVBoxLayout(self.control_widget)
- self.main_layout.addWidget(self.control_widget, stretch=2)
- title_label = QtWidgets.QLabel("Coincidence Mode - Time Trace")
- font = title_label.font()
- font.setBold(True)
- title_label.setFont(font)
- self.control_layout.addWidget(title_label)
- self.channel_group_box = QtWidgets.QGroupBox("Channels")
- self.channel_layout = QtWidgets.QVBoxLayout(self.channel_group_box)
- self.channel_checkboxes = []
- for i in range(1, 11):
- cb = QtWidgets.QCheckBox(f"Channel {i}")
- cb.setChecked(False)
- cb.stateChanged.connect(self.on_channel_toggled)
- self.channel_layout.addWidget(cb)
- self.channel_checkboxes.append(cb)
- self.control_layout.addWidget(self.channel_group_box)
- self.control_layout.addStretch()
- self.max_points = 60
- self.x_data = np.arange(self.max_points)
- self.lines: dict[int | str, dict[str, Any]] = {}
- for ch in range(1, 11):
- plot_item = self.coin_plot.plot([], [], pen=pg.mkPen(color=(ch, 11), width=3), name=f"Ch. {ch}")
- y_data = np.zeros(self.max_points)
- self.lines[ch] = {"plot_item": plot_item, "y_data": y_data}
- coincidence_plot = self.coin_plot.plot([], [], pen=pg.mkPen(color=(10, 11), width=3), name="Coincidence")
- coincidence_y = np.zeros(self.max_points)
- self.lines["coinc"] = {"plot_item": coincidence_plot, "y_data": coincidence_y}
- self._thread: QtCore.QThread = QtCore.QThread()
- self.worker = Worker(self.device)
- self.worker.moveToThread(self._thread)
- self.worker.data_ready.connect(self.handle_data)
- self._thread.started.connect(self.worker.measure)
- self._thread.start()
-
- def boot_gui(self) -> None:
- self.show()
-
- def delete_gui(self) -> None:
- self.close()
- self.deleteLater()
-
- def handle_data(self, singles: list[int], coincidence: int) -> None:
- channels_for_coincidence = 2
- active_channels = [i + 1 for i, cb in enumerate(self.channel_checkboxes) if cb.isChecked()]
- for ch in range(1, 11):
- old_y = self.lines[ch]["y_data"]
- shifted_y = np.roll(old_y, -1)
- shifted_y[-1] = singles[ch - 1]
- self.lines[ch]["y_data"] = shifted_y
- if ch in active_channels:
- self.lines[ch]["plot_item"].setData(self.x_data, shifted_y)
- else:
- self.lines[ch]["plot_item"].setData([], [])
- old_y = self.lines["coinc"]["y_data"]
- shifted_y = np.roll(old_y, -1)
- shifted_y[-1] = coincidence
- self.lines["coinc"]["y_data"] = shifted_y
- if len(active_channels) == channels_for_coincidence:
- self.lines["coinc"]["plot_item"].setData(self.x_data, shifted_y)
- else:
- self.lines["coinc"]["plot_item"].setData([], [])
- QtCore.QTimer.singleShot(200, self.worker.measure)
-
- def on_channel_toggled(self) -> None:
- self.worker.active_channels = [i + 1 for i, cb in enumerate(self.channel_checkboxes) if cb.isChecked()]
-
-
-if __name__ == "__main__":
- app = QtWidgets.QApplication(sys.argv)
- c = Client(host="172.30.63.109", timeout=300000)
- tagger = c.get_device("mini_pc", "tagger")
-
- device = tagger
-
- gui = DataViewerGUI(device=device)
- gui.boot_gui()
-
- sys.exit(app.exec_())
diff --git a/scripts/http_server b/scripts/http_server
deleted file mode 100755
index be7fe5fd..00000000
--- a/scripts/http_server
+++ /dev/null
@@ -1,248 +0,0 @@
-#!/usr/bin/env python
-
-import json
-import threading
-from dataclasses import dataclass
-from http.server import BaseHTTPRequestHandler
-from http.server import HTTPServer
-from typing import TYPE_CHECKING
-from typing import Any
-
-from pqnstack.constants import DA_BASIS
-from pqnstack.constants import HV_BASIS
-from pqnstack.constants import MeasurementBasis
-from pqnstack.network.client import Client
-from pqnstack.pqn.drivers.polarimeter import ArduinoPolarimeter
-from pqnstack.pqn.drivers.polarimeter import Polarimeter
-from pqnstack.pqn.protocols.measurement import MeasurementConfig
-from pqnstack.pqn.protocols.qkd import Devices as QkdDevices
-from pqnstack.pqn.protocols.qkd import qkd_run
-from pqnstack.pqn.protocols.qrng import pq_random
-
-if TYPE_CHECKING:
- from collections.abc import Callable
-
-HOST = "127.0.0.1"
-PORT = 8000
-ADDRESS: tuple[str, int] = (HOST, PORT)
-ALLOW_HEADER = "*"
-
-PARITY_THRESHOLD: float = 0.6
-MIN_COLLECTED_VALUES: int = 6
-CLIENT_TIMEOUT: int = 600000
-
-
-@dataclass
-class PolarimeterServerState:
- theta: float = 0.0
- angle1: float = 0.0
- angle2: float = 0.0
- parity_agreement: float = 0.0
-
-
-class PolarimeterRequestHandler(BaseHTTPRequestHandler):
- def __init__(
- self,
- *args: Any,
- server_state: PolarimeterServerState,
- polarimeter: Polarimeter | None,
- **kwargs: Any,
- ) -> None:
- self.server_state = server_state
- self.polarimeter = polarimeter
- super().__init__(*args, **kwargs)
-
- def do_OPTIONS(self) -> None:
- self.send_response(200)
- self.send_header("Access-Control-Allow-Origin", ALLOW_HEADER)
- self.send_header("Access-Control-Allow-Methods", "GET, POST, HEAD, OPTIONS")
- self.send_header(
- "Access-Control-Allow-Headers",
- "Content-Type, Cache-Control, Authorization",
- )
- self.end_headers()
-
- def do_GET(self) -> None:
- status: int = 200
- content_type: str = "text/plain"
- response_content: str
-
- if self.path == "/polarimeter/theta":
- if self.polarimeter:
- try:
- m = self.polarimeter.read()
- response_content = f"{-m.theta:.2f}"
- except (OSError, AttributeError):
- response_content = f"{self.server_state.theta:.2f}"
- else:
- response_content = f"{self.server_state.theta:.2f}"
-
- elif self.path == "/parity/agreement":
- payload = {"parity_agreement": self.server_state.parity_agreement}
- response_content = json.dumps(payload)
- content_type = "application/json"
-
- else:
- status = 404
- response_content = ""
-
- self.send_response(status)
- self.send_header("Access-Control-Allow-Origin", ALLOW_HEADER)
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Type", content_type)
- self.end_headers()
- self.wfile.write(response_content.encode("utf-8"))
-
- def do_POST(self) -> None:
- status: int = 200
- content_type: str = "text/plain"
- response_content: str = ""
-
- handlers: dict[str, Callable[[], tuple[int, str, str]]] = {
- "/polarimeter/start_normalizing": self._handle_start_normalizing,
- "/polarimeter/stop_normalizing": self._handle_stop_normalizing,
- "/chsh/set_angle1": self._handle_set_angle1,
- "/chsh/set_angle2": self._handle_set_angle2,
- "/rng/user_input": self._handle_rng_user_input,
- "/qkd/basis": self._handle_qkd_basis,
- "/parity/basis": self._handle_parity_basis,
- "/parity/done": self._handle_parity_done,
- }
-
- handler = handlers.get(self.path)
- if handler:
- try:
- status, content_type, response_content = handler()
- except json.JSONDecodeError:
- status = 400
- response_content = "Invalid JSON data"
- except Exception: # noqa: BLE001
- status = 500 # TODO: Log Exception
- response_content = ""
- else:
- status = 404
-
- self.send_response(status)
- self.send_header("Access-Control-Allow-Origin", ALLOW_HEADER)
- self.send_header("Content-Type", content_type)
- self.end_headers()
- self.wfile.write(response_content.encode("utf-8"))
-
- def _read_json(self) -> Any:
- length = int(self.headers.get("Content-Length", "0"))
- raw = self.rfile.read(length).decode("utf-8")
- return json.loads(raw)
-
- def _handle_start_normalizing(self) -> tuple[int, str, str]:
- if self.polarimeter:
- self.polarimeter.start_normalizing()
- return 200, "text/plain", ""
-
- def _handle_stop_normalizing(self) -> tuple[int, str, str]:
- if self.polarimeter:
- self.polarimeter.stop_normalizing()
- return 200, "text/plain", ""
-
- def _handle_set_angle1(self) -> tuple[int, str, str]:
- data = self._read_json()
- self.server_state.angle1 = float(data.get("option", 0.0))
- return 200, "text/plain", ""
-
- def _handle_set_angle2(self) -> tuple[int, str, str]:
- data = self._read_json()
- self.server_state.angle2 = float(data.get("option", 0.0))
- client = Client(host="172.30.63.109", timeout=CLIENT_TIMEOUT)
- cd = client.get_device("chsh_device", "cd")
- user_basis = abs(self.server_state.angle1 - self.server_state.angle2) % 90 / 2
- result = cd.measure_chsh(
- basis1=[0, 45],
- basis2=[user_basis, user_basis + 45],
- config=MeasurementConfig(5),
- )
- payload = {
- "chsh_value": f"{float(result.chsh_value):.2f}",
- "chsh_error": f"{float(result.chsh_error):.2f}",
- }
- self.server_state.angle1 = 0.0
- self.server_state.angle2 = 0.0
- return 200, "application/json", json.dumps(payload)
-
- def _handle_rng_user_input(self) -> tuple[int, str, str]:
- data = self._read_json()
- user_str = str(data.get("option", "10101010"))
- client = Client(host="172.30.63.109", timeout=CLIENT_TIMEOUT)
- timetagger = client.get_device("mini_pc", "tagger")
- try:
- number = pq_random(6, input_str=user_str, tagger=timetagger)
- except ValueError:
- number = pq_random(6, input_str=user_str)
- payload = {"number": number}
- return 200, "application/json", json.dumps(payload)
-
- def _handle_qkd_basis(self) -> tuple[int, str, str]:
- data = self._read_json()
- option = int(data.get("option", 1))
- basis: MeasurementBasis = HV_BASIS if option == 1 else DA_BASIS
- client = Client(host="172.30.63.109", timeout=CLIENT_TIMEOUT)
- qd = client.get_device("qkd_device", "qd")
- devices = QkdDevices(qd=qd, client=client)
- config = MeasurementConfig(duration=1)
- visibility, _error = qkd_run(devices, config, basis=basis)
- value = 1 if visibility > PARITY_THRESHOLD else 0
- payload = {"value": value}
- return 200, "application/json", json.dumps(payload)
-
- def _handle_parity_basis(self) -> tuple[int, str, str]:
- data = self._read_json()
- option = int(data.get("option", 1))
- basis: MeasurementBasis = HV_BASIS if option == 1 else DA_BASIS
- client = Client(host="172.30.63.109", timeout=CLIENT_TIMEOUT)
- qd = client.get_device("qkd_device", "qd")
- devices = QkdDevices(qd=qd, client=client)
- config = MeasurementConfig(duration=1)
- visibility, _error = qkd_run(devices, config, basis=basis)
- value = 1 if visibility > PARITY_THRESHOLD else 0
- payload = {"value": value}
- return 200, "application/json", json.dumps(payload)
-
- def _handle_parity_done(self) -> tuple[int, str, str]:
- self.server_state.parity_agreement = 0
- return 200, "text/plain", ""
-
-
-def create_request_handler(
- server_state: PolarimeterServerState,
- polarimeter: Polarimeter | None,
-) -> type:
- class CustomHandler(PolarimeterRequestHandler):
- def __init__(self, *args: Any, **kwargs: Any) -> None:
- super().__init__(*args, server_state=server_state, polarimeter=polarimeter, **kwargs)
-
- return CustomHandler
-
-
-def update_theta(server_state: PolarimeterServerState) -> None:
- while True:
- try:
- new_value = input("\nEnter new theta value: ")
- server_state.theta = float(new_value)
- except ValueError:
- continue
-
-
-if __name__ == "__main__":
- state = PolarimeterServerState()
- try:
- polarimeter = ArduinoPolarimeter()
- except Exception: # noqa: BLE001
- polarimeter = None # TODO: Log Exception
-
- input_thread = threading.Thread(target=update_theta, args=(state,), daemon=True)
- input_thread.start()
-
- handler_factory = create_request_handler(state, polarimeter)
- httpd = HTTPServer(ADDRESS, handler_factory)
- try:
- httpd.serve_forever()
- except KeyboardInterrupt:
- httpd.shutdown()
diff --git a/src/pqnstack/__init__.py b/src/pqn_node/__init__.py
similarity index 100%
rename from src/pqnstack/__init__.py
rename to src/pqn_node/__init__.py
diff --git a/src/pqnstack/app/__init__.py b/src/pqn_node/api/__init__.py
similarity index 100%
rename from src/pqnstack/app/__init__.py
rename to src/pqn_node/api/__init__.py
diff --git a/src/pqnstack/app/api/deps.py b/src/pqn_node/api/deps.py
similarity index 75%
rename from src/pqnstack/app/api/deps.py
rename to src/pqn_node/api/deps.py
index 95d33341..3ef982e5 100644
--- a/src/pqnstack/app/api/deps.py
+++ b/src/pqn_node/api/deps.py
@@ -4,15 +4,15 @@
import httpx
from fastapi import Depends
-
-from pqnstack.app.core.config import NodeState
-from pqnstack.app.core.config import get_state
-from pqnstack.app.core.config import logger
-from pqnstack.app.core.config import settings
-from pqnstack.network.client import Client
-from pqnstack.pqn.drivers.rotaryencoder import MockRotaryEncoder
-from pqnstack.pqn.drivers.rotaryencoder import RotaryEncoderInstrument
-from pqnstack.pqn.drivers.rotaryencoder import SerialRotaryEncoder
+from pqn_hardware.drivers.rotaryencoder import MockRotaryEncoder
+from pqn_hardware.drivers.rotaryencoder import RotaryEncoderInstrument
+from pqn_hardware.drivers.rotaryencoder import SerialRotaryEncoder
+from pqn_hardware.network.client import Client
+
+from pqn_node.core.config import NodeState
+from pqn_node.core.config import get_state
+from pqn_node.core.config import logger
+from pqn_node.core.config import settings
async def get_http_client() -> AsyncGenerator[httpx.AsyncClient, None]:
diff --git a/src/pqnstack/app/api/main.py b/src/pqn_node/api/main.py
similarity index 52%
rename from src/pqnstack/app/api/main.py
rename to src/pqn_node/api/main.py
index 2879dd07..63c2383b 100644
--- a/src/pqnstack/app/api/main.py
+++ b/src/pqn_node/api/main.py
@@ -1,14 +1,14 @@
from fastapi import APIRouter
-from pqnstack.app.api.routes import chsh
-from pqnstack.app.api.routes import coordination
-from pqnstack.app.api.routes import debug
-from pqnstack.app.api.routes import games
-from pqnstack.app.api.routes import health
-from pqnstack.app.api.routes import qkd
-from pqnstack.app.api.routes import rng
-from pqnstack.app.api.routes import serial
-from pqnstack.app.api.routes import timetagger
+from pqn_node.api.routes import chsh
+from pqn_node.api.routes import coordination
+from pqn_node.api.routes import debug
+from pqn_node.api.routes import games
+from pqn_node.api.routes import health
+from pqn_node.api.routes import qkd
+from pqn_node.api.routes import rng
+from pqn_node.api.routes import serial
+from pqn_node.api.routes import timetagger
api_router = APIRouter()
api_router.include_router(chsh.router)
diff --git a/src/pqnstack/app/api/__init__.py b/src/pqn_node/api/routes/__init__.py
similarity index 100%
rename from src/pqnstack/app/api/__init__.py
rename to src/pqn_node/api/routes/__init__.py
diff --git a/src/pqnstack/app/api/routes/chsh.py b/src/pqn_node/api/routes/chsh.py
similarity index 91%
rename from src/pqnstack/app/api/routes/chsh.py
rename to src/pqn_node/api/routes/chsh.py
index 1fb48cd5..3784d514 100644
--- a/src/pqnstack/app/api/routes/chsh.py
+++ b/src/pqn_node/api/routes/chsh.py
@@ -9,17 +9,16 @@
from fastapi import HTTPException
from fastapi import status
from fastapi.responses import StreamingResponse
+from pqn_hardware.network.client import Client
from pydantic import BaseModel
-from pqnstack.app.api.deps import ClientDep
-from pqnstack.app.api.deps import StateDep
-from pqnstack.app.core.config import chsh_progress_event
-from pqnstack.app.core.config import settings
-from pqnstack.app.core.models import calculate_chsh_expectation_error
-from pqnstack.network.client import Client
+from pqn_node.api.deps import ClientDep
+from pqn_node.api.deps import StateDep
+from pqn_node.core.config import chsh_progress_event
+from pqn_node.core.config import settings
if TYPE_CHECKING:
- from pqnstack.base.instrument import RotatorInstrument
+ from pqn_hardware.instrument import RotatorInstrument
logger = logging.getLogger(__name__)
@@ -213,3 +212,14 @@ async def request_angle_by_basis(index: int, state: StateDep, *, perp: bool = Fa
hwp.move_to(angle / 2)
logger.info("moving waveplate", extra={"angle": angle})
return True
+
+
+def calculate_chsh_expectation_error(counts: list[int], dark_count: int = 0) -> float:
+ total_counts = sum(counts)
+ corrected_total = total_counts - 4 * dark_count
+ if corrected_total <= 0:
+ return 0
+ first_term = (total_counts**0.5) / corrected_total
+ expectation = abs(counts[0] + counts[3] - counts[1] - counts[2])
+ second_term = (expectation / corrected_total**2) * (total_counts + 4 * dark_count) ** 0.5
+ return float(first_term + second_term)
diff --git a/src/pqnstack/app/api/routes/coordination.py b/src/pqn_node/api/routes/coordination.py
similarity index 97%
rename from src/pqnstack/app/api/routes/coordination.py
rename to src/pqn_node/api/routes/coordination.py
index ecd9fb8f..c7b8367a 100644
--- a/src/pqnstack/app/api/routes/coordination.py
+++ b/src/pqn_node/api/routes/coordination.py
@@ -12,13 +12,13 @@
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
-from pqnstack.app.api.deps import ClientDep
-from pqnstack.app.api.deps import StateDep
-from pqnstack.app.core.config import NodeRole
-from pqnstack.app.core.config import ask_user_for_follow_event
-from pqnstack.app.core.config import protocol_cancelled_event
-from pqnstack.app.core.config import settings
-from pqnstack.app.core.config import user_replied_event
+from pqn_node.api.deps import ClientDep
+from pqn_node.api.deps import StateDep
+from pqn_node.core.config import NodeRole
+from pqn_node.core.config import ask_user_for_follow_event
+from pqn_node.core.config import protocol_cancelled_event
+from pqn_node.core.config import settings
+from pqn_node.core.config import user_replied_event
logger = logging.getLogger(__name__)
diff --git a/src/pqnstack/app/api/routes/debug.py b/src/pqn_node/api/routes/debug.py
similarity index 58%
rename from src/pqnstack/app/api/routes/debug.py
rename to src/pqn_node/api/routes/debug.py
index 3e43d74b..d0dbfaa5 100644
--- a/src/pqnstack/app/api/routes/debug.py
+++ b/src/pqn_node/api/routes/debug.py
@@ -1,9 +1,9 @@
from fastapi import APIRouter
-from pqnstack.app.api.deps import StateDep
-from pqnstack.app.core.config import NodeState
-from pqnstack.app.core.config import Settings
-from pqnstack.app.core.config import settings
+from pqn_node.api.deps import StateDep
+from pqn_node.core.config import NodeState
+from pqn_node.core.config import Settings
+from pqn_node.core.config import settings
router = APIRouter(prefix="/debug", tags=["debug"])
diff --git a/src/pqnstack/app/api/routes/games.py b/src/pqn_node/api/routes/games.py
similarity index 66%
rename from src/pqnstack/app/api/routes/games.py
rename to src/pqn_node/api/routes/games.py
index 99f03224..23f103b7 100644
--- a/src/pqnstack/app/api/routes/games.py
+++ b/src/pqn_node/api/routes/games.py
@@ -1,7 +1,7 @@
from fastapi import APIRouter
-from pqnstack.app.core.config import GamesAvailability
-from pqnstack.app.core.config import get_settings
+from pqn_node.core.config import GamesAvailability
+from pqn_node.core.config import get_settings
router = APIRouter(prefix="/games", tags=["games"])
diff --git a/src/pqnstack/app/api/routes/health.py b/src/pqn_node/api/routes/health.py
similarity index 98%
rename from src/pqnstack/app/api/routes/health.py
rename to src/pqn_node/api/routes/health.py
index 607ebc47..b6230500 100644
--- a/src/pqnstack/app/api/routes/health.py
+++ b/src/pqn_node/api/routes/health.py
@@ -6,11 +6,11 @@
import serial
from fastapi import APIRouter
from fastapi import Query
+from pqn_hardware.network.client import Client
from pydantic import BaseModel
from pydantic import Field
-from pqnstack.app.core.config import settings
-from pqnstack.network.client import Client
+from pqn_node.core.config import settings
logger = logging.getLogger(__name__)
diff --git a/src/pqnstack/app/api/routes/qkd.py b/src/pqn_node/api/routes/qkd.py
similarity index 96%
rename from src/pqnstack/app/api/routes/qkd.py
rename to src/pqn_node/api/routes/qkd.py
index fc090bc9..858c3f80 100644
--- a/src/pqnstack/app/api/routes/qkd.py
+++ b/src/pqn_node/api/routes/qkd.py
@@ -9,21 +9,21 @@
from fastapi import APIRouter
from fastapi import HTTPException
from fastapi import status
+from pqn_hardware.network.client import Client
from pydantic import BaseModel
-from pqnstack.app.api.deps import ClientDep
-from pqnstack.app.api.deps import StateDep
-from pqnstack.app.core.config import NodeRole
-from pqnstack.app.core.config import NodeState
-from pqnstack.app.core.config import protocol_cancelled_event
-from pqnstack.app.core.config import qkd_result_received_event
-from pqnstack.app.core.config import settings
-from pqnstack.constants import BasisBool
-from pqnstack.constants import QKDEncodingBasis
-from pqnstack.network.client import Client
+from pqn_node.api.deps import ClientDep
+from pqn_node.api.deps import StateDep
+from pqn_node.constants import BasisBool
+from pqn_node.constants import QKDEncodingBasis
+from pqn_node.core.config import NodeRole
+from pqn_node.core.config import NodeState
+from pqn_node.core.config import protocol_cancelled_event
+from pqn_node.core.config import qkd_result_received_event
+from pqn_node.core.config import settings
if TYPE_CHECKING:
- from pqnstack.base.instrument import RotatorInstrument
+ from pqn_hardware.instrument import RotatorInstrument
logger = logging.getLogger(__name__)
diff --git a/src/pqnstack/app/api/routes/rng.py b/src/pqn_node/api/routes/rng.py
similarity index 96%
rename from src/pqnstack/app/api/routes/rng.py
rename to src/pqn_node/api/routes/rng.py
index 7823de08..0ff49e80 100644
--- a/src/pqnstack/app/api/routes/rng.py
+++ b/src/pqn_node/api/routes/rng.py
@@ -11,10 +11,10 @@
from fastapi import status
from fastapi.responses import StreamingResponse
-from pqnstack.app.api.deps import ClientDep
-from pqnstack.app.api.deps import StateDep
-from pqnstack.app.core.config import rng_progress_event
-from pqnstack.app.core.config import settings
+from pqn_node.api.deps import ClientDep
+from pqn_node.api.deps import StateDep
+from pqn_node.core.config import rng_progress_event
+from pqn_node.core.config import settings
logger = logging.getLogger(__name__)
diff --git a/src/pqnstack/app/api/routes/serial.py b/src/pqn_node/api/routes/serial.py
similarity index 89%
rename from src/pqnstack/app/api/routes/serial.py
rename to src/pqn_node/api/routes/serial.py
index f5811840..63d32bf4 100644
--- a/src/pqnstack/app/api/routes/serial.py
+++ b/src/pqn_node/api/routes/serial.py
@@ -5,10 +5,10 @@
from fastapi import APIRouter
from pydantic import BaseModel
-from pqnstack.app.api.deps import SERDep
+from pqn_node.api.deps import SERDep
if TYPE_CHECKING:
- from pqnstack.pqn.drivers.rotaryencoder import MockRotaryEncoder
+ from pqn_hardware.drivers.rotaryencoder import MockRotaryEncoder
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/serial", tags=["measure"])
diff --git a/src/pqnstack/app/api/routes/timetagger.py b/src/pqn_node/api/routes/timetagger.py
similarity index 92%
rename from src/pqnstack/app/api/routes/timetagger.py
rename to src/pqn_node/api/routes/timetagger.py
index 222cd2f1..22eb740a 100644
--- a/src/pqnstack/app/api/routes/timetagger.py
+++ b/src/pqn_node/api/routes/timetagger.py
@@ -7,13 +7,13 @@
from fastapi import HTTPException
from fastapi import Query
from fastapi import status
+from pqn_hardware.measurement import MeasurementConfig
+from pqn_hardware.network.client import Client
-from pqnstack.app.core.config import settings
-from pqnstack.network.client import Client
-from pqnstack.pqn.protocols.measurement import MeasurementConfig
+from pqn_node.core.config import settings
if TYPE_CHECKING:
- from pqnstack.base.instrument import TimeTaggerInstrument
+ from pqn_hardware.instrument import TimeTaggerInstrument
logger = logging.getLogger(__name__)
diff --git a/src/pqn_node/cli.py b/src/pqn_node/cli.py
new file mode 100644
index 00000000..7490193b
--- /dev/null
+++ b/src/pqn_node/cli.py
@@ -0,0 +1,177 @@
+import logging
+import tomllib
+from pathlib import Path
+from typing import Annotated
+
+import tomli_w
+import typer
+
+from pqn_node.core.config import get_settings
+from pqn_node.cron_manager import describe_schedule
+from pqn_node.cron_manager import get_daily_report_job
+from pqn_node.cron_manager import remove_daily_report_job
+from pqn_node.cron_manager import set_daily_report_schedule
+from pqn_node.daily_report import run_daily_report
+
+# TODO: check if this way of handling logging from a command line script is ok.
+logging.basicConfig(level=logging.INFO)
+
+logger = logging.getLogger(__name__)
+
+app = typer.Typer(no_args_is_help=True, help="CLI for pqn-node.")
+
+daily_report_app = typer.Typer(no_args_is_help=True, help="Run and manage the daily health + Slack report.")
+app.add_typer(daily_report_app, name="daily-report")
+
+
+@app.command()
+def toggle_game(
+ games: Annotated[list[str], typer.Argument(help="Games to toggle: chsh, qf, ssm")],
+ enable: Annotated[bool, typer.Option("--enable/--disable", help="Enable or disable the games")] = True, # noqa: FBT002
+ config: Annotated[str, typer.Option(help="Path to config.toml")] = "./config.toml",
+) -> None:
+ """
+ Enable or disable one or more games in config.toml.
+
+ Changes take effect on the next server restart. Games: chsh (Verify Quantum Link), qf (Quantum Fortune), ssm (Share a Secret Message).
+ """
+ valid_games = {"chsh", "qf", "ssm"}
+ invalid = [g for g in games if g not in valid_games]
+ if invalid:
+ msg = f"Game(s) must be one of: chsh, qf, ssm. Invalid: {invalid}"
+ raise typer.BadParameter(msg)
+
+ path = Path(config)
+ with path.open("rb") as f:
+ cfg = tomllib.load(f)
+
+ cfg.setdefault("games_availability", {})
+ for game in games:
+ cfg["games_availability"][game] = enable
+
+ with path.open("wb") as f:
+ tomli_w.dump(cfg, f)
+
+ status = "enabled" if enable else "disabled"
+ logger.info("Games %s %s in %s. Restart the server for changes to take effect.", games, status, path)
+
+
+@daily_report_app.command("run")
+def daily_report_run() -> None:
+ """
+ Run the daily health + games report and post the result to Slack.
+
+ Reads the [daily_report] section from config.toml, probes hardware via the
+ running API (`/health`), exercises each enabled game (except SSM), and posts a
+ consolidated Slack digest. Exits non-zero if anything failed.
+ """
+ report_config = get_settings().daily_report
+ if report_config is None:
+ logger.error("[daily_report] section missing from config.toml")
+ raise typer.Exit(code=1)
+
+ raise typer.Exit(code=run_daily_report(report_config))
+
+
+@daily_report_app.command("status")
+def daily_report_status() -> None:
+ """Show whether the daily report cron job is active and its schedule."""
+ job = get_daily_report_job()
+ if job is None:
+ typer.echo("Daily report is not scheduled.")
+ else:
+ typer.echo(f"Daily report is active. Schedule: {describe_schedule(job)}")
+
+
+_DOW_MAP = {
+ "monday": "1",
+ "tuesday": "2",
+ "wednesday": "3",
+ "thursday": "4",
+ "friday": "5",
+ "saturday": "6",
+ "sunday": "0",
+}
+
+
+def _prompt_hhmm() -> tuple[int, int]:
+ raw_time = typer.prompt("Time (HH:MM, 24-hour)")
+ try:
+ h_str, m_str = raw_time.strip().split(":")
+ hour, minute = int(h_str), int(m_str)
+ except ValueError:
+ typer.echo("Invalid time format. Use HH:MM (e.g. 09:00).", err=True)
+ raise typer.Exit(code=1) # noqa: B904
+ if not (0 <= hour <= 23 and 0 <= minute <= 59): # noqa: PLR2004
+ typer.echo("Hour must be 0-23 and minute 0-59.", err=True)
+ raise typer.Exit(code=1)
+ return hour, minute
+
+
+def _prompt_dow() -> str:
+ raw_day = typer.prompt("Day of week (monday-sunday)").strip().lower()
+ if raw_day not in _DOW_MAP:
+ typer.echo(f"Invalid day '{raw_day}'.", err=True)
+ raise typer.Exit(code=1)
+ return _DOW_MAP[raw_day]
+
+
+def _prompt_dom() -> str:
+ raw_dom = typer.prompt("Day of month (1-28)")
+ dom_int = int(raw_dom)
+ if not 1 <= dom_int <= 28: # noqa: PLR2004
+ typer.echo("Day of month must be between 1 and 28.", err=True)
+ raise typer.Exit(code=1)
+ return str(dom_int)
+
+
+@daily_report_app.command("schedule")
+def daily_report_schedule() -> None:
+ """Interactively schedule the daily report cron job."""
+ frequency = typer.prompt("Frequency (hourly/daily/weekly/monthly)").strip().lower()
+ valid = {"hourly", "daily", "weekly", "monthly"}
+ if frequency not in valid:
+ typer.echo(f"Invalid frequency '{frequency}'. Choose from: {', '.join(sorted(valid))}", err=True)
+ raise typer.Exit(code=1)
+
+ minute: int
+ hour: int | str = "*"
+ dow = "*"
+ dom = "*"
+
+ if frequency == "hourly":
+ raw_minute = typer.prompt("Minute past the hour (0-59)")
+ minute = int(raw_minute)
+ if not 0 <= minute <= 59: # noqa: PLR2004
+ typer.echo("Minute must be between 0 and 59.", err=True)
+ raise typer.Exit(code=1)
+ else:
+ hour, minute = _prompt_hhmm()
+ if frequency == "weekly":
+ dow = _prompt_dow()
+ elif frequency == "monthly":
+ dom = _prompt_dom()
+
+ try:
+ set_daily_report_schedule(minute=minute, hour=hour, dow=dow, dom=dom)
+ except RuntimeError as e:
+ typer.echo(str(e), err=True)
+ raise typer.Exit(code=1) # noqa: B904
+
+ job = get_daily_report_job()
+ description = describe_schedule(job) if job else "unknown"
+ typer.echo(f"Daily report scheduled. Schedule: {description}")
+
+
+@daily_report_app.command("unschedule")
+def daily_report_unschedule() -> None:
+ """Remove the daily report cron job."""
+ removed = remove_daily_report_job()
+ if removed:
+ typer.echo("Daily report unscheduled.")
+ else:
+ typer.echo("Daily report was not scheduled.")
+
+
+if __name__ == "__main__":
+ app()
diff --git a/src/pqn_node/constants.py b/src/pqn_node/constants.py
new file mode 100644
index 00000000..0d1359fd
--- /dev/null
+++ b/src/pqn_node/constants.py
@@ -0,0 +1,35 @@
+from enum import Enum
+
+
+class QKDAngleValuesHWP(Enum):
+ H = 0
+ V = 45
+ A = -22.5
+ D = 22.5
+
+
+class QKDEncodingBasis(Enum):
+ HV = 0
+ DA = 1
+
+ @property
+ def angles(self) -> list[QKDAngleValuesHWP]:
+ if self is QKDEncodingBasis.HV:
+ return [QKDAngleValuesHWP.H, QKDAngleValuesHWP.V]
+ if self is QKDEncodingBasis.DA:
+ return [QKDAngleValuesHWP.D, QKDAngleValuesHWP.A]
+ msg = f"Unknown basis: {self}"
+ raise ValueError(msg)
+
+
+class BasisBool(Enum):
+ HV = 0
+ DA = 1
+
+
+# FIXME: Populate missing bell states.
+class BellState(Enum):
+ """Encodes."""
+
+ Phi_plus = 0
+ Psi_plus = 1
diff --git a/src/pqnstack/app/api/routes/__init__.py b/src/pqn_node/core/__init__.py
similarity index 100%
rename from src/pqnstack/app/api/routes/__init__.py
rename to src/pqn_node/core/__init__.py
diff --git a/src/pqnstack/app/core/config.py b/src/pqn_node/core/config.py
similarity index 97%
rename from src/pqnstack/app/core/config.py
rename to src/pqn_node/core/config.py
index 21474459..46642b50 100644
--- a/src/pqnstack/app/core/config.py
+++ b/src/pqn_node/core/config.py
@@ -3,6 +3,7 @@
from enum import Enum
from functools import lru_cache
+from pqn_hardware.measurement import MeasurementConfig
from pydantic import BaseModel
from pydantic import Field
from pydantic_settings import BaseSettings
@@ -10,9 +11,8 @@
from pydantic_settings import SettingsConfigDict
from pydantic_settings import TomlConfigSettingsSource
-from pqnstack.constants import BellState
-from pqnstack.constants import QKDEncodingBasis
-from pqnstack.pqn.protocols.measurement import MeasurementConfig
+from pqn_node.constants import BellState
+from pqn_node.constants import QKDEncodingBasis
logger = logging.getLogger(__name__)
diff --git a/src/pqnstack/app/cron_manager.py b/src/pqn_node/cron_manager.py
similarity index 100%
rename from src/pqnstack/app/cron_manager.py
rename to src/pqn_node/cron_manager.py
diff --git a/src/pqnstack/app/daily_report.py b/src/pqn_node/daily_report.py
similarity index 98%
rename from src/pqnstack/app/daily_report.py
rename to src/pqn_node/daily_report.py
index 23866dff..39ff0625 100644
--- a/src/pqnstack/app/daily_report.py
+++ b/src/pqn_node/daily_report.py
@@ -1,6 +1,6 @@
"""Daily health + games report posted to Slack.
-Run via `pqn daily-report` (see pqnstack.cli). Loads `DailyReportConfig`, probes
+Run via `pqn-node daily-report` (see pqn_node.cli). Loads `DailyReportConfig`, probes
hardware via `/health`, exercises each enabled game except SSM, and posts a
single consolidated Slack digest.
"""
@@ -19,14 +19,14 @@
import httpx
-from pqnstack.app.api.routes.chsh import ChshResult
-from pqnstack.app.api.routes.health import ComponentStatus
-from pqnstack.app.api.routes.health import HealthStatus
+from pqn_node.api.routes.chsh import ChshResult
+from pqn_node.api.routes.health import ComponentStatus
+from pqn_node.api.routes.health import HealthStatus
if TYPE_CHECKING:
from types import FrameType
- from pqnstack.app.core.config import DailyReportConfig
+ from pqn_node.core.config import DailyReportConfig
logger = logging.getLogger(__name__)
diff --git a/src/pqnstack/app/main.py b/src/pqn_node/main.py
similarity index 93%
rename from src/pqnstack/app/main.py
rename to src/pqn_node/main.py
index 840269f9..ca199420 100644
--- a/src/pqnstack/app/main.py
+++ b/src/pqn_node/main.py
@@ -3,7 +3,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from pqnstack.app.api.main import api_router
+from pqn_node.api.main import api_router
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
diff --git a/src/pqnstack/app/core/__init__.py b/src/pqnstack/app/core/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/pqnstack/app/core/models.py b/src/pqnstack/app/core/models.py
deleted file mode 100644
index 348411af..00000000
--- a/src/pqnstack/app/core/models.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import logging
-
-logger = logging.getLogger(__name__)
-
-
-def calculate_chsh_expectation_error(counts: list[int], dark_count: int = 0) -> float:
- total_counts = sum(counts)
- corrected_total = total_counts - 4 * dark_count
- if corrected_total <= 0:
- return 0
- first_term = (total_counts**0.5) / corrected_total
- expectation = abs(counts[0] + counts[3] - counts[1] - counts[2])
- second_term = (expectation / corrected_total**2) * (total_counts + 4 * dark_count) ** 0.5
- return float(first_term + second_term)
diff --git a/src/pqnstack/base/__init__.py b/src/pqnstack/base/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/pqnstack/base/errors.py b/src/pqnstack/base/errors.py
deleted file mode 100644
index 3a2484a9..00000000
--- a/src/pqnstack/base/errors.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# University of Illinois Urbana-Champaign
-# Public Quantum Network
-#
-# NCSA/Illinois Computes
-
-
-class DeviceNotStartedError(Exception):
- def __init__(self, message: str = "Device not started") -> None:
- self.message = message
- super().__init__(self.message)
-
-
-class DriverNotFoundError(Exception):
- def __init__(self, message: str = "Device driver configuration not found") -> None:
- self.message = message
- super().__init__(self.message)
-
-
-class DriverFunctionNotImplementedError(Exception):
- def __init__(self, message: str = "One or more driver functions were not implemented") -> None:
- self.message = message
- super().__init__(self.message)
-
-
-class DriverFunctionUnknownError(Exception):
- def __init__(self, message: str = "Device driver function unknown") -> None:
- self.message = message
- super().__init__(self.message)
-
-
-class LogDecoratorOutsideOfClassError(Exception):
- def __init__(self, message: str = "Log decorator used outside of a class") -> None:
- self.message = message
- super().__init__(self.message)
-
-
-class PacketError(Exception):
- def __init__(self, message: str = "Packet error") -> None:
- self.message = message
- super().__init__(self.message)
-
-
-class InvalidInstrumentsConfigurationError(Exception):
- def __init__(self, message: str = "Invalid instruments configuration") -> None:
- self.message = message
- super().__init__(self.message)
-
-
-class InvalidNetworkConfigurationError(Exception):
- def __init__(self, message: str = "Invalid network configuration") -> None:
- self.message = message
- super().__init__(self.message)
-
-
-class CouldNotConnectToNetworkElementError(Exception):
- def __init__(self, message: str = "Could not connect to network element") -> None:
- self.message = message
- super().__init__(self.message)
diff --git a/src/pqnstack/base/instrument.py b/src/pqnstack/base/instrument.py
deleted file mode 100644
index 2b1cf2b2..00000000
--- a/src/pqnstack/base/instrument.py
+++ /dev/null
@@ -1,231 +0,0 @@
-# University of Illinois Urbana-Champaign
-# Public Quantum Network
-#
-# NCSA/Illinois Computes
-
-import atexit
-import datetime
-import logging
-from collections.abc import Callable
-from dataclasses import dataclass
-from dataclasses import field
-from functools import wraps
-from time import perf_counter
-from typing import Any
-from typing import Protocol
-from typing import runtime_checkable
-
-from pqnstack.base.errors import LogDecoratorOutsideOfClassError
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True)
-class InstrumentInfo:
- name: str = ""
- desc: str = ""
- hw_address: str = ""
- hw_status: dict[str, Any] = field(default_factory=dict)
-
-
-@runtime_checkable
-@dataclass(slots=True)
-class Instrument(Protocol):
- """Base class for all instruments in the PQN stack.
-
- Some rules for instruments:
-
- * You cannot use the character `:` in the names of instruments. This is used to separate parts of requests in
- proxy instruments.
-
- """
-
- name: str
- desc: str
- hw_address: str
- parameters: set[str] = field(default_factory=set)
- operations: dict[str, Callable[..., Any]] = field(default_factory=dict)
-
- def __post_init__(self) -> None:
- atexit.register(self.close)
-
- def start(self) -> None: ...
- def close(self) -> None: ...
-
- @property
- def info(self) -> InstrumentInfo: ...
-
-
-def log_operation[T](func: Callable[..., T]) -> Callable[..., T]:
- @wraps(func)
- def wrapper(*args: Any, **kwargs: Any) -> T:
- if len(args) == 0:
- msg = "log_operation has 0 args, this usually indicates that it has been used to decorate something that is not a class method. This is not allowed."
- raise LogDecoratorOutsideOfClassError(msg)
-
- ins = args[0]
- if not isinstance(ins, Instrument):
- msg = "log_operation has been used to decorate something that is not a Instrument method. This is not allowed."
- raise LogDecoratorOutsideOfClassError(msg)
-
- start_time = perf_counter()
- logger.info(
- "%s| %s, %s |Starting operation '%s' with args: '%s' and kwargs '%s'",
- start_time,
- ins.name,
- type(ins),
- func.__name__,
- args,
- kwargs,
- )
-
- result = func(*args, **kwargs)
-
- end_time = perf_counter()
- duration = end_time - start_time
- logger.info(
- "%s | %s, %s | Completed operation %s. Duration: %s",
- end_time,
- ins.name,
- type(ins),
- func.__name__,
- duration,
- )
-
- return result
-
- return wrapper
-
-
-def log_parameter[T](func: Callable[..., T]) -> Callable[..., T]:
- @wraps(func)
- def wrapper(*args: Any, **kwargs: Any) -> T:
- if len(args) == 0:
- msg = (
- "log_parameter has 0 args, "
- "this usually indicates that it has been used to decorate something that is not a class method. "
- "This is not allowed."
- )
- raise LogDecoratorOutsideOfClassError(msg)
-
- ins = args[0]
- if not isinstance(ins, Instrument):
- msg = (
- "log_operation has been used to decorate something that is not a Instrument method. "
- "This is not allowed."
- )
- raise LogDecoratorOutsideOfClassError(msg)
-
- # if no args or kwargs, we are reading the value of the param, else we are setting it.
- if len(args) == 1 and len(kwargs) == 0:
- current_time = datetime.datetime.now(tz=datetime.UTC)
- result = func(*args, **kwargs)
- logger.info(
- "%s | %s, %s | Parameter '%s' got read with value %s",
- current_time,
- ins.name,
- type(ins),
- func.__name__,
- result,
- )
-
- else:
- start_time = perf_counter()
- result = func(*args, **kwargs) # Always return None
- end_time = perf_counter()
- duration = end_time - start_time
- logger.info(
- "%s | %s, %s | Parameter '%s' got updated to '%s', parameter update took %s long ",
- end_time,
- ins.name,
- type(ins),
- func.__name__,
- args[1:],
- duration,
- )
-
- return result
-
- return wrapper
-
-
-@dataclass(frozen=True, slots=True)
-class TimeTaggerInfo(InstrumentInfo):
- active_channels: list[int] = field(default_factory=list)
- test_signal_enabled: bool = False
- test_signal_divider: int = 1
-
-
-@runtime_checkable
-@dataclass(slots=True)
-class TimeTaggerInstrument(Instrument, Protocol):
- active_channels: list[int] = field(default_factory=list)
- test_signal_enabled: bool = False
- test_signal_divider: int = 1
-
- def __post_init__(self) -> None:
- self.operations["count_singles"] = self.count_singles
- self.operations["measure_correlation"] = self.measure_correlation
-
- def count_singles(self, channels: list[int], integration_time_s: float) -> list[int]: ...
- def measure_correlation(self, start_ch: int, stop_ch: int, integration_time_s: float, binwidth_ps: int) -> int: ...
-
-
-@dataclass(frozen=True, slots=True)
-class RotatorInfo(InstrumentInfo):
- degrees: float = 0.0
- offset_degrees: float = 0.0
-
-
-@runtime_checkable
-@dataclass(slots=True)
-class RotatorInstrument(Instrument, Protocol):
- offset_degrees: float = 0.0
-
- def __post_init__(self) -> None:
- self.operations["move_to"] = self.move_to
- self.operations["move_by"] = self.move_by
-
- self.parameters.add("degrees")
-
- @property
- @log_parameter
- def degrees(self) -> float: ...
-
- @degrees.setter
- @log_parameter
- def degrees(self, degrees: float) -> None: ...
-
- def move_to(self, angle: float) -> None:
- """Move the rotator to the specified angle."""
- self.degrees = angle
-
- def move_by(self, angle: float) -> None:
- """Move the rotator by the specified angle."""
- self.degrees += angle
-
-
-@dataclass(frozen=True, slots=True)
-class PolarimeterInfo(InstrumentInfo):
- pass
-
-
-@runtime_checkable
-@dataclass(slots=True)
-class PolarimeterInstrument(Instrument, Protocol):
- def __post_init__(self) -> None:
- self.operations["reset"] = self.reset
- self.operations["start_normalizing"] = self.start_normalizing
- self.operations["stop_normalizing"] = self.stop_normalizing
-
- @property
- def info(self) -> PolarimeterInfo: ...
-
- @log_operation
- def reset(self) -> None: ...
-
- @log_operation
- def start_normalizing(self) -> None: ...
-
- @log_operation
- def stop_normalizing(self) -> None: ...
diff --git a/src/pqnstack/cli.py b/src/pqnstack/cli.py
deleted file mode 100644
index a1bad664..00000000
--- a/src/pqnstack/cli.py
+++ /dev/null
@@ -1,347 +0,0 @@
-import json
-import logging
-import tomllib
-from pathlib import Path
-from typing import Annotated
-
-import tomli_w
-import typer
-
-from pqnstack.app.core.config import get_settings
-from pqnstack.app.cron_manager import describe_schedule
-from pqnstack.app.cron_manager import get_daily_report_job
-from pqnstack.app.cron_manager import remove_daily_report_job
-from pqnstack.app.cron_manager import set_daily_report_schedule
-from pqnstack.app.daily_report import run_daily_report
-from pqnstack.base.errors import InvalidNetworkConfigurationError
-from pqnstack.network.instrument_provider import InstrumentProvider
-from pqnstack.network.router import Router
-
-# TODO: check if this way of handling logging from a command line script is ok.
-logging.basicConfig(level=logging.INFO)
-
-logger = logging.getLogger(__name__)
-
-app = typer.Typer(no_args_is_help=True, help="CLI for PQN-Stack.")
-
-daily_report_app = typer.Typer(no_args_is_help=True, help="Run and manage the daily health + Slack report.")
-app.add_typer(daily_report_app, name="daily-report")
-
-
-def _verify_instruments_config(instruments: list[dict[str, str]]) -> dict[str, dict[str, str]]:
- ins = {}
- for i, item in enumerate(instruments):
- if "name" not in item:
- msg = f"Instrument number #{i + 1} configuration is missing the field 'name'"
- raise InvalidNetworkConfigurationError(msg)
- if "import" not in item:
- msg = f"Instrument number #{i + 1} configuration is missing the field 'import'"
- raise InvalidNetworkConfigurationError(msg)
- if "desc" not in item:
- msg = f"Instrument number #{i + 1} configuration is missing the field 'desc'"
- raise InvalidNetworkConfigurationError(msg)
- if "hw_address" not in item:
- msg = f"Instrument number #{i + 1} configuration is missing the field 'hw_address'"
- raise InvalidNetworkConfigurationError(msg)
-
- name = item.pop("name")
- ins[name] = item
-
- return ins
-
-
-def _load_and_parse_provider_config(
- config_path: Path | str, kwargs: dict[str, str | int], instruments: dict[str, dict[str, str]]
-) -> tuple[dict[str, str | int], dict[str, dict[str, str]]]:
- path = Path(config_path)
- with path.open("rb") as f:
- config = tomllib.load(f)
-
- if "provider" not in config:
- msg = f"Config file {config_path} does not contain a provider section. Add provider configuration under '[provider]' section."
- raise InvalidNetworkConfigurationError(msg)
-
- provider = config["provider"]
- if "name" in provider:
- kwargs["name"] = str(provider["name"])
- if "router_name" in provider:
- kwargs["router_name"] = str(provider["router_name"])
- if "host" in provider:
- kwargs["host"] = str(provider["host"])
- if "port" in provider:
- kwargs["port"] = int(provider["port"])
- if "beat_period" in provider:
- kwargs["beat_period"] = int(provider["beat_period"])
-
- if "instruments" in provider:
- instruments = _verify_instruments_config(provider["instruments"])
-
- return kwargs, instruments
-
-
-@app.command()
-def start_provider( # noqa: PLR0913
- name: Annotated[str | None, typer.Option(help="Name of the InstrumentProvider.")] = None,
- router_name: Annotated[
- str | None, typer.Option(help="Name of the router this provider will talk to (default: 'router1').")
- ] = None,
- host: Annotated[
- str | None,
- typer.Option(
- help="Host hw_address (IP) of the provider (default: 'localhost'). Usually the IP hw_address of the Router this provider will talk to."
- ),
- ] = None,
- port: Annotated[
- int | None, typer.Option(help="Port of the provider (default: 5555). Has to be the same port as the Router.")
- ] = None,
- beat_period: Annotated[int | None, typer.Option(help="Heartbeat period in milliseconds (default: 1000)")] = None,
- instruments: Annotated[
- str | None,
- typer.Option(
- help='JSON formatted string with necessary arguments to instantiate instruments. Example: \'{"dummy1": {"import": "pqnstack.pqn.drivers.dummies.DummyInstrument", "desc": "Dummy Instrument 1", "hw_address": "123456"}}\''
- ),
- ] = None,
- config: Annotated[
- str | None, typer.Option(help="Path to the config file, will get overridden by command line arguments.")
- ] = None,
-) -> None:
- """
- Start a PQN InstrumentProvider.
-
- Can be configured by passing arguments directly into the command line but it is recommended to use a config file if instruments will be added.
- """
- kwargs: dict[str, str | int] = {}
- ins: dict[str, dict[str, str]] = {}
-
- if config:
- kwargs, ins = _load_and_parse_provider_config(config, kwargs, ins)
-
- if name:
- kwargs["name"] = name
- if router_name:
- kwargs["router_name"] = router_name
- if host:
- kwargs["host"] = host
- if port:
- kwargs["port"] = port
- if beat_period:
- kwargs["beat_period"] = beat_period
- if instruments:
- # We don't want to override instruments, instead combining them with the ones from config file is cleaner behaviour.
- ins = {**ins, **json.loads(instruments)}
-
- if "name" not in kwargs:
- msg = "InstrumentProvider name is required"
- raise InvalidNetworkConfigurationError(msg)
-
- provider = InstrumentProvider(**kwargs, **ins) # type: ignore[arg-type]
- provider.start()
-
-
-def _load_and_parse_router_config(config_path: Path | str, kwargs: dict[str, str | int]) -> dict[str, str | int]:
- path = Path(config_path)
- with path.open("rb") as f:
- config = tomllib.load(f)
- if "router" not in config:
- msg = f"Config file {config_path} does not contain a router section. Add router configuration under '[router]' section."
- raise InvalidNetworkConfigurationError(msg)
- router = config["router"]
- if "name" in router:
- kwargs["name"] = str(router["name"])
- if "host" in router:
- kwargs["host"] = str(router["host"])
- if "port" in router:
- kwargs["port"] = int(router["port"])
- return kwargs
-
-
-@app.command()
-def start_router(
- name: Annotated[str | None, typer.Option(help="Name of the router (default 'router1')")] = None,
- host: Annotated[
- str | None,
- typer.Option(
- help="Host hw_address (IP) of the router (default: 'localhost'). Usually the IP hw_address of the machine running the router."
- ),
- ] = None,
- port: Annotated[str | None, typer.Option(help="Port of the router (default: 5555)")] = None,
- config: Annotated[
- str | None, typer.Option(help="Path to the config file, will get overridden by command line arguments.")
- ] = None,
-) -> None:
- """
- Start a PQN Router.
-
- Can be configured by passing arguments directly into the command line or through a config file.
- """
- kwargs: dict[str, str | int] = {}
- if config:
- kwargs = _load_and_parse_router_config(config, kwargs)
-
- if name:
- kwargs["name"] = name
- if host:
- kwargs["host"] = host
- if port:
- kwargs["port"] = int(port)
-
- if "name" not in kwargs:
- msg = "Router name is required"
- raise InvalidNetworkConfigurationError(msg)
-
- # mypy doesn't like **kwargs https://github.com/python/mypy/issues/5382#issuecomment-417433738
- router = Router(**kwargs) # type: ignore[arg-type]
- router.start()
-
-
-@app.command()
-def toggle_game(
- games: Annotated[list[str], typer.Argument(help="Games to toggle: chsh, qf, ssm")],
- enable: Annotated[bool, typer.Option("--enable/--disable", help="Enable or disable the games")] = True, # noqa: FBT002
- config: Annotated[str, typer.Option(help="Path to config.toml")] = "./config.toml",
-) -> None:
- """
- Enable or disable one or more games in config.toml.
-
- Changes take effect on the next server restart. Games: chsh (Verify Quantum Link), qf (Quantum Fortune), ssm (Share a Secret Message).
- """
- valid_games = {"chsh", "qf", "ssm"}
- invalid = [g for g in games if g not in valid_games]
- if invalid:
- msg = f"Game(s) must be one of: chsh, qf, ssm. Invalid: {invalid}"
- raise InvalidNetworkConfigurationError(msg)
-
- path = Path(config)
- with path.open("rb") as f:
- cfg = tomllib.load(f)
-
- cfg.setdefault("games_availability", {})
- for game in games:
- cfg["games_availability"][game] = enable
-
- with path.open("wb") as f:
- tomli_w.dump(cfg, f)
-
- status = "enabled" if enable else "disabled"
- logger.info("Games %s %s in %s. Restart the server for changes to take effect.", games, status, path)
-
-
-@daily_report_app.command("run")
-def daily_report_run() -> None:
- """
- Run the daily health + games report and post the result to Slack.
-
- Reads the [daily_report] section from config.toml, probes hardware via the
- running API (`/health`), exercises each enabled game (except SSM), and posts a
- consolidated Slack digest. Exits non-zero if anything failed.
- """
- report_config = get_settings().daily_report
- if report_config is None:
- logger.error("[daily_report] section missing from config.toml")
- raise typer.Exit(code=1)
-
- raise typer.Exit(code=run_daily_report(report_config))
-
-
-@daily_report_app.command("status")
-def daily_report_status() -> None:
- """Show whether the daily report cron job is active and its schedule."""
- job = get_daily_report_job()
- if job is None:
- typer.echo("Daily report is not scheduled.")
- else:
- typer.echo(f"Daily report is active. Schedule: {describe_schedule(job)}")
-
-
-_DOW_MAP = {
- "monday": "1",
- "tuesday": "2",
- "wednesday": "3",
- "thursday": "4",
- "friday": "5",
- "saturday": "6",
- "sunday": "0",
-}
-
-
-def _prompt_hhmm() -> tuple[int, int]:
- raw_time = typer.prompt("Time (HH:MM, 24-hour)")
- try:
- h_str, m_str = raw_time.strip().split(":")
- hour, minute = int(h_str), int(m_str)
- except ValueError:
- typer.echo("Invalid time format. Use HH:MM (e.g. 09:00).", err=True)
- raise typer.Exit(code=1) # noqa: B904
- if not (0 <= hour <= 23 and 0 <= minute <= 59): # noqa: PLR2004
- typer.echo("Hour must be 0-23 and minute 0-59.", err=True)
- raise typer.Exit(code=1)
- return hour, minute
-
-
-def _prompt_dow() -> str:
- raw_day = typer.prompt("Day of week (monday-sunday)").strip().lower()
- if raw_day not in _DOW_MAP:
- typer.echo(f"Invalid day '{raw_day}'.", err=True)
- raise typer.Exit(code=1)
- return _DOW_MAP[raw_day]
-
-
-def _prompt_dom() -> str:
- raw_dom = typer.prompt("Day of month (1-28)")
- dom_int = int(raw_dom)
- if not 1 <= dom_int <= 28: # noqa: PLR2004
- typer.echo("Day of month must be between 1 and 28.", err=True)
- raise typer.Exit(code=1)
- return str(dom_int)
-
-
-@daily_report_app.command("schedule")
-def daily_report_schedule() -> None:
- """Interactively schedule the daily report cron job."""
- frequency = typer.prompt("Frequency (hourly/daily/weekly/monthly)").strip().lower()
- valid = {"hourly", "daily", "weekly", "monthly"}
- if frequency not in valid:
- typer.echo(f"Invalid frequency '{frequency}'. Choose from: {', '.join(sorted(valid))}", err=True)
- raise typer.Exit(code=1)
-
- minute: int
- hour: int | str = "*"
- dow = "*"
- dom = "*"
-
- if frequency == "hourly":
- raw_minute = typer.prompt("Minute past the hour (0-59)")
- minute = int(raw_minute)
- if not 0 <= minute <= 59: # noqa: PLR2004
- typer.echo("Minute must be between 0 and 59.", err=True)
- raise typer.Exit(code=1)
- else:
- hour, minute = _prompt_hhmm()
- if frequency == "weekly":
- dow = _prompt_dow()
- elif frequency == "monthly":
- dom = _prompt_dom()
-
- try:
- set_daily_report_schedule(minute=minute, hour=hour, dow=dow, dom=dom)
- except RuntimeError as e:
- typer.echo(str(e), err=True)
- raise typer.Exit(code=1) # noqa: B904
-
- job = get_daily_report_job()
- description = describe_schedule(job) if job else "unknown"
- typer.echo(f"Daily report scheduled. Schedule: {description}")
-
-
-@daily_report_app.command("unschedule")
-def daily_report_unschedule() -> None:
- """Remove the daily report cron job."""
- removed = remove_daily_report_job()
- if removed:
- typer.echo("Daily report unscheduled.")
- else:
- typer.echo("Daily report was not scheduled.")
-
-
-if __name__ == "__main__":
- app()
diff --git a/src/pqnstack/constants.py b/src/pqnstack/constants.py
deleted file mode 100644
index f6f9b5be..00000000
--- a/src/pqnstack/constants.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from dataclasses import dataclass
-from enum import Enum
-
-
-class QKDAngleValuesHWP(Enum):
- H = 0
- V = 45
- A = -22.5
- D = 22.5
-
-
-class QKDEncodingBasis(Enum):
- HV = 0
- DA = 1
-
- @property
- def angles(self) -> list[QKDAngleValuesHWP]:
- if self is QKDEncodingBasis.HV:
- return [QKDAngleValuesHWP.H, QKDAngleValuesHWP.V]
- if self is QKDEncodingBasis.DA:
- return [QKDAngleValuesHWP.D, QKDAngleValuesHWP.A]
- msg = f"Unknown basis: {self}"
- raise ValueError(msg)
-
-
-class BasisBool(Enum):
- HV = 0
- DA = 1
-
-
-# FIXME: Populate missing bell states.
-class BellState(Enum):
- """Encodes."""
-
- Phi_plus = 0
- Psi_plus = 1
-
-
-DEFAULT_SETTINGS: dict[str, tuple[float, float]] = {
- "H": (0, 0),
- "V": (45, 0),
- "D": (22.5, 0),
- "A": (-22.5, 0),
- "R": (22.5, 45),
- "L": (-22.5, 45),
-}
-
-
-@dataclass(frozen=True)
-class MeasurementBasis:
- name: str
- pairs: list[tuple[str, str]]
- settings: dict[str, tuple[float, float]]
-
-
-HV_BASIS = MeasurementBasis(
- name="HV",
- pairs=[("H", "H"), ("H", "V"), ("V", "H"), ("V", "V")],
- settings=DEFAULT_SETTINGS,
-)
-DA_BASIS = MeasurementBasis(
- name="DA",
- pairs=[("D", "D"), ("D", "A"), ("A", "D"), ("A", "A")],
- settings=DEFAULT_SETTINGS,
-)
-RL_BASIS = MeasurementBasis(
- name="RL",
- pairs=[("R", "R"), ("R", "L"), ("L", "R"), ("L", "L")],
- settings=DEFAULT_SETTINGS,
-)
diff --git a/src/pqnstack/network/__init__.py b/src/pqnstack/network/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/pqnstack/network/client.py b/src/pqnstack/network/client.py
deleted file mode 100644
index 9cdaf368..00000000
--- a/src/pqnstack/network/client.py
+++ /dev/null
@@ -1,319 +0,0 @@
-import logging
-import pickle
-import secrets
-import string
-from collections.abc import Callable
-from dataclasses import dataclass
-from types import TracebackType
-from typing import Any
-from typing import NamedTuple
-from typing import Self
-
-import zmq
-
-from pqnstack.base.errors import PacketError
-from pqnstack.base.instrument import Instrument
-from pqnstack.base.instrument import InstrumentInfo
-from pqnstack.network.packet import NetworkElementClass
-from pqnstack.network.packet import Packet
-from pqnstack.network.packet import PacketIntent
-from pqnstack.network.packet import create_registration_packet
-
-logger = logging.getLogger(__name__)
-
-
-class ClientBase:
- def __init__(
- self,
- name: str = "",
- host: str = "127.0.0.1",
- port: int = 5555,
- router_name: str = "router1",
- timeout: int = 30000,
- ) -> None:
- if name == "":
- name = "".join(
- secrets.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(6)
- )
- self.name = name
-
- self.host = host
- self.port = port
- self.address = f"tcp://{host}:{port}"
- self.router_name = router_name
-
- self.timeout = timeout
-
- self.connected = False
- self.context: zmq.Context[zmq.Socket[bytes]] | None = None
- self.socket: zmq.Socket[bytes] | None = None
-
- self.connect()
-
- async def __aenter__(self) -> Self:
- if not self.connected:
- self.connect()
- return self
-
- async def __aexit__(
- self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
- ) -> None:
- self.disconnect()
-
- def connect(self) -> None:
- logger.info("Starting client '%s' Connecting to %s", self.name, self.address)
- self.context = zmq.Context()
- self.socket = self.context.socket(zmq.REQ)
- self.socket.setsockopt(zmq.RCVTIMEO, self.timeout)
- self.socket.setsockopt_string(zmq.IDENTITY, self.name)
- self.socket.connect(self.address)
- self.connected = True
-
- reg_packet = create_registration_packet(
- source=self.name, destination=self.router_name, payload=NetworkElementClass.CLIENT, hops=0
- )
- ret = self.ask(reg_packet)
- if ret is None:
- msg = "Something went wrong with the registration."
- raise RuntimeError(msg)
- if ret.intent != PacketIntent.REGISTRATION_ACK:
- msg = "Registration failed."
- raise RuntimeError(msg)
- logger.info("Acknowledged by server. Client is connected.")
-
- def disconnect(self) -> None:
- logger.info("Disconnecting from %s", self.address)
- if self.socket is None:
- logger.warning("Socket is already None.")
- self.connected = False
- return
-
- self.socket.close()
- self.connected = False
- logger.info("Disconnected from %s", self.address)
-
- def ask(self, packet: Packet) -> Packet:
- if not self.connected:
- msg = "No connection yet."
- logger.error(msg)
- raise RuntimeError(msg)
-
- if self.socket is None:
- msg = "Socket is None. Cannot ask."
- logger.error(msg)
- raise RuntimeError(msg)
-
- # try so that if timeout happens, the client remains usable
-
- self.socket.send(pickle.dumps(packet))
- try:
- response = self.socket.recv()
- except zmq.error.Again as e:
- logger.exception("Timeout occurred.")
- raise TimeoutError from e
-
- ret: Packet = pickle.loads(response)
- logger.debug("Response received.")
- logger.debug("Response: %s", str(ret))
- if ret.intent == PacketIntent.ERROR:
- raise PacketError(str(ret))
-
- return ret
-
- def create_control_packet(
- self, destination: str, request: str, payload: tuple[tuple[Any, ...], dict[str, Any]]
- ) -> Packet:
- return Packet(
- intent=PacketIntent.CONTROL,
- request=request,
- source=self.name,
- destination=destination,
- payload=payload,
- )
-
- def create_data_packet(self, destination: str, request: str, payload: Any) -> Packet:
- return Packet(
- intent=PacketIntent.DATA,
- request=request,
- source=self.name,
- destination=destination,
- payload=payload,
- )
-
-
-class InstrumentClientInit(NamedTuple):
- name: str
- host: str
- port: int
- router_name: str
- timeout: int
- instrument_name: str
- provider_name: str
-
-
-class InstrumentClient(ClientBase):
- def __init__(self, init_args: InstrumentClientInit) -> None:
- super().__init__(
- init_args.name, init_args.host, init_args.port, init_args.router_name, timeout=init_args.timeout
- )
-
- self.instrument_name = init_args.instrument_name
- self.provider_name = init_args.provider_name
-
- def trigger_operation(self, operation: str, *args: Any, **kwargs: Any) -> Any:
- packet = self.create_control_packet(
- self.provider_name, self.instrument_name + ":OPERATION:" + operation, (args, kwargs)
- )
- response = self.ask(packet)
-
- return response.payload
-
- def trigger_parameter(self, parameter: str, *args: Any, **kwargs: Any) -> Any:
- packet = self.create_control_packet(
- self.provider_name, self.instrument_name + ":PARAMETER:" + parameter, (args, kwargs)
- )
-
- response = self.ask(packet)
- return response.payload
-
- def get_info(self) -> InstrumentInfo:
- packet = self.create_control_packet(self.provider_name, self.instrument_name + ":INFO:", ((), {}))
-
- response = self.ask(packet)
- if not isinstance(response.payload, InstrumentInfo):
- msg = "Asking for info to proxy driver did not get a InstrumentInfo object."
- raise PacketError(msg)
-
- return response.payload
-
-
-class ProxyInstrumentInit(NamedTuple):
- name: str
- host: str
- port: int
- router_name: str
- instrument_name: str
- timeout: int
- provider_name: str
- desc: str
- address: str
- parameters: set[str]
- operations: dict[str, Callable[[Any], Any]]
- client_name: str
-
-
-@dataclass
-class ProxyInstrument(Instrument):
- """The address here is the zmq address of the router that the InstrumentClient will talk to."""
-
- name: str = ""
- desc: str = ""
- hw_address: str = ""
- host: str = "127.0.0.1"
- port: int = 5555
- timeout_ms: int = 15000
- router_name: str = "router1"
- client_name: str = ""
- provider_name: str = "provider1"
- instrument_name: str = "instrument1"
-
- # Boolean used to control when new attributes are being set.
- _instantiating: bool = True
-
- def __post_init__(self) -> None:
- # The client's name is the instrument name with "_client" appended and a random 6 character string appended.
- # This is to avoid any potential conflicts with other clients.
- client_name = (
- self.name
- + "_client_"
- + "".join(secrets.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(6))
- )
- instrument_client_init = InstrumentClientInit(
- name=client_name,
- host=self.host,
- port=self.port,
- router_name=self.router_name,
- timeout=self.timeout_ms,
- instrument_name=self.name,
- provider_name=self.provider_name,
- )
- self.client = InstrumentClient(instrument_client_init)
-
- self._instantiating = False
-
- def __getattr__(self, name: str) -> Any:
- if name in self.operations:
- return lambda *args, **kwargs: self.client.trigger_operation(name, *args, **kwargs)
- if name in self.parameters:
- return self.client.trigger_parameter(name)
- msg = f"Attribute '{name}' not found."
- raise AttributeError(msg)
-
- def __setattr__(self, name: str, value: Any) -> None:
- # Catch the first iteration
- if name == "_instantiating" or self._instantiating:
- super().__setattr__(name, value)
- return
- if name in self.parameters:
- self.client.trigger_parameter(name, value)
- return
- msg = "Cannot manually set attributes in a ProxyInstrument"
- raise AttributeError(msg)
-
- def start(self) -> None:
- pass
-
- def close(self) -> None:
- self.client.disconnect()
-
- @property
- def info(self) -> InstrumentInfo:
- return self.client.get_info()
-
-
-class Client(ClientBase):
- def ping(self, destination: str) -> Packet | None:
- ping_packet = Packet(
- intent=PacketIntent.PING, request="PING", source=self.name, destination=destination, hops=0, payload=None
- )
- return self.ask(ping_packet)
-
- def get_available_devices(self, provider_name: str) -> dict[str, str]:
- packet = self.create_data_packet(provider_name, "GET_DEVICES", None)
- response = self.ask(packet)
-
- if not isinstance(response.payload, dict):
- msg = "Payload is not a dictionary."
- raise PacketError(msg)
-
- return response.payload
-
- def get_device(self, provider_name: str, device_name: str, timeout_ms: int = 60_000) -> Instrument:
- packet = self.create_data_packet(provider_name, "GET_DEVICE_STRUCTURE", device_name)
-
- response = self.ask(packet)
-
- if response.intent == PacketIntent.ERROR:
- raise PacketError(str(response))
-
- if not isinstance(response.payload, dict):
- msg = "Payload is not a dictionary."
- raise PacketError(msg)
-
- if response.payload["name"] != device_name:
- msg = f"No device named {device_name}"
- raise ValueError(msg)
-
- return ProxyInstrument(
- name=response.payload["name"],
- desc=response.payload["desc"],
- hw_address=response.payload["hw_address"],
- host=self.host,
- port=self.port,
- router_name=self.router_name,
- timeout_ms=timeout_ms,
- instrument_name=response.payload["name"],
- provider_name=provider_name,
- parameters=set(response.payload["parameters"]),
- operations=response.payload["operations"],
- )
diff --git a/src/pqnstack/network/instrument_provider.py b/src/pqnstack/network/instrument_provider.py
deleted file mode 100644
index 709f2259..00000000
--- a/src/pqnstack/network/instrument_provider.py
+++ /dev/null
@@ -1,402 +0,0 @@
-import datetime
-import importlib
-import logging
-import pickle
-from typing import Any
-
-import zmq
-
-from pqnstack.base.errors import CouldNotConnectToNetworkElementError
-from pqnstack.base.errors import InvalidInstrumentsConfigurationError
-from pqnstack.base.instrument import Instrument
-from pqnstack.network.packet import NetworkElementClass
-from pqnstack.network.packet import Packet
-from pqnstack.network.packet import PacketIntent
-from pqnstack.network.packet import create_registration_packet
-
-logger = logging.getLogger(__name__)
-
-
-class InstrumentProvider:
- def __init__(
- self,
- name: str,
- host: str = "localhost",
- port: int = 5555,
- router_name: str = "router1",
- beat_period: int = 1000,
- **instruments: dict[str, Any],
- ) -> None:
- """
- InstrumentProvider class for PQN.
-
- A InstrumentProvider is the class that talks with real hardware and performs experiments. It talks to a
- single `Router` instance through zqm and awaits for instructions from it. Every `beat_interval` milliseconds,
- sends a registration packet to the router.
- This is done so if the router goes offline, the provider can reconnect to the router automatically.
-
- :param name: Name for the InstrumentProvider.
- :param host: Hostname or IP address of the Router this provider talks to.
- :param port: Port of the name of the Router this provider talks to.
- :param router_name: Name of the Router this provider talks to.
- :param beat_period: Interval in milliseconds to send a beat to the Router.
- :param instruments: Instruments is a Dictionary holding the necessary instructions to initialize any hardware
- the InstrumentProvider talks to. The keys are the names of the instruments, every key has another dictionary as its value
- with all the necessary instructions to initialize the instrument. Inside of the dictionary for the specific
- instrument, a key called 'import' is required holding the import path for that specific instrument.
- Note that the name is not necessary since that is the key of the dictionary.
-
- e.g.
- ```
- instruments = {
- "rotator_1": {
- "import": "pqnstack.pqn.drivers.rotator.Rotator",
- "desc": "Rotator in optical table 1",
- "hw_address": "83860213",
- **extra_kwargs
- }
- }
- """
- self.name = name
- self.host = host
- self.port = port
- self.address = f"tcp://{host}:{port}"
- self.router_name = router_name
- self.beat_period = beat_period
- self._beats_since_reply = 0
- self._last_received_beat: datetime.datetime | None = None
- # How many sent beats with no replies do we start logging warnings for disconnected routers.
- self._disconnected_threshold = 3
-
- self.context: zmq.Context[zmq.Socket[bytes]] | None = None
- self.socket: zmq.Socket[bytes] | None = None # Has the instance of the socket talking to the router.
-
- # Verify that every instrument contains the minimum required keys.
- for ins_name, ins_dict in instruments.items():
- if not isinstance(ins_dict, dict):
- msg = f"{ins_name} is not a dictionary, please provide the necessary instructions for this instrument"
- raise InvalidInstrumentsConfigurationError(msg)
-
- if "import" not in ins_dict:
- msg = f"{ins_name} is missing its 'import' key, please provide an import path for this instrument"
- raise InvalidInstrumentsConfigurationError(msg)
-
- if "desc" not in ins_dict:
- msg = f"{ins_name} is missing its 'desc' key, please provide a description for this instrument"
- raise InvalidInstrumentsConfigurationError(msg)
-
- if "hw_address" not in ins_dict:
- msg = f"{ins_name} is missing its 'hw_address' key, please provide an address for this instrument"
- raise InvalidInstrumentsConfigurationError(msg)
-
- self.instruments = instruments
- self.instantiated_instruments: dict[str, Instrument] = {}
-
- self.running = False
-
- def instantiate_instruments(self) -> None:
- for ins_name, ins_dict in self.instruments.items():
- ins_import = ins_dict.pop("import")
- ins_desc = ins_dict.pop("desc")
- ins_hw_address = ins_dict.pop("hw_address")
-
- logger.info("Instantiating %s", ins_name)
- try:
- module_name, class_name = ins_import.rsplit(".", 1)
- module = importlib.import_module(module_name)
- class_ = getattr(module, class_name)
- except (ImportError, AttributeError) as e:
- msg = f"Could not import {ins_import}. Please verify the import path for this instrument."
- raise InvalidInstrumentsConfigurationError(msg) from e
-
- try:
- ins = class_(name=ins_name, desc=ins_desc, hw_address=ins_hw_address, **ins_dict)
- ins.start()
- # FIXME: Figure out what the exception type could be if the instrument cannot be instantiated.
- except Exception as e:
- msg = f"Could not instantiate {ins_import}. Please verify the parameters for this instrument."
- raise InvalidInstrumentsConfigurationError(msg) from e
-
- self.instantiated_instruments[ins_name] = ins
- logger.info("Successfully instantiated %s", ins_name)
-
- def start(self) -> None:
- self.instantiate_instruments()
-
- logger.info("Starting provider %s at %s", self.name, self.address)
- self.context = zmq.Context()
- self.socket = self.context.socket(zmq.DEALER)
- self.socket.setsockopt_string(zmq.IDENTITY, self.name)
-
- # Wait 10 beats for the first check before timing out
- self.socket.setsockopt(zmq.RCVTIMEO, self.beat_period * 10)
- try:
- self._beat()
- except zmq.error.Again as er:
- logger.exception("Could not connect to router at %s", self.address)
- msg = "Could not connect to router."
- raise CouldNotConnectToNetworkElementError(msg) from er
-
- # Set the beat interval to the normal value.
- self.socket.setsockopt(zmq.RCVTIMEO, self.beat_period)
- self.running = True
-
- try:
- while self.running:
- try:
- packet = self._listen()
- except zmq.error.ZMQError:
- logger.debug("Time interval happened, sending a beat.")
- self._beat()
- continue
-
- match packet.intent:
- case PacketIntent.PING:
- response = self._handle_ping(packet)
- self.socket.send(pickle.dumps(response))
-
- case PacketIntent.REGISTRATION_ACK:
- self._handle_reg_acknowledge()
-
- case PacketIntent.DATA:
- match packet.request:
- case "GET_DEVICES":
- response = self._handle_get_devices(packet)
- self.socket.send(pickle.dumps(response))
-
- case "GET_DEVICE_STRUCTURE":
- response = self._handle_get_device_structure(packet)
- self.socket.send(pickle.dumps(response))
-
- case PacketIntent.CONTROL:
- response = self._handle_instrument_control(packet)
- self.socket.send(pickle.dumps(response))
-
- finally:
- self.socket.close()
-
- def _listen(self) -> Packet:
- # This should never happen, but mypy complains if the check is not done
- if self.socket is None:
- msg = "Socket is None, cannot listen."
- logger.error(msg)
- raise RuntimeError(msg)
-
- _, pickled_packet = self.socket.recv_multipart()
- packet = pickle.loads(pickled_packet)
- if packet.destination != self.name:
- # FIXME: This should return an error packet instead of just crashing
- msg = f"Packet intended for {packet.destination} but received by {self.name}. Packet: {packet}"
- raise RuntimeError(msg)
-
- logger.info("Received packet: %s", packet)
-
- if not isinstance(packet, Packet):
- msg = f"Received packet is not a Packet object, got {type(packet)}"
- raise TypeError(msg)
-
- return packet
-
- def _beat(self) -> None:
- """
- Execute a single beat to the ROUTER.
-
- This is the same process as initial registration. If the Router cannot be reached, logs the message and keeps going.
-
- :return:
- """
- if self._beats_since_reply > self._disconnected_threshold:
- local_time = self._last_received_beat.astimezone() if self._last_received_beat else None
- logger.warning(
- "Router at %s did not reply to heartbeat, router seems offline. Will keep trying to reconnect. \n Beats since reply: %s. Time at last reply %s",
- self.address,
- self._beats_since_reply,
- local_time,
- )
-
- try:
- # This should never happen, but mypy complains if the check is not done
- if self.socket is None:
- msg = "Socket is None, cannot listen."
- logger.error(msg)
- raise RuntimeError(msg)
-
- self.socket.connect(self.address)
- reg_packet = create_registration_packet(
- source=self.name, destination=self.router_name, payload=NetworkElementClass.PROVIDER, hops=0
- )
- self.socket.send(pickle.dumps(reg_packet))
- logger.info("Sent registration packet to router at %s", self.address)
- self._beats_since_reply += 1
-
- except zmq.error.Again:
- logger.warning("Error while sending beat to router at %s", self.address)
-
- def _handle_reg_acknowledge(self) -> None:
- logger.info("InstrumentProvider %s is connected to router at %s", self.name, self.address)
- self.running = True
- self._beats_since_reply = 0
- self._last_received_beat = datetime.datetime.now(tz=datetime.UTC)
-
- def _handle_ping(self, packet: Packet) -> Packet:
- return Packet(
- intent=PacketIntent.PING, request="PONG", source=self.name, destination=packet.source, payload=None
- )
-
- def _handle_get_devices(self, packet: Packet) -> Packet:
- ret_instruments = {name: type(ins) for name, ins in self.instantiated_instruments.items()}
- return Packet(
- intent=PacketIntent.DATA,
- request="GET_DEVICES",
- source=self.name,
- destination=packet.source,
- payload=ret_instruments,
- )
-
- def _handle_get_device_structure(self, packet: Packet) -> Packet:
- if packet.payload not in self.instantiated_instruments:
- return self._create_error_packet(packet.source, f"Instrument '{packet.payload}' not found.")
-
- ins_name = packet.payload
- if not isinstance(ins_name, str):
- return self._create_error_packet(
- packet.source, f"Payload must be the instrument name as a string, not {type(ins_name)}"
- )
-
- params = self.instantiated_instruments[ins_name].parameters
- operations = set(self.instantiated_instruments[ins_name].operations.keys())
-
- payload = {
- "name": self.instantiated_instruments[ins_name].name,
- "desc": self.instantiated_instruments[ins_name].desc,
- "hw_address": self.instantiated_instruments[ins_name].hw_address,
- "parameters": params,
- "operations": operations,
- }
-
- return Packet(
- intent=PacketIntent.DATA,
- request="GET_DEVICE_STRUCTURE",
- source=self.name,
- destination=packet.source,
- payload=payload,
- )
-
- def _validate_instrument_control_packet(
- self, packet: Packet
- ) -> tuple[str, str, str, Instrument, tuple[Any, ...], dict[str, Any]] | Packet:
- request_parts = packet.request.split(":")
- correct_request_len = 3
- if len(request_parts) != correct_request_len:
- msg = (
- f"CONTROL packets should have a request field with 3 parts divided by a ':', "
- f"not {len(request_parts)}, formatted as: "
- f"::"
- )
- return self._create_error_packet(packet.source, msg)
-
- ins_name, request_type, request_name = request_parts
- if ins_name not in self.instantiated_instruments:
- return self._create_error_packet(packet.source, f"Instrument '{ins_name}' not found.")
-
- instrument = self.instantiated_instruments[ins_name]
-
- if request_type not in ["OPERATION", "PARAMETER", "INFO"]:
- msg = f"Request type must be either 'OPERATION', 'PARAMETER', 'INFO', not {request_type}"
- return self._create_error_packet(packet.source, msg)
-
- if not isinstance(packet.payload, tuple):
- msg = (
- f"Payload must be a tuple with the arguments and kwargs (have empty args and kwargs "
- f"if not necessary) for the operation or parameter, not {type(packet.payload)}"
- )
- return self._create_error_packet(packet.source, msg)
-
- args, kwargs = packet.payload
- return ins_name, request_type, request_name, instrument, args, kwargs
-
- def _handle_operation_control(
- self, request_name: str, instrument: Instrument, packet: Packet, args: tuple[Any, ...], kwargs: dict[str, Any]
- ) -> Packet:
- if request_name not in instrument.operations:
- return self._create_error_packet(
- packet.source, f"Operation '{request_name}' not found in '{instrument.name}'"
- )
-
- try:
- operation_ret = instrument.operations[request_name](*args, **kwargs)
- # Adding ruff exception due to not know what type of exceptions instruments can raise.
- except Exception as e: # noqa:BLE001
- msg = f"Error executing operation '{request_name}' in '{instrument.name}'. Error: {e}"
- return self._create_error_packet(packet.source, msg)
-
- return self._create_control_packet(packet.source, f"{instrument.name}:OPERATION:{request_name}", operation_ret)
-
- def _handle_parameter_control(
- self, request_name: str, instrument: Instrument, packet: Packet, args: tuple[Any, ...], kwargs: dict[str, Any]
- ) -> Packet:
- if request_name not in instrument.parameters:
- return self._create_error_packet(
- packet.source, f"Parameter '{request_name}' not found in '{instrument.name}'"
- )
-
- # Check if this is just reading the parameter or setting it.
- if len(args) == 0 and len(kwargs) == 0:
- try:
- parameter_ret = getattr(instrument, request_name)
- except AttributeError as e:
- msg = f"Error reading parameter '{request_name}' in '{instrument.name}'. Error: {e}"
- return self._create_error_packet(packet.source, msg)
-
- return self._create_control_packet(
- packet.source, f"{instrument.name}:PARAMETER:{request_name}", parameter_ret
- )
-
- try:
- setattr(instrument, request_name, *args, **kwargs)
- # TODO: Double check this exception type, I am not entirely sure this would work.
- except AttributeError as e:
- msg = f"Error setting parameter '{request_name}' in '{instrument.name}'. Error: {e}"
- return self._create_error_packet(packet.source, msg)
-
- return self._create_control_packet(packet.source, f"{instrument.name}:PARAMETER:{request_name}", "OK")
-
- def _handle_instrument_control(self, packet: Packet) -> Packet:
- validated_packet = self._validate_instrument_control_packet(packet)
-
- # If it's a packet it means that there was an error with the incoming packet so return the error packet.
- if isinstance(validated_packet, Packet):
- return validated_packet
-
- ins_name, request_type, request_name, instrument, args, kwargs = validated_packet
-
- if request_type == "OPERATION":
- return self._handle_operation_control(request_name, instrument, packet, args, kwargs)
-
- if request_type == "PARAMETER":
- return self._handle_parameter_control(request_name, instrument, packet, args, kwargs)
-
- if request_type == "INFO":
- return self._create_control_packet(packet.source, f"{ins_name}:INFO", instrument.info)
-
- # All the possible packet options should have been handled by now, so if we get here, something went wrong.
- msg = f"Something inside provider {self.name} went wrong. Check that your packet is correct and try again."
- return self._create_error_packet(packet.source, msg)
-
- def _create_error_packet(self, destination: str, error_msg: str) -> Packet:
- return Packet(
- intent=PacketIntent.ERROR,
- request="ERROR",
- source=self.name,
- destination=destination,
- payload=error_msg,
- )
-
- def _create_control_packet(self, destination: str, request: str, payload: Any) -> Packet:
- return Packet(
- intent=PacketIntent.CONTROL,
- request=request,
- source=self.name,
- destination=destination,
- payload=payload,
- )
diff --git a/src/pqnstack/network/packet.py b/src/pqnstack/network/packet.py
deleted file mode 100644
index c6b63927..00000000
--- a/src/pqnstack/network/packet.py
+++ /dev/null
@@ -1,60 +0,0 @@
-# University of Illinois Urbana-Champaign
-# Public Quantum Network
-#
-# NCSA/Illinois Computes
-#
-#
-from dataclasses import dataclass
-from enum import Enum
-from enum import auto
-from typing import Any
-
-from pqnstack.base.errors import PacketError
-
-
-class NetworkElementClass(Enum):
- ROUTER = auto()
- PROVIDER = auto()
- CLIENT = auto()
- TELEMETRY = auto()
-
-
-class PacketIntent(Enum):
- DATA = auto()
- PROTOCOL = auto()
- CONTROL = auto()
- REGISTRATION = auto()
- REGISTRATION_ACK = auto()
- ROUTING = auto() # These are used for discovering network topology automatically
- PING = auto()
- ERROR = auto()
-
-
-@dataclass(kw_only=True)
-class Packet:
- intent: PacketIntent
- request: str
- source: str
- destination: str
- payload: object = None
- hops: int = 0
- version: int = 1
-
- def signature(self) -> tuple[str, str, str]:
- return self.intent.name, self.request, str(self.payload)
-
- def routing(self) -> tuple[str, str]:
- return self.source, self.destination
-
-
-def create_registration_packet(**kwargs: Any) -> Packet:
- if "payload" not in kwargs:
- msg = "payload argument not present when creating registration packet."
- raise PacketError(msg)
-
- if not isinstance(kwargs["payload"], NetworkElementClass):
- msg = "payload argument must be of type NetworkElementClass."
- raise PacketError(msg)
-
- kwargs |= {"intent": PacketIntent.REGISTRATION, "request": "REGISTER"}
- return Packet(**kwargs)
diff --git a/src/pqnstack/network/router.py b/src/pqnstack/network/router.py
deleted file mode 100644
index dca0f810..00000000
--- a/src/pqnstack/network/router.py
+++ /dev/null
@@ -1,152 +0,0 @@
-import copy
-import logging
-import pickle
-
-import zmq
-
-from pqnstack.network.packet import NetworkElementClass
-from pqnstack.network.packet import Packet
-from pqnstack.network.packet import PacketIntent
-
-logger = logging.getLogger(__name__)
-
-
-# FIXME: handle not finding destination and source better
-class Router:
- def __init__(self, name: str, host: str = "localhost", port: int = 5555) -> None:
- self.name = name
- self.host = host
- self.port = port
-
- # TODO: Verify that this address is valid
- self.address = f"tcp://{host}:{port}"
-
- # FIXME, breaking this into 3 different dictionaries is probably not the way to go.
- self.routers: dict[str, bytes] = {} # Holds what other routers are in the network
- self.providers: dict[str, bytes] = {}
- self.clients: dict[str, bytes] = {}
-
- self.context: zmq.Context[zmq.Socket[bytes]] | None = None
- self.socket: zmq.Socket[bytes] | None = None
- self.running = False
-
- def start(self) -> None:
- logger.info("Starting router %s at %s", self.name, self.address)
- self.context = zmq.Context()
- self.socket = self.context.socket(zmq.ROUTER)
- self.socket.bind(self.address)
- logger.info("Router %s is now listening on %s", self.name, self.address)
- self.running = True
-
- try:
- while self.running:
- identity_binary, packet = self.listen()
- if packet is None or identity_binary is None:
- logger.error("Error listening to packets. Either the packet is None or the identity is None.")
- continue
-
- match packet.intent:
- case PacketIntent.REGISTRATION:
- self.handle_registration(identity_binary, packet)
- case PacketIntent.ROUTING:
- logger.info("Got routing packet from %s", identity_binary)
- case _:
- self.handle_pass_packet(identity_binary, packet)
-
- finally:
- self.socket.close()
-
- def handle_registration(self, identity_binary: bytes, packet: Packet) -> None:
- if packet.destination != self.name:
- self.handle_packet_error(identity_binary, f"Router {self.name} is not the destination")
- return
- match packet.payload:
- case NetworkElementClass.PROVIDER:
- self.providers[packet.source] = identity_binary
- logger.info("InstrumentProvider %s registered", identity_binary)
- case NetworkElementClass.CLIENT:
- self.clients[packet.source] = identity_binary
- logger.info("Client %s registered", identity_binary)
- case NetworkElementClass.ROUTER:
- self.routers[packet.source] = identity_binary
- logger.info("Router %s registered", identity_binary)
-
- ack_packet = Packet(
- intent=PacketIntent.REGISTRATION_ACK,
- source=self.name,
- destination=identity_binary.decode("utf-8"),
- hops=0,
- request="ACKNOWLEDGE",
- payload=None,
- )
- self._send(identity_binary, ack_packet)
-
- def handle_pass_packet(self, identity_binary: bytes, packet: Packet) -> None:
- """Handle all the logic to get a packet from one place to another."""
- if packet.destination == self.name:
- logger.info("Packet destination is self, dropping")
-
- elif packet.destination in self.providers or packet.destination in self.clients:
- logger.info("Packet destination is a provider called %s, routing message there", packet.destination)
- forward_packet = copy.copy(packet)
- forward_packet.hops += 1
- dest = self.providers.get(packet.destination) or self.clients.get(packet.destination)
- if dest is None:
- self.handle_packet_error(identity_binary, f"Destination {packet.destination} not found.")
- return
- self._send(dest, forward_packet)
- logger.info("Sent packet to %s", packet.destination)
-
- else:
- logger.info("Packet destination is not a provider will ask other routers in system")
- # FIXME: This is temporary and should be replaced with the routing algorithm.
- self.handle_packet_error(identity_binary, "Routing not implemented yet.")
-
- def listen(self) -> tuple[bytes, Packet] | tuple[None, None]:
- # This should never happen, but mypy complains if the check is not done
- if self.socket is None:
- msg = "Socket is None, cannot listen."
- logger.error(msg)
- raise RuntimeError(msg)
-
- # Depending on who is sending a request, the number of items received will be different. This is not
- # DEALER sockets send 2 items, REQ sockets send an empty delimiter.
- dealer_parts = 2
- req_parts = 3
- request = self.socket.recv_multipart()
- if len(request) == dealer_parts:
- identity_binary, pickled_packet = request
- elif len(request) == req_parts:
- identity_binary, _, pickled_packet = request
- else:
- self.handle_packet_error(request[0], f"Requests can only have 2 or 3 parts, not {len(request)}")
- return None, None
-
- packet = pickle.loads(pickled_packet)
- logger.info("Received packet from %s: %s", identity_binary, packet)
- return identity_binary, packet
-
- def _send(self, destination: bytes, packet: Packet) -> None:
- # This should never happen, but mypy complains if the check is not done
- if self.socket is None:
- msg = "Socket is None, cannot send message."
- logger.error(msg)
- raise RuntimeError(msg)
-
- logger.info("Sending packet to %s | Packet: %s", packet.destination, packet)
- self.socket.send_multipart([destination, b"", pickle.dumps(packet)])
- logger.info("Packet sent to %s", packet.destination)
-
- # TODO: This should reply with a standard, error in your packet message to whoever sent the packet instead of
- # just logging.
- def handle_packet_error(self, destination: bytes, message: str) -> None:
- logger.error(message)
- error_packet = Packet(
- intent=PacketIntent.ERROR,
- request="ERROR",
- source=self.name,
- destination=destination.decode("utf-8"),
- hops=0,
- payload=message,
- )
- self._send(destination, error_packet)
diff --git a/src/pqnstack/pqn/__init__.py b/src/pqnstack/pqn/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/pqnstack/pqn/drivers/__init__.py b/src/pqnstack/pqn/drivers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/pqnstack/pqn/drivers/chsh_driver.py b/src/pqnstack/pqn/drivers/chsh_driver.py
deleted file mode 100644
index 6c0b53b8..00000000
--- a/src/pqnstack/pqn/drivers/chsh_driver.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import logging
-from dataclasses import dataclass
-from dataclasses import field
-from typing import cast
-
-from pqnstack.base.instrument import Instrument
-from pqnstack.base.instrument import InstrumentInfo
-from pqnstack.base.instrument import RotatorInstrument
-from pqnstack.base.instrument import TimeTaggerInstrument
-from pqnstack.base.instrument import log_operation
-from pqnstack.network.client import Client
-from pqnstack.pqn.protocols.chsh import Devices
-from pqnstack.pqn.protocols.chsh import measure_chsh
-from pqnstack.pqn.protocols.measurement import CHSHValue
-from pqnstack.pqn.protocols.measurement import MeasurementConfig
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True, slots=True)
-class CHSHInfo(InstrumentInfo):
- queue_length: int = 0
-
-
-@dataclass(slots=True)
-class CHSHDevice(Instrument):
- motor_config: dict[str, dict[str, str]] = field(default_factory=dict)
- tagger_config: dict[str, str] = field(default_factory=dict)
- queue_length: int = field(default=0)
-
- _motors: dict[str, RotatorInstrument] = field(init=False, repr=False)
- _tagger: TimeTaggerInstrument = field(init=False, repr=False)
- _client: Client = field(init=False, repr=False)
-
- _players: dict[str, bool] = field(default_factory=dict, init=False, repr=False)
- _submissions: dict[str, bool] = field(default_factory=dict, init=False, repr=False)
- _value_gathered: dict[str, bool] = field(default_factory=dict, init=False, repr=False)
- _value: int = field(default=0, init=False, repr=False)
-
- def __post_init__(self) -> None:
- self._client = Client(host="172.30.63.109", timeout=600000)
- self._motors: dict[str, RotatorInstrument] = {
- motor: cast("RotatorInstrument", self._client.get_device(values["location"], values["name"]))
- for motor, values in self.motor_config.items()
- }
- self._tagger: TimeTaggerInstrument = cast(
- "TimeTaggerInstrument", self._client.get_device(self.tagger_config["location"], self.tagger_config["name"])
- )
- self.operations["measure_chsh"] = self.measure_chsh
-
- def start(self) -> None:
- logger.info("CHSHDevice started.")
-
- def close(self) -> None:
- logger.info("CHSHDevice closed.")
-
- @property
- def info(self) -> CHSHInfo:
- return CHSHInfo(
- name=self.name,
- desc=self.desc,
- hw_address=self.hw_address,
- queue_length=self.queue_length,
- )
-
- @log_operation
- def measure_chsh(self, basis1: list[float], basis2: list[float], config: MeasurementConfig) -> CHSHValue:
- self.queue_length += 1
- devices = Devices(
- idler_hwp=self._motors["idler_hwp"],
- idler_qwp=self._motors.get("idler_qwp"),
- signal_hwp=self._motors["signal_hwp"],
- signal_qwp=self._motors.get("signal_qwp"),
- timetagger=self._tagger,
- )
-
- self.queue_length -= 1
-
- return measure_chsh(
- basis1=basis1,
- basis2=basis2,
- devices=devices,
- config=config,
- )
diff --git a/src/pqnstack/pqn/drivers/dummies.py b/src/pqnstack/pqn/drivers/dummies.py
deleted file mode 100644
index 9f80d951..00000000
--- a/src/pqnstack/pqn/drivers/dummies.py
+++ /dev/null
@@ -1,105 +0,0 @@
-import time
-from dataclasses import dataclass
-
-from pqnstack.base.instrument import Instrument
-from pqnstack.base.instrument import InstrumentInfo
-from pqnstack.base.instrument import log_operation
-from pqnstack.base.instrument import log_parameter
-
-
-@dataclass(frozen=True, slots=True)
-class DummyInfo(InstrumentInfo):
- param_int: int = 0
- param_str: str = ""
- param_bool: bool = False
-
-
-@dataclass(slots=True)
-class DummyInstrument(Instrument):
- _param_int: int = 2
- _param_str: str = "hello"
- _param_bool: bool = True
- connected: bool = False
-
- def __post_init__(self) -> None:
- self.parameters = {"param_int", "param_str", "param_bool"}
- self.operations = {
- "double_int": self.double_int,
- "lowercase_str": self.lowercase_str,
- "uppercase_str": self.uppercase_str,
- "toggle_bool": self.toggle_bool,
- "set_half_input_int": self.set_half_input_int,
- }
-
- @property
- def info(self) -> DummyInfo:
- return DummyInfo(
- name=self.name,
- desc=self.desc,
- hw_address=self.hw_address,
- param_int=self.param_int,
- param_str=self.param_str,
- param_bool=self.param_bool,
- )
-
- def start(self) -> None:
- self.connected = True
-
- def close(self) -> None:
- self.connected = False
-
- @property
- @log_parameter
- def param_int(self) -> int:
- return self._param_int
-
- @param_int.setter
- @log_parameter
- def param_int(self, value: int) -> None:
- self._param_int = value
-
- @property
- @log_parameter
- def param_str(self) -> str:
- return self._param_str
-
- @param_str.setter
- @log_parameter
- def param_str(self, value: str) -> None:
- self._param_str = value
-
- @property
- @log_parameter
- def param_bool(self) -> bool:
- return self._param_bool
-
- @param_bool.setter
- @log_parameter
- def param_bool(self, value: bool) -> None:
- self._param_bool = value
-
- @log_operation
- def double_int(self) -> int:
- self._param_int *= 2
- return self._param_int
-
- @log_operation
- def set_half_input_int(self, value: int) -> int:
- self._param_int = value // 2
- return self._param_int
-
- @log_operation
- def lowercase_str(self) -> str:
- self._param_str = self._param_str.lower()
- return self._param_str
-
- @log_operation
- def uppercase_str(self) -> str:
- self._param_str = self._param_str.upper()
- return self._param_str
-
- @log_operation
- def toggle_bool(self) -> bool:
- time.sleep(1.4) # Simulate a long operation
- self._param_bool = not self._param_bool
- return self._param_bool
diff --git a/src/pqnstack/pqn/drivers/polarimeter.py b/src/pqnstack/pqn/drivers/polarimeter.py
deleted file mode 100644
index 823809ae..00000000
--- a/src/pqnstack/pqn/drivers/polarimeter.py
+++ /dev/null
@@ -1,139 +0,0 @@
-import logging
-import math
-from collections import deque
-from dataclasses import KW_ONLY
-from dataclasses import dataclass
-from dataclasses import field
-
-from pyfirmata2 import Arduino
-
-from pqnstack.base.instrument import PolarimeterInstrument
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass(slots=True)
-class Buffer:
- _buffer: deque[float]
- normalizing: bool = field(default=False)
- min: float = field(default=float("inf"), init=False)
- max: float = field(default=float("-inf"), init=False)
-
- def __post_init__(self) -> None:
- self.clear()
-
- def __len__(self) -> int:
- return len(self._buffer)
-
- def clear(self) -> None:
- """Clear all values in the buffer."""
- self._buffer.clear()
- self.min = float("inf")
- self.max = float("-inf")
-
- def append(self, value: float) -> None:
- self._buffer.append(value)
- if self.normalizing:
- self.min = min(self.min, value)
- self.max = max(self.max, value)
-
- def read(self) -> float:
- if len(self._buffer) == 0:
- return 0.0
-
- if self.max <= self.min:
- return 0.0
-
- avg = sum(self._buffer) / len(self._buffer)
- return (avg - self.min) / (self.max - self.min)
-
-
-@dataclass(frozen=True, slots=True)
-class PolarizationMeasurement:
- h: float
- v: float
- d: float
- a: float
- _last_theta: float = field(default=0.0, repr=False, kw_only=True) # HACK: Allow reporting of full 2pi angle
-
- def __format__(self, spec: str, /) -> str:
- if not spec:
- return self.__repr__()
- return f"{type(self).__name__}(h={self.h:{spec}}, v={self.v:{spec}}, d={self.d:{spec}}, a={self.a:{spec}})"
-
- @property
- def theta(self) -> float:
- """Return the calculated polarization angle in degrees."""
- if self.h + self.v == 0 or self.d + self.a == 0:
- return 0.0
-
- # Read polarization angle from photodiodes
- h = self.h / (self.h + self.v)
- radians = math.acos(math.sqrt(h))
- sign = math.copysign(1, self.a - self.d)
- degrees = sign * math.degrees(radians) % 180
-
- # Shift based on previous angle to allow full 0-360 range
- shifted = self._last_theta // 180
- prev_wedge = self._last_theta % 180 // 60
- new_wedge = degrees // 60
-
- if abs(new_wedge - prev_wedge) > 1:
- shifted = not shifted
-
- if shifted:
- degrees += 180
-
- return degrees % 360
-
- @property
- def phi(self) -> float:
- raise NotImplementedError
-
-
-@dataclass(slots=True)
-class ArduinoPolarimeter(PolarimeterInstrument):
- sample_rate: int = 10
- average_width: int = 10
- _: KW_ONLY
- board: Arduino = field(default_factory=lambda: Arduino(Arduino.AUTODETECT))
- pins: dict[str, int] = field(default_factory=lambda: dict(zip("hvda", range(4), strict=False)))
- _buffers: list[Buffer] = field(default_factory=list, init=False)
- _last_theta: float = field(default=0.0, init=False, repr=False) # HACK: Allow reporting of full 2pi angle
-
- def start(self) -> None:
- if not self.board:
- self.board = Arduino(Arduino.AUTODETECT)
- self.board.samplingOn(1000 // self.sample_rate)
- for pin in self.pins.values():
- buffer = Buffer(deque(maxlen=self.average_width))
- self._buffers.append(buffer)
- self.board.analog[pin].register_callback(buffer.append)
- self.board.analog[pin].enable_reporting()
- logger.info("Polarimeter started")
-
- def close(self) -> None:
- if self.board is not None:
- logger.info("Polarimeter stopped")
- self.board.exit()
-
- def reset(self) -> None:
- self._last_theta = 0.0
- for buffer in self._buffers:
- buffer.clear()
-
- def start_normalizing(self) -> None:
- self._last_theta = 0.0
- for buffer in self._buffers:
- buffer.clear()
- buffer.normalizing = True
-
- def stop_normalizing(self) -> None:
- for buffer in self._buffers:
- buffer.normalizing = False
-
- def read(self) -> PolarizationMeasurement:
- hvda = [buffer.read() for buffer in self._buffers]
- pm = PolarizationMeasurement(*hvda, _last_theta=self._last_theta)
- self._last_theta = pm.theta
- return pm
diff --git a/src/pqnstack/pqn/drivers/qkd_driver.py b/src/pqnstack/pqn/drivers/qkd_driver.py
deleted file mode 100644
index cca59d2e..00000000
--- a/src/pqnstack/pqn/drivers/qkd_driver.py
+++ /dev/null
@@ -1,120 +0,0 @@
-from dataclasses import dataclass
-from dataclasses import field
-from typing import cast
-
-from pqnstack.base.errors import DeviceNotStartedError
-from pqnstack.base.instrument import Instrument
-from pqnstack.base.instrument import InstrumentInfo
-from pqnstack.base.instrument import TimeTaggerInstrument
-from pqnstack.base.instrument import log_operation
-from pqnstack.network.client import Client
-
-
-@dataclass(frozen=True, slots=True)
-class QKDInfo(InstrumentInfo):
- number_trials: int = 0
- trial_values: list[float] = field(default_factory=list)
-
-
-@dataclass(slots=True)
-class QKDDevice(Instrument):
- motor_config: dict[str, dict[str, str]] = field(default_factory=dict)
- tagger_config: dict[str, str] = field(default_factory=dict)
-
- _tagger: TimeTaggerInstrument = field(init=False, repr=False)
- _client: Client = field(init=False, repr=False)
-
- _players: dict[str, bool] = field(default_factory=dict, init=False, repr=False)
- _submissions: dict[str, bool] = field(default_factory=dict, init=False, repr=False)
- _value_gathered: dict[str, bool] = field(default_factory=dict, init=False, repr=False)
- _value: int = 0
-
- def __post_init__(self) -> None:
- self._client = Client(host="172.30.63.109", timeout=30000)
-
- self._players: dict[str, bool] = {"player1": False, "player2": False}
- self._submissions: dict[str, bool] = {"player1": False, "player2": False}
- self._value_gathered: dict[str, bool] = {"player1": False, "player2": False}
-
- self.operations["add_player"] = self.add_player
- self.operations["remove_player"] = self.remove_player
- self.operations["get_motors"] = self.get_motors
- self.operations["submit"] = self.submit
- self.operations["get_counts"] = self.get_counts
-
- def start(self) -> None:
- self._set_tagger(self.tagger_config)
-
- def close(self) -> None:
- return
-
- @property
- def info(self) -> QKDInfo:
- return QKDInfo(
- name=self.name,
- desc=self.desc,
- hw_address=self.hw_address,
- number_trials=0,
- trial_values=[],
- )
-
- @log_operation
- def _set_motors(self, **kwargs: dict[str, str]) -> None:
- self.motor_config.update(kwargs)
-
- @log_operation
- def _set_tagger(self, tagger: dict[str, str]) -> None:
- self._tagger = cast("TimeTaggerInstrument", self._client.get_device(tagger["location"], tagger["name"]))
-
- @log_operation
- def add_player(self) -> str:
- for player, active in self._players.items():
- if not active:
- self._players[player] = True
- return player
- return ""
-
- @log_operation
- def remove_player(self, player: str) -> None:
- if player in self._players:
- self._players[player] = False
-
- @log_operation
- def get_motors(self, player: str) -> dict[str, dict[str, str]]:
- if player not in self._players:
- return {}
- key_filter = "signal" if player == "player1" else "idler"
- return {name: info for name, info in self.motor_config.items() if key_filter in name}
-
- @log_operation
- def submit(self, player: str) -> None:
- if player in self._submissions:
- self._submissions[player] = True
-
- if self._all_submitted():
- if self._tagger is None:
- msg = "TimeTagger is not set"
- raise DeviceNotStartedError(msg)
- self._value = self._tagger.measure_correlation(1, 2, integration_time_s=5, binwidth_ps=5)
-
- def _all_submitted(self) -> bool:
- return all(self._submissions.values())
-
- def _all_measured(self) -> bool:
- return all(self._value_gathered.values())
-
- @log_operation
- def get_counts(self, player: str) -> int:
- counts = -1
-
- if self._all_submitted():
- self._value_gathered[player] = True
- counts = self._value
-
- if self._all_measured():
- self._value = 0
- for key in self._submissions:
- self._submissions[key] = False
- self._value_gathered[key] = False
-
- return counts
diff --git a/src/pqnstack/pqn/drivers/rotaryencoder.py b/src/pqnstack/pqn/drivers/rotaryencoder.py
deleted file mode 100644
index 04daca89..00000000
--- a/src/pqnstack/pqn/drivers/rotaryencoder.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import atexit
-from dataclasses import dataclass
-from dataclasses import field
-from typing import Protocol
-from typing import runtime_checkable
-
-import serial
-
-
-@runtime_checkable
-class RotaryEncoderInstrument(Protocol):
- def read(self) -> float: ...
-
-
-@dataclass(slots=True)
-class SerialRotaryEncoder:
- label: str
- address: str
- offset_degrees: float = 0.0
- _conn: serial.Serial = field(init=False, repr=False)
-
- def __post_init__(self) -> None:
- self._conn = serial.Serial(self.address, baudrate=115200, timeout=1)
- self._conn.write(b"open_channel")
- self._conn.read(100)
- self._conn.write(b"ready")
- self._conn.read(100)
-
- atexit.register(self.close)
-
- def close(self) -> None:
- self._conn.close()
-
- def read(self) -> float:
- self._conn.write(b"ANGLE?\n")
- angle = self._conn.readline().decode().strip()
- return float(angle) + self.offset_degrees
-
-
-@dataclass(slots=True)
-class MockRotaryEncoder:
- """Mock rotary encoder for terminal input when hardware is not available."""
-
- theta: float = 0.0
-
- def read(self) -> float:
- return self.theta
diff --git a/src/pqnstack/pqn/drivers/rotator.py b/src/pqnstack/pqn/drivers/rotator.py
deleted file mode 100644
index 6ef3ddd3..00000000
--- a/src/pqnstack/pqn/drivers/rotator.py
+++ /dev/null
@@ -1,131 +0,0 @@
-# University of Illinois Urbana-Champaign
-# Public Quantum Network
-#
-# NCSA/Illinois Computes
-
-import logging
-import time
-from dataclasses import dataclass
-from dataclasses import field
-
-import serial
-from thorlabs_apt_device import KDC101
-from thorlabs_apt_device import TDC001
-
-from pqnstack.base.errors import DeviceNotStartedError
-from pqnstack.base.instrument import RotatorInfo
-from pqnstack.base.instrument import RotatorInstrument
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass(slots=True)
-class APTRotator(RotatorInstrument):
- _degrees: float = field(default=0.0, init=False)
- _device: TDC001 | KDC101 = field(init=False, repr=False)
- _encoder_units_per_degree: float = field(default=86384 / 45, init=False, repr=False)
-
- def start(self) -> None:
- # Additional setup for APT Rotator
- try:
- self._device = TDC001(serial_number=self.hw_address)
- except RuntimeError:
- self._device = KDC101(self.hw_address)
-
- offset_eu = round(self.offset_degrees * self._encoder_units_per_degree)
-
- # NOTE: Velocity units seem to not match position units
- # (Device does not actually move at 1000 deg/s...)
- # 500 is noticeably slower, but more than 1000 doesn't seem faster
- vel = round(1000 * self._encoder_units_per_degree)
-
- self._device.set_home_params(velocity=vel, offset_distance=offset_eu)
- self._device.set_velocity_params(vel, vel)
- time.sleep(0.5)
- self._wait_for_stop()
-
- def close(self) -> None:
- if self._device is not None:
- logger.info("Closing APT Rotator")
- self._device.close()
-
- @property
- def info(self) -> RotatorInfo:
- return RotatorInfo(
- name=self.name,
- desc=self.desc,
- hw_address=self.hw_address,
- hw_status=self._device.status,
- degrees=self.degrees,
- offset_degrees=self.offset_degrees,
- )
-
- def _wait_for_stop(self) -> None:
- if self._device is None:
- msg = "Start the device before setting parameters"
- raise DeviceNotStartedError(msg)
-
- try:
- time.sleep(0.5)
- while (
- self._device.status["moving_forward"]
- or self._device.status["moving_reverse"]
- or self._device.status["jogging_forward"]
- or self._device.status["jogging_reverse"]
- ):
- time.sleep(0.1)
- except KeyboardInterrupt:
- self._device.stop(immediate=True)
-
- @property
- def degrees(self) -> float:
- return self._degrees
-
- @degrees.setter
- def degrees(self, degrees: float) -> None:
- self._set_degrees_unsafe(degrees)
- self._wait_for_stop()
-
- def _set_degrees_unsafe(self, degrees: float) -> None:
- self._degrees = degrees
- self._device.move_absolute(int(degrees * self._encoder_units_per_degree))
-
-
-@dataclass(slots=True)
-class SerialRotator(RotatorInstrument):
- _degrees: float = 0.0 # The hardware doesn't support position tracking
- _conn: serial.Serial = field(init=False, repr=False)
-
- def start(self) -> None:
- self._conn = serial.Serial(self.hw_address, baudrate=115200, timeout=1)
- self._conn.write(b"open_channel")
- self._conn.read(100)
- self._conn.write(b"motor_ready")
- self._conn.read(100)
-
- self.degrees = self.offset_degrees
-
- def close(self) -> None:
- self.degrees = 0
- self._conn.close()
-
- @property
- def info(self) -> RotatorInfo:
- return RotatorInfo(
- name=self.name,
- desc=self.desc,
- hw_address=self.hw_address,
- # hw_status=,
- degrees=self.degrees,
- offset_degrees=self.offset_degrees,
- )
-
- @property
- def degrees(self) -> float:
- return self._degrees
-
- @degrees.setter
- def degrees(self, degrees: float) -> None:
- self._conn.write(f"SRA {degrees}".encode())
- self._degrees = degrees
- _ = self._conn.readline().decode()
diff --git a/src/pqnstack/pqn/drivers/timetagger.py b/src/pqnstack/pqn/drivers/timetagger.py
deleted file mode 100644
index e3cddd2a..00000000
--- a/src/pqnstack/pqn/drivers/timetagger.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import logging
-from dataclasses import dataclass
-from dataclasses import field
-
-from TimeTagger import ChannelEdge
-from TimeTagger import Correlation
-from TimeTagger import Counter
-from TimeTagger import TimeTagger
-from TimeTagger import createTimeTaggerNetwork
-from TimeTagger import freeTimeTagger
-
-from pqnstack.base.instrument import TimeTaggerInfo
-from pqnstack.base.instrument import TimeTaggerInstrument
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass(slots=True)
-class SwabianTimeTagger(TimeTaggerInstrument):
- """Instantiate a SwabianTimeTagger Instrument.
-
- `hw_address` should be of the form "ip:port"
- e.g.: hw_address = "127.0.0.1:41101".
- """
-
- _tagger: TimeTagger = field(init=False, repr=False)
-
- def start(self) -> None:
- """Initialize the connection to the Swabian time tagger hardware and configures channels for potential coincidence counting."""
- logger.info("Creating Swabian Time Tagger instance.")
- self._tagger = createTimeTaggerNetwork(self.hw_address)
- if not self._tagger:
- msg = "Failed to create time tagger. Verify hardware connection."
- logger.error(msg)
- raise RuntimeError(msg)
-
- hw_channels = self._tagger.getChannelList(ChannelEdge.Rising)
- self.active_channels = [hw_channels[ch - 1] for ch in self.active_channels]
-
- for ch in self.active_channels:
- self._tagger.setInputDelay(ch, 0)
-
- logger.info("Swabian Time Tagger device is now READY.")
-
- def close(self) -> None:
- """Safely closes the connection to the Swabian time tagger hardware."""
- if self._tagger is not None:
- logger.info("Closing Swabian Time Tagger connection.")
- freeTimeTagger(self._tagger)
- self._tagger = None
-
- logger.info("Swabian Time Tagger device is now OFF.")
-
- @property
- def info(self) -> TimeTaggerInfo:
- return TimeTaggerInfo(
- name=self.name,
- desc=self.desc,
- hw_address=self.hw_address,
- # hw_status=,
- active_channels=self.active_channels,
- test_signal_enabled=self.test_signal_enabled,
- test_signal_divider=self.test_signal_divider,
- )
-
- def set_input_delay(self, channel: int, delay_ps: int) -> None:
- self._tagger.setInputDelay(channel, delay_ps)
-
- def set_test_signal(self, channels: list[int], *, enable: bool = True, divider: int = 1) -> None:
- self._tagger.setTestSignal(channels, enable)
- if enable:
- self._tagger.setTestSignalDivider(divider)
-
- def count_singles(self, channels: list[int], integration_time_s: float = 1.0) -> list[int]:
- # TODO: use these as kwargs
- _duration_ps = int(integration_time_s * 1e12)
- counter = Counter(self._tagger, channels, _duration_ps, 1)
- counter.startFor(_duration_ps)
- counter.waitUntilFinished()
- return [item[0] for item in counter.getData()]
-
- def measure_correlation(
- self,
- start_ch: int,
- stop_ch: int,
- integration_time_s: float = 1.0,
- binwidth_ps: int = 1,
- n_bins: int = int(1e5),
- ) -> int:
- # TODO: use these as kwargs
- count_time_ps = int(integration_time_s * 1e12)
- corr = Correlation(self._tagger, start_ch, stop_ch, binwidth_ps, n_bins=n_bins)
- corr.startFor(count_time_ps)
- corr.waitUntilFinished()
- return int(max(corr.getData()))
diff --git a/src/pqnstack/pqn/protocols/__init__.py b/src/pqnstack/pqn/protocols/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/pqnstack/pqn/protocols/chsh.py b/src/pqnstack/pqn/protocols/chsh.py
deleted file mode 100644
index 422a3c7b..00000000
--- a/src/pqnstack/pqn/protocols/chsh.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import datetime
-import math
-from dataclasses import dataclass
-from time import sleep
-
-from pqnstack.base.instrument import RotatorInstrument
-from pqnstack.base.instrument import TimeTaggerInstrument
-from pqnstack.pqn.protocols.measurement import CHSHValue
-from pqnstack.pqn.protocols.measurement import ExpectationValue
-from pqnstack.pqn.protocols.measurement import MeasurementConfig
-
-
-@dataclass
-class Devices:
- idler_hwp: RotatorInstrument
- signal_hwp: RotatorInstrument
- idler_qwp: RotatorInstrument | None
- signal_qwp: RotatorInstrument | None
- timetagger: TimeTaggerInstrument
-
-
-def calculate_chsh_expectation_error(counts: list[int], dark_count: int = 0) -> float:
- total_counts = sum(counts)
- corrected_total = total_counts - 4 * dark_count
- if corrected_total <= 0:
- return 0
- first_term = math.sqrt(total_counts) / corrected_total
- expectation = abs(counts[0] + counts[3] - counts[1] - counts[2])
- second_term = (expectation / corrected_total**2) * math.sqrt(total_counts + 4 * dark_count)
- return first_term + second_term
-
-
-def calculate_chsh_error(error_values: list[float]) -> float:
- return math.sqrt(sum(x**2 for x in error_values))
-
-
-def basis_to_wp(basis: float) -> list[float]:
- return [basis / 2, 0.0] # TODO: Make input a complex number and have the quarter waveplate angle calculated from it
-
-
-def measure_expectation_value(
- devices: Devices, config: MeasurementConfig, base1: float, base2: float
-) -> ExpectationValue:
- idler_wp_angles = basis_to_wp(base1)
- signal_wp_angles = basis_to_wp(base2)
-
- angles_idler = [idler_wp_angles, [idler_wp_angles[0] + 45, idler_wp_angles[1]]]
- angles_signal = [signal_wp_angles, [signal_wp_angles[0] + 45, signal_wp_angles[1]]]
-
- coincidence_counts = []
- for angle_idler in angles_idler:
- for angle_signal in angles_signal:
- devices.idler_hwp.move_to(angle_idler[0])
- devices.signal_hwp.move_to(angle_signal[0])
- if devices.idler_qwp is not None:
- devices.idler_qwp.move_to(angle_idler[1])
- if devices.signal_qwp is not None:
- devices.signal_qwp.move_to(angle_signal[1])
- sleep(2)
- counts = devices.timetagger.measure_correlation(
- config.channel1, config.channel2, int(config.integration_time_s), int(config.binwidth_ps)
- )
- coincidence_counts.append(counts)
-
- numerator = coincidence_counts[0] - coincidence_counts[1] - coincidence_counts[2] + coincidence_counts[3]
- denominator = sum(coincidence_counts) - 4 * config.dark_count
- expectation_val = 0 if denominator == 0 else numerator / denominator
- expectation_error = calculate_chsh_expectation_error(coincidence_counts, config.dark_count)
-
- return ExpectationValue(
- timestamp=datetime.datetime.now(datetime.UTC).isoformat(),
- input_base1=base1,
- input_base2=base2,
- idler_wp_angles=angles_idler,
- signal_wp_angles=angles_signal,
- raw_counts=coincidence_counts,
- error=expectation_error,
- value=expectation_val,
- )
-
-
-def measure_chsh(basis1: list[float], basis2: list[float], devices: Devices, config: MeasurementConfig) -> CHSHValue:
- expectation_values = []
- expectation_errors = []
- raw_results = []
-
- for base1 in basis1:
- for base2 in basis2:
- raw = measure_expectation_value(devices, config, base1, base2)
- expectation_values.append(raw.value)
- expectation_errors.append(raw.error)
- raw_results.append(raw)
-
- chsh_value = -1 * expectation_values[0] + expectation_values[1] + expectation_values[2] + expectation_values[3]
- chsh_error = calculate_chsh_error(expectation_errors)
-
- return CHSHValue(
- timestamp=datetime.datetime.now(datetime.UTC).isoformat(),
- raw_results=raw_results,
- basis1=basis1,
- basis2=basis2,
- chsh_value=chsh_value,
- chsh_error=chsh_error,
- )
diff --git a/src/pqnstack/pqn/protocols/measurement.py b/src/pqnstack/pqn/protocols/measurement.py
deleted file mode 100644
index 00759f30..00000000
--- a/src/pqnstack/pqn/protocols/measurement.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from dataclasses import dataclass
-
-from pydantic import BaseModel
-
-
-class MeasurementConfig(BaseModel):
- integration_time_s: float
- binwidth_ps: int = 500
- channel1: int = 1
- channel2: int = 2
- dark_count: int = 0
-
-
-@dataclass
-class ExpectationValue:
- timestamp: str
- input_base1: float
- input_base2: float
- idler_wp_angles: list[list[float]]
- signal_wp_angles: list[list[float]]
- raw_counts: list[int]
- error: float
- value: float
-
-
-@dataclass
-class CHSHValue:
- timestamp: str
- raw_results: list[ExpectationValue]
- basis1: list[float]
- basis2: list[float]
- chsh_value: float
- chsh_error: float
-
-
-@dataclass(frozen=True)
-class MeasurementBasis:
- name: str
- pairs: list[tuple[str, str]]
- settings: dict[str, tuple[float, float]]
-
-
-DEFAULT_SETTINGS: dict[str, tuple[float, float]] = {
- "H": (0, 0),
- "V": (45, 0),
- "D": (22.5, 0),
- "A": (-22.5, 0),
- "R": (22.5, 45),
- "L": (-22.5, 45),
-}
-
-HV_BASIS = MeasurementBasis(
- name="HV",
- pairs=[("H", "H"), ("H", "V"), ("V", "H"), ("V", "V")],
- settings=DEFAULT_SETTINGS,
-)
-
-DA_BASIS = MeasurementBasis(
- name="DA",
- pairs=[("D", "D"), ("D", "A"), ("A", "D"), ("A", "A")],
- settings=DEFAULT_SETTINGS,
-)
-
-RL_BASIS = MeasurementBasis(
- name="RL",
- pairs=[("R", "R"), ("R", "L"), ("L", "R"), ("L", "L")],
- settings=DEFAULT_SETTINGS,
-)
diff --git a/src/pqnstack/pqn/protocols/qkd.py b/src/pqnstack/pqn/protocols/qkd.py
deleted file mode 100644
index 331941aa..00000000
--- a/src/pqnstack/pqn/protocols/qkd.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from dataclasses import dataclass
-from time import sleep
-from typing import TYPE_CHECKING
-from typing import cast
-
-from pqnstack.constants import DEFAULT_SETTINGS
-from pqnstack.constants import HV_BASIS
-from pqnstack.constants import MeasurementBasis
-from pqnstack.network.client import Client
-from pqnstack.network.client import ProxyInstrument
-from pqnstack.pqn.protocols.measurement import MeasurementConfig
-from pqnstack.pqn.protocols.visibility import calculate_visibility
-
-if TYPE_CHECKING:
- from pqnstack.base.instrument import RotatorInstrument
-
-
-@dataclass
-class Devices:
- qd: ProxyInstrument
- client: Client
-
-
-def qkd_run(
- devices: Devices,
- config: MeasurementConfig,
- basis: MeasurementBasis = HV_BASIS,
-) -> tuple[float, float]:
- """
- Run a QKD protocol for a single player, independently measuring visibility.
-
- Parameters
- ----------
- devices: Devices
- basis : MeasurementBasis
- Predefined measurement basis (e.g., HV_BASIS, DA_BASIS, RL_BASIS).
- config : MeasurementConfig
- the config for the measurement
-
- Returns
- -------
- visibility
- """
- player = devices.qd.add_player()
- if not player:
- msg = "No available player slots in QKD device."
- raise RuntimeError(msg)
-
- settings = DEFAULT_SETTINGS
-
- key_filter = "signal" if player == "player1" else "idler"
-
- player_motors = devices.qd.get_motors(player)
- motors: dict[str, RotatorInstrument] = {
- motor_name: cast("RotatorInstrument", devices.client.get_device(info["location"], info["name"]))
- for motor_name, info in player_motors.items()
- }
- coincidence_counts: dict[tuple[str, str], int] = {}
- for index, (state1, state2) in enumerate(basis.pairs):
- # Determine which state to move for this player
- if player == "player1": # noqa: SIM108
- move_state = state1 if index < 2 else state2 # noqa: PLR2004
- else:
- move_state = state1 if (index % 2) == 0 else state2
-
- angles = settings[move_state]
- hwp_angle, qwp_angle = angles
-
- hwp_key = f"{key_filter}_hwp"
- if hwp_key in motors:
- motors[hwp_key].move_to(hwp_angle)
-
- qwp_key = f"{key_filter}_qwp"
- if qwp_key in motors:
- motors[qwp_key].move_to(qwp_angle)
-
- sleep(config.integration_time_s)
-
- devices.qd.submit(player)
-
- counts: int | None
- while (counts := devices.qd.get_counts(player)) is None:
- sleep(0.5)
-
- coincidence_counts[(state1, state2)] = counts
-
- visibility, error = calculate_visibility(coincidence_counts, basis.pairs)
-
- devices.qd.remove_player(player)
-
- return visibility, error
-
-
-if __name__ == "__main__":
- from pqnstack.network.devices.client import client
-
- client = client(host="172.30.63.109", timeout=30000)
- qd_device = client.get_device("qkd_device", "devices.qd")
diff --git a/src/pqnstack/pqn/protocols/tomography.py b/src/pqnstack/pqn/protocols/tomography.py
deleted file mode 100644
index a4ae070f..00000000
--- a/src/pqnstack/pqn/protocols/tomography.py
+++ /dev/null
@@ -1,91 +0,0 @@
-import datetime
-import time
-from dataclasses import dataclass
-
-from pqnstack.base.driver.rotator import RotatorInstrument
-from pqnstack.base.instruments import TimeTaggerInstrument
-from pqnstack.constants import DEFAULT_SETTINGS
-from pqnstack.constants import MeasurementBasis
-from pqnstack.pqn.protocols.measurement import MeasurementConfig
-
-_TOMOGRAPHY_STATES: list[str] = ["H", "V", "D", "A", "R", "L"]
-
-TOMOGRAPHY_BASIS: MeasurementBasis = MeasurementBasis(
- name="TOMOGRAPHY",
- pairs=[(s, i) for s in _TOMOGRAPHY_STATES for i in _TOMOGRAPHY_STATES],
- settings=DEFAULT_SETTINGS,
-)
-
-
-@dataclass
-class Devices:
- idler_hwp: RotatorInstrument
- idler_qwp: RotatorInstrument
- signal_hwp: RotatorInstrument
- signal_qwp: RotatorInstrument
- timetagger: TimeTaggerInstrument
-
-
-@dataclass
-class TomographyValue:
- timestamp: str
- tomography_raw_counts: list[int]
-
-
-def measure_tomography_raw(
- devices: Devices,
- config: MeasurementConfig,
-) -> TomographyValue:
- tomography_counts: list[int] = []
-
- for signal_state, idler_state in TOMOGRAPHY_BASIS.pairs:
- signal_angles: tuple[float, float] = TOMOGRAPHY_BASIS.settings[signal_state]
- idler_angles: tuple[float, float] = TOMOGRAPHY_BASIS.settings[idler_state]
-
- devices.signal_hwp.move_to(signal_angles[0])
- devices.signal_qwp.move_to(signal_angles[1])
- devices.idler_hwp.move_to(idler_angles[0])
- devices.idler_qwp.move_to(idler_angles[1])
-
- time.sleep(3)
-
- coincidence = devices.timetagger.measure_correlation(
- config.channel1,
- config.channel2,
- config.integration_time_s,
- config.binwidth_ps,
- )
- tomography_counts.append(int(coincidence))
-
- current_time: str = datetime.datetime.now(datetime.UTC).isoformat()
-
- return TomographyValue(
- timestamp=current_time,
- tomography_raw_counts=tomography_counts,
- )
-
-
-"""
-Example:
-if __name__ == "__main__":
- from pqnstack.network.client import Client
-
- client = Client(host="172.30.63.109", timeout=30000)
-
- idler_hwp = client.get_device("pqn_test3", "idler_hwp")
- idler_qwp = client.get_device("pqn_test3", "idler_qwp")
- signal_hwp = client.get_device("pqn_test3", "signal_hwp")
- signal_qwp = client.get_device("pqn_test3", "signal_qwp")
- timetagger = client.get_device("mini_pc", "tagger")
-
- devices = Devices(
- idler_hwp=idler_hwp,
- idler_qwp=idler_qwp,
- signal_hwp=signal_hwp,
- signal_qwp=signal_qwp,
- timetagger=timetagger,
- )
-
- config = MeasurementConfig(channel1=1, channel2=2, binwidth=1_000, duration=0.5)
- result = measure_tomography_raw(devices, config)
-"""
diff --git a/src/pqnstack/pqn/protocols/visibility.py b/src/pqnstack/pqn/protocols/visibility.py
deleted file mode 100644
index ca22dbd3..00000000
--- a/src/pqnstack/pqn/protocols/visibility.py
+++ /dev/null
@@ -1,91 +0,0 @@
-import math
-import time
-from typing import Any
-
-from pqnstack.base.instrument import RotatorInstrument
-from pqnstack.constants import MeasurementBasis
-from pqnstack.pqn.protocols.measurement import MeasurementConfig
-
-
-class Devices:
- motors: dict[str, RotatorInstrument]
- tagger: Any
-
-
-def measure_visibility(
- devices: Devices,
- basis: MeasurementBasis,
- config: MeasurementConfig,
-) -> tuple[float, float]:
- coincidence_counts: dict[tuple[str, str], int] = {}
-
- for pair in basis.pairs:
- coincidence_counts[pair] = move_and_measure(
- devices,
- pair[0],
- pair[1],
- basis.settings,
- config,
- )
-
- return calculate_visibility(coincidence_counts, basis.pairs)
-
-
-def move_and_measure(
- devices: Devices,
- s_state: str,
- i_state: str,
- settings: dict[str, tuple[float, float]],
- config: MeasurementConfig,
-) -> int:
- if s_state not in settings or i_state not in settings:
- msg = f"State {s_state} or {i_state} is not defined in settings."
- raise KeyError(msg)
-
- for motor_key, angle in [("signal_hwp", settings[s_state][0]), ("idler_hwp", settings[i_state][0])]:
- if motor_key in devices.motors:
- devices.motors[motor_key].move_to(angle)
-
- if "signal_qwp" in devices.motors or "idler_qwp" in devices.motors:
- for motor_key, angle in [("signal_qwp", settings[s_state][1]), ("idler_qwp", settings[i_state][1])]:
- if motor_key in devices.motors:
- devices.motors[motor_key].move_to(angle)
-
- time.sleep(2)
- return int(
- devices.tagger.measure_correlation(
- config.channel1, config.channel2, config.binwidth_ps, config.integration_time_s
- )
- )
-
-
-def calculate_visibility(
- coincidence_counts: dict[tuple[str, str], int],
- pairs: list[tuple[str, str]],
-) -> tuple[float, float]:
- c_values = [coincidence_counts[pair] for pair in pairs]
- c_max, c_min = max(c_values), min(c_values)
-
- denominator = (c_max + c_min) ** 2
- if denominator == 0:
- return 0.0, 0.0
-
- c_err = 2 * math.sqrt((c_min**2) * c_max + (c_max**2) * c_min) / denominator
- return (c_max - c_min) / (c_max + c_min), c_err
-
-
-"""
-Example:
-
-if __name__ == "__main__":
- from pqnstack.network.client import Client
-
- c = Client(host="172.30.63.109", timeout=300000)
- idler_hwp = c.get_device("loomis_server", "signal_hwp")
- signal_hwp = c.get_device("ufl_closet", "signal_hwp")
- tagger = c.get_device("mini_pc", "tagger")
- motors = {"idler_hwp": idler_hwp, "signal_hwp": signal_hwp}
-
- devices = Devices(motors=motors, tagger=tagger)
- result = measure_visibility(devices, DA_BASIS)
-"""
diff --git a/tests/messaging/__init__.py b/tests/messaging/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/messaging/blocking_client.py b/tests/messaging/blocking_client.py
deleted file mode 100644
index db89f7e3..00000000
--- a/tests/messaging/blocking_client.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import logging
-
-from pqnstack.network.client import Client
-
-logging.basicConfig(level=logging.INFO)
-
-logger = logging.getLogger(__name__)
-
-if __name__ == "__main__":
- c = Client()
-
- # ping provider
- ping_reply = c.ping("provider1")
- logger.info(ping_reply)
-
- instrument = c.get_device("provider1", "dummy1")
- logger.info(instrument)
-
- # blocking operation
- instrument.toggle_bool_long()
diff --git a/tests/messaging/client.py b/tests/messaging/client.py
deleted file mode 100644
index 0c00d935..00000000
--- a/tests/messaging/client.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import logging
-
-from pqnstack.network.client import Client
-
-logging.basicConfig(level=logging.INFO)
-
-logger = logging.getLogger(__name__)
-
-if __name__ == "__main__":
- c = Client()
-
- # ping provider
- ping_reply = c.ping("provider1")
- logger.info(ping_reply)
-
- devices = c.get_available_devices("provider1")
- logger.info(devices)
-
- # Create instrument proxy
- instrument = c.get_device("provider1", "dummy1")
- logger.info(instrument)
- logger.info("I should have the proxy object here: %s", type(instrument))
-
- # Call a method on the instrument
- ret = instrument.double_int()
-
- logger.info(ret)
-
- # Callable
- call = instrument.double_int
- logger.info(type(call))
-
- # Pass argument to operation
- ret = instrument.set_half_input_int(10)
- logger.info(ret)
-
- # Passing keyword arguments
- ret = instrument.set_half_input_int(value=36)
- logger.info(ret)
-
- # Get a parameter
- param = instrument.param_int
- logger.info(param)
-
- param_str = instrument.param_str
- logger.info(param_str)
-
- # Set a parameter
- instrument.param_int = 42
- logger.info(instrument.param_int)
-
- # Set a parameter
- try:
- instrument.new_attr = 348
- except AttributeError as e:
- logger.info("Caught exception: %s cannot set parameter", e)
-
- logger.info("Proxy instrument seems to be working correctly.")
diff --git a/tests/messaging/config_example.toml b/tests/messaging/config_example.toml
deleted file mode 100644
index 53709a41..00000000
--- a/tests/messaging/config_example.toml
+++ /dev/null
@@ -1,23 +0,0 @@
-[router]
-name = "pqnstack-router"
-host = "localhost"
-port = 5556
-
-[provider]
-name = "pqnstack-provider"
-router_name = "pqnstack-router"
-host = "localhost"
-port = 5556
-beat_period = 2000
-
-[[provider.instruments]]
-name = "dummy1"
-import = "pqnstack.pqn.drivers.dummies.DummyInstrument"
-desc = "Dummy instrument1 for testing purposes"
-hw_address = "1234"
-
-[[provider.instruments]]
-name = "dummy2"
-import = "pqnstack.pqn.drivers.dummies.DummyInstrument"
-desc = "Dummy instrument2 for testing purposes"
-hw_address = "1234"
diff --git a/tests/messaging/provider.py b/tests/messaging/provider.py
deleted file mode 100644
index c2c6f96e..00000000
--- a/tests/messaging/provider.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import logging
-
-from pqnstack.network.instrument_provider import InstrumentProvider
-
-logging.basicConfig(level=logging.INFO)
-
-if __name__ == "__main__":
- instruments = {
- "dummy1": {
- "import": "pqnstack.pqn.drivers.dummies.DummyInstrument",
- "desc": "Dummy Instrument 1",
- "hw_address": "123456",
- }
- }
- provider = InstrumentProvider("provider1", "127.0.0.1", 5555, **instruments)
- provider.start()
diff --git a/tests/messaging/router.py b/tests/messaging/router.py
deleted file mode 100644
index ce8c63b3..00000000
--- a/tests/messaging/router.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import logging
-
-from pqnstack.network.router import Router
-
-logging.basicConfig(level=logging.INFO)
-
-logger = logging.getLogger(__name__)
-
-if __name__ == "__main__":
- router = Router("router1", "127.0.0.1", 5555)
- router.start()
diff --git a/tests/pytest/test_internal_network.py b/tests/pytest/test_internal_network.py
deleted file mode 100644
index 072d5054..00000000
--- a/tests/pytest/test_internal_network.py
+++ /dev/null
@@ -1,142 +0,0 @@
-import logging
-import shutil
-import subprocess
-import time
-from collections.abc import Generator
-from pathlib import Path
-from typing import Any
-
-import pytest
-
-from pqnstack.network.client import Client
-from pqnstack.network.client import ProxyInstrument
-from pqnstack.network.packet import Packet
-from pqnstack.network.packet import PacketIntent
-from pqnstack.pqn.drivers.dummies import DummyInstrument
-
-logger = logging.getLogger(__name__)
-
-
-@pytest.fixture(scope="module", autouse=True)
-def messaging_services() -> Generator[None, Any, None]:
- """Start router and provider services for testing."""
- logger.debug("Starting messaging services...")
- # Get the path to the config file relative to this test file, not current working directory
- test_dir = Path(__file__).parent
- config_path = test_dir / "test_network_config.toml"
- uv_path = shutil.which("uv")
- if not uv_path:
- msg = "Could not find 'uv' executable in PATH"
- raise RuntimeError(msg)
-
- logger.debug("Using uv path: %s, starting router and provider with config: %s", uv_path, config_path)
-
- # Start router process
- router_process = subprocess.Popen( # Noqa: S603 # Subprocess is used for testing purposes, not in production code.
- [uv_path, "run", "pqn", "start-router", "--config", str(config_path)],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
-
- # Give router time to start up
- time.sleep(1)
-
- # Start provider process
- provider_process = subprocess.Popen( # Noqa: S603 # Subprocess is used for testing purposes, not in production code.
- [uv_path, "run", "pqn", "start-provider", "--config", str(config_path)],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
-
- # Give provider time to start up and connect to router
- time.sleep(1)
-
- # Check if provider started successfully
- if provider_process.poll() is not None:
- stdout, stderr = provider_process.communicate()
- msg = f"Provider failed to start. Exit code: {provider_process.returncode}\nStdout: {stdout.decode()}\nStderr: {stderr.decode()}"
- raise RuntimeError(msg)
-
- logger.debug("Services should be ready for testing")
-
- try:
- yield
- finally:
- logger.debug("Cleaning up messaging services...")
-
- # Terminate provider first (depends on router)
- if provider_process.poll() is None:
- provider_process.terminate()
- try:
- provider_process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- provider_process.kill()
- provider_process.wait()
-
- # Then terminate router
- if router_process.poll() is None:
- router_process.terminate()
- try:
- router_process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- router_process.kill()
- router_process.wait()
-
- logger.debug("All services cleaned up")
-
-
-def test_client_ping() -> None:
- client = Client(host="localhost", port=5556, router_name="pqnstack-router", timeout=1000)
- response = client.ping("pqnstack-provider")
-
- assert isinstance(response, Packet)
- assert response.intent == PacketIntent.PING
- assert response.source == "pqnstack-provider"
- assert response.destination == client.name
- assert response.request == "PONG"
-
-
-def test_getting_all_instruments() -> None:
- client = Client(host="localhost", port=5556, router_name="pqnstack-router", timeout=1000)
-
- response = client.get_available_devices("pqnstack-provider")
-
- instruments_names = ["dummy1", "dummy2"]
-
- assert instruments_names == list(response.keys())
- # Get available devices returns the __class__ of the instrument as the value.
- assert isinstance(response["dummy1"], DummyInstrument.__class__)
- assert isinstance(response["dummy2"], DummyInstrument.__class__)
-
-
-def test_proxy_instrument() -> None:
- client = Client(host="localhost", port=5556, router_name="pqnstack-router", timeout=1000)
-
- proxy_instrument = client.get_device("pqnstack-provider", "dummy1")
- assert isinstance(proxy_instrument, ProxyInstrument)
-
- base_int = 2
- double_int = base_int * 2
- arbitrary_int = 12
-
- assert proxy_instrument.name == "dummy1"
- assert proxy_instrument.param_int == base_int
- assert proxy_instrument.param_str == "hello"
-
- assert proxy_instrument.double_int() == double_int
- assert proxy_instrument.param_int == double_int
-
- proxy_instrument.param_int = arbitrary_int
- assert proxy_instrument.param_int == arbitrary_int
-
- assert proxy_instrument.uppercase_str() == "HELLO"
- assert proxy_instrument.param_str == "HELLO"
-
- # Make sure you cannot add attributes to the proxy instrument
- fail_flag = False
- try:
- proxy_instrument.new_attr = 42
- except AttributeError:
- fail_flag = True
-
- assert fail_flag, "Should not be able to set new attributes on the ProxyInstrument"
diff --git a/tests/pytest/test_network_config.toml b/tests/pytest/test_network_config.toml
deleted file mode 100644
index 53709a41..00000000
--- a/tests/pytest/test_network_config.toml
+++ /dev/null
@@ -1,23 +0,0 @@
-[router]
-name = "pqnstack-router"
-host = "localhost"
-port = 5556
-
-[provider]
-name = "pqnstack-provider"
-router_name = "pqnstack-router"
-host = "localhost"
-port = 5556
-beat_period = 2000
-
-[[provider.instruments]]
-name = "dummy1"
-import = "pqnstack.pqn.drivers.dummies.DummyInstrument"
-desc = "Dummy instrument1 for testing purposes"
-hw_address = "1234"
-
-[[provider.instruments]]
-name = "dummy2"
-import = "pqnstack.pqn.drivers.dummies.DummyInstrument"
-desc = "Dummy instrument2 for testing purposes"
-hw_address = "1234"
diff --git a/uv.lock b/uv.lock
index 38f67f46..c012ee6b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -725,17 +725,29 @@ wheels = [
]
[[package]]
-name = "pqnstack"
+name = "pqn-hardware"
version = "0.2.0"
-source = { editable = "." }
+source = { git = "https://github.com/PublicQuantumNetwork/pqn-hardware.git?rev=6440be2830fddccb09b461caea023c84b7c3bea1#6440be2830fddccb09b461caea023c84b7c3bea1" }
dependencies = [
- { name = "fastapi", extra = ["standard"] },
- { name = "httpx" },
{ name = "numpy" },
- { name = "pydantic-settings" },
+ { name = "pydantic" },
{ name = "pyfirmata2" },
{ name = "pyzmq" },
{ name = "thorlabs-apt-device" },
+ { name = "typer" },
+]
+
+[[package]]
+name = "pqn-node"
+version = "0.2.0"
+source = { editable = "." }
+dependencies = [
+ { name = "fastapi", extra = ["standard"] },
+ { name = "httpx" },
+ { name = "pqn-hardware" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pyserial" },
{ name = "tomli-w" },
{ name = "typer" },
]
@@ -753,11 +765,10 @@ dev = [
requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.14" },
{ name = "httpx", specifier = ">=0.28.1" },
- { name = "numpy", specifier = ">=2.3.5" },
+ { name = "pqn-hardware", git = "https://github.com/PublicQuantumNetwork/pqn-hardware.git?rev=6440be2830fddccb09b461caea023c84b7c3bea1" },
+ { name = "pydantic", specifier = ">=2.0" },
{ name = "pydantic-settings", specifier = ">=2.10.1" },
- { name = "pyfirmata2", specifier = ">=2.5.0" },
- { name = "pyzmq", specifier = ">=26.2.0" },
- { name = "thorlabs-apt-device", specifier = ">=0.3.8" },
+ { name = "pyserial", specifier = ">=3.5" },
{ name = "tomli-w", specifier = ">=1.0.0" },
{ name = "typer", specifier = ">=0.15.1" },
]