343 lines
15 KiB
Python
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()
|