Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
11621ad
first crack at test
Relm-Arrowny Mar 12, 2026
6bcc51b
fixing typos
Relm-Arrowny Mar 12, 2026
6d6d19d
add start server
Relm-Arrowny Mar 13, 2026
4835373
added stop
Relm-Arrowny Mar 13, 2026
0a41653
add responses
Relm-Arrowny Mar 13, 2026
c5701cc
add test for timeout
Relm-Arrowny Mar 13, 2026
bbe2f05
add _run_command_loop
Relm-Arrowny Mar 13, 2026
429cd54
rename command loop to serve_client
Relm-Arrowny Mar 13, 2026
061b29d
rename process lline
Relm-Arrowny Mar 13, 2026
5cad303
Merge branch 'main' into 280-baseserver-abstract-class-for-tcp-services
Relm-Arrowny Mar 13, 2026
f516a16
Merge branch 'main' into 280-baseserver-abstract-class-for-tcp-services
Relm-Arrowny Mar 16, 2026
1676c97
correct timeout and allow ipv6
Relm-Arrowny Mar 16, 2026
9821bca
added error testing and full cycle test
Relm-Arrowny Mar 16, 2026
51c2db4
Add minimal docstring
Relm-Arrowny Mar 16, 2026
4fb19a0
add abc to class
Relm-Arrowny Mar 16, 2026
1e582d7
correct test
Relm-Arrowny Mar 16, 2026
4dfc68a
remove redundancy
Relm-Arrowny Mar 16, 2026
3f18d4a
add command_registry for Abstract class commands.
Relm-Arrowny Mar 17, 2026
f94cfaf
add helper for error handling
Relm-Arrowny Mar 17, 2026
e1446f9
clean up test
Relm-Arrowny Mar 17, 2026
ece821d
Merge branch 'main' into 280-baseserver-abstract-class-for-tcp-services
Relm-Arrowny Mar 19, 2026
514098d
change response to take byes
Relm-Arrowny Mar 19, 2026
3ea6058
Merge remote-tracking branch 'refs/remotes/origin/280-baseserver-abst…
Relm-Arrowny Mar 19, 2026
91a0d6c
just function prototype
Relm-Arrowny Mar 23, 2026
36cad88
adding tests
Relm-Arrowny Mar 23, 2026
574805f
more test
Relm-Arrowny Mar 23, 2026
d687d11
tst for scope
Relm-Arrowny Mar 24, 2026
a9b516b
complete the class
Relm-Arrowny Mar 24, 2026
9872ef1
change args to a list
Relm-Arrowny Mar 24, 2026
818aee6
Merge branch '280-baseserver-abstract-class-for-tcp-services' into 29…
Relm-Arrowny Mar 24, 2026
2006d19
add some more test
Relm-Arrowny Mar 24, 2026
1e52fd8
_get_combine_data test
Relm-Arrowny Mar 26, 2026
bffd1c2
add contextmanager for hardware not responding
Relm-Arrowny Mar 26, 2026
1c43785
complete hardware_watch test
Relm-Arrowny Mar 26, 2026
6a54bec
Merge branch 'main' into 280-baseserver-abstract-class-for-tcp-services
Relm-Arrowny Mar 26, 2026
59602ee
fix sigal required main thread.
Relm-Arrowny Mar 26, 2026
f6a5f42
Merge branch '280-baseserver-abstract-class-for-tcp-services' of gith…
Relm-Arrowny Mar 26, 2026
45daa75
cleanup test
Relm-Arrowny Mar 26, 2026
22a7472
Merge branch '280-baseserver-abstract-class-for-tcp-services' into 29…
Relm-Arrowny Mar 26, 2026
598c76b
move casting to decorators
Relm-Arrowny Mar 26, 2026
0aba657
complete tests
Relm-Arrowny Mar 26, 2026
292918b
change to use time_outcontext instead
Relm-Arrowny Mar 26, 2026
b44e52f
Merge branch '280-baseserver-abstract-class-for-tcp-services' into 29…
Relm-Arrowny Mar 26, 2026
d43d08c
update _get_lockin_data to use polling
Relm-Arrowny Mar 27, 2026
2e7a2cb
add docs
Relm-Arrowny Mar 30, 2026
ecf482c
add _check_timeout for sub class to check and raise timeout if it too…
Relm-Arrowny Mar 30, 2026
0920c79
add docs
Relm-Arrowny Mar 30, 2026
ee5d1f0
correct docs
Relm-Arrowny Mar 30, 2026
2ce95b8
Merge branch '280-baseserver-abstract-class-for-tcp-services' into 29…
Relm-Arrowny Mar 30, 2026
edf52ce
change docs to use auto_type_cast
Relm-Arrowny Mar 30, 2026
ed05de2
Merge remote-tracking branch 'origin/main' into 295-refactor-hf2serve…
Relm-Arrowny Mar 30, 2026
866a95a
correct typo in docs
Relm-Arrowny Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class MyMotorServer(AbstractInstrumentServer):
self._send_response(b"Moved to " + position)
if __name__ == "__main__":
# Initialize and start the server
server = MyInstrumentServer("127.0.0.1", 5000)
server = MyMotorServer("127.0.0.1", 5000)
try:
server.start()
except KeyboardInterrupt:
Expand Down
76 changes: 76 additions & 0 deletions docs/how-to/4a_Zurick_lockin_amplifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Zurick lockin amplifier(HF2Server)

