scietex.hal.vfd 2.0.0__tar.gz → 2.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scietex_hal_vfd-2.0.0/src/scietex.hal.vfd.egg-info → scietex_hal_vfd-2.0.2}/PKG-INFO +8 -2
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/README.md +7 -1
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/data.py +5 -2
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/rs485.py +24 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/client.py +67 -18
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/rs485.py +15 -5
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/version.py +1 -1
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/client.py +43 -12
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/rs485.py +11 -5
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/client.py +35 -6
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/rs485.py +6 -2
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2/src/scietex.hal.vfd.egg-info}/PKG-INFO +8 -2
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/SOURCES.txt +2 -1
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_modbus_client.py +67 -3
- scietex_hal_vfd-2.0.2/tests/test_vesper_optional_reads.py +176 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/LICENSE +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/pyproject.toml +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/setup.cfg +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/__init__.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/__init__.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/capabilities.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/protocol.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/__init__.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/__init__.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/errors.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/registers.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/modbus_drivers.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/py.typed +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/__init__.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/__init__.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/errors.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/registers.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/__init__.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/errors.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/registers.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/dependency_links.txt +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/entry_points.txt +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/requires.txt +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/top_level.txt +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_modbus_drivers.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_registers.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_version.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_vesper_e4_8400_registers.py +0 -0
- {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_vesper_e5_8200_registers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scietex.hal.vfd
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Scietex VFD HAL package
|
|
5
5
|
Author-email: Anton Bondarenko <bond.anton@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -86,7 +86,7 @@ A driver reaches a drive over one of two transports:
|
|
|
86
86
|
|
|
87
87
|
| Field | Intek SPE-B | Vesper E5-8200 | Vesper E4-8400 |
|
|
88
88
|
| --- | --- | --- | --- |
|
|
89
|
-
| `frequency_hz` | yes | yes | yes |
|
|
89
|
+
| `frequency_hz` | yes¹ | yes | yes |
|
|
90
90
|
| `frequency_percent` | yes | yes | yes |
|
|
91
91
|
| `output_current` | yes | yes | yes |
|
|
92
92
|
| `output_voltage` | yes | yes | yes |
|
|
@@ -98,6 +98,12 @@ A driver reaches a drive over one of two transports:
|
|
|
98
98
|
Only the E4-8400 exposes a live temperature reading (`004Dh`, U1-46). The other
|
|
99
99
|
two report overheat only as a fault code, and a fault is not a temperature.
|
|
100
100
|
|
|
101
|
+
¹ The SPE-B reports the operating frequency only as a percentage of `P0.10`, so
|
|
102
|
+
`frequency_hz` is `None` until `P0.10` has been read. A drive that refuses
|
|
103
|
+
`P0.10` still reports `frequency_percent`, current, voltage and power; the
|
|
104
|
+
connection stays usable and only the Hz field degrades. `set_frequency` is
|
|
105
|
+
refused in that state, because no percentage can be computed.
|
|
106
|
+
|
|
101
107
|
## Installation
|
|
102
108
|
|
|
103
109
|
Install the package via pip (assuming it's published to PyPI):
|
|
@@ -38,7 +38,7 @@ A driver reaches a drive over one of two transports:
|
|
|
38
38
|
|
|
39
39
|
| Field | Intek SPE-B | Vesper E5-8200 | Vesper E4-8400 |
|
|
40
40
|
| --- | --- | --- | --- |
|
|
41
|
-
| `frequency_hz` | yes | yes | yes |
|
|
41
|
+
| `frequency_hz` | yes¹ | yes | yes |
|
|
42
42
|
| `frequency_percent` | yes | yes | yes |
|
|
43
43
|
| `output_current` | yes | yes | yes |
|
|
44
44
|
| `output_voltage` | yes | yes | yes |
|
|
@@ -50,6 +50,12 @@ A driver reaches a drive over one of two transports:
|
|
|
50
50
|
Only the E4-8400 exposes a live temperature reading (`004Dh`, U1-46). The other
|
|
51
51
|
two report overheat only as a fault code, and a fault is not a temperature.
|
|
52
52
|
|
|
53
|
+
¹ The SPE-B reports the operating frequency only as a percentage of `P0.10`, so
|
|
54
|
+
`frequency_hz` is `None` until `P0.10` has been read. A drive that refuses
|
|
55
|
+
`P0.10` still reports `frequency_percent`, current, voltage and power; the
|
|
56
|
+
connection stays usable and only the Hz field degrades. `set_frequency` is
|
|
57
|
+
refused in that state, because no percentage can be computed.
|
|
58
|
+
|
|
53
59
|
## Installation
|
|
54
60
|
|
|
55
61
|
Install the package via pip (assuming it's published to PyPI):
|
|
@@ -44,7 +44,10 @@ class VFDParameters(msgspec.Struct, frozen=True):
|
|
|
44
44
|
"""Model for VFD operational parameters.
|
|
45
45
|
|
|
46
46
|
Attributes:
|
|
47
|
-
frequency_hz: Output frequency in Hz
|
|
47
|
+
frequency_hz: Output frequency in Hz, or ``None`` when the drive
|
|
48
|
+
reports the frequency only as a percentage and the maximum it is a
|
|
49
|
+
percentage of is unknown. ``None`` rather than 0.0 because the drive
|
|
50
|
+
has not reported a frequency of zero — it has reported nothing.
|
|
48
51
|
frequency_percent: Output frequency as a percentage of the configured
|
|
49
52
|
maximum, 0-100.
|
|
50
53
|
output_current: Output current in Amps.
|
|
@@ -61,7 +64,7 @@ class VFDParameters(msgspec.Struct, frozen=True):
|
|
|
61
64
|
state: Current VFD state.
|
|
62
65
|
"""
|
|
63
66
|
|
|
64
|
-
frequency_hz: float # Current operating frequency in Hz
|
|
67
|
+
frequency_hz: float | None # Current operating frequency in Hz
|
|
65
68
|
frequency_percent: float # Frequency as a percentage of max (0-100%)
|
|
66
69
|
output_current: float = 0.0 # Output current in Amps
|
|
67
70
|
output_voltage: float = 0.0 # Output voltage in Volts
|
|
@@ -4,6 +4,7 @@ from logging import Logger
|
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
6
|
from scietex.hal.serial import (
|
|
7
|
+
ModbusOperationError,
|
|
7
8
|
ModbusSerialConnectionConfig,
|
|
8
9
|
RS485Client,
|
|
9
10
|
SerialConnectionConfig,
|
|
@@ -116,6 +117,29 @@ class VFD(RS485Client, VFDProtocol):
|
|
|
116
117
|
parameters = await self.read_parameters()
|
|
117
118
|
return {f: getattr(parameters, f) for f in parameters.__struct_fields__}
|
|
118
119
|
|
|
120
|
+
async def _read_optional_register(self, register: int) -> int | None:
|
|
121
|
+
"""
|
|
122
|
+
Read one register, returning ``None`` instead of raising on failure.
|
|
123
|
+
|
|
124
|
+
Used for readings that refine a snapshot but must not fail it: a drive
|
|
125
|
+
that refuses one diagnostic register still reports its frequency,
|
|
126
|
+
current and state, and a poll loop that raised on every optional read
|
|
127
|
+
would never publish anything.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
register: The holding register address.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
int | None: The register value, or ``None`` when the read failed.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
return await self.read_register(register, holding=True)
|
|
137
|
+
except ModbusOperationError as exc:
|
|
138
|
+
self.logger.warning(
|
|
139
|
+
"%s: Optional register %s is unreadable (%s).", self.label, register, exc
|
|
140
|
+
)
|
|
141
|
+
return None
|
|
142
|
+
|
|
119
143
|
async def process_message(self, message: dict[str, Any]) -> None:
|
|
120
144
|
await super().process_message(message)
|
|
121
145
|
if "cmd" in message:
|
|
@@ -103,11 +103,13 @@ class IntekSPEBModbusVFD(VFDProtocol):
|
|
|
103
103
|
Open the TCP connection to the gateway and read the drive's scale factors.
|
|
104
104
|
|
|
105
105
|
Raises:
|
|
106
|
-
ModbusOperationError: If the gateway is unreachable
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
ModbusOperationError: If the gateway is unreachable. pymodbus
|
|
107
|
+
reports a failed connect by returning ``False`` rather than
|
|
108
|
+
raising, so the result is checked here; otherwise an
|
|
109
|
+
unreachable drive would look connected and only fail on the
|
|
110
|
+
first read. An unreadable P0.10 is not an error: the drive
|
|
111
|
+
still reports its measurements, only ``frequency_hz`` degrades
|
|
112
|
+
to ``None``.
|
|
111
113
|
"""
|
|
112
114
|
if self._client is None:
|
|
113
115
|
self._client = AsyncModbusTcpClient(self._host, port=self._port, timeout=self._timeout)
|
|
@@ -157,6 +159,31 @@ class IntekSPEBModbusVFD(VFDProtocol):
|
|
|
157
159
|
)
|
|
158
160
|
return list(response.registers)
|
|
159
161
|
|
|
162
|
+
async def _read_optional_registers(self, address: int, count: int) -> list[int] | None:
|
|
163
|
+
"""
|
|
164
|
+
Read holding registers, returning ``None`` instead of raising on failure.
|
|
165
|
+
|
|
166
|
+
Used for readings that refine a snapshot but must not fail it: a drive
|
|
167
|
+
that refuses one diagnostic register still reports its frequency,
|
|
168
|
+
current and state, and a poll loop that raised on every optional read
|
|
169
|
+
would never publish anything.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
address: Starting register address.
|
|
173
|
+
count: Number of registers to read.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
list[int] | None: The register values, or ``None`` when the read
|
|
177
|
+
failed.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
return await self._read_registers(address, count)
|
|
181
|
+
except ModbusOperationError as exc:
|
|
182
|
+
self.logger.warning(
|
|
183
|
+
"%s: Optional register %s is unreadable (%s).", self.label, address, exc
|
|
184
|
+
)
|
|
185
|
+
return None
|
|
186
|
+
|
|
160
187
|
async def _write_register(self, address: int, value: int) -> None:
|
|
161
188
|
"""
|
|
162
189
|
Write a single holding register, raising on a Modbus exception response.
|
|
@@ -180,16 +207,25 @@ class IntekSPEBModbusVFD(VFDProtocol):
|
|
|
180
207
|
"""
|
|
181
208
|
Read and cache the P0.10 maximum frequency and the Pd.06 resolution.
|
|
182
209
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
210
|
+
P0.10 is optional: a drive that refuses it still reports its operating
|
|
211
|
+
percentage, current, voltage and power, so the connection stays usable
|
|
212
|
+
and only ``frequency_hz`` degrades to ``None``. Raising here would make
|
|
213
|
+
the poll loop log a connect failure every cycle and publish nothing at
|
|
214
|
+
all, which is what an SPE-B that has not been configured for Modbus
|
|
215
|
+
(Pd.01/Pd.05) does — it answers the gateway with exception 11.
|
|
186
216
|
"""
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
217
|
+
try:
|
|
218
|
+
raw = await self._read_registers(registers.PARAM_MAX_FREQUENCY_EEPROM, 1)
|
|
219
|
+
except ModbusOperationError as exc:
|
|
190
220
|
self.logger.warning(
|
|
191
|
-
"%s: P0.10
|
|
221
|
+
"%s: P0.10 is unreadable (%s), frequency is reported as None.", self.label, exc
|
|
192
222
|
)
|
|
223
|
+
else:
|
|
224
|
+
self._max_frequency_hz = raw[0] / registers.PARAMETER_FREQUENCY_RESOLUTION
|
|
225
|
+
if self._max_frequency_hz <= 0:
|
|
226
|
+
self.logger.warning(
|
|
227
|
+
"%s: P0.10 reads %d, frequency is reported as None.", self.label, raw[0]
|
|
228
|
+
)
|
|
193
229
|
# Pd.06 only refines the current reading, so a drive that refuses the
|
|
194
230
|
# read keeps working with the factory resolution instead of failing
|
|
195
231
|
# the connection: stop and fault reset must stay available.
|
|
@@ -204,7 +240,7 @@ class IntekSPEBModbusVFD(VFDProtocol):
|
|
|
204
240
|
else registers.CURRENT_RESOLUTION_FINE
|
|
205
241
|
)
|
|
206
242
|
|
|
207
|
-
def _percent_to_hz(self, percent: float) -> float:
|
|
243
|
+
def _percent_to_hz(self, percent: float) -> float | None:
|
|
208
244
|
"""
|
|
209
245
|
Convert an operating frequency percentage into Hz.
|
|
210
246
|
|
|
@@ -212,10 +248,13 @@ class IntekSPEBModbusVFD(VFDProtocol):
|
|
|
212
248
|
percent: Frequency as reported by the drive.
|
|
213
249
|
|
|
214
250
|
Returns:
|
|
215
|
-
float: The frequency in Hz, or
|
|
251
|
+
float | None: The frequency in Hz, or ``None`` while P0.10 is
|
|
252
|
+
unknown. ``None`` rather than 0.0 because a drive that refuses
|
|
253
|
+
P0.10 has not reported a frequency of zero — it has reported
|
|
254
|
+
nothing, and a caller must be able to tell the two apart.
|
|
216
255
|
"""
|
|
217
256
|
if self._max_frequency_hz <= 0:
|
|
218
|
-
return
|
|
257
|
+
return None
|
|
219
258
|
return registers.percent_to_hz(percent, self._max_frequency_hz)
|
|
220
259
|
|
|
221
260
|
async def read_parameters(self) -> VFDParameters:
|
|
@@ -227,9 +266,16 @@ class IntekSPEBModbusVFD(VFDProtocol):
|
|
|
227
266
|
drive sends them, because the manual states no scaling factor for
|
|
228
267
|
either; current is scaled by the Pd.06 resolution.
|
|
229
268
|
|
|
269
|
+
The run state is read separately and is optional: a drive that refuses
|
|
270
|
+
the status register still reports its measurements, so the state falls
|
|
271
|
+
back to ``VFDState.UNKNOWN`` instead of failing the whole snapshot.
|
|
272
|
+
|
|
230
273
|
Returns:
|
|
231
274
|
VFDParameters: The decoded parameters. Both temperature fields are
|
|
232
275
|
``None``: SPE-B reports overheat as a fault code, not a reading.
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
ModbusOperationError: If the measurement block cannot be read.
|
|
233
279
|
"""
|
|
234
280
|
block = await self._read_registers(
|
|
235
281
|
registers.PARAMETERS_BLOCK_ADDRESS, registers.PARAMETERS_BLOCK_COUNT
|
|
@@ -264,10 +310,13 @@ class IntekSPEBModbusVFD(VFDProtocol):
|
|
|
264
310
|
Read the drive's run state.
|
|
265
311
|
|
|
266
312
|
Returns:
|
|
267
|
-
VFDState: The decoded state, or ``VFDState.UNKNOWN``
|
|
268
|
-
value the manual does not
|
|
313
|
+
VFDState: The decoded state, or ``VFDState.UNKNOWN`` when the status
|
|
314
|
+
register is unreadable or holds a value the manual does not
|
|
315
|
+
define.
|
|
269
316
|
"""
|
|
270
|
-
raw = await self.
|
|
317
|
+
raw = await self._read_optional_registers(registers.REG_STATUS, 1)
|
|
318
|
+
if raw is None:
|
|
319
|
+
return VFDState.UNKNOWN
|
|
271
320
|
state = registers.status_to_state(raw[0])
|
|
272
321
|
self.logger.debug("%s: Current state: %s", self.label, state)
|
|
273
322
|
return state
|
|
@@ -53,12 +53,22 @@ class SPEvB(VFD):
|
|
|
53
53
|
self._current_resolution = registers.CURRENT_RESOLUTION_FINE
|
|
54
54
|
|
|
55
55
|
async def _read_scale_factors(self) -> None:
|
|
56
|
-
"""
|
|
57
|
-
|
|
56
|
+
"""
|
|
57
|
+
Read P0.10 and Pd.06 from the drive once and cache them.
|
|
58
|
+
|
|
59
|
+
P0.10 is optional: a drive that refuses it still reports its operating
|
|
60
|
+
percentage, current, voltage and power, so only ``frequency_hz``
|
|
61
|
+
degrades to ``None``. Raising here would fail the whole snapshot.
|
|
62
|
+
"""
|
|
63
|
+
raw = await self.read_register(
|
|
64
|
+
registers.PARAM_MAX_FREQUENCY_EEPROM,
|
|
65
|
+
holding=True,
|
|
66
|
+
raise_on_error=False,
|
|
67
|
+
)
|
|
58
68
|
self._max_frequency_hz = (raw or 0) / registers.PARAMETER_FREQUENCY_RESOLUTION
|
|
59
69
|
if self._max_frequency_hz <= 0:
|
|
60
70
|
self.logger.warning(
|
|
61
|
-
"%s: P0.10 reads %s, frequency is reported as
|
|
71
|
+
"%s: P0.10 reads %s, frequency is reported as None.", self.label, raw
|
|
62
72
|
)
|
|
63
73
|
# Pd.06 only refines the current reading, so a drive that refuses the
|
|
64
74
|
# read keeps working with the factory resolution.
|
|
@@ -114,7 +124,7 @@ class SPEvB(VFD):
|
|
|
114
124
|
frequency_hz = (
|
|
115
125
|
registers.percent_to_hz(percent, self._max_frequency_hz)
|
|
116
126
|
if self._max_frequency_hz
|
|
117
|
-
else
|
|
127
|
+
else None
|
|
118
128
|
)
|
|
119
129
|
state = await self.read_state()
|
|
120
130
|
return VFDParameters(
|
|
@@ -135,7 +145,7 @@ class SPEvB(VFD):
|
|
|
135
145
|
|
|
136
146
|
async def read_state(self) -> VFDState:
|
|
137
147
|
"""Read the VFD run state"""
|
|
138
|
-
status = await self.
|
|
148
|
+
status = await self._read_optional_register(registers.REG_STATUS)
|
|
139
149
|
if status is None:
|
|
140
150
|
return VFDState.UNKNOWN
|
|
141
151
|
return registers.status_to_state(status)
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/client.py
RENAMED
|
@@ -160,6 +160,31 @@ class VesperE48400ModbusVFD(VFDProtocol):
|
|
|
160
160
|
)
|
|
161
161
|
return list(response.registers)
|
|
162
162
|
|
|
163
|
+
async def _read_optional_registers(self, address: int, count: int) -> list[int] | None:
|
|
164
|
+
"""
|
|
165
|
+
Read holding registers, returning ``None`` instead of raising on failure.
|
|
166
|
+
|
|
167
|
+
Used for readings that refine a snapshot but must not fail it: a drive
|
|
168
|
+
that refuses one diagnostic register still reports its frequency,
|
|
169
|
+
current and state, and a poll loop that raised on every optional read
|
|
170
|
+
would never publish anything.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
address: Starting register address.
|
|
174
|
+
count: Number of registers to read.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
list[int] | None: The register values, or ``None`` when the read
|
|
178
|
+
failed.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
return await self._read_registers(address, count)
|
|
182
|
+
except ModbusOperationError as exc:
|
|
183
|
+
self.logger.warning(
|
|
184
|
+
"%s: Optional register %s is unreadable (%s).", self.label, address, exc
|
|
185
|
+
)
|
|
186
|
+
return None
|
|
187
|
+
|
|
163
188
|
async def _write_register(self, address: int, value: int) -> None:
|
|
164
189
|
"""
|
|
165
190
|
Write a single holding register, raising on a Modbus exception response.
|
|
@@ -221,13 +246,19 @@ class VesperE48400ModbusVFD(VFDProtocol):
|
|
|
221
246
|
temperature are read separately because the gaps between them would
|
|
222
247
|
otherwise pull in reserved registers.
|
|
223
248
|
|
|
249
|
+
The three separate readings and the run state are optional: a drive that
|
|
250
|
+
refuses one of them still reports its frequency and current, so the
|
|
251
|
+
affected field stays ``None`` (or ``VFDState.UNKNOWN``) instead of
|
|
252
|
+
failing the whole snapshot.
|
|
253
|
+
|
|
224
254
|
Returns:
|
|
225
255
|
VFDParameters: The decoded parameters. ``heatsink_temperature_c`` is
|
|
226
256
|
populated from U1-46; ``motor_temperature_c`` stays ``None``
|
|
227
257
|
because the drive reports motor overheat only as a fault.
|
|
228
258
|
|
|
229
259
|
Raises:
|
|
230
|
-
ModbusOperationError: If
|
|
260
|
+
ModbusOperationError: If the measurement block or E1-04 cannot be
|
|
261
|
+
read.
|
|
231
262
|
"""
|
|
232
263
|
await self._read_max_frequency()
|
|
233
264
|
block = await self._read_registers(
|
|
@@ -235,16 +266,16 @@ class VesperE48400ModbusVFD(VFDProtocol):
|
|
|
235
266
|
)
|
|
236
267
|
offset = registers.PARAMETERS_BLOCK_ADDRESS
|
|
237
268
|
frequency_hz = registers.output_frequency_hz(block[registers.REG_OUTPUT_FREQUENCY - offset])
|
|
238
|
-
voltage = await self.
|
|
239
|
-
dc_bus = await self.
|
|
240
|
-
temperature = await self.
|
|
269
|
+
voltage = await self._read_optional_registers(registers.REG_OUTPUT_VOLTAGE, 1)
|
|
270
|
+
dc_bus = await self._read_optional_registers(registers.REG_DC_BUS_VOLTAGE, 1)
|
|
271
|
+
temperature = await self._read_optional_registers(registers.REG_HEATSINK_TEMPERATURE, 1)
|
|
241
272
|
parameters = VFDParameters(
|
|
242
273
|
frequency_hz=frequency_hz,
|
|
243
274
|
frequency_percent=self._percent(frequency_hz),
|
|
244
275
|
output_current=registers.output_current_a(block[registers.REG_OUTPUT_CURRENT - offset]),
|
|
245
|
-
output_voltage=float(voltage[0]),
|
|
246
|
-
dc_bus_voltage=float(dc_bus[0]),
|
|
247
|
-
heatsink_temperature_c=float(temperature[0]),
|
|
276
|
+
output_voltage=float(voltage[0]) if voltage is not None else 0.0,
|
|
277
|
+
dc_bus_voltage=float(dc_bus[0]) if dc_bus is not None else None,
|
|
278
|
+
heatsink_temperature_c=float(temperature[0]) if temperature is not None else None,
|
|
248
279
|
state=await self.read_state(),
|
|
249
280
|
)
|
|
250
281
|
self.logger.debug("%s: %s", self.label, parameters)
|
|
@@ -255,12 +286,12 @@ class VesperE48400ModbusVFD(VFDProtocol):
|
|
|
255
286
|
Read the drive's run state.
|
|
256
287
|
|
|
257
288
|
Returns:
|
|
258
|
-
VFDState: The decoded state.
|
|
259
|
-
|
|
260
|
-
Raises:
|
|
261
|
-
ModbusOperationError: If the status register cannot be read.
|
|
289
|
+
VFDState: The decoded state, or ``VFDState.UNKNOWN`` when the status
|
|
290
|
+
register is unreadable.
|
|
262
291
|
"""
|
|
263
|
-
raw = await self.
|
|
292
|
+
raw = await self._read_optional_registers(registers.REG_STATUS, 1)
|
|
293
|
+
if raw is None:
|
|
294
|
+
return VFDState.UNKNOWN
|
|
264
295
|
state = registers.status_to_state(raw[0])
|
|
265
296
|
self.logger.debug("%s: Current state: %s", self.label, state)
|
|
266
297
|
return state
|
|
@@ -198,13 +198,19 @@ class VesperE48400(VFD):
|
|
|
198
198
|
temperature are read separately because the gaps between them would
|
|
199
199
|
otherwise pull in reserved registers.
|
|
200
200
|
|
|
201
|
+
The three separate readings and the run state are optional: a drive that
|
|
202
|
+
refuses one of them still reports its frequency and current, so the
|
|
203
|
+
affected field stays ``None`` (or ``VFDState.UNKNOWN``) instead of
|
|
204
|
+
failing the whole snapshot.
|
|
205
|
+
|
|
201
206
|
Returns:
|
|
202
207
|
VFDParameters: The decoded parameters. ``heatsink_temperature_c`` is
|
|
203
208
|
populated from U1-46; ``motor_temperature_c`` stays ``None``
|
|
204
209
|
because the drive reports motor overheat only as a fault.
|
|
205
210
|
|
|
206
211
|
Raises:
|
|
207
|
-
ModbusOperationError: If
|
|
212
|
+
ModbusOperationError: If the measurement block or E1-04 cannot be
|
|
213
|
+
read.
|
|
208
214
|
"""
|
|
209
215
|
await self._read_max_frequency()
|
|
210
216
|
block = await self.read_registers(
|
|
@@ -218,9 +224,9 @@ class VesperE48400(VFD):
|
|
|
218
224
|
)
|
|
219
225
|
offset = registers.PARAMETERS_BLOCK_ADDRESS
|
|
220
226
|
frequency_hz = registers.output_frequency_hz(block[registers.REG_OUTPUT_FREQUENCY - offset])
|
|
221
|
-
voltage = await self.
|
|
222
|
-
dc_bus = await self.
|
|
223
|
-
temperature = await self.
|
|
227
|
+
voltage = await self._read_optional_register(registers.REG_OUTPUT_VOLTAGE)
|
|
228
|
+
dc_bus = await self._read_optional_register(registers.REG_DC_BUS_VOLTAGE)
|
|
229
|
+
temperature = await self._read_optional_register(registers.REG_HEATSINK_TEMPERATURE)
|
|
224
230
|
return VFDParameters(
|
|
225
231
|
frequency_hz=frequency_hz,
|
|
226
232
|
frequency_percent=self._percent(frequency_hz),
|
|
@@ -239,7 +245,7 @@ class VesperE48400(VFD):
|
|
|
239
245
|
VFDState: The decoded state, or ``VFDState.UNKNOWN`` if the status
|
|
240
246
|
register cannot be read.
|
|
241
247
|
"""
|
|
242
|
-
status = await self.
|
|
248
|
+
status = await self._read_optional_register(registers.REG_STATUS)
|
|
243
249
|
if status is None:
|
|
244
250
|
return VFDState.UNKNOWN
|
|
245
251
|
return registers.status_to_state(status)
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/client.py
RENAMED
|
@@ -156,6 +156,31 @@ class VesperE58200ModbusVFD(VFDProtocol):
|
|
|
156
156
|
)
|
|
157
157
|
return list(response.registers)
|
|
158
158
|
|
|
159
|
+
async def _read_optional_registers(self, address: int, count: int) -> list[int] | None:
|
|
160
|
+
"""
|
|
161
|
+
Read holding registers, returning ``None`` instead of raising on failure.
|
|
162
|
+
|
|
163
|
+
Used for readings that refine a snapshot but must not fail it: a drive
|
|
164
|
+
that refuses one diagnostic register still reports its frequency,
|
|
165
|
+
current and state, and a poll loop that raised on every optional read
|
|
166
|
+
would never publish anything.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
address: Starting register address.
|
|
170
|
+
count: Number of registers to read.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
list[int] | None: The register values, or ``None`` when the read
|
|
174
|
+
failed.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
return await self._read_registers(address, count)
|
|
178
|
+
except ModbusOperationError as exc:
|
|
179
|
+
self.logger.warning(
|
|
180
|
+
"%s: Optional register %s is unreadable (%s).", self.label, address, exc
|
|
181
|
+
)
|
|
182
|
+
return None
|
|
183
|
+
|
|
159
184
|
async def _write_register(self, address: int, value: int) -> None:
|
|
160
185
|
"""
|
|
161
186
|
Write a single holding register, raising on a Modbus exception response.
|
|
@@ -198,11 +223,15 @@ class VesperE58200ModbusVFD(VFDProtocol):
|
|
|
198
223
|
field stays 0.0, and no temperature, so both temperature fields stay
|
|
199
224
|
``None``.
|
|
200
225
|
|
|
226
|
+
The run state is read separately and is optional: a drive that refuses
|
|
227
|
+
the monitor register still reports its measurements, so the state falls
|
|
228
|
+
back to ``VFDState.UNKNOWN`` instead of failing the whole snapshot.
|
|
229
|
+
|
|
201
230
|
Returns:
|
|
202
231
|
VFDParameters: The decoded parameters.
|
|
203
232
|
|
|
204
233
|
Raises:
|
|
205
|
-
ModbusOperationError: If the block cannot be read.
|
|
234
|
+
ModbusOperationError: If the measurement block cannot be read.
|
|
206
235
|
"""
|
|
207
236
|
block = await self._read_registers(
|
|
208
237
|
registers.PARAMETERS_BLOCK_ADDRESS, registers.PARAMETERS_BLOCK_COUNT
|
|
@@ -225,12 +254,12 @@ class VesperE58200ModbusVFD(VFDProtocol):
|
|
|
225
254
|
Read the drive's run state.
|
|
226
255
|
|
|
227
256
|
Returns:
|
|
228
|
-
VFDState: The decoded state.
|
|
229
|
-
|
|
230
|
-
Raises:
|
|
231
|
-
ModbusOperationError: If the monitor register cannot be read.
|
|
257
|
+
VFDState: The decoded state, or ``VFDState.UNKNOWN`` when the monitor
|
|
258
|
+
register is unreadable.
|
|
232
259
|
"""
|
|
233
|
-
raw = await self.
|
|
260
|
+
raw = await self._read_optional_registers(registers.REG_MONITOR, 1)
|
|
261
|
+
if raw is None:
|
|
262
|
+
return VFDState.UNKNOWN
|
|
234
263
|
state = registers.status_to_state(raw[0])
|
|
235
264
|
self.logger.debug("%s: Current state: %s", self.label, state)
|
|
236
265
|
return state
|
|
@@ -133,11 +133,15 @@ class VesperE58200(VFD):
|
|
|
133
133
|
field stays 0.0, and no temperature, so both temperature fields stay
|
|
134
134
|
``None``.
|
|
135
135
|
|
|
136
|
+
The run state is read separately and is optional: a drive that refuses
|
|
137
|
+
the monitor register still reports its measurements, so the state falls
|
|
138
|
+
back to ``VFDState.UNKNOWN`` instead of failing the whole snapshot.
|
|
139
|
+
|
|
136
140
|
Returns:
|
|
137
141
|
VFDParameters: The decoded parameters.
|
|
138
142
|
|
|
139
143
|
Raises:
|
|
140
|
-
ModbusOperationError: If the block cannot be read.
|
|
144
|
+
ModbusOperationError: If the measurement block cannot be read.
|
|
141
145
|
"""
|
|
142
146
|
block = await self.read_registers(
|
|
143
147
|
registers.PARAMETERS_BLOCK_ADDRESS,
|
|
@@ -167,7 +171,7 @@ class VesperE58200(VFD):
|
|
|
167
171
|
VFDState: The decoded state, or ``VFDState.UNKNOWN`` if the monitor
|
|
168
172
|
register cannot be read.
|
|
169
173
|
"""
|
|
170
|
-
monitor = await self.
|
|
174
|
+
monitor = await self._read_optional_register(registers.REG_MONITOR)
|
|
171
175
|
if monitor is None:
|
|
172
176
|
return VFDState.UNKNOWN
|
|
173
177
|
return registers.status_to_state(monitor)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scietex.hal.vfd
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Scietex VFD HAL package
|
|
5
5
|
Author-email: Anton Bondarenko <bond.anton@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -86,7 +86,7 @@ A driver reaches a drive over one of two transports:
|
|
|
86
86
|
|
|
87
87
|
| Field | Intek SPE-B | Vesper E5-8200 | Vesper E4-8400 |
|
|
88
88
|
| --- | --- | --- | --- |
|
|
89
|
-
| `frequency_hz` | yes | yes | yes |
|
|
89
|
+
| `frequency_hz` | yes¹ | yes | yes |
|
|
90
90
|
| `frequency_percent` | yes | yes | yes |
|
|
91
91
|
| `output_current` | yes | yes | yes |
|
|
92
92
|
| `output_voltage` | yes | yes | yes |
|
|
@@ -98,6 +98,12 @@ A driver reaches a drive over one of two transports:
|
|
|
98
98
|
Only the E4-8400 exposes a live temperature reading (`004Dh`, U1-46). The other
|
|
99
99
|
two report overheat only as a fault code, and a fault is not a temperature.
|
|
100
100
|
|
|
101
|
+
¹ The SPE-B reports the operating frequency only as a percentage of `P0.10`, so
|
|
102
|
+
`frequency_hz` is `None` until `P0.10` has been read. A drive that refuses
|
|
103
|
+
`P0.10` still reports `frequency_percent`, current, voltage and power; the
|
|
104
|
+
connection stays usable and only the Hz field degrades. `set_frequency` is
|
|
105
|
+
refused in that state, because no percentage can be computed.
|
|
106
|
+
|
|
101
107
|
## Installation
|
|
102
108
|
|
|
103
109
|
Install the package via pip (assuming it's published to PyPI):
|
|
@@ -159,12 +159,76 @@ def test_read_parameters_uses_the_coarse_current_resolution():
|
|
|
159
159
|
assert asyncio.run(vfd.read_parameters()).output_current == pytest.approx(12.3)
|
|
160
160
|
|
|
161
161
|
|
|
162
|
-
def
|
|
163
|
-
"""An
|
|
162
|
+
def test_read_parameters_reports_no_hz_when_p010_is_zero():
|
|
163
|
+
"""An unusable maximum frequency keeps the percentage but cannot scale it."""
|
|
164
164
|
vfd, _ = _connected([_read([5000, 0, 0, 0, 0]), _read([3])], max_frequency_raw=0)
|
|
165
165
|
parameters = asyncio.run(vfd.read_parameters())
|
|
166
166
|
assert parameters.frequency_percent == pytest.approx(50.0)
|
|
167
|
-
assert parameters.frequency_hz
|
|
167
|
+
assert parameters.frequency_hz is None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_connect_survives_a_refused_p010_and_still_reports_measurements():
|
|
171
|
+
"""A drive that refuses P0.10 must not blank the whole snapshot.
|
|
172
|
+
|
|
173
|
+
An SPE-B that has not been configured for Modbus (Pd.01/Pd.05) answers the
|
|
174
|
+
gateway with exception 11 for every register. Raising from `connect()` made
|
|
175
|
+
the poll loop log a connect failure every cycle and publish nothing; the
|
|
176
|
+
percentage, current, voltage and power are still readable, so only
|
|
177
|
+
`frequency_hz` degrades to None.
|
|
178
|
+
"""
|
|
179
|
+
stub = _StubClient(
|
|
180
|
+
[
|
|
181
|
+
ExceptionResponse(function_code=3), # P0.10
|
|
182
|
+
_read([1]), # Pd.06 = coarse, 0.1 A
|
|
183
|
+
_read([5000, 540, 380, 123, 45]),
|
|
184
|
+
_read([1]), # status: running forward
|
|
185
|
+
]
|
|
186
|
+
)
|
|
187
|
+
vfd = IntekSPEBModbusVFD("127.0.0.1")
|
|
188
|
+
vfd._client = stub
|
|
189
|
+
asyncio.run(vfd.connect())
|
|
190
|
+
parameters = asyncio.run(vfd.read_parameters())
|
|
191
|
+
assert parameters.frequency_percent == pytest.approx(50.0)
|
|
192
|
+
assert parameters.frequency_hz is None
|
|
193
|
+
assert parameters.output_current == pytest.approx(12.3)
|
|
194
|
+
assert parameters.output_voltage == pytest.approx(380.0)
|
|
195
|
+
assert parameters.dc_bus_voltage == pytest.approx(540.0)
|
|
196
|
+
assert parameters.state is VFDState.RUNNING_FORWARD
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_set_frequency_still_refuses_when_p010_is_unknown():
|
|
200
|
+
"""Without P0.10 no percentage can be computed, so the write is rejected."""
|
|
201
|
+
vfd, _ = _connected([], max_frequency_raw=0)
|
|
202
|
+
with pytest.raises(ValueError, match="P0.10"):
|
|
203
|
+
asyncio.run(vfd.set_frequency(25.0))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_read_parameters_survives_an_unreadable_status_register():
|
|
207
|
+
"""A refused status read must not discard the measurements.
|
|
208
|
+
|
|
209
|
+
The poll loop publishes whatever `read_parameters` returns; raising here
|
|
210
|
+
would mean a drive that refuses one diagnostic register never reports its
|
|
211
|
+
frequency, current or voltage at all.
|
|
212
|
+
"""
|
|
213
|
+
vfd, _ = _connected([_read([5000, 540, 380, 123, 45]), ExceptionResponse(function_code=3)])
|
|
214
|
+
parameters = asyncio.run(vfd.read_parameters())
|
|
215
|
+
assert parameters.frequency_hz == pytest.approx(25.0)
|
|
216
|
+
assert parameters.output_current == pytest.approx(1.23)
|
|
217
|
+
assert parameters.output_voltage == pytest.approx(380.0)
|
|
218
|
+
assert parameters.state is VFDState.UNKNOWN
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_read_parameters_still_raises_when_the_measurement_block_fails():
|
|
222
|
+
"""The measurement block is mandatory: without it there is nothing to report."""
|
|
223
|
+
vfd, _ = _connected([ExceptionResponse(function_code=3)])
|
|
224
|
+
with pytest.raises(ModbusOperationError):
|
|
225
|
+
asyncio.run(vfd.read_parameters())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_read_state_returns_unknown_when_the_status_register_is_refused():
|
|
229
|
+
"""A refused status read is "unknown", not an exception."""
|
|
230
|
+
vfd, _ = _connected([ExceptionResponse(function_code=3)])
|
|
231
|
+
assert asyncio.run(vfd.read_state()) is VFDState.UNKNOWN
|
|
168
232
|
|
|
169
233
|
|
|
170
234
|
# -- state and faults -------------------------------------------------------
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the optional readings in the Vesper gateway clients.
|
|
3
|
+
|
|
4
|
+
A poll loop publishes whatever `read_parameters` returns, so a drive that
|
|
5
|
+
refuses one diagnostic register must still report its frequency, current and
|
|
6
|
+
voltage. Only the measurement block is mandatory; the run state and the
|
|
7
|
+
separate diagnostic registers degrade to ``None`` / ``VFDState.UNKNOWN``.
|
|
8
|
+
|
|
9
|
+
The pymodbus client is replaced by a stub that returns canned responses, so
|
|
10
|
+
these cover the degradation rules rather than the transport.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from pymodbus.pdu import ExceptionResponse
|
|
17
|
+
from pymodbus.pdu.register_message import ReadHoldingRegistersResponse
|
|
18
|
+
from scietex.hal.serial import ModbusOperationError
|
|
19
|
+
|
|
20
|
+
from scietex.hal.vfd.base.data import VFDState
|
|
21
|
+
from scietex.hal.vfd.vesper.e4_8400 import registers as e4_registers
|
|
22
|
+
from scietex.hal.vfd.vesper.e4_8400.client import VesperE48400ModbusVFD
|
|
23
|
+
from scietex.hal.vfd.vesper.e5_8200 import registers as e5_registers
|
|
24
|
+
from scietex.hal.vfd.vesper.e5_8200.client import VesperE58200ModbusVFD
|
|
25
|
+
|
|
26
|
+
#: E1-04 = 50.00 Hz, the factory maximum output frequency.
|
|
27
|
+
E4_MAX_FREQUENCY_RAW = 5000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _StubClient:
|
|
31
|
+
"""Stand-in for `AsyncModbusTcpClient` that replays canned responses."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, responses):
|
|
34
|
+
self.responses = list(responses)
|
|
35
|
+
self.calls = []
|
|
36
|
+
|
|
37
|
+
async def read_holding_registers(self, address, *, count=1, device_id=1):
|
|
38
|
+
"""Record the read and return the next canned response."""
|
|
39
|
+
self.calls.append(("read", address, count, device_id))
|
|
40
|
+
if not self.responses:
|
|
41
|
+
raise AssertionError("stub ran out of canned responses")
|
|
42
|
+
return self.responses.pop(0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read(values):
|
|
46
|
+
return ReadHoldingRegistersResponse(registers=list(values))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _refused():
|
|
50
|
+
return ExceptionResponse(function_code=3)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _e5(responses):
|
|
54
|
+
"""Build an E5-8200 client whose stub replays ``responses``."""
|
|
55
|
+
vfd = VesperE58200ModbusVFD("127.0.0.1")
|
|
56
|
+
vfd._client = _StubClient(responses)
|
|
57
|
+
return vfd
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _e4(responses):
|
|
61
|
+
"""Build an E4-8400 client with E1-04 pre-supplied, so no extra read is queued."""
|
|
62
|
+
vfd = VesperE48400ModbusVFD("127.0.0.1", max_frequency_hz=50.0)
|
|
63
|
+
vfd._client = _StubClient(responses)
|
|
64
|
+
return vfd
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# -- E5-8200 ----------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_e5_read_parameters_survives_an_unreadable_monitor_register():
|
|
71
|
+
"""A refused monitor read keeps the measurements and reports an unknown state."""
|
|
72
|
+
vfd = _e5([_read([5000, 3800, 540, 105]), _refused()])
|
|
73
|
+
parameters = asyncio.run(vfd.read_parameters())
|
|
74
|
+
assert parameters.frequency_hz == pytest.approx(50.0)
|
|
75
|
+
assert parameters.output_voltage == pytest.approx(380.0)
|
|
76
|
+
assert parameters.dc_bus_voltage == pytest.approx(540.0)
|
|
77
|
+
assert parameters.output_current == pytest.approx(10.5)
|
|
78
|
+
assert parameters.state is VFDState.UNKNOWN
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_e5_read_parameters_still_raises_when_the_block_fails():
|
|
82
|
+
"""The measurement block is mandatory: without it there is nothing to report."""
|
|
83
|
+
vfd = _e5([_refused()])
|
|
84
|
+
with pytest.raises(ModbusOperationError):
|
|
85
|
+
asyncio.run(vfd.read_parameters())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_e5_read_state_returns_unknown_when_the_monitor_register_is_refused():
|
|
89
|
+
"""A refused monitor read is "unknown", not an exception."""
|
|
90
|
+
vfd = _e5([_refused()])
|
|
91
|
+
assert asyncio.run(vfd.read_state()) is VFDState.UNKNOWN
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# -- E4-8400 ----------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_e4_read_parameters_survives_unreadable_diagnostics():
|
|
98
|
+
"""Voltage, DC bus, temperature and state all degrade instead of raising."""
|
|
99
|
+
vfd = _e4(
|
|
100
|
+
[
|
|
101
|
+
_read([5000, 5000, 105, 0]), # setpoint, output freq, current
|
|
102
|
+
_refused(), # U1-06 output voltage
|
|
103
|
+
_refused(), # U1-07 DC bus voltage
|
|
104
|
+
_refused(), # U1-46 heatsink temperature
|
|
105
|
+
_refused(), # status
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
parameters = asyncio.run(vfd.read_parameters())
|
|
109
|
+
assert parameters.frequency_hz == pytest.approx(50.0)
|
|
110
|
+
assert parameters.output_current == pytest.approx(10.5)
|
|
111
|
+
assert parameters.output_voltage == pytest.approx(0.0)
|
|
112
|
+
assert parameters.dc_bus_voltage is None
|
|
113
|
+
assert parameters.heatsink_temperature_c is None
|
|
114
|
+
assert parameters.state is VFDState.UNKNOWN
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_e4_read_parameters_keeps_the_temperature_when_only_the_state_fails():
|
|
118
|
+
"""One refused register must not blank the others."""
|
|
119
|
+
vfd = _e4(
|
|
120
|
+
[
|
|
121
|
+
_read([5000, 5000, 105, 0]),
|
|
122
|
+
_read([380]), # U1-06 output voltage
|
|
123
|
+
_read([540]), # U1-07 DC bus voltage
|
|
124
|
+
_read([42]), # U1-46 heatsink temperature
|
|
125
|
+
_refused(), # status
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
parameters = asyncio.run(vfd.read_parameters())
|
|
129
|
+
assert parameters.output_voltage == pytest.approx(380.0)
|
|
130
|
+
assert parameters.dc_bus_voltage == pytest.approx(540.0)
|
|
131
|
+
assert parameters.heatsink_temperature_c == pytest.approx(42.0)
|
|
132
|
+
assert parameters.state is VFDState.UNKNOWN
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_e4_read_parameters_still_raises_when_the_block_fails():
|
|
136
|
+
"""The measurement block is mandatory: without it there is nothing to report."""
|
|
137
|
+
vfd = _e4([_refused()])
|
|
138
|
+
with pytest.raises(ModbusOperationError):
|
|
139
|
+
asyncio.run(vfd.read_parameters())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_e4_read_state_returns_unknown_when_the_status_register_is_refused():
|
|
143
|
+
"""A refused status read is "unknown", not an exception."""
|
|
144
|
+
vfd = _e4([_refused()])
|
|
145
|
+
assert asyncio.run(vfd.read_state()) is VFDState.UNKNOWN
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_e4_read_parameters_reads_e1_04_when_no_maximum_is_supplied():
|
|
149
|
+
"""Without a constructor maximum the drive's own E1-04 is read first."""
|
|
150
|
+
vfd = VesperE48400ModbusVFD("127.0.0.1")
|
|
151
|
+
vfd._client = _StubClient(
|
|
152
|
+
[
|
|
153
|
+
_read([E4_MAX_FREQUENCY_RAW]), # E1-04
|
|
154
|
+
_read([5000, 2500, 105, 0]), # setpoint, output freq, current
|
|
155
|
+
_read([380]),
|
|
156
|
+
_read([540]),
|
|
157
|
+
_read([42]),
|
|
158
|
+
_read([0b011]), # running forward
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
parameters = asyncio.run(vfd.read_parameters())
|
|
162
|
+
assert vfd._client.calls[0] == (
|
|
163
|
+
"read",
|
|
164
|
+
e4_registers.parameter_register(e4_registers.PARAM_MAX_FREQUENCY),
|
|
165
|
+
1,
|
|
166
|
+
1,
|
|
167
|
+
)
|
|
168
|
+
assert parameters.frequency_hz == pytest.approx(25.0)
|
|
169
|
+
assert parameters.frequency_percent == pytest.approx(50.0)
|
|
170
|
+
assert parameters.state is VFDState.RUNNING_FORWARD
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_e5_parameter_block_addresses_are_unchanged():
|
|
174
|
+
"""Guard the block the optional-read refactor reads from."""
|
|
175
|
+
assert e5_registers.PARAMETERS_BLOCK_ADDRESS == e5_registers.REG_OUTPUT_FREQUENCY
|
|
176
|
+
assert e5_registers.PARAMETERS_BLOCK_COUNT == 4
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/registers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/__init__.py
RENAMED
|
File without changes
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/errors.py
RENAMED
|
File without changes
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/registers.py
RENAMED
|
File without changes
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/__init__.py
RENAMED
|
File without changes
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/errors.py
RENAMED
|
File without changes
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/registers.py
RENAMED
|
File without changes
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|