Skip to content

Commit eef9642

Browse files
dccoteclaude
andcommitted
Add capability mixins to the power-meter family
Problem: PowerMeterDevice baked wavelength-calibration hooks directly into the base class even though not every meter calibrates by wavelength, and there was no way to introspect what a given meter can do. The laser-source family already solves this with interface-segregated Capability mixins, but the power meters did not mirror that structure. Solution: Introduce hardwarelibrary/powermeters/capabilities.py with a Capability ABC and three orthogonal mixins matching the sources/ pattern: WavelengthCalibratable (wavelength hooks moved out of the base), AutoScalable (toggle automatic ranging), and ScaleAdjustable (read/set the manual full-scale range and enumerate available scales). PowerMeterDevice now keeps only the always-present doGetAbsolutePower hook and gains capabilities()/hasCapability() introspection copied from LaserSourceDevice. IntegraDevice and FieldMasterDevice declare WavelengthCalibratable; their existing hooks satisfy the contract unchanged. Adds TestFieldMasterCapabilities covering the introspection API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c715b4d commit eef9642

6 files changed

Lines changed: 129 additions & 20 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11

2+
from .capabilities import Capability, WavelengthCalibratable, AutoScalable, ScaleAdjustable
23
from .powermeterdevice import PowerMeterDevice
34
from .integradevice import IntegraDevice
45
from .fieldmasterdevice import FieldMasterDevice, DebugFieldMasterDevice
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from abc import ABC, abstractmethod
2+
3+
4+
class Capability(ABC):
5+
pass
6+
7+
8+
class WavelengthCalibratable(Capability):
9+
unit = "nm"
10+
isReadable = True
11+
isWritable = True
12+
calibrationWavelength = None
13+
14+
def getCalibrationWavelength(self):
15+
self.doGetCalibrationWavelength()
16+
return self.calibrationWavelength
17+
18+
def setCalibrationWavelength(self, wavelength):
19+
self.doSetCalibrationWavelength(wavelength)
20+
self.doGetCalibrationWavelength()
21+
22+
@abstractmethod
23+
def doGetCalibrationWavelength(self):
24+
...
25+
26+
@abstractmethod
27+
def doSetCalibrationWavelength(self, wavelength):
28+
...
29+
30+
31+
class AutoScalable(Capability):
32+
# The meter picks its measurement range automatically when auto-scaling is
33+
# on; turning it off pins the range to whatever scale is active. A meter may
34+
# also expose ScaleAdjustable to choose that range by hand.
35+
def autoScaleIsOn(self) -> bool:
36+
return self.doGetAutoScale()
37+
38+
def turnAutoScaleOn(self):
39+
self.doTurnAutoScaleOn()
40+
41+
def turnAutoScaleOff(self):
42+
self.doTurnAutoScaleOff()
43+
44+
@abstractmethod
45+
def doGetAutoScale(self) -> bool:
46+
...
47+
48+
@abstractmethod
49+
def doTurnAutoScaleOn(self):
50+
...
51+
52+
@abstractmethod
53+
def doTurnAutoScaleOff(self):
54+
...
55+
56+
57+
class ScaleAdjustable(Capability):
58+
# The full-scale measurement range (e.g. 200e-3 W). Independent of
59+
# AutoScalable: setting a scale by hand generally requires auto-scaling off.
60+
unit = "W"
61+
isReadable = True
62+
isWritable = True
63+
scale = None
64+
65+
def getScale(self):
66+
self.doGetScale()
67+
return self.scale
68+
69+
def setScale(self, scale):
70+
self.doSetScale(scale)
71+
self.doGetScale()
72+
73+
def availableScales(self) -> list:
74+
return self.doGetAvailableScales()
75+
76+
@abstractmethod
77+
def doGetScale(self):
78+
...
79+
80+
@abstractmethod
81+
def doSetScale(self, scale):
82+
...
83+
84+
@abstractmethod
85+
def doGetAvailableScales(self) -> list:
86+
...

hardwarelibrary/powermeters/fieldmasterdevice.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
)
88
from hardwarelibrary.physicaldevice import PhysicalDevice
99
from hardwarelibrary.powermeters.powermeterdevice import PowerMeterDevice
10+
from hardwarelibrary.powermeters.capabilities import WavelengthCalibratable
1011

1112
FLOAT_PATTERN = r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)"
1213

1314

