Skip to content

Commit b1cf98a

Browse files
authored
Merge pull request #101 from DCC-Lab/device/coherent-fieldmaster-powermeter
Add Coherent FieldMaster GS power meter driver
2 parents dd06a52 + d4c3c8e commit b1cf98a

5 files changed

Lines changed: 270 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ Notable changes to PyHardwareLibrary, loosely following
44
[Keep a Changelog](https://keepachangelog.com/). Read this before upgrading:
55
API changes can land even when the minor version is unchanged.
66

7+
## [Unreleased]
8+
9+
### Added
10+
- `FieldMasterDevice` and `DebugFieldMasterDevice` (`powermeters/`): a driver
11+
for the Coherent FieldMaster GS laser power/energy meter over RS-232 via an
12+
FTDI adaptor (9600 8N1, LF terminator, `pw?`/`en?`/`wv?`/`v` commands). The
13+
meter has no USB identity of its own, so `classIdVendor`/`classIdProduct` are
14+
the generic FTDI values (0x0403/0x6001); disambiguate multiple FTDI adaptors
15+
with the adaptor `serialNumber` or an explicit `portPath`. The message
16+
terminator is a front-panel Menu setting (LF/CR/CR-LF); `initializeDevice`
17+
probes with the configured `terminator` (default LF) and falls through the
18+
other combinations until one replies, so a mismatched Menu self-heals. Note:
19+
the meter only answers RS-232 while on its Home or Trend screen, and
20+
`initializeDevice` raises with that hint when none of the terminators reply.
21+
722
## [1.1.0] - 2026-05-29
823

924
### Changed
Binary file not shown.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11

22
from .powermeterdevice import PowerMeterDevice
33
from .integradevice import IntegraDevice
4+
from .fieldmasterdevice import FieldMasterDevice, DebugFieldMasterDevice
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import re
2+
import time
3+
4+
from hardwarelibrary.communication import SerialPort
5+
from hardwarelibrary.communication.communicationport import (
6+
CommunicationReadTimeout, CommunicationReadNoMatch,
7+
)
8+
from hardwarelibrary.physicaldevice import PhysicalDevice
9+
from hardwarelibrary.powermeters.powermeterdevice import PowerMeterDevice
10+
11+
FLOAT_PATTERN = r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)"
12+
13+
14+
class FieldMasterDevice(PowerMeterDevice):
15+
"""Coherent FieldMaster GS laser power/energy meter over RS-232.
16+
17+
The meter has no USB identity of its own: it connects through a generic
18+
FTDI RS-232 adaptor, so classIdVendor/classIdProduct are the FTDI values
19+
(0x0403/0x6001) shared by every FTDI cable. When several FTDI adaptors are
20+
present, disambiguate with the adaptor's serialNumber (e.g. "FTFDLOTS") or
21+
by passing an explicit portPath.
22+
23+
Protocol (hardwarelibrary/manuals/Coherent_FieldMaster_GS_Manual, IEEE-488.2 style):
24+
9600 baud, no parity, 8 data bits, 1 stop bit, no handshaking (3-wire,
25+
only TxD/RxD/GND wired). 'pw?' returns watts in scientific notation,
26+
e.g. '1.430000e-03'.
27+
28+
The message terminator is a front-panel Menu setting on the meter: LF
29+
('\\n'), CR ('\\r'), or both/CR-LF ('\\r\\n'). The `terminator` argument
30+
(default LF) is only the first guess: doInitializeDevice probes with it and,
31+
if the meter does not answer, tries the remaining combinations until one
32+
replies, so a mismatched Menu setting self-heals. The working value is left
33+
on the port's `terminator`, which both readString and the command writes
34+
use, keeping reads and writes in sync with the meter.
35+
36+
Important operational constraint from the manual: the FieldMaster GS only
37+
answers RS-232 while it is on its Home or Trend screen. On any other screen
38+
it silently buffers commands. When no terminator elicits a reply,
39+
doInitializeDevice raises with that hint.
40+
"""
41+
42+
candidateTerminators = (b'\n', b'\r', b'\r\n')
43+
44+
classIdVendor = 0x0403
45+
classIdProduct = 0x6001
46+
47+
def __init__(self, serialNumber: str = None, idProduct: int = 0x6001,
48+
idVendor: int = 0x0403, portPath: str = None,
49+
terminator: bytes = b'\n'):
50+
super().__init__(serialNumber, idProduct=idProduct, idVendor=idVendor)
51+
self.portPath = portPath
52+
self.terminator = terminator
53+
self.version = ""
54+
55+
def doInitializeDevice(self):
56+
if self.portPath is not None:
57+
self.port = SerialPort(portPath=self.portPath)
58+
else:
59+
self.port = SerialPort(idVendor=self.idVendor, idProduct=self.idProduct,
60+
serialNumber=self.serialNumber)
61+
if self.port.portPath is None:
62+
raise PhysicalDevice.UnableToInitialize("No FieldMaster (FTDI adaptor) connected")
63+
64+
self.port.open(baudRate=9600, timeout=1.0)
65+
66+
if not self.detectTerminator():
67+
self.port.close()
68+
self.port = None
69+
raise PhysicalDevice.UnableToInitialize(
70+
"FieldMaster is connected but not answering on any terminator "
71+
"(LF/CR/CR-LF). Put it on its HOME screen (front panel): it "
72+
"ignores RS-232 from any other screen."
73+
)
74+
75+
self.doGetVersion()
76+
77+
def detectTerminator(self):
78+
"""Set port.terminator to the meter's Menu terminator by probing.
79+
80+
The terminator is a front-panel Menu setting (LF/CR/CR-LF), so a query
81+
only draws a reply when the terminator matches. The configured
82+
self.terminator is tried first, then the remaining candidates. On the
83+
first probe that answers, the working value is kept on the port and on
84+
self.terminator and True is returned; False means none replied.
85+
"""
86+
ordered = [self.terminator]
87+
ordered += [term for term in self.candidateTerminators if term != self.terminator]
88+
for term in ordered:
89+
self.clearMeterLineBuffer()
90+
self.port.terminator = term
91+
try:
92+
self.doGetAbsolutePower()
93+
except (CommunicationReadTimeout, CommunicationReadNoMatch):
94+
continue
95+
self.terminator = term
96+
return True
97+
return False
98+
99+
def clearMeterLineBuffer(self):
100+
"""Complete and drain any partial command a previous probe left in the
101+
meter's input buffer so it cannot corrupt the next probe."""
102+
self.port.writeData(b"\r\n")
103+
time.sleep(0.2)
104+
self.port.flush()
105+
106+
def doShutdownDevice(self):
107+
self.port.close()
108+
self.port = None
109+
110+
def sendQuery(self, command, replyPattern=FLOAT_PATTERN, maxLines=3):
111+
"""Send a query and return the first capture group matching replyPattern.
112+
113+
The meter answers a query with a single value line, but a stray echo or
114+
blank line can precede it, so up to maxLines are read before giving up.
115+
"""
116+
self.port.flush()
117+
self.port.writeString(command + self.port.terminator.decode())
118+
for _ in range(maxLines):
119+
reply = self.port.readString()
120+
match = re.search(replyPattern, reply)
121+
if match is not None:
122+
return match.groups()[0]
123+
raise CommunicationReadNoMatch(
124+
"No reply matching '{0}' from FieldMaster for '{1}'".format(replyPattern, command)
125+
)
126+
127+
def doGetAbsolutePower(self):
128+
self.absolutePower = float(self.sendQuery("pw?"))
129+
130+
def doGetCalibrationWavelength(self):
131+
wavelengthInMeters = float(self.sendQuery("wv?"))
132+
self.calibrationWavelength = wavelengthInMeters * 1e9
133+
134+
def doSetCalibrationWavelength(self, wavelength):
135+
"""Set the calibration wavelength. wavelength is in nanometres; the
136+
meter's 'wv' command takes metres, so it is converted on the wire."""
137+
wavelengthInMeters = wavelength * 1e-9
138+
self.port.flush()
139+
self.port.writeString("wv {0:.6e}".format(wavelengthInMeters) + self.port.terminator.decode())
140+
141+
def getEnergy(self):
142+
"""Return the current energy reading in joules ('en?')."""
143+
return float(self.sendQuery("en?"))
144+
145+
def doGetVersion(self):
146+
self.version = self.sendQuery("v", replyPattern=r"(.+)")
147+
148+
149+
class DebugFieldMasterDevice(FieldMasterDevice):
150+
"""Hardware-free FieldMaster GS for tests. Holds power and calibration
151+
state in memory; power tracks a fixed simulated reading."""
152+
153+
classIdVendor = 0xFFFF
154+
classIdProduct = 0xFFF1
155+
156+
def __init__(self, serialNumber='debug'):
157+
PhysicalDevice.__init__(self, serialNumber=serialNumber,
158+
idProduct=self.classIdProduct, idVendor=self.classIdVendor)
159+
self.portPath = None
160+
self.terminator = b'\n'
161+
self.version = "Debug FieldMaster GS 1.0"
162+
self._simulatedPower = 1.43e-3
163+
self._calibrationWavelengthInNanometers = 1064.0
164+
165+
def doInitializeDevice(self):
166+
pass
167+
168+
def doShutdownDevice(self):
169+
pass
170+
171+
def doGetAbsolutePower(self):
172+
self.absolutePower = self._simulatedPower
173+
174+
def doGetCalibrationWavelength(self):
175+
self.calibrationWavelength = self._calibrationWavelengthInNanometers
176+
177+
def doSetCalibrationWavelength(self, wavelength):
178+
self._calibrationWavelengthInNanometers = wavelength
179+
180+
def getEnergy(self):
181+
return self._simulatedPower
182+
183+
def doGetVersion(self):
184+
pass
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import env
2+
import unittest
3+
4+
from hardwarelibrary.powermeters import FieldMasterDevice, DebugFieldMasterDevice
5+
from hardwarelibrary.powermeters.powermeterdevice import PowerMeterNotification
6+
from hardwarelibrary.notificationcenter import NotificationCenter
7+
8+
9+
class TestDebugFieldMasterDevice(unittest.TestCase):
10+
def setUp(self):
11+
self.device = DebugFieldMasterDevice()
12+
self.device.initializeDevice()
13+
14+
def tearDown(self):
15+
self.device.shutdownDevice()
16+
NotificationCenter().clear()
17+
18+
def testCreate(self):
19+
self.assertIsNotNone(self.device)
20+
21+
def testMeasureAbsolutePower(self):
22+
power = self.device.measureAbsolutePower()
23+
self.assertIsInstance(power, float)
24+
self.assertGreater(power, 0.0)
25+
26+
def testMeasureAbsolutePowerPostsNotification(self):
27+
self.received = None
28+
29+
def handler(notification):
30+
self.received = notification.userInfo
31+
32+
NotificationCenter().addObserver(self, handler, PowerMeterNotification.didMeasure)
33+
power = self.device.measureAbsolutePower()
34+
self.assertEqual(self.received, power)
35+
36+
def testGetCalibrationWavelength(self):
37+
self.assertEqual(self.device.getCalibrationWavelength(), 1064.0)
38+
39+
def testSetCalibrationWavelength(self):
40+
self.device.setCalibrationWavelength(532.0)
41+
self.assertEqual(self.device.getCalibrationWavelength(), 532.0)
42+
43+
def testEnergy(self):
44+
self.assertIsInstance(self.device.getEnergy(), float)
45+
46+
47+
class TestFieldMasterDevice(unittest.TestCase):
48+
def setUp(self):
49+
self.device = FieldMasterDevice()
50+
try:
51+
self.device.initializeDevice()
52+
except Exception:
53+
self.skipTest("No FieldMaster (on its Home screen) connected")
54+
55+
def tearDown(self):
56+
self.device.shutdownDevice()
57+
58+
def testMeasureAbsolutePower(self):
59+
power = self.device.measureAbsolutePower()
60+
self.assertIsInstance(power, float)
61+
print("FieldMaster power:", power)
62+
63+
def testGetCalibrationWavelength(self):
64+
wavelength = self.device.getCalibrationWavelength()
65+
self.assertGreater(wavelength, 100)
66+
print("FieldMaster calibration wavelength (nm):", wavelength)
67+
68+
69+
if __name__ == "__main__":
70+
unittest.main()

0 commit comments

Comments
 (0)