feat(ft546): run shared dispatch kernel in shadow
This commit is contained in:
@@ -52,6 +52,12 @@
|
||||
<version>1.2.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.2.224</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
package com.ruoyi.shadow.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
|
||||
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
|
||||
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
|
||||
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
|
||||
import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory;
|
||||
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.dispatch.VehicleAssignmentPolicy;
|
||||
@@ -10,9 +21,20 @@ import com.ruoyi.wcs.domain.traffic.RoutePlanner;
|
||||
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import com.ruoyi.wcs.port.out.TrafficStateRepository;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@@ -40,12 +62,23 @@ public class ShadowKernelConfiguration {
|
||||
return () -> "SHADOW-" + sequence.incrementAndGet();
|
||||
}
|
||||
|
||||
@Bean(name = "shadowClock")
|
||||
public Clock shadowClock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TrafficStateRepository disabledShadowTrafficStateRepository() {
|
||||
return commit -> {
|
||||
throw new IllegalStateException(
|
||||
"SHADOW_TRAFFIC_STATE_REPOSITORY_NOT_CONFIGURED");
|
||||
};
|
||||
public JdbcShadowTrafficStateRepository shadowTrafficStateRepository(
|
||||
@Qualifier("shadowEvidenceJdbc") JdbcTemplate evidenceJdbc,
|
||||
@Qualifier("shadowEvidenceTx")
|
||||
PlatformTransactionManager evidenceTx,
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("shadowClock") Clock clock,
|
||||
ShadowCommandIntentFactory intentFactory,
|
||||
RecordingFt546CommandGateway commandGateway) {
|
||||
return new JdbcShadowTrafficStateRepository(evidenceJdbc,
|
||||
new TransactionTemplate(evidenceTx), objectMapper, clock,
|
||||
intentFactory, commandGateway);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -57,4 +90,69 @@ public class ShadowKernelConfiguration {
|
||||
return new DispatchCoordinator(assignmentPolicy, deadlockGuard,
|
||||
trafficStateRepository, idGenerator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ShadowCommandIntentFactory shadowCommandIntentFactory() {
|
||||
return new ShadowCommandIntentFactory();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ShadowDispatchSnapshotAssembler shadowDispatchSnapshotAssembler(
|
||||
JdbcShadowTrafficStateRepository trafficStateRepository,
|
||||
@Qualifier("shadowClock") Clock clock) {
|
||||
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
|
||||
trafficStateRepository::load, clock);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>
|
||||
shadowReadOnlyDispatchCycle(
|
||||
DispatchCoordinator coordinator,
|
||||
ShadowCommandIntentFactory intentFactory) {
|
||||
return new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
|
||||
coordinator::dispatch, intentFactory::create);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RecordingFt546CommandGateway recordingFt546CommandGateway() {
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
profile.setDeviceId("FT546_SHADOW_VIRTUAL_PLC");
|
||||
profile.setDeviceType("FOUR_WAY_SHUTTLE");
|
||||
profile.setAdapterType("FT546_LEGACY_SRGV_RECORDING");
|
||||
profile.setProfileVersion(1L);
|
||||
Set<String> capabilities = new LinkedHashSet<String>(Arrays.asList(
|
||||
"POINT_TO_POINT_COMMAND", "TWO_STAGE_ATOMIC_TASK"));
|
||||
profile.setCapabilities(capabilities);
|
||||
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);
|
||||
return new RecordingFt546CommandGateway(
|
||||
new Ft546SrgvCommandEncoder(), profile, capabilities);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ShadowFactBatchValidator shadowFactBatchValidator() {
|
||||
return new ShadowFactBatchValidator(Duration.ofSeconds(4),
|
||||
Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Ft546ShadowCycle ft546ShadowCycle(
|
||||
ShadowFactBatchValidator validator,
|
||||
ShadowDispatchSnapshotAssembler assembler,
|
||||
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatchCycle,
|
||||
RecordingFt546CommandGateway commandGateway) {
|
||||
return new Ft546ShadowCycle(validator, assembler::assemble,
|
||||
dispatchCycle, commandGateway);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatch;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
|
||||
import com.ruoyi.shadow.facts.ShadowFactValidation;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.ShadowDispatchResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class Ft546ShadowCycle {
|
||||
private final ShadowFactBatchValidator validator;
|
||||
private final Function<ShadowFactBatch, DispatchSnapshot> assembler;
|
||||
private final ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatchCycle;
|
||||
private final RecordingFt546CommandGateway commandGateway;
|
||||
|
||||
public Ft546ShadowCycle(
|
||||
ShadowFactBatchValidator validator,
|
||||
Function<ShadowFactBatch, DispatchSnapshot> assembler,
|
||||
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatchCycle,
|
||||
RecordingFt546CommandGateway commandGateway) {
|
||||
this.validator = Objects.requireNonNull(validator, "validator");
|
||||
this.assembler = Objects.requireNonNull(assembler, "assembler");
|
||||
this.dispatchCycle = Objects.requireNonNull(dispatchCycle,
|
||||
"dispatchCycle");
|
||||
this.commandGateway = Objects.requireNonNull(commandGateway,
|
||||
"commandGateway");
|
||||
}
|
||||
|
||||
public ShadowCycleResult run(ShadowFactBatch batch, Instant now) {
|
||||
ShadowFactValidation validation = validator.validate(batch, now);
|
||||
if (validation.getStatus()
|
||||
!= ShadowFactValidation.Status.COMPARABLE) {
|
||||
return ShadowCycleResult.insufficient(validation.getReasonCode());
|
||||
}
|
||||
final DispatchSnapshot snapshot;
|
||||
try {
|
||||
snapshot = assembler.apply(batch);
|
||||
} catch (RuntimeException failure) {
|
||||
return ShadowCycleResult.insufficient(
|
||||
"INVALID_KERNEL_FACTS:" + message(failure));
|
||||
}
|
||||
ShadowDispatchResult<Ft546SrgvCommandIntent> result;
|
||||
try {
|
||||
result = dispatchCycle.run(snapshot);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
return ShadowCycleResult.insufficient(
|
||||
"INVALID_COMMAND_FACTS:" + message(failure));
|
||||
}
|
||||
if (result.getDecisions().isRetrySnapshotChanged()) {
|
||||
return ShadowCycleResult.retry(result.getDecisions());
|
||||
}
|
||||
final List<Ft546EncodedCommand> encoded;
|
||||
try {
|
||||
encoded = commandGateway.recordAll(result.getIntents());
|
||||
} catch (RuntimeException failure) {
|
||||
return ShadowCycleResult.insufficient(
|
||||
"INVALID_COMMAND_FACTS:" + message(failure));
|
||||
}
|
||||
return ShadowCycleResult.comparable(result.getDecisions(),
|
||||
result.getIntents(), encoded);
|
||||
}
|
||||
|
||||
private static String message(RuntimeException failure) {
|
||||
String value = failure.getMessage();
|
||||
return value == null || value.trim().isEmpty()
|
||||
? failure.getClass().getSimpleName() : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCommit;
|
||||
import com.ruoyi.wcs.application.dispatch.HeldDispatch;
|
||||
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||
import com.ruoyi.wcs.domain.traffic.ReservationState;
|
||||
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
|
||||
import com.ruoyi.wcs.domain.traffic.ResourceLedgerState;
|
||||
import com.ruoyi.wcs.domain.traffic.ResourceReservationState;
|
||||
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
|
||||
import com.ruoyi.wcs.port.out.TrafficStateRepository;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
public final class JdbcShadowTrafficStateRepository
|
||||
implements TrafficStateRepository {
|
||||
private static final String KEY_SEPARATOR = "\u001f";
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final TransactionTemplate transaction;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
private final ShadowCommandIntentFactory intentFactory;
|
||||
private final RecordingFt546CommandGateway commandGateway;
|
||||
|
||||
public JdbcShadowTrafficStateRepository(
|
||||
JdbcTemplate jdbc,
|
||||
TransactionTemplate transaction,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock,
|
||||
ShadowCommandIntentFactory intentFactory,
|
||||
RecordingFt546CommandGateway commandGateway) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
this.transaction = Objects.requireNonNull(transaction, "transaction");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.intentFactory = Objects.requireNonNull(intentFactory,
|
||||
"intentFactory");
|
||||
this.commandGateway = Objects.requireNonNull(commandGateway,
|
||||
"commandGateway");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean commit(DispatchCommit commit) {
|
||||
Objects.requireNonNull(commit, "commit");
|
||||
commit.getGranted().forEach(grant -> commandGateway.preview(
|
||||
intentFactory.create(grant)));
|
||||
try {
|
||||
Boolean result = transaction.execute(status -> doCommit(commit));
|
||||
return Boolean.TRUE.equals(result);
|
||||
} catch (DuplicateKeyException concurrentInsert) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public ShadowTrafficState load(EffectiveTopology topology) {
|
||||
Objects.requireNonNull(topology, "topology");
|
||||
StoredRow row = read(stateKey(topology.getWarehouseCode(),
|
||||
topology.version()));
|
||||
if (row == null) {
|
||||
return new ShadowTrafficState(new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L,
|
||||
Collections.<String, Integer>emptyMap());
|
||||
}
|
||||
PersistedState persisted = decode(row.stateJson);
|
||||
ResourceLedger ledger = ResourceLedger.restore(persisted.ledger);
|
||||
Map<String, Integer> topologyCapacities =
|
||||
new ResourceLedger(topology).snapshotState().getCapacities();
|
||||
if (!topologyCapacities.equals(
|
||||
ledger.snapshotState().getCapacities())) {
|
||||
throw new IllegalStateException(
|
||||
"persisted traffic resources do not match topology");
|
||||
}
|
||||
return new ShadowTrafficState(ledger,
|
||||
WaitForGraph.restore(persisted.waitForEdges), row.version,
|
||||
persisted.holdCounts);
|
||||
}
|
||||
|
||||
private boolean doCommit(DispatchCommit commit) {
|
||||
String key = stateKey(commit.getWarehouseCode(),
|
||||
commit.getTopologyVersion());
|
||||
StoredRow current = read(key);
|
||||
if (current == null && commit.getSnapshotVersion() != 0L) {
|
||||
return false;
|
||||
}
|
||||
if (current != null
|
||||
&& current.version != commit.getSnapshotVersion()) {
|
||||
return false;
|
||||
}
|
||||
Map<String, Integer> previousHolds = current == null
|
||||
? Collections.<String, Integer>emptyMap()
|
||||
: decode(current.stateJson).holdCounts;
|
||||
Map<String, Integer> nextHolds = nextHoldCounts(previousHolds,
|
||||
commit.getHeld());
|
||||
long nextVersion = increment(commit.getSnapshotVersion());
|
||||
String stateJson = encode(commit.getResultingLedger().snapshotState(),
|
||||
commit.getResultingWaitForGraph().snapshotEdges(), nextHolds);
|
||||
Timestamp now = Timestamp.from(clock.instant());
|
||||
if (current == null) {
|
||||
jdbc.update("INSERT INTO shadow_traffic_state "
|
||||
+ "(state_key,snapshot_version,state_json,updated_at) "
|
||||
+ "VALUES (?,?,?,?)",
|
||||
key, nextVersion, stateJson, now);
|
||||
} else {
|
||||
int updated = jdbc.update("UPDATE shadow_traffic_state "
|
||||
+ "SET snapshot_version=?,state_json=?,updated_at=? "
|
||||
+ "WHERE state_key=? AND snapshot_version=?",
|
||||
nextVersion, stateJson, now, key,
|
||||
commit.getSnapshotVersion());
|
||||
if (updated != 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
synchronizeHolds(previousHolds, nextHolds, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
private StoredRow read(String key) {
|
||||
List<StoredRow> rows = jdbc.query(
|
||||
"SELECT snapshot_version,state_json "
|
||||
+ "FROM shadow_traffic_state WHERE state_key=?",
|
||||
(resultSet, rowNumber) -> new StoredRow(
|
||||
resultSet.getLong("snapshot_version"),
|
||||
resultSet.getString("state_json")), key);
|
||||
if (rows.size() > 1) {
|
||||
throw new IllegalStateException(
|
||||
"multiple shadow traffic states for key " + key);
|
||||
}
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private void synchronizeHolds(Map<String, Integer> previous,
|
||||
Map<String, Integer> current,
|
||||
Timestamp now) {
|
||||
for (String key : previous.keySet()) {
|
||||
if (!current.containsKey(key)) {
|
||||
HoldIdentity identity = HoldIdentity.parse(key);
|
||||
jdbc.update("DELETE FROM shadow_hold_history "
|
||||
+ "WHERE parent_task_id=? AND vehicle_id=? "
|
||||
+ "AND resource_key=? AND reason_code=?",
|
||||
identity.parentTaskId, identity.vehicleId,
|
||||
identity.resourceKey, identity.reasonCode);
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, Integer> entry : current.entrySet()) {
|
||||
HoldIdentity identity = HoldIdentity.parse(entry.getKey());
|
||||
int updated = jdbc.update("UPDATE shadow_hold_history "
|
||||
+ "SET consecutive_count=?,updated_at=? "
|
||||
+ "WHERE parent_task_id=? AND vehicle_id=? "
|
||||
+ "AND resource_key=? AND reason_code=?",
|
||||
entry.getValue(), now, identity.parentTaskId,
|
||||
identity.vehicleId, identity.resourceKey,
|
||||
identity.reasonCode);
|
||||
if (updated == 0) {
|
||||
jdbc.update("INSERT INTO shadow_hold_history "
|
||||
+ "(parent_task_id,vehicle_id,resource_key,"
|
||||
+ "reason_code,consecutive_count,updated_at) "
|
||||
+ "VALUES (?,?,?,?,?,?)",
|
||||
identity.parentTaskId, identity.vehicleId,
|
||||
identity.resourceKey, identity.reasonCode,
|
||||
entry.getValue(), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Integer> nextHoldCounts(
|
||||
Map<String, Integer> previous,
|
||||
List<HeldDispatch> held) {
|
||||
Map<String, Integer> next = new TreeMap<String, Integer>();
|
||||
for (HeldDispatch decision : held) {
|
||||
String key = HoldIdentity.from(decision).key();
|
||||
if (next.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
Integer count = previous.get(key);
|
||||
next.put(key, count == null ? 1 : increment(count.intValue()));
|
||||
}
|
||||
return Collections.unmodifiableMap(next);
|
||||
}
|
||||
|
||||
private String encode(ResourceLedgerState ledger,
|
||||
Map<String, Set<String>> waitForEdges,
|
||||
Map<String, Integer> holdCounts) {
|
||||
Map<String, Object> root = new LinkedHashMap<String, Object>();
|
||||
root.put("schemaVersion", 1);
|
||||
root.put("ledger", ledgerMap(ledger));
|
||||
Map<String, Object> edges = new TreeMap<String, Object>();
|
||||
for (Map.Entry<String, Set<String>> entry : waitForEdges.entrySet()) {
|
||||
edges.put(entry.getKey(),
|
||||
new ArrayList<String>(new TreeSet<String>(entry.getValue())));
|
||||
}
|
||||
root.put("waitForEdges", edges);
|
||||
root.put("holdCounts", new TreeMap<String, Integer>(holdCounts));
|
||||
try {
|
||||
return objectMapper.writeValueAsString(root);
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException(
|
||||
"cannot serialize shadow traffic state", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> ledgerMap(ResourceLedgerState ledger) {
|
||||
Map<String, Object> result = new LinkedHashMap<String, Object>();
|
||||
result.put("capacities", new TreeMap<String, Integer>(
|
||||
ledger.getCapacities()));
|
||||
result.put("nextFencingToken", ledger.getNextFencingToken());
|
||||
List<ResourceReservationState> reservations =
|
||||
new ArrayList<ResourceReservationState>(
|
||||
ledger.getReservations());
|
||||
Collections.sort(reservations, Comparator.comparing(
|
||||
ResourceReservationState::getReservationId));
|
||||
List<Map<String, Object>> values =
|
||||
new ArrayList<Map<String, Object>>(reservations.size());
|
||||
for (ResourceReservationState reservation : reservations) {
|
||||
Map<String, Object> value = new LinkedHashMap<String, Object>();
|
||||
value.put("reservationId", reservation.getReservationId());
|
||||
value.put("orderId", reservation.getOrderId());
|
||||
value.put("stepId", reservation.getStepId());
|
||||
value.put("vehicleId", reservation.getVehicleId());
|
||||
value.put("resourceKeys", reservation.getResourceKeys());
|
||||
value.put("state", reservation.getState().name());
|
||||
value.put("fencingToken", reservation.getFencingToken());
|
||||
value.put("terminalReason", reservation.getTerminalReason());
|
||||
value.put("releaseEvidenceId", reservation.getReleaseEvidenceId());
|
||||
value.put("commandUnknown", reservation.isCommandUnknown());
|
||||
value.put("recoveryUnresolved",
|
||||
reservation.isRecoveryUnresolved());
|
||||
values.add(value);
|
||||
}
|
||||
result.put("reservations", values);
|
||||
return result;
|
||||
}
|
||||
|
||||
private PersistedState decode(String json) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
if (root.path("schemaVersion").asInt(-1) != 1) {
|
||||
throw new IllegalStateException(
|
||||
"unsupported shadow traffic state schema");
|
||||
}
|
||||
JsonNode ledger = requiredObject(root, "ledger");
|
||||
Map<String, Integer> capacities = integerMap(
|
||||
requiredObject(ledger, "capacities"));
|
||||
List<ResourceReservationState> reservations =
|
||||
reservations(requiredArray(ledger, "reservations"));
|
||||
long nextToken = requiredLong(ledger, "nextFencingToken");
|
||||
ResourceLedgerState ledgerState = new ResourceLedgerState(
|
||||
capacities, reservations, nextToken);
|
||||
Map<String, Set<String>> edges = edgeMap(
|
||||
requiredObject(root, "waitForEdges"));
|
||||
Map<String, Integer> holds = integerMap(
|
||||
requiredObject(root, "holdCounts"));
|
||||
return new PersistedState(ledgerState, edges, holds);
|
||||
} catch (RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException(
|
||||
"cannot deserialize shadow traffic state", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ResourceReservationState> reservations(JsonNode array) {
|
||||
List<ResourceReservationState> result =
|
||||
new ArrayList<ResourceReservationState>();
|
||||
for (JsonNode node : array) {
|
||||
List<String> resourceKeys = new ArrayList<String>();
|
||||
for (JsonNode resource : requiredArray(node, "resourceKeys")) {
|
||||
resourceKeys.add(requiredNodeText(resource, "resourceKey"));
|
||||
}
|
||||
result.add(new ResourceReservationState(
|
||||
requiredText(node, "reservationId"),
|
||||
requiredText(node, "orderId"),
|
||||
requiredText(node, "stepId"),
|
||||
requiredText(node, "vehicleId"), resourceKeys,
|
||||
ReservationState.valueOf(requiredText(node, "state")),
|
||||
requiredLong(node, "fencingToken"),
|
||||
optionalText(node.get("terminalReason")),
|
||||
optionalText(node.get("releaseEvidenceId")),
|
||||
requiredBoolean(node, "commandUnknown"),
|
||||
requiredBoolean(node, "recoveryUnresolved")));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Set<String>> edgeMap(JsonNode object) {
|
||||
Map<String, Set<String>> result =
|
||||
new LinkedHashMap<String, Set<String>>();
|
||||
object.fields().forEachRemaining(entry -> {
|
||||
if (!entry.getValue().isArray()) {
|
||||
throw new IllegalStateException("wait-for holders must be an array");
|
||||
}
|
||||
Set<String> holders = new LinkedHashSet<String>();
|
||||
for (JsonNode holder : entry.getValue()) {
|
||||
holders.add(requiredNodeText(holder, "holder"));
|
||||
}
|
||||
result.put(entry.getKey(), holders);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Integer> integerMap(JsonNode object) {
|
||||
Map<String, Integer> result = new LinkedHashMap<String, Integer>();
|
||||
object.fields().forEachRemaining(entry -> {
|
||||
if (!entry.getValue().canConvertToInt()) {
|
||||
throw new IllegalStateException("state count must be int32");
|
||||
}
|
||||
result.put(entry.getKey(), entry.getValue().intValue());
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonNode requiredObject(JsonNode parent, String field) {
|
||||
JsonNode value = parent.get(field);
|
||||
if (value == null || !value.isObject()) {
|
||||
throw new IllegalStateException(field + " must be an object");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static JsonNode requiredArray(JsonNode parent, String field) {
|
||||
JsonNode value = parent.get(field);
|
||||
if (value == null || !value.isArray()) {
|
||||
throw new IllegalStateException(field + " must be an array");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String requiredText(JsonNode parent, String field) {
|
||||
return requiredNodeText(parent.get(field), field);
|
||||
}
|
||||
|
||||
private static String requiredNodeText(JsonNode value, String field) {
|
||||
String text = optionalText(value);
|
||||
if (text == null) {
|
||||
throw new IllegalStateException(field + " must be text");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private static String optionalText(JsonNode value) {
|
||||
if (value == null || value.isNull() || !value.isTextual()) {
|
||||
return null;
|
||||
}
|
||||
String text = value.textValue();
|
||||
return text == null || text.trim().isEmpty() ? null : text.trim();
|
||||
}
|
||||
|
||||
private static long requiredLong(JsonNode parent, String field) {
|
||||
JsonNode value = parent.get(field);
|
||||
if (value == null || !value.canConvertToLong()) {
|
||||
throw new IllegalStateException(field + " must be int64");
|
||||
}
|
||||
return value.longValue();
|
||||
}
|
||||
|
||||
private static boolean requiredBoolean(JsonNode parent, String field) {
|
||||
JsonNode value = parent.get(field);
|
||||
if (value == null || !value.isBoolean()) {
|
||||
throw new IllegalStateException(field + " must be boolean");
|
||||
}
|
||||
return value.booleanValue();
|
||||
}
|
||||
|
||||
private static String stateKey(String warehouseCode, long topologyVersion) {
|
||||
if (topologyVersion <= 0L) {
|
||||
throw new IllegalArgumentException(
|
||||
"topologyVersion must be positive");
|
||||
}
|
||||
return requiredPart(warehouseCode, "warehouseCode")
|
||||
+ ":TOPOLOGY:" + topologyVersion;
|
||||
}
|
||||
|
||||
private static String requiredPart(String value, String field) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if (normalized.contains(KEY_SEPARATOR)) {
|
||||
throw new IllegalArgumentException(field + " contains a reserved character");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static long increment(long value) {
|
||||
if (value == Long.MAX_VALUE) {
|
||||
throw new IllegalStateException("snapshotVersion overflow");
|
||||
}
|
||||
return value + 1L;
|
||||
}
|
||||
|
||||
private static int increment(int value) {
|
||||
if (value == Integer.MAX_VALUE) {
|
||||
throw new IllegalStateException("consecutive HOLD count overflow");
|
||||
}
|
||||
return value + 1;
|
||||
}
|
||||
|
||||
private static final class StoredRow {
|
||||
private final long version;
|
||||
private final String stateJson;
|
||||
|
||||
private StoredRow(long version, String stateJson) {
|
||||
this.version = version;
|
||||
this.stateJson = stateJson;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PersistedState {
|
||||
private final ResourceLedgerState ledger;
|
||||
private final Map<String, Set<String>> waitForEdges;
|
||||
private final Map<String, Integer> holdCounts;
|
||||
|
||||
private PersistedState(ResourceLedgerState ledger,
|
||||
Map<String, Set<String>> waitForEdges,
|
||||
Map<String, Integer> holdCounts) {
|
||||
this.ledger = ledger;
|
||||
this.waitForEdges = waitForEdges;
|
||||
this.holdCounts = holdCounts;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class HoldIdentity {
|
||||
private final String parentTaskId;
|
||||
private final String vehicleId;
|
||||
private final String resourceKey;
|
||||
private final String reasonCode;
|
||||
|
||||
private HoldIdentity(String parentTaskId, String vehicleId,
|
||||
String resourceKey, String reasonCode) {
|
||||
this.parentTaskId = requiredPart(parentTaskId, "parentTaskId");
|
||||
this.vehicleId = requiredPart(vehicleId, "vehicleId");
|
||||
this.resourceKey = requiredPart(resourceKey, "resourceKey");
|
||||
this.reasonCode = requiredPart(reasonCode, "reasonCode");
|
||||
}
|
||||
|
||||
private static HoldIdentity from(HeldDispatch held) {
|
||||
String vehicle = held.getVehicleId() == null
|
||||
? "UNASSIGNED" : held.getVehicleId();
|
||||
String resource;
|
||||
if (held.getYieldNodeId() != null) {
|
||||
resource = "NODE:" + held.getYieldNodeId();
|
||||
} else if (!held.getBlockingVehicleIds().isEmpty()) {
|
||||
resource = "BLOCKERS:" + String.join(",",
|
||||
new TreeSet<String>(held.getBlockingVehicleIds()));
|
||||
} else {
|
||||
resource = "UNSPECIFIED";
|
||||
}
|
||||
return new HoldIdentity(held.getOrderId(), vehicle, resource,
|
||||
held.getReasonCode());
|
||||
}
|
||||
|
||||
private static HoldIdentity parse(String key) {
|
||||
String[] values = key.split(KEY_SEPARATOR, -1);
|
||||
if (values.length != 4) {
|
||||
throw new IllegalStateException("invalid persisted HOLD key");
|
||||
}
|
||||
return new HoldIdentity(values[0], values[1], values[2], values[3]);
|
||||
}
|
||||
|
||||
private String key() {
|
||||
return parentTaskId + KEY_SEPARATOR + vehicleId + KEY_SEPARATOR
|
||||
+ resourceKey + KEY_SEPARATOR + reasonCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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 java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public final class RecordingFt546CommandGateway {
|
||||
private final Ft546SrgvCommandEncoder encoder;
|
||||
private final Ft546DeviceProfile profile;
|
||||
private final Set<String> capabilities;
|
||||
private final List<Ft546EncodedCommand> recorded =
|
||||
new ArrayList<Ft546EncodedCommand>();
|
||||
|
||||
public RecordingFt546CommandGateway(Ft546SrgvCommandEncoder encoder,
|
||||
Ft546DeviceProfile profile,
|
||||
Set<String> capabilities) {
|
||||
this.encoder = Objects.requireNonNull(encoder, "encoder");
|
||||
this.profile = Objects.requireNonNull(profile, "profile");
|
||||
this.capabilities = Collections.unmodifiableSet(
|
||||
new LinkedHashSet<String>(Objects.requireNonNull(capabilities,
|
||||
"capabilities")));
|
||||
}
|
||||
|
||||
public synchronized Ft546EncodedCommand record(
|
||||
Ft546SrgvCommandIntent intent) {
|
||||
Ft546EncodedCommand encoded = preview(intent);
|
||||
recorded.add(encoded);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
public synchronized Ft546EncodedCommand preview(
|
||||
Ft546SrgvCommandIntent intent) {
|
||||
return encoder.encode(Objects.requireNonNull(intent, "intent"),
|
||||
profile, capabilities);
|
||||
}
|
||||
|
||||
public synchronized List<Ft546EncodedCommand> recordAll(
|
||||
List<Ft546SrgvCommandIntent> intents) {
|
||||
Objects.requireNonNull(intents, "intents");
|
||||
List<Ft546EncodedCommand> encoded =
|
||||
new ArrayList<Ft546EncodedCommand>(intents.size());
|
||||
for (Ft546SrgvCommandIntent intent : intents) {
|
||||
encoded.add(preview(intent));
|
||||
}
|
||||
recorded.addAll(encoded);
|
||||
return Collections.unmodifiableList(encoded);
|
||||
}
|
||||
|
||||
public synchronized List<Ft546EncodedCommand> getRecorded() {
|
||||
return Collections.unmodifiableList(
|
||||
new ArrayList<Ft546EncodedCommand>(recorded));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class ShadowCommandIntentFactory {
|
||||
|
||||
public Ft546SrgvCommandIntent create(GrantedDispatch grant) {
|
||||
Objects.requireNonNull(grant, "grant");
|
||||
Map<String, String> parameters = grant.getCommandParameters();
|
||||
String parentTaskId = required(parameters, "parentTaskId");
|
||||
String phase = required(parameters, "phase");
|
||||
long routeVersion = positiveLong(parameters, "routeVersion");
|
||||
int taskNo = positiveInt(parameters, "taskNo");
|
||||
short taskType = signedShort(parameters, "taskType");
|
||||
Integer stage = optionalStage(parameters.get("stage"));
|
||||
Short crossFloorTarget = crossFloorTarget(parameters, taskType, stage);
|
||||
List<Integer> path = path(grant);
|
||||
String commandKey = parentTaskId + ':' + phase + ':' + routeVersion
|
||||
+ ':' + requiredText(grant.getStepId(), "stepId");
|
||||
return new Ft546SrgvCommandIntent(
|
||||
requiredText(grant.getVehicleId(), "vehicleId"),
|
||||
parentTaskId, phase, commandKey, routeVersion, taskNo,
|
||||
taskType, path, crossFloorTarget, stage);
|
||||
}
|
||||
|
||||
private static List<Integer> path(GrantedDispatch grant) {
|
||||
List<Integer> points = new ArrayList<Integer>();
|
||||
for (String nodeId : grant.getProtectedRoute().getNodeIds()) {
|
||||
try {
|
||||
points.add(Integer.valueOf(requiredText(nodeId, "route node")));
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException(
|
||||
"route node must be a numeric FT546 site: " + nodeId,
|
||||
failure);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private static Short crossFloorTarget(Map<String, String> parameters,
|
||||
short taskType, Integer stage) {
|
||||
String value = parameters.get("crossFloorTarget");
|
||||
if (stage != null && value != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"crossFloorTarget is not valid for a two-stage phase");
|
||||
}
|
||||
if (taskType != 8) {
|
||||
if (value != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"crossFloorTarget is valid only when taskType is 8");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (stage != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"taskType 8 is not valid for a two-stage phase");
|
||||
}
|
||||
short target = positiveShort(parameters, "crossFloorTarget");
|
||||
return Short.valueOf(target);
|
||||
}
|
||||
|
||||
private static Integer optionalStage(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
int stage;
|
||||
try {
|
||||
stage = Integer.parseInt(requiredText(value, "stage"));
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException("stage must be 1 or 2", failure);
|
||||
}
|
||||
if (stage != 1 && stage != 2) {
|
||||
throw new IllegalArgumentException("stage must be 1 or 2");
|
||||
}
|
||||
return Integer.valueOf(stage);
|
||||
}
|
||||
|
||||
private static int positiveInt(Map<String, String> values, String key) {
|
||||
long value = positiveLong(values, key);
|
||||
if (value > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException(key + " exceeds int32");
|
||||
}
|
||||
return (int) value;
|
||||
}
|
||||
|
||||
private static long positiveLong(Map<String, String> values, String key) {
|
||||
String text = required(values, key);
|
||||
long value;
|
||||
try {
|
||||
value = Long.parseLong(text);
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException(key + " must be numeric", failure);
|
||||
}
|
||||
if (value <= 0L) {
|
||||
throw new IllegalArgumentException(key + " must be positive");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static short signedShort(Map<String, String> values, String key) {
|
||||
String text = required(values, key);
|
||||
try {
|
||||
return Short.parseShort(text);
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException(key + " must be int16", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static short positiveShort(Map<String, String> values, String key) {
|
||||
short value = signedShort(values, key);
|
||||
if (value <= 0) {
|
||||
throw new IllegalArgumentException(key + " must be positive");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String required(Map<String, String> values, String key) {
|
||||
return requiredText(values.get(key), key);
|
||||
}
|
||||
|
||||
private static String requiredText(String value, String field) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class ShadowCycleResult {
|
||||
private final String statusCode;
|
||||
private final String reasonCode;
|
||||
private final DispatchCycleResult decisions;
|
||||
private final List<Ft546SrgvCommandIntent> intents;
|
||||
private final List<Ft546EncodedCommand> encodedCommands;
|
||||
|
||||
private ShadowCycleResult(String statusCode, String reasonCode,
|
||||
DispatchCycleResult decisions,
|
||||
List<Ft546SrgvCommandIntent> intents,
|
||||
List<Ft546EncodedCommand> encodedCommands) {
|
||||
this.statusCode = statusCode;
|
||||
this.reasonCode = reasonCode;
|
||||
this.decisions = decisions;
|
||||
this.intents = immutable(intents);
|
||||
this.encodedCommands = immutable(encodedCommands);
|
||||
}
|
||||
|
||||
public static ShadowCycleResult insufficient(String reasonCode) {
|
||||
return new ShadowCycleResult("INSUFFICIENT_FACTS", reasonCode, null,
|
||||
Collections.<Ft546SrgvCommandIntent>emptyList(),
|
||||
Collections.<Ft546EncodedCommand>emptyList());
|
||||
}
|
||||
|
||||
public static ShadowCycleResult retry(DispatchCycleResult decisions) {
|
||||
return new ShadowCycleResult("RETRY_SNAPSHOT_CHANGED",
|
||||
"SHADOW_TRAFFIC_STATE_CHANGED", decisions,
|
||||
Collections.<Ft546SrgvCommandIntent>emptyList(),
|
||||
Collections.<Ft546EncodedCommand>emptyList());
|
||||
}
|
||||
|
||||
public static ShadowCycleResult comparable(
|
||||
DispatchCycleResult decisions,
|
||||
List<Ft546SrgvCommandIntent> intents,
|
||||
List<Ft546EncodedCommand> encodedCommands) {
|
||||
return new ShadowCycleResult("COMPARABLE", null, decisions, intents,
|
||||
encodedCommands);
|
||||
}
|
||||
|
||||
public String getStatusCode() { return statusCode; }
|
||||
public String getReasonCode() { return reasonCode; }
|
||||
public DispatchCycleResult getDecisions() { return decisions; }
|
||||
public List<Ft546SrgvCommandIntent> getIntents() { return intents; }
|
||||
public List<Ft546EncodedCommand> getEncodedCommands() {
|
||||
return encodedCommands;
|
||||
}
|
||||
|
||||
private static <T> List<T> immutable(List<T> source) {
|
||||
return Collections.unmodifiableList(new ArrayList<T>(source));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatch;
|
||||
import com.ruoyi.shadow.facts.ShadowMapFact;
|
||||
import com.ruoyi.shadow.facts.ShadowQueueFact;
|
||||
import com.ruoyi.shadow.facts.ShadowVehicleFact;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
|
||||
import com.ruoyi.wcs.domain.equipment.DeviceCapability;
|
||||
import com.ruoyi.wcs.domain.equipment.DeviceDescriptor;
|
||||
import com.ruoyi.wcs.domain.equipment.DeviceType;
|
||||
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
|
||||
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||
import com.ruoyi.wcs.domain.topology.ResourceDefinition;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyEdge;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyNode;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class ShadowDispatchSnapshotAssembler {
|
||||
private static final String ACTIVE_QUEUE =
|
||||
"shadow_ro.v_wcs_srgv_queue";
|
||||
|
||||
private final String warehouseCode;
|
||||
private final long topologyVersion;
|
||||
private final Function<EffectiveTopology, ShadowTrafficState> stateLoader;
|
||||
private final Clock clock;
|
||||
|
||||
public ShadowDispatchSnapshotAssembler(
|
||||
String warehouseCode,
|
||||
long topologyVersion,
|
||||
Function<EffectiveTopology, ShadowTrafficState> stateLoader,
|
||||
Clock clock) {
|
||||
this.warehouseCode = required(warehouseCode, "warehouseCode");
|
||||
if (topologyVersion <= 0L) {
|
||||
throw new IllegalArgumentException(
|
||||
"topologyVersion must be positive");
|
||||
}
|
||||
this.topologyVersion = topologyVersion;
|
||||
this.stateLoader = Objects.requireNonNull(stateLoader, "stateLoader");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
public DispatchSnapshot assemble(ShadowFactBatch batch) {
|
||||
Objects.requireNonNull(batch, "batch");
|
||||
EffectiveTopology topology = topology(batch.getMaps());
|
||||
ShadowTrafficState state = Objects.requireNonNull(
|
||||
stateLoader.apply(topology), "stateLoader result");
|
||||
return new DispatchSnapshot(tasks(batch, topology),
|
||||
vehicles(batch, topology), topology, state.getLedger(),
|
||||
state.getWaitForGraph(), state.getSnapshotVersion());
|
||||
}
|
||||
|
||||
private EffectiveTopology topology(List<ShadowMapFact> facts) {
|
||||
List<TopologyNode> nodes = new ArrayList<TopologyNode>();
|
||||
Map<String, ShadowMapFact> coordinates =
|
||||
new LinkedHashMap<String, ShadowMapFact>();
|
||||
for (ShadowMapFact fact : facts) {
|
||||
if (fact.getValue() != 1 && fact.getValue() != 2) {
|
||||
continue;
|
||||
}
|
||||
nodes.add(new TopologyNode(fact.getSiteId(), floor(fact.getZ()),
|
||||
fact.getX(), fact.getY(), 1, false, false));
|
||||
String coordinate = coordinate(fact.getX(), fact.getY(), fact.getZ());
|
||||
if (coordinates.put(coordinate, fact) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate passable map coordinate: " + coordinate);
|
||||
}
|
||||
}
|
||||
if (nodes.isEmpty()) {
|
||||
throw new IllegalArgumentException("map has no passable nodes");
|
||||
}
|
||||
List<TopologyEdge> edges = new ArrayList<TopologyEdge>();
|
||||
for (ShadowMapFact fact : coordinates.values()) {
|
||||
addEdge(edges, fact, coordinates.get(coordinate(
|
||||
fact.getX() + 1, fact.getY(), fact.getZ())));
|
||||
addEdge(edges, fact, coordinates.get(coordinate(
|
||||
fact.getX(), fact.getY() + 1, fact.getZ())));
|
||||
}
|
||||
TopologyDefinition definition = new TopologyDefinition(warehouseCode,
|
||||
topologyVersion, true, nodes, edges,
|
||||
Collections.<ResourceDefinition>emptyList(),
|
||||
Collections.<String>emptyList(),
|
||||
Collections.<String>emptyList());
|
||||
return new TopologyCompiler().compile(definition);
|
||||
}
|
||||
|
||||
private List<DispatchTask> tasks(ShadowFactBatch batch,
|
||||
EffectiveTopology topology) {
|
||||
List<DispatchTask> tasks = new ArrayList<DispatchTask>();
|
||||
Instant now = clock.instant();
|
||||
for (ShadowQueueFact fact : batch.getQueues()) {
|
||||
if (!ACTIVE_QUEUE.equals(fact.getTableName())) {
|
||||
continue;
|
||||
}
|
||||
String source = required(fact.getStartSite(), "startSite");
|
||||
String destination = required(fact.getEndSite(), "endSite");
|
||||
if (!topology.containsNode(source)
|
||||
|| !topology.containsNode(destination)) {
|
||||
throw new IllegalArgumentException(
|
||||
"active queue endpoint is outside passable topology: "
|
||||
+ fact.getGuid());
|
||||
}
|
||||
Map<String, String> command = commandParameters(fact);
|
||||
tasks.add(new DispatchTask(required(fact.getTaskNo(), "taskNo"),
|
||||
fact.getGuid(), source, destination, priority(fact),
|
||||
waitingAge(fact.getCreatedAt(), now), 31,
|
||||
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE), command));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private Map<String, String> commandParameters(ShadowQueueFact fact) {
|
||||
Map<String, String> values = new LinkedHashMap<String, String>();
|
||||
String parentTaskId = required(fact.getTaskNo(), "taskNo");
|
||||
values.put("parentTaskId", parentTaskId);
|
||||
values.put("phase", phase(required(fact.getTaskType(), "taskType")));
|
||||
values.put("routeVersion", Long.toString(topologyVersion));
|
||||
values.put("taskNo", positiveNumber(parentTaskId, "taskNo"));
|
||||
short taskType = parsedShort(required(fact.getDefault02(), "default02"),
|
||||
"default02");
|
||||
values.put("taskType", Short.toString(taskType));
|
||||
String stage = optional(fact.getDefault08());
|
||||
if (stage != null) {
|
||||
if (!"1".equals(stage) && !"2".equals(stage)) {
|
||||
throw new IllegalArgumentException("default08 must be 1 or 2");
|
||||
}
|
||||
values.put("stage", stage);
|
||||
}
|
||||
String target = optional(fact.getDefault07());
|
||||
if (taskType == 8) {
|
||||
if (target == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"default07 is required for cross-floor task");
|
||||
}
|
||||
values.put("crossFloorTarget", positiveNumber(target,
|
||||
"default07"));
|
||||
}
|
||||
if (stage != null && taskType == 8) {
|
||||
throw new IllegalArgumentException(
|
||||
"cross-floor and two-stage parameters cannot be combined");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<VehicleSnapshot> vehicles(ShadowFactBatch batch,
|
||||
EffectiveTopology topology) {
|
||||
List<VehicleSnapshot> result = new ArrayList<VehicleSnapshot>();
|
||||
for (ShadowVehicleFact fact : batch.getVehicles()) {
|
||||
String location = required(fact.getCurrentSite(), "currentSite");
|
||||
if (!topology.containsNode(location)) {
|
||||
throw new IllegalArgumentException(
|
||||
"vehicle is outside passable topology: "
|
||||
+ fact.getDeviceId());
|
||||
}
|
||||
String floor = fact.getCurrentFloor() == null
|
||||
? topology.node(location).getFloorCode()
|
||||
: floor(fact.getCurrentFloor().intValue());
|
||||
DeviceDescriptor descriptor = new DeviceDescriptor(
|
||||
fact.getDeviceId(), DeviceType.FOUR_WAY_SHUTTLE, floor,
|
||||
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE,
|
||||
DeviceCapability.LOAD_HANDLING,
|
||||
DeviceCapability.LIFT_TRANSFER));
|
||||
boolean connectedAndSendable = "1".equals(optional(
|
||||
fact.getAgvStatus()))
|
||||
&& Integer.valueOf(1).equals(fact.getAllowSend());
|
||||
boolean healthy = zeroOrMissing(fact.getAlarm())
|
||||
&& zeroOrMissing(fact.getErrorCode());
|
||||
boolean idle = Integer.valueOf(1).equals(fact.getStatus());
|
||||
result.add(new VehicleSnapshot(descriptor,
|
||||
connectedAndSendable
|
||||
? VehicleSnapshot.ConnectionState.ONLINE
|
||||
: VehicleSnapshot.ConnectionState.OFFLINE,
|
||||
Integer.valueOf(5).equals(fact.getWorkMode())
|
||||
? VehicleSnapshot.OperatingMode.AUTO
|
||||
: VehicleSnapshot.OperatingMode.MANUAL,
|
||||
healthy ? VehicleSnapshot.HealthState.NORMAL
|
||||
: VehicleSnapshot.HealthState.FAULT,
|
||||
idle ? VehicleSnapshot.OccupationState.IDLE
|
||||
: VehicleSnapshot.OccupationState.BUSY,
|
||||
location,
|
||||
Integer.valueOf(1).equals(fact.getCargoFlag())
|
||||
? "LOAD_PRESENT" : null,
|
||||
fact.getBattery().intValue(),
|
||||
positive(fact.getTaskNo())
|
||||
? Integer.toString(fact.getTaskNo()) : null,
|
||||
floor, fact.getCapturedAt()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void addEdge(List<TopologyEdge> edges,
|
||||
ShadowMapFact first,
|
||||
ShadowMapFact second) {
|
||||
if (second != null) {
|
||||
edges.add(new TopologyEdge(first.getSiteId(), second.getSiteId(),
|
||||
1, 1));
|
||||
}
|
||||
}
|
||||
|
||||
private static int priority(ShadowQueueFact fact) {
|
||||
if (fact.getPriority() == null) {
|
||||
return Integer.MIN_VALUE;
|
||||
}
|
||||
return (int) Math.max(Integer.MIN_VALUE,
|
||||
Math.min(Integer.MAX_VALUE, fact.getPriority().longValue()));
|
||||
}
|
||||
|
||||
private static long waitingAge(Instant createdAt, Instant now) {
|
||||
if (createdAt == null || createdAt.isAfter(now)) {
|
||||
throw new IllegalArgumentException(
|
||||
"active queue createdAt must not be missing or future");
|
||||
}
|
||||
return Duration.between(createdAt, now).getSeconds();
|
||||
}
|
||||
|
||||
private static String phase(String taskType) {
|
||||
if ("1".equals(taskType)) {
|
||||
return "EMPTY_TO_PICKUP";
|
||||
}
|
||||
if ("2".equals(taskType)) {
|
||||
return "LOADED_TO_PLACE";
|
||||
}
|
||||
return "LEGACY_PHASE_" + taskType;
|
||||
}
|
||||
|
||||
private static String floor(int value) {
|
||||
return "F" + value;
|
||||
}
|
||||
|
||||
private static String coordinate(int x, int y, int z) {
|
||||
return z + ":" + x + ":" + y;
|
||||
}
|
||||
|
||||
private static boolean zeroOrMissing(Integer value) {
|
||||
return value == null || value.intValue() == 0;
|
||||
}
|
||||
|
||||
private static boolean positive(Integer value) {
|
||||
return value != null && value.intValue() > 0;
|
||||
}
|
||||
|
||||
private static String positiveNumber(String value, String field) {
|
||||
long parsed;
|
||||
try {
|
||||
parsed = Long.parseLong(required(value, field));
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException(field + " must be numeric", failure);
|
||||
}
|
||||
if (parsed <= 0L) {
|
||||
throw new IllegalArgumentException(field + " must be positive");
|
||||
}
|
||||
return Long.toString(parsed);
|
||||
}
|
||||
|
||||
private static short parsedShort(String value, String field) {
|
||||
try {
|
||||
return Short.parseShort(value);
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException(field + " must be int16", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String optional(String value) {
|
||||
return value == null || value.trim().isEmpty() ? null : value.trim();
|
||||
}
|
||||
|
||||
private static String required(String value, String field) {
|
||||
String normalized = optional(value);
|
||||
if (normalized == null) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
|
||||
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class ShadowTrafficState {
|
||||
private final ResourceLedger ledger;
|
||||
private final WaitForGraph waitForGraph;
|
||||
private final long snapshotVersion;
|
||||
private final Map<String, Integer> consecutiveHoldCounts;
|
||||
|
||||
public ShadowTrafficState(ResourceLedger ledger,
|
||||
WaitForGraph waitForGraph,
|
||||
long snapshotVersion,
|
||||
Map<String, Integer> consecutiveHoldCounts) {
|
||||
if (snapshotVersion < 0L) {
|
||||
throw new IllegalArgumentException(
|
||||
"snapshotVersion must not be negative");
|
||||
}
|
||||
this.ledger = Objects.requireNonNull(ledger, "ledger").copy();
|
||||
this.waitForGraph = Objects.requireNonNull(waitForGraph,
|
||||
"waitForGraph").copy();
|
||||
this.snapshotVersion = snapshotVersion;
|
||||
this.consecutiveHoldCounts = Collections.unmodifiableMap(
|
||||
new LinkedHashMap<String, Integer>(Objects.requireNonNull(
|
||||
consecutiveHoldCounts, "consecutiveHoldCounts")));
|
||||
}
|
||||
|
||||
public ResourceLedger getLedger() {
|
||||
return ledger.copy();
|
||||
}
|
||||
|
||||
public WaitForGraph getWaitForGraph() {
|
||||
return waitForGraph.copy();
|
||||
}
|
||||
|
||||
public long getSnapshotVersion() {
|
||||
return snapshotVersion;
|
||||
}
|
||||
|
||||
public Map<String, Integer> getConsecutiveHoldCounts() {
|
||||
return consecutiveHoldCounts;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import com.ruoyi.project.shadowtest.ForbiddenFactoryBean;
|
||||
import com.ruoyi.shadow.startup.ShadowDatabaseGate;
|
||||
import com.ruoyi.shadow.startup.ShadowStartupReport;
|
||||
import com.ruoyi.shadow.web.ShadowStatusController;
|
||||
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
|
||||
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
|
||||
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
|
||||
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
|
||||
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
|
||||
@@ -63,6 +68,16 @@ class Ft546ShadowApplicationTest {
|
||||
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
|
||||
.getStartupChecks().get("EVIDENCE_DATABASE_VERIFIED"));
|
||||
assertNotNull(context.getBean(DispatchCoordinator.class));
|
||||
assertNotNull(context.getBean(ReadOnlyDispatchCycle.class));
|
||||
assertNotNull(context.getBean(Ft546ShadowCycle.class));
|
||||
assertNotNull(context.getBean(ShadowDispatchSnapshotAssembler.class));
|
||||
assertNotNull(context.getBean(RecordingFt546CommandGateway.class));
|
||||
assertNotNull(context.getBean(
|
||||
JdbcShadowTrafficStateRepository.class));
|
||||
assertEquals(context.getBean(
|
||||
JdbcShadowTrafficStateRepository.class),
|
||||
context.getBean(com.ruoyi.wcs.port.out
|
||||
.TrafficStateRepository.class));
|
||||
assertNotNull(context.getBean(ShadowStatusController.class));
|
||||
Map<String, String> persisted = context.getBean(
|
||||
RecordingShadowDatabaseGate.class).getPersistedChecks();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCommit;
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
import com.ruoyi.wcs.application.dispatch.HeldDispatch;
|
||||
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyEdge;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyNode;
|
||||
import com.ruoyi.wcs.domain.traffic.ReservationDecision;
|
||||
import com.ruoyi.wcs.domain.traffic.ReservationRequest;
|
||||
import com.ruoyi.wcs.domain.traffic.ReservationState;
|
||||
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlan;
|
||||
import com.ruoyi.wcs.domain.traffic.TrafficDecisionType;
|
||||
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcShadowTrafficStateRepositoryTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private TransactionTemplate transaction;
|
||||
private Clock clock;
|
||||
private EffectiveTopology topology;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
transaction = new TransactionTemplate(
|
||||
new DataSourceTransactionManager(dataSource));
|
||||
clock = Clock.fixed(Instant.parse("2026-08-14T06:00:00Z"),
|
||||
ZoneOffset.UTC);
|
||||
jdbc.execute("CREATE TABLE shadow_traffic_state ("
|
||||
+ "state_key nvarchar(256) PRIMARY KEY,"
|
||||
+ "snapshot_version bigint NOT NULL,"
|
||||
+ "state_json nvarchar(max) NOT NULL,"
|
||||
+ "updated_at timestamp NOT NULL)");
|
||||
jdbc.execute("CREATE TABLE shadow_hold_history ("
|
||||
+ "parent_task_id nvarchar(128) NOT NULL,"
|
||||
+ "vehicle_id nvarchar(64) NOT NULL,"
|
||||
+ "resource_key nvarchar(256) NOT NULL,"
|
||||
+ "reason_code nvarchar(512) NOT NULL,"
|
||||
+ "consecutive_count int NOT NULL,"
|
||||
+ "updated_at timestamp NOT NULL,"
|
||||
+ "PRIMARY KEY(parent_task_id,vehicle_id,resource_key,reason_code))");
|
||||
topology = topology();
|
||||
}
|
||||
|
||||
@Test
|
||||
void restartReloadPreservesReservationsWaitEdgesHoldsAndFencing() {
|
||||
JdbcShadowTrafficStateRepository first = repository();
|
||||
DispatchCommit initial = commit(0L, protectedLedger(), waitGraph(),
|
||||
held("CAPACITY_CONFLICT"));
|
||||
|
||||
assertTrue(first.commit(initial));
|
||||
|
||||
JdbcShadowTrafficStateRepository restarted = repository();
|
||||
ShadowTrafficState loaded = restarted.load(topology);
|
||||
assertEquals(1L, loaded.getSnapshotVersion());
|
||||
assertEquals(ReservationState.OCCUPIED,
|
||||
loaded.getLedger().reservation("RES-1").getState());
|
||||
assertTrue(loaded.getLedger().reservation("RES-1").isCommandUnknown());
|
||||
assertTrue(loaded.getLedger().reservation("RES-1")
|
||||
.isRecoveryUnresolved());
|
||||
assertEquals(Collections.singleton("VEH-2"),
|
||||
loaded.getWaitForGraph().holdersWaitedOnBy("VEH-1"));
|
||||
assertEquals(Collections.singletonList(Integer.valueOf(1)),
|
||||
Arrays.asList(loaded.getConsecutiveHoldCounts().values()
|
||||
.toArray(new Integer[0])));
|
||||
|
||||
assertTrue(restarted.commit(commit(1L, loaded.getLedger(),
|
||||
loaded.getWaitForGraph(), held("CAPACITY_CONFLICT"))));
|
||||
ShadowTrafficState twice = repository().load(topology);
|
||||
assertEquals(2L, twice.getSnapshotVersion());
|
||||
assertEquals(Collections.singletonList(Integer.valueOf(2)),
|
||||
Arrays.asList(twice.getConsecutiveHoldCounts().values()
|
||||
.toArray(new Integer[0])));
|
||||
|
||||
ReservationDecision next = twice.getLedger().reserve(
|
||||
new ReservationRequest("RES-2", "ORDER-2", "STEP-2",
|
||||
"VEH-2", Collections.singletonList("NODE:B")));
|
||||
assertTrue(next.isGranted());
|
||||
assertEquals(2L, next.getFencingToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleSnapshotVersionIsRejectedWithoutChangingStoredState() {
|
||||
JdbcShadowTrafficStateRepository repository = repository();
|
||||
DispatchCommit initial = commit(0L, protectedLedger(), waitGraph(),
|
||||
held("CAPACITY_CONFLICT"));
|
||||
assertTrue(repository.commit(initial));
|
||||
|
||||
assertFalse(repository.commit(initial));
|
||||
|
||||
assertEquals(1L, repository.load(topology).getSnapshotVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void holdWriteFailureRollsBackTrafficStateUpdate() {
|
||||
JdbcShadowTrafficStateRepository repository = repository();
|
||||
assertTrue(repository.commit(commit(0L, protectedLedger(), waitGraph(),
|
||||
held("CAPACITY_CONFLICT"))));
|
||||
String tooLong = String.join("", Collections.nCopies(600, "X"));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> repository.commit(
|
||||
commit(1L, repository.load(topology).getLedger(), waitGraph(),
|
||||
held(tooLong))));
|
||||
|
||||
assertEquals(1L, repository.load(topology).getSnapshotVersion());
|
||||
assertEquals(1, jdbc.queryForObject(
|
||||
"SELECT consecutive_count FROM shadow_hold_history",
|
||||
Integer.class).intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unencodableGrantedRouteNeverAdvancesTrafficState() {
|
||||
JdbcShadowTrafficStateRepository repository = repository();
|
||||
ListBuilder route = new ListBuilder();
|
||||
for (int index = 1; index <= 9; index++) {
|
||||
route.add(Integer.toString(70000 + index));
|
||||
}
|
||||
Map<String, String> parameters = new LinkedHashMap<String, String>();
|
||||
parameters.put("parentTaskId", "PARENT-1");
|
||||
parameters.put("phase", "LOADED_TO_PLACE");
|
||||
parameters.put("routeVersion", "3");
|
||||
parameters.put("taskNo", "30001");
|
||||
parameters.put("taskType", "1");
|
||||
GrantedDispatch invalid = new GrantedDispatch("PARENT-1", "STEP-1",
|
||||
"VEH-1", new RoutePlan(route.values,
|
||||
Collections.singletonList("NODE:A"), 8, 0, 7L),
|
||||
Collections.singletonList("RES-1"), 7L, 1L, parameters);
|
||||
DispatchCommit commit = new DispatchCommit("FT546", 7L, 0L,
|
||||
Collections.singletonList(invalid), Collections.emptyList(),
|
||||
Collections.emptyList(), Collections.emptyList(),
|
||||
new ResourceLedger(topology), new WaitForGraph());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> repository.commit(commit));
|
||||
|
||||
assertEquals(0L, repository.load(topology).getSnapshotVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void protocolEncoderRejectionNeverAdvancesTrafficState() {
|
||||
ShadowCommandIntentFactory intentFactory =
|
||||
new ShadowCommandIntentFactory();
|
||||
Ft546DeviceProfile missingPathRegister = new Ft546DeviceProfile();
|
||||
missingPathRegister.setRegisters(Collections.emptyMap());
|
||||
RecordingFt546CommandGateway rejectingGateway =
|
||||
new RecordingFt546CommandGateway(
|
||||
new Ft546SrgvCommandEncoder(), missingPathRegister,
|
||||
Collections.singleton("POINT_TO_POINT_COMMAND"));
|
||||
JdbcShadowTrafficStateRepository repository =
|
||||
new JdbcShadowTrafficStateRepository(jdbc, transaction,
|
||||
new ObjectMapper(), clock, intentFactory,
|
||||
rejectingGateway);
|
||||
Map<String, String> parameters = new LinkedHashMap<String, String>();
|
||||
parameters.put("parentTaskId", "PARENT-1");
|
||||
parameters.put("phase", "LOADED_TO_PLACE");
|
||||
parameters.put("routeVersion", "3");
|
||||
parameters.put("taskNo", "30001");
|
||||
parameters.put("taskType", "1");
|
||||
GrantedDispatch grant = new GrantedDispatch("PARENT-1", "STEP-1",
|
||||
"VEH-1", new RoutePlan(Arrays.asList("70001", "70002"),
|
||||
Collections.singletonList("NODE:A"), 1, 0, 7L),
|
||||
Collections.singletonList("RES-1"), 7L, 1L, parameters);
|
||||
DispatchCommit commit = new DispatchCommit("FT546", 7L, 0L,
|
||||
Collections.singletonList(grant), Collections.emptyList(),
|
||||
Collections.emptyList(), Collections.emptyList(),
|
||||
new ResourceLedger(topology), new WaitForGraph());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> repository.commit(commit));
|
||||
|
||||
assertEquals(0L, repository.load(topology).getSnapshotVersion());
|
||||
assertEquals(Collections.emptyList(), rejectingGateway.getRecorded());
|
||||
}
|
||||
|
||||
private JdbcShadowTrafficStateRepository repository() {
|
||||
ShadowCommandIntentFactory intentFactory =
|
||||
new ShadowCommandIntentFactory();
|
||||
return new JdbcShadowTrafficStateRepository(jdbc, transaction,
|
||||
new ObjectMapper(), clock, intentFactory,
|
||||
commandGateway());
|
||||
}
|
||||
|
||||
private static RecordingFt546CommandGateway commandGateway() {
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
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);
|
||||
return new RecordingFt546CommandGateway(
|
||||
new Ft546SrgvCommandEncoder(), profile,
|
||||
new LinkedHashSet<String>(Arrays.asList(
|
||||
"POINT_TO_POINT_COMMAND",
|
||||
"TWO_STAGE_ATOMIC_TASK")));
|
||||
}
|
||||
|
||||
private DispatchCommit commit(long snapshotVersion, ResourceLedger ledger,
|
||||
WaitForGraph graph, HeldDispatch held) {
|
||||
return new DispatchCommit("FT546", topology.version(), snapshotVersion,
|
||||
Collections.emptyList(), Collections.singletonList(held),
|
||||
Collections.emptyList(), Collections.emptyList(), ledger, graph);
|
||||
}
|
||||
|
||||
private ResourceLedger protectedLedger() {
|
||||
ResourceLedger ledger = new ResourceLedger(topology);
|
||||
ReservationDecision reserved = ledger.reserve(new ReservationRequest(
|
||||
"RES-1", "ORDER-1", "STEP-1", "VEH-1",
|
||||
Collections.singletonList("NODE:A")));
|
||||
ledger.occupy("RES-1", reserved.getFencingToken());
|
||||
ledger.markCommandUnknown("RES-1");
|
||||
ledger.markRecoveryUnresolved("RES-1");
|
||||
return ledger;
|
||||
}
|
||||
|
||||
private static WaitForGraph waitGraph() {
|
||||
WaitForGraph graph = new WaitForGraph();
|
||||
graph.addWait("VEH-1", "VEH-2");
|
||||
return graph;
|
||||
}
|
||||
|
||||
private static HeldDispatch held(String reason) {
|
||||
return new HeldDispatch("PARENT-1", "STEP-1", "VEH-1",
|
||||
TrafficDecisionType.HOLD, reason,
|
||||
new LinkedHashSet<String>(Collections.singleton("VEH-2")),
|
||||
null, null);
|
||||
}
|
||||
|
||||
private static EffectiveTopology topology() {
|
||||
return new TopologyCompiler().compile(new TopologyDefinition("FT546",
|
||||
7L, true,
|
||||
Arrays.asList(
|
||||
new TopologyNode("A", "F1", 0, 0, 1, false, false),
|
||||
new TopologyNode("B", "F1", 1, 0, 1, false, false)),
|
||||
Collections.singletonList(new TopologyEdge("A", "B", 1, 1)),
|
||||
Collections.emptyList(), Collections.emptyList(),
|
||||
Collections.emptyList()));
|
||||
}
|
||||
|
||||
private static final class ListBuilder {
|
||||
private final java.util.List<String> values =
|
||||
new java.util.ArrayList<String>();
|
||||
|
||||
private void add(String value) {
|
||||
values.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user