dsh-engineering-ui 0.0.0-stage → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +772 -3
- package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
- package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
- package/engineering/plugins/dsh-engineering-ui/lib/client.js +8257 -0
- package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
- package/engineering/plugins/dsh-engineering-ui/lib/index.js +5634 -0
- package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
- package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
- package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
- package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
- package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
- package/engineering/skills/cad-workflow/SKILL.md +132 -0
- package/engineering/skills/mode-selection/SKILL.md +660 -0
- package/engineering/skills/physics-in-loop/SKILL.md +238 -0
- package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
- package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
- package/engineering/skills/sw-design/SKILL.md +112 -0
- package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
- package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
- package/engineering/skills/sw-to-cad/SKILL.md +121 -0
- package/engineering/skills/webots-simulation/SKILL.md +58 -0
- package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
- package/engineering/tools/_plugin_paths.py +93 -0
- package/engineering/tools/_root.py +330 -0
- package/engineering/tools/_store.py +446 -0
- package/engineering/tools/ac_bridge.py +459 -0
- package/engineering/tools/ac_rules.json +21 -0
- package/engineering/tools/ac_validate.py +699 -0
- package/engineering/tools/ask_user.py +887 -0
- package/engineering/tools/build_arm1.py +46 -0
- package/engineering/tools/build_assembly.py +94 -0
- package/engineering/tools/build_base.py +57 -0
- package/engineering/tools/build_housing.py +70 -0
- package/engineering/tools/build_structural.py +43 -0
- package/engineering/tools/build_transmission.py +69 -0
- package/engineering/tools/cadx.lsp +128 -0
- package/engineering/tools/choice_contract.py +61 -0
- package/engineering/tools/conn_state.py +1671 -0
- package/engineering/tools/defense_gate.py +3374 -0
- package/engineering/tools/drawing_input.py +314 -0
- package/engineering/tools/env_check.py +467 -0
- package/engineering/tools/examples/build_axis.py +142 -0
- package/engineering/tools/examples/build_cup.py +56 -0
- package/engineering/tools/examples/build_plate.py +58 -0
- package/engineering/tools/examples/build_sphere.py +30 -0
- package/engineering/tools/examples/build_transition_duct.py +381 -0
- package/engineering/tools/gate_integrity.py +401 -0
- package/engineering/tools/mode2_flow.py +3589 -0
- package/engineering/tools/mode_checker.py +77 -0
- package/engineering/tools/mode_gate.py +8013 -0
- package/engineering/tools/part_registry.py +4266 -0
- package/engineering/tools/physics/__init__.py +50 -0
- package/engineering/tools/physics/_compat.py +12 -0
- package/engineering/tools/physics/design_state.py +142 -0
- package/engineering/tools/physics/domain_validator.py +751 -0
- package/engineering/tools/physics/fatigue.py +489 -0
- package/engineering/tools/physics/fea_solver.py +1163 -0
- package/engineering/tools/physics/feapy_solver.py +314 -0
- package/engineering/tools/physics/gb_materials.json +1228 -0
- package/engineering/tools/physics/geometry_gate.py +337 -0
- package/engineering/tools/physics/load_case.py +951 -0
- package/engineering/tools/physics/material_db.py +874 -0
- package/engineering/tools/physics/mesh_adapter.py +321 -0
- package/engineering/tools/physics/refine_rules.py +272 -0
- package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
- package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
- package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
- package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
- package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
- package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
- package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
- package/engineering/tools/physics/rules/housing_rules.json +48 -0
- package/engineering/tools/physics/rules/manifest.json +54 -0
- package/engineering/tools/physics/rules/mold_rules.json +48 -0
- package/engineering/tools/physics/rules/structural_rules.json +56 -0
- package/engineering/tools/physics/rules/transmission_rules.json +48 -0
- package/engineering/tools/physics/simulation_report.py +829 -0
- package/engineering/tools/physics_bridge.py +2685 -0
- package/engineering/tools/profile-manifest.ps1 +72 -0
- package/engineering/tools/run_regression.py +109 -0
- package/engineering/tools/settings.py +419 -0
- package/engineering/tools/solidworks-modeling.md +519 -0
- package/engineering/tools/sw_bridge.py +8215 -0
- package/engineering/tools/sw_guard.py +1066 -0
- package/engineering/tools/sw_surface.py +87 -0
- package/engineering/tools/swapi.py +14766 -0
- package/engineering/tools/verify_sync.py +218 -0
- package/engineering/tools/visual_check.py +2589 -0
- package/engineering/tools/webots_env.py +121 -0
- package/engineering/tools/webots_mcp/__init__.py +3 -0
- package/engineering/tools/webots_mcp/__main__.py +3 -0
- package/engineering/tools/webots_mcp/assembly.py +391 -0
- package/engineering/tools/webots_mcp/bootstrap.py +55 -0
- package/engineering/tools/webots_mcp/controller_worker.py +124 -0
- package/engineering/tools/webots_mcp/evidence.py +288 -0
- package/engineering/tools/webots_mcp/fake_worker.py +69 -0
- package/engineering/tools/webots_mcp/fault_resources.py +121 -0
- package/engineering/tools/webots_mcp/host.py +45 -0
- package/engineering/tools/webots_mcp/ledger.py +73 -0
- package/engineering/tools/webots_mcp/owned_job.py +70 -0
- package/engineering/tools/webots_mcp/physical_gate.py +187 -0
- package/engineering/tools/webots_mcp/physics.py +152 -0
- package/engineering/tools/webots_mcp/pipeline.py +108 -0
- package/engineering/tools/webots_mcp/recipes.py +129 -0
- package/engineering/tools/webots_mcp/repair.py +132 -0
- package/engineering/tools/webots_mcp/repair_trial.py +375 -0
- package/engineering/tools/webots_mcp/runtime.py +311 -0
- package/engineering/tools/webots_mcp/server.py +217 -0
- package/engineering/tools/webots_mcp/sw_export.py +509 -0
- package/engineering/tools/webots_mcp/transport.py +156 -0
- package/engineering/tools/workflow_gate.py +6134 -0
- package/package.json +66 -6
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Single live owner, durable intent and conservative crash recovery."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def now():
|
|
9
|
+
return datetime.now(timezone.utc).isoformat()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Ledger:
|
|
13
|
+
def __init__(self, root):
|
|
14
|
+
import msvcrt
|
|
15
|
+
from .evidence import _no_reparse
|
|
16
|
+
_no_reparse(root)
|
|
17
|
+
for name in ("webots_instance.lock","webots_jobs.sqlite3","webots_jobs.sqlite3-wal",
|
|
18
|
+
"webots_jobs.sqlite3-shm","webots_jobs.sqlite3-journal","webots_state.json"):
|
|
19
|
+
_no_reparse(root/name)
|
|
20
|
+
self.lock = (root / "webots_instance.lock").open("a+b")
|
|
21
|
+
self.lock.seek(0)
|
|
22
|
+
try:
|
|
23
|
+
msvcrt.locking(self.lock.fileno(), msvcrt.LK_NBLCK, 1)
|
|
24
|
+
self.db = sqlite3.connect(root / "webots_jobs.sqlite3", timeout=.2)
|
|
25
|
+
self.db.row_factory = sqlite3.Row
|
|
26
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
27
|
+
self.db.execute("PRAGMA synchronous=FULL")
|
|
28
|
+
self.db.execute("""CREATE TABLE IF NOT EXISTS actions(
|
|
29
|
+
op TEXT PRIMARY KEY, digest TEXT NOT NULL, task TEXT NOT NULL, room TEXT NOT NULL,
|
|
30
|
+
job TEXT UNIQUE NOT NULL, run TEXT NOT NULL, gen INTEGER NOT NULL,
|
|
31
|
+
phase TEXT NOT NULL, result TEXT NOT NULL, updated TEXT NOT NULL)""")
|
|
32
|
+
with self.db:
|
|
33
|
+
for row in self.db.execute("SELECT * FROM actions WHERE phase IN ('accepted','dispatched')").fetchall():
|
|
34
|
+
dispatched = row["phase"] == "dispatched"
|
|
35
|
+
self.finish(row["op"], "unknown" if dispatched else "rejected",
|
|
36
|
+
error_code="OUTCOME_UNKNOWN" if dispatched else "INTERRUPTED_BEFORE_DISPATCH",
|
|
37
|
+
execution_state="unknown" if dispatched else "not_started",
|
|
38
|
+
ok=False, job_state="UNKNOWN" if dispatched else "FAILED", stale=True)
|
|
39
|
+
except BaseException:
|
|
40
|
+
if hasattr(self, "db"):
|
|
41
|
+
self.db.close()
|
|
42
|
+
self.lock.close()
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
def close(self):
|
|
46
|
+
self.db.close()
|
|
47
|
+
self.lock.close()
|
|
48
|
+
|
|
49
|
+
def lookup(self, key, value):
|
|
50
|
+
assert key in ("op", "job")
|
|
51
|
+
return self.db.execute(f"SELECT * FROM actions WHERE {key}=?", (value,)).fetchone()
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def digest(name, args):
|
|
55
|
+
canonical = json.dumps([name, {k:v for k,v in args.items() if k != "request_id"}],
|
|
56
|
+
sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
57
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
58
|
+
|
|
59
|
+
def accept(self, args, digest, result):
|
|
60
|
+
op = args["operation_id"]
|
|
61
|
+
with self.db:
|
|
62
|
+
self.db.execute("INSERT INTO actions VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
63
|
+
(op,digest,args["task_id"],args["room_id"],result["job_id"],result["run_id"],
|
|
64
|
+
result["session_generation"],"accepted",json.dumps(result,allow_nan=False),now()))
|
|
65
|
+
|
|
66
|
+
def finish(self, op, phase, **updates):
|
|
67
|
+
row = self.lookup("op", op)
|
|
68
|
+
old = json.loads(row["result"])
|
|
69
|
+
old.update(updates, snapshot_at=now())
|
|
70
|
+
with self.db:
|
|
71
|
+
self.db.execute("UPDATE actions SET phase=?,result=?,updated=? WHERE op=?",
|
|
72
|
+
(phase,json.dumps(old,allow_nan=False),old["snapshot_at"],op))
|
|
73
|
+
return old
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Keep Windows descendants inside an unnamed kill-on-close Job from birth."""
|
|
2
|
+
import subprocess
|
|
3
|
+
import time
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import win32api
|
|
7
|
+
import win32con
|
|
8
|
+
import win32job
|
|
9
|
+
import win32process
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OwnedJob:
|
|
14
|
+
def __init__(self,max_processes=None):
|
|
15
|
+
self.handle = win32job.CreateJobObject(None, "A04-" + uuid4().hex)
|
|
16
|
+
limits = win32job.QueryInformationJobObject(self.handle, win32job.JobObjectExtendedLimitInformation)
|
|
17
|
+
limits["BasicLimitInformation"]["LimitFlags"] = win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
18
|
+
if max_processes is not None:
|
|
19
|
+
if type(max_processes) is not int or not 1<=max_processes<=64:
|
|
20
|
+
self.handle.Close()
|
|
21
|
+
raise ValueError("Process ceiling must be an integer in 1..64")
|
|
22
|
+
limits["BasicLimitInformation"]["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_ACTIVE_PROCESS
|
|
23
|
+
limits["BasicLimitInformation"]["ActiveProcessLimit"] = max_processes
|
|
24
|
+
win32job.SetInformationJobObject(self.handle, win32job.JobObjectExtendedLimitInformation, limits)
|
|
25
|
+
|
|
26
|
+
def spawn(self, argv, env, cwd):
|
|
27
|
+
process, thread, pid, _ = win32process.CreateProcess(argv[0], subprocess.list2cmdline(argv),
|
|
28
|
+
None, None, False, win32con.CREATE_SUSPENDED | win32con.CREATE_NO_WINDOW | win32process.BELOW_NORMAL_PRIORITY_CLASS,
|
|
29
|
+
env, str(cwd), win32process.STARTUPINFO())
|
|
30
|
+
try:
|
|
31
|
+
win32job.AssignProcessToJobObject(self.handle, process)
|
|
32
|
+
identity = self.identity(pid)
|
|
33
|
+
win32process.ResumeThread(thread)
|
|
34
|
+
return identity
|
|
35
|
+
except BaseException:
|
|
36
|
+
win32api.TerminateProcess(process, 90)
|
|
37
|
+
raise
|
|
38
|
+
finally:
|
|
39
|
+
thread.Close()
|
|
40
|
+
process.Close()
|
|
41
|
+
|
|
42
|
+
def identity(self, pid):
|
|
43
|
+
handle = win32api.OpenProcess(win32con.PROCESS_QUERY_INFORMATION | win32con.PROCESS_VM_READ, False, pid)
|
|
44
|
+
try:
|
|
45
|
+
if not win32job.IsProcessInJob(handle, self.handle):
|
|
46
|
+
raise PermissionError("Peer outside owned Job")
|
|
47
|
+
return dict(pid=pid, creation_time=str(win32process.GetProcessTimes(handle)["CreationTime"]),
|
|
48
|
+
exe=win32process.GetModuleFileNameEx(handle, 0))
|
|
49
|
+
finally:
|
|
50
|
+
handle.Close()
|
|
51
|
+
|
|
52
|
+
def active(self):
|
|
53
|
+
return win32job.QueryInformationJobObject(self.handle,
|
|
54
|
+
win32job.JobObjectBasicAccountingInformation)["ActiveProcesses"]
|
|
55
|
+
|
|
56
|
+
def stop(self):
|
|
57
|
+
# No persisted PID is ever reopened for termination.
|
|
58
|
+
if self.handle is None:
|
|
59
|
+
return
|
|
60
|
+
try:
|
|
61
|
+
if self.active():
|
|
62
|
+
win32job.TerminateJobObject(self.handle, 91)
|
|
63
|
+
until = time.monotonic() + 2
|
|
64
|
+
while self.active() and time.monotonic() < until:
|
|
65
|
+
time.sleep(.01)
|
|
66
|
+
if self.active():
|
|
67
|
+
raise TimeoutError("Job termination unconfirmed")
|
|
68
|
+
finally:
|
|
69
|
+
self.handle.Close()
|
|
70
|
+
self.handle = None
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Judge a bounded rigid-body pair experiment from two complete observation traces."""
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def number(x):
|
|
7
|
+
if type(x) not in (float,int) or not math.isfinite(x):
|
|
8
|
+
raise ValueError("Expected finite measurement")
|
|
9
|
+
return x
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def digest(x):
|
|
13
|
+
if not isinstance(x,str) or not re.fullmatch(r"[a-f0-9]{64}",x):
|
|
14
|
+
raise ValueError("Missing SHA256 evidence binding")
|
|
15
|
+
return x
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CONDITIONS = ("model","scene_except_step","actions","initial_state","materials","thresholds",
|
|
19
|
+
"coverage","calibration","sensor_binding")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sample_from_observations(store,run,receipts,*,sensor,unit,pairs):
|
|
23
|
+
"""Decode one aligned SI position sample and exact-node contacts from owned snapshots.
|
|
24
|
+
|
|
25
|
+
Stability metric in this supported recipe is peak monitored body angular speed (rad/s).
|
|
26
|
+
Physical calibration/coverage is evaluated separately; empty contacts alone cannot pass.
|
|
27
|
+
"""
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
if not pairs or len(pairs)>64 or len(receipts)>129:
|
|
31
|
+
raise ValueError("Bounded explicit collision pairs required")
|
|
32
|
+
bodies = {}
|
|
33
|
+
measurement = None
|
|
34
|
+
scope = None
|
|
35
|
+
provenance = None
|
|
36
|
+
for ref in receipts:
|
|
37
|
+
origin = ref["provenance"]["kind"]
|
|
38
|
+
if ref["kind"] != "observation" or origin not in ("real-webots","synthetic"):
|
|
39
|
+
raise ValueError("Wrong observation source")
|
|
40
|
+
actual = store.file_evidence(run,ref["path"],kind=ref["kind"],provenance=ref["provenance"],
|
|
41
|
+
producer=ref["producer"])
|
|
42
|
+
if actual != ref or actual["size_bytes"] > 65536:
|
|
43
|
+
raise ValueError("Observation index mismatch")
|
|
44
|
+
with store._path(f"webots_runs/{run}/{ref['path']}").open("rb") as file:
|
|
45
|
+
raw = file.read(65537)
|
|
46
|
+
if hashlib.sha256(raw).hexdigest()!=ref["sha256"]:
|
|
47
|
+
raise ValueError("Observation changed")
|
|
48
|
+
record = json.loads(raw)
|
|
49
|
+
identity = tuple(record[k] for k in ("run_id","generation","robot","world_sha256","model_sha256",
|
|
50
|
+
"step","sim_time"))
|
|
51
|
+
if record["run_id"] != run or (scope is not None and (scope!=identity or provenance!=origin)):
|
|
52
|
+
raise ValueError("Observations from different physics instants")
|
|
53
|
+
scope,provenance = identity,origin
|
|
54
|
+
m = record["measurement"]
|
|
55
|
+
if record["action"]=="sim_sensor" and m["device"]==sensor and m["unit"]==unit and unit in ("m","rad"):
|
|
56
|
+
if measurement is not None or m["device_type"]!="PositionSensor":
|
|
57
|
+
raise ValueError("Ambiguous sensor binding")
|
|
58
|
+
measurement = number(m["value"])
|
|
59
|
+
elif record["action"]=="sim_contacts" and m["include_descendants"] is False and m["boundingObject"]:
|
|
60
|
+
if m["node_def"] in bodies:
|
|
61
|
+
raise ValueError("Duplicate body snapshot")
|
|
62
|
+
bodies[m["node_def"]]=m
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError("Unexpected observation for recipe")
|
|
65
|
+
if measurement is None or set(bodies)!={body for pair in pairs.values() for body in pair}:
|
|
66
|
+
raise ValueError("Missing sensor or required body")
|
|
67
|
+
contacts = {}
|
|
68
|
+
max_speed = 0.
|
|
69
|
+
for body in bodies.values():
|
|
70
|
+
velocity = body["velocity_world"]
|
|
71
|
+
if len(velocity)!=6:
|
|
72
|
+
raise ValueError("Missing rigid-body velocity")
|
|
73
|
+
max_speed = max(max_speed,math.hypot(*(number(v) for v in velocity[3:])))
|
|
74
|
+
for label,(a,b) in pairs.items():
|
|
75
|
+
left,right=bodies[a],bodies[b]
|
|
76
|
+
if left["node_id"]==right["node_id"]:
|
|
77
|
+
raise ValueError("Pair resolves to the same body")
|
|
78
|
+
direct = any(p["node_id"]==right["node_id"] for p in left["contacts"])
|
|
79
|
+
reverse = any(p["node_id"]==left["node_id"] for p in right["contacts"])
|
|
80
|
+
if direct != reverse:
|
|
81
|
+
raise ValueError("Contact endpoints disagree")
|
|
82
|
+
contacts[label]=direct
|
|
83
|
+
ms=number(scope[-1])*1000
|
|
84
|
+
if not math.isclose(ms,round(ms),rel_tol=0,abs_tol=1e-6):
|
|
85
|
+
raise ValueError("Nonintegral simulation clock")
|
|
86
|
+
return dict(time_ms=round(ms),contacts=contacts,value=measurement,stability_error=max_speed)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def evaluate(coarse,fine,policy):
|
|
90
|
+
"""Synthetic traces exercise identical decisions but cannot pass a physical gate."""
|
|
91
|
+
result = dict(verdict="UNKNOWN",physical_valid=False,provenance="synthetic",reasons=[])
|
|
92
|
+
try:
|
|
93
|
+
real = all(trace["provenance"] == "real-webots" for trace in (coarse,fine))
|
|
94
|
+
if real:
|
|
95
|
+
result["provenance"] = "real-webots"
|
|
96
|
+
elif any(trace["provenance"] != "synthetic" for trace in (coarse,fine)):
|
|
97
|
+
raise ValueError("Mixed or unsupported provenance")
|
|
98
|
+
if coarse["run_id"] == fine["run_id"] or not coarse["run_id"] or not fine["run_id"]:
|
|
99
|
+
raise ValueError("Two distinct runs required")
|
|
100
|
+
if not isinstance(coarse["robot"],str) or not coarse["robot"] or coarse["robot"] != fine["robot"]:
|
|
101
|
+
raise ValueError("Different or missing robot identity")
|
|
102
|
+
for trace in (coarse,fine):
|
|
103
|
+
digest(trace["world_sha256"])
|
|
104
|
+
for key in CONDITIONS:
|
|
105
|
+
if digest(coarse["conditions"][key]) != digest(fine["conditions"][key]):
|
|
106
|
+
raise ValueError("Different condition: "+key)
|
|
107
|
+
if coarse["seed"] != fine["seed"] or any(type(t["seed"]) is not int for t in (coarse,fine)):
|
|
108
|
+
raise ValueError("Incomparable seed")
|
|
109
|
+
dc,df = coarse["step_ms"],fine["step_ms"]
|
|
110
|
+
if type(dc) is not int or type(df) is not int or not 0 < df <= 1000 or dc != 2*df:
|
|
111
|
+
raise ValueError("Expected step-halving comparison")
|
|
112
|
+
if policy["topology"] != "open-rigid":
|
|
113
|
+
raise ValueError("Closed-loop/flexible/global constraint validity unsupported")
|
|
114
|
+
pairs = policy["pairs"]
|
|
115
|
+
if not isinstance(pairs,dict) or not 0 < len(pairs)<=64 or not policy["unit"] or not policy["metric"]:
|
|
116
|
+
raise ValueError("Explicit pairs, measurement and unit required")
|
|
117
|
+
tolerance = number(policy["convergence_absolute"])
|
|
118
|
+
stable_limit = number(policy["stability_limit"])
|
|
119
|
+
if min(tolerance,stable_limit) < 0:
|
|
120
|
+
raise ValueError("Negative tolerance")
|
|
121
|
+
threshold = number(policy["metric_max"])
|
|
122
|
+
from hashlib import sha256
|
|
123
|
+
import json
|
|
124
|
+
policy_hash = sha256(json.dumps(policy,sort_keys=True,separators=(",",":"),allow_nan=False).encode()).hexdigest()
|
|
125
|
+
if coarse["conditions"]["thresholds"] != policy_hash:
|
|
126
|
+
raise ValueError("Policy differs from frozen threshold hash")
|
|
127
|
+
samples = []
|
|
128
|
+
hits = []
|
|
129
|
+
contact_times = []
|
|
130
|
+
for trace in (coarse,fine):
|
|
131
|
+
digest(trace["raw_evidence_sha256"])
|
|
132
|
+
if trace["generation"] != 1 or type(trace["generation"]) is not int:
|
|
133
|
+
raise ValueError("Invalid generation")
|
|
134
|
+
if trace["calibrated"] is not True or trace["feedback_verified"] is not True:
|
|
135
|
+
raise ValueError("Unverified model or actuator feedback")
|
|
136
|
+
if trace["metric"] != policy["metric"] or trace["unit"] != policy["unit"]:
|
|
137
|
+
raise ValueError("Metric or unit mismatch")
|
|
138
|
+
if set(trace["coverage"]) != set(pairs):
|
|
139
|
+
raise ValueError("Missing required collision pair")
|
|
140
|
+
for pair, expectation in pairs.items():
|
|
141
|
+
c = trace["coverage"][pair]
|
|
142
|
+
if (expectation not in ("expected","forbidden") or c["distinct_bodies"] is not True
|
|
143
|
+
or c["geometry_calibrated"] is not True or c["collision_enabled"] is not True
|
|
144
|
+
or c["detector_verified"] is not True or c["filtered"] is not False
|
|
145
|
+
or type(c["self_pair"]) is not bool
|
|
146
|
+
or (c["self_pair"] and c["self_collision_enabled"] is not True)):
|
|
147
|
+
raise ValueError("Uncovered collision pair: "+pair)
|
|
148
|
+
digest(c["evidence_sha256"])
|
|
149
|
+
rows = trace["samples"]
|
|
150
|
+
if not 1 <= len(rows) <= 10000:
|
|
151
|
+
raise ValueError("Unsupported trace size")
|
|
152
|
+
values,h = [],set()
|
|
153
|
+
events = {k:[] for k in pairs}
|
|
154
|
+
for i,row in enumerate(rows,1):
|
|
155
|
+
if type(row["time_ms"]) is not int or row["time_ms"] != i*trace["step_ms"] or set(row["contacts"]) != set(pairs):
|
|
156
|
+
raise ValueError("Missing observation or inconsistent sample time")
|
|
157
|
+
if any(type(v) is not bool for v in row["contacts"].values()):
|
|
158
|
+
raise ValueError("Unknown contact sample")
|
|
159
|
+
h.update(k for k,v in row["contacts"].items() if v)
|
|
160
|
+
for pair, present in row["contacts"].items():
|
|
161
|
+
if present:
|
|
162
|
+
events[pair].append(row["time_ms"])
|
|
163
|
+
values.append(number(row["value"]))
|
|
164
|
+
if not 0 <= number(row["stability_error"]) <= stable_limit:
|
|
165
|
+
raise ValueError("Instability exceeds calibrated bound")
|
|
166
|
+
samples.append(values)
|
|
167
|
+
hits.append(h)
|
|
168
|
+
contact_times.append(events)
|
|
169
|
+
if len(samples[1]) != 2*len(samples[0]):
|
|
170
|
+
raise ValueError("Different duration")
|
|
171
|
+
if hits[0] != hits[1] or any(abs(x-y)>tolerance for x,y in zip(samples[0],samples[1][1::2])):
|
|
172
|
+
raise ValueError("Contact classification or metric failed convergence")
|
|
173
|
+
for pair in hits[0]:
|
|
174
|
+
left,right = (events[pair] for events in contact_times)
|
|
175
|
+
if set(left) != {((t+dc-1)//dc)*dc for t in right}:
|
|
176
|
+
raise ValueError("Contact interval occupancy failed convergence: "+pair)
|
|
177
|
+
if (max(samples[0]) > threshold) != (max(samples[1]) > threshold):
|
|
178
|
+
raise ValueError("Threshold classification failed convergence")
|
|
179
|
+
violations = [pair for pair,expected in pairs.items() if (pair in hits[0]) != (expected=="expected")]
|
|
180
|
+
if max(max(v) for v in samples) > threshold:
|
|
181
|
+
violations.append("metric_max")
|
|
182
|
+
decision = "FAIL" if violations else "PASS"
|
|
183
|
+
result.update(criteria_result=decision,physical_valid=real,verdict=decision if real else "UNKNOWN",
|
|
184
|
+
run_ids=[trace["run_id"] for trace in (coarse,fine)], raw_sha256=[trace["raw_evidence_sha256"] for trace in (coarse,fine)], reasons=violations if real else ["Synthetic trace"]+violations)
|
|
185
|
+
except (KeyError,TypeError,ValueError,OverflowError) as err:
|
|
186
|
+
result["reasons"] = [str(err)]
|
|
187
|
+
return result
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""SI rigid-body math. No COM, application launch, files, or global state.
|
|
2
|
+
|
|
3
|
+
Column vectors, p_target = R @ p_source + t. Inertia is a physical tensor
|
|
4
|
+
about the COM, expressed in the named frame (not principal moments).
|
|
5
|
+
"""
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
IDENTITY = ((1., 0., 0.), (0., 1., 0.), (0., 0., 1.))
|
|
9
|
+
MASS_FIELDS = ('CenterOfMassX', 'CenterOfMassY', 'CenterOfMassZ', 'Volume',
|
|
10
|
+
'Area', 'Mass', 'MomXX', 'MomYY', 'MomZZ', 'MomXY', 'MomZX', 'MomYZ')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _numbers(values, count):
|
|
14
|
+
if not isinstance(values, (list, tuple)) or len(values) != count:
|
|
15
|
+
raise ValueError(f'expected {count} numbers')
|
|
16
|
+
if any(isinstance(x, bool) or not isinstance(x, (int, float)) or not math.isfinite(x) for x in values):
|
|
17
|
+
raise ValueError('finite numeric values required')
|
|
18
|
+
return [float(x) for x in values]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _matrix(value):
|
|
22
|
+
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
|
23
|
+
raise ValueError('expected 3 by 3 matrix')
|
|
24
|
+
return [_numbers(row, 3) for row in value]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _det(a):
|
|
28
|
+
return (a[0][0]*(a[1][1]*a[2][2]-a[1][2]*a[2][1])
|
|
29
|
+
- a[0][1]*(a[1][0]*a[2][2]-a[1][2]*a[2][0])
|
|
30
|
+
+ a[0][2]*(a[1][0]*a[2][1]-a[1][1]*a[2][0]))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _psd(a):
|
|
34
|
+
# All principal minors, not just leading minors, characterize PSD.
|
|
35
|
+
return (all(a[i][i] >= -1e-12 for i in range(3))
|
|
36
|
+
and all(a[i][i]*a[j][j]-a[i][j]**2 >= -1e-12 for i in range(3) for j in range(i))
|
|
37
|
+
and _det(a) >= -1e-12)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_inertia(value):
|
|
41
|
+
a = _matrix(value)
|
|
42
|
+
scale = max(abs(x) for row in a for x in row)
|
|
43
|
+
if scale == 0:
|
|
44
|
+
raise ValueError('zero inertia unsupported')
|
|
45
|
+
b = [[x / scale for x in row] for row in a]
|
|
46
|
+
if any(abs(b[i][j]-b[j][i]) > 1e-12 for i in range(3) for j in range(3)):
|
|
47
|
+
raise ValueError('inertia must be symmetric')
|
|
48
|
+
half_trace = sum(b[i][i] for i in range(3)) / 2
|
|
49
|
+
covariance = [[(half_trace if i == j else 0)-b[i][j] for j in range(3)] for i in range(3)]
|
|
50
|
+
if not _psd(b) or not _psd(covariance):
|
|
51
|
+
raise ValueError('inertia is not physically realizable')
|
|
52
|
+
return a
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_rotation(value):
|
|
56
|
+
r = _matrix(value)
|
|
57
|
+
if (abs(_det(r)-1) > 1e-9 or
|
|
58
|
+
any(abs(sum(r[k][i]*r[k][j] for k in range(3))-(i == j)) > 1e-9
|
|
59
|
+
for i in range(3) for j in range(3))):
|
|
60
|
+
raise ValueError('proper orthonormal rotation required; scale/reflection unsupported')
|
|
61
|
+
return r
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_mass_array(method, raw):
|
|
65
|
+
"""Read documented slots verbatim; this does NOT infer cross-term signs."""
|
|
66
|
+
if method in ('IModelDocExtension.GetMassProperties2', 'IBody2.GetMassProperties'):
|
|
67
|
+
fields = MASS_FIELDS
|
|
68
|
+
elif method == 'IModelDocExtension.GetMassProperties':
|
|
69
|
+
fields = MASS_FIELDS + ('Accuracy',)
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError('unverified mass method: ' + str(method))
|
|
72
|
+
return dict(zip(fields, _numbers(raw, len(fields))))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sw_inertia_tensor(raw):
|
|
76
|
+
"""IMassProperty.GetMomentOfInertia(0) products -> physical tensor.
|
|
77
|
+
|
|
78
|
+
Off-diagonal sign follows ROS SW exporter Inertia.SetMomentMatrix.
|
|
79
|
+
Do not apply to an already converted tensor or unidentified legacy array.
|
|
80
|
+
"""
|
|
81
|
+
v = _numbers(raw, 9)
|
|
82
|
+
return validate_inertia([[v[3*i+j] * (1 if i == j else -1)
|
|
83
|
+
for j in range(3)] for i in range(3)])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def mass_properties(mass, com, inertia, *, length_unit, mass_unit, frame):
|
|
87
|
+
"""One explicit input-unit conversion; downstream APIs accept SI only."""
|
|
88
|
+
if length_unit not in ('m', 'mm') or mass_unit not in ('kg', 'g'):
|
|
89
|
+
raise ValueError('unsupported or missing units')
|
|
90
|
+
if not isinstance(frame, str) or not frame.strip():
|
|
91
|
+
raise ValueError('frame required')
|
|
92
|
+
m = _numbers([mass], 1)[0] * (1 if mass_unit == 'kg' else .001)
|
|
93
|
+
length = 1 if length_unit == 'm' else .001
|
|
94
|
+
if m <= 0: raise ValueError('positive mass required')
|
|
95
|
+
factor = (1 if mass_unit == 'kg' else .001) * length**2
|
|
96
|
+
c = [x*length for x in _numbers(com, 3)]
|
|
97
|
+
tensor = [[x*factor for x in row] for row in _matrix(inertia)]
|
|
98
|
+
return {'mass_kg': m, 'com_m': c, 'inertia_kg_m2': validate_inertia(tensor),
|
|
99
|
+
'frame': frame, 'units': 'SI', 'inertia_about': 'center_of_mass'}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _checked(body, frame):
|
|
103
|
+
if body.get('units') != 'SI' or body.get('frame') != frame or body.get('inertia_about') != 'center_of_mass':
|
|
104
|
+
raise ValueError('SI COM tensor and matching source frame required')
|
|
105
|
+
return mass_properties(body['mass_kg'], body['com_m'], body['inertia_kg_m2'],
|
|
106
|
+
length_unit='m', mass_unit='kg', frame=frame)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sw_transform(raw):
|
|
110
|
+
"""IComponent2.Transform2.ArrayData -> column-vector R,t in metres.
|
|
111
|
+
|
|
112
|
+
SW stores row-vector axes; transpose its first nine entries exactly once.
|
|
113
|
+
Component Transform2 is relative to root assembly, even for subassemblies.
|
|
114
|
+
"""
|
|
115
|
+
v = _numbers(raw, 16)
|
|
116
|
+
if v[12] != 1:
|
|
117
|
+
raise ValueError('scaled component transform unsupported')
|
|
118
|
+
return validate_rotation([[v[j*3+i] for j in range(3)] for i in range(3)]), v[9:12]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def transform_mass(body, rotation, translation_m, *, source_frame, target_frame):
|
|
122
|
+
b = _checked(body, source_frame)
|
|
123
|
+
r, t = validate_rotation(rotation), _numbers(translation_m, 3)
|
|
124
|
+
c = [sum(r[i][j]*b['com_m'][j] for j in range(3))+t[i] for i in range(3)]
|
|
125
|
+
a = b['inertia_kg_m2']
|
|
126
|
+
tensor = [[sum(r[i][k]*a[k][l]*r[j][l] for k in range(3) for l in range(3))
|
|
127
|
+
for j in range(3)] for i in range(3)]
|
|
128
|
+
return mass_properties(b['mass_kg'], c, tensor, length_unit='m', mass_unit='kg', frame=target_frame)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def merge_rigid_bodies(components, *, frame):
|
|
132
|
+
"""Merge explicit fixed-group members (id, SI properties in one frame).
|
|
133
|
+
|
|
134
|
+
A02 decides membership; this function never infers fixedness from contact.
|
|
135
|
+
"""
|
|
136
|
+
members = list(components)
|
|
137
|
+
ids = [item[0] for item in members]
|
|
138
|
+
if not ids or any(not isinstance(x, str) or not x.strip() for x in ids) or len(set(ids)) != len(ids):
|
|
139
|
+
raise ValueError('unique nonempty component ids required')
|
|
140
|
+
bodies = [_checked(b, frame) for _, b in members]
|
|
141
|
+
mass = math.fsum(b['mass_kg'] for b in bodies)
|
|
142
|
+
com = [math.fsum(b['mass_kg']*b['com_m'][i] for b in bodies)/mass for i in range(3)]
|
|
143
|
+
terms = []
|
|
144
|
+
for b in bodies:
|
|
145
|
+
d = [b['com_m'][i]-com[i] for i in range(3)]
|
|
146
|
+
norm2 = sum(x*x for x in d)
|
|
147
|
+
terms.append([[b['inertia_kg_m2'][i][j] + b['mass_kg']*((norm2 if i == j else 0)-d[i]*d[j])
|
|
148
|
+
for j in range(3)] for i in range(3)])
|
|
149
|
+
tensor = [[math.fsum(a[i][j] for a in terms) for j in range(3)] for i in range(3)]
|
|
150
|
+
result = mass_properties(mass, com, tensor, length_unit='m', mass_unit='kg', frame=frame)
|
|
151
|
+
result['component_ids'] = ids
|
|
152
|
+
return result
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Offline A01 single-part preview. Native CAD and physical gates stay closed."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .evidence import EvidenceStore, _identifier, _load_json
|
|
11
|
+
from .sw_export import part_bundle
|
|
12
|
+
|
|
13
|
+
CONVERTER = "2025.0.0"
|
|
14
|
+
PRODUCER = {"name": "webots_mcp.pipeline", "version": "1", "source_revision": None}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def native_gate():
|
|
18
|
+
return {"ok": False, "error_code": "CALIBRATION_REQUIRED", "ready_for_physics": False}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def publish_part(store, run, snapshot, *, provenance="static-derived"):
|
|
22
|
+
if provenance not in {"static-derived", "fixture"} or snapshot["physics"]["frame"] != "part-local":
|
|
23
|
+
raise ValueError("Only uncalibrated part-local previews are supported")
|
|
24
|
+
bundle = part_bundle(snapshot)
|
|
25
|
+
files = [(name, bundle[name].encode("utf-8"), kind) for name, kind in
|
|
26
|
+
(("robot.urdf", "exported-model"), ("visual.obj", "visual-mesh"))]
|
|
27
|
+
if any(len(raw) > 4 * 1024**2 for _, raw, _ in files):
|
|
28
|
+
raise ValueError("Preview exceeds the per-file evidence budget")
|
|
29
|
+
store._path(f"webots_runs/{_identifier(run)}").mkdir(parents=True)
|
|
30
|
+
evidence = [store.save_snapshot(run, name, raw, kind=kind, provenance=provenance,
|
|
31
|
+
producer=PRODUCER) for name, raw, kind in files]
|
|
32
|
+
common = dict(provenance=provenance, source_revision=snapshot["source_revision"],
|
|
33
|
+
status="approximate", evidence=evidence,
|
|
34
|
+
missing_information=bundle["conversion_data"]["missing_information"])
|
|
35
|
+
return {kind: store.write_report(store.new_report(kind, run, data=bundle[key], **common))
|
|
36
|
+
for kind, key in (("model_manifest", "manifest_data"), ("conversion_report", "conversion_data"))}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def convert_part(store, run, *, operation_id):
|
|
40
|
+
operation_id = _identifier(operation_id)
|
|
41
|
+
manifest = store.read_report(run, "model_manifest")
|
|
42
|
+
report = store.read_report(run, "conversion_report")
|
|
43
|
+
provenance = report["provenance"]["kind"]
|
|
44
|
+
files = {item["path"] for item in report["evidence"] if item["producer"] == PRODUCER}
|
|
45
|
+
if (provenance not in {"fixture", "static-derived"} or manifest["provenance"]["kind"] != provenance
|
|
46
|
+
or manifest["source_revision"] != report["source_revision"]
|
|
47
|
+
or not {"robot.urdf", "visual.obj"} <= files):
|
|
48
|
+
raise ValueError("An owned part preview is required")
|
|
49
|
+
if version("urdf2webots") != CONVERTER:
|
|
50
|
+
raise ValueError(f"Expected urdf2webots {CONVERTER}")
|
|
51
|
+
root = store._run(run)
|
|
52
|
+
stage = store._path(f"webots_runs/{run}/preview")
|
|
53
|
+
stage.mkdir()
|
|
54
|
+
target = stage / "Preview.proto"
|
|
55
|
+
with (stage / "converter.log").open("wb") as log:
|
|
56
|
+
subprocess.run([sys.executable, "-B", "-m", "urdf2webots.importer",
|
|
57
|
+
"--input", str(root / "robot.urdf"), "--output", str(target)],
|
|
58
|
+
cwd=stage, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=30)
|
|
59
|
+
text = target.read_text(encoding="utf-8")
|
|
60
|
+
urls = re.findall(r'^\s*url\s+"([^"]+)"\s*$', text, re.M)
|
|
61
|
+
if not text.startswith("#VRML_SIM R2025a") or [url.replace("\\", "/") for url in urls] != ["../visual.obj"]:
|
|
62
|
+
raise ValueError("Unexpected preview format or mesh reference")
|
|
63
|
+
body, = manifest["data"]["rigid_bodies"]
|
|
64
|
+
matrix = body["inertia_kg_m2"]
|
|
65
|
+
expected = {"mass": [body["mass_kg"]], "centerOfMass": body["com_m"],
|
|
66
|
+
"inertiaMatrix": [matrix[i][j] for i,j in ((0,0),(1,1),(2,2),(0,1),(0,2),(1,2))]}
|
|
67
|
+
observed = {}
|
|
68
|
+
for field in expected:
|
|
69
|
+
pattern = r"([^\n]+)" if field == "mass" else r"\[([^\]]+)\]"
|
|
70
|
+
match = re.search(r"^\s*" + field + r"\s+" + pattern, text, re.M)
|
|
71
|
+
observed[field] = [float(value) for value in match[1].split()] if match else None
|
|
72
|
+
changed = [field for field in expected if expected[field] != observed[field]]
|
|
73
|
+
precision = dict(preserved=not changed, expected=expected, observed=observed, changed=changed)
|
|
74
|
+
report["evidence"].extend(store.file_evidence(run, f"preview/{name}", kind=kind,
|
|
75
|
+
provenance=provenance, producer={"name": "urdf2webots", "version": CONVERTER})
|
|
76
|
+
for name,kind in (("Preview.proto","preview-proto"),("converter.log","converter-log")))
|
|
77
|
+
report["data"].update(preview_operation_id=operation_id, converter_version=CONVERTER,
|
|
78
|
+
precision=precision, physical_validation="not_run")
|
|
79
|
+
receipt = store.write_report(report)
|
|
80
|
+
return {"ready_for_physics": False, "precision": precision, "report": receipt}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main():
|
|
84
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
85
|
+
parser.add_argument("action", choices=("publish-part", "convert-part", "native-status"))
|
|
86
|
+
parser.add_argument("--state", type=Path)
|
|
87
|
+
parser.add_argument("--task")
|
|
88
|
+
parser.add_argument("--room")
|
|
89
|
+
parser.add_argument("--run")
|
|
90
|
+
parser.add_argument("--snapshot", type=Path)
|
|
91
|
+
parser.add_argument("--operation-id")
|
|
92
|
+
args = parser.parse_args()
|
|
93
|
+
try:
|
|
94
|
+
if args.action == "native-status":
|
|
95
|
+
result = native_gate()
|
|
96
|
+
else:
|
|
97
|
+
store = EvidenceStore(args.state, task_id=args.task, room_id=args.room)
|
|
98
|
+
if args.action == "publish-part":
|
|
99
|
+
result = publish_part(store, args.run, _load_json(args.snapshot))
|
|
100
|
+
else:
|
|
101
|
+
result = convert_part(store, args.run, operation_id=args.operation_id)
|
|
102
|
+
except (OSError, ValueError, TypeError, KeyError, subprocess.SubprocessError) as error:
|
|
103
|
+
parser.exit(2, json.dumps({"ok": False, "error": str(error)}) + "\n")
|
|
104
|
+
print(json.dumps(result, ensure_ascii=False, allow_nan=False))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|