feat(ft546): run shared dispatch kernel in shadow

This commit is contained in:
zgw
2026-08-14 15:40:31 +08:00
parent 08003407eb
commit 6864010832
22 changed files with 2346 additions and 8 deletions

View File

@@ -0,0 +1,362 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.facts.ShadowMapFact;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowVehicleFact;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.ShadowDispatchResult;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner;
import com.ruoyi.wcs.domain.traffic.DeadlockGuard;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.RoutePlan;
import com.ruoyi.wcs.domain.traffic.RoutePlanner;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import com.ruoyi.wcs.port.out.DispatchPolicy;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class Ft546ShadowCycleTest {
@Test
void ordinaryGrantUsesTheLegacyMainRegisterSequence() {
Ft546EncodedCommand encoded = encode(grant("VEH-2", points("70511", "70512"),
parameters("1", null, null)));
assertEquals(Arrays.asList("4004", "4000", "4002", "4021"),
addresses(encoded));
assertEquals("VEH-2", encoded.getDeviceId());
assertEquals(points(70511, 70512), encoded.getPathPoints());
}
@Test
void crossFloorGrantAddsOnlyTheLegacyCrossFloorRegisters() {
Ft546EncodedCommand encoded = encode(grant("VEH-3", points("70501", "70502"),
parameters("8", "8", null)));
assertEquals(Arrays.asList("4004", "4000", "4002", "4021",
"4020", "4022"), addresses(encoded));
}
@Test
void twoStageGrantsUseTheMatchingLegacyRegisterSequences() {
Ft546EncodedCommand first = encode(grant("VEH-1",
points("70501", "70503"), parameters("1", null, "1")));
Ft546EncodedCommand second = encode(grant("VEH-1",
points("70503", "70504"), parameters("1", null, "2")));
assertEquals(Arrays.asList("4404", "4422", "4402", "4400"),
addresses(first));
assertEquals(Arrays.asList("4404", "4422", "4423", "4402", "4400"),
addresses(second));
}
@Test
void malformedExplicitPhaseParametersFailBeforeAnythingIsRecorded() {
RecordingFt546CommandGateway gateway = gateway();
ShadowCommandIntentFactory factory = new ShadowCommandIntentFactory();
GrantedDispatch invalid = grant("VEH-1", points("70501", "70502"),
parameters("8", null, null));
assertThrows(IllegalArgumentException.class,
() -> gateway.record(factory.create(invalid)));
assertEquals(Collections.emptyList(), gateway.getRecorded());
}
@Test
void recorderKeepsImmutableEncodedEvidenceWithoutSending() {
RecordingFt546CommandGateway gateway = gateway();
Ft546SrgvCommandIntent intent = new ShadowCommandIntentFactory().create(
grant("VEH-1", points("70501", "70502"),
parameters("1", null, null)));
Ft546EncodedCommand encoded = gateway.record(intent);
assertEquals(Collections.singletonList(encoded), gateway.getRecorded());
assertThrows(UnsupportedOperationException.class,
() -> gateway.getRecorded().clear());
}
@Test
void assemblerUsesOnlyActiveQueueAndIndependentVehicleFacts() {
Instant now = Instant.parse("2026-08-14T06:00:00Z");
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
Arrays.asList(queue("shadow_ro.v_wcs_srgv_queue", "ACTIVE", now),
queue("shadow_ro.v_wcs_srgv_queue_finish", "FINISH", now)),
Collections.singletonList(vehicle(now)), maps());
ShadowDispatchSnapshotAssembler assembler =
new ShadowDispatchSnapshotAssembler("FT546", 3L,
topology -> new ShadowTrafficState(
new ResourceLedger(topology),
new WaitForGraph(), 9L,
Collections.emptyMap()),
Clock.fixed(now, ZoneOffset.UTC));
DispatchSnapshot snapshot = assembler.assemble(batch);
assertEquals(1, snapshot.getReadySteps().size());
assertEquals("ACTIVE", snapshot.getReadySteps().get(0).getStepId());
assertEquals("70501",
snapshot.getReadySteps().get(0).getSourceNodeId());
assertEquals("70502",
snapshot.getReadySteps().get(0).getDestinationNodeId());
assertEquals("30001", snapshot.getReadySteps().get(0)
.getCommandParameters().get("parentTaskId"));
assertEquals(1, snapshot.getVehicleSnapshots().size());
assertEquals("VEH-9", snapshot.getVehicleSnapshots().get(0)
.getVehicleId());
assertEquals(9L, snapshot.getSnapshotVersion());
assertEquals(Arrays.asList("70502"),
snapshot.getTopology().neighbors("70501"));
}
@Test
void invalidFactsProduceNoDispatchIntentOrEncodedEvidence() {
Instant now = Instant.parse("2026-08-14T06:00:00Z");
ShadowFactBatch invalid = ShadowFactBatch.create(now.minusSeconds(1), now,
Collections.singletonList(
queue("shadow_ro.v_wcs_srgv_queue", "ACTIVE", now)),
Collections.emptyList(), maps());
AtomicInteger dispatchCalls = new AtomicInteger();
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
snapshot -> {
dispatchCalls.incrementAndGet();
return DispatchCycleResult.retrySnapshotChanged();
}, new ShadowCommandIntentFactory()::create);
RecordingFt546CommandGateway gateway = gateway();
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
new ShadowFactBatchValidator(Duration.ofSeconds(4),
Duration.ofSeconds(30)),
batch -> {
throw new AssertionError("invalid facts must not assemble");
}, readOnly, gateway);
ShadowCycleResult result = cycle.run(invalid, now);
assertEquals("INSUFFICIENT_FACTS", result.getStatusCode());
assertEquals("MISSING_VEHICLE_FACTS", result.getReasonCode());
assertEquals(0, dispatchCalls.get());
assertEquals(Collections.emptyList(), result.getIntents());
assertEquals(Collections.emptyList(), result.getEncodedCommands());
assertEquals(Collections.emptyList(), gateway.getRecorded());
}
@Test
void validFactsRunTheRealKernelThroughVirtualRegisterEvidence() {
Instant now = Instant.parse("2026-08-14T06:00:00Z");
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
Collections.singletonList(queueWithoutLegacyAssignment(now)),
Collections.singletonList(vehicle(now)), maps());
AtomicInteger commitCalls = new AtomicInteger();
AtomicInteger generatedIds = new AtomicInteger();
RoutePlanner routePlanner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(routePlanner,
DispatchPolicy.NONE),
new DeadlockGuard(new WaitForGraph(), routePlanner),
commit -> {
commitCalls.incrementAndGet();
return true;
},
() -> "SHADOW-" + generatedIds.incrementAndGet());
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
coordinator::dispatch,
new ShadowCommandIntentFactory()::create);
ShadowDispatchSnapshotAssembler assembler =
new ShadowDispatchSnapshotAssembler("FT546", 3L,
topology -> new ShadowTrafficState(
new ResourceLedger(topology),
new WaitForGraph(), 0L,
Collections.emptyMap()),
Clock.fixed(now, ZoneOffset.UTC));
RecordingFt546CommandGateway gateway = gateway();
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
new ShadowFactBatchValidator(Duration.ofSeconds(4),
Duration.ofSeconds(30)),
assembler::assemble, readOnly, gateway);
ShadowCycleResult result = cycle.run(batch, now);
assertEquals("COMPARABLE", result.getStatusCode());
assertEquals(1, result.getDecisions().getGranted().size());
assertEquals("VEH-9", result.getDecisions().getGranted().get(0)
.getVehicleId());
assertEquals("ACTIVE", result.getDecisions().getGranted().get(0)
.getStepId());
assertEquals(1, commitCalls.get());
assertEquals(1, result.getIntents().size());
assertEquals(1, result.getEncodedCommands().size());
assertEquals("VEH-9", result.getEncodedCommands().get(0)
.getDeviceId());
assertEquals(Arrays.asList("4004", "4000", "4002", "4021"),
addresses(result.getEncodedCommands().get(0)));
assertEquals(1, gateway.getRecorded().size());
}
private static Ft546EncodedCommand encode(GrantedDispatch grant) {
RecordingFt546CommandGateway gateway = gateway();
Ft546SrgvCommandIntent intent =
new ShadowCommandIntentFactory().create(grant);
return gateway.record(intent);
}
private static RecordingFt546CommandGateway gateway() {
return new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(),
profile(), capabilities("POINT_TO_POINT_COMMAND",
"TWO_STAGE_ATOMIC_TASK"));
}
private static GrantedDispatch grant(String vehicleId,
List<String> routeNodes,
Map<String, String> parameters) {
RoutePlan route = new RoutePlan(routeNodes,
resourceKeys(routeNodes), 1, 0, 7L);
return new GrantedDispatch("PARENT-1", "STEP-1", vehicleId, route,
Collections.singletonList("RES-1"), 7L, 11L, parameters);
}
private static Map<String, String> parameters(String taskType,
String crossFloorTarget,
String stage) {
Map<String, String> values = new LinkedHashMap<String, String>();
values.put("parentTaskId", "PARENT-1");
values.put("phase", stage == null ? "LOADED_TO_PLACE"
: "TWO_STAGE_" + stage);
values.put("routeVersion", "3");
values.put("taskNo", "30001");
values.put("taskType", taskType);
if (crossFloorTarget != null) {
values.put("crossFloorTarget", crossFloorTarget);
}
if (stage != null) {
values.put("stage", stage);
}
return values;
}
private static List<String> resourceKeys(List<String> nodes) {
return Arrays.asList("NODE:" + nodes.get(0),
"EDGE:" + nodes.get(0) + ':' + nodes.get(1),
"NODE:" + nodes.get(1));
}
private static Ft546DeviceProfile profile() {
Ft546DeviceProfile profile = new Ft546DeviceProfile();
profile.setDeviceId("SHADOW");
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000");
registers.put("taskType", "4002");
registers.put("path", "4004");
registers.put("trigger", "4021");
registers.put("crossFloorFlag", "4020");
registers.put("crossFloorTarget", "4022");
registers.put("secondTaskNo", "4400");
registers.put("secondTaskType", "4402");
registers.put("secondPath", "4404");
registers.put("secondStageFlag", "4422");
registers.put("secondComplete", "4423");
profile.setRegisters(registers);
profile.setCapabilities(capabilities("POINT_TO_POINT_COMMAND",
"TWO_STAGE_ATOMIC_TASK"));
return profile;
}
private static Set<String> capabilities(String... values) {
return new LinkedHashSet<String>(Arrays.asList(values));
}
private static List<String> points(String... values) {
return Arrays.asList(values);
}
private static List<Integer> points(Integer... values) {
return Arrays.asList(values);
}
private static List<String> addresses(Ft546EncodedCommand encoded) {
java.util.ArrayList<String> values = new java.util.ArrayList<String>();
encoded.getWrites().forEach(write -> values.add(write.getAddress()));
return values;
}
private static ShadowQueueFact queue(String table, String guid,
Instant createdAt) {
return ShadowQueueFact.builder(table, guid)
.taskNo("30001")
.taskType("2")
.priority(2L)
.cargoPickSite("70501")
.cargoPlaceSite("70502")
.startSite("70501")
.endSite("70502")
.createdAt(createdAt.minusSeconds(10))
.agvId("LEGACY-VEHICLE-MUST-NOT-BE-CANDIDATE")
.pathInfo("99999,99998")
.turnPath("99997")
.default02("1")
.build();
}
private static ShadowQueueFact queueWithoutLegacyAssignment(
Instant createdAt) {
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "ACTIVE")
.taskNo("30001")
.taskType("2")
.priority(2L)
.cargoPickSite("70501")
.cargoPlaceSite("70502")
.startSite("70501")
.endSite("70502")
.createdAt(createdAt.minusSeconds(10))
.default02("1")
.build();
}
private static ShadowVehicleFact vehicle(Instant capturedAt) {
return ShadowVehicleFact.builder("VEH-GUID", "VEH-9")
.agvStatus("1")
.currentSite("70501")
.battery(80)
.workMode(5)
.alarm(0)
.status(1)
.errorCode(0)
.allowSend(1)
.currentFloor(1)
.capturedAt(capturedAt)
.build();
}
private static List<ShadowMapFact> maps() {
return Arrays.asList(
new ShadowMapFact("MAP-1", "70501", 0, 0, 1, 1),
new ShadowMapFact("MAP-2", "70502", 1, 0, 1, 1));
}
}