A TCP-based gateway for Zurich Instruments HF2 series lock-in amplifiers. This server allows remote clients to control hardware nodes and acquire averaged data via a simple Tab-Separated Protocol.

## 📡 Network Protocol

The server uses a **Tab-Separated Protocol** over TCP.

**Request Format:**
`command` + `\t` + `arg1` + `\t` + `arg2` ... + `\n`

**Response Format:**
* `1\t[Data]\n` : **Success**.
* `0\t[Error Message]\n` : **Failure**.

# HF2Server Reference

A TCP-based gateway for Zurich Instruments HF2 series lock-in amplifiers. This server allows remote clients to control hardware nodes and acquire averaged data via a simple Tab-Separated Protocol.

## 📡 Network Protocol

**Request Format:** `command` + `\t` + `arg1` + `\t` + `arg2` ... + `\n`
**Response Format:** `1\t[Data]\n` (Success) or `0\t[Error Message]\n` (Failure)

## 📖 Complete Command Reference

| Command | Arguments | Description |
| :--- | :--- | :--- |
| **Data Acquisition** | | |
| `getData` | `duration` (float) | Returns: `x, y, theta, scope_mean, r` |
| `setupScope` | `freq`, `len`, `ch` | Configures Scope for single shot (Time, Length, Input Select). |
| **Oscillator & Output** | | |
| `setRefFreq` | `val` (float) | Sets Lockin reference Frequency (Hz). |
| `setRefV` | `val` (float) | Sets reference voltage Amplitude (Vpk). |
| `setRefVoff` | `val` (float) | Sets Signal voltage Offset (V). |
| `setsRefOutSwitch`| `state` (0/1) | Enables (1) or Disables (0) Signal Output 0. |
| **Demodulator Settings** | | |
| `setTimeConstant` | `val` (float) | Sets Lockin (low pass) Time Constant (s). |
| `setDataRate` | `val` (float) | Sets Demodulator 0 Sample Rate (Hz). |
| `setsRefHarm` | `val` (int) | Sets Demodulator Harmonic. |
| **Input & Autorange** | | |
| `setCurrentInRange`| `val` (float) | Sets Current Input Range (Powers of 10). |
| `autoCurrentInRange`| None | Triggers Autorange for Current Input 0. |
| `autoVoltageInRange`| None | Triggers Autorange for Signal Input 0. |
| **System** | | |
| `ping` | None | Returns `1\t` if server is alive. |
| `connect_hardware`| None | Re-establishes connection to ZI Data Server. |
| `disconnect_hardware`| None | Safely disconnects from hardware. |
| `shutdown` | None | Stops the server and disconnects hardware. |
## 💻 Example Usage

### 1. Start the Server
```python
from sm_bluesky.common.server import HF2Server

server = HF2Server(
host="0.0.0.0",
port=7891,
device_id="dev4206",
hf2_ip="172.23.110.84"
)
server.start()
```
### 2. Client

import socket
```python

def query_hf2(command):
with socket.create_connection(("localhost", 7891)) as sock:
sock.sendall(f"{command}\n".encode())
return sock.recv(1024).decode()

# Example: Get 0.5s of averaged data
print(query_hf2("getData\t0.5"))
```
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ license.file = "LICENSE"
readme = "README.md"
requires-python = ">=3.11"


