Files
orisys-app/apps/autocal/src/orisys_autocal/preferences.py
T

421 lines
18 KiB
Python

"""AutoCal's preference schema and storage.
The shared store keys off whatever schema it is handed, so AutoCal gets its own
key set rather than inheriting the studio's camera/plot/3D keys. The values here
are the ones an operator would otherwise retype at every start-up.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from orisys_app_shared import prefs as _prefs
from orisys_app_shared.paths import preference_path
from .motion.service import (
DEFAULT_AXIS_ROLES,
DEFAULT_BAS_TASK,
PLATFORM_AXES,
clamp_bas_task,
)
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
#: Connection modes, in the order the combo offers them.
CONNECTION_MODES = ("eth", "com", "pci", "local", "demo")
#: Addresses the controller is normally reached on, tried first by a scan.
DEFAULT_CONTROLLER_IPS = ("192.168.0.11", "127.0.0.1")
#: Grid modes the sequence profile offers (workflow P5).
SURFACE_MODES = ("flat", "curved")
#: Point visiting orders (workflow P5).
POINT_ORDERS = ("row", "serpentine", "distance")
#: The design target is a well-lit bench, so light is the default; dark stays
#: selectable for a dim room.
DEFAULT_THEME = "light"
#: The sensor-design export the finger debug screen loads by default. The bench
#: confirmed this revision, and it carries the mesh, housing, intrinsics and the
#: pose the unit scale is derived from.
DEFAULT_FINGER_BUNDLE = (
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
)
#: The UV-space artwork for that revision. Resolved from the bundle when it can
#: be, so the two cannot be pointed at different revisions; this preference is
#: the override for a checkout that keeps its textures elsewhere.
DEFAULT_FINGER_TEXTURE = (
r"D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01"
r"\patch_texture_compute.png"
)
def _default_sequence_profile() -> dict[str, Any]:
"""The point-generation and run parameters of workflow P5.
One profile is global for now; named, saved sequences come with the runner.
The numbers are starting points an operator confirms at the bench, not
measured values — in particular ``home_mode``-style unknowns are left at 0 so
nothing is silently inherited from another machine.
"""
return {
# --- grid shape
"mode": "flat",
"pitch_mm": 2.0,
"margin_mm": 1.0,
"radius_mm": 0.0,
# --- motion
"clear_height_mm": 5.0,
"approach_speed_mm_s": 2.0,
"press_speed_mm_s": 0.5,
"press_depth_mm": 0.2,
"dwell_s": 0.5,
# --- measurement
"settle_window_s": 0.5,
"repeats": 3,
# --- order
"order": "serpentine",
}
def _default_safety_limits() -> dict[str, float]:
"""Workflow P0: the limits that must have a value before anything moves.
``0`` means "not set" for the ones where a real value is required, and the
app reports that at the P0 gate rather than treating it as unlimited.
Lengths are millimetres and angles degrees, because the roles they bound are:
one number cannot bound both, and a degree target checked against a millimetre
limit is not a check.
"""
return {
"max_travel_mm": 100.0,
"max_descent_mm": 10.0,
"max_press_depth_mm": 1.0,
"follow_error_mm": 0.5,
#: How far the axis may descend looking for contact before the run is
#: aborted (workflow P7, no-contact abort).
"contact_search_mm": 3.0,
#: Stage envelope. Yaw turns ±180. Pitch is limited to ±90 on this bench:
#: past that the fixture interferes, so a pose move is refused.
"max_yaw_deg": 180.0,
"max_pitch_deg": 90.0,
}
def _default_press_axis() -> dict[str, Any]:
"""How the press axis's coordinate relates to the geometric contact height.
The solved contact sits at ``Pose.contact_z_mm`` above the table's origin
plane, in millimetres of *geometry*. The press axis reports its own coordinate,
in its own units, counted from its own mechanical zero — the two are different
numbers, and only a measurement on the bench relates them.
``zero_mm`` is the press axis reading at which the tool tip meets the table's
origin plane (so the tool tip's own height is already inside it), and
``rising`` is the drive's sign: true when a larger coordinate lifts the tip.
``calibrated`` stays false until an operator has measured both against the
machine, and until then the pose-move path holds the press axis instead of
driving it to a geometric height nobody has anchored — the previous behaviour,
which passed the geometric number straight through as an absolute target.
"""
return {
"zero_mm": 0.0,
"rising": True,
"calibrated": False,
}
def _default_sensor_setup() -> dict[str, Any]:
"""Workflow P3/P4: what is under test, what touches it, and the transform.
``sensor_to_platform`` stays empty until the transform is established by
probing known features; the residual travels with it so a re-fixtured sensor
cannot silently reuse a stale transform.
"""
return {
"sensor_type": "box",
"config_ref": "",
"geometry_id": "",
#: The probe tip's offset from the platform's reference point. Empty offset
#: means "not set", which blocks P5. Which axis presses is **not** here: it
#: is ``axis_roles["z"]``, because one fact written twice is one fact that
#: eventually disagrees.
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
"probe_tip_set": False,
#: 4x4 row-major sensor -> platform transform, plus how well it fitted.
"sensor_to_platform": [],
"transform_residual_mm": 0.0,
"transform_feature_count": 0,
}
def _default_axis_params() -> dict[str, dict[str, float | int]]:
"""One parameter block per platform axis.
``home_mode`` starts at 0, which is **not** a valid CiA-402 homing mode:
the operator must choose one for the drive in front of them rather than
inherit a number from another machine. The reference program pre-filled 17,
which is a specific drive's mode, so it is deliberately not copied here.
"""
return {
str(axis): {
"units": DEFAULT_UNITS,
"speed": DEFAULT_SPEED,
"accel": DEFAULT_ACCEL,
"creep": 10.0,
"home_mode": 0,
"home_high": 50.0,
"home_low": 10.0,
"home_offset": 0.0,
}
for axis in range(PLATFORM_AXES)
}
def default_preferences() -> dict[str, Any]:
"""AutoCal's default preference document."""
return {
"language": "zh_CN",
"theme": DEFAULT_THEME,
"window_geometry": None,
"connection": {
"mode": "eth",
"ip": DEFAULT_CONTROLLER_IPS[0],
"com_id": 0,
"card_num": 0,
"local_string": "0",
},
# Remembered so "download the BAS program" is one click after the first
# time, instead of re-picking the file every session.
"bas_path": "",
#: The controller task the BAS program runs on. The vendor reference
#: starts it on 4 and re-initialises it with ``RUN "<file>",4``; stopping
#: any other task restarts nothing. Kept configurable because the task
#: number is a property of the project loaded on the controller.
"bas_task": DEFAULT_BAS_TASK,
#: Displayed millimetres are derived from this; it is a bench-measured
#: constant, not a calibrated length (see motion/units.py).
"units_per_mm": DEFAULT_UNITS,
"state_poll_ms": 100,
"axis_params": _default_axis_params(),
#: {node, index, subindex, type} of the last SDO read/written, so the
#: operator can repeat a drive-setting check quickly.
"sdo_last": {"node": 0, "index": 24704, "subindex": 0, "type": 0},
"rtex_last": {"axis": 0, "parameter": 0},
"show_diagnostics": False,
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
#: P0: limits enforced from P2 onward.
"safety_limits": _default_safety_limits(),
#: Which drive is which platform direction, from the bench wiring: drives
#: 0-4 are x, y, pitch, yaw, z. There is no way to read this off the bus —
#: five CiA-402 axes report numbers, not names — so it is configuration, and
#: the pose-move path refuses to run until it is complete and free of
#: duplicates. It is also the *only* place "which axis presses" is written:
#: the sensor setup used to carry a second copy, which is how the two would
#: have drifted apart.
"axis_roles": dict(DEFAULT_AXIS_ROLES),
#: Ask before a pose move. On by default — the first moves on a machine are
#: the ones worth reading — and turnable off once the numbers are trusted,
#: because a prompt that cannot be dismissed unread is one that gets clicked
#: through without being read.
"move_confirm": True,
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
"sensor_setup": _default_sensor_setup(),
#: P5: grid generation and run parameters.
"sequence_profile": _default_sequence_profile(),
#: The press axis's mechanical zero and sign, so a solved contact height
#: can become a controller target. Uncalibrated means the press axis is
#: held rather than driven.
"press_axis": _default_press_axis(),
# --- finger debug screen ---------------------------------------------
#: The export bundle the debug screen loads. Empty means "ask", and the
#: default below points at the revision the bench confirmed.
"finger_bundle_path": DEFAULT_FINGER_BUNDLE,
"finger_video_path": "",
"finger_config_ref": "",
"finger_source_kind": "video",
"finger_camera_index": 0,
#: The UV-space texture for the flat picker. Preferred over the derived
#: path when it exists; empty falls back to the layout map.
"finger_texture_path": DEFAULT_FINGER_TEXTURE,
#: How the sensor actually sits on the stack when the machine is at zero:
#: x/y/z translation along the table's axes and a yaw about its z. **The z
#: is the mounting plate**, so the plate is not a setting of its own — it
#: lifts the sensing face and does nothing else, and two fields for one
#: number drift apart. The bench plate under the finger is 8 mm thick.
"finger_sensor_offset_mm": [0.0, 0.0, 8.0],
# Tip points forward, away from the operator. 180° turned it back toward
# the operator; the preview then showed the green tip on the near side.
"finger_sensor_yaw_deg": 0.0,
#: Where the tool is, in the table frame, mm. A tool-tip offset belongs
#: here. The bench X shift is the initial position, not this number.
"finger_tool_xy_mm": [0.0, 3.0],
#: Where 「回初始位」 parks the table, mm. After the power cycle the
#: coordinate origin sits on the previous park, so this is the origin:
#: one millimetre back in X and 15 mm back in Y from the old target.
"table_initial_x_mm": 0.0,
"table_initial_y_mm": 0.0,
#: Stage travel, degrees. Yaw turns ±180. Pitch is held to ±90 because the
#: fixture interferes outside that.
"finger_yaw_limit_deg": 180.0,
"finger_pitch_limit_deg": 90.0,
}
#: Preference blocks whose *keys* are added to over time. Stored preferences are
#: merged one level deep, so a block saved by an older build replaces the whole
#: default block and any key added since reads as absent. For a P0 limit that is not
#: a cosmetic problem: the move is refused with "the limit is not set" while the file
#: in front of the operator says nothing about it. Filling the block key by key keeps
#: what was chosen and supplies only what was never there.
_MERGED_BLOCKS = (
"connection",
"safety_limits",
"sensor_setup",
"sequence_profile",
)
def load_preferences() -> dict[str, Any]:
"""Stored preferences with AutoCal's defaults filling the gaps."""
defaults = default_preferences()
prefs = _prefs.load_preferences(defaults)
for name in _MERGED_BLOCKS:
stored = prefs.get(name)
if not isinstance(stored, dict):
continue
block = dict(defaults.get(name) or {})
block.update(stored)
prefs[name] = block
# A stored document from an older build may miss a newly added axis block.
axis_params = prefs.setdefault("axis_params", {})
for axis, values in _default_axis_params().items():
axis_params.setdefault(axis, values)
# The mounting plate used to be a setting of its own. It is now the z of the
# sensor's mounting pose, so a stored value is carried over rather than quietly
# dropped — a machine that had it tuned would otherwise come back 4 mm off.
plate = prefs.pop("finger_plate_thickness_mm", None)
try:
plate = float(plate) if plate is not None else 0.0
except (TypeError, ValueError):
plate = 0.0
offset = prefs.get("finger_sensor_offset_mm")
offset = list(offset) if isinstance(offset, list) else [0.0, 0.0, 0.0]
while len(offset) < 3:
offset.append(0.0)
if plate and not offset[2]:
offset[2] = plate
# A session started before the plate was entered saves Z as 0 and would put
# that 0 back over the 8 mm bench plate. Keep a stored non-zero thickness.
if not offset[2]:
offset[2] = float(defaults["finger_sensor_offset_mm"][2])
prefs["finger_sensor_offset_mm"] = offset
# The mounted shell needs no extra yaw: the tip points forward, away from
# the operator. A running session still has 180° in memory and writes it
# back on close, which would turn the tip toward the operator again.
prefs["finger_sensor_yaw_deg"] = float(defaults["finger_sensor_yaw_deg"])
# The tool tip offset used to be a vector added to the reported coordinates
# while the drawing ignored it, so the picture and the numbers disagreed by
# exactly that much. It is the tool's position now: fold a stored x/y into it,
# and take whatever is left out of the document so nothing reads it again. Its
# z had no meaning the model kept — the tool's height is the Z axis's job — so
# it is dropped, deliberately and visibly rather than silently applied.
legacy = prefs.pop("finger_tool_offset_mm", None)
tool = prefs.get("finger_tool_xy_mm")
tool = list(tool) if isinstance(tool, list) else [0.0, 0.0]
while len(tool) < 2:
tool.append(0.0)
if isinstance(legacy, list) and len(legacy) >= 2:
for index in range(2):
try:
value = float(legacy[index])
except (TypeError, ValueError):
continue
if value and not tool[index]:
tool[index] = value
prefs["finger_tool_xy_mm"] = tool
# Closing AutoCal writes the whole in-memory file back. A session that started
# on the previous initial position would otherwise put that old Y over this one.
prefs["table_initial_x_mm"] = defaults["table_initial_x_mm"]
prefs["table_initial_y_mm"] = defaults["table_initial_y_mm"]
# 360° was the old pitch envelope. This fixture interferes outside ±90, and a
# session started on 360 would write that envelope back on close.
limits = prefs.get("safety_limits")
if isinstance(limits, dict):
try:
if float(limits.get("max_pitch_deg")) == 360.0:
limits["max_pitch_deg"] = 90.0
except (TypeError, ValueError):
limits["max_pitch_deg"] = 90.0
try:
if float(prefs.get("finger_pitch_limit_deg")) == 360.0:
prefs["finger_pitch_limit_deg"] = 90.0
except (TypeError, ValueError):
prefs["finger_pitch_limit_deg"] = 90.0
# Yaw travel is ±180. The same-sign pitch solution sits near ±150° of yaw,
# and a stored ±90 drops it, leaving the opposite pitch sign on one side.
prefs["finger_yaw_limit_deg"] = float(defaults["finger_yaw_limit_deg"])
# The solver's envelope and the move guard's are one bench fact written twice: a
# measured point has two exact solutions, the second past 118° of yaw, and a file
# saved on the old ±90 would have the solver call a pose reachable while the guard
# refused the 118° the move needs. Normalise both spellings, as the pitch envelope
# above is normalised, so "cannot reach" can only mean the stage really cannot.
if isinstance(limits, dict):
limits["max_yaw_deg"] = float(defaults["safety_limits"]["max_yaw_deg"])
# A hand-edited task number must not reach the controller as typed.
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
prefs = _migrate_axis_roles(prefs)
return prefs
def _migrate_axis_roles(prefs: dict[str, Any]) -> dict[str, Any]:
"""Complete the axis mapping, and take the old duplicate press axis out.
``sensor_setup.press_axis`` named the pressing drive on its own. That is
``axis_roles["z"]`` now — the same fact — and a stored value is moved there when
the mapping has not been set, so a bench that had it configured keeps working.
The old key is removed either way: leaving it would keep a second answer to
"which axis presses" in the file.
"""
roles = prefs.get("axis_roles")
roles = dict(roles) if isinstance(roles, dict) else {}
sensor = prefs.get("sensor_setup")
if isinstance(sensor, dict):
legacy = sensor.pop("press_axis", None)
if legacy is not None and "z" not in roles:
try:
roles["z"] = int(legacy)
except (TypeError, ValueError):
pass
merged = dict(DEFAULT_AXIS_ROLES)
# A stored mapping wins where it is usable; the default fills what it does not
# cover, so an old document that only named the press axis still comes up with a
# complete mapping rather than a refusal.
for role, value in roles.items():
try:
merged[role] = int(value)
except (TypeError, ValueError):
continue
# The first bench note had these two drives swapped. A session that still
# has that pair would otherwise put it back on close.
if merged.get("pitch") == 2 and merged.get("yaw") == 3:
merged["pitch"] = 3
merged["yaw"] = 2
prefs["axis_roles"] = merged
return prefs
def save_preferences(prefs: dict[str, Any]) -> Path:
return _prefs.save_preferences(prefs, default_preferences())
def path() -> Path:
"""Where AutoCal's ``preference.json`` lives (for the About box)."""
return preference_path()