handover-check 0.1.0__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.
- handover_check-0.1.0/LICENSE +21 -0
- handover_check-0.1.0/PKG-INFO +150 -0
- handover_check-0.1.0/README.md +129 -0
- handover_check-0.1.0/pyproject.toml +34 -0
- handover_check-0.1.0/setup.cfg +4 -0
- handover_check-0.1.0/src/handover_check/__init__.py +7 -0
- handover_check-0.1.0/src/handover_check/__main__.py +100 -0
- handover_check-0.1.0/src/handover_check/checks.py +342 -0
- handover_check-0.1.0/src/handover_check/station.py +120 -0
- handover_check-0.1.0/src/handover_check.egg-info/PKG-INFO +150 -0
- handover_check-0.1.0/src/handover_check.egg-info/SOURCES.txt +14 -0
- handover_check-0.1.0/src/handover_check.egg-info/dependency_links.txt +1 -0
- handover_check-0.1.0/src/handover_check.egg-info/entry_points.txt +2 -0
- handover_check-0.1.0/src/handover_check.egg-info/requires.txt +2 -0
- handover_check-0.1.0/src/handover_check.egg-info/top_level.txt +1 -0
- handover_check-0.1.0/tests/test_checks.py +135 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Suleman Ali
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: handover-check
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Test a conveyor station's AMR/AGV load-handover handshake over OPC UA before the robot arrives: plays the vehicle side, also the ways a vehicle fails, and reports rule by rule.
|
|
5
|
+
Author: Suleman Ali
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/sulemanali5760/amr-station-handshake
|
|
8
|
+
Keywords: agv,amr,plc,opc-ua,handshake,commissioning,conveyor,vda5050,intralogistics
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Manufacturing
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: asyncua<3,>=1.1
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# handover-check
|
|
23
|
+
|
|
24
|
+
Test a conveyor station's **AMR/AGV load-handover handshake** before the robot arrives.
|
|
25
|
+
|
|
26
|
+
The tool connects to the station PLC over OPC UA and plays the vehicle side: it requests a handover,
|
|
27
|
+
docks, releases, and also does what vehicles do when things go wrong (goes silent, leaves mid-transfer,
|
|
28
|
+
changes its mind). It checks the station's answers and response times against ten rules and reports
|
|
29
|
+
rule by rule.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
$ handover-check run opc.tcp://192.168.0.10:4840 --map station.yaml --station-is-safe
|
|
33
|
+
|
|
34
|
+
[PASS] R1 Idle at rest: no output is on without a request
|
|
35
|
+
all outputs off for 1 s without a request
|
|
36
|
+
[PASS] R2 StationReady follows AmrDockRequest in time, and nothing moves yet
|
|
37
|
+
StationReady 0.53 s after the request (limit 5 s)
|
|
38
|
+
[PASS] R3 No transfer before AmrInPosition
|
|
39
|
+
no transfer for 1 s while the vehicle was not in position
|
|
40
|
+
[PASS] R4 Transfer starts after AmrInPosition and completes; TransferActive is off when TransferDone is on
|
|
41
|
+
transfer started after 0.02 s and finished after 4.02 s
|
|
42
|
+
[PASS] R5 Station returns to idle after the vehicle releases
|
|
43
|
+
station idle 0.03 s after the release (limit 3 s)
|
|
44
|
+
[PASS] R6 A second handover works straight after the first
|
|
45
|
+
two handovers in a row completed
|
|
46
|
+
[PASS] R7 A withdrawn request returns the station to idle without a transfer
|
|
47
|
+
station back to idle 0.02 s after the request was withdrawn
|
|
48
|
+
[FAIL] R8 Heartbeat watchdog: a silent vehicle stops the transfer with a fault code
|
|
49
|
+
the vehicle went silent for 2 s during the transfer and the station carried on: Ready, TransferActive
|
|
50
|
+
[SKIP] R9 Dropping the request acknowledges and clears a fault
|
|
51
|
+
could not bring the station into a fault to acknowledge (see R8): Ready, TransferActive
|
|
52
|
+
[PASS] R10 The conveyor stops when the vehicle leaves its position
|
|
53
|
+
conveyor stopped 0.02 s after the vehicle left its position (limit 1 s; station: Fault(code 3))
|
|
54
|
+
|
|
55
|
+
Station does NOT conform. 8 passed, 1 failed, 1 not testable.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
*Real output, against the bundled reference station with its watchdog switched off; address changed.*
|
|
59
|
+
|
|
60
|
+
## Why
|
|
61
|
+
|
|
62
|
+
VDA 5050 covers fleet controller to vehicle and expressly leaves out "interfaces to peripheral
|
|
63
|
+
equipment". The handshake between vehicle and conveyor is therefore written again in every project,
|
|
64
|
+
by two teams, and its mistakes show up at commissioning with the robot standing in front of the
|
|
65
|
+
station. The rules are written down in [docs/SPEC.md](../../docs/SPEC.md); this tool lets the PLC
|
|
66
|
+
programmer test the station side alone, at the desk or on site, days earlier.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install handover-check
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Python 3.11 or newer; Windows or Linux. No ROS and no robot needed.
|
|
75
|
+
Offline bundles for machines without internet are attached to each
|
|
76
|
+
[release](https://github.com/sulemanali5760/amr-station-handshake/releases).
|
|
77
|
+
|
|
78
|
+
## Use
|
|
79
|
+
|
|
80
|
+
Try it without a PLC, in two terminals:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
handover-check station # reference station on opc.tcp://127.0.0.1:4860/station/
|
|
84
|
+
handover-check run opc.tcp://127.0.0.1:4860/station/
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
handover-check station --defect no_watchdog # a station with one rule switched off
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Against your PLC: describe where the eight signals live in a signal map (examples for the reference
|
|
92
|
+
station, Siemens S7-1500 and Beckhoff TwinCAT 3 are in [`maps/`](maps)), then:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
handover-check run opc.tcp://192.168.0.10:4840 --map my_station.yaml --station-is-safe
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Option | Purpose |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `--t-ready 5` `--t-start 3` `--t-transfer 15` `--t-release 3` `--t-watchdog 2` `--t-stop 1` | the time limits of your plant specification, in seconds |
|
|
101
|
+
| `--only R2 R8` | run selected rules |
|
|
102
|
+
| `--report record.md` | Markdown report for the commissioning record |
|
|
103
|
+
| `--json` | machine-readable output |
|
|
104
|
+
| `--strict` | exit code 2 if a rule could not be tested |
|
|
105
|
+
|
|
106
|
+
Exit code 0 = no rule failed, 1 = at least one rule failed.
|
|
107
|
+
|
|
108
|
+
## It moves your conveyor
|
|
109
|
+
|
|
110
|
+
The tool writes the request signals. On a real station that starts the conveyor.
|
|
111
|
+
|
|
112
|
+
- For any endpoint that is not this computer it refuses to run without `--station-is-safe`.
|
|
113
|
+
- Run it with the station in a test state, clear of people and loads.
|
|
114
|
+
- It always writes both request bits back to FALSE when it ends, also after an error.
|
|
115
|
+
- It is a commissioning aid. It is **not** a safety function and no substitute for the safety
|
|
116
|
+
validation of the station.
|
|
117
|
+
|
|
118
|
+
## Tested
|
|
119
|
+
|
|
120
|
+
The test suite starts the reference station over real OPC UA, once conforming and once for each of
|
|
121
|
+
eight built-in defects, and expects the tester to report exactly the broken rule:
|
|
122
|
+
|
|
123
|
+
| Defect in the station | Reported under |
|
|
124
|
+
|---|---|
|
|
125
|
+
| Ready on without a request | R1 |
|
|
126
|
+
| Ready far too late | R2 |
|
|
127
|
+
| conveyor starts before the vehicle is in position | R3 |
|
|
128
|
+
| TransferActive still on with TransferDone | R4 |
|
|
129
|
+
| outputs not cleared after release | R5 |
|
|
130
|
+
| no heartbeat watchdog | R8 |
|
|
131
|
+
| fault not cleared by the acknowledge | R9 |
|
|
132
|
+
| conveyor keeps running when the vehicle leaves | R10 |
|
|
133
|
+
|
|
134
|
+
A transfer that is too short to judge R8 or R10 is reported as "not testable", not as a pass.
|
|
135
|
+
|
|
136
|
+
## Limits
|
|
137
|
+
|
|
138
|
+
- **Not tested against a real PLC.** Only against the bundled reference station. The S7-1500 and
|
|
139
|
+
TwinCAT maps are examples; node ids depend on the project.
|
|
140
|
+
- **No OPC UA security.** The tool connects anonymously without encryption. A PLC that demands
|
|
141
|
+
certificates or a login cannot be tested yet.
|
|
142
|
+
- **Polling.** Signals are read every 25 ms by default, so measured times are accurate to a few
|
|
143
|
+
tens of milliseconds plus the network. It cannot see events shorter than that.
|
|
144
|
+
- **The rules are this project's own** ([SPEC](../../docs/SPEC.md)), shaped after SEMI E84. They
|
|
145
|
+
are not a standard of any body, and a station built to another handshake will fail them.
|
|
146
|
+
- **One vehicle, one station.** No carrier identification, no multi-lane stations.
|
|
147
|
+
|
|
148
|
+
## Licence
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# handover-check
|
|
2
|
+
|
|
3
|
+
Test a conveyor station's **AMR/AGV load-handover handshake** before the robot arrives.
|
|
4
|
+
|
|
5
|
+
The tool connects to the station PLC over OPC UA and plays the vehicle side: it requests a handover,
|
|
6
|
+
docks, releases, and also does what vehicles do when things go wrong (goes silent, leaves mid-transfer,
|
|
7
|
+
changes its mind). It checks the station's answers and response times against ten rules and reports
|
|
8
|
+
rule by rule.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
$ handover-check run opc.tcp://192.168.0.10:4840 --map station.yaml --station-is-safe
|
|
12
|
+
|
|
13
|
+
[PASS] R1 Idle at rest: no output is on without a request
|
|
14
|
+
all outputs off for 1 s without a request
|
|
15
|
+
[PASS] R2 StationReady follows AmrDockRequest in time, and nothing moves yet
|
|
16
|
+
StationReady 0.53 s after the request (limit 5 s)
|
|
17
|
+
[PASS] R3 No transfer before AmrInPosition
|
|
18
|
+
no transfer for 1 s while the vehicle was not in position
|
|
19
|
+
[PASS] R4 Transfer starts after AmrInPosition and completes; TransferActive is off when TransferDone is on
|
|
20
|
+
transfer started after 0.02 s and finished after 4.02 s
|
|
21
|
+
[PASS] R5 Station returns to idle after the vehicle releases
|
|
22
|
+
station idle 0.03 s after the release (limit 3 s)
|
|
23
|
+
[PASS] R6 A second handover works straight after the first
|
|
24
|
+
two handovers in a row completed
|
|
25
|
+
[PASS] R7 A withdrawn request returns the station to idle without a transfer
|
|
26
|
+
station back to idle 0.02 s after the request was withdrawn
|
|
27
|
+
[FAIL] R8 Heartbeat watchdog: a silent vehicle stops the transfer with a fault code
|
|
28
|
+
the vehicle went silent for 2 s during the transfer and the station carried on: Ready, TransferActive
|
|
29
|
+
[SKIP] R9 Dropping the request acknowledges and clears a fault
|
|
30
|
+
could not bring the station into a fault to acknowledge (see R8): Ready, TransferActive
|
|
31
|
+
[PASS] R10 The conveyor stops when the vehicle leaves its position
|
|
32
|
+
conveyor stopped 0.02 s after the vehicle left its position (limit 1 s; station: Fault(code 3))
|
|
33
|
+
|
|
34
|
+
Station does NOT conform. 8 passed, 1 failed, 1 not testable.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
*Real output, against the bundled reference station with its watchdog switched off; address changed.*
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
VDA 5050 covers fleet controller to vehicle and expressly leaves out "interfaces to peripheral
|
|
42
|
+
equipment". The handshake between vehicle and conveyor is therefore written again in every project,
|
|
43
|
+
by two teams, and its mistakes show up at commissioning with the robot standing in front of the
|
|
44
|
+
station. The rules are written down in [docs/SPEC.md](../../docs/SPEC.md); this tool lets the PLC
|
|
45
|
+
programmer test the station side alone, at the desk or on site, days earlier.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install handover-check
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Python 3.11 or newer; Windows or Linux. No ROS and no robot needed.
|
|
54
|
+
Offline bundles for machines without internet are attached to each
|
|
55
|
+
[release](https://github.com/sulemanali5760/amr-station-handshake/releases).
|
|
56
|
+
|
|
57
|
+
## Use
|
|
58
|
+
|
|
59
|
+
Try it without a PLC, in two terminals:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
handover-check station # reference station on opc.tcp://127.0.0.1:4860/station/
|
|
63
|
+
handover-check run opc.tcp://127.0.0.1:4860/station/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
handover-check station --defect no_watchdog # a station with one rule switched off
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Against your PLC: describe where the eight signals live in a signal map (examples for the reference
|
|
71
|
+
station, Siemens S7-1500 and Beckhoff TwinCAT 3 are in [`maps/`](maps)), then:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
handover-check run opc.tcp://192.168.0.10:4840 --map my_station.yaml --station-is-safe
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Option | Purpose |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `--t-ready 5` `--t-start 3` `--t-transfer 15` `--t-release 3` `--t-watchdog 2` `--t-stop 1` | the time limits of your plant specification, in seconds |
|
|
80
|
+
| `--only R2 R8` | run selected rules |
|
|
81
|
+
| `--report record.md` | Markdown report for the commissioning record |
|
|
82
|
+
| `--json` | machine-readable output |
|
|
83
|
+
| `--strict` | exit code 2 if a rule could not be tested |
|
|
84
|
+
|
|
85
|
+
Exit code 0 = no rule failed, 1 = at least one rule failed.
|
|
86
|
+
|
|
87
|
+
## It moves your conveyor
|
|
88
|
+
|
|
89
|
+
The tool writes the request signals. On a real station that starts the conveyor.
|
|
90
|
+
|
|
91
|
+
- For any endpoint that is not this computer it refuses to run without `--station-is-safe`.
|
|
92
|
+
- Run it with the station in a test state, clear of people and loads.
|
|
93
|
+
- It always writes both request bits back to FALSE when it ends, also after an error.
|
|
94
|
+
- It is a commissioning aid. It is **not** a safety function and no substitute for the safety
|
|
95
|
+
validation of the station.
|
|
96
|
+
|
|
97
|
+
## Tested
|
|
98
|
+
|
|
99
|
+
The test suite starts the reference station over real OPC UA, once conforming and once for each of
|
|
100
|
+
eight built-in defects, and expects the tester to report exactly the broken rule:
|
|
101
|
+
|
|
102
|
+
| Defect in the station | Reported under |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Ready on without a request | R1 |
|
|
105
|
+
| Ready far too late | R2 |
|
|
106
|
+
| conveyor starts before the vehicle is in position | R3 |
|
|
107
|
+
| TransferActive still on with TransferDone | R4 |
|
|
108
|
+
| outputs not cleared after release | R5 |
|
|
109
|
+
| no heartbeat watchdog | R8 |
|
|
110
|
+
| fault not cleared by the acknowledge | R9 |
|
|
111
|
+
| conveyor keeps running when the vehicle leaves | R10 |
|
|
112
|
+
|
|
113
|
+
A transfer that is too short to judge R8 or R10 is reported as "not testable", not as a pass.
|
|
114
|
+
|
|
115
|
+
## Limits
|
|
116
|
+
|
|
117
|
+
- **Not tested against a real PLC.** Only against the bundled reference station. The S7-1500 and
|
|
118
|
+
TwinCAT maps are examples; node ids depend on the project.
|
|
119
|
+
- **No OPC UA security.** The tool connects anonymously without encryption. A PLC that demands
|
|
120
|
+
certificates or a login cannot be tested yet.
|
|
121
|
+
- **Polling.** Signals are read every 25 ms by default, so measured times are accurate to a few
|
|
122
|
+
tens of milliseconds plus the network. It cannot see events shorter than that.
|
|
123
|
+
- **The rules are this project's own** ([SPEC](../../docs/SPEC.md)), shaped after SEMI E84. They
|
|
124
|
+
are not a standard of any body, and a station built to another handshake will fail them.
|
|
125
|
+
- **One vehicle, one station.** No carrier identification, no multi-lane stations.
|
|
126
|
+
|
|
127
|
+
## Licence
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "handover-check"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Test a conveyor station's AMR/AGV load-handover handshake over OPC UA before the robot arrives: plays the vehicle side, also the ways a vehicle fails, and reports rule by rule."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Suleman Ali" }]
|
|
13
|
+
keywords = ["agv", "amr", "plc", "opc-ua", "handshake", "commissioning", "conveyor", "vda5050", "intralogistics"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Manufacturing",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["asyncua>=1.1,<3", "pyyaml>=6.0"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Repository = "https://github.com/sulemanali5760/amr-station-handshake"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
handover-check = "handover_check.__main__:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""handover-check: test a conveyor station's AMR handover handshake against rules R1-R10."""
|
|
2
|
+
__version__ = "0.1.0"
|
|
3
|
+
|
|
4
|
+
from handover_check.checks import RULES, Limits, Result, run_checks # noqa: E402
|
|
5
|
+
from handover_check.station import DEFECTS, StationLogic, serve # noqa: E402
|
|
6
|
+
|
|
7
|
+
__all__ = ["DEFECTS", "Limits", "RULES", "Result", "StationLogic", "run_checks", "serve"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""handover-check run opc.tcp://plc:4840 --map station.yaml | handover-check station"""
|
|
2
|
+
import argparse
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.parse
|
|
7
|
+
|
|
8
|
+
from handover_check import __version__
|
|
9
|
+
from handover_check.checks import FAIL, PASS, SIM_MAP, SKIP, Limits, as_dicts, load_signal_map, run_checks
|
|
10
|
+
from handover_check.station import DEFECTS, StationLogic, serve
|
|
11
|
+
|
|
12
|
+
MARK = {PASS: "[PASS]", FAIL: "[FAIL]", SKIP: "[SKIP]"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render(results, endpoint: str) -> str:
|
|
16
|
+
lines = [f"Station handover check, {endpoint}", ""]
|
|
17
|
+
for r in results:
|
|
18
|
+
lines += [f"{MARK[r.status]} {r.rule:<3} {r.title}", f" {r.detail}"]
|
|
19
|
+
n = {s: sum(r.status == s for r in results) for s in (PASS, FAIL, SKIP)}
|
|
20
|
+
verdict = "Station conforms." if not n[FAIL] and not n[SKIP] else \
|
|
21
|
+
"Station does NOT conform." if n[FAIL] else "No rule failed, but not every rule could be tested."
|
|
22
|
+
lines += ["", f"{verdict} {n[PASS]} passed, {n[FAIL]} failed, {n[SKIP]} not testable."]
|
|
23
|
+
return "\n".join(lines)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def markdown(results, endpoint: str) -> str:
|
|
27
|
+
rows = "\n".join(f"| {r.rule} | {r.title} | **{r.status}** | {r.detail} |" for r in results)
|
|
28
|
+
return (f"# Station handover check\n\nStation: `{endpoint}` \nTool: handover-check {__version__}\n\n"
|
|
29
|
+
f"| Rule | Requirement | Result | Observed |\n|---|---|---|---|\n{rows}\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(a) -> int:
|
|
33
|
+
host = urllib.parse.urlparse(a.endpoint).hostname or ""
|
|
34
|
+
if host not in ("127.0.0.1", "localhost", "::1") and not a.station_is_safe:
|
|
35
|
+
print("handover-check writes request signals to the station. On a real station this can start a conveyor.\n"
|
|
36
|
+
"Make sure the station is in a test state and clear of people and loads, then repeat the command "
|
|
37
|
+
"with --station-is-safe.", file=sys.stderr)
|
|
38
|
+
return 64
|
|
39
|
+
signal_map = load_signal_map(a.map) if a.map else SIM_MAP
|
|
40
|
+
limits = Limits(a.t_ready, a.t_start, a.t_transfer, a.t_release, a.t_watchdog, a.t_stop, a.t_hold)
|
|
41
|
+
results = asyncio.run(run_checks(a.endpoint, signal_map, limits, a.only))
|
|
42
|
+
print(json.dumps(as_dicts(results), indent=2) if a.json else render(results, a.endpoint))
|
|
43
|
+
if a.report:
|
|
44
|
+
with open(a.report, "w", encoding="utf-8") as f:
|
|
45
|
+
f.write(markdown(results, a.endpoint))
|
|
46
|
+
failed = any(r.status == FAIL for r in results)
|
|
47
|
+
skipped = any(r.status == SKIP for r in results)
|
|
48
|
+
return 1 if failed else 2 if skipped and a.strict else 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _station(a) -> int:
|
|
52
|
+
logic = StationLogic(a.ready_delay, a.transfer_time, a.watchdog, frozenset(a.defect or ()))
|
|
53
|
+
endpoint = f"opc.tcp://{a.host}:{a.port}/station/"
|
|
54
|
+
print(f"reference station on {endpoint}" + (f", defects: {', '.join(a.defect)}" if a.defect else ""), flush=True)
|
|
55
|
+
try:
|
|
56
|
+
asyncio.run(serve(endpoint, logic))
|
|
57
|
+
except KeyboardInterrupt:
|
|
58
|
+
pass
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv=None) -> int:
|
|
63
|
+
ap = argparse.ArgumentParser(prog="handover-check", description="Check a conveyor station's AMR handover "
|
|
64
|
+
"handshake against rules R1-R10, before the robot arrives.")
|
|
65
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
66
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
67
|
+
r = sub.add_parser("run", help="play the vehicle side against a station and report rule by rule")
|
|
68
|
+
r.add_argument("endpoint", help="OPC UA endpoint of the station PLC, e.g. opc.tcp://192.168.0.10:4840")
|
|
69
|
+
r.add_argument("--map", help="signal map YAML (namespace_uri, node_ids); default: the reference station")
|
|
70
|
+
r.add_argument("--station-is-safe", action="store_true",
|
|
71
|
+
help="confirm the station is in a test state, clear of people and loads (needed for any "
|
|
72
|
+
"endpoint that is not this computer)")
|
|
73
|
+
r.add_argument("--only", nargs="+", metavar="RULE", help="run only these rules, e.g. --only R2 R8")
|
|
74
|
+
for name, default, text in (("ready", 5.0, "request -> StationReady"), ("start", 3.0, "in position -> TransferActive"),
|
|
75
|
+
("transfer", 15.0, "TransferActive -> TransferDone"), ("release", 3.0, "release -> idle"),
|
|
76
|
+
("watchdog", 2.0, "heartbeat stopped -> fault"), ("stop", 1.0, "vehicle left -> conveyor stopped"),
|
|
77
|
+
("hold", 1.0, "how long 'must stay off' is observed")):
|
|
78
|
+
r.add_argument(f"--t-{name}", type=float, default=default, metavar="S", help=f"limit in seconds: {text} (default {default:g})")
|
|
79
|
+
r.add_argument("--json", action="store_true", help="machine-readable output")
|
|
80
|
+
r.add_argument("--report", metavar="FILE.md", help="also write a Markdown report (for the commissioning record)")
|
|
81
|
+
r.add_argument("--strict", action="store_true", help="exit code 2 when a rule could not be tested")
|
|
82
|
+
r.set_defaults(func=_run)
|
|
83
|
+
s = sub.add_parser("station", help="run the reference station (no PLC needed), optionally with a defect")
|
|
84
|
+
s.add_argument("--host", default="127.0.0.1")
|
|
85
|
+
s.add_argument("--port", type=int, default=4860)
|
|
86
|
+
s.add_argument("--ready-delay", type=float, default=0.5)
|
|
87
|
+
s.add_argument("--transfer-time", type=float, default=4.0)
|
|
88
|
+
s.add_argument("--watchdog", type=float, default=1.0)
|
|
89
|
+
s.add_argument("--defect", action="append", choices=DEFECTS, help="switch one rule off (repeatable)")
|
|
90
|
+
s.set_defaults(func=_station)
|
|
91
|
+
a = ap.parse_args(argv)
|
|
92
|
+
try:
|
|
93
|
+
return a.func(a)
|
|
94
|
+
except (ValueError, OSError, asyncio.TimeoutError) as e:
|
|
95
|
+
print(f"handover-check: {e or type(e).__name__}", file=sys.stderr)
|
|
96
|
+
return 64
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
sys.exit(main())
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Play the vehicle side of the handover against a station PLC and check the station against rules R1-R10.
|
|
2
|
+
|
|
3
|
+
The tester only needs the eight handshake signals over OPC UA (docs/SPEC.md). It does not need a robot,
|
|
4
|
+
ROS or the load: it sets the two request bits and the heartbeat like a vehicle would, also in the ways a
|
|
5
|
+
vehicle fails (goes silent, leaves, changes its mind), and watches what the station answers and how fast.
|
|
6
|
+
|
|
7
|
+
It WRITES to the station. On a real station that can start a conveyor. Run it with the station in a
|
|
8
|
+
test state, clear of people and loads.
|
|
9
|
+
"""
|
|
10
|
+
import asyncio
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from asyncua import Client, ua
|
|
15
|
+
|
|
16
|
+
SIGNALS = ("AmrDockRequest", "AmrInPosition", "AmrHeartbeat",
|
|
17
|
+
"StationReady", "TransferActive", "TransferDone", "Fault", "FaultCode")
|
|
18
|
+
SIM_MAP = {"namespace_uri": "urn:amr-station-handshake:station",
|
|
19
|
+
"node_ids": {name: f"Station.{name}" for name in SIGNALS}}
|
|
20
|
+
PASS, FAIL, SKIP = "PASS", "FAIL", "SKIP"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Limits:
|
|
25
|
+
"""Longest time the station may take, in seconds. Set them to what the plant specification says."""
|
|
26
|
+
ready: float = 5.0 # request -> StationReady
|
|
27
|
+
start: float = 3.0 # in position -> TransferActive
|
|
28
|
+
transfer: float = 15.0 # TransferActive -> TransferDone
|
|
29
|
+
release: float = 3.0 # request dropped -> station idle
|
|
30
|
+
watchdog: float = 2.0 # heartbeat stopped -> Fault, conveyor stopped
|
|
31
|
+
stop: float = 1.0 # vehicle left position -> conveyor stopped
|
|
32
|
+
hold: float = 1.0 # how long "must stay off" conditions are observed
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Result:
|
|
37
|
+
rule: str
|
|
38
|
+
title: str
|
|
39
|
+
status: str
|
|
40
|
+
detail: str = ""
|
|
41
|
+
seconds: float | None = None # the measured response time, where the rule has one
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class State:
|
|
46
|
+
ready: bool
|
|
47
|
+
active: bool
|
|
48
|
+
done: bool
|
|
49
|
+
fault: bool
|
|
50
|
+
code: int
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def idle(self) -> bool:
|
|
54
|
+
return not (self.ready or self.active or self.done or self.fault)
|
|
55
|
+
|
|
56
|
+
def __str__(self) -> str:
|
|
57
|
+
on = [n for n, v in (("Ready", self.ready), ("TransferActive", self.active),
|
|
58
|
+
("TransferDone", self.done), (f"Fault(code {self.code})", self.fault)) if v]
|
|
59
|
+
return ", ".join(on) or "all outputs off"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_signal_map(path) -> dict:
|
|
63
|
+
with open(path, encoding="utf-8") as f:
|
|
64
|
+
signal_map = yaml.safe_load(f)
|
|
65
|
+
missing = [n for n in SIGNALS if n not in (signal_map or {}).get("node_ids", {})]
|
|
66
|
+
if not signal_map or "namespace_uri" not in signal_map or missing:
|
|
67
|
+
raise ValueError(f"{path}: needs namespace_uri and node_ids for {', '.join(missing or SIGNALS)}")
|
|
68
|
+
return signal_map
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Rig:
|
|
72
|
+
"""The vehicle side: two request bits, a heartbeat, and eyes on the station's five outputs."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, endpoint: str, signal_map: dict, period: float = 0.05, timeout: float = 4.0):
|
|
75
|
+
self.endpoint, self.signal_map, self.period = endpoint, signal_map, period
|
|
76
|
+
self._client = Client(endpoint, timeout=timeout)
|
|
77
|
+
self._beating = True
|
|
78
|
+
self._beat = 0
|
|
79
|
+
self._task = None
|
|
80
|
+
self._n = {}
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> "Rig":
|
|
83
|
+
await self._client.connect()
|
|
84
|
+
try:
|
|
85
|
+
ns = await self._client.get_namespace_index(self.signal_map["namespace_uri"])
|
|
86
|
+
except ValueError:
|
|
87
|
+
await self._client.disconnect()
|
|
88
|
+
raise ValueError(f"namespace {self.signal_map['namespace_uri']} not found on {self.endpoint}") from None
|
|
89
|
+
self._n = {name: self._client.get_node(ua.NodeId(self.signal_map["node_ids"][name], ns)) for name in SIGNALS}
|
|
90
|
+
self._outputs = [self._n[k] for k in SIGNALS[3:]]
|
|
91
|
+
await self.read() # fails here if a node id is wrong
|
|
92
|
+
self._task = asyncio.create_task(self._heartbeat())
|
|
93
|
+
await asyncio.sleep(self.period * 4) # heartbeat is running before the first request
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
async def __aexit__(self, *exc) -> None:
|
|
97
|
+
if self._task:
|
|
98
|
+
self._task.cancel()
|
|
99
|
+
await asyncio.gather(self._task, return_exceptions=True)
|
|
100
|
+
try: # never leave a request latched on the PLC
|
|
101
|
+
await self.set(False, False)
|
|
102
|
+
finally:
|
|
103
|
+
await self._client.disconnect()
|
|
104
|
+
|
|
105
|
+
async def _heartbeat(self) -> None:
|
|
106
|
+
while True:
|
|
107
|
+
if self._beating:
|
|
108
|
+
self._beat = (self._beat + 1) % 32768 # PLC INT
|
|
109
|
+
await self._n["AmrHeartbeat"].write_value(self._beat, ua.VariantType.Int16)
|
|
110
|
+
await asyncio.sleep(self.period)
|
|
111
|
+
|
|
112
|
+
def heartbeat(self, on: bool) -> None:
|
|
113
|
+
self._beating = on
|
|
114
|
+
|
|
115
|
+
async def set(self, request: bool, in_position: bool) -> None:
|
|
116
|
+
await self._n["AmrDockRequest"].write_value(request, ua.VariantType.Boolean)
|
|
117
|
+
await self._n["AmrInPosition"].write_value(in_position, ua.VariantType.Boolean)
|
|
118
|
+
|
|
119
|
+
async def read(self) -> State:
|
|
120
|
+
ready, active, done, fault, code = await self._client.read_values(self._outputs) # one consistent snapshot
|
|
121
|
+
return State(bool(ready), bool(active), bool(done), bool(fault), int(code or 0))
|
|
122
|
+
|
|
123
|
+
async def wait(self, wanted, limit: float, forbidden=None):
|
|
124
|
+
"""Poll until wanted(state). Returns (reached, seconds, state, violation): violation is the first
|
|
125
|
+
state in which forbidden(state) was true while waiting."""
|
|
126
|
+
loop = asyncio.get_running_loop()
|
|
127
|
+
start = loop.time()
|
|
128
|
+
violation = None
|
|
129
|
+
while True:
|
|
130
|
+
s = await self.read()
|
|
131
|
+
t = loop.time() - start
|
|
132
|
+
if forbidden and violation is None and forbidden(s):
|
|
133
|
+
violation = s
|
|
134
|
+
if wanted(s):
|
|
135
|
+
return True, t, s, violation
|
|
136
|
+
if t > limit:
|
|
137
|
+
return False, t, s, violation
|
|
138
|
+
await asyncio.sleep(self.period / 2)
|
|
139
|
+
|
|
140
|
+
async def observe(self, seconds: float, forbidden):
|
|
141
|
+
"""Watch for `seconds`; returns the first state that broke the rule, or None."""
|
|
142
|
+
_, _, _, violation = await self.wait(lambda s: False, seconds, forbidden)
|
|
143
|
+
return violation
|
|
144
|
+
|
|
145
|
+
async def to_idle(self, limit: float) -> bool:
|
|
146
|
+
self.heartbeat(True)
|
|
147
|
+
await self.set(False, False)
|
|
148
|
+
reached, *_ = await self.wait(lambda s: s.idle, limit)
|
|
149
|
+
# A vehicle is alive before it asks. Give the station a few fresh heartbeats first, otherwise the
|
|
150
|
+
# next request can meet a stale counter and a correct watchdog trips at once.
|
|
151
|
+
await asyncio.sleep(self.period * 4)
|
|
152
|
+
return reached
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------- building blocks
|
|
156
|
+
async def _to_ready(rig: Rig, lim: Limits):
|
|
157
|
+
await rig.set(True, False)
|
|
158
|
+
return await rig.wait(lambda s: s.ready, lim.ready, forbidden=lambda s: s.active or s.done)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _to_transfer(rig: Rig, lim: Limits):
|
|
162
|
+
reached, t, s, _ = await _to_ready(rig, lim)
|
|
163
|
+
if not reached:
|
|
164
|
+
return False, t, s
|
|
165
|
+
await rig.set(True, True)
|
|
166
|
+
reached, t, s, _ = await rig.wait(lambda s: s.active or s.done or s.fault, lim.start)
|
|
167
|
+
return reached and s.active, t, s
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------- the rules
|
|
171
|
+
async def r1_idle_at_rest(rig, lim):
|
|
172
|
+
bad = await rig.observe(lim.hold, lambda s: not s.idle)
|
|
173
|
+
if bad:
|
|
174
|
+
return Result("R1", "", FAIL, f"with no request the station shows: {bad}")
|
|
175
|
+
return Result("R1", "", PASS, f"all outputs off for {lim.hold:g} s without a request")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def r2_ready_follows_request(rig, lim):
|
|
179
|
+
reached, t, s, early = await _to_ready(rig, lim)
|
|
180
|
+
if early:
|
|
181
|
+
return Result("R2", "", FAIL, f"before the vehicle was in position the station showed: {early}", t)
|
|
182
|
+
if not reached:
|
|
183
|
+
return Result("R2", "", FAIL, f"no StationReady within {lim.ready:g} s of the request (station: {s})", t)
|
|
184
|
+
return Result("R2", "", PASS, f"StationReady {t:.2f} s after the request (limit {lim.ready:g} s)", t)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def r3_no_transfer_before_in_position(rig, lim):
|
|
188
|
+
reached, _, s, _ = await _to_ready(rig, lim)
|
|
189
|
+
if not reached:
|
|
190
|
+
return Result("R3", "", SKIP, f"station never became ready (see R2), so this could not be tested: {s}")
|
|
191
|
+
bad = await rig.observe(lim.hold, lambda s: s.active or s.done)
|
|
192
|
+
if bad:
|
|
193
|
+
return Result("R3", "", FAIL, f"the vehicle was NOT in position, but the station showed: {bad}")
|
|
194
|
+
return Result("R3", "", PASS, f"no transfer for {lim.hold:g} s while the vehicle was not in position")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def r4_transfer_completes(rig, lim):
|
|
198
|
+
ok, t_start, s = await _to_transfer(rig, lim)
|
|
199
|
+
if not ok:
|
|
200
|
+
return Result("R4", "", FAIL, f"no TransferActive within {lim.start:g} s of AmrInPosition (station: {s})", t_start)
|
|
201
|
+
reached, t, s, _ = await rig.wait(lambda s: s.done or s.fault, lim.transfer)
|
|
202
|
+
if not reached or s.fault:
|
|
203
|
+
return Result("R4", "", FAIL, f"no TransferDone within {lim.transfer:g} s (station: {s})", t)
|
|
204
|
+
s = await rig.read()
|
|
205
|
+
if s.active:
|
|
206
|
+
return Result("R4", "", FAIL, "TransferDone is set but TransferActive is still on: the vehicle cannot tell "
|
|
207
|
+
"whether the conveyor has stopped", t)
|
|
208
|
+
return Result("R4", "", PASS, f"transfer started after {t_start:.2f} s and finished after {t:.2f} s", t)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def r5_release(rig, lim):
|
|
212
|
+
ok, _, s = await _to_transfer(rig, lim)
|
|
213
|
+
reached, _, s, _ = await rig.wait(lambda s: s.done or s.fault, lim.transfer) if ok else (False, 0, s, None)
|
|
214
|
+
if not reached or s.fault:
|
|
215
|
+
return Result("R5", "", SKIP, f"no completed transfer to release from (see R4): {s}")
|
|
216
|
+
await rig.set(False, False)
|
|
217
|
+
reached, t, s, _ = await rig.wait(lambda s: s.idle, lim.release)
|
|
218
|
+
if not reached:
|
|
219
|
+
return Result("R5", "", FAIL, f"{lim.release:g} s after the vehicle released, the station still shows: {s}", t)
|
|
220
|
+
return Result("R5", "", PASS, f"station idle {t:.2f} s after the release (limit {lim.release:g} s)", t)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def r6_second_handover(rig, lim):
|
|
224
|
+
for n in (1, 2):
|
|
225
|
+
ok, _, s = await _to_transfer(rig, lim)
|
|
226
|
+
reached, _, s, _ = await rig.wait(lambda s: s.done or s.fault, lim.transfer) if ok else (False, 0, s, None)
|
|
227
|
+
if not reached or s.fault:
|
|
228
|
+
return Result("R6", "", FAIL if n == 2 else SKIP, f"handover {n} did not complete: {s}")
|
|
229
|
+
if not await rig.to_idle(lim.release):
|
|
230
|
+
return Result("R6", "", FAIL if n == 2 else SKIP, f"station not idle after handover {n}: {await rig.read()}")
|
|
231
|
+
return Result("R6", "", PASS, "two handovers in a row completed")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def r7_withdrawn_request(rig, lim):
|
|
235
|
+
reached, _, s, _ = await _to_ready(rig, lim)
|
|
236
|
+
if not reached:
|
|
237
|
+
return Result("R7", "", SKIP, f"station never became ready (see R2): {s}")
|
|
238
|
+
await rig.set(False, False)
|
|
239
|
+
reached, t, s, moved = await rig.wait(lambda s: s.idle, lim.release, forbidden=lambda s: s.active or s.done)
|
|
240
|
+
if moved:
|
|
241
|
+
return Result("R7", "", FAIL, f"the vehicle withdrew its request, yet the station showed: {moved}", t)
|
|
242
|
+
if not reached:
|
|
243
|
+
return Result("R7", "", FAIL, f"{lim.release:g} s after the request was withdrawn the station still shows: {s}", t)
|
|
244
|
+
return Result("R7", "", PASS, f"station back to idle {t:.2f} s after the request was withdrawn", t)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def r8_heartbeat_loss(rig, lim):
|
|
248
|
+
ok, _, s = await _to_transfer(rig, lim)
|
|
249
|
+
if not ok:
|
|
250
|
+
return Result("R8", "", SKIP, f"no running transfer to interrupt (see R4): {s}")
|
|
251
|
+
rig.heartbeat(False)
|
|
252
|
+
reached, t, s, _ = await rig.wait(lambda s: s.fault or s.done, lim.watchdog)
|
|
253
|
+
if s.done and not s.fault:
|
|
254
|
+
return Result("R8", "", SKIP, f"the transfer finished {t:.2f} s after the heartbeat stopped, before a watchdog "
|
|
255
|
+
"could show. Test with a longer transfer.", t)
|
|
256
|
+
if not reached:
|
|
257
|
+
return Result("R8", "", FAIL, f"the vehicle went silent for {lim.watchdog:g} s during the transfer and the "
|
|
258
|
+
f"station carried on: {s}", t)
|
|
259
|
+
if s.active:
|
|
260
|
+
return Result("R8", "", FAIL, f"Fault is set but TransferActive is still on: {s}", t)
|
|
261
|
+
if s.code == 0:
|
|
262
|
+
return Result("R8", "", FAIL, "Fault is set but FaultCode is 0: nobody can tell what happened", t)
|
|
263
|
+
return Result("R8", "", PASS, f"fault code {s.code} and conveyor stopped {t:.2f} s after the heartbeat stopped "
|
|
264
|
+
f"(limit {lim.watchdog:g} s)", t)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def r9_fault_acknowledge(rig, lim):
|
|
268
|
+
ok, _, s = await _to_transfer(rig, lim)
|
|
269
|
+
if ok:
|
|
270
|
+
rig.heartbeat(False)
|
|
271
|
+
ok, _, s, _ = await rig.wait(lambda s: s.fault or s.done, lim.watchdog)
|
|
272
|
+
if not ok or not s.fault:
|
|
273
|
+
return Result("R9", "", SKIP, f"could not bring the station into a fault to acknowledge (see R8): {s}")
|
|
274
|
+
await rig.set(False, False) # the acknowledge; the heartbeat may still be dead
|
|
275
|
+
reached, t, s, _ = await rig.wait(lambda s: s.idle, lim.release)
|
|
276
|
+
if not reached:
|
|
277
|
+
return Result("R9", "", FAIL, f"{lim.release:g} s after the request was dropped (the acknowledge) the station "
|
|
278
|
+
f"still shows: {s}", t)
|
|
279
|
+
return Result("R9", "", PASS, f"fault cleared {t:.2f} s after the acknowledge", t)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def r10_vehicle_leaves(rig, lim):
|
|
283
|
+
ok, _, s = await _to_transfer(rig, lim)
|
|
284
|
+
if not ok:
|
|
285
|
+
return Result("R10", "", SKIP, f"no running transfer to interrupt (see R4): {s}")
|
|
286
|
+
await rig.set(True, False) # still requesting, but no longer in position
|
|
287
|
+
reached, t, s, _ = await rig.wait(lambda s: not s.active, lim.stop)
|
|
288
|
+
if not reached:
|
|
289
|
+
return Result("R10", "", FAIL, f"the vehicle left its position and {lim.stop:g} s later the conveyor is "
|
|
290
|
+
f"still running: {s}", t)
|
|
291
|
+
if s.done and not s.fault:
|
|
292
|
+
return Result("R10", "", SKIP, f"the transfer finished {t:.2f} s after the vehicle left, too fast to judge. "
|
|
293
|
+
"Test with a longer transfer.", t)
|
|
294
|
+
return Result("R10", "", PASS, f"conveyor stopped {t:.2f} s after the vehicle left its position "
|
|
295
|
+
f"(limit {lim.stop:g} s; station: {s})", t)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
RULES = {
|
|
299
|
+
"R1": ("Idle at rest: no output is on without a request", r1_idle_at_rest),
|
|
300
|
+
"R2": ("StationReady follows AmrDockRequest in time, and nothing moves yet", r2_ready_follows_request),
|
|
301
|
+
"R3": ("No transfer before AmrInPosition", r3_no_transfer_before_in_position),
|
|
302
|
+
"R4": ("Transfer starts after AmrInPosition and completes; TransferActive is off when TransferDone is on", r4_transfer_completes),
|
|
303
|
+
"R5": ("Station returns to idle after the vehicle releases", r5_release),
|
|
304
|
+
"R6": ("A second handover works straight after the first", r6_second_handover),
|
|
305
|
+
"R7": ("A withdrawn request returns the station to idle without a transfer", r7_withdrawn_request),
|
|
306
|
+
"R8": ("Heartbeat watchdog: a silent vehicle stops the transfer with a fault code", r8_heartbeat_loss),
|
|
307
|
+
"R9": ("Dropping the request acknowledges and clears a fault", r9_fault_acknowledge),
|
|
308
|
+
"R10": ("The conveyor stops when the vehicle leaves its position", r10_vehicle_leaves),
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def run_checks(endpoint: str, signal_map: dict = SIM_MAP, limits: Limits = Limits(),
|
|
313
|
+
only: list[str] | None = None, period: float = 0.05) -> list[Result]:
|
|
314
|
+
"""Run the rules in order against the station. Before each rule the station is brought back to idle;
|
|
315
|
+
a rule that cannot start from idle is reported as SKIP with the reason."""
|
|
316
|
+
results = []
|
|
317
|
+
stuck = False
|
|
318
|
+
async with Rig(endpoint, signal_map, period) as rig:
|
|
319
|
+
for rule, (title, check) in RULES.items():
|
|
320
|
+
if only and rule not in only:
|
|
321
|
+
continue
|
|
322
|
+
if rule != "R1" and not await rig.to_idle(limits.release * 2):
|
|
323
|
+
# A station that keeps outputs on after the vehicle dropped both request bits has broken
|
|
324
|
+
# the release rule. Say so once as a failure; the rules after it cannot be judged.
|
|
325
|
+
detail = (f"the vehicle dropped its request and {limits.release * 2:g} s later the station still "
|
|
326
|
+
f"shows: {await rig.read()}")
|
|
327
|
+
r = Result(rule, title, SKIP if stuck else FAIL,
|
|
328
|
+
("not testable, " if stuck else "station does not return to idle (rule R5): ") + detail)
|
|
329
|
+
stuck = True
|
|
330
|
+
else:
|
|
331
|
+
try:
|
|
332
|
+
r = await check(rig, limits)
|
|
333
|
+
finally:
|
|
334
|
+
rig.heartbeat(True)
|
|
335
|
+
r.title = title
|
|
336
|
+
results.append(r)
|
|
337
|
+
await rig.to_idle(limits.release)
|
|
338
|
+
return results
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def as_dicts(results: list[Result]) -> list[dict]:
|
|
342
|
+
return [asdict(r) for r in results]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Reference station: the station side of the handover as a pure, PLC-style step function, plus an
|
|
2
|
+
OPC UA server around it.
|
|
3
|
+
|
|
4
|
+
Two uses:
|
|
5
|
+
- a station to develop against when no PLC is at hand (`handover-check station`);
|
|
6
|
+
- the yardstick for the tester's own tests: each DEFECT switches one rule off, and the tester must
|
|
7
|
+
report exactly that rule as broken.
|
|
8
|
+
|
|
9
|
+
The logic is written the way a PLC task works: read inputs, decide, write outputs, once per cycle.
|
|
10
|
+
"""
|
|
11
|
+
import asyncio
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from asyncua import Server, ua
|
|
15
|
+
|
|
16
|
+
NAMESPACE = "urn:amr-station-handshake:station"
|
|
17
|
+
FAULT_CONVEYOR_BLOCKED, FAULT_HEARTBEAT_LOST, FAULT_AMR_LEFT = 1, 2, 3
|
|
18
|
+
DEFECTS = (
|
|
19
|
+
"ready_without_request", # R1: Ready is on although nobody asked
|
|
20
|
+
"slow_ready", # R2: Ready comes far too late
|
|
21
|
+
"transfer_before_docked", # R3: conveyor starts before the vehicle is in position
|
|
22
|
+
"active_stays_on", # R4: TransferActive still on when TransferDone is set
|
|
23
|
+
"ready_stuck", # R5: Ready/Done not cleared after the vehicle released
|
|
24
|
+
"no_watchdog", # R8: a dead vehicle is not noticed
|
|
25
|
+
"fault_not_cleared", # R9: fault stays latched after the acknowledge
|
|
26
|
+
"ignores_undock", # R10: conveyor keeps running when the vehicle leaves
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Outputs:
|
|
32
|
+
ready: bool = False
|
|
33
|
+
transfer_active: bool = False
|
|
34
|
+
transfer_done: bool = False
|
|
35
|
+
fault: bool = False
|
|
36
|
+
fault_code: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class StationLogic:
|
|
41
|
+
ready_delay: float = 0.5 # s from request to Ready
|
|
42
|
+
transfer_time: float = 2.0 # s the conveyor runs
|
|
43
|
+
watchdog: float = 1.0 # s without a new heartbeat value before the station faults
|
|
44
|
+
defects: frozenset = field(default_factory=frozenset)
|
|
45
|
+
phase: str = "idle"
|
|
46
|
+
code: int = 0
|
|
47
|
+
_due: float = 0.0
|
|
48
|
+
_last_beat: int | None = None
|
|
49
|
+
_beat_seen: float = 0.0
|
|
50
|
+
|
|
51
|
+
def step(self, request: bool, docked: bool, beat: int, now: float) -> Outputs:
|
|
52
|
+
d = self.defects
|
|
53
|
+
if beat != self._last_beat:
|
|
54
|
+
self._last_beat, self._beat_seen = beat, now
|
|
55
|
+
|
|
56
|
+
if not request:
|
|
57
|
+
if self.phase == "fault" and "fault_not_cleared" in d:
|
|
58
|
+
pass
|
|
59
|
+
elif self.phase in ("ready", "done") and "ready_stuck" in d:
|
|
60
|
+
pass
|
|
61
|
+
else:
|
|
62
|
+
self.phase, self.code = "idle", 0 # dropping the request also acknowledges a fault
|
|
63
|
+
elif self.phase == "fault":
|
|
64
|
+
pass # latched until the request drops
|
|
65
|
+
elif now - self._beat_seen > self.watchdog and "no_watchdog" not in d:
|
|
66
|
+
self.phase, self.code = "fault", FAULT_HEARTBEAT_LOST
|
|
67
|
+
elif self.phase == "idle":
|
|
68
|
+
self.phase = "preparing"
|
|
69
|
+
self._due = now + (self.ready_delay * 20 if "slow_ready" in d else self.ready_delay)
|
|
70
|
+
elif self.phase == "preparing" and now >= self._due:
|
|
71
|
+
self.phase = "ready"
|
|
72
|
+
elif self.phase == "ready" and (docked or "transfer_before_docked" in d):
|
|
73
|
+
self.phase, self._due = "transferring", now + self.transfer_time
|
|
74
|
+
elif self.phase == "transferring":
|
|
75
|
+
if not docked and not d & {"ignores_undock", "transfer_before_docked"}:
|
|
76
|
+
self.phase, self.code = "fault", FAULT_AMR_LEFT
|
|
77
|
+
elif now >= self._due:
|
|
78
|
+
self.phase = "done"
|
|
79
|
+
|
|
80
|
+
return Outputs(
|
|
81
|
+
ready=self.phase in ("ready", "transferring", "done") or "ready_without_request" in d,
|
|
82
|
+
transfer_active=self.phase == "transferring" or (self.phase == "done" and "active_stays_on" in d),
|
|
83
|
+
transfer_done=self.phase == "done",
|
|
84
|
+
fault=self.phase == "fault",
|
|
85
|
+
fault_code=self.code,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def serve(endpoint: str, logic: StationLogic, ready: asyncio.Event | None = None,
|
|
90
|
+
stop: asyncio.Event | None = None, cycle: float = 0.02) -> None:
|
|
91
|
+
"""Run the station as an OPC UA server (no security: a development aid, bind it to localhost)."""
|
|
92
|
+
server = Server()
|
|
93
|
+
await server.init()
|
|
94
|
+
server.set_endpoint(endpoint)
|
|
95
|
+
server.set_server_name("Reference conveyor station (handover-check)")
|
|
96
|
+
server.set_security_policy([ua.SecurityPolicyType.NoSecurity])
|
|
97
|
+
idx = await server.register_namespace(NAMESPACE)
|
|
98
|
+
station = await server.nodes.objects.add_object(ua.NodeId("Station", idx), "Station")
|
|
99
|
+
|
|
100
|
+
async def signal(name, value, vt):
|
|
101
|
+
node = await station.add_variable(ua.NodeId(f"Station.{name}", idx), name, value, vt)
|
|
102
|
+
await node.set_writable()
|
|
103
|
+
return node
|
|
104
|
+
boolean, int16 = ua.VariantType.Boolean, ua.VariantType.Int16
|
|
105
|
+
inputs = [await signal(n, v, t) for n, v, t in (("AmrDockRequest", False, boolean),
|
|
106
|
+
("AmrInPosition", False, boolean), ("AmrHeartbeat", 0, int16))]
|
|
107
|
+
outs = [await signal(n, v, t) for n, v, t in (("StationReady", False, boolean), ("TransferActive", False, boolean),
|
|
108
|
+
("TransferDone", False, boolean), ("Fault", False, boolean),
|
|
109
|
+
("FaultCode", 0, int16))]
|
|
110
|
+
loop = asyncio.get_running_loop()
|
|
111
|
+
async with server:
|
|
112
|
+
if ready:
|
|
113
|
+
ready.set()
|
|
114
|
+
while not (stop and stop.is_set()):
|
|
115
|
+
request, docked, beat = [await n.read_value() for n in inputs]
|
|
116
|
+
o = logic.step(bool(request), bool(docked), int(beat), loop.time())
|
|
117
|
+
for node, value, vt in zip(outs, (o.ready, o.transfer_active, o.transfer_done, o.fault, o.fault_code),
|
|
118
|
+
(boolean, boolean, boolean, boolean, int16)):
|
|
119
|
+
await node.write_value(value, vt)
|
|
120
|
+
await asyncio.sleep(cycle)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: handover-check
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Test a conveyor station's AMR/AGV load-handover handshake over OPC UA before the robot arrives: plays the vehicle side, also the ways a vehicle fails, and reports rule by rule.
|
|
5
|
+
Author: Suleman Ali
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/sulemanali5760/amr-station-handshake
|
|
8
|
+
Keywords: agv,amr,plc,opc-ua,handshake,commissioning,conveyor,vda5050,intralogistics
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Manufacturing
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: asyncua<3,>=1.1
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# handover-check
|
|
23
|
+
|
|
24
|
+
Test a conveyor station's **AMR/AGV load-handover handshake** before the robot arrives.
|
|
25
|
+
|
|
26
|
+
The tool connects to the station PLC over OPC UA and plays the vehicle side: it requests a handover,
|
|
27
|
+
docks, releases, and also does what vehicles do when things go wrong (goes silent, leaves mid-transfer,
|
|
28
|
+
changes its mind). It checks the station's answers and response times against ten rules and reports
|
|
29
|
+
rule by rule.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
$ handover-check run opc.tcp://192.168.0.10:4840 --map station.yaml --station-is-safe
|
|
33
|
+
|
|
34
|
+
[PASS] R1 Idle at rest: no output is on without a request
|
|
35
|
+
all outputs off for 1 s without a request
|
|
36
|
+
[PASS] R2 StationReady follows AmrDockRequest in time, and nothing moves yet
|
|
37
|
+
StationReady 0.53 s after the request (limit 5 s)
|
|
38
|
+
[PASS] R3 No transfer before AmrInPosition
|
|
39
|
+
no transfer for 1 s while the vehicle was not in position
|
|
40
|
+
[PASS] R4 Transfer starts after AmrInPosition and completes; TransferActive is off when TransferDone is on
|
|
41
|
+
transfer started after 0.02 s and finished after 4.02 s
|
|
42
|
+
[PASS] R5 Station returns to idle after the vehicle releases
|
|
43
|
+
station idle 0.03 s after the release (limit 3 s)
|
|
44
|
+
[PASS] R6 A second handover works straight after the first
|
|
45
|
+
two handovers in a row completed
|
|
46
|
+
[PASS] R7 A withdrawn request returns the station to idle without a transfer
|
|
47
|
+
station back to idle 0.02 s after the request was withdrawn
|
|
48
|
+
[FAIL] R8 Heartbeat watchdog: a silent vehicle stops the transfer with a fault code
|
|
49
|
+
the vehicle went silent for 2 s during the transfer and the station carried on: Ready, TransferActive
|
|
50
|
+
[SKIP] R9 Dropping the request acknowledges and clears a fault
|
|
51
|
+
could not bring the station into a fault to acknowledge (see R8): Ready, TransferActive
|
|
52
|
+
[PASS] R10 The conveyor stops when the vehicle leaves its position
|
|
53
|
+
conveyor stopped 0.02 s after the vehicle left its position (limit 1 s; station: Fault(code 3))
|
|
54
|
+
|
|
55
|
+
Station does NOT conform. 8 passed, 1 failed, 1 not testable.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
*Real output, against the bundled reference station with its watchdog switched off; address changed.*
|
|
59
|
+
|
|
60
|
+
## Why
|
|
61
|
+
|
|
62
|
+
VDA 5050 covers fleet controller to vehicle and expressly leaves out "interfaces to peripheral
|
|
63
|
+
equipment". The handshake between vehicle and conveyor is therefore written again in every project,
|
|
64
|
+
by two teams, and its mistakes show up at commissioning with the robot standing in front of the
|
|
65
|
+
station. The rules are written down in [docs/SPEC.md](../../docs/SPEC.md); this tool lets the PLC
|
|
66
|
+
programmer test the station side alone, at the desk or on site, days earlier.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install handover-check
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Python 3.11 or newer; Windows or Linux. No ROS and no robot needed.
|
|
75
|
+
Offline bundles for machines without internet are attached to each
|
|
76
|
+
[release](https://github.com/sulemanali5760/amr-station-handshake/releases).
|
|
77
|
+
|
|
78
|
+
## Use
|
|
79
|
+
|
|
80
|
+
Try it without a PLC, in two terminals:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
handover-check station # reference station on opc.tcp://127.0.0.1:4860/station/
|
|
84
|
+
handover-check run opc.tcp://127.0.0.1:4860/station/
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
handover-check station --defect no_watchdog # a station with one rule switched off
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Against your PLC: describe where the eight signals live in a signal map (examples for the reference
|
|
92
|
+
station, Siemens S7-1500 and Beckhoff TwinCAT 3 are in [`maps/`](maps)), then:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
handover-check run opc.tcp://192.168.0.10:4840 --map my_station.yaml --station-is-safe
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Option | Purpose |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `--t-ready 5` `--t-start 3` `--t-transfer 15` `--t-release 3` `--t-watchdog 2` `--t-stop 1` | the time limits of your plant specification, in seconds |
|
|
101
|
+
| `--only R2 R8` | run selected rules |
|
|
102
|
+
| `--report record.md` | Markdown report for the commissioning record |
|
|
103
|
+
| `--json` | machine-readable output |
|
|
104
|
+
| `--strict` | exit code 2 if a rule could not be tested |
|
|
105
|
+
|
|
106
|
+
Exit code 0 = no rule failed, 1 = at least one rule failed.
|
|
107
|
+
|
|
108
|
+
## It moves your conveyor
|
|
109
|
+
|
|
110
|
+
The tool writes the request signals. On a real station that starts the conveyor.
|
|
111
|
+
|
|
112
|
+
- For any endpoint that is not this computer it refuses to run without `--station-is-safe`.
|
|
113
|
+
- Run it with the station in a test state, clear of people and loads.
|
|
114
|
+
- It always writes both request bits back to FALSE when it ends, also after an error.
|
|
115
|
+
- It is a commissioning aid. It is **not** a safety function and no substitute for the safety
|
|
116
|
+
validation of the station.
|
|
117
|
+
|
|
118
|
+
## Tested
|
|
119
|
+
|
|
120
|
+
The test suite starts the reference station over real OPC UA, once conforming and once for each of
|
|
121
|
+
eight built-in defects, and expects the tester to report exactly the broken rule:
|
|
122
|
+
|
|
123
|
+
| Defect in the station | Reported under |
|
|
124
|
+
|---|---|
|
|
125
|
+
| Ready on without a request | R1 |
|
|
126
|
+
| Ready far too late | R2 |
|
|
127
|
+
| conveyor starts before the vehicle is in position | R3 |
|
|
128
|
+
| TransferActive still on with TransferDone | R4 |
|
|
129
|
+
| outputs not cleared after release | R5 |
|
|
130
|
+
| no heartbeat watchdog | R8 |
|
|
131
|
+
| fault not cleared by the acknowledge | R9 |
|
|
132
|
+
| conveyor keeps running when the vehicle leaves | R10 |
|
|
133
|
+
|
|
134
|
+
A transfer that is too short to judge R8 or R10 is reported as "not testable", not as a pass.
|
|
135
|
+
|
|
136
|
+
## Limits
|
|
137
|
+
|
|
138
|
+
- **Not tested against a real PLC.** Only against the bundled reference station. The S7-1500 and
|
|
139
|
+
TwinCAT maps are examples; node ids depend on the project.
|
|
140
|
+
- **No OPC UA security.** The tool connects anonymously without encryption. A PLC that demands
|
|
141
|
+
certificates or a login cannot be tested yet.
|
|
142
|
+
- **Polling.** Signals are read every 25 ms by default, so measured times are accurate to a few
|
|
143
|
+
tens of milliseconds plus the network. It cannot see events shorter than that.
|
|
144
|
+
- **The rules are this project's own** ([SPEC](../../docs/SPEC.md)), shaped after SEMI E84. They
|
|
145
|
+
are not a standard of any body, and a station built to another handshake will fail them.
|
|
146
|
+
- **One vehicle, one station.** No carrier identification, no multi-lane stations.
|
|
147
|
+
|
|
148
|
+
## Licence
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/handover_check/__init__.py
|
|
5
|
+
src/handover_check/__main__.py
|
|
6
|
+
src/handover_check/checks.py
|
|
7
|
+
src/handover_check/station.py
|
|
8
|
+
src/handover_check.egg-info/PKG-INFO
|
|
9
|
+
src/handover_check.egg-info/SOURCES.txt
|
|
10
|
+
src/handover_check.egg-info/dependency_links.txt
|
|
11
|
+
src/handover_check.egg-info/entry_points.txt
|
|
12
|
+
src/handover_check.egg-info/requires.txt
|
|
13
|
+
src/handover_check.egg-info/top_level.txt
|
|
14
|
+
tests/test_checks.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
handover_check
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""The tester is only worth something if it catches a station that breaks a rule, and stays quiet on one
|
|
2
|
+
that does not. Every test here runs it against the reference station over real OPC UA."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from handover_check.__main__ import main, markdown, render
|
|
9
|
+
from handover_check.checks import FAIL, PASS, RULES, SKIP, Limits, load_signal_map, run_checks
|
|
10
|
+
from handover_check.station import DEFECTS, FAULT_AMR_LEFT, FAULT_HEARTBEAT_LOST, StationLogic, serve
|
|
11
|
+
|
|
12
|
+
# Short times so the suite stays fast; the ratios are those of a real station.
|
|
13
|
+
LIMITS = Limits(ready=1.0, start=0.6, transfer=3.0, release=0.8, watchdog=0.9, stop=0.5, hold=0.4)
|
|
14
|
+
PORT = [48480]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check(defects=(), only=None, limits=LIMITS, **station):
|
|
18
|
+
PORT[0] += 1
|
|
19
|
+
endpoint = f"opc.tcp://127.0.0.1:{PORT[0]}/station/"
|
|
20
|
+
logic = StationLogic(ready_delay=0.15, transfer_time=station.pop("transfer_time", 1.6), watchdog=0.4,
|
|
21
|
+
defects=frozenset(defects), **station)
|
|
22
|
+
|
|
23
|
+
async def go():
|
|
24
|
+
ready, stop = asyncio.Event(), asyncio.Event()
|
|
25
|
+
task = asyncio.create_task(serve(endpoint, logic, ready, stop))
|
|
26
|
+
await asyncio.wait_for(ready.wait(), 20)
|
|
27
|
+
try:
|
|
28
|
+
return await run_checks(endpoint, limits=limits, only=only, period=0.03)
|
|
29
|
+
finally:
|
|
30
|
+
stop.set()
|
|
31
|
+
await task
|
|
32
|
+
return {r.rule: r for r in asyncio.run(go())}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_conforming_station_passes_every_rule():
|
|
36
|
+
results = check()
|
|
37
|
+
assert list(results) == list(RULES)
|
|
38
|
+
assert {r.status for r in results.values()} == {PASS}, render(list(results.values()), "x")
|
|
39
|
+
assert 0.1 < results["R2"].seconds < 0.6 # the ready delay of 0.15 s is measured, not assumed
|
|
40
|
+
assert f"fault code {FAULT_HEARTBEAT_LOST}" in results["R8"].detail
|
|
41
|
+
assert f"code {FAULT_AMR_LEFT}" in results["R10"].detail
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("defect, rule, words", [
|
|
45
|
+
("ready_without_request", "R1", "with no request"),
|
|
46
|
+
("slow_ready", "R2", "no StationReady within"),
|
|
47
|
+
("transfer_before_docked", "R3", "NOT in position"),
|
|
48
|
+
("active_stays_on", "R4", "TransferActive is still on"),
|
|
49
|
+
("ready_stuck", "R5", "still shows"),
|
|
50
|
+
("no_watchdog", "R8", "went silent"),
|
|
51
|
+
("fault_not_cleared", "R9", "still shows"),
|
|
52
|
+
("ignores_undock", "R10", "still running"),
|
|
53
|
+
])
|
|
54
|
+
def test_each_defect_is_reported_under_its_rule(defect, rule, words):
|
|
55
|
+
only = [rule] if rule != "R2" else ["R2"]
|
|
56
|
+
r = check([defect], only=only)[rule]
|
|
57
|
+
assert r.status == FAIL, r
|
|
58
|
+
assert words in r.detail, r.detail
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_every_defect_has_a_test():
|
|
62
|
+
assert len(DEFECTS) == 8
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_a_broken_station_never_passes_everything():
|
|
66
|
+
"""Whatever single defect the station has, the full run reports at least one FAIL."""
|
|
67
|
+
for defect in ("transfer_before_docked", "no_watchdog", "ready_stuck"):
|
|
68
|
+
statuses = [r.status for r in check([defect]).values()]
|
|
69
|
+
assert FAIL in statuses, (defect, statuses)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_too_fast_transfer_is_reported_as_not_testable_not_as_pass():
|
|
73
|
+
"""With a 0.2 s transfer the watchdog cannot show before the transfer ends: say so."""
|
|
74
|
+
r = check(only=["R8"], transfer_time=0.2)["R8"]
|
|
75
|
+
assert r.status == SKIP and "longer transfer" in r.detail
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_second_handover_fails_when_the_station_stays_latched():
|
|
79
|
+
r = check(["ready_stuck"], only=["R6"])["R6"]
|
|
80
|
+
assert r.status in (FAIL, SKIP) and "idle" in r.detail
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------- pure station logic, no network
|
|
84
|
+
def test_station_logic_walks_the_normal_sequence():
|
|
85
|
+
s = StationLogic(ready_delay=1.0, transfer_time=2.0, watchdog=1.0)
|
|
86
|
+
assert not s.step(False, False, 0, 0.0).ready
|
|
87
|
+
s.step(True, False, 1, 0.1)
|
|
88
|
+
assert not s.step(True, False, 2, 0.5).ready
|
|
89
|
+
assert s.step(True, False, 3, 1.2).ready
|
|
90
|
+
assert s.step(True, True, 4, 1.3).transfer_active
|
|
91
|
+
out = s.step(True, True, 5, 3.4)
|
|
92
|
+
assert out.transfer_done and not out.transfer_active and out.ready
|
|
93
|
+
assert s.step(False, False, 6, 3.5) == type(out)() # all off again
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_station_logic_faults_on_a_frozen_heartbeat():
|
|
97
|
+
s = StationLogic(ready_delay=0.0, transfer_time=10.0, watchdog=1.0)
|
|
98
|
+
for t in (0.0, 0.1, 0.2):
|
|
99
|
+
s.step(True, True, int(t * 10) + 1, t)
|
|
100
|
+
assert s.step(True, True, 3, 0.9).transfer_active # same counter value, still inside the watchdog time
|
|
101
|
+
out = s.step(True, True, 3, 1.3)
|
|
102
|
+
assert out.fault and out.fault_code == FAULT_HEARTBEAT_LOST and not out.transfer_active
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------- command line and files
|
|
106
|
+
def test_refuses_a_remote_station_without_the_safety_confirmation(capsys):
|
|
107
|
+
assert main(["run", "opc.tcp://192.168.0.10:4840"]) == 64
|
|
108
|
+
assert "--station-is-safe" in capsys.readouterr().err
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_unreachable_station_is_an_error_not_a_traceback(capsys):
|
|
112
|
+
assert main(["run", "opc.tcp://127.0.0.1:48479"]) == 64
|
|
113
|
+
assert "handover-check:" in capsys.readouterr().err
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_signal_map_must_name_all_eight_signals(tmp_path):
|
|
117
|
+
p = tmp_path / "map.yaml"
|
|
118
|
+
p.write_text("namespace_uri: urn:x\nnode_ids:\n AmrDockRequest: a\n", encoding="utf-8")
|
|
119
|
+
with pytest.raises(ValueError, match="AmrInPosition"):
|
|
120
|
+
load_signal_map(p)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_shipped_maps_load():
|
|
124
|
+
import pathlib
|
|
125
|
+
maps = sorted((pathlib.Path(__file__).parents[1] / "maps").glob("*.yaml"))
|
|
126
|
+
assert len(maps) == 3
|
|
127
|
+
for m in maps:
|
|
128
|
+
assert len(load_signal_map(m)["node_ids"]) == 8
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_reports():
|
|
132
|
+
results = list(check(only=["R1", "R2"]).values())
|
|
133
|
+
assert "[PASS] R1" in render(results, "opc.tcp://x") and "Station conforms." in render(results, "opc.tcp://x")
|
|
134
|
+
assert "| R2 |" in markdown(results, "opc.tcp://x")
|
|
135
|
+
assert json.dumps([r.__dict__ for r in results])
|