agrowell-ikh-client 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.
- agrowell_ikh_client-0.1.0/.gitignore +65 -0
- agrowell_ikh_client-0.1.0/LICENSE +29 -0
- agrowell_ikh_client-0.1.0/PKG-INFO +298 -0
- agrowell_ikh_client-0.1.0/README.md +235 -0
- agrowell_ikh_client-0.1.0/pyproject.toml +89 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/__init__.py +135 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/_version.py +3 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/api/__init__.py +28 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/api/anchor_groups.py +59 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/api/anchors.py +62 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/api/base.py +26 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/auth/__init__.py +15 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/auth/base.py +44 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/auth/client_credentials.py +130 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/auth/token_store.py +32 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/client.py +176 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/config.py +83 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/exceptions.py +80 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/geometry.py +51 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/hooks.py +36 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/models/__init__.py +1 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/models/anchors.py +43 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/models/common.py +198 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/models/localization_provider.py +73 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/py.typed +0 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/reporting/__init__.py +12 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/reporting/pdf.py +388 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/reporting/sinks.py +168 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/resources/__init__.py +7 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/resources/alignment.py +176 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/transport/__init__.py +8 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/transport/base.py +41 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/transport/httpx_transport.py +152 -0
- agrowell_ikh_client-0.1.0/src/agrowell_ikh_client/validation.py +638 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# These are some examples of commonly ignored file patterns.
|
|
2
|
+
# You should customize this list as applicable to your project.
|
|
3
|
+
# Learn more about .gitignore:
|
|
4
|
+
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
|
|
5
|
+
|
|
6
|
+
# Node artifact files
|
|
7
|
+
node_modules/
|
|
8
|
+
dist/
|
|
9
|
+
|
|
10
|
+
# Compiled Java class files
|
|
11
|
+
*.class
|
|
12
|
+
|
|
13
|
+
# Compiled Python bytecode
|
|
14
|
+
*.py[cod]
|
|
15
|
+
|
|
16
|
+
# Log files
|
|
17
|
+
*.log
|
|
18
|
+
|
|
19
|
+
# Package files
|
|
20
|
+
*.jar
|
|
21
|
+
|
|
22
|
+
# Maven
|
|
23
|
+
target/
|
|
24
|
+
dist/
|
|
25
|
+
|
|
26
|
+
# JetBrains IDE
|
|
27
|
+
.idea/
|
|
28
|
+
|
|
29
|
+
# Unit test reports
|
|
30
|
+
TEST*.xml
|
|
31
|
+
|
|
32
|
+
# Generated by MacOS
|
|
33
|
+
.DS_Store
|
|
34
|
+
|
|
35
|
+
# Generated by Windows
|
|
36
|
+
Thumbs.db
|
|
37
|
+
|
|
38
|
+
# Applications
|
|
39
|
+
*.app
|
|
40
|
+
*.exe
|
|
41
|
+
*.war
|
|
42
|
+
|
|
43
|
+
# Large media files
|
|
44
|
+
*.mp4
|
|
45
|
+
*.tiff
|
|
46
|
+
*.avi
|
|
47
|
+
*.flv
|
|
48
|
+
*.mov
|
|
49
|
+
*.wmv
|
|
50
|
+
|
|
51
|
+
# Python virtual environments
|
|
52
|
+
.venv/
|
|
53
|
+
venv/
|
|
54
|
+
.env
|
|
55
|
+
|
|
56
|
+
# Python build / test caches
|
|
57
|
+
__pycache__/
|
|
58
|
+
*.egg-info/
|
|
59
|
+
build/
|
|
60
|
+
.pytest_cache/
|
|
61
|
+
.mypy_cache/
|
|
62
|
+
.ruff_cache/
|
|
63
|
+
.coverage
|
|
64
|
+
htmlcov/
|
|
65
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, up2metric P.C.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of up2metric P.C. nor the names of its contributors
|
|
17
|
+
may be used to endorse or promote products derived from this software
|
|
18
|
+
without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agrowell-ikh-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK bridging the I Know How (IKH) robot with the Agro-Well greenhouse platform.
|
|
5
|
+
Project-URL: Homepage, https://up2metric.com
|
|
6
|
+
Project-URL: Repository, https://bitbucket.org/up2metricPC/agro-well-ikh-client
|
|
7
|
+
Project-URL: Issues, https://bitbucket.org/up2metricPC/agro-well-ikh-client/issues
|
|
8
|
+
Author-email: "up2metric P.C." <info@up2metric.com>
|
|
9
|
+
License: BSD 3-Clause License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026, up2metric P.C.
|
|
12
|
+
All rights reserved.
|
|
13
|
+
|
|
14
|
+
Redistribution and use in source and binary forms, with or without
|
|
15
|
+
modification, are permitted provided that the following conditions are met:
|
|
16
|
+
|
|
17
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
18
|
+
this list of conditions and the following disclaimer.
|
|
19
|
+
|
|
20
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
21
|
+
this list of conditions and the following disclaimer in the documentation
|
|
22
|
+
and/or other materials provided with the distribution.
|
|
23
|
+
|
|
24
|
+
3. Neither the name of up2metric P.C. nor the names of its contributors
|
|
25
|
+
may be used to endorse or promote products derived from this software
|
|
26
|
+
without specific prior written permission.
|
|
27
|
+
|
|
28
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
29
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
30
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
31
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
32
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
33
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
34
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
35
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
36
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
37
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
38
|
+
License-File: LICENSE
|
|
39
|
+
Keywords: agro-well,apriltag,keycloak,object-placement,robotics,sdk
|
|
40
|
+
Classifier: Development Status :: 3 - Alpha
|
|
41
|
+
Classifier: Intended Audience :: Developers
|
|
42
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
43
|
+
Classifier: Operating System :: OS Independent
|
|
44
|
+
Classifier: Programming Language :: Python :: 3
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
48
|
+
Classifier: Typing :: Typed
|
|
49
|
+
Requires-Python: >=3.10
|
|
50
|
+
Requires-Dist: fpdf2<3,>=2.8
|
|
51
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
52
|
+
Requires-Dist: pydantic-settings<3,>=2.3
|
|
53
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
54
|
+
Provides-Extra: dev
|
|
55
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
56
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
57
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
58
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
59
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
60
|
+
Provides-Extra: minio
|
|
61
|
+
Requires-Dist: minio>=7.2; extra == 'minio'
|
|
62
|
+
Description-Content-Type: text/markdown
|
|
63
|
+
|
|
64
|
+
# agrowell-ikh-client
|
|
65
|
+
|
|
66
|
+
A small, typed Python SDK that bridges the **I Know How (IKH)** robot with the
|
|
67
|
+
**Agro-Well** greenhouse platform.
|
|
68
|
+
|
|
69
|
+
**v1 scope:**
|
|
70
|
+
|
|
71
|
+
1. **Authenticate** to the platform via Keycloak (client-credentials / machine-to-machine).
|
|
72
|
+
2. **Read anchors** — resolve a detected AprilTag to its platform anchor (and its AR pose).
|
|
73
|
+
3. **Commission (align)** — from a reference anchor, compute the anchor-group transform that
|
|
74
|
+
maps the robot's **ROS** measurements onto the platform **AR** scene, write it, and emit a
|
|
75
|
+
quantitative **validation report**. The ROS ↔ Three.js conversion happens **internally**.
|
|
76
|
+
|
|
77
|
+
> **Status:** v1 boilerplate. Some platform-side prerequisites must be confirmed before
|
|
78
|
+
> live integration — see [Open items](#open-items). Open-source under the
|
|
79
|
+
> [BSD 3-Clause License](LICENSE).
|
|
80
|
+
|
|
81
|
+
## Installation
|
|
82
|
+
|
|
83
|
+
Released under the [BSD 3-Clause License](#license). Install from PyPI:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install agrowell-ikh-client
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or from a built wheel:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install ./agrowell_ikh_client-<version>-py3-none-any.whl
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Requires Python 3.10+.
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
Read from `AGROWELL_`-prefixed environment variables (and an optional `.env` file), or
|
|
100
|
+
passed explicitly via `Settings`. Copy [`.env.example`](.env.example) to `.env` and fill
|
|
101
|
+
in the values from the Agro-Well platform team.
|
|
102
|
+
|
|
103
|
+
| Variable | Required | Default | Description |
|
|
104
|
+
|---|---|---|---|
|
|
105
|
+
| `AGROWELL_KEYCLOAK_BASE_PATH` | ✅ | — | Keycloak base URL |
|
|
106
|
+
| `AGROWELL_KEYCLOAK_REALM` | | `AGRO-WELL` | Keycloak realm |
|
|
107
|
+
| `AGROWELL_KEYCLOAK_CLIENT_ID` | ✅ | — | Service-account client id |
|
|
108
|
+
| `AGROWELL_KEYCLOAK_CLIENT_SECRET` | ✅ | — | Service-account client secret |
|
|
109
|
+
| `AGROWELL_API_BASE_URL` | ✅ | — | object-placement REST base URL |
|
|
110
|
+
| `AGROWELL_API_PATH_PREFIX` | | `/v1` | API path prefix (`/v1` or `/api/v1`) |
|
|
111
|
+
| `AGROWELL_ORGANIZATION_ID` | ✅ | — | Organization the robot belongs to (scopes all anchor reads/writes) |
|
|
112
|
+
| `AGROWELL_FACILITY_ID` | ✅ | — | Facility the robot is installed at (== platform scene id; scopes every read to that facility) |
|
|
113
|
+
| `AGROWELL_DEV_MODE` | | `false` | Collect a validation report during commissioning and emit it on `close()` |
|
|
114
|
+
| `AGROWELL_VERIFY_SSL` | | `true` | TLS certificate verification |
|
|
115
|
+
| `AGROWELL_HTTP_TIMEOUT_SECONDS` | | `10.0` | Request timeout |
|
|
116
|
+
| `AGROWELL_HTTP_MAX_RETRIES` | | `3` | Retry budget for idempotent requests |
|
|
117
|
+
|
|
118
|
+
## Quickstart
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from agrowell_ikh_client import AgroWellClient, ScannedTag
|
|
122
|
+
|
|
123
|
+
with AgroWellClient.from_env() as client:
|
|
124
|
+
# Discover the anchors visible to your organization (raw platform models, AR frame):
|
|
125
|
+
anchors = client.api.anchors.list()
|
|
126
|
+
|
|
127
|
+
# Commission: on each AprilTag detection, pass the robot's measured pose (ROS frame).
|
|
128
|
+
# The SDK resolves the anchor, computes the anchor-group transform, and writes it.
|
|
129
|
+
for detection in detections: # your detector's per-frame loop
|
|
130
|
+
client.alignment.update_group(
|
|
131
|
+
ScannedTag(
|
|
132
|
+
apriltag_id=detection.id,
|
|
133
|
+
translation=(1.20, 0.0, 3.45), # metres, ROS frame
|
|
134
|
+
quaternion=(0.0, 0.0, 0.0, 1.0), # (x, y, z, w)
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The robot needs only its **organization** (from config) and the **AprilTag ids** it scans.
|
|
140
|
+
It never handles anchor UUIDs, anchors-groups, scenes, or raw 4×4 matrices — those are
|
|
141
|
+
resolved or converted internally. Poses cross the SDK boundary in the **ROS** frame; the
|
|
142
|
+
raw reads under `client.api.*` return platform models in the **AR (Three.js)** frame.
|
|
143
|
+
|
|
144
|
+
## The rigid-isometry property (why the between-anchor check works)
|
|
145
|
+
|
|
146
|
+
The internal ROS → Three.js conversion is a **rigid isometry** — a pure rotation of axes
|
|
147
|
+
(`det = +1`, no scale or handedness flip), so distances and angles between anchors are
|
|
148
|
+
preserved. The commissioning report exploits this: in the relative transform between two
|
|
149
|
+
anchors the anchor-group transform `G` cancels, so a residual there can only come from the
|
|
150
|
+
ROS ↔ AR conversion itself (a handedness/axis flip or a metre/centimetre mix-up), not from a
|
|
151
|
+
single mis-placed anchor. That is what makes the between-anchor check a clean, objective
|
|
152
|
+
signal even though there is no visual validation. (See `tests/test_simulation.py`.)
|
|
153
|
+
|
|
154
|
+
## Commissioning (alignment + validation)
|
|
155
|
+
|
|
156
|
+
After the 3 anchors are AR-calibrated in the web app, the robot aligns its ROS frame to the
|
|
157
|
+
scene by writing the **anchor-group transform**. On **each** AprilTag detection it calls
|
|
158
|
+
`update_group`, which resolves the anchor's AR pose, computes the group transform `G` from
|
|
159
|
+
that AR pose and the detected ROS pose (`G = T_ar ∘ inverse(convert(T_ros))`), and PATCHes the
|
|
160
|
+
group. This runs many times during a commissioning session.
|
|
161
|
+
|
|
162
|
+
Because updating that transform moves the whole scene subtree together, there is **no visual
|
|
163
|
+
validation**. So, with `AGROWELL_DEV_MODE=true`, the client collects each anchor's server
|
|
164
|
+
("before") and ROS-computed ("after") pose and emits one **validation report** on `close()`.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from agrowell_ikh_client import AgroWellClient, ScannedTag, ObjectStoreSink
|
|
168
|
+
|
|
169
|
+
# AGROWELL_DEV_MODE=true enables the report; the sink is where it is uploaded.
|
|
170
|
+
# Sinks: ObjectStoreSink (presigned URL), MinioSink.from_settings(settings) (direct MinIO/S3,
|
|
171
|
+
# needs the 'minio' extra), or LocalFileSink (disk).
|
|
172
|
+
with AgroWellClient.builder().with_report_sink(
|
|
173
|
+
ObjectStoreSink(presign=mint_upload_url) # mint_upload_url(key) -> presigned PUT URL
|
|
174
|
+
).build() as client:
|
|
175
|
+
for tag in detections: # the robot's per-detection loop
|
|
176
|
+
client.alignment.update_group(
|
|
177
|
+
ScannedTag(tag.id, tag.translation, tag.quaternion) # ROS frame
|
|
178
|
+
)
|
|
179
|
+
# on exit: one report (per-anchor errors + between-anchor conversion check) is uploaded
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The report (`ValidationReport`, render with `report.to_text()`) carries per-anchor
|
|
183
|
+
position/orientation errors and aggregates, **plus a pairwise (between-anchor) check that the
|
|
184
|
+
ROS → AR conversion holds** — in the relative transform between two anchors `G` cancels, so it
|
|
185
|
+
isolates conversion errors (handedness/axis flips, metre/centimetre mix-ups) from a single
|
|
186
|
+
mis-placed anchor. Try it offline: `python examples/simulate_commissioning.py`.
|
|
187
|
+
|
|
188
|
+
## Usage
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
# Raw request layer, grouped by resource category (client.api.<category>).
|
|
192
|
+
|
|
193
|
+
# List the anchors visible to your organization (raw platform models, AR frame).
|
|
194
|
+
# No group/scene needed; the org context scopes the result.
|
|
195
|
+
anchors = client.api.anchors.list() # or .list(registered=False)
|
|
196
|
+
|
|
197
|
+
# Resolve a single detected tag to its anchor. Raises AnchorNotFoundError if none match,
|
|
198
|
+
# AmbiguousAnchorError if more than one does:
|
|
199
|
+
anchor = client.api.anchors.resolve_by_tag(42)
|
|
200
|
+
if anchor.image_anchor and anchor.image_anchor.transform:
|
|
201
|
+
x, y, z = anchor.image_anchor.transform.translation # AR (Three.js) frame
|
|
202
|
+
|
|
203
|
+
# Commission: per detection, compute + write the anchor-group transform from a ROS pose.
|
|
204
|
+
client.alignment.update_group(
|
|
205
|
+
ScannedTag(42, translation=(1.2, 0.0, 3.45), quaternion=(0.0, 0.0, 0.0, 1.0))
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Coordinate frames (internal)
|
|
210
|
+
|
|
211
|
+
The robot speaks only **ROS** (REP-103: X-forward, Y-left, Z-up, right-handed, metres,
|
|
212
|
+
quaternions). The platform stores transforms in the **Three.js** AR frame (right-handed,
|
|
213
|
+
Y-up). The SDK converts at the boundary automatically (`ROS_X = −AR_Z`, `ROS_Y = −AR_X`,
|
|
214
|
+
`ROS_Z = AR_Y`); it is not a user-facing option. If the platform's AR engine ever changes,
|
|
215
|
+
that is a one-line internal change.
|
|
216
|
+
|
|
217
|
+
### Error handling
|
|
218
|
+
|
|
219
|
+
All errors derive from `AgroWellError`. HTTP errors map to typed exceptions
|
|
220
|
+
(`BadRequestError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`, …),
|
|
221
|
+
each carrying `status_code`, `response_body`, and `request_url`. `AnchorNotFoundError` is
|
|
222
|
+
raised when no anchor matches a tag, and `AmbiguousAnchorError` when more than one does.
|
|
223
|
+
|
|
224
|
+
### Advanced: dependency injection
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
client = (
|
|
228
|
+
AgroWellClient.builder()
|
|
229
|
+
.with_settings(settings)
|
|
230
|
+
.add_request_hook(my_tracing_hook)
|
|
231
|
+
.with_token_store(my_token_store)
|
|
232
|
+
.build()
|
|
233
|
+
)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Architecture
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
AgroWellClient (facade)
|
|
240
|
+
├─ api/ ObjectPlacementApi → .anchors, .anchor_groups (raw request layer, by category)
|
|
241
|
+
└─ alignment commissioning workflow (composes api: read anchor → compute G → write group)
|
|
242
|
+
│
|
|
243
|
+
└─> transport/ (httpx) ──> auth/ + models/
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- `api/` is the **single reusable request layer**, grouped by resource category
|
|
247
|
+
(`client.api.anchors`, `client.api.anchor_groups`); domain resources like
|
|
248
|
+
`resources/alignment` compose it rather than issuing requests directly, so each endpoint
|
|
249
|
+
lives in exactly one place and a new category is one module plus one line.
|
|
250
|
+
- `Transport`, `AuthStrategy`, `TokenStore` are `typing.Protocol`s (dependency inversion):
|
|
251
|
+
implementations swap and fake in tests without touching call sites.
|
|
252
|
+
- **Sync today, async-ready:** the synchronous `HttpxTransport` sits behind the `Transport`
|
|
253
|
+
seam, so an async transport can be added additively later.
|
|
254
|
+
- Logging uses a `NullHandler`; nothing is emitted unless your application configures it.
|
|
255
|
+
- Pure math (`geometry.py`, `validation.py`) has no I/O; the validation report uploads via a
|
|
256
|
+
pluggable `ReportSink` (`reporting/sinks.py`).
|
|
257
|
+
|
|
258
|
+
## Development
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
make install # uv sync --extra dev (or: python3.11 -m venv .venv && .venv/bin/pip install -e ".[dev]")
|
|
262
|
+
make check # ruff + mypy (strict) + pytest
|
|
263
|
+
pytest -m integration # live smoke test (requires AGROWELL_* env)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Tests use `respx` to mock httpx and a `FakeTransport` (the `Transport` Protocol) for pure
|
|
267
|
+
unit tests — no network and no ROS install required.
|
|
268
|
+
|
|
269
|
+
## Open items
|
|
270
|
+
|
|
271
|
+
These do not block using the SDK, but must be resolved for live integration.
|
|
272
|
+
|
|
273
|
+
1. **Organization is mandatory:** every anchor read/write is scoped to an organization. The
|
|
274
|
+
SDK sends `Grpc-Metadata-organization` (`AGROWELL_ORGANIZATION_ID`, required) — **and** the
|
|
275
|
+
Keycloak token must carry a matching `organization` claim, or the platform returns
|
|
276
|
+
`ErrOrgHeaderMissing` / `ErrOrgMembershipDenied`.
|
|
277
|
+
2. **Group WRITE needs a backend change (blocker):** anchor reads accept the robot's
|
|
278
|
+
client-credentials token, but `UpdateAnchorsGroup` (the commissioning write) currently
|
|
279
|
+
requires a *user* token. The platform must relax that RPC to accept machine tokens (org
|
|
280
|
+
kept) before the robot can write the group transform.
|
|
281
|
+
3. **Report upload:** `ObjectStoreSink` PUTs the report to a presigned URL; a backend endpoint
|
|
282
|
+
must mint it (so no object-store credentials live on the robot).
|
|
283
|
+
4. **Confirm conventions:** `localization_provider_id` vs `anchor.anchors_group_id` for the
|
|
284
|
+
PATCH, and the `TransformMatrix` orientation / multiplication order (verify with one real
|
|
285
|
+
anchor round-trip).
|
|
286
|
+
5. **Anchors must pre-exist** and be AR-calibrated (with `apriltag_id`s) for the organization.
|
|
287
|
+
6. **API prefix:** confirm `/v1` vs `/api/v1` (`AGROWELL_API_PATH_PREFIX`).
|
|
288
|
+
7. **Keycloak client:** a service-account client in realm `AGRO-WELL` (+ secret).
|
|
289
|
+
8. **Distribution:** released under the [BSD 3-Clause License](LICENSE) and publishable to
|
|
290
|
+
public PyPI. Scrub the repo (and git history) for secrets / internal hosts before any
|
|
291
|
+
public release — see `.env` (gitignored) and `examples/`.
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
**BSD 3-Clause License.** Copyright (c) 2026 up2metric P.C. See [`LICENSE`](LICENSE).
|
|
296
|
+
Redistribution and use in source and binary forms, with or without modification, are
|
|
297
|
+
permitted provided the copyright notice and the conditions in `LICENSE` are retained. For
|
|
298
|
+
inquiries, contact info@up2metric.com.
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# agrowell-ikh-client
|
|
2
|
+
|
|
3
|
+
A small, typed Python SDK that bridges the **I Know How (IKH)** robot with the
|
|
4
|
+
**Agro-Well** greenhouse platform.
|
|
5
|
+
|
|
6
|
+
**v1 scope:**
|
|
7
|
+
|
|
8
|
+
1. **Authenticate** to the platform via Keycloak (client-credentials / machine-to-machine).
|
|
9
|
+
2. **Read anchors** — resolve a detected AprilTag to its platform anchor (and its AR pose).
|
|
10
|
+
3. **Commission (align)** — from a reference anchor, compute the anchor-group transform that
|
|
11
|
+
maps the robot's **ROS** measurements onto the platform **AR** scene, write it, and emit a
|
|
12
|
+
quantitative **validation report**. The ROS ↔ Three.js conversion happens **internally**.
|
|
13
|
+
|
|
14
|
+
> **Status:** v1 boilerplate. Some platform-side prerequisites must be confirmed before
|
|
15
|
+
> live integration — see [Open items](#open-items). Open-source under the
|
|
16
|
+
> [BSD 3-Clause License](LICENSE).
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Released under the [BSD 3-Clause License](#license). Install from PyPI:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install agrowell-ikh-client
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or from a built wheel:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install ./agrowell_ikh_client-<version>-py3-none-any.whl
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.10+.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
Read from `AGROWELL_`-prefixed environment variables (and an optional `.env` file), or
|
|
37
|
+
passed explicitly via `Settings`. Copy [`.env.example`](.env.example) to `.env` and fill
|
|
38
|
+
in the values from the Agro-Well platform team.
|
|
39
|
+
|
|
40
|
+
| Variable | Required | Default | Description |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| `AGROWELL_KEYCLOAK_BASE_PATH` | ✅ | — | Keycloak base URL |
|
|
43
|
+
| `AGROWELL_KEYCLOAK_REALM` | | `AGRO-WELL` | Keycloak realm |
|
|
44
|
+
| `AGROWELL_KEYCLOAK_CLIENT_ID` | ✅ | — | Service-account client id |
|
|
45
|
+
| `AGROWELL_KEYCLOAK_CLIENT_SECRET` | ✅ | — | Service-account client secret |
|
|
46
|
+
| `AGROWELL_API_BASE_URL` | ✅ | — | object-placement REST base URL |
|
|
47
|
+
| `AGROWELL_API_PATH_PREFIX` | | `/v1` | API path prefix (`/v1` or `/api/v1`) |
|
|
48
|
+
| `AGROWELL_ORGANIZATION_ID` | ✅ | — | Organization the robot belongs to (scopes all anchor reads/writes) |
|
|
49
|
+
| `AGROWELL_FACILITY_ID` | ✅ | — | Facility the robot is installed at (== platform scene id; scopes every read to that facility) |
|
|
50
|
+
| `AGROWELL_DEV_MODE` | | `false` | Collect a validation report during commissioning and emit it on `close()` |
|
|
51
|
+
| `AGROWELL_VERIFY_SSL` | | `true` | TLS certificate verification |
|
|
52
|
+
| `AGROWELL_HTTP_TIMEOUT_SECONDS` | | `10.0` | Request timeout |
|
|
53
|
+
| `AGROWELL_HTTP_MAX_RETRIES` | | `3` | Retry budget for idempotent requests |
|
|
54
|
+
|
|
55
|
+
## Quickstart
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from agrowell_ikh_client import AgroWellClient, ScannedTag
|
|
59
|
+
|
|
60
|
+
with AgroWellClient.from_env() as client:
|
|
61
|
+
# Discover the anchors visible to your organization (raw platform models, AR frame):
|
|
62
|
+
anchors = client.api.anchors.list()
|
|
63
|
+
|
|
64
|
+
# Commission: on each AprilTag detection, pass the robot's measured pose (ROS frame).
|
|
65
|
+
# The SDK resolves the anchor, computes the anchor-group transform, and writes it.
|
|
66
|
+
for detection in detections: # your detector's per-frame loop
|
|
67
|
+
client.alignment.update_group(
|
|
68
|
+
ScannedTag(
|
|
69
|
+
apriltag_id=detection.id,
|
|
70
|
+
translation=(1.20, 0.0, 3.45), # metres, ROS frame
|
|
71
|
+
quaternion=(0.0, 0.0, 0.0, 1.0), # (x, y, z, w)
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The robot needs only its **organization** (from config) and the **AprilTag ids** it scans.
|
|
77
|
+
It never handles anchor UUIDs, anchors-groups, scenes, or raw 4×4 matrices — those are
|
|
78
|
+
resolved or converted internally. Poses cross the SDK boundary in the **ROS** frame; the
|
|
79
|
+
raw reads under `client.api.*` return platform models in the **AR (Three.js)** frame.
|
|
80
|
+
|
|
81
|
+
## The rigid-isometry property (why the between-anchor check works)
|
|
82
|
+
|
|
83
|
+
The internal ROS → Three.js conversion is a **rigid isometry** — a pure rotation of axes
|
|
84
|
+
(`det = +1`, no scale or handedness flip), so distances and angles between anchors are
|
|
85
|
+
preserved. The commissioning report exploits this: in the relative transform between two
|
|
86
|
+
anchors the anchor-group transform `G` cancels, so a residual there can only come from the
|
|
87
|
+
ROS ↔ AR conversion itself (a handedness/axis flip or a metre/centimetre mix-up), not from a
|
|
88
|
+
single mis-placed anchor. That is what makes the between-anchor check a clean, objective
|
|
89
|
+
signal even though there is no visual validation. (See `tests/test_simulation.py`.)
|
|
90
|
+
|
|
91
|
+
## Commissioning (alignment + validation)
|
|
92
|
+
|
|
93
|
+
After the 3 anchors are AR-calibrated in the web app, the robot aligns its ROS frame to the
|
|
94
|
+
scene by writing the **anchor-group transform**. On **each** AprilTag detection it calls
|
|
95
|
+
`update_group`, which resolves the anchor's AR pose, computes the group transform `G` from
|
|
96
|
+
that AR pose and the detected ROS pose (`G = T_ar ∘ inverse(convert(T_ros))`), and PATCHes the
|
|
97
|
+
group. This runs many times during a commissioning session.
|
|
98
|
+
|
|
99
|
+
Because updating that transform moves the whole scene subtree together, there is **no visual
|
|
100
|
+
validation**. So, with `AGROWELL_DEV_MODE=true`, the client collects each anchor's server
|
|
101
|
+
("before") and ROS-computed ("after") pose and emits one **validation report** on `close()`.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from agrowell_ikh_client import AgroWellClient, ScannedTag, ObjectStoreSink
|
|
105
|
+
|
|
106
|
+
# AGROWELL_DEV_MODE=true enables the report; the sink is where it is uploaded.
|
|
107
|
+
# Sinks: ObjectStoreSink (presigned URL), MinioSink.from_settings(settings) (direct MinIO/S3,
|
|
108
|
+
# needs the 'minio' extra), or LocalFileSink (disk).
|
|
109
|
+
with AgroWellClient.builder().with_report_sink(
|
|
110
|
+
ObjectStoreSink(presign=mint_upload_url) # mint_upload_url(key) -> presigned PUT URL
|
|
111
|
+
).build() as client:
|
|
112
|
+
for tag in detections: # the robot's per-detection loop
|
|
113
|
+
client.alignment.update_group(
|
|
114
|
+
ScannedTag(tag.id, tag.translation, tag.quaternion) # ROS frame
|
|
115
|
+
)
|
|
116
|
+
# on exit: one report (per-anchor errors + between-anchor conversion check) is uploaded
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The report (`ValidationReport`, render with `report.to_text()`) carries per-anchor
|
|
120
|
+
position/orientation errors and aggregates, **plus a pairwise (between-anchor) check that the
|
|
121
|
+
ROS → AR conversion holds** — in the relative transform between two anchors `G` cancels, so it
|
|
122
|
+
isolates conversion errors (handedness/axis flips, metre/centimetre mix-ups) from a single
|
|
123
|
+
mis-placed anchor. Try it offline: `python examples/simulate_commissioning.py`.
|
|
124
|
+
|
|
125
|
+
## Usage
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
# Raw request layer, grouped by resource category (client.api.<category>).
|
|
129
|
+
|
|
130
|
+
# List the anchors visible to your organization (raw platform models, AR frame).
|
|
131
|
+
# No group/scene needed; the org context scopes the result.
|
|
132
|
+
anchors = client.api.anchors.list() # or .list(registered=False)
|
|
133
|
+
|
|
134
|
+
# Resolve a single detected tag to its anchor. Raises AnchorNotFoundError if none match,
|
|
135
|
+
# AmbiguousAnchorError if more than one does:
|
|
136
|
+
anchor = client.api.anchors.resolve_by_tag(42)
|
|
137
|
+
if anchor.image_anchor and anchor.image_anchor.transform:
|
|
138
|
+
x, y, z = anchor.image_anchor.transform.translation # AR (Three.js) frame
|
|
139
|
+
|
|
140
|
+
# Commission: per detection, compute + write the anchor-group transform from a ROS pose.
|
|
141
|
+
client.alignment.update_group(
|
|
142
|
+
ScannedTag(42, translation=(1.2, 0.0, 3.45), quaternion=(0.0, 0.0, 0.0, 1.0))
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Coordinate frames (internal)
|
|
147
|
+
|
|
148
|
+
The robot speaks only **ROS** (REP-103: X-forward, Y-left, Z-up, right-handed, metres,
|
|
149
|
+
quaternions). The platform stores transforms in the **Three.js** AR frame (right-handed,
|
|
150
|
+
Y-up). The SDK converts at the boundary automatically (`ROS_X = −AR_Z`, `ROS_Y = −AR_X`,
|
|
151
|
+
`ROS_Z = AR_Y`); it is not a user-facing option. If the platform's AR engine ever changes,
|
|
152
|
+
that is a one-line internal change.
|
|
153
|
+
|
|
154
|
+
### Error handling
|
|
155
|
+
|
|
156
|
+
All errors derive from `AgroWellError`. HTTP errors map to typed exceptions
|
|
157
|
+
(`BadRequestError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`, …),
|
|
158
|
+
each carrying `status_code`, `response_body`, and `request_url`. `AnchorNotFoundError` is
|
|
159
|
+
raised when no anchor matches a tag, and `AmbiguousAnchorError` when more than one does.
|
|
160
|
+
|
|
161
|
+
### Advanced: dependency injection
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
client = (
|
|
165
|
+
AgroWellClient.builder()
|
|
166
|
+
.with_settings(settings)
|
|
167
|
+
.add_request_hook(my_tracing_hook)
|
|
168
|
+
.with_token_store(my_token_store)
|
|
169
|
+
.build()
|
|
170
|
+
)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Architecture
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
AgroWellClient (facade)
|
|
177
|
+
├─ api/ ObjectPlacementApi → .anchors, .anchor_groups (raw request layer, by category)
|
|
178
|
+
└─ alignment commissioning workflow (composes api: read anchor → compute G → write group)
|
|
179
|
+
│
|
|
180
|
+
└─> transport/ (httpx) ──> auth/ + models/
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- `api/` is the **single reusable request layer**, grouped by resource category
|
|
184
|
+
(`client.api.anchors`, `client.api.anchor_groups`); domain resources like
|
|
185
|
+
`resources/alignment` compose it rather than issuing requests directly, so each endpoint
|
|
186
|
+
lives in exactly one place and a new category is one module plus one line.
|
|
187
|
+
- `Transport`, `AuthStrategy`, `TokenStore` are `typing.Protocol`s (dependency inversion):
|
|
188
|
+
implementations swap and fake in tests without touching call sites.
|
|
189
|
+
- **Sync today, async-ready:** the synchronous `HttpxTransport` sits behind the `Transport`
|
|
190
|
+
seam, so an async transport can be added additively later.
|
|
191
|
+
- Logging uses a `NullHandler`; nothing is emitted unless your application configures it.
|
|
192
|
+
- Pure math (`geometry.py`, `validation.py`) has no I/O; the validation report uploads via a
|
|
193
|
+
pluggable `ReportSink` (`reporting/sinks.py`).
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
make install # uv sync --extra dev (or: python3.11 -m venv .venv && .venv/bin/pip install -e ".[dev]")
|
|
199
|
+
make check # ruff + mypy (strict) + pytest
|
|
200
|
+
pytest -m integration # live smoke test (requires AGROWELL_* env)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Tests use `respx` to mock httpx and a `FakeTransport` (the `Transport` Protocol) for pure
|
|
204
|
+
unit tests — no network and no ROS install required.
|
|
205
|
+
|
|
206
|
+
## Open items
|
|
207
|
+
|
|
208
|
+
These do not block using the SDK, but must be resolved for live integration.
|
|
209
|
+
|
|
210
|
+
1. **Organization is mandatory:** every anchor read/write is scoped to an organization. The
|
|
211
|
+
SDK sends `Grpc-Metadata-organization` (`AGROWELL_ORGANIZATION_ID`, required) — **and** the
|
|
212
|
+
Keycloak token must carry a matching `organization` claim, or the platform returns
|
|
213
|
+
`ErrOrgHeaderMissing` / `ErrOrgMembershipDenied`.
|
|
214
|
+
2. **Group WRITE needs a backend change (blocker):** anchor reads accept the robot's
|
|
215
|
+
client-credentials token, but `UpdateAnchorsGroup` (the commissioning write) currently
|
|
216
|
+
requires a *user* token. The platform must relax that RPC to accept machine tokens (org
|
|
217
|
+
kept) before the robot can write the group transform.
|
|
218
|
+
3. **Report upload:** `ObjectStoreSink` PUTs the report to a presigned URL; a backend endpoint
|
|
219
|
+
must mint it (so no object-store credentials live on the robot).
|
|
220
|
+
4. **Confirm conventions:** `localization_provider_id` vs `anchor.anchors_group_id` for the
|
|
221
|
+
PATCH, and the `TransformMatrix` orientation / multiplication order (verify with one real
|
|
222
|
+
anchor round-trip).
|
|
223
|
+
5. **Anchors must pre-exist** and be AR-calibrated (with `apriltag_id`s) for the organization.
|
|
224
|
+
6. **API prefix:** confirm `/v1` vs `/api/v1` (`AGROWELL_API_PATH_PREFIX`).
|
|
225
|
+
7. **Keycloak client:** a service-account client in realm `AGRO-WELL` (+ secret).
|
|
226
|
+
8. **Distribution:** released under the [BSD 3-Clause License](LICENSE) and publishable to
|
|
227
|
+
public PyPI. Scrub the repo (and git history) for secrets / internal hosts before any
|
|
228
|
+
public release — see `.env` (gitignored) and `examples/`.
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
**BSD 3-Clause License.** Copyright (c) 2026 up2metric P.C. See [`LICENSE`](LICENSE).
|
|
233
|
+
Redistribution and use in source and binary forms, with or without modification, are
|
|
234
|
+
permitted provided the copyright notice and the conditions in `LICENSE` are retained. For
|
|
235
|
+
inquiries, contact info@up2metric.com.
|