feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""Deterministic native-math oracle, consumed by Vitest. Run from repository root."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from training_server.mobile_manipulator.kernel import ROBOTS, TASK, TaskKernel, decode_action
|
||||
from training_server.mobile_manipulator.motion import SafeActionController
|
||||
|
||||
|
||||
def generate():
|
||||
rng = np.random.default_rng(2026)
|
||||
cases = []
|
||||
for config in ROBOTS:
|
||||
kernel = TaskKernel(config)
|
||||
for i in range(20):
|
||||
s = rng.uniform(-2, 2, TASK["stateSize"])
|
||||
for start in [3, 33, 40, 50]:
|
||||
q = rng.normal(size=4)
|
||||
s[start : start + 4] = q / np.linalg.norm(q)
|
||||
s[29] = rng.uniform(0, 1)
|
||||
action = rng.uniform(-1.5, 1.5, TASK["actionSize"]).astype(np.float32)
|
||||
kernel.reset()
|
||||
kernel.has_lifted = bool(i % 2)
|
||||
obs, reward, terminated, truncated, info = kernel.evaluate(s)
|
||||
cases.append(
|
||||
{
|
||||
"robotId": config["id"],
|
||||
"state": s.tolist(),
|
||||
"action": action.tolist(),
|
||||
"lifted": bool(i % 2),
|
||||
"observation": obs.tolist(),
|
||||
"control": decode_action(config, action).tolist(),
|
||||
"reward": reward,
|
||||
"terminated": terminated,
|
||||
"truncated": truncated,
|
||||
"info": info,
|
||||
}
|
||||
)
|
||||
target = Path(__file__).resolve().parents[2] / "contracts/fixtures/mobile-golden.json"
|
||||
target.write_text(json.dumps(cases, indent=2) + "\n")
|
||||
motion_cases = []
|
||||
for config in ROBOTS:
|
||||
for stage in ("navigate", "reach", "pick-place"):
|
||||
s = np.zeros(TASK["stateSize"])
|
||||
s[[3, 33, 40, 50]] = 1
|
||||
s[37:40] = [*TASK["navigationOffset"][:2], TASK["objectStart"][2]]
|
||||
s[29] = 1
|
||||
for i, joint in enumerate(config["armJoints"]):
|
||||
s[13 + i] = joint["neutral"]
|
||||
motion = SafeActionController(config)
|
||||
motion.reset(s)
|
||||
frames = []
|
||||
for _ in range(12):
|
||||
action = rng.uniform(-2, 2, TASK["actionSize"]).astype(np.float32)
|
||||
motion.apply(action, s, stage, False)
|
||||
frames.append(
|
||||
dict(
|
||||
action=action.tolist(),
|
||||
control=motion.control.tolist(),
|
||||
applied=motion.applied.tolist(),
|
||||
targets=motion.targets.tolist(),
|
||||
)
|
||||
)
|
||||
motion_cases.append(
|
||||
dict(robotId=config["id"], stage=stage, state=s.tolist(), frames=frames)
|
||||
)
|
||||
target.with_name("mobile-motion-v2-golden.json").write_text(
|
||||
json.dumps(motion_cases, indent=2) + "\n"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate()
|
||||
@@ -0,0 +1,224 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
|
||||
from training_server.mobile_manipulator.kernel import (
|
||||
CONTRACTS,
|
||||
ROBOTS,
|
||||
TASK,
|
||||
TaskKernel,
|
||||
decode_action,
|
||||
)
|
||||
|
||||
|
||||
def make_package(path):
|
||||
import mujoco
|
||||
|
||||
config = copy.deepcopy(ROBOTS[0])
|
||||
config["recipe"] = "mjcf"
|
||||
root = ET.Element("mujoco")
|
||||
ET.SubElement(root, "compiler", angle="radian")
|
||||
ET.SubElement(root, "option", timestep=".002", gravity="0 0 0", integrator="implicitfast")
|
||||
world = ET.SubElement(root, "worldbody")
|
||||
base = ET.SubElement(world, "body", name=config["baseBodyName"], pos="0 0 .1")
|
||||
ET.SubElement(base, "freejoint", name=config["baseJointName"])
|
||||
ET.SubElement(base, "geom", type="sphere", size=".05", mass="2", contype="0", conaffinity="0")
|
||||
actuators = ET.SubElement(root, "actuator")
|
||||
for name in config["baseJoints"]:
|
||||
body = ET.SubElement(base, "body")
|
||||
ET.SubElement(body, "joint", name=name, axis="0 1 0", damping=".1")
|
||||
ET.SubElement(
|
||||
body, "geom", type="sphere", size=".02", mass=".1", contype="0", conaffinity="0"
|
||||
)
|
||||
ET.SubElement(
|
||||
actuators,
|
||||
"velocity",
|
||||
name=name + "_servo",
|
||||
joint=name,
|
||||
kv="1",
|
||||
ctrllimited="true",
|
||||
ctrlrange=f"{-config['wheelLimit']} {config['wheelLimit']}",
|
||||
)
|
||||
for j in [
|
||||
*config["armJoints"],
|
||||
dict(name=config["gripperJoint"], min=config["gripperClosed"], max=config["gripperOpen"]),
|
||||
]:
|
||||
body = ET.SubElement(base, "body", pos="0 0 .1")
|
||||
if j["name"] == config["armJoints"][-1]["name"]:
|
||||
body.set("name", config["eefBodyName"])
|
||||
ET.SubElement(body, "site", name=config["eefSiteName"], pos=".1 0 0")
|
||||
ET.SubElement(
|
||||
body,
|
||||
"joint",
|
||||
name=j["name"],
|
||||
axis="0 0 1",
|
||||
limited="true",
|
||||
range=f"{j['min']} {j['max']}",
|
||||
damping=".1",
|
||||
armature=".01",
|
||||
)
|
||||
ET.SubElement(
|
||||
body, "geom", type="sphere", size=".02", mass=".1", contype="0", conaffinity="0"
|
||||
)
|
||||
ET.SubElement(
|
||||
actuators,
|
||||
"position",
|
||||
name=j["name"] + "_servo",
|
||||
joint=j["name"],
|
||||
kp="10",
|
||||
kv="1",
|
||||
ctrllimited="true",
|
||||
ctrlrange=f"{j['min']} {j['max']}",
|
||||
)
|
||||
body = ET.SubElement(
|
||||
world, "body", name="__mm_object", pos=" ".join(map(str, TASK["objectStart"]))
|
||||
)
|
||||
ET.SubElement(body, "freejoint", name="__mm_object_joint")
|
||||
ET.SubElement(body, "geom", type="box", size=".018 .018 .018", mass=".04")
|
||||
ET.SubElement(
|
||||
world, "body", name="__mm_goal", mocap="true", pos=" ".join(map(str, TASK["goalStart"]))
|
||||
)
|
||||
(path / "scene.xml").write_text(ET.tostring(root, encoding="unicode"))
|
||||
(path / "robot.json").write_text(json.dumps(config, separators=(",", ":")))
|
||||
(path / "task.json").write_text(json.dumps(TASK))
|
||||
(path / "environment.json").write_text(
|
||||
json.dumps(
|
||||
dict(
|
||||
scene="scene.xml",
|
||||
mujoco=mujoco.__version__,
|
||||
taskId=TASK["id"],
|
||||
robotId=config["id"],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class KernelTests(unittest.TestCase):
|
||||
def test_golden_and_reusable_buffers(self):
|
||||
cases = json.loads((CONTRACTS / "fixtures/mobile-golden.json").read_text())
|
||||
for case in cases:
|
||||
config = next(c for c in ROBOTS if c["id"] == case["robotId"])
|
||||
kernel = TaskKernel(config)
|
||||
kernel.has_lifted = case["lifted"]
|
||||
observation, reward, *_, info = kernel.evaluate(np.array(case["state"]))
|
||||
np.testing.assert_allclose(observation, case["observation"], atol=1e-7)
|
||||
np.testing.assert_allclose(
|
||||
decode_action(config, case["action"]), case["control"], atol=1e-12
|
||||
)
|
||||
self.assertAlmostEqual(reward, case["reward"], places=12)
|
||||
self.assertEqual(info["stage"], case["info"]["stage"])
|
||||
self.assertIs(kernel.observe(np.array(case["state"])), observation)
|
||||
|
||||
def test_invalid_action_is_atomic_and_velocity_mode(self):
|
||||
config = copy.deepcopy(ROBOTS[0])
|
||||
config["armJoints"][0]["mode"] = "velocity"
|
||||
output = np.ones(9)
|
||||
action = np.zeros(12, dtype=np.float32)
|
||||
action[3] = 0.5
|
||||
decode_action(config, action, output)
|
||||
self.assertEqual(output[3], 1)
|
||||
previous = output.copy()
|
||||
action[11] = np.nan
|
||||
with self.assertRaises(ValueError):
|
||||
decode_action(config, action, output)
|
||||
np.testing.assert_equal(previous, output)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
importlib.util.find_spec("mujoco") and importlib.util.find_spec("gymnasium"),
|
||||
"install mobile_manipulator/requirements.txt for native tests",
|
||||
)
|
||||
class NativeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from training_server.mobile_manipulator.env import MobileManipulatorEnv
|
||||
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.directory.name)
|
||||
make_package(self.path)
|
||||
self.env = MobileManipulatorEnv(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
self.env.close()
|
||||
self.directory.cleanup()
|
||||
|
||||
def test_gym_contract_and_reset(self):
|
||||
from gymnasium.utils.env_checker import check_env
|
||||
|
||||
check_env(self.env, skip_render_check=True)
|
||||
obs, _ = self.env.reset(seed=42)
|
||||
action = self.env.action.copy()
|
||||
action[3] = 0.2
|
||||
stepped = self.env.step(action)
|
||||
self.assertTrue(np.isfinite(stepped[0]).all())
|
||||
self.assertFalse(np.shares_memory(obs, stepped[0]))
|
||||
self.assertAlmostEqual(self.env.data.time, TASK["controlDt"])
|
||||
self.env.move_task_entity("goal", [0.7, 0.2, 0.019])
|
||||
np.testing.assert_allclose(self.env.state[47:50], [0.7, 0.2, 0.019])
|
||||
self.assertEqual(self.env.kernel.steps, 0)
|
||||
self.env.kernel.has_lifted = True
|
||||
self.env.move_task_entity("object", [0.3, 0.1, 0.02])
|
||||
self.assertFalse(self.env.kernel.has_lifted)
|
||||
self.assertEqual(self.env.data.qvel[self.env.object_v : self.env.object_v + 6].sum(), 0)
|
||||
self.env.close()
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.env.step(action)
|
||||
|
||||
def test_navigation_holds_arm_and_has_observable_controller_state(self):
|
||||
self.env.kernel.stage = "navigate"
|
||||
self.env.reset(seed=7)
|
||||
before = self.env.control.copy()
|
||||
obs, _, _, _, info = self.env.step(np.ones(TASK["actionSize"], dtype=np.float32))
|
||||
np.testing.assert_allclose(self.env.control[3:], before[3:])
|
||||
np.testing.assert_equal(obs[68:80], self.env.motion.applied)
|
||||
np.testing.assert_equal(obs[80:92], self.env.motion.targets)
|
||||
self.assertEqual(info["safety_stop"], "")
|
||||
self.assertLess(info["max_joint_velocity"], TASK["jointSpeedStop"])
|
||||
self.env.data.qvel[self.env.arm[0][1]] = 30
|
||||
self.assertTrue(self.env.step(np.zeros(12, dtype=np.float32))[2])
|
||||
self.assertEqual(self.env.kernel.info["safety_stop"], "joint_velocity")
|
||||
|
||||
def test_hold_does_not_ratchet_targets_toward_gravity_sag(self):
|
||||
import mujoco
|
||||
|
||||
previous = self.env.control.copy()
|
||||
self.env.data.qpos[self.env.arm[0][0]] -= 0.1
|
||||
mujoco.mj_forward(self.env.model, self.env.data)
|
||||
for _ in range(10):
|
||||
self.env.hold()
|
||||
np.testing.assert_equal(self.env.control, previous)
|
||||
|
||||
def test_randomized_reset_is_seeded_and_evaluation_can_be_fixed(self):
|
||||
self.env.position_jitter = 0.1
|
||||
first, _ = self.env.reset(seed=7)
|
||||
second, _ = self.env.reset(seed=7)
|
||||
third, _ = self.env.reset(seed=8)
|
||||
np.testing.assert_equal(first, second)
|
||||
self.assertFalse(np.array_equal(first, third))
|
||||
self.env.position_jitter = 0
|
||||
first, _ = self.env.reset(seed=7)
|
||||
second, _ = self.env.reset(seed=8)
|
||||
np.testing.assert_equal(first, second)
|
||||
|
||||
def test_reject_mismatching_model_and_version(self):
|
||||
from training_server.mobile_manipulator.env import MobileManipulatorEnv
|
||||
|
||||
meta = json.loads((self.path / "environment.json").read_text())
|
||||
meta["mujoco"] = "0.0.0"
|
||||
(self.path / "environment.json").write_text(json.dumps(meta))
|
||||
with self.assertRaisesRegex(ValueError, "version mismatch"):
|
||||
MobileManipulatorEnv(self.path)
|
||||
config = json.loads((self.path / "robot.json").read_text())
|
||||
config["armJoints"][0]["max"] = 1
|
||||
(self.path / "robot.json").write_text(json.dumps(config))
|
||||
with self.assertRaisesRegex(ValueError, "joint range"):
|
||||
MobileManipulatorEnv(self.path, allow_version_mismatch=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,273 @@
|
||||
"""One-click API/runner tests. No CUDA, mjlab or large robot assets required."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import zipfile
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from mobile_manipulator.config import MOBILE_TASKS, ROBOTS, TASK
|
||||
from server import DEFAULT_TASKS, ApiError, TrainingJob, TrainingManager, TrainingRequestHandler
|
||||
|
||||
TASK_ID = next(iter(MOBILE_TASKS))
|
||||
|
||||
|
||||
def archive(robot_id="lekiwi-v1", extra=None):
|
||||
files = {
|
||||
"robot.json": json.dumps(ROBOTS[robot_id], separators=(",", ":")),
|
||||
"task.json": json.dumps(TASK),
|
||||
"environment.json": json.dumps(
|
||||
dict(scene="scene.xml", robotId=robot_id, taskId=TASK["id"], mujoco="3.11.0")
|
||||
),
|
||||
"scene.xml": "<mujoco><worldbody/></mujoco>",
|
||||
}
|
||||
files.update(extra or {})
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, "w") as z:
|
||||
for name, value in files.items():
|
||||
z.writestr(zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)), value)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
class MobileTrainingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.manager = TrainingManager(
|
||||
self.root, sys.executable, DEFAULT_TASKS, check_environment=False
|
||||
)
|
||||
raw = archive()
|
||||
self.package = self.manager.mobile_packages.receive(io.BytesIO(raw), len(raw))
|
||||
|
||||
def tearDown(self):
|
||||
self.manager.shutdown()
|
||||
self.temp.cleanup()
|
||||
|
||||
def payload(self, **changes):
|
||||
result = dict(
|
||||
taskId=TASK_ID,
|
||||
numEnvs=2,
|
||||
maxIterations=2,
|
||||
seed=123,
|
||||
runName="mobile",
|
||||
device="cpu",
|
||||
gpuIds=[],
|
||||
mobilePackageId=self.package["id"],
|
||||
mobileParams=dict(rolloutSteps=8, goalPosition=[0.5, 0.4, 0.05]),
|
||||
)
|
||||
result.update(changes)
|
||||
return result
|
||||
|
||||
def test_registry_readiness_is_per_family(self):
|
||||
health = self.manager.health()
|
||||
self.assertTrue(health["ready"])
|
||||
mobile = [m for m in health["taskMetadata"] if m.get("family") == "mobile-manipulator"]
|
||||
self.assertEqual(len(mobile), 2)
|
||||
self.assertTrue(all(m["ready"] and not m["terrainPresets"] for m in mobile))
|
||||
self.assertFalse(health["taskMetadata"][0]["ready"])
|
||||
|
||||
def test_validation_variant_and_parameter_bounds(self):
|
||||
config = self.manager.parse_config(self.payload())
|
||||
self.assertEqual(config.seed, 123)
|
||||
self.assertEqual(config.mobile_params["rolloutSteps"], 8)
|
||||
self.assertEqual(config.mobile_params["goalPosition"], [0.5, 0.4, 0.05])
|
||||
self.assertEqual(config.deployment["sceneSha256"], self.package["sceneSha256"])
|
||||
for fields in [
|
||||
dict(taskId="MobileManipulator-LeKiwi-Bundle"),
|
||||
dict(numEnvs=65),
|
||||
dict(mobilePackageId="../../etc"),
|
||||
dict(terrainPreset="plane"),
|
||||
dict(mobileParams={"rolloutSteps": True}),
|
||||
dict(mobileParams={"rolloutSteps": 7}),
|
||||
dict(mobileParams={"stage": "fly"}),
|
||||
dict(mobileParams={"stage": "reach"}),
|
||||
dict(mobileParams={"sourceJobId": "../../untrusted.zip"}),
|
||||
dict(mobileParams={"positionJitter": float("nan")}),
|
||||
dict(mobileParams={"evaluationEpisodes": True}),
|
||||
dict(mobileParams={"goalPosition": [0, float("nan"), 1]}),
|
||||
dict(mobileParams={"goalPosition": [0, 0, -1]}),
|
||||
dict(device="gpu", gpuIds=[0, 1]),
|
||||
dict(pretrainedSourceId="abc"),
|
||||
]:
|
||||
with self.subTest(fields=fields), self.assertRaises(ApiError):
|
||||
self.manager.parse_config(self.payload(**fields))
|
||||
bundle = archive("lekiwi-bundle")
|
||||
uploaded = self.manager.mobile_packages.receive(io.BytesIO(bundle), len(bundle))
|
||||
self.assertEqual(
|
||||
self.manager.parse_config(
|
||||
self.payload(
|
||||
taskId="MobileManipulator-LeKiwi-Bundle", mobilePackageId=uploaded["id"]
|
||||
)
|
||||
).deployment["robotId"],
|
||||
"lekiwi-bundle",
|
||||
)
|
||||
|
||||
def test_runner_arguments_use_server_owned_paths_and_mobile_interpreter(self):
|
||||
self.manager.mobile_python = "/isolated/mobile/python"
|
||||
args = self.manager.command_for(
|
||||
self.manager.parse_config(self.payload(device="gpu", gpuIds=[2]))
|
||||
)
|
||||
self.assertEqual(args[0], self.manager.mobile_python)
|
||||
self.assertIn("training_server.mobile_manipulator.train", args)
|
||||
self.assertIn("cuda:2", args)
|
||||
self.assertEqual(args[args.index("--seed") + 1], "123")
|
||||
self.assertEqual(args[args.index("--num-envs") + 1], "2")
|
||||
self.assertTrue(Path(args[args.index("--package") + 1]).is_relative_to(self.root))
|
||||
|
||||
def test_upload_rejects_traversal_xml_external_paths_and_wrong_contract(self):
|
||||
for extra in [
|
||||
{"../escape": "x"},
|
||||
{"scene.xml": '<mujoco><include file="a.xml"/></mujoco>'},
|
||||
{"scene.xml": '<mujoco><asset><mesh file="/etc/passwd"/></asset></mujoco>'},
|
||||
{"scene.xml": '<mujoco><extension><plugin plugin="bad"/></extension></mujoco>'},
|
||||
{"robot.json": "{}"},
|
||||
{"robot.json": "[]"},
|
||||
{"environment.json": "[]"},
|
||||
{"task.json": "{}"},
|
||||
{"./scene.xml": "<mujoco/>"},
|
||||
{"scene.xml": '<mujoco><asset><texture fileleft="/etc/passwd"/></asset></mujoco>'},
|
||||
]:
|
||||
data = archive(extra=extra)
|
||||
with self.subTest(extra=extra), self.assertRaises(ValueError):
|
||||
self.manager.mobile_packages.receive(io.BytesIO(data), len(data))
|
||||
with self.assertRaises(ValueError):
|
||||
self.manager.mobile_packages.receive(io.BytesIO(b"bad"), 3)
|
||||
with patch("mobile_manipulator.packages.MAX_EXPANDED", 1), self.assertRaises(ValueError):
|
||||
data = archive(extra={"extra.txt": "other"})
|
||||
self.manager.mobile_packages.receive(io.BytesIO(data), len(data))
|
||||
|
||||
def test_staged_resume_requires_matching_successful_evaluated_job(self):
|
||||
source = TrainingJob(
|
||||
id="d" * 32, config=self.manager.parse_config(self.payload()), state="succeeded"
|
||||
)
|
||||
source.artifact = self.root / "policy.onnx"
|
||||
source.artifact.write_bytes(b"onnx")
|
||||
source.artifact.with_suffix(".ppo.zip").write_bytes(b"trusted-checkpoint")
|
||||
self.manager.jobs[source.id] = source
|
||||
params = source.config.mobile_params | {"stage": "reach", "sourceJobId": source.id}
|
||||
with self.assertRaisesRegex(ApiError, "尚未达标"):
|
||||
self.manager.parse_config(self.payload(mobileParams=params))
|
||||
source.config.deployment["evaluation"] = {
|
||||
"episodes": 10,
|
||||
"successRate": 0.8,
|
||||
"safetyStops": 0,
|
||||
}
|
||||
config = self.manager.parse_config(self.payload(mobileParams=params))
|
||||
command = self.manager.command_for(config)
|
||||
self.assertEqual(
|
||||
command[command.index("--resume") + 1], str(source.artifact.with_suffix(".ppo.zip"))
|
||||
)
|
||||
with self.assertRaisesRegex(ApiError, "依次推进"):
|
||||
self.manager.parse_config(self.payload(mobileParams=params | {"stage": "pick-place"}))
|
||||
source.config.deployment["evaluation"]["safetyStops"] = 1
|
||||
with self.assertRaisesRegex(ApiError, "尚未达标"):
|
||||
self.manager.parse_config(self.payload(mobileParams=params))
|
||||
# Poor quality still permits same-stage continuation, never a stage promotion.
|
||||
self.manager.parse_config(self.payload(mobileParams=params | {"stage": "navigate"}))
|
||||
changed = archive(extra={"mesh.txt": "different bytes"})
|
||||
other_package = self.manager.mobile_packages.receive(io.BytesIO(changed), len(changed))
|
||||
with self.assertRaisesRegex(ApiError, "资产快照不匹配"):
|
||||
self.manager.parse_config(
|
||||
self.payload(
|
||||
mobilePackageId=other_package["id"], mobileParams=params | {"stage": "navigate"}
|
||||
)
|
||||
)
|
||||
source.config.deployment["sceneSha256"] = "other"
|
||||
with self.assertRaisesRegex(ApiError, "不匹配"):
|
||||
self.manager.parse_config(self.payload(mobileParams=params | {"stage": "navigate"}))
|
||||
|
||||
def fake_command(self, config, _path=None):
|
||||
# Real subprocess and lifecycle, deterministic stand-in only for expensive PPO/export.
|
||||
script = self.root / "fake.py"
|
||||
metadata = config.deployment | {"modelSha256": hashlib.sha256(b"onnx").hexdigest()}
|
||||
script.write_text(
|
||||
"import pathlib,sys,json,time\n"
|
||||
"out=pathlib.Path(sys.argv[sys.argv.index('--output')+1])\n"
|
||||
"print('Learning iteration 1 / 2',flush=True)\n"
|
||||
"print('Mean surrogate loss: -0.25',flush=True)\n"
|
||||
"time.sleep(.05)\n"
|
||||
"out.write_bytes(b'onnx')\n"
|
||||
f"out.with_name('deployment.json').write_text({json.dumps(json.dumps(metadata))})\n"
|
||||
)
|
||||
return [sys.executable, "-u", str(script)]
|
||||
|
||||
def test_http_create_poll_download_and_failures(self):
|
||||
manager = self.manager
|
||||
|
||||
class Handler(TrainingRequestHandler):
|
||||
access_token = "test-token"
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
Handler.manager = manager
|
||||
http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=http.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = f"http://127.0.0.1:{http.server_port}"
|
||||
|
||||
def request(path, body=None, content_type="application/json", token="test-token"):
|
||||
req = Request(
|
||||
base + path,
|
||||
data=body,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
|
||||
)
|
||||
return urlopen(req, timeout=5)
|
||||
|
||||
try:
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
request(
|
||||
"/api/training/mobile-packages", archive(), "application/zip", token="wrong"
|
||||
)
|
||||
self.assertEqual(error.exception.code, 401)
|
||||
error.exception.close()
|
||||
with request("/api/training/mobile-packages", archive(), "application/zip") as response:
|
||||
self.assertEqual(json.load(response)["id"], self.package["id"])
|
||||
with patch.object(manager, "command_for", side_effect=self.fake_command):
|
||||
with request("/api/training/jobs", json.dumps(self.payload()).encode()) as response:
|
||||
self.assertEqual(response.status, 202)
|
||||
job = json.load(response)
|
||||
for _ in range(100):
|
||||
with request("/api/training/jobs/" + job["id"]) as response:
|
||||
job = json.load(response)
|
||||
if job["state"] not in ("queued", "running"):
|
||||
break
|
||||
time.sleep(0.02)
|
||||
self.assertEqual(job["state"], "succeeded", job)
|
||||
self.assertEqual(job["progress"], 1)
|
||||
self.assertIn("Mean surrogate loss: -0.25", job["logs"])
|
||||
for filename in ("policy.onnx", "deployment.json"):
|
||||
with request(f"/api/training/jobs/{job['id']}/artifacts/{filename}") as response:
|
||||
self.assertTrue(response.read())
|
||||
self.assertIsNone(manager.lease.public())
|
||||
failed = TrainingJob(id="f" * 32, config=manager.parse_config(self.payload()))
|
||||
with patch.object(
|
||||
manager, "command_for", return_value=[sys.executable, "-c", "raise SystemExit(7)"]
|
||||
):
|
||||
manager._run(failed)
|
||||
self.assertEqual(failed.state, "failed")
|
||||
self.assertIsNone(failed.artifact)
|
||||
finally:
|
||||
http.shutdown()
|
||||
http.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_cancel_before_launch_and_progress_parser(self):
|
||||
job = TrainingJob(id="c" * 32, config=self.manager.parse_config(self.payload()))
|
||||
self.manager._update_from_log(job, "\x1b[32mLearning iteration 1 / 2\x1b[0m")
|
||||
self.assertEqual(job.public()["progress"], 0.5)
|
||||
job.cancel_requested = True
|
||||
with patch("server.subprocess.Popen") as popen:
|
||||
self.manager._run(job)
|
||||
popen.assert_not_called()
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
Reference in New Issue
Block a user