feat(simulation): add Worker AI polygon runtime and terrain navigation
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
"""Synthetic protocol checks; these do not qualify rendering or AI model quality."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
from pydantic import ValidationError
|
||||
from test_observatory_recorded_jobs import _definitions
|
||||
|
||||
from k1link.observatory.recorded_jobs import ObservatoryRecordedJobQueue
|
||||
from k1link.simulation.ai_polygon.contracts import (
|
||||
Decision,
|
||||
RunApplied,
|
||||
RunCreate,
|
||||
RunSample,
|
||||
WorkerHello,
|
||||
WorkerWorldCreate,
|
||||
WorldCreate,
|
||||
WorldSettings,
|
||||
)
|
||||
from k1link.simulation.ai_polygon.policy import RoadPolicy
|
||||
from k1link.simulation.ai_polygon.runs import RunStore
|
||||
from k1link.simulation.ai_polygon.worlds import WorldStore, inspect_gaussian_ply
|
||||
from k1link.web.ai_polygon_api import build_ai_polygon_router
|
||||
|
||||
|
||||
def ply(value=0.0):
|
||||
names = [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"opacity",
|
||||
"f_dc_0",
|
||||
"f_dc_1",
|
||||
"f_dc_2",
|
||||
"scale_0",
|
||||
"scale_1",
|
||||
"scale_2",
|
||||
"rot_0",
|
||||
"rot_1",
|
||||
"rot_2",
|
||||
"rot_3",
|
||||
]
|
||||
header = "ply\nformat binary_little_endian 1.0\nelement vertex 1\n"
|
||||
header += "".join(f"property float {name}\n" for name in names) + "end_header\n"
|
||||
return header.encode() + np.full(14, value, dtype="<f4").tobytes()
|
||||
|
||||
|
||||
def make_world(worlds):
|
||||
data = ply()
|
||||
world = worlds.create(
|
||||
WorldCreate(
|
||||
name="Synthetic protocol fixture",
|
||||
filename="fixture.PLY",
|
||||
byte_length=len(data),
|
||||
author="test",
|
||||
license="CC0",
|
||||
)
|
||||
)
|
||||
worlds.append(world["world_id"], 0, data)
|
||||
worlds.complete(world["world_id"])
|
||||
return worlds.configure(world["world_id"], WorldSettings(prepared=True))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runs(tmp_path):
|
||||
queue = ObservatoryRecordedJobQueue(tmp_path, definitions=_definitions())
|
||||
worlds = WorldStore(tmp_path)
|
||||
store = RunStore(worlds, queue)
|
||||
hello = WorkerHello(
|
||||
worker_id="worker-006",
|
||||
instance_id="a" * 32,
|
||||
runtime="isaac-sim-6.1",
|
||||
model_ids=["ddrnet-goose-pytorch-reference", "rf_detr_large"],
|
||||
profile_sha256="b" * 64,
|
||||
runtime_sources={key: "c" * 64 for key in ("worker", "scene", "models", "robot")},
|
||||
)
|
||||
store.register(hello)
|
||||
return store, hello, make_world(worlds)
|
||||
|
||||
|
||||
def sample(sequence=0, **changes):
|
||||
stream = io.BytesIO()
|
||||
Image.new("RGB", (800, 600)).save(stream, "JPEG")
|
||||
row = dict(
|
||||
sequence=sequence,
|
||||
simulation_time_ns=sequence * 100_000_000,
|
||||
inference_ms=5,
|
||||
pose_xy=(0, 0),
|
||||
decision=Decision(
|
||||
speed_mps=0, yaw_rate_rps=0, reason="no-road", road_fraction=0, obstacle_count=0
|
||||
),
|
||||
image_jpeg_base64=base64.b64encode(stream.getvalue()).decode(),
|
||||
)
|
||||
return RunSample(**{**row, **changes})
|
||||
|
||||
|
||||
def test_resume_and_atomic_publication_recovery(tmp_path):
|
||||
worlds = WorldStore(tmp_path)
|
||||
data = ply()
|
||||
row = worlds.create(
|
||||
WorldCreate(
|
||||
name=" Test ", filename="scan.ply", byte_length=len(data), author="Test", license="CC0"
|
||||
)
|
||||
)
|
||||
key = row["world_id"]
|
||||
worlds.append(key, 0, data[:20])
|
||||
assert worlds.prefix_hashes(key)["chunks"] == [
|
||||
{"byte_length": 20, "sha256": hashlib.sha256(data[:20]).hexdigest()}
|
||||
]
|
||||
with pytest.raises(RuntimeError):
|
||||
worlds.append(key, 0, data[:20])
|
||||
with pytest.raises(RuntimeError):
|
||||
worlds.complete(key)
|
||||
worlds.append(key, 20, data[20:])
|
||||
directory = worlds.directory(key)
|
||||
(directory / "source.part").replace(directory / "source.ply")
|
||||
recovered = WorldStore(tmp_path).complete(key)
|
||||
assert recovered["status"] == "available"
|
||||
assert recovered["sha256"] == hashlib.sha256(data).hexdigest()
|
||||
assert recovered["name"] == "Test"
|
||||
assert worlds.complete(key) == recovered
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data", [b"not ply", ply()[:-1], ply() + b"xxxx", ply(float("nan")), ply(float("inf"))]
|
||||
)
|
||||
def test_invalid_ply_rejected(tmp_path, data):
|
||||
path = tmp_path / "bad.ply"
|
||||
path.write_bytes(data)
|
||||
with pytest.raises(ValueError):
|
||||
inspect_gaussian_ply(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"settings",
|
||||
[{"spawn_xy": [float("nan"), 0]}, {"rotation_degrees": [0, 0, 900]}, {"max_speed_mps": 2}],
|
||||
)
|
||||
def test_nonfinite_or_out_of_bounds_settings_rejected(settings):
|
||||
with pytest.raises(ValidationError):
|
||||
WorldSettings(**settings)
|
||||
|
||||
|
||||
def test_start_snapshots_and_idempotency(runs):
|
||||
store, hello, world = runs
|
||||
request = RunCreate(world_id=world["world_id"], max_steps=2)
|
||||
row = store.start(request, "request-001")
|
||||
assert store.start(request, "request-001") == row
|
||||
store.worlds.configure(world["world_id"], WorldSettings(max_speed_mps=0.5, prepared=True))
|
||||
assert store.get(row["run_id"])["world"]["settings"]["max_speed_mps"] == 0.3
|
||||
with pytest.raises(RuntimeError):
|
||||
store.start(RunCreate(world_id=world["world_id"], max_steps=3), "request-001")
|
||||
with pytest.raises(RuntimeError):
|
||||
store.start(request, "request-002")
|
||||
assert store.poll(hello.instance_id, None)["action"] == "load"
|
||||
|
||||
|
||||
def test_sequence_clock_pause_step_stop_and_resource_release(runs):
|
||||
store, hello, world = runs
|
||||
key = store.start(RunCreate(world_id=world["world_id"], max_steps=2), "request-001")["run_id"]
|
||||
with pytest.raises(RuntimeError):
|
||||
store.control(key, "step")
|
||||
store.poll(hello.instance_id, key)
|
||||
store.sample(key, hello.instance_id, sample())
|
||||
with pytest.raises(RuntimeError):
|
||||
store.sample(key, hello.instance_id, sample())
|
||||
store.applied(
|
||||
key,
|
||||
hello.instance_id,
|
||||
RunApplied(sequence=0, simulation_time_ns=100_000_000, physics_steps=6, pose_xy=(0, 0)),
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
store.sample(key, hello.instance_id, sample(1, simulation_time_ns=999))
|
||||
store.control(key, "pause")
|
||||
assert store.get(key)["state"] == "running"
|
||||
assert store.poll(hello.instance_id, key)["run"]["state"] == "paused"
|
||||
store.control(key, "step")
|
||||
assert store.poll(hello.instance_id, key)["action"] == "step"
|
||||
assert store.poll(hello.instance_id, key)["action"] == "pause"
|
||||
store.control(key, "stop")
|
||||
with pytest.raises(RuntimeError):
|
||||
store.sample(key, hello.instance_id, sample(1))
|
||||
store.finish(key, hello.instance_id, "stopped", "")
|
||||
store.queue.reserve_simulation("airun-" + "b" * 32)
|
||||
|
||||
|
||||
def test_expired_worker_stops_run_but_does_not_claim_gpu_released(runs):
|
||||
store, hello, world = runs
|
||||
key = store.start(RunCreate(world_id=world["world_id"]), "request-001")["run_id"]
|
||||
store.seen -= 21
|
||||
assert not store.status()["available"]
|
||||
assert store.get(key)["state"] == "failed"
|
||||
with pytest.raises(RuntimeError):
|
||||
store.sample(key, hello.instance_id, sample())
|
||||
with pytest.raises(RuntimeError):
|
||||
store.queue.reserve_simulation("airun-" + "b" * 32)
|
||||
store.register(hello)
|
||||
assert store.finish(key, hello.instance_id, "failed", "")["state"] == "failed"
|
||||
store.queue.reserve_simulation("airun-" + "b" * 32)
|
||||
|
||||
|
||||
def test_restart_fences_worker_and_preserves_journal(runs):
|
||||
store, hello, world = runs
|
||||
key = store.start(RunCreate(world_id=world["world_id"], max_steps=2), "request-001")["run_id"]
|
||||
store.poll(hello.instance_id, key)
|
||||
store.sample(key, hello.instance_id, sample())
|
||||
restarted = RunStore(store.worlds, store.queue)
|
||||
assert restarted.get(key)["state"] == "failed"
|
||||
with pytest.raises(RuntimeError):
|
||||
restarted.sample(key, hello.instance_id, sample(1))
|
||||
saved = json.loads((store.directory(key) / "decisions.jsonl").read_text())
|
||||
assert (
|
||||
saved["image_sha256"]
|
||||
== hashlib.sha256((store.directory(key) / "frames/000000.jpg").read_bytes()).hexdigest()
|
||||
)
|
||||
assert "image_jpeg_base64" not in saved
|
||||
|
||||
|
||||
def test_completion_requires_expected_steps(runs):
|
||||
store, hello, world = runs
|
||||
key = store.start(RunCreate(world_id=world["world_id"], max_steps=1), "request-001")["run_id"]
|
||||
with pytest.raises(RuntimeError):
|
||||
store.finish(key, hello.instance_id, "completed", "")
|
||||
store.poll(hello.instance_id, key)
|
||||
store.sample(key, hello.instance_id, sample())
|
||||
with pytest.raises(RuntimeError):
|
||||
store.finish(key, hello.instance_id, "completed", "")
|
||||
store.applied(
|
||||
key,
|
||||
hello.instance_id,
|
||||
RunApplied(sequence=0, simulation_time_ns=100_000_000, physics_steps=6, pose_xy=(0, 0)),
|
||||
)
|
||||
assert store.finish(key, hello.instance_id, "completed", "")["state"] == "completed"
|
||||
|
||||
|
||||
def test_api_auth_and_bounded_upload(tmp_path):
|
||||
app = FastAPI()
|
||||
app.include_router(build_ai_polygon_router(tmp_path))
|
||||
with TestClient(app) as client:
|
||||
root = "/api/v1/ai-polygon"
|
||||
assert client.get(root + "/catalog").json()["runtime"]["available"] is False
|
||||
assert client.post(root + "/worker/poll", json={"instance_id": "a" * 32}).status_code == 401
|
||||
assert client.post(root + "/worlds", json={"filename": "../bad.ply"}).status_code == 422
|
||||
world = make_world(WorldStore(tmp_path))
|
||||
response = client.get(root + f"/worlds/{world['world_id']}/source.ply")
|
||||
assert response.status_code == 200 and response.content == ply()
|
||||
assert (
|
||||
client.post(
|
||||
root + "/runs",
|
||||
headers={"Idempotency-Key": "test-run-001"},
|
||||
json={"world_id": world["world_id"]},
|
||||
).status_code
|
||||
== 409
|
||||
)
|
||||
assert not any((tmp_path / "ai-polygon/runs").iterdir())
|
||||
|
||||
|
||||
def test_camera_policy_brakes_and_waits_before_resume():
|
||||
policy = RoadPolicy(0.3)
|
||||
road = np.ones((512, 512), dtype=bool)
|
||||
assert policy.decide(road, []).speed_mps == 0
|
||||
policy.decide(road, [])
|
||||
assert policy.decide(road, []).speed_mps == 0.3
|
||||
obstacle = policy.decide(road, [(0.4, 0.2, 0.6, 0.8)])
|
||||
assert obstacle.reason == "obstacle" and obstacle.speed_mps == 0
|
||||
assert policy.decide(road, []).speed_mps == 0
|
||||
assert policy.decide(np.zeros_like(road), []).reason == "no-road"
|
||||
with pytest.raises(ValueError):
|
||||
policy.decide(road, [(float("nan"), 0, 1, 1)])
|
||||
|
||||
|
||||
def worker_asset():
|
||||
return WorkerWorldCreate(
|
||||
name="Synthetic paired scene",
|
||||
filename="fixture.ply",
|
||||
byte_length=len(ply()),
|
||||
author="test",
|
||||
license="CC0",
|
||||
sha256="a" * 64,
|
||||
collider_sha256="b" * 64,
|
||||
splat_count=1,
|
||||
settings=WorldSettings(prepared=True),
|
||||
)
|
||||
|
||||
|
||||
def test_worker_asset_is_manifest_only_and_retry_preserves_settings(tmp_path):
|
||||
worlds = WorldStore(tmp_path)
|
||||
request = worker_asset()
|
||||
row = worlds.register_worker_asset(request, "worker-006")
|
||||
assert {p.name for p in worlds.directory(row["world_id"]).iterdir()} == {"world.json"}
|
||||
changed = worlds.configure(row["world_id"], WorldSettings(spawn_xy=(3, 4), prepared=True))
|
||||
assert worlds.register_worker_asset(request, "worker-006") == changed
|
||||
with pytest.raises(RuntimeError):
|
||||
worlds.register_worker_asset(
|
||||
request.model_copy(update={"collider_sha256": "c" * 64}), "worker-006"
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
worlds.prefix_hashes(row["world_id"])
|
||||
|
||||
|
||||
def test_worker_asset_api_requires_current_authenticated_instance(tmp_path):
|
||||
app = FastAPI()
|
||||
app.include_router(build_ai_polygon_router(tmp_path))
|
||||
base = "/api/v1/ai-polygon"
|
||||
store = RunStore(WorldStore(tmp_path))
|
||||
hello = WorkerHello(
|
||||
worker_id="worker-006",
|
||||
instance_id="a" * 32,
|
||||
runtime="isaac-sim-6.1",
|
||||
model_ids=["test"],
|
||||
runtime_sources={k: "c" * 64 for k in ("worker", "scene", "models", "robot")},
|
||||
profile_sha256="b" * 64,
|
||||
)
|
||||
auth = {"Authorization": "Bearer " + store.token, "Worker-Instance": hello.instance_id}
|
||||
body = worker_asset().model_dump(mode="json")
|
||||
with TestClient(app) as client:
|
||||
assert (
|
||||
client.post(
|
||||
base + "/worker/worlds", json=body, headers={"Worker-Instance": hello.instance_id}
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
assert client.post(base + "/worker/worlds", json=body, headers=auth).status_code == 409
|
||||
assert (
|
||||
client.post(
|
||||
base + "/worker/register", json=hello.model_dump(), headers=auth
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
response = client.post(base + "/worker/worlds", json=body, headers=auth)
|
||||
assert response.status_code == 201
|
||||
row = response.json()
|
||||
assert row["storage"] == {"kind": "worker", "worker_id": "worker-006"}
|
||||
assert client.get(base + f"/worlds/{row['world_id']}/source.ply").status_code == 409
|
||||
assert (
|
||||
client.post(
|
||||
base + "/worker/worlds", json=body, headers={**auth, "Worker-Instance": "d" * 32}
|
||||
).status_code
|
||||
== 409
|
||||
)
|
||||
|
||||
|
||||
def test_camera_policy_turns_towards_connected_road():
|
||||
policy = RoadPolicy(0.3)
|
||||
road = np.zeros((512, 512), dtype=bool)
|
||||
road[:, 100:260] = True
|
||||
policy.decide(road, [])
|
||||
policy.decide(road, [])
|
||||
decision = policy.decide(road, [])
|
||||
assert decision.yaw_rate_rps > 0 and 0 < decision.speed_mps < 0.3
|
||||
|
||||
|
||||
def test_progress_does_not_claim_camera_ready(runs):
|
||||
store, hello, world = runs
|
||||
run = store.start(RunCreate(world_id=world["world_id"]), "progress-case-001")
|
||||
store.progress(run["run_id"], hello.instance_id, "scene")
|
||||
assert store.get(run["run_id"])["phase"] == "scene"
|
||||
assert store.get(run["run_id"])["state"] == "starting"
|
||||
store.control(run["run_id"], "stop")
|
||||
assert store.progress(run["run_id"], hello.instance_id, "models")["control"] == "stop"
|
||||
assert store.get(run["run_id"])["state"] == "stopping"
|
||||
|
||||
|
||||
def test_worker_stop_race_accepts_already_exited_process(monkeypatch):
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
runtime = Path(__file__).resolve().parents[1] / "simulation/ai-polygon"
|
||||
monkeypatch.syspath_prepend(str(runtime))
|
||||
spec = importlib.util.spec_from_file_location("polygon_worker_stop_test", runtime / "worker.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls = []
|
||||
|
||||
class Child:
|
||||
pid = 123
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def wait(self, timeout):
|
||||
calls.append(("wait", timeout))
|
||||
return 0
|
||||
|
||||
def taskkill(*args, **kwargs):
|
||||
# Windows reports a non-zero taskkill when the process exits in the race.
|
||||
assert kwargs["check"] is False
|
||||
calls.append(("taskkill", 255))
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "run", taskkill)
|
||||
module.terminate_episode(Child())
|
||||
assert calls == [("taskkill", 255), ("wait", 30)]
|
||||
@@ -0,0 +1,141 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.simulation.ai_polygon.contracts import WorldSettings
|
||||
from k1link.simulation.ai_polygon.mission_policy import WaypointMission, inclination
|
||||
|
||||
|
||||
def pose(x=0, y=0):
|
||||
return [x, y, 0.37, 0, 0, 0, 1]
|
||||
|
||||
|
||||
def goal(target, prior, excluded):
|
||||
return [2, 0.7 * len(excluded), 0]
|
||||
|
||||
|
||||
def test_chassis_jitter_cannot_hide_stall_and_retries_are_bounded():
|
||||
mission = WaypointMission([[8, 0]])
|
||||
states = []
|
||||
for t in np.arange(0, 34, 0.2):
|
||||
_, intent = mission.update(pose(0.01 * np.sin(t * 10)), float(t), goal)
|
||||
states.append(intent["state"])
|
||||
assert "replanning" in states
|
||||
assert states[-1] == "stuck"
|
||||
assert mission.update(pose(1), 35, goal)[1]["state"] == "stuck"
|
||||
|
||||
|
||||
def test_route_cursor_and_completion_survive_pause_without_restarting_task():
|
||||
mission = WaypointMission([[1, 0], [3, 0]])
|
||||
mission.update(pose(), 0, goal)
|
||||
assert mission.update(pose(1), 6, goal)[1]["waypoint"] == 1
|
||||
mission.resume()
|
||||
assert mission.update(pose(1), 6, goal)[1]["waypoint"] == 1
|
||||
assert mission.update(pose(3), 18, goal)[1]["state"] == "goal-reached"
|
||||
mission.resume()
|
||||
assert mission.update(pose(3), 18, goal)[1]["state"] == "goal-reached"
|
||||
|
||||
|
||||
def test_overturned_and_excessive_tilt_are_latched_before_goal_success():
|
||||
mission = WaypointMission([[0, 0]])
|
||||
overturned = [0, 0, 0.37, 1, 0, 0, 0]
|
||||
assert inclination(overturned) == pytest.approx(180)
|
||||
assert mission.update(overturned, 0, goal)[1]["state"] == "unstable"
|
||||
mission.resume()
|
||||
assert mission.update(pose(), 1, goal)[1]["state"] == "unstable"
|
||||
|
||||
|
||||
def test_missing_surface_never_becomes_a_drive_permission_or_unbounded_wait():
|
||||
mission = WaypointMission([[8, 0]])
|
||||
for t in range(34):
|
||||
target, intent = mission.update(pose(), t, lambda *_: None)
|
||||
assert target is None
|
||||
assert intent["state"] == "stuck"
|
||||
|
||||
|
||||
def test_route_contract_bounds_and_finiteness():
|
||||
from pydantic import ValidationError
|
||||
|
||||
for route in ([[float("nan"), 0]], [[0, 0]] * 33, [[0, 10001]]):
|
||||
with pytest.raises(ValidationError):
|
||||
WorldSettings(route_xy=route)
|
||||
|
||||
|
||||
def test_recovery_uses_observed_goal_and_does_not_count_retreat_as_progress():
|
||||
mission = WaypointMission([[8, 0]])
|
||||
|
||||
def retreat(prior):
|
||||
return prior or [-0.65, 0, 0]
|
||||
|
||||
mission.update(pose(), 0, goal, retreat)
|
||||
target, intent = mission.update(pose(), 8, goal, retreat)
|
||||
assert target == [-0.65, 0, 0] and intent["state"] == "reversing"
|
||||
assert mission.update(pose(-0.2), 10, goal, retreat)[1]["state"] == "reversing"
|
||||
assert mission.update(pose(-0.4), 12, goal, retreat)[1]["state"] == "replanning"
|
||||
mission.update(pose(), 16, goal, retreat)
|
||||
assert mission.attempts == 1
|
||||
# Moving back to where we started cannot create a fresh three-attempt budget.
|
||||
assert mission.update(pose(), 20, goal, retreat)[1]["recovery_attempt"] == 2
|
||||
|
||||
|
||||
def test_recovery_stops_immediately_on_lost_support_and_times_out_without_motion():
|
||||
mission = WaypointMission([[8, 0]])
|
||||
|
||||
def retreat(prior):
|
||||
return prior or [-0.65, 0, 0]
|
||||
|
||||
mission.update(pose(), 0, goal, retreat)
|
||||
mission.update(pose(), 8, goal, retreat)
|
||||
assert mission.update(pose(), 8.2, goal, lambda _: None)[0] is None
|
||||
assert mission.recovery_goal is None
|
||||
for seconds in (17, 23, 32, 38, 47):
|
||||
_, intent = mission.update(pose(), seconds, goal, retreat)
|
||||
assert intent["state"] == "stuck"
|
||||
|
||||
|
||||
def test_wrong_direction_never_replenishes_route_attempts():
|
||||
mission = WaypointMission([[8, 0]])
|
||||
for seconds in range(34):
|
||||
_, intent = mission.update(pose(-seconds * 0.1), seconds, goal)
|
||||
assert intent["state"] == "stuck"
|
||||
|
||||
|
||||
def test_signed_motion_contract_accepts_reverse_but_stays_bounded():
|
||||
from pydantic import ValidationError
|
||||
|
||||
from k1link.simulation.ai_polygon.contracts import Decision
|
||||
|
||||
assert (
|
||||
Decision(
|
||||
speed_mps=-0.1, yaw_rate_rps=0, reason="replanning", road_fraction=0, obstacle_count=0
|
||||
).speed_mps
|
||||
< 0
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Decision(speed_mps=-1.1, yaw_rate_rps=0, reason="road", road_fraction=1, obstacle_count=0)
|
||||
|
||||
|
||||
def test_slow_regulated_progress_is_not_mistaken_for_stall():
|
||||
mission = WaypointMission([[8, 0]])
|
||||
for seconds in range(60):
|
||||
_, intent = mission.update(pose(seconds * 0.02), seconds, goal)
|
||||
assert intent["state"] == "following"
|
||||
assert intent["recovery_attempt"] == 0
|
||||
|
||||
|
||||
def test_rejected_observation_stops_but_does_not_forget_revalidated_goal():
|
||||
mission = WaypointMission([[2, 0]])
|
||||
observed = [2, 0, 0]
|
||||
assert mission.update(pose(), 0, lambda *_: observed)[0] == observed
|
||||
assert mission.update(pose(1), 1, lambda *_: None)[0] is None
|
||||
assert mission.goal == observed
|
||||
mission.resume()
|
||||
calls = []
|
||||
|
||||
def revalidate(_target, prior, _excluded):
|
||||
calls.append(prior)
|
||||
return prior
|
||||
|
||||
assert mission.update(pose(1.3), 1.2, revalidate)[0] == observed
|
||||
assert calls == [observed]
|
||||
# Memory alone never authorizes a command when the new frame is rejected.
|
||||
assert mission.update(pose(1.3), 1.4, lambda *_: None)[0] is None
|
||||
@@ -0,0 +1,40 @@
|
||||
"""The actuator may soften acceleration but must never prolong a safety command."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"motion_control", Path(__file__).parents[1] / "simulation/ai-polygon/motion_control.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
def test_slow_perception_keeps_continuous_physics_command():
|
||||
drive = module.DriveEnvelope()
|
||||
rows = [drive.step(0.15, 0, 1 / 60)[0] for _ in range(180)]
|
||||
assert rows[0] == pytest.approx(0.2 / 60)
|
||||
assert all(0 <= b - a <= 0.2 / 60 + 1e-9 for a, b in zip(rows, rows[1:], strict=False))
|
||||
assert rows[44:] == pytest.approx([0.15] * 136)
|
||||
|
||||
|
||||
def test_braking_and_deadman_bypass_ramp():
|
||||
drive = module.DriveEnvelope()
|
||||
for _ in range(60):
|
||||
drive.step(0.15, 0, 1 / 60)
|
||||
assert drive.step(0.03, 0, 1 / 60) == (0.03, 0)
|
||||
assert drive.step(0.15, 0.2, 1 / 60, stop=True) == (0, 0)
|
||||
assert drive.wheels == (0, 0)
|
||||
assert drive.step(0.15, 0, 1 / 60)[0] == pytest.approx(0.2 / 60)
|
||||
|
||||
|
||||
def test_curvature_and_direction_change():
|
||||
drive = module.DriveEnvelope()
|
||||
for _ in range(60):
|
||||
speed, yaw = drive.step(0.15, 0.2, 1 / 60)
|
||||
assert yaw / speed == pytest.approx(0.2 / 0.15)
|
||||
speed, yaw = drive.step(-0.15, -0.2, 1 / 60)
|
||||
assert speed < 0 and yaw < 0
|
||||
assert max(map(abs, drive.wheels)) <= 0.2 / 60 + 1e-9
|
||||
@@ -0,0 +1,390 @@
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.observatory.modular_composition import CompositionError
|
||||
from k1link.simulation.ai_polygon.composition import compose, registry
|
||||
from k1link.simulation.ai_polygon.terrain_contract import terrain_matches
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / "simulation/ai-polygon"
|
||||
|
||||
|
||||
def module(name):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / (name + ".py"))
|
||||
result = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(result)
|
||||
return result
|
||||
|
||||
|
||||
def test_simulation_composition_has_causal_dependencies_and_separate_authority():
|
||||
graph = compose(ROOT)
|
||||
assert graph.as_dict()["execution"]["mode"] == "worker-local-simulation"
|
||||
motion = graph.nodes[-1]
|
||||
assert motion.module.group == "motion"
|
||||
assert dict(motion.inputs)["segmentation.surface"] == "simulation-segformer-ade"
|
||||
assert dict(motion.inputs)["detection.boxes"] == "simulation-rf-detr"
|
||||
assert motion.module.state_policy == "causal-reset-at-source-start"
|
||||
selection = graph.selection_document()
|
||||
selection["selections"] = [r for r in selection["selections"] if r["group"] != "segmentation"]
|
||||
with pytest.raises(CompositionError, match="segmentation.surface"):
|
||||
compose(ROOT, selection)
|
||||
|
||||
|
||||
def test_surface_providers_are_interchangeable_in_the_shared_constructor():
|
||||
selection = compose(ROOT).selection_document()
|
||||
reference = next(m for m in registry(ROOT).modules if m.module_id == "simulation-ddrnet-goose")
|
||||
for row in selection["selections"]:
|
||||
if row["group"] == "segmentation":
|
||||
row.update(module_id=reference.module_id, module_sha256=reference.sha256)
|
||||
graph = compose(ROOT, selection)
|
||||
assert dict(graph.nodes[-1].inputs)["segmentation.surface"] == reference.module_id
|
||||
|
||||
|
||||
def test_composition_rejects_stale_module_identity():
|
||||
selection = compose(ROOT).selection_document()
|
||||
selection["selections"][0]["module_sha256"] = "0" * 64
|
||||
with pytest.raises(CompositionError, match="not installed"):
|
||||
compose(ROOT, selection)
|
||||
|
||||
|
||||
def test_shared_constructor_import_needs_no_core_or_third_party_runtime():
|
||||
# -S removes site-packages, as in the minimal Windows coordinator. Loading
|
||||
# a contract must not load the POSIX-only artifact gateway through __init__.
|
||||
source = str(ROOT.parents[1] / "src")
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-S",
|
||||
"-c",
|
||||
f"import sys; sys.path.insert(0, {source!r}); "
|
||||
"from k1link.observatory.modular_composition import ModuleRegistry; "
|
||||
"from k1link.simulation.ai_polygon.composition import compose; "
|
||||
"assert 'k1link.artifact_gateway' not in sys.modules",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_semantic_goal_uses_range_and_correct_square_camera_crop():
|
||||
nav = module("navigation_client")
|
||||
points = np.array(
|
||||
[[x, y, 0] for x in np.linspace(1.5, 3, 20) for y in np.linspace(-0.3, 0.3, 9)],
|
||||
dtype=np.float32,
|
||||
)
|
||||
pose = [0, 0, 0.27, 0, 0, 0, 1]
|
||||
calibration = {
|
||||
"origin": [0.38, 0, 0.8],
|
||||
"rotation": np.eye(3).reshape(-1).tolist(),
|
||||
"intrinsics": [800 * 24 / 36, 800 * 24 / 36, 400, 300],
|
||||
}
|
||||
leaves = np.ones((512, 512), dtype=bool)
|
||||
goal = nav.visual_goal(leaves, points, pose, calibration)
|
||||
assert goal is not None and 1.5 < goal[0] < 3 and abs(goal[1]) < 0.3
|
||||
assert nav.visual_goal(np.zeros_like(leaves), points, pose, calibration) is None
|
||||
assert nav.visual_goal(leaves, points + [0, 0, 2], pose, calibration) is None
|
||||
# A previously valid goal cannot authorize motion through newly unknown RGB.
|
||||
assert nav.visual_goal(np.zeros_like(leaves), points, pose, calibration, goal) is None
|
||||
# An explicit-route waypoint entering the camera blind strip is retained,
|
||||
# but losing all current visual surface support still forbids movement.
|
||||
close = [0.65, 0, 0]
|
||||
assert nav.visual_goal(leaves, points, pose, calibration, close, target=[0.65, 0]) == close
|
||||
assert (
|
||||
nav.visual_goal(np.zeros_like(leaves), points, pose, calibration, close, target=[0.65, 0])
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_ground_placement_uses_actual_triangle_intersection():
|
||||
terrain = module("terrain")
|
||||
vertices = np.array([[0, 0, 0], [1, 0, 0.2], [0, 1, 0]], dtype=np.float32)
|
||||
faces = np.array([[0, 1, 2]], dtype=np.int32)
|
||||
assert terrain.ground_intersections(vertices, faces, 0.25, 0.25)[0] == pytest.approx(0.05)
|
||||
assert len(terrain.ground_intersections(vertices, faces, 0.9, 0.9)) == 0
|
||||
|
||||
|
||||
def test_observed_route_goal_keeps_task_position_and_cannot_run_away_from_it():
|
||||
choose = module("navigation_client").visual_goal
|
||||
points = np.array(
|
||||
[[x, y, 0] for x in np.arange(1.2, 3.1, 0.05) for y in np.arange(-0.5, 0.51, 0.05)]
|
||||
)
|
||||
pose = [0, 0, 0.37, 0, 0, 0, 1]
|
||||
calibration = {
|
||||
"origin": [0.38, 0, 0.8],
|
||||
"rotation": np.eye(3).reshape(-1).tolist(),
|
||||
"intrinsics": [800 * 24 / 36, 800 * 24 / 36, 400, 300],
|
||||
"body_contact_height_m": 0.37,
|
||||
}
|
||||
surface = np.ones((512, 512), dtype=bool)
|
||||
target = [2.03, 0.27]
|
||||
assert choose(surface, points, pose, calibration, target=target) == pytest.approx([*target, 0])
|
||||
# The close, already observed waypoint can enter the camera blind strip.
|
||||
advanced = [1.5, 0, 0.37, 0, 0, 0, 1]
|
||||
camera = {**calibration, "origin": [1.88, 0, 0.8]}
|
||||
prior = [*target, 0]
|
||||
assert choose(surface, points + [1.5, 0, 0], advanced, camera, prior, target) == prior
|
||||
# The actual camera loses nearby ground beyond the old hardcoded 0.8 m.
|
||||
advanced = [1.1, 0, 0.37, 0, 0, 0, 1]
|
||||
camera = {**calibration, "origin": [1.48, 0, 0.8]}
|
||||
assert choose(surface, points + [1.1, 0, 0], advanced, camera, prior, target) == prior
|
||||
assert choose(np.zeros_like(surface), points, pose, calibration, target=target) is None
|
||||
# Clear road ahead is not permission to drive away from a missed waypoint.
|
||||
assert choose(surface, points, pose, calibration, target=[-1, 0]) is None
|
||||
# A distant task may still use an observed local goal towards it.
|
||||
far = choose(surface, points, pose, calibration, target=[8, 0])
|
||||
assert far is not None and 1.2 <= far[0] <= 3.1
|
||||
|
||||
|
||||
def test_collision_identity_follows_geometry_and_tile_coverage_not_camera_or_start():
|
||||
settings = dict(
|
||||
meters_per_unit=1,
|
||||
rotation_degrees=[-90, 0, 180],
|
||||
spawn_xy=[0, 0],
|
||||
ground_z=0,
|
||||
camera_height_m=0.8,
|
||||
max_speed_mps=0.15,
|
||||
)
|
||||
terrain = dict(source_sha256="a" * 64, generator_sha256="b" * 64, settings=settings)
|
||||
world = dict(sha256="a" * 64, settings={**settings, "spawn_xy": [1, 1], "camera_height_m": 1})
|
||||
assert terrain_matches(terrain, world, "b" * 64)
|
||||
assert not terrain_matches(terrain, world, "c" * 64)
|
||||
assert not terrain_matches(terrain, {**world, "sha256": "d" * 64})
|
||||
for change in ({"spawn_xy": [20, 0]}, {"meters_per_unit": 2}, {"rotation_degrees": [0, 0, 0]}):
|
||||
assert not terrain_matches(terrain, {**world, "settings": {**world["settings"], **change}})
|
||||
|
||||
|
||||
def test_paired_full_scene_does_not_inherit_generated_tile_bounds():
|
||||
settings = dict(meters_per_unit=1, rotation_degrees=[90, 0, 0], spawn_xy=[0, 0], ground_z=5)
|
||||
terrain = dict(
|
||||
generator="paired-source",
|
||||
source_sha256="a" * 64,
|
||||
collider_sha256="b" * 64,
|
||||
settings=settings,
|
||||
)
|
||||
world = dict(
|
||||
sha256="a" * 64,
|
||||
collider_sha256="b" * 64,
|
||||
settings={**settings, "spawn_xy": [210, 30], "ground_z": 1.5},
|
||||
)
|
||||
assert terrain_matches(terrain, world)
|
||||
assert not terrain_matches(terrain, {**world, "collider_sha256": "c" * 64})
|
||||
assert not terrain_matches(
|
||||
terrain, {**world, "settings": {**world["settings"], "meters_per_unit": 2}}
|
||||
)
|
||||
assert not terrain_matches({**terrain, "generator": "generated-tile"}, world)
|
||||
|
||||
|
||||
def test_square_footprint_fits_straight_corridor_and_rejects_corner_sweep():
|
||||
check = module("navigation/footprint").swept_footprint_clear
|
||||
pose = [0, 0, 0.27, 0, 0, 0, 1]
|
||||
path = [[0, 0, 0], [0.5, 0, 0], [1, 0, 0]]
|
||||
walls = np.array([[x, y, 0.5, 0.5] for x in np.arange(-1, 2, 0.1) for y in [-0.65, 0.65]])
|
||||
assert check(path, walls, pose)
|
||||
walls[:, 1] *= 0.45 / 0.65
|
||||
assert not check(path, walls, pose)
|
||||
# A diagonal turn sweeps a square corner into this obstacle, even though
|
||||
# the chassis at its initial and final straight poses does not contain it.
|
||||
assert not check([[0, 0, 0], [0.5, 0.5, 0]], [[0.7, 0, 0.5, 0.5]], pose)
|
||||
|
||||
|
||||
def test_command_monitor_covers_deadman_braking_distance_and_rotation():
|
||||
check = module("navigation/footprint").command_footprint_clear
|
||||
pose = [0, 0, 0.27, 0, 0, 0, 1]
|
||||
assert check(0.15, 0, [[1.5, 0, 0.5, 0.5]], pose)
|
||||
assert not check(0.15, 0, [[0.7, 0, 0.5, 0.5]], pose)
|
||||
assert not check(0, 0.8, [[0.7, 0, 0.5, 0.5]], pose)
|
||||
|
||||
|
||||
def test_smooth_slope_is_distinct_from_a_step_or_vertical_terrain():
|
||||
costs = module("navigation/terrain_costs").supported_slope_costs
|
||||
xy = np.array([[x, y] for x in np.arange(-1, 1.01, 0.1) for y in np.arange(-1, 1.01, 0.1)])
|
||||
slope = np.column_stack((xy, xy[:, 0] * np.tan(np.radians(20)), np.full(len(xy), 0.15)))
|
||||
normalized, corrected = costs(slope)
|
||||
assert corrected > len(slope) * 0.9
|
||||
assert normalized[len(slope) // 2, 3] == 0
|
||||
assert np.array_equal(normalized[:, :3], slope[:, :3])
|
||||
# A 15 cm ledge across the initial footprint cannot become a traversable ramp.
|
||||
step = slope.copy()
|
||||
step[:, 2] = np.where(step[:, 0] >= 0, 0.15, 0)
|
||||
assert costs(step)[1] == 0
|
||||
cliff = slope.copy()
|
||||
cliff[:, 2] = np.where(cliff[:, 0] >= 0, -0.4, 0)
|
||||
assert costs(cliff)[1] == 0
|
||||
steep = slope.copy()
|
||||
steep[:, 2] = steep[:, 0] * np.tan(np.radians(35))
|
||||
assert costs(steep)[1] == 0
|
||||
assert costs(slope[np.abs(slope[:, 1]) < 0.01])[1] == 0 # Unobserved lateral support.
|
||||
|
||||
|
||||
def test_underbody_support_does_not_clear_future_terrain_walls_or_drops():
|
||||
correct = module("navigation/terrain_costs").underbody_support_costs
|
||||
terrain = np.array(
|
||||
[
|
||||
[-0.375, -0.28, 0.066, 0.103], # Low return already under the chassis.
|
||||
[0.46, 0, 0.066, 0.103], # Inset excludes the leading edge.
|
||||
[0.75, 0, 0.066, 0.103], # Never change future terrain from body pose.
|
||||
[0, 0, 0.12, 0.12], # A real step within the footprint remains blocked.
|
||||
[0, 0, 0.5, 0.5],
|
||||
[0, 0, -0.4, 0.4],
|
||||
]
|
||||
)
|
||||
original = terrain.copy()
|
||||
result, count = correct(terrain, [0, 0, 0.37, 0, 0, 0, 1])
|
||||
assert count == 1 and result[0, 3] == pytest.approx(0.066)
|
||||
assert np.array_equal(result[1:], original[1:])
|
||||
assert np.array_equal(terrain, original) # Never erase the causal raw map.
|
||||
# Rotate both observations and the measured chassis; the result must agree.
|
||||
yaw = np.pi / 2
|
||||
rotated = terrain.copy()
|
||||
rotated[:, :2] = terrain[:, :2] @ np.array([[0, 1], [-1, 0]]) + [3, 4]
|
||||
pose = [3, 4, 0.37, 0, 0, np.sin(yaw / 2), np.cos(yaw / 2)]
|
||||
assert np.allclose(correct(rotated, pose)[0][:, 3], result[:, 3])
|
||||
assert correct(terrain, [0, 0, 0.37, 0, np.sin(np.pi / 12), 0, np.cos(np.pi / 12)])[1] == 0
|
||||
|
||||
|
||||
def test_retreat_requires_observed_full_width_support_and_no_drop_or_step():
|
||||
choose = module("navigation_client").recovery_goal
|
||||
points = np.array(
|
||||
[[x, y, 0.0] for x in np.arange(-1.5, -0.39, 0.05) for y in np.arange(-0.85, 0.86, 0.05)]
|
||||
)
|
||||
pose = [0, 0, 0.37, 0, 0, 0, 1]
|
||||
calibration = {"body_contact_height_m": 0.37}
|
||||
assert choose(points, pose, calibration) == pytest.approx([-0.65, 0, 0])
|
||||
assert choose(points[points[:, 1] > -0.2], pose, calibration) is None
|
||||
assert choose(points[points[:, 0] < -0.9], pose, calibration) is None
|
||||
for height in (-0.4, 0.15):
|
||||
discontinuous = points.copy()
|
||||
discontinuous[points[:, 0] < -0.9, 2] = height
|
||||
assert choose(discontinuous, pose, calibration) is None
|
||||
slope = points.copy()
|
||||
slope[:, 2] = slope[:, 0] * np.tan(np.radians(10))
|
||||
assert choose(slope, pose, calibration) is not None
|
||||
assert choose(points, pose, calibration, [-0.65, 0.3, 0]) is None
|
||||
|
||||
|
||||
def test_reverse_monitor_checks_behind_the_body():
|
||||
check = module("navigation/footprint").command_footprint_clear
|
||||
pose = [0, 0, 0.37, 0, 0, 0, 1]
|
||||
assert check(-0.1, 0, [[-1.5, 0, 0.5, 0.5]], pose)
|
||||
assert not check(-0.1, 0, [[-0.65, 0, 0.5, 0.5]], pose)
|
||||
|
||||
|
||||
def test_legacy_coordinate_migration_is_an_exact_rigid_rotation():
|
||||
import json
|
||||
import struct
|
||||
|
||||
migrate = module("navigation/migrate_terrain_coordinates")
|
||||
positions = [[1.0, -2.0, -3.0], [2.0, -2.0, -3.0], [1.0, -1.0, -3.0]]
|
||||
binary = struct.pack("<9f3I", *(v for p in positions for v in p), 0, 1, 2)
|
||||
document = {
|
||||
"nodes": [{"mesh": 0}],
|
||||
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
|
||||
"accessors": [
|
||||
{
|
||||
"bufferView": 0,
|
||||
"componentType": 5126,
|
||||
"type": "VEC3",
|
||||
"count": 3,
|
||||
"min": [1, -2, -3],
|
||||
"max": [2, -1, -3],
|
||||
},
|
||||
{"bufferView": 1, "componentType": 5125, "type": "SCALAR", "count": 3},
|
||||
],
|
||||
"bufferViews": [{"byteOffset": 0, "byteLength": 36}, {"byteOffset": 36, "byteLength": 12}],
|
||||
}
|
||||
raw = json.dumps(document).encode()
|
||||
raw += b" " * ((-len(raw)) % 4)
|
||||
glb = (
|
||||
struct.pack("<III", 0x46546C67, 2, 28 + len(raw) + len(binary))
|
||||
+ struct.pack("<II", len(raw), 0x4E4F534A)
|
||||
+ raw
|
||||
+ struct.pack("<II", len(binary), 0x004E4942)
|
||||
+ binary
|
||||
)
|
||||
corrected = migrate.rotate_glb(glb)
|
||||
length = struct.unpack_from("<I", corrected, 12)[0]
|
||||
result = np.array(struct.unpack_from("<9f", corrected, 28 + length)).reshape(-1, 3)
|
||||
assert np.array_equal(result, np.array(positions) * [-1, 1, -1])
|
||||
assert np.array_equal(
|
||||
result[:, [0, 2, 1]] * [1, -1, 1], [[-1, -3, -2], [-2, -3, -2], [-1, -3, -1]]
|
||||
)
|
||||
assert struct.unpack_from("<3I", corrected, 28 + length + 36) == (0, 1, 2)
|
||||
|
||||
|
||||
def test_voxel_quantized_grade_does_not_become_a_wall_but_ledge_remains():
|
||||
costs = module("navigation/terrain_costs").supported_slope_costs
|
||||
xy = np.array([[x, y] for x in np.arange(-1, 1.01, 0.06) for y in np.arange(-1, 1.01, 0.06)])
|
||||
height = np.round(xy[:, 0] * np.tan(np.radians(20)) / 0.06) * 0.06
|
||||
surface = np.column_stack((xy, height, np.full(len(xy), 0.15)))
|
||||
assert costs(surface)[1] > len(surface) * 0.8
|
||||
for discontinuity in (0.12, 0.15, -0.4):
|
||||
ledge = surface.copy()
|
||||
ledge[:, 2] = np.where(xy[:, 0] >= 0, discontinuity, 0)
|
||||
corrected, _ = costs(ledge)
|
||||
near_edge = np.abs(xy[:, 0]) < 0.12
|
||||
assert np.all(corrected[near_edge, 3] > 0.1)
|
||||
stone = surface.copy()
|
||||
stone[:, 2] = 0
|
||||
stone[(abs(xy[:, 0]) < 0.12) & (abs(xy[:, 1]) < 0.12), 2] = 0.15
|
||||
assert costs(stone)[1] == 0
|
||||
|
||||
|
||||
def test_grade_fit_cannot_bridge_an_unobserved_gap():
|
||||
costs = module("navigation/terrain_costs").supported_slope_costs
|
||||
points = np.array(
|
||||
[
|
||||
[x, y, 0 if x < 0 else 0.15, 0.15]
|
||||
for x in [-0.3, -0.2, 0.2, 0.3]
|
||||
for y in np.arange(-0.3, 0.31, 0.1)
|
||||
]
|
||||
)
|
||||
assert costs(points)[1] == 0
|
||||
|
||||
|
||||
def test_existing_reserve_overlap_only_allows_departure_not_approach_or_body_overlap():
|
||||
check = module("navigation/footprint").command_footprint_clear
|
||||
pose = [0, 0, 0.37, 0, 0, 0, 1]
|
||||
behind = [[-0.53, 0, 0.5, 0.5]]
|
||||
assert check(0.15, 0, behind, pose)
|
||||
assert not check(-0.1, 0, behind, pose)
|
||||
assert not check(0, 0.35, behind, pose)
|
||||
assert not check(0.15, 0, [[-0.49, 0, 0.5, 0.5]], pose)
|
||||
assert not check(0.15, 0, [[0.53, 0, 0.5, 0.5]], pose)
|
||||
# A longer admitted camera age must also enlarge the collision envelope.
|
||||
assert not check(0.15, 0, [[0.80, 0, 0.5, 0.5]], pose)
|
||||
|
||||
|
||||
def test_terrain_fit_cache_invalidates_when_a_new_obstacle_is_observed():
|
||||
costs = module("navigation/terrain_costs")
|
||||
normalize = costs.TerrainCostNormalizer()
|
||||
xy = np.array([[x, y] for x in np.arange(-1, 1.01, 0.1) for y in np.arange(-1, 1.01, 0.1)])
|
||||
grade = np.column_stack((xy, xy[:, 0] * np.tan(np.radians(20)), np.full(len(xy), 0.15)))
|
||||
clear, count = normalize(grade)
|
||||
assert count > len(grade) * 0.9
|
||||
assert np.array_equal(normalize(grade)[0], clear)
|
||||
changed = np.vstack((grade, [0.02, 0.02, 0.3, 0.3]))
|
||||
cached, count = normalize(changed)
|
||||
fresh, expected_count = costs.supported_slope_costs(changed)
|
||||
assert np.array_equal(cached, fresh) and count == expected_count
|
||||
assert cached[len(grade) // 2, 3] > 0.1
|
||||
assert np.array_equal(normalize(grade)[0], clear)
|
||||
|
||||
|
||||
def test_velocity_regulation_keeps_the_selected_arc_and_its_braking_clearance():
|
||||
monitor = module("navigation/footprint")
|
||||
pose = [0, 0, 0.37, 0, 0, 0, 1]
|
||||
hazard = [[0.834, -0.08, 0.15, 0.15]]
|
||||
speed, yaw, scale = monitor.regulate_command(0.15, -0.245, hazard, pose)
|
||||
assert 0.2 <= scale < 1
|
||||
assert speed / yaw == pytest.approx(0.15 / -0.245)
|
||||
assert monitor.command_footprint_clear(speed, yaw, hazard, pose)
|
||||
assert monitor.regulate_command(0.15, 0, [[0.56, 0, 0.2, 0.2]], pose) == (0, 0, 0)
|
||||
assert monitor.regulate_command(0.15, 0, [[0.49, 0, 0.2, 0.2]], pose) == (0, 0, 0)
|
||||
reverse, _, scale = monitor.regulate_command(-0.1, 0, [[-0.68, 0, 0.2, 0.2]], pose)
|
||||
assert 0.2 <= scale < 1 and reverse < 0
|
||||
assert monitor.command_footprint_clear(reverse, 0, [[-0.68, 0, 0.2, 0.2]], pose)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Lifecycle and clock-isolation checks; GPU/stream performance is qualified on Worker."""
|
||||
|
||||
import importlib.util
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from test_ai_polygon import make_world, sample
|
||||
from test_observatory_recorded_jobs import _definitions
|
||||
|
||||
from k1link.observatory.recorded_jobs import ObservatoryRecordedJobQueue
|
||||
from k1link.simulation.ai_polygon.contracts import (
|
||||
RealtimeSnapshot,
|
||||
RunCreate,
|
||||
StreamEndpoint,
|
||||
WorkerHello,
|
||||
)
|
||||
from k1link.simulation.ai_polygon.runs import RunStore
|
||||
from k1link.simulation.ai_polygon.worlds import WorldStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def realtime(tmp_path):
|
||||
queue = ObservatoryRecordedJobQueue(tmp_path, definitions=_definitions())
|
||||
store = RunStore(WorldStore(tmp_path), queue)
|
||||
hello = WorkerHello(
|
||||
worker_id="worker-006",
|
||||
instance_id="a" * 32,
|
||||
runtime="isaac-sim-6.1",
|
||||
model_ids=["reference"],
|
||||
profile_sha256="b" * 64,
|
||||
runtime_sources={key: "c" * 64 for key in ("worker", "scene", "models", "robot")},
|
||||
execution_modes=["realtime"],
|
||||
stream=StreamEndpoint(server="100.80.1.2"),
|
||||
)
|
||||
store.register(hello)
|
||||
world = make_world(store.worlds)
|
||||
row = store.start(
|
||||
RunCreate(world_id=world["world_id"], clock="realtime", start_paused=True),
|
||||
"test-realtime-01",
|
||||
)
|
||||
return store, hello, row
|
||||
|
||||
|
||||
def snapshot(**changes):
|
||||
fields = dict(
|
||||
sequence=0,
|
||||
control_sequence=0,
|
||||
state="ready",
|
||||
phase="running",
|
||||
simulation_time_ns=0,
|
||||
wall_elapsed_seconds=1,
|
||||
physics_steps=0,
|
||||
render_frames=30,
|
||||
sensor_frames=0,
|
||||
inference_count=0,
|
||||
dropped_frames=0,
|
||||
rtf=0,
|
||||
render_fps=30,
|
||||
sensor_fps=0,
|
||||
ai_hz=0,
|
||||
pose_xy=(0, 0),
|
||||
pose_yaw=0,
|
||||
speed_mps=0,
|
||||
applied_speed_mps=0,
|
||||
applied_yaw_rate_rps=0,
|
||||
stop_reason="paused",
|
||||
ai_ready=False,
|
||||
stream_ready=True,
|
||||
camera="follow",
|
||||
)
|
||||
return RealtimeSnapshot(**{**fields, **changes})
|
||||
|
||||
|
||||
def test_worker_owns_acknowledgement_and_no_frame_archive_on_core(realtime):
|
||||
store, hello, row = realtime
|
||||
key = row["run_id"]
|
||||
assert not (store.directory(key) / "frames").exists()
|
||||
assert store.poll(hello.instance_id, key)["run"]["state"] == "starting"
|
||||
store.snapshot(key, hello.instance_id, snapshot())
|
||||
command = store.control(key, "play")
|
||||
assert command["state"] == "ready"
|
||||
assert store.control(key, "play")["control_sequence"] == command["control_sequence"]
|
||||
assert store.poll(hello.instance_id, key)["run"]["state"] == "ready"
|
||||
store.snapshot(
|
||||
key,
|
||||
hello.instance_id,
|
||||
snapshot(sequence=1, control_sequence=1, state="running", simulation_time_ns=66666667),
|
||||
)
|
||||
assert store.get(key)["state"] == "running"
|
||||
with pytest.raises(RuntimeError):
|
||||
store.sample(key, hello.instance_id, sample())
|
||||
with pytest.raises(RuntimeError):
|
||||
store.control(key, "step")
|
||||
|
||||
|
||||
def test_disconnect_restart_reconcile_never_releases_gpu_or_replays(realtime):
|
||||
store, hello, row = realtime
|
||||
key = row["run_id"]
|
||||
store.snapshot(key, hello.instance_id, snapshot())
|
||||
store.seen -= 21
|
||||
assert store.status()["active_run"]["state"] == "disconnected"
|
||||
with pytest.raises(RuntimeError):
|
||||
store.queue.reserve_simulation("airun-" + "d" * 32)
|
||||
recovered = RunStore(store.worlds, store.queue)
|
||||
with pytest.raises(RuntimeError):
|
||||
recovered.register(hello.model_copy(update={"instance_id": "d" * 32}))
|
||||
recovered.register(hello)
|
||||
assert recovered.poll(hello.instance_id, key)["action"] == "pause"
|
||||
recovered.snapshot(key, hello.instance_id, snapshot(sequence=3))
|
||||
assert recovered.get(key)["state"] == "ready"
|
||||
recovered.control(key, "stop")
|
||||
recovered.snapshot(key, hello.instance_id, snapshot(sequence=4))
|
||||
assert recovered.get(key)["state"] == "stopping"
|
||||
recovered.finish(key, hello.instance_id, "stopped", "")
|
||||
recovered.queue.reserve_simulation("airun-" + "d" * 32)
|
||||
|
||||
|
||||
def test_snapshot_is_idempotent_and_rejects_unissued_ack(realtime):
|
||||
store, hello, row = realtime
|
||||
key = row["run_id"]
|
||||
first = snapshot(sequence=5, simulation_time_ns=1000)
|
||||
assert store.snapshot(key, hello.instance_id, first) == store.snapshot(
|
||||
key, hello.instance_id, first
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
store.snapshot(key, hello.instance_id, snapshot(sequence=6, control_sequence=2))
|
||||
with pytest.raises(ValueError):
|
||||
store.snapshot(key, hello.instance_id, snapshot(sequence=6, simulation_time_ns=999))
|
||||
with pytest.raises(ValidationError):
|
||||
StreamEndpoint(server="8.8.8.8")
|
||||
|
||||
|
||||
def test_slow_ai_is_bounded_and_pause_fences_inflight_result(tmp_path):
|
||||
path = Path(__file__).parents[1] / "simulation/ai-polygon/realtime_ai.py"
|
||||
spec = importlib.util.spec_from_file_location("polygon_realtime_ai", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
seen = []
|
||||
|
||||
class Model:
|
||||
def ready(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def infer(self, rgb):
|
||||
seen.append(int(rgb[0, 0, 0]))
|
||||
entered.set()
|
||||
assert release.wait(2)
|
||||
return None, []
|
||||
|
||||
decision = {
|
||||
"speed_mps": 0.3,
|
||||
"yaw_rate_rps": 0,
|
||||
"reason": "road",
|
||||
"road_fraction": 0.7,
|
||||
"obstacle_count": 0,
|
||||
}
|
||||
ai = module.LatestInference(
|
||||
Model,
|
||||
SimpleNamespace(
|
||||
reset=lambda: None, decide=lambda *_: SimpleNamespace(model_dump=lambda: decision)
|
||||
),
|
||||
tmp_path,
|
||||
)
|
||||
ai.start()
|
||||
try:
|
||||
ai.enable(True)
|
||||
ai.submit(np.zeros((2, 2, 3), dtype=np.uint8), 0, time.monotonic(), 0)
|
||||
assert entered.wait(2)
|
||||
for frame_id in range(1, 11):
|
||||
ai.submit(
|
||||
np.full((2, 2, 3), frame_id, dtype=np.uint8),
|
||||
frame_id,
|
||||
time.monotonic(),
|
||||
frame_id * 1000,
|
||||
)
|
||||
assert ai.dropped == 9
|
||||
assert ai.command(time.monotonic())[0] == 0
|
||||
ai.enable(False)
|
||||
release.set()
|
||||
ai.close()
|
||||
assert seen == [0]
|
||||
assert ai.result is None
|
||||
finally:
|
||||
release.set()
|
||||
ai.close()
|
||||
|
||||
|
||||
def test_expired_source_stops_even_if_inference_just_completed(tmp_path):
|
||||
path = Path(__file__).parents[1] / "simulation/ai-polygon/realtime_ai.py"
|
||||
spec = importlib.util.spec_from_file_location("polygon_command_deadline", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
ai = module.LatestInference(None, None, tmp_path)
|
||||
ai.enabled = True
|
||||
ai.result = {
|
||||
"captured_at": 0.0,
|
||||
"completed_at": 1.0,
|
||||
"decision": {"speed_mps": 0.3, "yaw_rate_rps": 0},
|
||||
}
|
||||
assert ai.command(1.1)[:3] == (0.0, 0.0, "stale-camera")
|
||||
ai.result["captured_at"] = 1.0
|
||||
assert ai.command(1.1)[:3] == (0.3, 0, "none")
|
||||
ai.result["decision"]["speed_mps"] = -0.1
|
||||
assert ai.command(1.1)[:3] == (-0.1, 0, "none")
|
||||
assert ai.command(1.6)[:2] == (0, 0)
|
||||
# The worker's 0.8 s camera budget still rejects old images, even when
|
||||
# a result has just arrived, and never extends the command watchdog.
|
||||
ai.result.update(captured_at=1.0, completed_at=1.7)
|
||||
assert ai.command(1.79, frame_deadline=0.8)[:3] == (-0.1, 0, "none")
|
||||
assert ai.command(1.81, frame_deadline=0.8)[:3] == (0.0, 0.0, "stale-camera")
|
||||
ai.result.update(captured_at=1.7, completed_at=1.0)
|
||||
assert ai.command(1.79, frame_deadline=0.8)[:2] == (0.0, 0.0)
|
||||
|
||||
|
||||
def test_reverse_telemetry_keeps_the_run_speed_limit(realtime):
|
||||
store, hello, row = realtime
|
||||
store.snapshot(row["run_id"], hello.instance_id, snapshot(applied_speed_mps=-0.1))
|
||||
assert store.get(row["run_id"])["telemetry"]["applied_speed_mps"] == -0.1
|
||||
with pytest.raises(ValueError, match="скорость"):
|
||||
store.snapshot(
|
||||
row["run_id"], hello.instance_id, snapshot(sequence=1, applied_speed_mps=-0.4)
|
||||
)
|
||||
|
||||
|
||||
def test_resume_requires_fresh_clear_observations(tmp_path):
|
||||
from k1link.simulation.ai_polygon.policy import RoadPolicy
|
||||
|
||||
path = Path(__file__).parents[1] / "simulation/ai-polygon/realtime_ai.py"
|
||||
spec = importlib.util.spec_from_file_location("polygon_resume", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
class Model:
|
||||
def ready(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def infer(self, rgb):
|
||||
return np.ones((512, 512), dtype=bool), []
|
||||
|
||||
ai = module.LatestInference(Model, RoadPolicy(0.3), tmp_path)
|
||||
ai.start()
|
||||
|
||||
def frame(number):
|
||||
ai.submit(np.zeros((2, 2, 3), dtype=np.uint8), number, time.monotonic(), number)
|
||||
deadline = time.monotonic() + 2
|
||||
while ai.count < number and time.monotonic() < deadline:
|
||||
time.sleep(0.001)
|
||||
assert ai.count == number
|
||||
return ai.command(time.monotonic())[0]
|
||||
|
||||
try:
|
||||
ai.enable(True)
|
||||
assert frame(1) == frame(2) == 0
|
||||
assert frame(3) > 0
|
||||
ai.enable(False)
|
||||
ai.enable(True)
|
||||
assert frame(4) == frame(5) == 0
|
||||
assert frame(6) > 0
|
||||
finally:
|
||||
ai.close()
|
||||
|
||||
|
||||
def test_windows_snapshot_sharing_retry_is_bounded(tmp_path, monkeypatch):
|
||||
path = Path(__file__).parents[1] / "simulation/ai-polygon/local_state.py"
|
||||
spec = importlib.util.spec_from_file_location("polygon_ipc", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
original = module.os.replace
|
||||
attempts = []
|
||||
|
||||
def transient(source, target):
|
||||
attempts.append(1)
|
||||
if len(attempts) <= 2:
|
||||
raise PermissionError("Windows sharing violation")
|
||||
original(source, target)
|
||||
|
||||
monkeypatch.setattr(module.os, "replace", transient)
|
||||
monkeypatch.setattr(module.time, "sleep", lambda _: None)
|
||||
target = tmp_path / "snapshot.json"
|
||||
module.write_json(target, {"sequence": 3})
|
||||
assert module.read_json(target) == {"sequence": 3}
|
||||
assert len(attempts) == 3
|
||||
attempts.clear()
|
||||
|
||||
def permanent():
|
||||
attempts.append(1)
|
||||
raise PermissionError("Not a transient lock")
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
module.sharing_retry(permanent)
|
||||
assert len(attempts) == 8
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Prevent a reconstructed trunk/face from being admitted between ground rays."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"spawn_clearance", Path(__file__).parents[1] / "simulation/ai-polygon/spawn_clearance.py"
|
||||
)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def count(points, xy=(0, 0), heading=0, plane=(0, 0, 0)):
|
||||
return MODULE.obstructing_triangles(np.array(points), np.array([[0, 1, 2]]), xy, heading, plane)
|
||||
|
||||
|
||||
def test_narrow_trunk_between_support_rays_and_crossing_triangle():
|
||||
assert count([[0.21, 0.21, 0], [0.23, 0.21, 0], [0.21, 0.21, 2]]) == 1
|
||||
# All vertices outside the prism, but the face crosses through its centre.
|
||||
assert count([[-2, 0, 0.3], [2, 0, 0.3], [0, 2, 0.3]]) == 1
|
||||
|
||||
|
||||
def test_ground_step_and_overhead_geometry_do_not_block_start():
|
||||
for z in (0, 0.05, 0.10, 1.5):
|
||||
assert count([[-2, -2, z], [2, -2, z], [0, 2, z]]) == 0
|
||||
assert count([[-2, -2, 0.12], [2, -2, 0.12], [0, 2, 0.12]]) == 1
|
||||
|
||||
|
||||
def test_triangle_bounding_box_alone_does_not_reject_empty_corner():
|
||||
assert count([[0.4, 2, 0.5], [2, 0.4, 0.5], [2, 2, 0.5]]) == 0
|
||||
|
||||
|
||||
def test_clearance_tracks_translation_heading_and_support_slope():
|
||||
points = np.array([[0.6, 0, 0.3], [0.7, 0, 0.3], [0.6, 0.03, 0.8]])
|
||||
assert count(points) == 0
|
||||
assert count(points, heading=45) == 1
|
||||
points[:, 2] += points[:, 0] * 0.2 + 4
|
||||
points[:, :2] += [10, -3]
|
||||
assert count(points, (10, -3), 45, (0.2, 0, 4)) == 1
|
||||
assert count([[8, -5, 3.6], [12, -5, 4.4], [10, -1, 4]], (10, -3), 45, (0.2, 0, 4)) == 0
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Paired assets remain on Worker; hashes and manifests cross the operator link."""
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from test_ai_polygon import ply, worker_asset
|
||||
|
||||
|
||||
def triangle_glb():
|
||||
binary = np.array([[0, 0, 0], [1, 0, 0], [0, 0, 1]], dtype="<f4").tobytes()
|
||||
binary += np.array([0, 1, 2], dtype="<u4").tobytes()
|
||||
doc = {
|
||||
"buffers": [{"byteLength": 48}],
|
||||
"bufferViews": [
|
||||
{"buffer": 0, "byteLength": 36},
|
||||
{"buffer": 0, "byteOffset": 36, "byteLength": 12},
|
||||
],
|
||||
"accessors": [
|
||||
{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3},
|
||||
{"bufferView": 1, "componentType": 5125, "type": "SCALAR", "count": 3},
|
||||
],
|
||||
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
|
||||
}
|
||||
encoded = json.dumps(doc).encode()
|
||||
encoded += b" " * (-len(encoded) % 4)
|
||||
return (
|
||||
struct.pack("<III", 0x46546C67, 2, 28 + len(encoded) + len(binary))
|
||||
+ struct.pack("<II", len(encoded), 0x4E4F534A)
|
||||
+ encoded
|
||||
+ struct.pack("<II", len(binary), 0x004E4942)
|
||||
+ binary
|
||||
)
|
||||
|
||||
|
||||
def test_paired_import_checks_actual_files_and_never_fetches_from_operator(tmp_path, monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).parents[1] / "simulation/ai-polygon"))
|
||||
importer = importlib.import_module("register_paired_scene")
|
||||
preparer = importlib.import_module("prepare_terrain")
|
||||
client_module = importlib.import_module("core_client")
|
||||
source, collider = tmp_path / "source.ply", tmp_path / "mesh.glb"
|
||||
source.write_bytes(ply())
|
||||
collider.write_bytes(triangle_glb())
|
||||
descriptor = worker_asset().model_dump(mode="json")
|
||||
descriptor.update(
|
||||
sha256=hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
collider_sha256=hashlib.sha256(collider.read_bytes()).hexdigest(),
|
||||
)
|
||||
root = tmp_path / "worker"
|
||||
request = importer.stage(root, source, collider, descriptor)
|
||||
world = {**request.model_dump(mode="json"), "storage": {"kind": "worker"}}
|
||||
manifest_path = preparer.prepare_terrain(root, tmp_path, world)
|
||||
assert json.loads(manifest_path.read_text())["collider_sha256"] == descriptor["collider_sha256"]
|
||||
assert importer.stage(root, source, collider, descriptor) == request
|
||||
token = tmp_path / "token"
|
||||
token.write_text("test" * 12)
|
||||
client = client_module.CoreClient("http://127.0.0.1:18080", token, "a" * 32)
|
||||
monkeypatch.setattr(
|
||||
client_module.http.client,
|
||||
"HTTPConnection",
|
||||
lambda *args, **kwargs: pytest.fail("Operator must not supply this asset"),
|
||||
)
|
||||
cached = root / "state/worlds" / (request.sha256 + ".ply")
|
||||
client.download(world, cached)
|
||||
with pytest.raises(RuntimeError, match="missing or changed"):
|
||||
client.download(world, tmp_path / "missing.ply")
|
||||
with pytest.raises(ValueError, match="match"):
|
||||
preparer.prepare_terrain(root, tmp_path, {**world, "collider_sha256": "f" * 64})
|
||||
with pytest.raises(ValueError, match="identity"):
|
||||
importer.stage(root, source, collider, {**descriptor, "collider_sha256": "f" * 64})
|
||||
@@ -1604,3 +1604,47 @@ def test_queue_detects_mutated_immutable_identity(tmp_path: Path) -> None:
|
||||
|
||||
with pytest.raises(ObservatoryRecordedQueueIntegrityError, match="identity"):
|
||||
queue.get(job.job_id)
|
||||
|
||||
|
||||
def test_simulation_reservation_serializes_recorded_claims_and_survives_restart(tmp_path):
|
||||
queue = _queue(tmp_path)
|
||||
job, _ = queue.submit(_intent(), enqueue=True)
|
||||
owner = "airun-" + "a" * 32
|
||||
queue.reserve_simulation(owner)
|
||||
queue.reserve_simulation(owner)
|
||||
restarted = _queue(tmp_path)
|
||||
assert restarted.claim_next(claimant_id="worker-006", claim_request_id="sim-blocked") is None
|
||||
assert restarted.get(job.job_id).state == "queued"
|
||||
with pytest.raises(ObservatoryRecordedQueueConflictError):
|
||||
restarted.release_simulation("airun-" + "b" * 32)
|
||||
restarted.release_simulation(owner)
|
||||
claim = restarted.claim_next(claimant_id="worker-006", claim_request_id="sim-released")
|
||||
assert claim.job.job_id == job.job_id
|
||||
with pytest.raises(ObservatoryRecordedQueueBusyError):
|
||||
queue.reserve_simulation(owner)
|
||||
|
||||
|
||||
def test_simulation_and_recorded_claim_cannot_win_together(tmp_path):
|
||||
queue = _queue(tmp_path)
|
||||
queue.submit(_intent(), enqueue=True)
|
||||
second = _queue(tmp_path)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def simulate():
|
||||
barrier.wait()
|
||||
try:
|
||||
queue.reserve_simulation("airun-" + "a" * 32)
|
||||
return True
|
||||
except ObservatoryRecordedQueueBusyError:
|
||||
return False
|
||||
|
||||
def recorded():
|
||||
barrier.wait()
|
||||
return (
|
||||
second.claim_next(claimant_id="worker-006", claim_request_id="racing-claim") is not None
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
simulation = pool.submit(simulate)
|
||||
inference = pool.submit(recorded)
|
||||
assert simulation.result() != inference.result()
|
||||
|
||||
Reference in New Issue
Block a user