added motion trajectory simulation
This commit is contained in:
@@ -41,28 +41,23 @@ Because the atlas is a single island, a click maps to exactly one 3D point.
|
||||
An earlier revision of this note reported that the **yaw +-90 deg limit made a
|
||||
quarter of the patch unalignable**, worst residual 65.1 deg, only 47 of 61
|
||||
sampled points reachable. **That was a solver bug, not a property of the
|
||||
machine.** The corrected solver solves the real surface as:
|
||||
machine.**
|
||||
|
||||
| Envelope | Worst residual | Median | Within 0.01 deg | Within 0.5 deg |
|
||||
|---|---|---|---|---|
|
||||
| pitch 360 deg, **yaw +-90 deg** (the machine) | **0.21 deg** | 0.00 deg | **98.4%** | **100%** |
|
||||
| pitch 360 deg, yaw +-180 deg | 0.24 deg | 0.00 deg | — | 100% |
|
||||
A later revision then reported the residual as 0.21 deg worst with the yaw limit
|
||||
"not the constraint". **That was also wrong**, in the opposite direction: the
|
||||
limit itself was wrong. The bench confirmed the yaw stage turns a **full half-turn
|
||||
either way (+-180 deg)**, not +-90.
|
||||
|
||||
So **every** vertex of the sensing patch is reachable, and widening the yaw range
|
||||
buys essentially nothing (0.21 deg → 0.24 deg worst). The 0.21 deg worst case is
|
||||
a genuinely clamped vertex: the sweep's own minimum at that vertex is 0.211 deg
|
||||
at the yaw clamp, so the number is physical, not lost precision.
|
||||
With the corrected envelope, solved by closed form
|
||||
(see `inverse-kinematics.md`):
|
||||
|
||||
Why the yaw limit is not the constraint, which the earlier note had backwards:
|
||||
| Envelope | Worst residual | Within 0.5 deg | In range |
|
||||
|---|---|---|---|
|
||||
| pitch 360 deg, **yaw +-180 deg** (the machine) | **1.2e-06 deg** | 100% | **1032 / 1032** |
|
||||
|
||||
- The pitch stage turns a vector around a circle in the plane **perpendicular to
|
||||
the pitch axis**.
|
||||
- The yaw stage can reach a given great circle as long as the yawed normal is not
|
||||
parallel to the pitch axis. A normal parallel to the pitch axis after yawing
|
||||
would have to be perpendicular to the tool axis already, and the finger patch
|
||||
normals — which point along sensor +Y, mapping to platform **+Z**, the tool
|
||||
direction — are nothing of the sort.
|
||||
- Pitch travel is therefore what does the work, and the 360 deg stage provides it.
|
||||
Every vertex is reachable, and the residual is at the floating-point floor rather
|
||||
than at any clamp. The "0.21 deg worst, genuinely clamped vertex" claim above was
|
||||
an artefact of the wrong limit and no longer holds.
|
||||
|
||||
**No mounting change is required for reachability.** The three options the earlier
|
||||
note listed (re-mount, state a tolerance, index the plate) are all unnecessary on
|
||||
@@ -70,6 +65,12 @@ the reachability evidence. Whether they help *repeatability* — a shorter pitch
|
||||
move, a better-conditioned contact — is a separate bench question, not a
|
||||
kinematic one.
|
||||
|
||||
One thing the corrected solve did surface: **51 of 1032 vertices end inverted**,
|
||||
whichever closed-form branch is chosen, because the tool direction is
|
||||
antiparallel to the sensor's own up axis. That is the tool-polarity question, it
|
||||
needs the bench, and it is *not* visible in the residual. See
|
||||
`inverse-kinematics.md` → *Still open*.
|
||||
|
||||
## What was wrong before, and why it is worth recording
|
||||
|
||||
**Four** solver versions preceded this one, each wrong in a way that produced
|
||||
@@ -104,9 +105,11 @@ as a number rather than as a plausible pose.
|
||||
| `sensor/uv_flat.py` | flat image + pixel→3D table; gap pixels are `None` |
|
||||
| `motion/kinematics.py` | constrained search + measured residual; stage limits are parameters; singular case reported, not guessed |
|
||||
| `ui/sensor_panel.py` | picker, readouts, point table; refuses gap pixels with a reason; picked rows kept at full precision, not re-read from the 3-decimal display text |
|
||||
| `ui/pose_preview.py` | 3D preview coloured by residual, tool axis drawn; the legend is a sibling label, because text composited over the GL surface is unreadable once it overflows |
|
||||
| `ui/pose_preview.py` | 3D preview coloured by residual, tool axis drawn; the legend is a sibling label, because text composited over the GL surface is unreadable once it overflows. The scene, the palette and the framing rule are described in [finger-preview.md](finger-preview.md) |
|
||||
| `motion/kinematics.py` | closed-form solve, exact to 1.2e-06°; the sweep is kept only as the reference the tests check it against — see [inverse-kinematics.md](inverse-kinematics.md) |
|
||||
| workspace `finger` tab | wired, checked at 1440×900 and 1920×1080 in both themes |
|
||||
| startup | the remembered bundle loads automatically, so the tab comes up working instead of showing a filled-in path over two empty views |
|
||||
| `tools/preview_snapshot.py` | photographs the 3D pane with a real GL context, at rest or mid-playback, pane-only or composited into the page |
|
||||
|
||||
Not yet built: capture of picked points into the sequence, and the live-sensor
|
||||
counterpart of the simulated pose.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# The finger screen's 3D view
|
||||
|
||||
What the picture draws, what each colour means, and which of those meanings is a
|
||||
measurement rather than a convenience. Written because the view had grown two
|
||||
coordinate systems, a path, three markers and four competing hues, and the question
|
||||
"which line is which?" was being answered from memory.
|
||||
|
||||
## What is in the scene
|
||||
|
||||
| Element | Colour | What it is |
|
||||
| --- | --- | --- |
|
||||
| Sensing surface | green → red, shaded per vertex | **The measurement.** Green is an aligned normal, red is `BAD_RESIDUAL_DEG` (20°) or worse of residual. The shading stays on during playback. |
|
||||
| Shell | dark grey wireframe | The housing mesh from the export, drawn so the face has a body to belong to. |
|
||||
| Origin plane | grey grid, translucent | The platform's `z = 0` plane, i.e. the plane the two rotation axes lie in. **This is the initial state and it never moves.** |
|
||||
| Table marker | violet ring + cross-hairs | The table's *own* frame as it now stands: the stages' rotation and the table's XY applied to a circle drawn in the origin plane. While a move has not finished, a fainter ring shows where it ends up. Square grid = before, violet ring = now, faint ring = after. |
|
||||
| Table triad | red / green / blue, full strength | The machine's X/Y/Z, drawn at the **table origin**. The only full-strength triad in the picture, so "the machine" is readable at a glance. |
|
||||
| Rotation axes | grey, long | The yaw and pitch axes. They must cross at the one origin; a misalignment shows as the two lines missing each other instead of hiding inside the body. |
|
||||
| Origin | orange dot | The point the stages turn and the tables translate *reference to*. |
|
||||
| Base plane | amber outline | The sensor's mounting face: part of the **body**, so it turns with the stages and rides the table. Its height in the initial state is the sensor's z setup offset — the mounting plate. |
|
||||
| Tool axis | yellow line, arrowhead | The `+Z` direction the sensing face has to meet, drawn at the tool's own position in the table frame. **It does not follow the contact**: the tool is bolted to the machine and the table brings the point to it. |
|
||||
| Planned path | cyan line with arrowheads, cyan end dot | The contact's route: the stage rotation arc about the table origin, then the straight table move. |
|
||||
| Picked contact | white dot | The point that was picked on the UV map. It travels onto the tool during playback. |
|
||||
| Contact normal | blue line, arrowhead | The picked vertex's normal. It is what the solved rotation aims at the tool axis. |
|
||||
| Selected point | red dot | The point selected in the table below — the one a run would start from. |
|
||||
|
||||
## Setup: where the tool and the sensor actually are
|
||||
|
||||
Three numbers that are the same on the drawing and never the same on the bench. All
|
||||
three default to zero, and all three have to be measured on the real machine:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| **Tool X / Y** | The tool's position in the table frame. Every XY move exists to bring the contact here, so this is what the reported move is measured against. It used to be taken as the patch's centre — a stand-in that made the picture self-consistent and the numbers meaningless. **A tool-tip offset is this field**, not a second one. It *was* a second one, added to the reported coordinates while the drawing left the geometry alone: a machine with a 10 mm tip offset reported one landing point and drew another, 10 mm away. A stored `finger_tool_offset_mm` is folded in here when preferences load. |
|
||||
| **Sensor X / Y** | How far the mounted sensor is from the table origin, along the table's own axes. |
|
||||
| **Sensor Z / yaw** | The mounting height — **this is the mounting plate**, shims included, so there is no separate plate field — and the sensor's misalignment with the table about its z. |
|
||||
|
||||
### What the screen shows at rest, and what playing it does
|
||||
|
||||
The screen is a solve view, so **at rest everything stands at the end of the move**:
|
||||
the body, the contact marker, the tool's position and the table marker. The fixed
|
||||
grid is the frame they started in and the cyan path is drawn from where the contact
|
||||
was, so "will it reach, and where does the table finish" is answerable without
|
||||
watching anything. Pressing play runs the move from the initial state to that same
|
||||
end state — which is what makes the path checkable rather than decorative.
|
||||
|
||||
The one line at the bottom of the rail is the x-y check, printed as two pairs:
|
||||
|
||||
```
|
||||
落点 X/Y +10.000 +0.000 mm / 刀具 X/Y +10.000 +0.000 mm ✓ 已对准
|
||||
```
|
||||
|
||||
They are computed two different ways on purpose — the landing by pushing the contact
|
||||
through the *drawing's* transform, the tool from its setup number — so "x and y are
|
||||
aligned" is a measurement rather than a judgement about a picture. Note that the
|
||||
reported move is **not** simply `tool − platform position`: the stages turn the point
|
||||
about the origin first, so the move is `tool_xy − (R_pitch · R_yaw · p_table).xy`.
|
||||
When the two do not match, the line says so, with the distance.
|
||||
|
||||
A point of view worth knowing: at rest the sensing face is aimed along the tool axis,
|
||||
which can put the face on the far side of the body from the default camera. The tool
|
||||
axis is drawn with depth testing **off** for that reason — it is a reference line,
|
||||
and hiding it behind the body it is meant to align with is the opposite of useful.
|
||||
|
||||
The chain the solver and the drawing share is, in order: **mount** (sensor axes into
|
||||
table axes) → **setup** (the mounting pose above) → **stages** (the solved yaw and
|
||||
pitch, about the table origin) → **table** (the XY slide). A point of the sensor is
|
||||
therefore at
|
||||
|
||||
```
|
||||
p_table = R_z(sensor_yaw) · (mount · p_sensor) + sensor_offset
|
||||
p_final = R_pitch · R_yaw · p_table + (table_x, table_y, 0)
|
||||
```
|
||||
|
||||
and the solver reports the move that puts the contact at the tool:
|
||||
|
||||
```
|
||||
(table_x, table_y) = tool_xy − (R_pitch · R_yaw · p_table).xy
|
||||
```
|
||||
|
||||
Two consequences worth knowing, both covered by tests:
|
||||
|
||||
* the mounting yaw is **absorbed by the stage** — a sensor mounted 10° out needs the
|
||||
yaw stage to turn 10° less, which is why the field exists;
|
||||
* the mounting z is a lift along the table's z, which the mounting yaw leaves alone,
|
||||
which is why the plate can live in that same field instead of being a second
|
||||
number that drifts away from it.
|
||||
|
||||
`Pose.target_xy_mm` says where the contact *is*; `Pose.table_xy_mm` says what the
|
||||
table must *do* about it. The panel shows both, and the trajectory summary is the
|
||||
same claim again: the contact's last drawn position **is** the tool, to within
|
||||
1e-6 mm (`test_the_path_ends_where_the_pose_says_the_table_moves_to`).
|
||||
|
||||
## The path, and where it starts
|
||||
|
||||
`trajectory()` returns the contact's route in the order the machine moves it:
|
||||
|
||||
1. **rotation** — the stages turn the mounted body about the table origin, sweeping
|
||||
the contact along an arc whose radius is the contact's distance from that origin
|
||||
(reported as `arc_radius_mm`);
|
||||
2. **translation** — the table slides the contact onto the tool axis, in the table's
|
||||
own XY, in a straight line (reported as `reach_mm`, the total distance travelled).
|
||||
|
||||
Nothing in the path is *at* the table origin. The origin is what the stages turn
|
||||
about; the path is traced by a point on the body, so it starts at the picked contact
|
||||
and ends under the tool. The two numbers a reader needs in order to judge the path —
|
||||
how far the contact travels, and how large the swing is — are printed beside the
|
||||
readouts.
|
||||
|
||||
The palette lives in one place, `pose_preview.COLOUR`, with a rule the view used to
|
||||
break: **a colour identifies exactly one thing**. Two things sharing a hue read as a
|
||||
duplicate or a contradiction, which is what "messy" turned out to mean. An import
|
||||
check (`_check_palette_has_no_collisions`) refuses a duplicate, and
|
||||
`set_colour`/`set_rgb` accept either 3- or 4-channel entries so the length of a
|
||||
palette tuple can never abort a paint again — it did once, and silently deleted the
|
||||
base plane, the contact and the whole trajectory from the view.
|
||||
|
||||
## Two ways the picture can lie
|
||||
|
||||
Both were real, and both looked like a plausible picture rather than a fault:
|
||||
|
||||
* **A half-drawn scene.** A `TypeError` from a colour call inside `paintGL` abandons
|
||||
every draw call after it. The remaining picture still looks like a picture, so the
|
||||
failure is invisible until someone counts what should be there. `preview.problem`
|
||||
now reaches the panel through `problemChanged`, and is drawn on the widget.
|
||||
* **A camera that frames the wrong thing.** The eye distance was
|
||||
`half the mesh's bounding-box diagonal × 3`, which cropped the table plane and the
|
||||
far end of the trajectory exactly when a pose swung them outwards. Framing now
|
||||
solves the distance from the field of view and the bounding sphere of everything
|
||||
drawn (`scene_points`, `scene_radius`, `_frame_distance`), so the whole scene fits
|
||||
at the default zoom at any pane shape. Long reference lines are excluded on
|
||||
purpose: they are meant to run off the edge, and including them would shrink the
|
||||
sensor to a speck.
|
||||
* **A render and a projection that disagreed.** Two separate faults, both invisible
|
||||
on their own and both found by checking a projected landmark against the pixels:
|
||||
`project()` left out the frustum's near-plane factor (`x_ndc = (near/right)·x/depth`),
|
||||
scaling every projected point by about 100; and `_draw` aimed the camera at the
|
||||
*raw* mesh centre while `_camera`/`project` orbited the *placed* one, so the drawn
|
||||
scene and the projected overlay sat in different places as soon as the body was
|
||||
drawn anywhere but at the identity. The axis labels were the visible casualty.
|
||||
`preview_snapshot.py --debug` now reports, for every shot, how many pixels of a
|
||||
known colour are near the projection of a known point — origin, tool line, table
|
||||
marker — so a drift shows up as a number instead of a shrug.
|
||||
|
||||
## The path, and where it starts
|
||||
|
||||
`trajectory()` returns the contact's route in the order the machine moves it:
|
||||
|
||||
1. **rotation** — the stages turn the body about the table origin, sweeping the
|
||||
contact along an arc whose radius is the contact's distance from that origin
|
||||
(reported as `arc_radius_mm`);
|
||||
2. **translation** — the table slides the contact onto the tool axis in a straight
|
||||
line (reported as `reach_mm`, the total distance travelled).
|
||||
|
||||
Nothing in the path is *at* the table origin. The origin is what the stages turn
|
||||
about; the path is traced by a point on the body, so it starts at the picked contact
|
||||
and ends under the tool. The two numbers a reader needs in order to judge the path —
|
||||
how far the contact travels, and how large the swing is — are printed next to the
|
||||
readouts.
|
||||
|
||||
The drawn path and the animation are the same transform by construction:
|
||||
`trajectory()` samples `body_transform()`, the function the drawing uses. They did
|
||||
not always: the path was sampled up to `SIM_ROTATION_SHARE` of the solved rotation —
|
||||
the *time* that phase takes in the animation, not how far it turns — so the drawn
|
||||
arc ended 45% short and the straight move began from a point the machine never
|
||||
passes through. `test_the_drawn_path_ends_where_the_animation_puts_the_contact` pins
|
||||
them together, and `test_the_path_ends_where_the_pose_says_the_table_moves_to` pins
|
||||
both to the reported move.
|
||||
|
||||
## Frames, in one line
|
||||
|
||||
The mesh is stored in sensor coordinates and drawn through `body_transform`; the
|
||||
machine's own frame is drawn separately, in mesh coordinates, by `table_frame()`.
|
||||
Every frame conversion goes through `PlatformStack` — `mount`, `setup_rotation`,
|
||||
`setup_point`, `platform_rotation`, `stage_rotation` — and never through a second
|
||||
copy composed in the view. Two copies of a frame convention is what produced two
|
||||
wrong orientations in opposite directions, and it is the mistake this screen was
|
||||
built to stop making.
|
||||
|
||||
## Reproducing a picture
|
||||
|
||||
The GL pane needs a real context, so the offscreen platform cannot photograph it:
|
||||
`tools/panel_snapshot.py` renders the window but leaves the 3D pane empty.
|
||||
|
||||
```powershell
|
||||
# the pane itself, large enough to judge
|
||||
python apps/autocal/tools/preview_snapshot.py --bundle <bundle> --tab sequence `
|
||||
--pane 1000x760 --points 3 --out pane.png
|
||||
|
||||
# the same, caught mid-playback
|
||||
python apps/autocal/tools/preview_snapshot.py --bundle <bundle> --tab sequence `
|
||||
--pane 1000x760 --points 1 --play 0.5 --out playing.png
|
||||
|
||||
# with a real setup: sensor x,y,z,yaw and the tool's x,y
|
||||
python apps/autocal/tools/preview_snapshot.py --bundle <bundle> --tab sequence `
|
||||
--pane 1000x760 --points 1 --setup "6,-3,5,12" --tool "7,4" --play 1.0 `
|
||||
--out tuned.png
|
||||
|
||||
# the whole page, with the pane composited in
|
||||
python apps/autocal/tools/preview_snapshot.py --bundle <bundle> --tab finger `
|
||||
--size 1920x1080 --points 3 --whole --out page.png
|
||||
```
|
||||
|
||||
`--debug` prints the state and, most usefully, where the claims land on screen: the
|
||||
path's end and the tool must project to the same pixel, and it prints the distance
|
||||
between them in mm so "they look close" is never the evidence.
|
||||
|
||||
Two things that tool has to do and are easy to get wrong: the pane is **hidden** for
|
||||
the read-back (a visible `QOpenGLWidget` is painted by the window system, which a
|
||||
session with no interactive desktop never does, so its framebuffer stays blank), and
|
||||
it retries until the framebuffer is not one flat colour (`grabFramebuffer` returns a
|
||||
valid, blank image before the first paint).
|
||||
@@ -0,0 +1,242 @@
|
||||
# Inverse kinematics: the simulated 5-axis pose
|
||||
|
||||
How AutoCal turns a picked surface point into the X, Y, yaw and pitch a run would
|
||||
command. The solve is **closed form** — two `atan2` calls, exact to floating
|
||||
point, no search.
|
||||
|
||||
Everything below is measured, not reasoned. The commands that reproduce each
|
||||
number are at the end.
|
||||
|
||||
## The machine
|
||||
|
||||
An XY table with a yaw stage and a pitch stage stacked on it, and a separate Z
|
||||
axis carrying the tool. Yaw and pitch are solved for; X and Y follow from where
|
||||
the contact point ends up, and Z is reported, never solved.
|
||||
|
||||
| Item | Value | Where |
|
||||
|---|---|---|
|
||||
| Tool axis | `z` | `motion/frames.py` |
|
||||
| Yaw axis | `z`, travel **±180°** | `YAW_AXIS`, `finger_yaw_limit_deg` |
|
||||
| Pitch axis | `y`, travel 360° | `PITCH_AXIS`, `finger_pitch_limit_deg` |
|
||||
| Stage order | `R_pitch @ R_yaw` | `stage_rotation` |
|
||||
| Mount map | sensor `x→x, y→z, z→-y` | `AXIS_MAP` |
|
||||
| Aim direction | `(0, 0, +1)` in the platform frame | `approach_sign = +1` |
|
||||
|
||||
### The face is aimed at +Z, and this was wrong
|
||||
|
||||
The sensor sits at the **bottom of its shell with its sensing face up**, and the
|
||||
tool feeds **down** onto it. So the sensing face — and the surface normal — point
|
||||
along **`+Z`**, and the solver's aim is to bring the normal onto `+Z`.
|
||||
|
||||
`approach_sign` was **`-1`**, aiming every normal at `-Z` and asking the machine to
|
||||
turn the sensor over. It survived because the residual is sign-blind
|
||||
(`arccos` maps a face turned fully over to `0.0000°`, the same as perfectly
|
||||
aligned) and because every test asserted only that the residual was *small*. The
|
||||
failure was visible **only** as the model flipping upside down.
|
||||
|
||||
Measured over the export, counting vertices whose face ends up pointing away from
|
||||
the tool:
|
||||
|
||||
| `approach_sign` | Face ends inverted | Nominal pose (`yaw = pitch = 0`) |
|
||||
|---|---|---|
|
||||
| `-1` (was) | **981 / 1032** | pitch **180°** needed; lands on `(0, 0, -1)` |
|
||||
| `+1` (now) | **22 / 1032** | **no rotation**; lands on `(0, 0, +1)` |
|
||||
|
||||
The 22 are the rim of the dome: their normals sit 60–90° off the tool axis, so
|
||||
aligning them necessarily rolls the body about that normal and the sign is
|
||||
decided by rounding. They are edge-on, not turned over — the most negative
|
||||
`uprightness` among them is a hair past zero, not `-1`.
|
||||
|
||||
The nominal pose needing **no rotation at all** is the tell: a machine that has to
|
||||
make a half-turn to reach its own commissioned configuration has the polarity
|
||||
backwards. `test_the_nominal_normal_needs_no_rotation_and_lands_on_plus_z` pins
|
||||
this, and `test_the_normal_is_aligned_to_plus_z_not_minus_z` checks the direction
|
||||
over the whole export. Both assert the direction landed in, which is the thing the
|
||||
residual cannot express.
|
||||
|
||||
The yaw figure is **±180°, not ±90°** — confirmed at the bench after an earlier
|
||||
value of 90 sent this document, and the solver, down a wrong path. With a
|
||||
half-turn available, **every normal on the export is reachable**; the reachability
|
||||
question below was an artefact of the wrong limit.
|
||||
|
||||
The solver's whole job is to make
|
||||
|
||||
```
|
||||
R_pitch @ R_yaw @ n_platform == tool_direction
|
||||
```
|
||||
|
||||
and to report the angle by which it fails. That angle is the **residual**.
|
||||
|
||||
## The closed form
|
||||
|
||||
### Yaw first, because the pitch cannot help with one component
|
||||
|
||||
The pitch stage turns about the pitch axis, so it **cannot change the normal's
|
||||
component along that axis**. Whatever that component is after the yaw, it stays
|
||||
that way through the pitch. So the yaw must bring it onto the tool's:
|
||||
|
||||
```
|
||||
n_pinned(yaw) = tool_pinned
|
||||
```
|
||||
|
||||
That component after a yaw is `a·cos(y) + b·sin(y)` — one sinusoid — so it has at
|
||||
most two roots in a turn:
|
||||
|
||||
```
|
||||
y = atan2(b, a) ± acos(tool_pinned / hypot(a, b))
|
||||
```
|
||||
|
||||
The coefficients `a` and `b` are read straight off the rotation primitive, by
|
||||
evaluating it at yaw 0 and yaw 90 degrees. No basis is written out by hand: every
|
||||
sign error made while deriving this module came from transcribing the rotation
|
||||
convention locally, and there is no convention left to transcribe wrong this way.
|
||||
|
||||
If `|tool_pinned| > hypot(a, b)` the normal is **unreachable at any pitch**, which
|
||||
is reported rather than hidden.
|
||||
|
||||
### Then pitch, in one `atan2`
|
||||
|
||||
With the yaw fixed, the pitch rotates the remaining pair onto the tool's:
|
||||
|
||||
```
|
||||
pitch = atan2(u₁w₂ − u₂w₁, u₁w₁ + u₂w₂)
|
||||
```
|
||||
|
||||
where `(u₁, u₂)` is the yawed normal and `(w₁, w₂)` the tool, both taken on the
|
||||
pair the pitch actually turns.
|
||||
|
||||
### Two branches
|
||||
|
||||
Both yaw roots satisfy the first condition. They reach the same normal in
|
||||
opposite senses, so one usually leaves the sensing face up and the other turns it
|
||||
over. Both are exact; the choice between them is a machine decision, not a
|
||||
numerical one.
|
||||
|
||||
## Results
|
||||
|
||||
Measured on the real 1032-vertex export
|
||||
(`…\orisys-sensor-design\build\export\finger\r01\compute`):
|
||||
|
||||
| | Worst residual | Time for 1032 | Per solve |
|
||||
|---|---|---|---|
|
||||
| Closed form (`method="analytical"`) | **1.2e-06°** | **148 ms** | 0.126 ms |
|
||||
| Sweep (`method="search"`) | 0.238° | 918 ms | 0.910 ms |
|
||||
|
||||
The closed form is **~200,000× more accurate** and **~7× faster**. Verified at
|
||||
`1.2e-06°` worst over all 1032 vertices and `1.5e-06°` over 5000 random normals.
|
||||
That residual is the floating-point floor: `acos` near 1 loses about `sqrt(eps)`
|
||||
in radians, so "exact" cannot be reported as more precise than this.
|
||||
|
||||
All 1032 vertices are **in range**, and 1010 report `upright`. The 22 that do not
|
||||
are the rim of the dome, where the normal is 60–90° off the tool axis — see
|
||||
*the face is aimed at +Z* above and *still open* below.
|
||||
|
||||
### The two pitch bindings are one family
|
||||
|
||||
`PITCH_AXIS` is `y`, while the trunnion drawing shows the pitch arc about `x`.
|
||||
This turns out not to matter:
|
||||
|
||||
```
|
||||
R_y(p) · R_z(t) ≡ R_x(p) · R_z(t − 90°)
|
||||
```
|
||||
|
||||
agreeing to machine precision in alignment for every yaw and pitch tried. So the
|
||||
axis binding is a **naming and zero-offset choice**: the two reach exactly the
|
||||
same set of alignments, and the 90° difference is absorbed when the yaw stage is
|
||||
zeroed at commissioning. `PITCH_AXIS` is documentation, not a correctness
|
||||
question — which is why the tests pin the identity rather than a value.
|
||||
|
||||
## What the solver reports
|
||||
|
||||
`Pose` carries more than angles and a residual, because those two cannot express
|
||||
everything the machine needs to know:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `residual_deg` | angle between the rotated normal and the tool direction |
|
||||
| `upright` | whether the pose leaves the sensing face pointing along the tool |
|
||||
| `in_range` | whether the pose is inside the stage travel |
|
||||
| `reason` | why not, when it is not |
|
||||
|
||||
A pose that aligns perfectly and turns the sensor over is a different thing from
|
||||
one that aligns perfectly and does not, and the residual alone cannot tell them
|
||||
apart. Likewise a pose needing more travel than the stage has: `in_range` says so,
|
||||
and the solver reports the **best pose the limits do allow** with its real
|
||||
residual, rather than an angle the machine cannot reach or a residual it cannot
|
||||
achieve.
|
||||
|
||||
## History: the sign-blind residual
|
||||
|
||||
The previous residual was `acos(turned · tool_direction)`, which is **sign-blind**.
|
||||
A rotation putting the normal *on* the tool axis and one putting it *opposite*
|
||||
both give `|cos| = 1`, so both scored `0.0000°`. That is how an upside-down pose
|
||||
read as perfect, and it is why this document previously distrusted the solver's
|
||||
"100% within 0.5°".
|
||||
|
||||
The angle itself is correct and is still what `residual_deg` reports; what was
|
||||
missing was `upright`, which now travels with every pose so the distinction cannot
|
||||
be lost. The lesson is recorded rather than deleted: a scalar that cannot express
|
||||
the failure mode it is used to detect will not detect it.
|
||||
|
||||
## Still open
|
||||
|
||||
**22 of 1032 vertices report not-upright** (16 by more than the tie band). These
|
||||
are the rim of the dome, where the surface normal is 60–90° from the tool axis.
|
||||
Aligning an edge-on face necessarily rolls the body about that normal, and which
|
||||
side of zero it lands on is settled by rounding — the most negative uprightness is
|
||||
`-0.997` for a vertex whose normal is `90.4°` from the tool axis, i.e. a face being
|
||||
presented side-on rather than turned over.
|
||||
|
||||
They are not a solver defect: the closed form is exact for all of them
|
||||
(`residual < 1.3e-06°`). Whether they matter is a coverage question — a contact
|
||||
made on the rim is the least well-conditioned one on the patch anyway — and it is
|
||||
worth a look at the bench rather than a change in the maths.
|
||||
|
||||
The polarity question that used to live here is **settled**: see *the face is aimed
|
||||
at +Z* above.
|
||||
|
||||
## Reproducing the numbers
|
||||
|
||||
Interpreter `C:\Users\cheun\anaconda3\envs\py310\python.exe`, repo root
|
||||
`D:\orisys_ws\orisys-app`:
|
||||
|
||||
```python
|
||||
from orisys_autocal.motion.kinematics import PlatformStack
|
||||
from orisys_autocal.sensor import load_bundle
|
||||
import numpy as np
|
||||
|
||||
bundle = load_bundle(r"…\finger\r01\compute")
|
||||
stack = PlatformStack()
|
||||
normals, points = bundle.vertex_normals(), bundle.vertices_mm
|
||||
|
||||
analytical = [stack.solve(p, n, method="analytical") for p, n in zip(points, normals)]
|
||||
residuals = np.array([pose.residual_deg for pose in analytical])
|
||||
print(residuals.max()) # ~1.2e-06
|
||||
print(sum(pose.upright for pose in analytical), "/", len(analytical)) # 1010 / 1032
|
||||
print(all(pose.in_range for pose in analytical)) # True
|
||||
```
|
||||
|
||||
The closed form on a single normal, with no data needed:
|
||||
|
||||
```python
|
||||
unit = np.array([0.3, 0.5, -0.81]); unit /= np.linalg.norm(unit)
|
||||
for branch in stack.alignment_branches(unit, stack.tool_direction):
|
||||
print(branch.describe())
|
||||
```
|
||||
|
||||
Tests and checks:
|
||||
|
||||
```
|
||||
pytest apps/autocal/tests
|
||||
python apps/autocal/tools/panel_check.py
|
||||
```
|
||||
|
||||
## What is not affected
|
||||
|
||||
The **UV picker** and the **pixel → 3D mapping** are independent of this and are
|
||||
verified separately: a click's 3D point, normal and face come from one triangle
|
||||
test, and the lookup tables are unchanged by the texture. This document is about
|
||||
which pose is reported and drawn.
|
||||
|
||||
Also out of scope: the sensor origin versus the stage rotation centre, which is a
|
||||
lever-arm error in the reported X/Y rather than in the angles.
|
||||
Reference in New Issue
Block a user