[project.optional-dependencies]
server = ["pyserial", "zhinst-core"]
[dependency-groups]
dev = [
"sm_bluesky[server]",
"copier",
"myst-parser",
"pre-commit",
Expand Down
2 changes: 1 addition & 1 deletion src/sm_bluesky/beamlines/p99/plans/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from sm_bluesky.common.helper import add_default_metadata
from sm_bluesky.common.plans import grid_fast_scan, grid_step_scan
from sm_bluesky.common.utils import add_default_metadata

P99_DEFAULT_METADATA = {
"energy": {"value": 1.8, "unit": "eV"},
Expand Down
3 changes: 0 additions & 3 deletions src/sm_bluesky/common/helper/__init__.py

This file was deleted.

2 changes: 1 addition & 1 deletion src/sm_bluesky/common/plans/fast_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from ophyd_async.core import FlyMotorInfo
from ophyd_async.epics.motor import Motor

from sm_bluesky.common.helper import add_extra_names_to_meta
from sm_bluesky.common.plan_stubs import check_within_limit
from sm_bluesky.common.utils import add_extra_names_to_meta
from sm_bluesky.log import LOGGER


Expand Down
3 changes: 2 additions & 1 deletion src/sm_bluesky/common/server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .abstract_instrument_server import AbstractInstrumentServer
from .zurich_lockin_amplifier import HF2Server

__all__ = ["AbstractInstrumentServer"]
__all__ = ["AbstractInstrumentServer", "HF2Server"]
234 changes: 234 additions & 0 deletions src/sm_bluesky/common/server/zurich_lockin_amplifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
from time import sleep
from typing import Literal

import numpy as np
from zhinst.core import ScopeModule, ziDAQServer

from sm_bluesky.common.server import AbstractInstrumentServer
from sm_bluesky.common.utils import auto_type_cast
from sm_bluesky.log import LOGGER


class HF2Server(AbstractInstrumentServer):
"""Python class to create a sever that connect to HF2 data server and listen for
data request from client."""

api_level: Literal[0, 1, 4, 5, 6]

def __init__(
self,
host: str = "",
port: int = 7891,
hf2_ip: str = "172.23.110.84",
hf2_port: int = 8004,
api_level: Literal[0, 1, 4, 5, 6] = 6,
device_id: str = "dev4206",
):
super().__init__(host, port)
self.hf2_ip = hf2_ip
self.hf2_port = hf2_port
self.api_level = api_level
self.device_id = device_id
self._minimum_scope_wait = 0.1
self._device: ziDAQServer | None = None
self._scope: ScopeModule | None = None
self._scope_frequency: float | None = None
# Register HF2 specific commands
self._command_registry.update(
{
b"getData": self._get_combined_data,
b"autoVoltageInRange": self._auto_voltage_range,
b"setTimeConstant": self._set_time_constant,
b"setDataRate": self._set_data_rate,
b"setCurrentInRange": self._set_current_range,
b"autoCurrentInRange": self._auto_current_range,
b"setRefFreq": self._set_ref_freq,
b"setRefV": self._set_ref_vpk,
b"setRefVoff": self._set_ref_voff,
b"setsRefOutSwitch": self._set_ref_output,
b"setsRefHarm": self._set_ref_harmonic,
b"setupScope": self._setup_scope_cmd,
}
)

@property
def device(self) -> ziDAQServer:
if self._device is None:
raise ConnectionError("Lockin amplifier not connected")
return self._device

@device.setter
def device(self, value: ziDAQServer | None):
self._device = value

@property
def scope(self) -> ScopeModule:
if self._scope is None:
raise ConnectionError(
"Scope module not initialized. Run setupScope before using scope."
)
return self._scope

@scope.setter
def scope(self, value: ScopeModule | None):
self._scope = value

# --- Example Method ---

def connect_hardware(self) -> bool:
"""Connect to Zurich Instruments HF2 Data Server."""
try:
self.device = ziDAQServer(self.hf2_ip, self.hf2_port, self.api_level)
self._setup_scope()
LOGGER.info(f"HF2 Data server connected at {self.hf2_ip}")
return True
except Exception as e:
self._error_helper("HF2 Connection failed", e)
return False

def disconnect_hardware(self) -> None:
"""Disconnect from HF2 and cleanup modules."""
try:
self.device.disconnect()
LOGGER.info("HF2 disconnected")
except Exception as e:
self._error_helper("Error during HF2 disconnect", e)
finally:
self.device = None

# --- Hardware Logic Methods ---
def _setup_scope(self, freq: float = 5.0, length: int = 4096, channel: int = 0):
self.scope = self.device.scopeModule()
self._scope_frequency = 5.0
self.device.set(f"/{self.device_id}/scopes/0/time", freq)
self.device.set(f"/{self.device_id}/scopes/0/length", length)
self.device.set(f"/{self.device_id}/scopes/0/channels/0/inputselect", channel)
self.device.set(f"/{self.device_id}/scopes/0/enable", 0)

def _get_single_scope_shot(self) -> float:
"""Returns the mean value of a single scope shot."""
if self._scope_frequency:
self.device.set(f"/{self.device_id}/scopes/0/enable", 0)
self.scope.set("scopeModule/mode", 1)
self.scope.subscribe(f"/{self.device_id}/scopes/0/wave/")
self.scope.execute()
self.device.setInt(f"/{self.device_id}/scopes/0/single", 1)
self.device.setInt(f"/{self.device_id}/scopes/0/enable", 1)
self.device.sync()
sleep(1.0 / self._scope_frequency + self._minimum_scope_wait)
self.scope.finish()
result = self.scope.read(True)
static_mean = result[f"/{self.device_id}/scopes/0/wave"][0][0]["wave"][
0
].mean()
self.scope.unsubscribe("*")
self.device.set(f"/{self.device_id}/scopes/0/enable", 0)
return float(static_mean)
else:
raise ValueError(
"Scope frequency not set, use 'setupScope' before taking data."
)

def _get_lockin_data(self, duration: float) -> tuple[float, float, float, float]:
"""Averages demodulator data over a specific duration."""

path = f"/{self.device_id}/demods/0/sample"
self.device.subscribe(path)
try:
poll_results = self.device.poll(
recording_time_s=duration, timeout_ms=500, flat=True
)
if path in poll_results:
samples = poll_results[path]
avg_x = float(np.mean(samples["x"]))
avg_y = float(np.mean(samples["y"]))
else:
LOGGER.warning(
f"Poll returned no data for {path}, falling back to getSample"
)
sample = self.device.getSample(path)
avg_x, avg_y = float(sample["x"]), float(sample["y"])
finally:
self.device.unsubscribe(path)

r = float(np.abs(avg_x + 1j * avg_y))
theta = float(np.rad2deg(np.arctan2(avg_y, avg_x)))
return avg_x, avg_y, r, theta

def _set_node(self, path: str, value: float | int, response_msg: bytes):
if isinstance(value, int):
self.device.setInt(f"/{self.device_id}/{path}", value)
self._send_response(response_msg + b": %i" % value)
else:
self.device.setDouble(f"/{self.device_id}/{path}", value)
self._send_response(response_msg + b": %f" % value)

# --- Command Handlers ---
@auto_type_cast
def _get_combined_data(self, duration: float = 0.1) -> None:
x, y, r, theta = self._get_lockin_data(duration)
static = self._get_single_scope_shot()
response = f"{x:e}, {y:e}, {theta:f}, {static:e}, {r:e}"
self._send_response(response.encode())

@auto_type_cast
def _setup_scope_cmd(self, freq: float = 5.0, length: int = 4096, channel: int = 0):
self._setup_scope(freq, length, channel)
self._send_response(b"Scope configured")

@auto_type_cast
def _set_current_range(self, value: float):
# current range is in multiple of 10 between 1e-9 to 1e-2
exponent = int(np.floor(np.log10(value)))

self._set_node(
path="currins/0/range",
value=10.0**exponent,
response_msg=b"Current range set",
)

@auto_type_cast
def _set_ref_output(self, value: int):
self._set_node(
path="sigouts/0/enables/1", value=value, response_msg=b"Output set to"
)

def _auto_voltage_range(self):
self._set_node(
path="sigins/0/autorange", value=1, response_msg=b"Auto voltage triggered"
)

def _auto_current_range(self):
self._set_node(
path="currins/0/autorange", value=1, response_msg=b"Auto current triggered"
)

@auto_type_cast
def _set_time_constant(self, val: float):
self._set_node(
path="demods/0/timeconstant", value=val, response_msg=b"Time constant set"
)

@auto_type_cast
def _set_ref_freq(self, val: float):
self._set_node(path="oscs/0/freq", value=val, response_msg=b"Frequency set")

@auto_type_cast
def _set_data_rate(self, val: float):
self._set_node(path="demods/0/rate", value=val, response_msg=b"Data rate set")

@auto_type_cast
def _set_ref_vpk(self, val: float):
self._set_node(
path="sigouts/0/amplitudes/1", value=val, response_msg=b"Ref Vpk set"
)

@auto_type_cast
def _set_ref_voff(self, val: float):
self._set_node(path="sigouts/0/offset", value=val, response_msg=b"Ref Voff set")

@auto_type_cast
def _set_ref_harmonic(self, val: float):
self._set_node(
path="demods/1/harmonic", value=val, response_msg=b"Harmonic set"
)
3 changes: 3 additions & 0 deletions src/sm_bluesky/common/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .decorators import add_default_metadata, add_extra_names_to_meta, auto_type_cast

__all__ = ["add_default_metadata", "add_extra_names_to_meta", "auto_type_cast"]
Loading
Loading