Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\dev\sst_luminox.py: 50%

# Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\dev\sst_luminox.py : 50%

## 139 statements

Hot-keys on this page

r m x p toggle line displays

j k next/prev highlighted chunk

0 (zero) top of page

1 (one) first highlighted chunk

1# Copyright (c) 2020 ETH Zurich, SIS ID and HVL D-ITET

2#

3"""

4 Device class for a SST Luminox Oxygen sensor. This device can measure the oxygen

5 concentration between 0 % and 25 %.

6

7 Furthermore, it measures the barometric pressure and internal temperature.

8 The device supports two operating modes: in streaming mode the device measures all

9 parameters every second, in polling mode the device measures only after a query.

10

11 Technical specification and documentation for the device can be found a the

12 manufacturer's page:

13 https://www.sstsensing.com/product/luminox-optical-oxygen-sensors-2/

14"""

15

16 import logging

17 import re

18 from enum import Enum

19 from time import sleep

20 from typing import Union, Tuple, List, cast, Optional, Dict

21

22 from .base import SingleCommDevice

23 from ..comm import SerialCommunication, SerialCommunicationConfig

24 from ..comm.serial import (

25 SerialCommunicationParity,

26 SerialCommunicationStopbits,

27 SerialCommunicationBytesize,

28)

29 from ..configuration import configdataclass

30 from ..utils.enum import ValueEnum

31 from ..utils.typing import Number

32

33

34 class LuminoxOutputModeError(Exception):

35 """

36 Wrong output mode for requested data

37 """

38

39 pass

40

41

42 class LuminoxOutputMode(Enum):

43 """

44 output mode.

45 """

46

47 streaming = 0

48 polling = 1

49

50

51 class LuminoxMeasurementTypeError(Exception):

52 """

53 Wrong measurement type for requested data

54 """

55

56 pass

57

58

59 LuminoxMeasurementTypeValue = Union[float, int, str]

60"""A typing hint for all possible LuminoxMeasurementType values as read in either

61 streaming mode or in a polling mode with `LuminoxMeasurementType.all_measurements`.

62

63 Beware: has to be manually kept in sync with `LuminoxMeasurementType` instances

64`cast_type` attribute values.

65"""

66

67

68 LuminoxMeasurementTypeDict = Dict[

69 Union[str, "LuminoxMeasurementType"], LuminoxMeasurementTypeValue

70]

71"""A typing hint for a dictionary holding LuminoxMeasurementType values. Keys are

72 allowed as strings because `LuminoxMeasurementType` is of a `StrEnumBase` type.

73"""

74

75

76 class LuminoxMeasurementType(ValueEnum):

77 """

78 Measurement types for `LuminoxOutputMode.polling`.

79

80 The `all_measurements` type will read values for the actual measurement types

81 as given in `LuminoxOutputMode.all_measurements_types()`; it parses multiple

82 single values using regexp's for other measurement types, therefore, no regexp is

83 defined for this measurement type.

84 """

85

86 _init_ = "value cast_type value_re"

87 partial_pressure_o2 = "O", float, r"[0-9]{4}.[0-9]"

88 percent_o2 = "%", float, r"[0-9]{3}.[0-9]{2}"

89 temperature_sensor = "T", float, r"[+-][0-9]{2}.[0-9]"

90 barometric_pressure = "P", int, r"[0-9]{4}"

91 sensor_status = "e", int, r"[0-9]{4}"

92 date_of_manufacture = "# 0", str, r"[0-9]{5} [0-9]{5}"

93 serial_number = "# 1", str, r"[0-9]{5} [0-9]{5}"

94 software_revision = "# 2", str, r"[0-9]{5}"

95 all_measurements = "A", str, None

96

97 @classmethod

98 def all_measurements_types(cls) -> Tuple["LuminoxMeasurementType", ...]:

99 """

100 A tuple of `LuminoxMeasurementType` enum instances which are actual

101 measurements, i.e. not date of manufacture or software revision.

102 """

103 return cast(

104 Tuple["LuminoxMeasurementType", ...],

105 (

106 cls.partial_pressure_o2,

107 cls.temperature_sensor,

108 cls.barometric_pressure,

109 cls.percent_o2,

110 cls.sensor_status,

111 ),

112 )

