backend
This commit is contained in:
@@ -339,6 +339,55 @@ class ConversationStateStore:
|
||||
self._persist_presets(conversation_id, state["presets"])
|
||||
return deepcopy(state)
|
||||
|
||||
def save_mode_profile(
|
||||
self,
|
||||
conversation_id: str,
|
||||
utterance: str,
|
||||
mode_profile: dict[str, Any],
|
||||
source_actions: list[dict[str, Any]],
|
||||
decision_summary: list[str],
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = self._state(conversation_id)
|
||||
state["turn_count"] += 1
|
||||
state["updated_at"] = _now()
|
||||
state["recent_turns"].append(
|
||||
{
|
||||
"utterance": utterance,
|
||||
"actions": deepcopy(source_actions),
|
||||
"decision_summary": list(decision_summary),
|
||||
"control_event": "mode_profile_save",
|
||||
"mode_profile": deepcopy(mode_profile),
|
||||
}
|
||||
)
|
||||
state["recent_turns"] = state["recent_turns"][-6:]
|
||||
return deepcopy(state)
|
||||
|
||||
def record_mode_profile_event(
|
||||
self,
|
||||
conversation_id: str,
|
||||
utterance: str,
|
||||
mode_profile: dict[str, Any],
|
||||
source_actions: list[dict[str, Any]],
|
||||
decision_summary: list[str],
|
||||
control_event: str,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = self._state(conversation_id)
|
||||
state["turn_count"] += 1
|
||||
state["updated_at"] = _now()
|
||||
state["recent_turns"].append(
|
||||
{
|
||||
"utterance": utterance,
|
||||
"actions": deepcopy(source_actions),
|
||||
"decision_summary": list(decision_summary),
|
||||
"control_event": control_event,
|
||||
"mode_profile": deepcopy(mode_profile),
|
||||
}
|
||||
)
|
||||
state["recent_turns"] = state["recent_turns"][-6:]
|
||||
return deepcopy(state)
|
||||
|
||||
def reset(self, conversation_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = _new_state(conversation_id, self._load_presets(conversation_id))
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
"""Repository-backed cabin mode profiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
_WHITESPACE = re.compile(r"\s+")
|
||||
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def _normalize_match_text(value: str) -> str:
|
||||
return _WHITESPACE.sub("", value.casefold())
|
||||
|
||||
|
||||
def _clean_name(value: str) -> str:
|
||||
cleaned = value.strip().strip(",。,.!!")
|
||||
if not cleaned:
|
||||
raise ValueError("mode name must not be empty")
|
||||
if len(cleaned) > 64:
|
||||
raise ValueError("mode name must be at most 64 characters")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]:
|
||||
values = [name, *(aliases or [])]
|
||||
if name.endswith("模式") and len(name) > 2:
|
||||
values.append(name.removesuffix("模式"))
|
||||
else:
|
||||
values.append(f"{name}模式")
|
||||
|
||||
normalized: set[str] = set()
|
||||
unique = []
|
||||
for value in values:
|
||||
candidate = str(value).strip()
|
||||
key = _normalize_match_text(candidate)
|
||||
if not candidate or key in normalized:
|
||||
continue
|
||||
normalized.add(key)
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
def _slugify_mode_id(name: str) -> str:
|
||||
ascii_slug = _NON_ASCII_SLUG.sub("-", name.casefold()).strip("-")
|
||||
digest = hashlib.sha256(name.encode("utf-8")).hexdigest()[:10]
|
||||
if ascii_slug:
|
||||
return ascii_slug if len(ascii_slug) >= 3 else f"{ascii_slug}-{digest[:6]}"
|
||||
return f"mode-{digest}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModeOperation:
|
||||
domain: str
|
||||
capability: str
|
||||
target_scope: str
|
||||
parameters: dict[str, Any]
|
||||
operation: str = "set"
|
||||
reason: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, document: dict[str, Any]) -> "ModeOperation":
|
||||
parameters = document.get("parameters", {})
|
||||
if not isinstance(parameters, dict):
|
||||
raise ValueError("mode operation parameters must be an object")
|
||||
return cls(
|
||||
domain=str(document["domain"]),
|
||||
capability=str(document["capability"]),
|
||||
target_scope=str(document["target_scope"]),
|
||||
operation=str(document.get("operation", "set")),
|
||||
parameters=dict(parameters),
|
||||
reason=str(document.get("reason", "")),
|
||||
)
|
||||
|
||||
def to_request(self, mode_name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"domain": self.domain,
|
||||
"capability": self.capability,
|
||||
"target_scope": self.target_scope,
|
||||
"operation": self.operation,
|
||||
"parameters": [
|
||||
{"name": name, "value": value}
|
||||
for name, value in self.parameters.items()
|
||||
],
|
||||
"reason": self.reason or f"Matched mode profile {mode_name}.",
|
||||
}
|
||||
|
||||
def to_yaml(self) -> dict[str, Any]:
|
||||
return {
|
||||
"domain": self.domain,
|
||||
"capability": self.capability,
|
||||
"target_scope": self.target_scope,
|
||||
"operation": self.operation,
|
||||
"parameters": dict(self.parameters),
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModeProfile:
|
||||
id: str
|
||||
name: str
|
||||
aliases: tuple[str, ...]
|
||||
description: str
|
||||
icon: str
|
||||
operations: tuple[ModeOperation, ...]
|
||||
source: str
|
||||
common: bool = True
|
||||
vehicle_ids: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError(f"{path} must contain a YAML object")
|
||||
operations = document.get("operations", [])
|
||||
if not isinstance(operations, list) or not operations:
|
||||
raise ValueError(f"{path} must contain at least one operation")
|
||||
name = _clean_name(str(document["name"]))
|
||||
common, vehicle_ids = _mode_scope(document, path, profile_root)
|
||||
aliases = _mode_aliases(
|
||||
name,
|
||||
[str(alias) for alias in document.get("aliases", [])],
|
||||
)
|
||||
return cls(
|
||||
id=str(document["id"]),
|
||||
name=name,
|
||||
aliases=tuple(aliases),
|
||||
description=str(document.get("description", "")),
|
||||
icon=str(document.get("icon", "bookmark")),
|
||||
operations=tuple(ModeOperation.from_dict(item) for item in operations),
|
||||
source=str(path),
|
||||
common=common,
|
||||
vehicle_ids=vehicle_ids,
|
||||
)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"aliases": list(self.aliases),
|
||||
"description": self.description,
|
||||
"icon": self.icon,
|
||||
"domains": sorted({operation.domain for operation in self.operations}),
|
||||
"operation_count": len(self.operations),
|
||||
"scope": {
|
||||
"common": self.common,
|
||||
"vehicle_ids": list(self.vehicle_ids),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ModeProfileRegistry:
|
||||
"""Load, match, and persist long-term cabin mode profiles."""
|
||||
|
||||
def __init__(self, profile_root: Path):
|
||||
self.profile_root = Path(profile_root)
|
||||
self._profiles: list[ModeProfile] = []
|
||||
self.reload()
|
||||
|
||||
def reload(self) -> None:
|
||||
if not self.profile_root.exists():
|
||||
self._profiles = []
|
||||
return
|
||||
self._profiles = [
|
||||
ModeProfile.from_yaml(path, self.profile_root)
|
||||
for path in sorted(self.profile_root.rglob("*.yaml"))
|
||||
if path.is_file()
|
||||
]
|
||||
|
||||
def profiles(self) -> list[ModeProfile]:
|
||||
return list(self._profiles)
|
||||
|
||||
def public_modes(self, vehicle_id: str | None = None) -> list[dict[str, Any]]:
|
||||
return [
|
||||
profile.public_dict()
|
||||
for profile in self._profiles_for_vehicle(vehicle_id)
|
||||
]
|
||||
|
||||
def match_text(
|
||||
self,
|
||||
utterance: str,
|
||||
vehicle_id: str | None = None,
|
||||
) -> list[ModeProfile]:
|
||||
haystack = _normalize_match_text(utterance)
|
||||
selected = {
|
||||
profile.id: profile
|
||||
for profile in self._profiles_for_vehicle(vehicle_id)
|
||||
}
|
||||
matches = []
|
||||
matched_ids: set[str] = set()
|
||||
for profile in self._visible_candidates_for_vehicle(vehicle_id):
|
||||
candidates = [profile.id, profile.name, *profile.aliases]
|
||||
if any(
|
||||
candidate
|
||||
and _normalize_match_text(str(candidate)) in haystack
|
||||
for candidate in candidates
|
||||
):
|
||||
if profile.id in matched_ids:
|
||||
continue
|
||||
matched_ids.add(profile.id)
|
||||
matches.append(selected[profile.id])
|
||||
return matches
|
||||
|
||||
@staticmethod
|
||||
def requests_for_profiles(profiles: list[ModeProfile]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
operation.to_request(profile.name)
|
||||
for profile in profiles
|
||||
for operation in profile.operations
|
||||
]
|
||||
|
||||
def save_mode(
|
||||
self,
|
||||
name: str,
|
||||
operations: list[dict[str, Any] | ModeOperation],
|
||||
mode_id: str | None = None,
|
||||
aliases: list[str] | None = None,
|
||||
vehicle_id: str | None = None,
|
||||
) -> ModeProfile:
|
||||
clean_name = _clean_name(name)
|
||||
canonical_operations = [
|
||||
operation
|
||||
if isinstance(operation, ModeOperation)
|
||||
else ModeOperation.from_dict(operation)
|
||||
for operation in operations
|
||||
]
|
||||
if not canonical_operations:
|
||||
raise ValueError("mode profile must contain at least one operation")
|
||||
profile_id = mode_id or _slugify_mode_id(clean_name)
|
||||
scope = (
|
||||
{"vehicle_ids": [vehicle_id]}
|
||||
if vehicle_id
|
||||
else {"common": True, "vehicle_ids": []}
|
||||
)
|
||||
document = {
|
||||
"id": profile_id,
|
||||
"name": clean_name,
|
||||
"aliases": _mode_aliases(clean_name, aliases),
|
||||
"description": "用户固化的长期模式配置。",
|
||||
"icon": "bookmark",
|
||||
"scope": scope,
|
||||
"operations": [
|
||||
operation.to_yaml() for operation in canonical_operations
|
||||
],
|
||||
}
|
||||
path = (
|
||||
self.profile_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
|
||||
if vehicle_id
|
||||
else self.profile_root / "common" / "user" / f"{profile_id}.yaml"
|
||||
)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
self.reload()
|
||||
return ModeProfile.from_yaml(path, self.profile_root)
|
||||
|
||||
def update_mode(
|
||||
self,
|
||||
profile: ModeProfile,
|
||||
operations: list[dict[str, Any] | ModeOperation],
|
||||
) -> ModeProfile:
|
||||
canonical_updates = _canonical_operations(operations)
|
||||
if not canonical_updates:
|
||||
raise ValueError("mode profile update must contain at least one operation")
|
||||
|
||||
path = Path(profile.source)
|
||||
if not path.is_file():
|
||||
raise ValueError(f"mode profile source does not exist: {path}")
|
||||
backup_path = _initial_backup_path(path)
|
||||
if not backup_path.exists():
|
||||
backup_path.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
merged_operations = _merge_operations(profile.operations, canonical_updates)
|
||||
document = _mode_profile_document(profile, merged_operations)
|
||||
_replace_yaml(path, document)
|
||||
self.reload()
|
||||
return ModeProfile.from_yaml(path, self.profile_root)
|
||||
|
||||
def restore_initial_mode(self, profile: ModeProfile) -> ModeProfile:
|
||||
path = Path(profile.source)
|
||||
backup_path = _initial_backup_path(path)
|
||||
if not backup_path.is_file():
|
||||
raise ValueError(f"no initial mode backup found: {backup_path}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(backup_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
self.reload()
|
||||
return ModeProfile.from_yaml(path, self.profile_root)
|
||||
|
||||
def _profiles_for_vehicle(self, vehicle_id: str | None) -> list[ModeProfile]:
|
||||
selected: dict[str, ModeProfile] = {}
|
||||
order: list[str] = []
|
||||
for profile in self._visible_candidates_for_vehicle(vehicle_id):
|
||||
if profile.id not in selected:
|
||||
order.append(profile.id)
|
||||
selected[profile.id] = profile
|
||||
continue
|
||||
current = selected[profile.id]
|
||||
if _profile_rank(profile, vehicle_id) >= _profile_rank(current, vehicle_id):
|
||||
selected[profile.id] = profile
|
||||
return [selected[profile_id] for profile_id in order]
|
||||
|
||||
def _visible_candidates_for_vehicle(
|
||||
self,
|
||||
vehicle_id: str | None,
|
||||
) -> list[ModeProfile]:
|
||||
return [
|
||||
profile
|
||||
for profile in self._profiles
|
||||
if _is_visible_for_vehicle(profile, vehicle_id)
|
||||
]
|
||||
|
||||
|
||||
def _canonical_operations(
|
||||
operations: list[dict[str, Any] | ModeOperation],
|
||||
) -> list[ModeOperation]:
|
||||
return [
|
||||
operation
|
||||
if isinstance(operation, ModeOperation)
|
||||
else ModeOperation.from_dict(operation)
|
||||
for operation in operations
|
||||
]
|
||||
|
||||
|
||||
def _operation_signature(operation: ModeOperation) -> tuple[str, str, str]:
|
||||
return (operation.domain, operation.capability, operation.target_scope)
|
||||
|
||||
|
||||
def _merge_operations(
|
||||
existing: tuple[ModeOperation, ...],
|
||||
updates: list[ModeOperation],
|
||||
) -> list[ModeOperation]:
|
||||
merged = list(existing)
|
||||
positions = {
|
||||
_operation_signature(operation): index
|
||||
for index, operation in enumerate(merged)
|
||||
}
|
||||
for operation in updates:
|
||||
signature = _operation_signature(operation)
|
||||
index = positions.get(signature)
|
||||
if index is None:
|
||||
positions[signature] = len(merged)
|
||||
merged.append(operation)
|
||||
continue
|
||||
merged[index] = operation
|
||||
return merged
|
||||
|
||||
|
||||
def _initial_backup_path(path: Path) -> Path:
|
||||
return path.with_suffix(path.suffix + ".initial.bak")
|
||||
|
||||
|
||||
def _mode_profile_document(
|
||||
profile: ModeProfile,
|
||||
operations: list[ModeOperation],
|
||||
) -> dict[str, Any]:
|
||||
scope: dict[str, Any] = {
|
||||
"common": profile.common,
|
||||
"vehicle_ids": list(profile.vehicle_ids),
|
||||
}
|
||||
return {
|
||||
"id": profile.id,
|
||||
"name": profile.name,
|
||||
"aliases": list(profile.aliases),
|
||||
"description": profile.description,
|
||||
"icon": profile.icon,
|
||||
"scope": scope,
|
||||
"operations": [operation.to_yaml() for operation in operations],
|
||||
}
|
||||
|
||||
|
||||
def _replace_yaml(path: Path, document: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _mode_scope(
|
||||
document: dict[str, Any],
|
||||
path: Path,
|
||||
profile_root: Path | None,
|
||||
) -> tuple[bool, tuple[str, ...]]:
|
||||
scope = document.get("scope")
|
||||
if isinstance(scope, dict):
|
||||
vehicle_ids = tuple(
|
||||
str(vehicle_id).strip()
|
||||
for vehicle_id in scope.get("vehicle_ids", [])
|
||||
if str(vehicle_id).strip()
|
||||
)
|
||||
common = bool(scope.get("common", not vehicle_ids))
|
||||
return common, vehicle_ids
|
||||
|
||||
if profile_root is not None:
|
||||
try:
|
||||
parts = path.relative_to(profile_root).parts
|
||||
except ValueError:
|
||||
parts = ()
|
||||
if len(parts) >= 3 and parts[0] == "vehicles":
|
||||
return False, (parts[1],)
|
||||
if parts and parts[0] == "common":
|
||||
return True, ()
|
||||
return True, ()
|
||||
|
||||
|
||||
def _is_visible_for_vehicle(
|
||||
profile: ModeProfile,
|
||||
vehicle_id: str | None,
|
||||
) -> bool:
|
||||
if vehicle_id is None:
|
||||
return True
|
||||
return profile.common or vehicle_id in profile.vehicle_ids
|
||||
|
||||
|
||||
def _profile_rank(profile: ModeProfile, vehicle_id: str | None) -> int:
|
||||
if vehicle_id is not None and vehicle_id in profile.vehicle_ids:
|
||||
return 2
|
||||
if profile.common:
|
||||
return 1
|
||||
return 0
|
||||
@@ -10,6 +10,7 @@ from cognitive_agent.application.seat_calibration import (
|
||||
ANGLE_CALIBRATIONS,
|
||||
angle_to_position_pct,
|
||||
)
|
||||
from cognitive_agent.application.vehicle_profile import VehicleProfile
|
||||
|
||||
|
||||
SEAT_CAPABILITIES = {
|
||||
@@ -23,6 +24,7 @@ SEAT_MOTION_CAPABILITIES = {
|
||||
"seat.set_legrest_height": "legrest_height",
|
||||
"seat.set_legrest_extension": "legrest_extension",
|
||||
}
|
||||
SEAT_RELATIVE_PHYSICAL_PARAMETERS = {"distance_mm", "angle_delta_deg"}
|
||||
SAVE_PRESET_CAPABILITY = "seat.save_posture_preset"
|
||||
APPLY_PRESET_CAPABILITY = "seat.apply_posture_preset"
|
||||
HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
@@ -45,6 +47,7 @@ class ModelPlanAdapter:
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
context: dict[str, Any] | None = None,
|
||||
vehicle_profile: VehicleProfile | None = None,
|
||||
) -> dict[str, Any]:
|
||||
actions: list[dict[str, Any]] = []
|
||||
domain_actions: list[dict[str, Any]] = []
|
||||
@@ -73,6 +76,11 @@ class ModelPlanAdapter:
|
||||
}
|
||||
allowed_parameters = {item["name"] for item in capability.get("parameters", [])}
|
||||
unknown_parameters = set(parameters) - allowed_parameters
|
||||
if (
|
||||
domain == "seat"
|
||||
and capability_name in SEAT_MOTION_CAPABILITIES
|
||||
):
|
||||
unknown_parameters -= SEAT_RELATIVE_PHYSICAL_PARAMETERS
|
||||
if unknown_parameters:
|
||||
ambiguities.append(
|
||||
{
|
||||
@@ -162,6 +170,7 @@ class ModelPlanAdapter:
|
||||
operation_parameters,
|
||||
ambiguities,
|
||||
context,
|
||||
vehicle_profile,
|
||||
)
|
||||
if action is not None:
|
||||
actions.append(action)
|
||||
@@ -174,7 +183,7 @@ class ModelPlanAdapter:
|
||||
)
|
||||
continue
|
||||
action = self._seat_action(
|
||||
index, request, parameters, ambiguities, context
|
||||
index, request, parameters, ambiguities, context, vehicle_profile
|
||||
)
|
||||
if action is not None:
|
||||
actions.append(action)
|
||||
@@ -367,6 +376,7 @@ class ModelPlanAdapter:
|
||||
parameters: dict[str, Any],
|
||||
ambiguities: list[dict[str, str]],
|
||||
context: dict[str, Any],
|
||||
vehicle_profile: VehicleProfile | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
target = request.get("target_scope")
|
||||
if target not in {"driver", "passenger"}:
|
||||
@@ -381,21 +391,68 @@ class ModelPlanAdapter:
|
||||
capability = request["capability"]
|
||||
if capability in SEAT_MOTION_CAPABILITIES:
|
||||
axis = SEAT_MOTION_CAPABILITIES[capability]
|
||||
physical_action = ModelPlanAdapter._relative_physical_motion_action(
|
||||
index,
|
||||
request,
|
||||
parameters,
|
||||
ambiguities,
|
||||
context,
|
||||
target,
|
||||
axis,
|
||||
vehicle_profile,
|
||||
)
|
||||
if physical_action is not None:
|
||||
return physical_action
|
||||
angle = parameters.get("angle_deg")
|
||||
if angle is not None and capability in ANGLE_CALIBRATIONS:
|
||||
calibration = ANGLE_CALIBRATIONS[capability]
|
||||
if (
|
||||
not isinstance(angle, (int, float))
|
||||
or isinstance(angle, bool)
|
||||
or not calibration.minimum_deg <= float(angle) <= calibration.maximum_deg
|
||||
):
|
||||
if angle is not None:
|
||||
if not isinstance(angle, (int, float)) or isinstance(angle, bool):
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters.angle_deg",
|
||||
"question": (
|
||||
f"{capability} 的夹角必须在 "
|
||||
f"{calibration.minimum_deg:g}-{calibration.maximum_deg:g} 度之间。"
|
||||
),
|
||||
"question": f"{capability} 的夹角必须是数字。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
profile_motor = (
|
||||
vehicle_profile.seat_motors.get(axis)
|
||||
if vehicle_profile is not None
|
||||
else None
|
||||
)
|
||||
if profile_motor is not None and profile_motor.display_angle:
|
||||
if not profile_motor.contains_display_angle(float(angle)):
|
||||
display_angle = profile_motor.display_angle
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters.angle_deg",
|
||||
"question": (
|
||||
f"{capability} 的夹角必须在 "
|
||||
f"{display_angle['zero_deg']:g}-{display_angle['max_deg']:g} 度之间。"
|
||||
),
|
||||
}
|
||||
)
|
||||
return None
|
||||
position_pct = profile_motor.position_pct_for_display_angle(
|
||||
float(angle)
|
||||
)
|
||||
elif capability in ANGLE_CALIBRATIONS:
|
||||
calibration = ANGLE_CALIBRATIONS[capability]
|
||||
if not calibration.minimum_deg <= float(angle) <= calibration.maximum_deg:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters.angle_deg",
|
||||
"question": (
|
||||
f"{capability} 的夹角必须在 "
|
||||
f"{calibration.minimum_deg:g}-{calibration.maximum_deg:g} 度之间。"
|
||||
),
|
||||
}
|
||||
)
|
||||
return None
|
||||
position_pct = angle_to_position_pct(capability, float(angle))
|
||||
else:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters.position_pct",
|
||||
"question": "当前车型未给出该座椅轴的角度标定,需要 0-100 的绝对百分比。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
@@ -406,7 +463,7 @@ class ModelPlanAdapter:
|
||||
"action_type": "position",
|
||||
"target": target,
|
||||
"axis": axis,
|
||||
"position_pct": angle_to_position_pct(capability, float(angle)),
|
||||
"position_pct": position_pct,
|
||||
"angle_deg": exact_angle,
|
||||
}
|
||||
return ModelPlanAdapter._preserve_relative_motion(
|
||||
@@ -460,6 +517,157 @@ class ModelPlanAdapter:
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _relative_physical_motion_action(
|
||||
index: int,
|
||||
request: dict[str, Any],
|
||||
parameters: dict[str, Any],
|
||||
ambiguities: list[dict[str, str]],
|
||||
context: dict[str, Any],
|
||||
target: str,
|
||||
axis: str,
|
||||
vehicle_profile: VehicleProfile | None,
|
||||
) -> dict[str, Any] | None:
|
||||
physical_parameters = [
|
||||
name
|
||||
for name in ("distance_mm", "angle_delta_deg")
|
||||
if parameters.get(name) is not None
|
||||
]
|
||||
if not physical_parameters:
|
||||
return None
|
||||
if len(physical_parameters) > 1:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters",
|
||||
"question": "单个座椅轴相对移动只能提供一种物理增量。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
if vehicle_profile is None:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters.position_pct",
|
||||
"question": "缺少车型座椅量程,无法把相对物理移动换算为绝对百分比。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
parameter_name = physical_parameters[0]
|
||||
delta = parameters[parameter_name]
|
||||
if (
|
||||
not isinstance(delta, (int, float))
|
||||
or isinstance(delta, bool)
|
||||
or float(delta) <= 0
|
||||
):
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters.{parameter_name}",
|
||||
"question": "座椅相对物理移动量必须是正数。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
motor = vehicle_profile.seat_motors.get(axis)
|
||||
if motor is None:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].capability",
|
||||
"question": f"车型 {vehicle_profile.name} 未配置 {axis} 轴量程。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
expected_unit = "mm" if parameter_name == "distance_mm" else "degree"
|
||||
if motor.unit != expected_unit:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters.{parameter_name}",
|
||||
"question": (
|
||||
f"{axis} 轴使用 {motor.unit} 标定,不能用 {expected_unit} 增量换算。"
|
||||
),
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
direction = str(request.get("operation", "set"))
|
||||
if direction not in {"increase", "decrease"}:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].operation",
|
||||
"question": "相对物理移动需要 increase 或 decrease 方向。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
previous = (
|
||||
context.get("devices", {})
|
||||
.get(target, {})
|
||||
.get("position", {})
|
||||
.get(axis)
|
||||
)
|
||||
if not isinstance(previous, dict):
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].target_scope",
|
||||
"question": "缺少当前座椅姿态,无法安全执行相对物理移动。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
current_pct = previous.get("position_pct")
|
||||
if (
|
||||
not isinstance(current_pct, (int, float))
|
||||
or isinstance(current_pct, bool)
|
||||
):
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].target_scope",
|
||||
"question": "当前座椅百分比位置无效,无法安全执行相对物理移动。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
position_pct = vehicle_profile.relative_target_pct(
|
||||
axis,
|
||||
current_pct=current_pct,
|
||||
delta=float(delta),
|
||||
direction=direction,
|
||||
)
|
||||
action: dict[str, Any] = {
|
||||
"action_type": "position",
|
||||
"target": target,
|
||||
"axis": axis,
|
||||
"position_pct": position_pct,
|
||||
}
|
||||
if parameter_name == "angle_delta_deg":
|
||||
action["angle_deg"] = ModelPlanAdapter._relative_target_angle(
|
||||
previous,
|
||||
motor,
|
||||
position_pct,
|
||||
float(delta),
|
||||
direction,
|
||||
)
|
||||
return ModelPlanAdapter._preserve_relative_motion(
|
||||
index, request, action, context, ambiguities
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _relative_target_angle(
|
||||
previous: dict[str, Any],
|
||||
motor: Any,
|
||||
position_pct: int,
|
||||
delta_deg: float,
|
||||
direction: str,
|
||||
) -> int | float:
|
||||
start_angle = previous.get("angle_deg")
|
||||
if (
|
||||
isinstance(start_angle, (int, float))
|
||||
and not isinstance(start_angle, bool)
|
||||
):
|
||||
signed_delta = delta_deg if direction == "increase" else -delta_deg
|
||||
return _clean_number(float(start_angle) + signed_delta)
|
||||
display_angle = motor.display_angle_for_pct(position_pct)
|
||||
if display_angle is None:
|
||||
return _clean_number(delta_deg)
|
||||
return _clean_number(display_angle)
|
||||
|
||||
@staticmethod
|
||||
def _preserve_relative_motion(
|
||||
index: int,
|
||||
@@ -530,3 +738,7 @@ class ModelPlanAdapter:
|
||||
)
|
||||
action["motion_intent"] = motion_intent
|
||||
return action
|
||||
|
||||
|
||||
def _clean_number(value: float) -> int | float:
|
||||
return int(value) if float(value).is_integer() else round(float(value), 1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
"""Vehicle-specific seat calibration profiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SeatMotorProfile:
|
||||
id: str
|
||||
name: str
|
||||
unit: str
|
||||
range: float
|
||||
reference_zero: str
|
||||
positive_direction: str
|
||||
hall_total_pulses: int
|
||||
display_angle: dict[str, Any] | None = None
|
||||
|
||||
def delta_to_pct(self, delta: float) -> float:
|
||||
if self.range <= 0:
|
||||
raise ValueError(f"motor {self.id} range must be positive")
|
||||
return float(delta) / self.range * 100
|
||||
|
||||
def display_angle_for_pct(self, position_pct: int) -> float | None:
|
||||
if not self.display_angle:
|
||||
return None
|
||||
zero = float(self.display_angle["zero_deg"])
|
||||
maximum = float(self.display_angle["max_deg"])
|
||||
return zero + (maximum - zero) * _clamp_pct(position_pct) / 100
|
||||
|
||||
def position_pct_for_display_angle(self, angle_deg: int | float) -> int:
|
||||
if not self.display_angle:
|
||||
raise ValueError(f"motor {self.id} does not define a display angle range")
|
||||
zero = float(self.display_angle["zero_deg"])
|
||||
maximum = float(self.display_angle["max_deg"])
|
||||
if maximum <= zero:
|
||||
raise ValueError(f"motor {self.id} display angle range must be positive")
|
||||
normalized = (float(angle_deg) - zero) / (maximum - zero)
|
||||
return _clamp_pct(round(normalized * 100))
|
||||
|
||||
def contains_display_angle(self, angle_deg: int | float) -> bool:
|
||||
if not self.display_angle:
|
||||
return False
|
||||
zero = float(self.display_angle["zero_deg"])
|
||||
maximum = float(self.display_angle["max_deg"])
|
||||
return zero <= float(angle_deg) <= maximum
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"unit": self.unit,
|
||||
"range": _public_number(self.range),
|
||||
"reference_zero": self.reference_zero,
|
||||
"positive_direction": self.positive_direction,
|
||||
"hall_total_pulses": self.hall_total_pulses,
|
||||
}
|
||||
if self.display_angle is not None:
|
||||
payload["display_angle"] = dict(self.display_angle)
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VehicleProfile:
|
||||
id: str
|
||||
name: str
|
||||
seat_motors: dict[str, SeatMotorProfile]
|
||||
|
||||
def relative_target_pct(
|
||||
self,
|
||||
axis: str,
|
||||
*,
|
||||
current_pct: int | float,
|
||||
delta: int | float,
|
||||
direction: str,
|
||||
) -> int:
|
||||
motor = self.seat_motors[axis]
|
||||
delta_pct = motor.delta_to_pct(float(delta))
|
||||
if direction == "decrease":
|
||||
delta_pct = -delta_pct
|
||||
elif direction != "increase":
|
||||
raise ValueError(f"unsupported relative direction: {direction}")
|
||||
return _clamp_pct(round(float(current_pct) + delta_pct))
|
||||
|
||||
def display_angle_for_pct(self, axis: str, position_pct: int) -> float | None:
|
||||
return self.seat_motors[axis].display_angle_for_pct(position_pct)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"seat": {
|
||||
"motors": {
|
||||
key: motor.public_dict()
|
||||
for key, motor in self.seat_motors.items()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VehicleProfileRegistry:
|
||||
def __init__(self, profile_root: Path) -> None:
|
||||
self.profile_root = profile_root
|
||||
self._profiles = self._load_profiles(profile_root)
|
||||
|
||||
def get(self, vehicle_id: str | None) -> VehicleProfile:
|
||||
resolved = vehicle_id or self.default().id
|
||||
try:
|
||||
return self._profiles[resolved]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"unknown vehicle profile: {resolved}") from exc
|
||||
|
||||
def default(self) -> VehicleProfile:
|
||||
if "denza_n7" in self._profiles:
|
||||
return self._profiles["denza_n7"]
|
||||
try:
|
||||
return next(iter(self._profiles.values()))
|
||||
except StopIteration as exc:
|
||||
raise ValueError(f"no vehicle profiles found in {self.profile_root}") from exc
|
||||
|
||||
def public_profiles(self) -> list[dict[str, Any]]:
|
||||
return [profile.public_dict() for profile in self._profiles.values()]
|
||||
|
||||
@staticmethod
|
||||
def _load_profiles(profile_root: Path) -> dict[str, VehicleProfile]:
|
||||
profiles: dict[str, VehicleProfile] = {}
|
||||
for path in sorted(profile_root.glob("*.yaml")):
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
profile = _parse_vehicle_profile(data)
|
||||
profiles[profile.id] = profile
|
||||
return profiles
|
||||
|
||||
|
||||
def _parse_vehicle_profile(data: dict[str, Any]) -> VehicleProfile:
|
||||
seat = data.get("seat") or {}
|
||||
motors = seat.get("motors") or {}
|
||||
seat_motors = {
|
||||
str(axis): SeatMotorProfile(
|
||||
id=str(axis),
|
||||
name=str(payload["name"]),
|
||||
unit=str(payload["unit"]),
|
||||
range=float(payload["range"]),
|
||||
reference_zero=str(payload["reference_zero"]),
|
||||
positive_direction=str(payload["positive_direction"]),
|
||||
hall_total_pulses=int(payload["hall_total_pulses"]),
|
||||
display_angle=payload.get("display_angle"),
|
||||
)
|
||||
for axis, payload in motors.items()
|
||||
}
|
||||
return VehicleProfile(
|
||||
id=str(data["id"]),
|
||||
name=str(data["name"]),
|
||||
seat_motors=seat_motors,
|
||||
)
|
||||
|
||||
|
||||
def _clamp_pct(value: int | float) -> int:
|
||||
return max(0, min(100, int(value)))
|
||||
|
||||
|
||||
def _public_number(value: float) -> int | float:
|
||||
return int(value) if value.is_integer() else value
|
||||
Reference in New Issue
Block a user