package com.zy.asrs.planner; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.google.ortools.Loader; import com.google.ortools.sat.BoolVar; import com.google.ortools.sat.CircuitConstraint; import com.google.ortools.sat.CpModel; import com.google.ortools.sat.CpSolver; import com.google.ortools.sat.CpSolverStatus; import com.google.ortools.sat.CumulativeConstraint; import com.google.ortools.sat.IntervalVar; import com.google.ortools.sat.IntVar; import com.google.ortools.sat.LinearExpr; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @Component public class PlannerOrtoolsSolverService { private static volatile boolean loaded = false; public JSONObject solve(JSONObject req) { ensureLoaded(); JSONArray cranesIn = req.getJSONArray("cranes"); JSONArray tasksIn = req.getJSONArray("tasks"); JSONObject cfgIn = req.getJSONObject("config"); if (cranesIn == null || cranesIn.isEmpty()) { throw new IllegalArgumentException("cranes is empty"); } if (tasksIn == null || tasksIn.isEmpty()) { JSONObject out = new JSONObject(); out.put("schedule", new JSONArray()); out.put("objectiveValue", 0); return out; } List craneCodes = new ArrayList<>(); Map craneByCode = new HashMap<>(); for (int i = 0; i < cranesIn.size(); i++) { JSONObject c = cranesIn.getJSONObject(i); if (c == null) continue; String code = c.getString("code"); if (code == null || code.trim().isEmpty()) continue; craneCodes.add(code); craneByCode.put(code, c); } if (craneCodes.isEmpty()) { throw new IllegalArgumentException("cranes is empty"); } int outMinGapSec = optInt(cfgIn, "outMinGapSec", 0); int wMakespan = optInt(cfgIn, "wMakespan", 1000); int wPriorityEarlyFinish = optInt(cfgIn, "wPriorityEarlyFinish", 5); int wWaitTime = optInt(cfgIn, "wWaitTime", 1); int wOutTardiness = optInt(cfgIn, "wOutTardiness", 0); boolean enableOutDue = optBool(cfgIn, "enableOutDue", false); int outTaktSec = optInt(cfgIn, "outTaktSec", 20); double maxSolveSeconds = optDouble(cfgIn, "maxSolveSeconds", 5.0); int numSearchWorkers = optInt(cfgIn, "numSearchWorkers", 8); boolean logSearchProgress = optBool(cfgIn, "logSearchProgress", false); int horizonPaddingSec = optInt(cfgIn, "horizonPaddingSec", 3600); int conveyorStationTaskLimit = optInt(cfgIn, "conveyorStationTaskLimit", 0); int conveyorStationRunningTasks = optInt(cfgIn, "conveyorStationRunningTasks", 0); double conveyorDefaultSpeedMps = optDouble(req, "conveyorDefaultSpeedMps", 0.6); if (conveyorDefaultSpeedMps <= 0) conveyorDefaultSpeedMps = 0.6; List taskIds = new ArrayList<>(); Map taskById = new HashMap<>(); for (int i = 0; i < tasksIn.size(); i++) { JSONObject t = tasksIn.getJSONObject(i); if (t == null) continue; Integer taskId = t.getInteger("taskId"); if (taskId == null || taskId <= 0) continue; taskIds.add(taskId); taskById.put(taskId, t); } if (taskIds.isEmpty()) { JSONObject out = new JSONObject(); out.put("schedule", new JSONArray()); out.put("objectiveValue", 0); return out; } Map nodeByTaskId = new HashMap<>(); int nodeIdx = 1; for (Integer taskId : taskIds) { nodeByTaskId.put(taskId, nodeIdx); nodeIdx++; } Map fromX = new HashMap<>(); Map fromY = new HashMap<>(); Map toX = new HashMap<>(); Map toY = new HashMap<>(); double minX = Double.POSITIVE_INFINITY; double maxX = Double.NEGATIVE_INFINITY; double minY = Double.POSITIVE_INFINITY; double maxY = Double.NEGATIVE_INFINITY; for (Integer taskId : taskIds) { JSONObject t = taskById.get(taskId); JSONObject fp = t == null ? null : t.getJSONObject("fromPos"); JSONObject tp = t == null ? null : t.getJSONObject("toPos"); double fx = optDouble(fp, "x", 0); double fy = optDouble(fp, "y", 0); double tx = optDouble(tp, "x", 0); double ty = optDouble(tp, "y", 0); fromX.put(taskId, fx); fromY.put(taskId, fy); toX.put(taskId, tx); toY.put(taskId, ty); minX = Math.min(minX, Math.min(fx, tx)); maxX = Math.max(maxX, Math.max(fx, tx)); minY = Math.min(minY, Math.min(fy, ty)); maxY = Math.max(maxY, Math.max(fy, ty)); } if (!Double.isFinite(minX)) { minX = 0; maxX = 0; minY = 0; maxY = 0; } Map outConv = new HashMap<>(); Map baseDur = new HashMap<>(); int maxReady = 0; int setupBoundPerTask = 0; for (String cc : craneCodes) { JSONObject c = craneByCode.get(cc); int ready = optInt(c, "readySec", 0); if (ready > maxReady) maxReady = ready; int bound = craneMoveTimeSecForDelta(c, Math.abs(maxX - minX), Math.abs(maxY - minY)); setupBoundPerTask = Math.max(setupBoundPerTask, bound); } for (Integer taskId : taskIds) { JSONObject t = taskById.get(taskId); outConv.put(taskId, conveyorTimeSec(t, conveyorDefaultSpeedMps)); for (String cc : eligibleCodes(t, craneCodes, craneByCode)) { baseDur.put(durKey(taskId, cc), craneBaseExecTimeSec(craneByCode.get(cc), t)); } } int horizon = estimateHorizon(taskIds, taskById, craneCodes, baseDur, outConv, maxReady, horizonPaddingSec, craneByCode, setupBoundPerTask); CpModel m = new CpModel(); Map startVars = new HashMap<>(); Map endVars = new HashMap<>(); Map chosen = new HashMap<>(); Map> presenceByCrane = new HashMap<>(); Map> eligibleTasksByCrane = new HashMap<>(); for (String cc : craneCodes) { presenceByCrane.put(cc, new HashMap<>()); eligibleTasksByCrane.put(cc, new ArrayList<>()); } Map outEtaVars = new HashMap<>(); List convIntervals = new ArrayList<>(); for (Integer taskId : taskIds) { JSONObject t = taskById.get(taskId); IntVar s = m.newIntVar(0, horizon, "S_" + taskId); IntVar e = m.newIntVar(0, horizon, "E_" + taskId); startVars.put(taskId, s); endVars.put(taskId, e); List eligible = eligibleCodes(t, craneCodes, craneByCode); List presences = new ArrayList<>(); for (String cc : eligible) { BoolVar p = m.newBoolVar("P_" + taskId + "_" + cc); presences.add(p); chosen.put(chosenKey(taskId, cc), p); presenceByCrane.get(cc).put(taskId, p); eligibleTasksByCrane.get(cc).add(taskId); int ready = optInt(craneByCode.get(cc), "readySec", 0); m.addGreaterOrEqual(s, ready).onlyEnforceIf(p); } m.addExactlyOne(presences.toArray(new BoolVar[0])); String taskType = optStr(t, "taskType", ""); if ("OUT".equalsIgnoreCase(taskType)) { IntVar outEta = m.newIntVar(0, horizon, "OUT_" + taskId); outEtaVars.put(taskId, outEta); m.addEquality(outEta, LinearExpr.sum(new LinearExpr[]{ LinearExpr.term(e, 1), LinearExpr.constant(outConv.get(taskId)) })); int conv = outConv.get(taskId); IntVar sConv = m.newIntVar(0, horizon, "CS_" + taskId); IntVar eConv = m.newIntVar(0, horizon, "CE_" + taskId); m.addEquality(sConv, e); m.addEquality(eConv, LinearExpr.sum(new LinearExpr[]{ LinearExpr.term(sConv, 1), LinearExpr.constant(conv) })); IntervalVar itvConv = m.newIntervalVar(sConv, LinearExpr.constant(conv), eConv, "CITV_" + taskId); convIntervals.add(itvConv); } } for (String cc : craneCodes) { JSONObject crane = craneByCode.get(cc); int ready = optInt(crane, "readySec", 0); Map pMap = presenceByCrane.get(cc); List eligibleTasks = eligibleTasksByCrane.get(cc); CircuitConstraint circuit = m.addCircuit(); BoolVar loop0 = m.newBoolVar("LOOP0_" + cc); circuit.addArc(0, 0, loop0); for (Integer tid : taskIds) { int node = nodeByTaskId.get(tid); BoolVar p = pMap.get(tid); if (p != null) { circuit.addArc(node, node, p.not()); m.addImplication(p, loop0.not()); } else { circuit.addArc(node, node, m.trueLiteral()); } } for (Integer tid : eligibleTasks) { int nodeJ = nodeByTaskId.get(tid); BoolVar pJ = pMap.get(tid); BoolVar arc0j = m.newBoolVar("ARC_" + cc + "_0_" + nodeJ); circuit.addArc(0, nodeJ, arc0j); m.addImplication(arc0j, loop0.not()); m.addImplication(arc0j, pJ); int setup0j = craneSetupFromInitSec(crane, fromX.get(tid), fromY.get(tid)); int dJ = baseDur.get(durKey(tid, cc)); m.addGreaterOrEqual(startVars.get(tid), ready).onlyEnforceIf(arc0j); m.addGreaterOrEqual(endVars.get(tid), LinearExpr.sum(new LinearExpr[]{ LinearExpr.term(startVars.get(tid), 1), LinearExpr.constant(setup0j + dJ) })).onlyEnforceIf(arc0j); BoolVar arcj0 = m.newBoolVar("ARC_" + cc + "_" + nodeJ + "_0"); circuit.addArc(nodeJ, 0, arcj0); m.addImplication(arcj0, loop0.not()); m.addImplication(arcj0, pJ); } for (int i = 0; i < eligibleTasks.size(); i++) { int tidI = eligibleTasks.get(i); int nodeI = nodeByTaskId.get(tidI); BoolVar pI = pMap.get(tidI); for (int j = 0; j < eligibleTasks.size(); j++) { if (i == j) continue; int tidJ = eligibleTasks.get(j); int nodeJ = nodeByTaskId.get(tidJ); BoolVar pJ = pMap.get(tidJ); BoolVar arc = m.newBoolVar("ARC_" + cc + "_" + nodeI + "_" + nodeJ); circuit.addArc(nodeI, nodeJ, arc); m.addImplication(arc, pI); m.addImplication(arc, pJ); int setup = craneMoveTimeSec(crane, toX.get(tidI), toY.get(tidI), fromX.get(tidJ), fromY.get(tidJ)); int dJ = baseDur.get(durKey(tidJ, cc)); m.addGreaterOrEqual(startVars.get(tidJ), endVars.get(tidI)).onlyEnforceIf(arc); m.addGreaterOrEqual(endVars.get(tidJ), LinearExpr.sum(new LinearExpr[]{ LinearExpr.term(startVars.get(tidJ), 1), LinearExpr.constant(setup + dJ) })).onlyEnforceIf(arc); } } } if (conveyorStationTaskLimit > 0 && !convIntervals.isEmpty()) { int effCap = Math.max(0, conveyorStationTaskLimit - Math.max(0, conveyorStationRunningTasks)); if (effCap > 0) { CumulativeConstraint cumul = m.addCumulative(effCap); for (IntervalVar itv : convIntervals) { cumul.addDemand(itv, 1); } } } addOutboundOrderConstraints(m, taskIds, taskById, outEtaVars, outMinGapSec); IntVar makespan = m.newIntVar(0, horizon, "MAKESPAN"); List ends = new ArrayList<>(); for (Integer tid : taskIds) { ends.add(endVars.get(tid)); } m.addMaxEquality(makespan, ends.toArray(new IntVar[0])); List obj = new ArrayList<>(); obj.add(LinearExpr.term(makespan, wMakespan)); if (wPriorityEarlyFinish > 0) { for (Integer tid : taskIds) { int pri = optInt(taskById.get(tid), "priority", 5); if (pri < 0) pri = 0; long coeff = (long) wPriorityEarlyFinish * (long) pri; if (coeff != 0) { obj.add(LinearExpr.term(endVars.get(tid), coeff)); } } } if (wWaitTime > 0) { long now = System.currentTimeMillis(); for (Integer tid : taskIds) { long createTime = optLong(taskById.get(tid), "createTime", now); long waitSeconds = Math.max(0, (now - createTime) / 1000); long coeff = (long) wWaitTime * waitSeconds; // 等待时间越长,权重越大,越希望尽早完成 // 这里我们希望尽早完成等待时间长的任务,所以将完成时间乘以等待时间作为惩罚项 // 惩罚 = endVar * wWaitTime * waitSeconds if (coeff > 0) { obj.add(LinearExpr.term(endVars.get(tid), coeff)); } } } if (enableOutDue && wOutTardiness > 0) { IntVar tardSum = addOutboundDueTardiness(m, taskIds, taskById, outEtaVars, outTaktSec); obj.add(LinearExpr.term(tardSum, wOutTardiness)); } m.minimize(LinearExpr.sum(obj.toArray(new LinearExpr[0]))); CpSolver solver = new CpSolver(); solver.getParameters().setMaxTimeInSeconds(maxSolveSeconds); solver.getParameters().setNumSearchWorkers(numSearchWorkers); solver.getParameters().setLogSearchProgress(logSearchProgress); CpSolverStatus status = solver.solve(m); if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) { throw new IllegalStateException("No feasible solution"); } JSONArray schedule = new JSONArray(); for (Integer tid : taskIds) { JSONObject t = taskById.get(tid); int s = (int) solver.value(startVars.get(tid)); int e = (int) solver.value(endVars.get(tid)); String chosenCrane = null; for (String cc : eligibleCodes(t, craneCodes, craneByCode)) { BoolVar p = chosen.get(chosenKey(tid, cc)); if (p != null && solver.booleanValue(p)) { chosenCrane = cc; break; } } if (chosenCrane == null) { throw new IllegalStateException("internal error: task " + tid + " no crane chosen"); } int d = (int) (solver.value(endVars.get(tid)) - solver.value(startVars.get(tid))); JSONObject item = new JSONObject(); item.put("craneCode", chosenCrane); item.put("taskId", tid); item.put("taskType", optStr(t, "taskType", "")); item.put("startSec", s); item.put("endSec", e); item.put("durationSec", d); item.put("priority", optInt(t, "priority", 5)); if ("OUT".equalsIgnoreCase(optStr(t, "taskType", ""))) { IntVar outEta = outEtaVars.get(tid); if (outEta != null) { item.put("outEtaSec", (int) solver.value(outEta)); } } schedule.add(item); } schedule.sort(new Comparator() { @Override public int compare(Object o1, Object o2) { JSONObject a = (JSONObject) JSONObject.toJSON(o1); JSONObject b = (JSONObject) JSONObject.toJSON(o2); String ca = a.getString("craneCode"); String cb = b.getString("craneCode"); int cmp = (ca == null ? "" : ca).compareTo(cb == null ? "" : cb); if (cmp != 0) return cmp; return Integer.compare(a.getIntValue("startSec"), b.getIntValue("startSec")); } }); JSONObject out = new JSONObject(); out.put("schedule", schedule); out.put("objectiveValue", solver.objectiveValue()); return out; } private static void ensureLoaded() { if (loaded) return; synchronized (PlannerOrtoolsSolverService.class) { if (loaded) return; Loader.loadNativeLibraries(); loaded = true; } } private static String chosenKey(int taskId, String cc) { return taskId + "|" + cc; } private static String durKey(int taskId, String cc) { return taskId + "|" + cc; } private static int estimateHorizon(List taskIds, Map taskById, List craneCodes, Map dur, Map outConv, int maxReady, int padding, Map craneByCode, int setupBoundPerTask) { int total = 0; for (Integer tid : taskIds) { JSONObject t = taskById.get(tid); List elig = eligibleCodes(t, craneCodes, craneByCode); int best = Integer.MAX_VALUE; for (String cc : elig) { Integer d = dur.get(durKey(tid, cc)); if (d != null && d < best) best = d; } if (best != Integer.MAX_VALUE) total += best; total += Math.max(0, setupBoundPerTask); Integer conv = outConv.get(tid); if (conv != null) total += conv; } return total + maxReady + Math.max(0, padding); } private static List eligibleCodes(JSONObject task, List craneCodes, Map craneByCode) { JSONArray arr = task.getJSONArray("eligibleCranes"); if (arr == null || arr.isEmpty()) { return new ArrayList<>(craneCodes); } List out = new ArrayList<>(); for (int i = 0; i < arr.size(); i++) { String cc = arr.getString(i); if (cc != null && craneByCode.containsKey(cc)) { out.add(cc); } } if (out.isEmpty()) { throw new IllegalArgumentException("Task " + task.getInteger("taskId") + " has no eligible cranes after filtering"); } return out; } private static int craneBaseExecTimeSec(JSONObject crane, JSONObject task) { JSONObject fromPos = task.getJSONObject("fromPos"); JSONObject toPos = task.getJSONObject("toPos"); double fromX = optDouble(fromPos, "x", 0); double fromY = optDouble(fromPos, "y", 0); double toX = optDouble(toPos, "x", 0); double toY = optDouble(toPos, "y", 0); int move = craneMoveTimeSec(crane, fromX, fromY, toX, toY); int pick = optInt(crane, "pickSec", 0); int drop = optInt(crane, "dropSec", 0); return move + pick + drop; } private static int craneSetupFromInitSec(JSONObject crane, double toX, double toY) { JSONObject initPos = crane.getJSONObject("initPos"); if (initPos == null) { return 0; } double initX = optDouble(initPos, "x", Double.NaN); double initY = optDouble(initPos, "y", Double.NaN); if (Double.isNaN(initX) || Double.isNaN(initY)) { return 0; } return craneMoveTimeSec(crane, initX, initY, toX, toY); } private static int craneMoveTimeSecForDelta(JSONObject crane, double dx, double dy) { double vx = optDouble(crane, "vx", 1.0); double vy = optDouble(crane, "vy", 1.0); String moveMode = optStr(crane, "moveMode", "max"); double tx = vx <= 0 ? 0 : Math.abs(dx) / vx; double ty = vy <= 0 ? 0 : Math.abs(dy) / vy; double t = "sum".equalsIgnoreCase(moveMode) ? (tx + ty) : Math.max(tx, ty); return (int) Math.round(t); } private static int craneMoveTimeSec(JSONObject crane, double fromX, double fromY, double toX, double toY) { return craneMoveTimeSecForDelta(crane, toX - fromX, toY - fromY); } private static int conveyorTimeSec(JSONObject task, double defaultSpeedMps) { String taskType = optStr(task, "taskType", ""); if (!"OUT".equalsIgnoreCase(taskType)) { return 0; } JSONArray segs = task.getJSONArray("conveyorPath"); if (segs != null && !segs.isEmpty()) { double total = 0.0; for (int i = 0; i < segs.size(); i++) { JSONObject seg = segs.getJSONObject(i); if (seg == null) continue; double len = optDouble(seg, "length_m", 0); double sp = optDouble(seg, "speed_mps", 0); if (len > 0 && sp > 0) total += len / sp; } return (int) Math.round(total); } JSONArray nav = task.getJSONArray("navigatePath"); JSONArray edges = task.getJSONArray("pathEdges"); if (nav != null && nav.size() >= 2 && edges != null && !edges.isEmpty()) { Map edgeMap = new HashMap<>(); for (int i = 0; i < edges.size(); i++) { JSONObject e = edges.getJSONObject(i); if (e == null) continue; int u = e.getIntValue("fromId"); int v = e.getIntValue("toId"); double len = optDouble(e, "length_m", 0); Double sp = e.containsKey("speed_mps") ? e.getDouble("speed_mps") : null; edgeMap.put(u + "->" + v, new Edge(len, sp)); } double total = 0.0; for (int i = 0; i < nav.size() - 1; i++) { int u = nav.getIntValue(i); int v = nav.getIntValue(i + 1); Edge ed = edgeMap.get(u + "->" + v); if (ed == null) ed = edgeMap.get(v + "->" + u); if (ed == null) { continue; } double sp = ed.speedMps != null && ed.speedMps > 0 ? ed.speedMps : defaultSpeedMps; if (ed.lengthM > 0 && sp > 0) total += ed.lengthM / sp; } return (int) Math.round(total); } return 0; } private static void addOutboundOrderConstraints(CpModel m, List taskIds, Map taskById, Map outEtaVars, int minGap) { Map> groups = new HashMap<>(); for (Integer tid : taskIds) { JSONObject t = taskById.get(tid); if (!"OUT".equalsIgnoreCase(optStr(t, "taskType", ""))) continue; String g = t.getString("outGroup"); Integer seq = t.getInteger("outSeq"); if (g == null || seq == null) continue; groups.computeIfAbsent(g, k -> new ArrayList<>()).add(new GroupItem(seq, tid)); } for (Map.Entry> e : groups.entrySet()) { List items = e.getValue(); Collections.sort(items, new Comparator() { @Override public int compare(GroupItem a, GroupItem b) { return Integer.compare(a.seq, b.seq); } }); for (int i = 0; i < items.size() - 1; i++) { int aId = items.get(i).taskId; int bId = items.get(i + 1).taskId; IntVar aEta = outEtaVars.get(aId); IntVar bEta = outEtaVars.get(bId); if (aEta != null && bEta != null) { m.addGreaterOrEqual(bEta, LinearExpr.sum(new LinearExpr[]{ LinearExpr.term(aEta, 1), LinearExpr.constant(minGap) })); } } } } private static IntVar addOutboundDueTardiness(CpModel m, List taskIds, Map taskById, Map outEtaVars, int taktSec) { Map> groups = new HashMap<>(); for (Integer tid : taskIds) { JSONObject t = taskById.get(tid); if (!"OUT".equalsIgnoreCase(optStr(t, "taskType", ""))) continue; String g = t.getString("outGroup"); Integer seq = t.getInteger("outSeq"); if (g == null || seq == null) continue; if (!outEtaVars.containsKey(tid)) continue; groups.computeIfAbsent(g, k -> new ArrayList<>()).add(new GroupItem(seq, tid)); } List tardVars = new ArrayList<>(); for (Map.Entry> e : groups.entrySet()) { List items = e.getValue(); Collections.sort(items, new Comparator() { @Override public int compare(GroupItem a, GroupItem b) { return Integer.compare(a.seq, b.seq); } }); for (int idx = 0; idx < items.size(); idx++) { int tid = items.get(idx).taskId; IntVar outEta = outEtaVars.get(tid); int due = idx * taktSec; IntVar tard = m.newIntVar(0, 1_000_000_000, "TARD_" + e.getKey() + "_" + tid); m.addGreaterOrEqual(tard, LinearExpr.sum(new LinearExpr[]{ LinearExpr.term(outEta, 1), LinearExpr.constant(-due) })); m.addGreaterOrEqual(tard, 0); tardVars.add(tard); } } if (tardVars.isEmpty()) { IntVar z = m.newIntVar(0, 0, "TARD_SUM_ZERO"); m.addEquality(z, 0); return z; } IntVar s = m.newIntVar(0, 1_000_000_000, "TARD_SUM"); m.addEquality(s, LinearExpr.sum(tardVars.toArray(new IntVar[0]))); return s; } private static int optInt(JSONObject o, String k, int def) { if (o == null) return def; try { Integer v = o.getInteger(k); return v == null ? def : v; } catch (Exception e) { return def; } } private static double optDouble(JSONObject o, String k, double def) { if (o == null) return def; try { Double v = o.getDouble(k); return v == null ? def : v; } catch (Exception e) { return def; } } private static String optStr(JSONObject o, String k, String def) { if (o == null) return def; try { String v = o.getString(k); return v == null ? def : v; } catch (Exception e) { return def; } } private static boolean optBool(JSONObject o, String k, boolean def) { if (o == null) return def; try { Object v = o.get(k); if (v == null) return def; if (v instanceof Boolean) return (Boolean) v; String s = String.valueOf(v).trim().toUpperCase(); if ("Y".equals(s) || "TRUE".equals(s) || "1".equals(s)) return true; if ("N".equals(s) || "FALSE".equals(s) || "0".equals(s)) return false; return def; } catch (Exception e) { return def; } } private static double optDouble(JSONObject o, String k, Double def) { return optDouble(o, k, def == null ? 0.0 : def); } private static long optLong(JSONObject o, String k, long def) { if (o == null) return def; try { Long v = o.getLong(k); return v == null ? def : v; } catch (Exception e) { return def; } } private static final class Edge { final double lengthM; final Double speedMps; Edge(double lengthM, Double speedMps) { this.lengthM = lengthM; this.speedMps = speedMps; } } private static final class GroupItem { final int seq; final int taskId; GroupItem(int seq, int taskId) { this.seq = seq; this.taskId = taskId; } } }