113

114 @property

115 def command(self) -> str:

116 return self.value.split(" ")[0]

117

118 def parse_read_measurement_value(

119 self, read_txt: str

120 ) -> Union[LuminoxMeasurementTypeDict, LuminoxMeasurementTypeValue]:

121 if self is LuminoxMeasurementType.all_measurements:

122 return {

123 measurement: measurement._parse_single_measurement_value(read_txt)

124 for measurement in LuminoxMeasurementType.all_measurements_types()

125 }

126 return self._parse_single_measurement_value(read_txt)

127

128 def _parse_single_measurement_value(

129 self, read_txt: str

130 ) -> LuminoxMeasurementTypeValue:

131

132 parsed_data: List[str] = re.findall(f"{self.command} {self.value_re}", read_txt)

133 if len(parsed_data) != 1:

134 self._parse_error(parsed_data)

135

136 parsed_measurement: str = parsed_data[0]

137 try:

138 parsed_value = self.cast_type(

139 # don't check for empty match - we know already that there is one

140 re.search(self.value_re, parsed_measurement).group() # type: ignore

141 )

142 except ValueError:

143 self._parse_error(parsed_data)

144

145 return parsed_value

146

147 def _parse_error(self, parsed_data: List[str]) -> None:

148 err_msg = (

149 f"Expected measurement value for {self.name.replace('_', ' ')} of type "

150 f'{self.cast_type}; instead tyring to parse: "{parsed_data}"'

151 )

152 logging.error(err_msg)

153 raise LuminoxMeasurementTypeError(err_msg)

154

155

156@configdataclass

157 class LuminoxSerialCommunicationConfig(SerialCommunicationConfig):

158 #: Baudrate for SST Luminox is 9600 baud

159 baudrate: int = 9600

160

161 #: SST Luminox does not use parity

162 parity: Union[str, SerialCommunicationParity] = SerialCommunicationParity.NONE

163

164 #: SST Luminox does use one stop bit

165 stopbits: Union[int, SerialCommunicationStopbits] = SerialCommunicationStopbits.ONE

166

167 #: One byte is eight bits long

168 bytesize: Union[

169 int, SerialCommunicationBytesize

170 ] = SerialCommunicationBytesize.EIGHTBITS

171

172 #: The terminator is CR LF

173 terminator: bytes = b"\r\n"

174

175 #: use 3 seconds timeout as default

176 timeout: Number = 3

177

178

179 class LuminoxSerialCommunication(SerialCommunication):

180 """

181 Specific communication protocol implementation for the SST Luminox oxygen sensor.

182 Already predefines device-specific protocol parameters in config.

183 """

184

185 @staticmethod

186 def config_cls():

187 return LuminoxSerialCommunicationConfig

188

189

190@configdataclass

191 class LuminoxConfig:

192 """

193 Configuration for the SST Luminox oxygen sensor.

194 """

195

196 # wait between set and validation of output mode

197 wait_sec_post_activate: Number = 0.5

198 wait_sec_trials_activate: Number = 0.1

199 nr_trials_activate: int = 5

200

201 def clean_values(self):

202 if self.wait_sec_post_activate <= 0:

203 raise ValueError(

204 "Wait time (sec) post output mode activation must be a positive number."

205 )

206 if self.wait_sec_trials_activate <= 0:

207 raise ValueError(

208 "Re-try wait time (sec) for mode activation must be a positive number."

209 )

210 if self.nr_trials_activate <= 0:

211 raise ValueError(

212 "Trials for mode activation must be a positive integer >=1)."

213 )

214

215

216 class Luminox(SingleCommDevice):

217 """

218 Luminox oxygen sensor device class.

219 """

220

221 def __init__(self, com, dev_config=None):

222

223 # Call superclass constructor

224 super().__init__(com, dev_config)

225 self.output: Optional[LuminoxOutputMode] = None

226

227 @staticmethod

228 def config_cls():

229 return LuminoxConfig

230

231 @staticmethod

232 def default_com_cls():

233 return LuminoxSerialCommunication

234

235 def start(self) -> None:

236 """

237 Start this device. Opens the communication protocol.

238 """

239

240 logging.info(f"Starting device {self}")

241 super().start()

242

243 def stop(self) -> None:

