Files
orisys-app/apps/autocal/src/orisys_autocal/preferences.py
T
2026-09-27 19:20:43 +08:00

343 lines
15 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, from the confirmed bench values: the yaw stage turns a
#: half-turn either way, the pitch stage a full turn. These are the limits a
#: pose move is checked against before any axis is commanded.
"max_yaw_deg": 180.0,
"max_pitch_deg": 360.0,
}
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(),
# --- 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,
#: 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.
"finger_sensor_offset_mm": [0.0, 0.0, 0.0],
"finger_sensor_yaw_deg": 0.0,
#: Where the tool is, in the table frame, mm. Straight above the table
#: origin unless the real machine says otherwise; every XY move is measured
#: against it, so it is what makes the reported move match the bench. A tool
#: tip offset belongs here — it used to be a separate field, and having two
#: of them is what let the picture and the numbers disagree.
"finger_tool_xy_mm": [0.0, 0.0],
#: Stage travel, degrees. Confirmed at the bench: the vertical stage turns
#: a full half-turn either way (±180), and the horizontal stage turns
#: continuously. An earlier value of 90 was wrong and made half the patch
#: look unreachable when it is not.
"finger_yaw_limit_deg": 180.0,
"finger_pitch_limit_deg": 360.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
prefs["finger_sensor_offset_mm"] = offset
# 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
# 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
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()