14-
class FieldMasterDevice(PowerMeterDevice):
15+
class FieldMasterDevice(PowerMeterDevice, WavelengthCalibratable):
1516
"""Coherent FieldMaster GS laser power/energy meter over RS-232.
1617
1718
The meter has no USB identity of its own: it connects through a generic

hardwarelibrary/powermeters/integradevice.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
from enum import Enum
33
from hardwarelibrary.communication import USBPort, TextCommand, MultilineTextCommand
44
from hardwarelibrary.powermeters.powermeterdevice import PowerMeterDevice
5+
from hardwarelibrary.powermeters.capabilities import WavelengthCalibratable
56
from hardwarelibrary.notificationcenter import NotificationCenter, Notification
67

7-
class IntegraDevice(PowerMeterDevice):
8+
class IntegraDevice(PowerMeterDevice, WavelengthCalibratable):
89
classIdProduct = 0x0300
910
classIdVendor = 0x1ad5
1011
commands = {

hardwarelibrary/powermeters/powermeterdevice.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from hardwarelibrary.communication import USBPort, TextCommand
66
from hardwarelibrary.physicaldevice import *
77
from hardwarelibrary.notificationcenter import NotificationCenter, Notification
8+
from hardwarelibrary.powermeters.capabilities import Capability
89

910
class PowerMeterNotification(Enum):
1011
didMeasure = "didMeasure"
@@ -14,20 +15,22 @@ class PowerMeterDevice(PhysicalDevice):
1415
def __init__(self, serialNumber:str, idProduct:int, idVendor:int):
1516
super().__init__(serialNumber, idProduct, idVendor)
1617
self.absolutePower = 0
17-
self.calibrationWavelength = None
1818

19-
# Hardware hooks a driver must implement, on top of doInitializeDevice
20-
# and doShutdownDevice inherited from PhysicalDevice.
21-
@abstractmethod
22-
def doGetAbsolutePower(self):
23-
...
19+
def capabilities(self) -> list:
20+
# The capability mixins, not the marker nor the device class itself
21+
# (a driver is a Capability subclass too, but it is a PhysicalDevice).
22+
return [klass for klass in type(self).__mro__
23+
if issubclass(klass, Capability)
24+
and klass is not Capability
25+
and not issubclass(klass, PhysicalDevice)]
2426

25-
@abstractmethod
26-
def doGetCalibrationWavelength(self):
27-
...
27+
def hasCapability(self, capabilityClass) -> bool:
28+
return isinstance(self, capabilityClass)
2829

30+
# Measuring absolute power is the one capability every power meter has, so
31+
# its hook stays on the base; optional capabilities live in mixins.
2932
@abstractmethod
30-
def doSetCalibrationWavelength(self, wavelength):
33+
def doGetAbsolutePower(self):
3134
...
3235

3336
def measureAbsolutePower(self):
@@ -36,13 +39,5 @@ def measureAbsolutePower(self):
3639
NotificationCenter().postNotification(PowerMeterNotification.didMeasure, notifyingObject=self, userInfo=power)
3740
return power
3841

39-
def getCalibrationWavelength(self):
40-
self.doGetCalibrationWavelength()
41-
return self.calibrationWavelength
42-
43-
def setCalibrationWavelength(self, wavelength):
44-
self.doSetCalibrationWavelength(wavelength)
45-
self.doGetCalibrationWavelength()
46-
4742
def doGetStatusUserInfo(self):
4843
return self.measureAbsolutePower()

hardwarelibrary/tests/testFieldMasterDevice.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
import unittest
33

44
from hardwarelibrary.powermeters import FieldMasterDevice, DebugFieldMasterDevice
5+
from hardwarelibrary.powermeters import (
6+
WavelengthCalibratable, AutoScalable, ScaleAdjustable,
7+
)
58
from hardwarelibrary.powermeters.powermeterdevice import PowerMeterNotification
69
from hardwarelibrary.notificationcenter import NotificationCenter
710

@@ -44,6 +47,28 @@ def testEnergy(self):
4447
self.assertIsInstance(self.device.getEnergy(), float)
4548

4649

50+
class TestFieldMasterCapabilities(unittest.TestCase):
51+
def setUp(self):
52+
self.device = DebugFieldMasterDevice()
53+
54+
def testDeclaresWavelengthCalibratable(self):
55+
self.assertTrue(self.device.hasCapability(WavelengthCalibratable))
56+
57+
def testDoesNotDeclareScaleCapabilities(self):
58+
self.assertFalse(self.device.hasCapability(AutoScalable))
59+
self.assertFalse(self.device.hasCapability(ScaleAdjustable))
60+
61+
def testCapabilitiesListsOnlyDeclaredMixins(self):
62+
self.assertEqual(self.device.capabilities(), [WavelengthCalibratable])
63+
64+
def testCapabilitiesExcludeDeviceClass(self):
65+
# The driver is itself a Capability subclass, but capabilities() must
66+
# report only the mixins, never the device class or its base.
67+
capabilities = self.device.capabilities()
68+
self.assertNotIn(DebugFieldMasterDevice, capabilities)
69+
self.assertNotIn(FieldMasterDevice, capabilities)
70+
71+
4772
class TestFieldMasterDevice(unittest.TestCase):
4873
def setUp(self):
4974
self.device = FieldMasterDevice()

0 commit comments

Comments
 (0)