244 """

245 Stop the device. Closes also the communication protocol.

246 """

247

248 logging.info(f"Stopping device {self}")

249 super().stop()

250

251 def _write(self, value: str) -> None:

252 """

253 Write given `value` string to `self.com`.

254

255 :param value: String value to send.

256 :raises SerialCommunicationIOError: when communication port is not opened

257 """

258

259 self.com.write_text(value)

260

261 def _read(self) -> str:

262 """

263 Read a string value from `self.com`.

264

265 :return: Read text from the serial port, without the trailing terminator,

266 as defined in the communcation protocol configuration.

267 :raises SerialCommunicationIOError: when communication port is not opened

268 """

269 return self.com.read_text().rstrip(self.com.config.terminator_str())

270

271 def activate_output(self, mode: LuminoxOutputMode) -> None:

272 """

273 activate the selected output mode of the Luminox Sensor.

274 :param mode: polling or streaming

275 """

276 with self.com.access_lock:

277 self._write(f"M {mode.value}")

278 # needs a little bit of time ot activate

279 sleep(self.config.wait_sec_post_activate)

280

281 for trial in range(self.config.nr_trials_activate + 1):

282 msg = self._read()

283 if (

284 not msg == f"M 0{mode.value}"

285 and trial == self.config.nr_trials_activate

286 ):

287 err_msg = (

288 f"Stream mode activation was not possible "

289 f"after {self.config.nr_trials_activate} trials {self}"

290 )

291 logging.error(err_msg)

292 raise LuminoxOutputModeError(err_msg)

293 if msg == f"M 0{mode.value}":

294 msg = (

295 f"Stream mode activation possible "

296 f"in trial {trial} out of {self.config.nr_trials_activate}"

297 )

298 logging.info(msg)

299 break

300 sleep(self.config.wait_sec_trials_activate)

301

302 self.output = mode

303 logging.info(f"{mode.name} mode activated {self}")

304

305 def read_streaming(self) -> LuminoxMeasurementTypeDict:

306 """

307 Read values of Luminox in the streaming mode. Convert the single string

308 into separate values.

309

310 :return: dictionary with `LuminoxMeasurementType.all_measurements_types()` keys

311 and accordingly type-parsed values.

312 :raises LuminoxOutputModeError: when streaming mode is not activated

313 :raises LuminoxMeasurementTypeError: when any of expected measurement values is

314 not read

315 """

316 if not self.output == LuminoxOutputMode.streaming:

317 err_msg = f"Streaming mode not activated {self}"

318 logging.error(err_msg)

319 raise LuminoxOutputModeError(err_msg)

320

321 read_txt = self._read()

322 return cast(

323 LuminoxMeasurementTypeDict,

324 cast(

325 LuminoxMeasurementType, LuminoxMeasurementType.all_measurements

326 ).parse_read_measurement_value(read_txt),

327 )

328

329 def query_polling(

330 self,

331 measurement: Union[str, LuminoxMeasurementType],

332 ) -> Union[LuminoxMeasurementTypeDict, LuminoxMeasurementTypeValue]:

333 """

334 Query a value or values of Luminox measurements in the polling mode,

335 according to a given measurement type.

336

337 :param measurement: type of measurement

338 :return: value of requested measurement

339 :raises ValueError: when a wrong key for LuminoxMeasurementType is provided

340 :raises LuminoxOutputModeError: when polling mode is not activated

341 :raises LuminoxMeasurementTypeError: when expected measurement value is not read

342 """

343 if not isinstance(measurement, LuminoxMeasurementType):

344 try:

345 measurement = cast(

346 LuminoxMeasurementType,

347 LuminoxMeasurementType[measurement], # type: ignore

348 )

349 except KeyError:

350 measurement = cast(

351 LuminoxMeasurementType,

352 LuminoxMeasurementType(measurement),

353 )

354

355 if not self.output == LuminoxOutputMode.polling:

356 err_msg = f"Polling mode not activated {self}"

357 logging.error(err_msg)

358 raise LuminoxOutputModeError(err_msg)

359

360 with self.com.access_lock:

361 self._write(str(measurement))

362 read_txt = self._read()

363 read_value = measurement.parse_read_measurement_value(read_txt)

364 return read_value