修正朝上判断、接触高度与过期姿态目标
朝上判断误用平台轴、接触高度报旋转前数值、多点对准复用缓存姿态,且多点会话被复核入口拒绝;
现按修正计划改正并接通 autocal-multipoint-1.0 只读复核,缺失帧源如实报告而不补零。
离线验证:新回归在 dc5a6f8 上失败、此处通过,panel_check 通过,两截图点接触高度复现
19.0510 / 21.5781 mm;实机项未做,详见 .scratch/autocal-motion-fix/verification.md。
This commit is contained in:
@@ -180,20 +180,21 @@ def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_win
|
||||
window = make_window()
|
||||
try:
|
||||
dialog = window.device_setup
|
||||
assert dialog.tabs.count() == 3
|
||||
assert dialog.tabs.count() == 4
|
||||
page = dialog.roles_page
|
||||
assert page.roles() == DEFAULT_AXIS_ROLES
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES
|
||||
|
||||
# Two roles on one drive: reported on the page *and* by the guard.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3))
|
||||
# Two roles on one drive: reported on the page *and* by the guard. Yaw is
|
||||
# moved onto pitch's drive, which is the duplicate this page exists to refuse.
|
||||
page.combos["yaw"].setCurrentIndex(page.combos["yaw"].findData(3))
|
||||
assert page.problem() != ""
|
||||
assert window.poller.service.roles_problem() != ""
|
||||
assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None
|
||||
|
||||
# Correcting it clears both.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(2))
|
||||
page.combos["yaw"].setCurrentIndex(page.combos["yaw"].findData(2))
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.roles_problem() == ""
|
||||
finally:
|
||||
@@ -222,3 +223,63 @@ def test_opening_the_setup_does_not_touch_the_connection(make_window):
|
||||
assert window.connection_panel.open_button.isEnabled() is True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_press_page_starts_uncalibrated_and_says_so(make_window):
|
||||
"""Until the zero is measured the press axis is not a coordinate anyone knows."""
|
||||
window = make_window()
|
||||
try:
|
||||
page = window.device_setup.press_page
|
||||
assert page.press()["calibrated"] is False
|
||||
assert page.state_label.text() == t("setup.press_unset")
|
||||
assert window.finger_panel.solved_pose # bound; the pose itself needs a bundle
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_press_axis_edit_reaches_the_panel_that_plans_the_move(make_window):
|
||||
"""The page is a measurement, and the move has to obey it immediately.
|
||||
|
||||
A zero typed here but not handed to the solver's consumer would leave the
|
||||
confirmation box showing a height the press axis was never told about.
|
||||
"""
|
||||
window = make_window()
|
||||
try:
|
||||
page = window.device_setup.press_page
|
||||
page.zero_spin.setValue(12.5)
|
||||
page.calibrated_check.setChecked(True)
|
||||
assert window.prefs["press_axis"] == {
|
||||
"zero_mm": 12.5,
|
||||
"rising": True,
|
||||
"calibrated": True,
|
||||
}
|
||||
assert window.finger_panel._press.calibrated is True # noqa: SLF001
|
||||
assert window._press_axis.zero_mm == 12.5 # noqa: SLF001
|
||||
assert page.state_label.text() == t("setup.press_set")
|
||||
|
||||
page.rising_check.setChecked(False)
|
||||
assert window.prefs["press_axis"]["rising"] is False
|
||||
assert window._press_axis.rising is False # noqa: SLF001
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_a_stored_press_axis_is_restored_into_the_page(make_window):
|
||||
window = make_window(
|
||||
press_axis={"zero_mm": -3.25, "rising": False, "calibrated": True}
|
||||
)
|
||||
try:
|
||||
page = window.device_setup.press_page
|
||||
assert page.press() == {"zero_mm": -3.25, "rising": False, "calibrated": True}
|
||||
assert window.finger_panel._press.zero_mm == -3.25 # noqa: SLF001
|
||||
finally:
|
||||
window.close()
|
||||
@pytest.mark.parametrize("settings", [
|
||||
{"zero_mm": 12.5, "rising": True, "calibrated": "false"},
|
||||
{"zero_mm": float("nan"), "rising": True, "calibrated": True},
|
||||
{"zero_mm": 12.5, "rising": "false", "calibrated": True},
|
||||
])
|
||||
def test_invalid_stored_press_relation_does_not_enable_motion(make_window, settings):
|
||||
window = make_window()
|
||||
window.prefs["press_axis"] = settings
|
||||
assert window._press_axis.target_for(19.051) is None
|
||||
|
||||
@@ -1168,7 +1168,11 @@ def test_the_sensor_z_offset_reaches_the_solve(panel):
|
||||
panel.set_sensor_setup((0.0, 0.0, 4.0), 0.0)
|
||||
thick = panel.stack.solve(point, normal)
|
||||
|
||||
assert thick.contact_z_mm - thin.contact_z_mm == pytest.approx(4.0)
|
||||
# The mounting height rises by the four millimetres the setup asks for; the
|
||||
# height the stages *present* rises by 4*cos(tilt) of that, because the offset
|
||||
# is applied before the rotation.
|
||||
assert thick.mounted_point_mm[2] - thin.mounted_point_mm[2] == pytest.approx(4.0)
|
||||
assert 0.0 < thick.contact_z_mm - thin.contact_z_mm < 4.0
|
||||
assert thick.target_xy_mm == pytest.approx(thin.target_xy_mm)
|
||||
# Picking through the panel uses the same fields, so the two must agree.
|
||||
panel.clear_points()
|
||||
@@ -1179,7 +1183,8 @@ def test_the_sensor_z_offset_reaches_the_solve(panel):
|
||||
thick_row = panel.handle_pick(x, y)
|
||||
assert thin_row is not None and thick_row is not None
|
||||
assert thick_row["residual"] == pytest.approx(thin_row["residual"], abs=1e-6)
|
||||
assert thick_row["Z"] - thin_row["Z"] == pytest.approx(4.0)
|
||||
assert thin_row["Z"] == pytest.approx(thin.contact_z_mm)
|
||||
assert thick_row["Z"] == pytest.approx(thick.contact_z_mm)
|
||||
assert thick_row["X"] == pytest.approx(thin_row["X"])
|
||||
assert thick_row["Y"] == pytest.approx(thin_row["Y"])
|
||||
|
||||
@@ -1202,7 +1207,12 @@ def test_the_mounting_pose_moves_the_point_the_way_the_setup_says(panel):
|
||||
|
||||
assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(2.0)
|
||||
assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-3.0)
|
||||
assert moved.contact_z_mm == pytest.approx(base.contact_z_mm)
|
||||
# An in-plane offset leaves the mounting height alone but not the height the
|
||||
# stages present: the same angles lift a contact that sits further from the
|
||||
# trunnion axis. (This line used to compare contact_z_mm on both sides, which
|
||||
# only held while that field was reported before the rotation.)
|
||||
assert moved.mounted_point_mm[2] == pytest.approx(base.mounted_point_mm[2])
|
||||
assert moved.contact_z_mm != pytest.approx(base.contact_z_mm)
|
||||
|
||||
# A yaw about the table's z turns the contact about the origin, so it moves the
|
||||
# point and leaves its height alone.
|
||||
@@ -1247,6 +1257,58 @@ def test_the_readouts_name_the_frame_mapping(panel):
|
||||
assert "-y" in text or "−y" in text
|
||||
|
||||
|
||||
def test_the_readout_names_the_chosen_pose_and_the_key_that_chose_it(panel):
|
||||
"""The plan's rule: a branch is picked for a reason, and the bench sees which.
|
||||
|
||||
Both closed-form poses align a measured point; they differ by a half turn of
|
||||
the body about the vertical. Whichever one the machine wants is a bench
|
||||
convention, so the screen has to say which was taken and what decided it.
|
||||
"""
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
x, y = _first_surface_pixel(panel.view.flat)
|
||||
panel.handle_pick(x, y)
|
||||
|
||||
pose = panel._last_pose # noqa: SLF001
|
||||
text = panel._readout_labels["branch"].text() # noqa: SLF001
|
||||
assert pose.branch_reason in {"only", "alignment", "in_range", "upright", "smallest"}
|
||||
assert t(f"finger.branch_{pose.branch_reason}") in text
|
||||
# And the alternative's cost is on screen too, not just the chosen one's.
|
||||
assert f"{pose.other_travel_deg:.1f}" in text
|
||||
|
||||
|
||||
def test_the_press_readout_says_why_it_is_held_and_then_names_the_target(panel):
|
||||
"""A geometric contact height is not a controller coordinate until it is measured."""
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_autocal.motion.pose_move import PressAxis
|
||||
|
||||
x, y = _first_surface_pixel(panel.view.flat)
|
||||
panel.handle_pick(x, y)
|
||||
pose = panel._last_pose # noqa: SLF001
|
||||
# The row records where the point *is*; the move records where the table goes.
|
||||
height = pose.contact_z_mm
|
||||
|
||||
# Shipped state: no zero measured, so the press role is held — and the readout
|
||||
# says so rather than showing a number the machine was never told about.
|
||||
assert panel._readout_labels["press"].text() == t("finger.press_unset") # noqa: SLF001
|
||||
targets, held = panel.solved_pose()
|
||||
assert held == ("z",)
|
||||
assert "z" not in targets
|
||||
assert targets["x"] == pytest.approx(pose.table_xy_mm[0])
|
||||
|
||||
# With the bench's zero entered, the same contact height becomes a coordinate.
|
||||
panel.set_press(PressAxis(zero_mm=2.0, rising=True, calibrated=True))
|
||||
targets, held = panel.solved_pose()
|
||||
assert held == ()
|
||||
assert targets["z"] == pytest.approx(height + 2.0)
|
||||
text = panel._readout_labels["press"].text() # noqa: SLF001
|
||||
assert f"{height + 2.0:+.4f}" in text
|
||||
# A downward-reading press axis flips the relation, and the readout follows.
|
||||
panel.set_press(PressAxis(zero_mm=2.0, rising=False, calibrated=True))
|
||||
targets, _held = panel.solved_pose()
|
||||
assert targets["z"] == pytest.approx(2.0 - height)
|
||||
|
||||
|
||||
def test_the_preview_refuses_to_draw_without_a_context(qt_app):
|
||||
from orisys_autocal.ui.pose_preview import PosePreview
|
||||
|
||||
@@ -1613,3 +1675,18 @@ def test_the_surface_keeps_its_residual_colours_while_the_machine_moves(qt_app,
|
||||
assert np.allclose(moved[index], residual_colour(preview._residuals[index])) # noqa: SLF001
|
||||
finally:
|
||||
preview.deleteLater()
|
||||
def test_reapplying_the_current_theme_does_not_repolish_all_windows(make_window, qt_app, monkeypatch):
|
||||
window = make_window()
|
||||
calls = []
|
||||
apply_style = qt_app.setStyleSheet
|
||||
|
||||
def record_style(style):
|
||||
calls.append(style)
|
||||
apply_style(style)
|
||||
|
||||
monkeypatch.setattr(qt_app, "setStyleSheet", record_style)
|
||||
window._apply_theme()
|
||||
assert calls == []
|
||||
window._theme = "dark" if window._theme == "light" else "light"
|
||||
window._apply_theme()
|
||||
assert len(calls) == 1
|
||||
|
||||
@@ -199,13 +199,13 @@ def test_the_surface_normals_point_up_so_the_face_faces_the_tool(bundle):
|
||||
)
|
||||
|
||||
|
||||
def test_few_vertices_end_turned_over_and_those_are_edge_on(bundle):
|
||||
"""Turning the face over is the failure mode; measure it and bound it.
|
||||
def test_no_vertex_ends_turned_over(bundle):
|
||||
"""Turning the face over is the failure mode; with the right up axis it is zero.
|
||||
|
||||
The remainder are vertices whose normals are 60-90 degrees off the tool axis —
|
||||
the rim of the dome. Aligning an edge-on face has to roll the body about that
|
||||
normal, and the sign is decided by rounding, so they cannot be pushed to zero
|
||||
and should not be asserted away.
|
||||
``uprightness`` used to compare the platform's Y axis, so 47 of these vertices read
|
||||
as turned over even though they only rolled about the vertical. Against the
|
||||
post-mount nominal up (the tool axis) no vertex ends inverted, and this now fails
|
||||
if the reference regresses rather than reporting a small count as acceptable.
|
||||
"""
|
||||
stack = PlatformStack()
|
||||
normals = bundle.vertex_normals()
|
||||
@@ -214,9 +214,7 @@ def test_few_vertices_end_turned_over_and_those_are_edge_on(bundle):
|
||||
pose = stack.solve(np.zeros(3), normal)
|
||||
if stack.uprightness(pose.yaw_deg, pose.pitch_deg) < -0.5:
|
||||
flipped += 1
|
||||
assert flipped <= len(normals) * 0.03, (
|
||||
f"{flipped} of {len(normals)} vertices ended turned over"
|
||||
)
|
||||
assert flipped == 0, f"{flipped} of {len(normals)} vertices ended turned over"
|
||||
|
||||
|
||||
def test_a_pose_reports_whether_it_is_upright():
|
||||
|
||||
@@ -19,6 +19,7 @@ import numpy as np
|
||||
import pytest
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
ALIGNED_TOL_DEG,
|
||||
Branch,
|
||||
PlatformStack,
|
||||
Pose,
|
||||
residual_for,
|
||||
@@ -512,6 +513,10 @@ def test_the_table_move_lands_the_contact_on_the_tool():
|
||||
final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0])
|
||||
assert final[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-9)
|
||||
assert final[1] == pytest.approx(stack.tool_xy_mm[1], abs=1e-9)
|
||||
# The reported contact is that same landing point, height included — the
|
||||
# whole point of the move is to arrive there, not merely at its xy.
|
||||
assert pose.contact_point_mm == pytest.approx(tuple(final), abs=1e-9)
|
||||
assert pose.contact_z_mm == pytest.approx(float(final[2]), abs=1e-9)
|
||||
|
||||
|
||||
def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_frame():
|
||||
@@ -526,7 +531,12 @@ def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_fram
|
||||
moved = shifted.solve(point, normal)
|
||||
assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(1.5)
|
||||
assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-2.5)
|
||||
assert moved.contact_z_mm == pytest.approx(base.contact_z_mm)
|
||||
# An in-plane offset leaves the *mounting* height alone, but not the height the
|
||||
# stages present: the same angles now lift a contact that sits further from the
|
||||
# trunnion axis. This line used to compare contact_z_mm on both sides, which
|
||||
# only held while that field was reported before the rotation.
|
||||
assert moved.mounted_point_mm[2] == pytest.approx(base.mounted_point_mm[2])
|
||||
assert moved.contact_z_mm - base.contact_z_mm > 0.1
|
||||
|
||||
turned = dataclasses.replace(stack, sensor_yaw_deg=35.0)
|
||||
yawed = turned.solve(point, normal)
|
||||
@@ -552,7 +562,11 @@ def test_the_setup_z_is_the_mounting_plate():
|
||||
|
||||
lifted = dataclasses.replace(stack, sensor_offset_mm=(0.0, 0.0, 4.25))
|
||||
raised = lifted.solve(point, normal)
|
||||
assert raised.contact_z_mm - plain.contact_z_mm == pytest.approx(4.25)
|
||||
# The plate lifts the mounting pose by exactly its thickness. The height the
|
||||
# stages present rises by less, because the lift is applied before the stage
|
||||
# rotation and the rotation tilts it — 4.2285 mm of the 4.25 mm here.
|
||||
assert raised.mounted_point_mm[2] - plain.mounted_point_mm[2] == pytest.approx(4.25)
|
||||
assert 0.0 < raised.contact_z_mm - plain.contact_z_mm < 4.25
|
||||
assert raised.target_xy_mm == pytest.approx(plain.target_xy_mm)
|
||||
# And it is the same number as the plate parameter it replaced.
|
||||
assert lifted.setup_point(point)[2] == pytest.approx(
|
||||
@@ -605,3 +619,181 @@ def test_an_aligned_pose_reports_that_it_reached_tolerance():
|
||||
assert loose.reachable
|
||||
far = Pose(ok=True, residual_deg=30.0)
|
||||
assert not far.reached_tolerance and not far.reachable
|
||||
|
||||
|
||||
# --------------------------------------- the branch, the height and the up axis
|
||||
|
||||
|
||||
def _independent_rotation(yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||
"""``R_pitch @ R_yaw`` from the formulas, not through ``rotation_about``."""
|
||||
yaw, pitch = np.radians(yaw_deg), np.radians(pitch_deg)
|
||||
about_yaw = np.array(
|
||||
[
|
||||
[np.cos(yaw), -np.sin(yaw), 0.0],
|
||||
[np.sin(yaw), np.cos(yaw), 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
]
|
||||
)
|
||||
about_pitch = np.array(
|
||||
[
|
||||
[np.cos(pitch), 0.0, np.sin(pitch)],
|
||||
[0.0, 1.0, 0.0],
|
||||
[-np.sin(pitch), 0.0, np.cos(pitch)],
|
||||
]
|
||||
)
|
||||
return about_pitch @ about_yaw
|
||||
|
||||
|
||||
#: The two measured points from the bench screenshots, in the sensor frame:
|
||||
#: point, normal, mounting height, and the height the stages present. The mounting
|
||||
#: height is the plate (8 mm) over the point's own height; the presented height is
|
||||
#: what the plan records as 19.051 and 21.578 mm.
|
||||
MEASURED = (
|
||||
((4.872, 5.444, 16.176), (0.595, 0.673, 0.439), 13.444, 19.051),
|
||||
((-2.698, 5.186, 18.228), (-0.343, 0.699, 0.627), 13.186, 21.578),
|
||||
)
|
||||
|
||||
|
||||
def _measured_stack() -> PlatformStack:
|
||||
"""The bench's mounting: the plate is the mounting pose's z, 8 mm."""
|
||||
return PlatformStack(sensor_offset_mm=(0.0, 0.0, 8.0))
|
||||
|
||||
|
||||
def test_the_contact_height_is_the_height_after_the_stages_turn():
|
||||
"""Defect: the pose reported the contact's height *before* the stages turned.
|
||||
|
||||
On the bench that is the difference between 13.444 mm and 19.051 mm — the
|
||||
stages lift the contact several millimetres, and any press-axis target is
|
||||
derived from the height the contact is *presented* at, not from where it sits
|
||||
with the stages at zero.
|
||||
"""
|
||||
stack = _measured_stack()
|
||||
for point, normal, mounting, presented in MEASURED:
|
||||
pose = stack.solve(np.array(point), np.array(normal) / np.linalg.norm(normal))
|
||||
assert pose.mounted_point_mm[2] == pytest.approx(mounting, abs=5e-4)
|
||||
assert pose.contact_z_mm == pytest.approx(presented, abs=5e-4)
|
||||
|
||||
# The same height again, from a rotation built here rather than by the
|
||||
# solver: the landing point of the reported angles.
|
||||
landed = _independent_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point(
|
||||
np.array(point)
|
||||
)
|
||||
assert pose.contact_z_mm == pytest.approx(float(landed[2]), abs=1e-9)
|
||||
assert pose.contact_point_mm == pytest.approx(
|
||||
(stack.tool_xy_mm[0], stack.tool_xy_mm[1], float(landed[2])), abs=1e-9
|
||||
)
|
||||
assert pose.contact_point_mm[2] > pose.mounted_point_mm[2]
|
||||
|
||||
|
||||
def test_the_pose_aligns_the_normal_and_keeps_the_body_up():
|
||||
"""Task-1 acceptance: independent rotation, tool axis and the platform's up.
|
||||
|
||||
"Up" is the direction the sensing face nominally aims — platform +Z, which is
|
||||
where the mount sends the sensor's own up axis — not a horizontal axis of the
|
||||
platform frame.
|
||||
"""
|
||||
stack = _measured_stack()
|
||||
up = np.array([0.0, 0.0, 1.0])
|
||||
for point, normal, _mounting, _presented in MEASURED:
|
||||
pose = stack.solve(np.array(point), np.array(normal) / np.linalg.norm(normal))
|
||||
rotate = _independent_rotation(pose.yaw_deg, pose.pitch_deg)
|
||||
|
||||
aligned = rotate @ stack.setup_normal(np.array(normal))
|
||||
angle = np.degrees(np.arccos(np.clip(float(aligned @ up), -1.0, 1.0)))
|
||||
assert angle <= 1e-5
|
||||
|
||||
# ``uprightness`` is that same rotation carried onto the up axis: the
|
||||
# component left pointing up, 1.0 when nothing has tipped at all.
|
||||
assert stack.uprightness(pose.yaw_deg, pose.pitch_deg) == pytest.approx(
|
||||
float((rotate @ up)[2]), abs=1e-12
|
||||
)
|
||||
assert 0.0 < stack.uprightness(pose.yaw_deg, pose.pitch_deg) <= 1.0
|
||||
assert pose.upright
|
||||
|
||||
|
||||
def test_both_exact_poses_of_a_measured_point_leave_the_sensor_face_up():
|
||||
"""Defect: the up test measured the body against a *horizontal* axis.
|
||||
|
||||
The two exact poses of a measured point are one pose rolled 180 degrees about
|
||||
the vertical: same contact, same height, mirrored azimuth. Neither turns the
|
||||
sensing face over, so flagging one of them inverted was the bug — and the
|
||||
panel, the preview and the guards all read that flag.
|
||||
"""
|
||||
stack = PlatformStack()
|
||||
first = _independent_rotation(-143.580, 47.692)
|
||||
second = _independent_rotation(36.420, -47.692)
|
||||
assert second == pytest.approx(rotation_about("z", 180.0) @ first, abs=1e-9)
|
||||
|
||||
for yaw_deg, pitch_deg in (
|
||||
(36.420, -47.692),
|
||||
(-143.580, 47.692),
|
||||
(-61.319, 45.636),
|
||||
(118.681, -45.636),
|
||||
):
|
||||
assert stack.uprightness(yaw_deg, pitch_deg) > 0.0
|
||||
assert stack.ends_upright(yaw_deg, pitch_deg)
|
||||
|
||||
|
||||
def test_equivalent_poses_are_chosen_by_the_machine_not_by_the_residual():
|
||||
"""Two exact poses differ by float noise; that must not decide the answer.
|
||||
|
||||
The right-hand screenshot point, both ways round: 84 degrees of stage travel
|
||||
against 191, and the shorter one carries the marginally *larger* residual.
|
||||
"""
|
||||
short = Branch(
|
||||
yaw_deg=36.420, pitch_deg=-47.692, residual_deg=1e-9, upright=True, in_range=True
|
||||
)
|
||||
long_way = Branch(
|
||||
yaw_deg=-143.580, pitch_deg=47.692, residual_deg=0.0, upright=True, in_range=True
|
||||
)
|
||||
assert PlatformStack.select_branch([long_way, short]) is short
|
||||
assert PlatformStack.branch_reason([long_way, short], short) == "smallest"
|
||||
|
||||
|
||||
def test_a_materially_better_alignment_still_wins():
|
||||
"""Equivalent is a tolerance, not an excuse to ignore the residual."""
|
||||
coarse = Branch(yaw_deg=0.0, pitch_deg=0.0, residual_deg=3.0, upright=True, in_range=True)
|
||||
exact = Branch(yaw_deg=80.0, pitch_deg=40.0, residual_deg=0.05, upright=True, in_range=True)
|
||||
assert PlatformStack.select_branch([coarse, exact]) is exact
|
||||
assert PlatformStack.branch_reason([coarse, exact], exact) == "alignment"
|
||||
|
||||
|
||||
def test_real_roundoff_does_not_choose_a_half_turn_over_the_shorter_pose():
|
||||
stack = PlatformStack(pitch_limit_deg=90)
|
||||
normal = np.array([-0.499590704094966, 0.6734102426572587, -0.5449107940444822])
|
||||
unit = stack.setup_normal(normal)
|
||||
branches = stack.alignment_branches(unit, stack.tool_direction)
|
||||
short = min(branches, key=lambda branch: branch.travel_deg)
|
||||
for branch in branches:
|
||||
turned = stack.platform_rotation(branch.yaw_deg, branch.pitch_deg) @ unit
|
||||
np.testing.assert_allclose(turned, [0, 0, 1], atol=1e-12)
|
||||
assert stack.select_branch(branches) is short
|
||||
|
||||
|
||||
def test_the_branch_keys_are_applied_in_the_order_the_machine_cares_about():
|
||||
"""The envelope, then which way the body ends up, then the shorter turn."""
|
||||
inside = Branch(
|
||||
yaw_deg=-61.319, pitch_deg=45.636, residual_deg=0.0, upright=True, in_range=True
|
||||
)
|
||||
outside = Branch(
|
||||
yaw_deg=0.0, pitch_deg=0.0, residual_deg=0.0, upright=True, in_range=False
|
||||
)
|
||||
assert PlatformStack.select_branch([inside, outside]) is inside
|
||||
assert PlatformStack.branch_reason([inside, outside], inside) == "in_range"
|
||||
|
||||
upright = Branch(
|
||||
yaw_deg=118.681, pitch_deg=-45.636, residual_deg=0.0, upright=True, in_range=True
|
||||
)
|
||||
rolled = Branch(
|
||||
yaw_deg=10.0, pitch_deg=10.0, residual_deg=0.0, upright=False, in_range=True
|
||||
)
|
||||
assert PlatformStack.select_branch([upright, rolled]) is upright
|
||||
assert PlatformStack.branch_reason([upright, rolled], upright) == "upright"
|
||||
|
||||
|
||||
def test_a_lone_branch_is_its_own_reason_and_an_empty_list_is_refused():
|
||||
only = Branch(yaw_deg=10.0, pitch_deg=10.0, residual_deg=0.0, upright=True, in_range=True)
|
||||
assert PlatformStack.select_branch([only]) is only
|
||||
assert PlatformStack.branch_reason([only], only) == "only"
|
||||
with pytest.raises(ValueError):
|
||||
PlatformStack.select_branch([])
|
||||
|
||||
@@ -90,8 +90,16 @@ def test_the_next_point_waits_until_the_indenter_is_confirmed_clear(tmp_path):
|
||||
assert all(trial["status"] == "complete" for trial in session["trials"])
|
||||
assert directory.name.startswith("finger_SN1_")
|
||||
from orisys_autocal.session_review import review_session
|
||||
with pytest.raises(ValueError, match="finger sessions"):
|
||||
review_session(directory)
|
||||
review = review_session(directory)
|
||||
assert review["source_format"] == "autocal-multipoint-1.0"
|
||||
assert review["status"] == "exploratory" and review["formal_conclusion"] is False
|
||||
assert [site["site_id"] for site in review["sites"]] == ["A", "B"]
|
||||
# This campaign ran without a camera, so every window reports the missing frame
|
||||
# source instead of looking like a measurement of zero force.
|
||||
assert {issue["reason"] for issue in review["issues"]} == {"no_frame_source"}
|
||||
assert all(window["samples"] == 0 and window["raw_mean"] is None
|
||||
for site in review["sites"] for window in site["windows"])
|
||||
assert all(site["fit"] is None and not site["formal_conclusion"] for site in review["sites"])
|
||||
|
||||
first = directory / "sites" / "A" / "repeat-001" / "reference.csv"
|
||||
rows = list(csv.DictReader(first.open(encoding="utf-8")))
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Aligning the normal solves the point again instead of trusting the stored pose.
|
||||
|
||||
The stored pose is a record of what the solver said when the point was taken.
|
||||
Mounting offsets, the mounting angle, the tool position and the travel limits are
|
||||
all editable afterwards, so a stale pose can name a target this machine no longer
|
||||
reaches. These tests drive the panel's own align path and watch what it emits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_autocal.sensor.uv_flat import FlatSurface
|
||||
|
||||
#: The right-hand measured point, as the bench reported it.
|
||||
POINT = (4.872, 5.444, 16.176)
|
||||
NORMAL = (0.595, 0.673, 0.439)
|
||||
|
||||
|
||||
def _flat(point=POINT, normal=NORMAL) -> FlatSurface:
|
||||
"""A stand-in for the rasterised surface: one valid pixel, one point."""
|
||||
size = 8
|
||||
positions = np.tile(np.asarray(point, dtype=np.float64), (size, size, 1))
|
||||
normals = np.tile(np.asarray(normal, dtype=np.float64), (size, size, 1))
|
||||
mask = np.zeros((size, size), dtype=np.int32)
|
||||
return FlatSurface(
|
||||
size_hw=(size, size),
|
||||
image_rgb=np.zeros((size, size, 3), dtype=np.uint8),
|
||||
positions_mm=positions,
|
||||
normals=normals,
|
||||
face_index=mask,
|
||||
uv_min=(0.0, 0.0),
|
||||
uv_max=(1.0, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def _prefix(key: str) -> str:
|
||||
"""The part of a locale message before its first placeholder."""
|
||||
return t(key).split("{")[0]
|
||||
|
||||
|
||||
def _add_site(panel, stack, *, site_id="P1", point=POINT, normal=NORMAL, uv=None):
|
||||
solved = stack.solve(
|
||||
np.asarray(point, dtype=np.float64),
|
||||
np.asarray(normal, dtype=np.float64),
|
||||
)
|
||||
assert solved.ok
|
||||
panel.campaign.add_solved_site(
|
||||
site_id,
|
||||
float(point[0]),
|
||||
float(point[1]),
|
||||
float(point[2]),
|
||||
tuple(float(value) for value in normal),
|
||||
{
|
||||
"x": float(solved.table_xy_mm[0]),
|
||||
"y": float(solved.table_xy_mm[1]),
|
||||
"yaw": float(solved.yaw_deg),
|
||||
"pitch": float(solved.pitch_deg),
|
||||
"z": float(solved.contact_z_mm),
|
||||
"residual_deg": float(solved.residual_deg),
|
||||
"upright": bool(solved.upright),
|
||||
"in_range": bool(solved.in_range),
|
||||
"ok": bool(solved.ok),
|
||||
"normal_aligned_to": f"tool_axis:{stack.tool_axis}",
|
||||
},
|
||||
[5.0],
|
||||
1.0,
|
||||
1,
|
||||
max_force_n=20.0,
|
||||
uv_px=uv,
|
||||
)
|
||||
return solved
|
||||
|
||||
|
||||
def _watch(panel, window) -> list[dict]:
|
||||
"""Record what the panel emits, without letting the window plan a move."""
|
||||
panel.alignRequested.disconnect()
|
||||
emitted: list[dict] = []
|
||||
panel.alignRequested.connect(emitted.append)
|
||||
return emitted
|
||||
|
||||
|
||||
def test_aligning_solves_the_point_against_the_mounting_in_force_now(make_window):
|
||||
window = make_window()
|
||||
panel = window.calibration_panel.multipoint
|
||||
stored = _add_site(panel, window.finger_stack)
|
||||
panel._selected = "P1" # the panel is the unit under test - the table's selection, set without a click
|
||||
emitted = _watch(panel, window)
|
||||
|
||||
panel._align() # the panel is the unit under test
|
||||
assert emitted, panel.message.text()
|
||||
assert emitted[-1]["z"] == pytest.approx(stored.contact_z_mm)
|
||||
|
||||
# The operator raises the sensor on its plate: the same point now needs a
|
||||
# different pose, and the panel has to ask the solver again.
|
||||
window.finger_panel.sensor_z_spin.setValue(12.0)
|
||||
panel._align() # the panel is the unit under test
|
||||
assert len(emitted) == 2, panel.message.text()
|
||||
fresh = panel.campaign.sites[0].pose
|
||||
assert fresh["z"] == pytest.approx(emitted[-1]["z"])
|
||||
assert fresh["z"] != pytest.approx(stored.contact_z_mm)
|
||||
|
||||
|
||||
def test_a_point_the_map_has_moved_asks_to_be_taken_again(make_window):
|
||||
window = make_window()
|
||||
panel = window.calibration_panel.multipoint
|
||||
window.calibration_panel._flat = _flat() # the panel is the unit under test - the loaded surface
|
||||
_add_site(panel, window.finger_stack, uv=(0.0, 0.0))
|
||||
# The record says the point is half a millimetre from where the map puts it.
|
||||
panel.campaign.sites[0].x += 0.5
|
||||
panel._selected = "P1" # the panel is the unit under test
|
||||
emitted = _watch(panel, window)
|
||||
|
||||
panel._align() # the panel is the unit under test
|
||||
assert not emitted
|
||||
assert _prefix("multipoint.point_moved") in panel.message.text()
|
||||
|
||||
|
||||
def test_a_point_whose_normal_has_moved_asks_to_be_taken_again(make_window):
|
||||
window = make_window()
|
||||
panel = window.calibration_panel.multipoint
|
||||
window.calibration_panel._flat = _flat() # the panel is the unit under test
|
||||
_add_site(panel, window.finger_stack, uv=(0.0, 0.0))
|
||||
tilted = np.asarray(NORMAL, dtype=np.float64)
|
||||
tilted = tilted / np.linalg.norm(tilted)
|
||||
panel.campaign.sites[0].normal = (float(tilted[0]), float(tilted[1]), float(-tilted[2]))
|
||||
panel._selected = "P1" # the panel is the unit under test
|
||||
emitted = _watch(panel, window)
|
||||
|
||||
panel._align() # the panel is the unit under test
|
||||
assert not emitted
|
||||
assert _prefix("multipoint.normal_moved") in panel.message.text()
|
||||
|
||||
|
||||
def test_a_point_without_a_usable_source_is_refused(make_window):
|
||||
window = make_window()
|
||||
panel = window.calibration_panel.multipoint
|
||||
_add_site(panel, window.finger_stack)
|
||||
panel.campaign.sites[0].position_frame = "fixture"
|
||||
panel._selected = "P1" # the panel is the unit under test
|
||||
emitted = _watch(panel, window)
|
||||
|
||||
panel._align() # the panel is the unit under test
|
||||
assert not emitted
|
||||
assert _prefix("multipoint.point_unmapped") in panel.message.text()
|
||||
|
||||
|
||||
def test_a_pose_the_travel_no_longer_reaches_is_never_commanded(make_window):
|
||||
window = make_window()
|
||||
panel = window.calibration_panel.multipoint
|
||||
_add_site(panel, window.finger_stack)
|
||||
panel._selected = "P1" # the panel is the unit under test
|
||||
emitted = _watch(panel, window)
|
||||
|
||||
# A yaw axis with no travel cannot reach any of the measured points, and the
|
||||
# stored pose still says it could.
|
||||
window.finger_panel.stack.yaw_limit_deg = 0.0
|
||||
panel._align() # the panel is the unit under test
|
||||
assert not emitted
|
||||
assert _prefix("multipoint.pose_refused") in panel.message.text()
|
||||
|
||||
|
||||
def test_a_point_cannot_be_aligned_in_the_middle_of_a_run(make_window, tmp_path):
|
||||
window = make_window()
|
||||
panel = window.calibration_panel.multipoint
|
||||
_add_site(panel, window.finger_stack)
|
||||
panel._selected = "P1" # the panel is the unit under test
|
||||
emitted = _watch(panel, window)
|
||||
panel.campaign.start(
|
||||
tmp_path,
|
||||
sensor_model="finger",
|
||||
sensor_sn="SN1",
|
||||
coordinate_frame="sensor",
|
||||
max_force_n=20.0,
|
||||
simulate=True,
|
||||
)
|
||||
|
||||
panel._align() # the panel is the unit under test
|
||||
assert not emitted
|
||||
assert t("multipoint.align_busy") in panel.message.text()
|
||||
|
||||
|
||||
def test_next_site_can_be_aligned_between_trials(make_window, tmp_path):
|
||||
window = make_window()
|
||||
panel = window.calibration_panel.multipoint
|
||||
_add_site(panel, window.finger_stack, site_id="P1")
|
||||
_add_site(panel, window.finger_stack, site_id="P2")
|
||||
emitted = _watch(panel, window)
|
||||
panel.campaign.start(tmp_path, sensor_model="finger", sensor_sn="SN1",
|
||||
coordinate_frame="sensor", max_force_n=20.0, simulate=True)
|
||||
while panel.campaign.phase == "load":
|
||||
target = panel.campaign.progress()["target_n"]
|
||||
panel.campaign.confirm_reference(target)
|
||||
panel.campaign.complete_hold(
|
||||
now_ns=panel.campaign._hold_started_ns + int(panel.campaign._hold_s * 1e9))
|
||||
assert panel.campaign.phase == "liftoff"
|
||||
panel._selected = "P2"
|
||||
panel._refresh()
|
||||
assert panel.align_button.isEnabled()
|
||||
panel._align()
|
||||
assert len(emitted) == 1
|
||||
panel._selected = "P1"
|
||||
panel._align()
|
||||
assert len(emitted) == 1
|
||||
|
||||
@@ -32,8 +32,10 @@ from orisys_autocal.motion.models import AxisState, AxisStatusBits, BusStatus
|
||||
from orisys_autocal.motion.pose_move import (
|
||||
MOVE_ORDER,
|
||||
PoseMover,
|
||||
PressAxis,
|
||||
SETTLE_DEG,
|
||||
SETTLE_MM,
|
||||
press_targets,
|
||||
)
|
||||
from orisys_autocal.motion.transport import DemoTransport
|
||||
from orisys_autocal.preferences import default_preferences
|
||||
@@ -465,3 +467,62 @@ def test_the_units_round_trip_through_the_service():
|
||||
from orisys_autocal.motion import user_units_to_deg
|
||||
|
||||
assert user_units_to_deg(axis, units) == pytest.approx(value)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ press axis
|
||||
|
||||
|
||||
def test_an_uncalibrated_press_axis_is_held_rather_than_commanded():
|
||||
"""A geometric contact height is not a controller coordinate.
|
||||
|
||||
``solve`` reports where the point ends up; what the press axis *reads* there
|
||||
is a separate bench measurement. Until someone has made it, commanding the
|
||||
geometric number would drive the press to a height that means nothing.
|
||||
"""
|
||||
targets, held = press_targets({"x": 1.0, "y": 2.0, "z": 19.051}, PressAxis())
|
||||
assert held == ("z",)
|
||||
assert "z" not in targets
|
||||
assert targets == {"x": 1.0, "y": 2.0}
|
||||
|
||||
|
||||
def test_a_calibrated_press_axis_turns_the_contact_height_into_its_coordinate():
|
||||
"""The relation has both a zero and a direction, and both come from the bench."""
|
||||
rising = PressAxis(zero_mm=12.5, rising=True, calibrated=True)
|
||||
falling = PressAxis(zero_mm=12.5, rising=False, calibrated=True)
|
||||
assert rising.target_for(19.051) == pytest.approx(31.551)
|
||||
assert falling.target_for(19.051) == pytest.approx(-6.551)
|
||||
targets, held = press_targets({"x": 1.0, "z": 19.051}, rising)
|
||||
assert held == ()
|
||||
assert targets["z"] == pytest.approx(31.551)
|
||||
|
||||
|
||||
def test_planning_the_press_axis_does_not_touch_the_caller_s_mapping():
|
||||
"""The panel hands over its solved targets; holding z must not edit them."""
|
||||
targets = {"x": 1.0, "z": 3.0}
|
||||
press_targets(targets, PressAxis())
|
||||
assert targets == {"x": 1.0, "z": 3.0}
|
||||
|
||||
|
||||
def test_a_held_press_axis_still_plans_the_other_roles():
|
||||
"""Held is not refused: the four axes that are justified still move."""
|
||||
service = make_service()
|
||||
plan = plan_pose_move(service, {"x": 1.0, "y": 2.0}, hold=("z",))
|
||||
assert [step.role for step in plan.steps] == ["x", "y"]
|
||||
assert plan.held == ("z",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("settings", [
|
||||
{"zero_mm": 12.5, "rising": True, "calibrated": "false"},
|
||||
{"zero_mm": float("nan"), "rising": True, "calibrated": True},
|
||||
{"zero_mm": 12.5, "rising": "false", "calibrated": True},
|
||||
{"zero_mm": True, "rising": True, "calibrated": True},
|
||||
{"calibrated": True},
|
||||
])
|
||||
def test_invalid_press_settings_keep_the_axis_held(settings):
|
||||
press = PressAxis.from_settings(settings)
|
||||
assert press.target_for(19.051) is None
|
||||
|
||||
|
||||
def test_valid_press_settings_preserve_the_measured_direction():
|
||||
press = PressAxis.from_settings({"zero_mm": 12.5, "rising": False, "calibrated": True})
|
||||
assert press.target_for(19.051) == pytest.approx(-6.551)
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Multi-point review: declared hold windows, one frame source per repeat, no invention."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.session_review import review_session, save_review
|
||||
|
||||
REFERENCE_HEADER = (
|
||||
"step_index", "branch", "target_load_n", "t_start_host_mono_ns", "t_end_host_mono_ns",
|
||||
"F_ref_normal_N", "site_id", "x", "y", "z", "coordinate_frame", "repeat_index",
|
||||
"repeat_count", "hold_complete", "reference_source", "fx_N", "fy_N", "fz_N",
|
||||
"force_to_tool_rotation", "nx", "ny", "nz", "pose_x_mm", "pose_y_mm", "pose_yaw_deg",
|
||||
"pose_pitch_deg", "pose_z_mm", "normal_aligned_to",
|
||||
)
|
||||
FRAMES_HEADER = (
|
||||
"frame_id", "source_frame_id", "t_rel_s", "t_host_mono_ns", "t_host_utc_ns", "acquired_at",
|
||||
"time_basis", "media_time_ms", "segment", "segment_frame", "in_video", "dropped",
|
||||
"capture_dropped",
|
||||
)
|
||||
FORCES_HEADER = ("session_id", "frame_id", "t_rel_s", "FN_raw", "valid")
|
||||
|
||||
#: The campaign's own step order, and a raw signal linear in the declared force so the
|
||||
#: reviewed fit is checkable by hand: 0 N -> 100 raw, 5 N -> 300 raw, 10 N -> 500 raw.
|
||||
STEPS = (("baseline", 0.0), ("load", 5.0), ("load", 10.0), ("unload", 5.0), ("unload", 0.0))
|
||||
RAW_BASE, RAW_PER_N = 100.0, 40.0
|
||||
SAMPLES_PER_HOLD = 3
|
||||
CAPTURE_SESSION_ID = 42
|
||||
BRANCHES = ["zero", "load", "load", "unload", "unload"]
|
||||
|
||||
|
||||
def _write(path: Path, header, rows) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=list(header))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def _trial(root: Path, site_id: str, repeat: int, *, status: str = "complete",
|
||||
capture: bool = True, basis: str = "host_camera") -> str:
|
||||
directory = f"sites/{site_id}/repeat-{repeat:03d}"
|
||||
trial = root / directory
|
||||
trial.mkdir(parents=True, exist_ok=True)
|
||||
(trial / "session.json").write_text(json.dumps({
|
||||
"trial_id": f"{site_id}-{repeat}", "site_id": site_id, "repeat_index": repeat,
|
||||
"status": status, "coordinate_frame": "sensor",
|
||||
"steps": [{"step_index": index, "branch": branch, "target_load_n": load}
|
||||
for index, (branch, load) in enumerate(STEPS)],
|
||||
}), encoding="utf-8")
|
||||
rows, frames, forces = [], [], []
|
||||
base = 1_000_000_000 + 500_000_000 * (site_id != "P1") + 10_000_000 * repeat
|
||||
frame_id = 10_000 * repeat + 1_000 * (site_id != "P1")
|
||||
for step, (branch, load) in enumerate(STEPS):
|
||||
start = base + step * 100_000_000
|
||||
end = start + 50_000_000
|
||||
rows.append({
|
||||
"step_index": step, "branch": branch, "target_load_n": f"{load}",
|
||||
"t_start_host_mono_ns": start, "t_end_host_mono_ns": end,
|
||||
"F_ref_normal_N": f"{load}", "site_id": site_id, "x": 1.0, "y": 2.0, "z": 3.0,
|
||||
"coordinate_frame": "sensor", "repeat_index": repeat, "repeat_count": 2,
|
||||
"hold_complete": 1, "reference_source": "declared",
|
||||
})
|
||||
for index in range(SAMPLES_PER_HOLD):
|
||||
frame_id += 1
|
||||
frames.append({"frame_id": frame_id, "t_rel_s": 0.0, "segment": 1,
|
||||
"segment_frame": frame_id, "in_video": 1, "dropped": 0,
|
||||
"capture_dropped": 0, "time_basis": basis,
|
||||
"t_host_mono_ns": start + 5_000_000 + index * 15_000_000})
|
||||
forces.append({"session_id": CAPTURE_SESSION_ID, "frame_id": frame_id, "t_rel_s": 0.0,
|
||||
"FN_raw": RAW_BASE + RAW_PER_N * load, "valid": 1})
|
||||
_write(trial / "reference.csv", REFERENCE_HEADER, rows)
|
||||
_write(trial / "events.csv", ("t_host_mono_ns", "kind", "detail"), [])
|
||||
if capture:
|
||||
(trial / "capture").mkdir()
|
||||
(trial / "capture" / "raw_0001.mp4").write_bytes(b"recorded segment fixture")
|
||||
_write(trial / "capture" / "frames.csv", FRAMES_HEADER, frames)
|
||||
_write(trial / "capture" / "forces.csv", FORCES_HEADER, forces)
|
||||
(trial / "capture" / "session.json").write_text(json.dumps({
|
||||
"session_id": CAPTURE_SESSION_ID, "metadata": {"images_only": True},
|
||||
}), encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
def _session(root: Path, *, repeats: int = 2, sites=("P1", "P2"), trial_status: str = "complete",
|
||||
capture: bool = True, basis: str = "host_camera", status: str = "complete") -> Path:
|
||||
"""One multi-point session with the pieces each test varies."""
|
||||
trials, completion = [], []
|
||||
for site_id in sites:
|
||||
for repeat in range(1, repeats + 1):
|
||||
directory = _trial(root, site_id, repeat, status=trial_status, capture=capture, basis=basis)
|
||||
trials.append({"trial_id": f"{site_id}-{repeat}", "site_id": site_id,
|
||||
"repeat_index": repeat, "directory": directory, "status": trial_status,
|
||||
"reason": ""})
|
||||
completion.append({"site_id": site_id, "repeats_planned": repeats,
|
||||
"repeats_complete": repeats if trial_status == "complete" else 0,
|
||||
"complete": trial_status == "complete"})
|
||||
manifest = {
|
||||
"format_version": "autocal-multipoint-1.0", "session_id": "mp-test",
|
||||
"sensor_model": "finger", "sensor_sn": "SN1", "status": status,
|
||||
"started_at": "2026-10-15T00:00:00+00:00", "coordinate_frame": "sensor",
|
||||
"platform_motion": False, "automatic_force_control": False,
|
||||
"min_force_n": 0.0, "max_force_n": 10.0,
|
||||
"sync": {"method": "host_monotonic_time_overlap"},
|
||||
"sites": [{"site_id": site_id, "position": {"x": 1.0, "y": 2.0, "z": 3.0},
|
||||
"coordinate_frame": "sensor", "position_frame": "sensor", "normal_sensor": None,
|
||||
"pose": None, "uv_px": {"u": 10 if site_id == "P1" else 30, "v": 20},
|
||||
"levels_n": [5.0, 10.0], "hold_s": 1.0,
|
||||
"repeats": repeats} for site_id in sites],
|
||||
"trials": trials, "completion": completion,
|
||||
}
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / "session.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def test_two_points_with_two_repeats_review_into_windows_curves_and_a_fit(tmp_path):
|
||||
root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234")
|
||||
|
||||
review = review_session(root)
|
||||
|
||||
assert review["source_format"] == "autocal-multipoint-1.0"
|
||||
assert review["session_status"] == "complete" and review["status"] == "exploratory"
|
||||
assert review["formal_conclusion"] is False and review["issues"] == []
|
||||
assert [site["site_id"] for site in review["sites"]] == ["P1", "P2"]
|
||||
first = review["sites"][0]
|
||||
assert first["uv_xy_px"] == [10, 20] and first["position_sensor_mm"] == [1.0, 2.0, 3.0]
|
||||
assert len(first["windows"]) == 2 * len(STEPS)
|
||||
assert all(window["status"] == "candidate" for window in first["windows"])
|
||||
assert all(window["samples"] == SAMPLES_PER_HOLD for window in first["windows"])
|
||||
# The declared steps are the loading and unloading curve, in recorded order.
|
||||
assert [window["branch"] for window in first["curve"]] == BRANCHES * 2
|
||||
assert [window["recorded_branch"] for window in first["curve"]][:5] == [s[0] for s in STEPS]
|
||||
assert first["fit"]["sensitivity_raw_per_n"] == pytest.approx(RAW_PER_N)
|
||||
assert first["fit"]["windows"] == 6
|
||||
assert first["coverage"] == {"repeats_planned": 2, "repeats_complete": 2,
|
||||
"trials": 2, "usable_trials": 2}
|
||||
assert {trial["trial_id"] for trial in review["coverage"]} == {"P1-1", "P1-2", "P2-1", "P2-2"}
|
||||
assert review["coverage"][0]["frames"] == len(STEPS) * SAMPLES_PER_HOLD
|
||||
assert review["coverage"][0]["segments"] == [1] and review["coverage"][0]["usable"] is True
|
||||
# Nothing was confirmed window by window, so the review stays exploratory.
|
||||
assert first["formal_conclusion"] is False and "unverified_window:0" in first["blockers"]
|
||||
|
||||
|
||||
def test_a_repeat_without_a_frame_source_reports_the_gap_not_a_zero_reading(tmp_path):
|
||||
root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234", capture=False)
|
||||
|
||||
review = review_session(root)
|
||||
|
||||
assert {issue["reason"] for issue in review["issues"]} == {"no_frame_source"}
|
||||
first = review["sites"][0]
|
||||
assert all(window["status"] == "no_frame_source" for window in first["windows"])
|
||||
assert all(window["samples"] == 0 and window["raw_mean"] is None for window in first["windows"])
|
||||
assert first["fit"] is None and first["coverage"]["usable_trials"] == 0
|
||||
assert {"no_fit", "no_frame_source"} <= set(first["blockers"])
|
||||
assert all(trial["frame_source"] is None and trial["usable"] is False for trial in review["coverage"])
|
||||
|
||||
|
||||
def test_a_replayed_time_basis_is_reported_instead_of_being_joined(tmp_path):
|
||||
root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234", basis="host_replay")
|
||||
|
||||
review = review_session(root)
|
||||
|
||||
assert {issue["reason"] for issue in review["issues"]} == {"time_basis_not_comparable"}
|
||||
assert review["coverage"][0]["time_bases"] == ["host_replay"]
|
||||
assert all(window["status"] == "time_basis_not_comparable" and window["samples"] == 0
|
||||
for window in review["sites"][0]["windows"])
|
||||
|
||||
|
||||
def test_an_unfinished_repeat_cannot_produce_a_conclusion(tmp_path):
|
||||
root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234",
|
||||
trial_status="incomplete", status="incomplete")
|
||||
|
||||
review = review_session(root)
|
||||
|
||||
assert review["session_status"] == "incomplete" and review["formal_conclusion"] is False
|
||||
assert {issue["reason"] for issue in review["issues"]} == {"incomplete_trial"}
|
||||
first = review["sites"][0]
|
||||
assert first["formal_conclusion"] is False and "incomplete_trial" in first["blockers"]
|
||||
assert first["coverage"]["repeats_complete"] == 0
|
||||
assert first["fit"] is None and first["confirmed_fit"] is None
|
||||
assert first["coverage"]["usable_trials"] == 0
|
||||
assert all(trial["status"] == "incomplete" for trial in review["coverage"])
|
||||
|
||||
|
||||
def test_missing_video_cannot_supply_candidate_windows(tmp_path):
|
||||
root = _session(tmp_path / "session")
|
||||
for video in root.glob("sites/*/repeat-*/capture/raw_0001.mp4"):
|
||||
video.unlink()
|
||||
review = review_session(root)
|
||||
assert all(site["fit"] is None for site in review["sites"])
|
||||
assert all(not trial["usable"] for trial in review["coverage"])
|
||||
|
||||
|
||||
def test_missing_geometry_is_not_reported_as_a_zero_coordinate(tmp_path):
|
||||
root = _session(tmp_path / "session")
|
||||
path = root / "session.json"
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
del manifest["sites"][0]["position"]["z"]
|
||||
path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
review = review_session(root)
|
||||
assert review["sites"][0]["position_sensor_mm"] is None
|
||||
|
||||
|
||||
def test_multipoint_overview_reads_capture_trace_and_shows_both_sites(qt_app, tmp_path):
|
||||
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
|
||||
|
||||
root = _session(tmp_path / "session")
|
||||
dialog = RecordOverviewDialog(root)
|
||||
try:
|
||||
assert dialog.table.rowCount() == 2
|
||||
report = dialog.diagnostics["P1-1"]
|
||||
assert len(report["trace"]) == len(STEPS) * SAMPLES_PER_HOLD
|
||||
assert not report["errors"]
|
||||
assert any(series.count() > 0 for series in dialog.timeline_chart.series())
|
||||
finally:
|
||||
dialog.close()
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_simulation_with_recorded_samples_cannot_supply_a_fit(tmp_path):
|
||||
root = _session(tmp_path / "session")
|
||||
path = root / "session.json"
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
manifest["simulate"] = True
|
||||
path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
review = review_session(root)
|
||||
assert all(site["fit"] is None for site in review["sites"])
|
||||
assert all(not trial["usable"] for trial in review["coverage"])
|
||||
|
||||
|
||||
def test_trial_metadata_cannot_override_incomplete_status(tmp_path):
|
||||
root = _session(tmp_path / "session")
|
||||
for path in root.glob("sites/*/repeat-*/session.json"):
|
||||
metadata = json.loads(path.read_text(encoding="utf-8"))
|
||||
metadata["status"] = "incomplete"
|
||||
path.write_text(json.dumps(metadata), encoding="utf-8")
|
||||
review = review_session(root)
|
||||
assert all(site["fit"] is None for site in review["sites"])
|
||||
assert all(not trial["usable"] for trial in review["coverage"])
|
||||
|
||||
|
||||
def test_reference_above_declared_session_range_is_refused(tmp_path):
|
||||
root = _session(tmp_path / "session")
|
||||
path = root / "sites/P1/repeat-001/reference.csv"
|
||||
rows = list(csv.DictReader(path.open(encoding="utf-8")))
|
||||
rows[1]["F_ref_normal_N"] = "11"
|
||||
_write(path, REFERENCE_HEADER, rows)
|
||||
with pytest.raises(ValueError, match="invalid reference window"):
|
||||
review_session(root)
|
||||
|
||||
|
||||
def test_a_reference_window_that_does_not_hold_is_refused(tmp_path):
|
||||
root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234")
|
||||
reference = root / "sites" / "P1" / "repeat-001" / "reference.csv"
|
||||
rows = list(csv.DictReader(reference.open(encoding="utf-8")))
|
||||
rows[1]["t_end_host_mono_ns"] = rows[1]["t_start_host_mono_ns"]
|
||||
_write(reference, REFERENCE_HEADER, rows)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid reference window"):
|
||||
review_session(root)
|
||||
|
||||
|
||||
def test_a_confirmed_selection_narrows_the_window_and_saves_a_derived_copy(tmp_path):
|
||||
root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234")
|
||||
before = (root / "session.json").read_bytes()
|
||||
rows = list(csv.DictReader(
|
||||
(root / "sites" / "P1" / "repeat-001" / "reference.csv").open(encoding="utf-8")))
|
||||
start = int(rows[1]["t_start_host_mono_ns"])
|
||||
# Three frames sit at +5, +20 and +35 ms inside every hold; confirming the first two
|
||||
# must narrow the window without touching the recorded file.
|
||||
selections = {"P1-1:1": {"t_start_host_mono_ns": start + 4_000_000,
|
||||
"t_end_host_mono_ns": start + 21_000_000,
|
||||
"stability_verified": True}}
|
||||
|
||||
review = review_session(root, selections=selections)
|
||||
|
||||
chosen = review["sites"][0]["windows"][1]
|
||||
assert chosen["stability_verified"] is True and chosen["samples"] == 2
|
||||
assert chosen["hold_start_host_mono_ns"] == start
|
||||
assert chosen["raw_mean"] == pytest.approx(RAW_BASE + RAW_PER_N * 5.0)
|
||||
assert review["sites"][0]["quality"]["basis"] == "confirmed_window_means"
|
||||
# One confirmed window is a confirmed mean, not a fit; the exploratory fit still uses
|
||||
# every candidate window the repeats provide.
|
||||
assert review["sites"][0]["confirmed_fit"] is None
|
||||
assert review["sites"][0]["fit"]["windows"] == 6
|
||||
saved = json.loads(save_review(review).read_text(encoding="utf-8"))
|
||||
assert saved["source_format"] == "autocal-multipoint-1.0" and saved["baselines"] == []
|
||||
assert (root / "session.json").read_bytes() == before
|
||||
assert (root / "sites" / "P1" / "repeat-001" / "reference.csv").is_file()
|
||||
with pytest.raises(ValueError, match="outside recorded hold"):
|
||||
review_session(root, selections={"P1-1:1": {"t_start_host_mono_ns": 0,
|
||||
"t_end_host_mono_ns": 1,
|
||||
"stability_verified": True}})
|
||||
Reference in New Issue
Block a user