feat: adOutboundPlanner — turvurdering, prisregel, politikker, opsamlingsrunde (ren)
This commit is contained in:
@@ -0,0 +1,168 @@
|
|||||||
|
-- AD Outbound Planner
|
||||||
|
-- Ren turvurdering (ingen spil-API) for udkørsel: skal traktoren køre nu, med hvilke
|
||||||
|
-- varer, fra hvilke kilder, til silo eller salgssted?
|
||||||
|
--
|
||||||
|
-- input = {
|
||||||
|
-- capacity = liter (vognen), hasAutoload = bool (Universal Autoload på vognen),
|
||||||
|
-- sellAtPercent, storeAbovePercent, collectRound,
|
||||||
|
-- products = { { fillType, name, policy = "store"|"sell"|"sellElseStore", isPallet,
|
||||||
|
-- sources = { {wayPointId, available, level, capacity} ... }, -- kilder med varen
|
||||||
|
-- quote = {percent, price, wayPointId, stationName} | nil, -- bedste NÅELIGE salgssted
|
||||||
|
-- store = {wayPointId, name, free} | nil } ... } } -- silo med plads, nåelig
|
||||||
|
-- plan(input) -> trip | nil, reasons
|
||||||
|
-- trip = {kind = "sell"|"store", fillTypes, names, sourceIds, targetWayPointId, targetName, percent}
|
||||||
|
-- reasons = én linje pr. vare der ikke gav en tur ("SUGAR 78 % af top (kræver 90), lager 41 %")
|
||||||
|
|
||||||
|
ADOutboundPlanner = {}
|
||||||
|
|
||||||
|
-- Mindre end dette på lager regnes som intet.
|
||||||
|
ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1
|
||||||
|
-- Opsamlingsrunde (bulk): næste kilde besøges kun hvis mindst så stor en andel af vognen er fri.
|
||||||
|
ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1
|
||||||
|
|
||||||
|
local function wantsSell(policy)
|
||||||
|
return policy == "sell" or policy == "sellElseStore"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function wantsStore(policy)
|
||||||
|
return policy == "store" or policy == "sellElseStore"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Samlet tilgængeligt over kilderne og den højeste fyldningsgrad (%) blandt dem.
|
||||||
|
local function sumSources(product)
|
||||||
|
local total, percent = 0, 0
|
||||||
|
for _, source in ipairs(product.sources or {}) do
|
||||||
|
total = total + (source.available or 0)
|
||||||
|
if source.capacity ~= nil and source.capacity > 0 then
|
||||||
|
percent = math.max(percent, 100 * (source.level or 0) / source.capacity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return total, percent
|
||||||
|
end
|
||||||
|
|
||||||
|
function ADOutboundPlanner.evaluate(product, input)
|
||||||
|
local available, storagePercent = sumSources(product)
|
||||||
|
local percent = product.quote ~= nil and product.quote.percent or nil
|
||||||
|
return {
|
||||||
|
available = available,
|
||||||
|
fullLoad = input.capacity ~= nil and input.capacity > 0 and available >= input.capacity,
|
||||||
|
storagePercent = storagePercent,
|
||||||
|
storageHigh = storagePercent >= (input.storeAbovePercent or 80),
|
||||||
|
percent = percent,
|
||||||
|
priceOk = percent ~= nil and percent >= (input.sellAtPercent or 90),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function reasonFor(product, e, input)
|
||||||
|
if e.available < ADOutboundPlanner.MIN_AVAILABLE_LITERS then
|
||||||
|
return product.name .. ": intet på lager"
|
||||||
|
end
|
||||||
|
local canStore = wantsStore(product.policy) and product.store ~= nil
|
||||||
|
if wantsSell(product.policy) and product.quote == nil and not canStore then
|
||||||
|
return product.name .. ": intet nåeligt salgssted"
|
||||||
|
end
|
||||||
|
if wantsStore(product.policy) and product.store == nil and (e.fullLoad or e.storageHigh) and not e.priceOk then
|
||||||
|
return product.name .. ": ingen silo med plads"
|
||||||
|
end
|
||||||
|
local parts = {}
|
||||||
|
if wantsSell(product.policy) and e.percent ~= nil then
|
||||||
|
table.insert(parts, string.format("%d %% af top (kræver %d)", e.percent, input.sellAtPercent or 90))
|
||||||
|
end
|
||||||
|
table.insert(parts, string.format("lager %d %%", math.floor(e.storagePercent + 0.5)))
|
||||||
|
return product.name .. " " .. table.concat(parts, ", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Kandidater: {product, e, kind, targetWayPointId, targetName, percent}; salg og lager hver for sig.
|
||||||
|
local function collectCandidates(input)
|
||||||
|
local sell, store, reasons = {}, {}, {}
|
||||||
|
for _, product in ipairs(input.products or {}) do
|
||||||
|
local e = ADOutboundPlanner.evaluate(product, input)
|
||||||
|
local added = false
|
||||||
|
if e.available >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then
|
||||||
|
if wantsSell(product.policy) and e.priceOk and product.quote ~= nil then
|
||||||
|
table.insert(sell, {product = product, e = e, kind = "sell", targetWayPointId = product.quote.wayPointId, targetName = product.quote.stationName, percent = e.percent})
|
||||||
|
added = true
|
||||||
|
elseif wantsStore(product.policy) and (e.fullLoad or e.storageHigh) and product.store ~= nil then
|
||||||
|
table.insert(store, {product = product, e = e, kind = "store", targetWayPointId = product.store.wayPointId, targetName = product.store.name})
|
||||||
|
added = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not added then
|
||||||
|
table.insert(reasons, reasonFor(product, e, input))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(sell, function(a, b)
|
||||||
|
if a.e.percent ~= b.e.percent then return a.e.percent > b.e.percent end
|
||||||
|
if a.e.available ~= b.e.available then return a.e.available > b.e.available end
|
||||||
|
return a.product.name < b.product.name
|
||||||
|
end)
|
||||||
|
table.sort(store, function(a, b)
|
||||||
|
if a.e.storagePercent ~= b.e.storagePercent then return a.e.storagePercent > b.e.storagePercent end
|
||||||
|
if a.e.available ~= b.e.available then return a.e.available > b.e.available end
|
||||||
|
return a.product.name < b.product.name
|
||||||
|
end)
|
||||||
|
return sell, store, reasons
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Kilder med varen, mest først (rester ryddes med i samme tur ved runde).
|
||||||
|
local function sortedSources(product)
|
||||||
|
local sources = {}
|
||||||
|
for _, source in ipairs(product.sources or {}) do
|
||||||
|
if (source.available or 0) >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then
|
||||||
|
table.insert(sources, source)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(sources, function(a, b)
|
||||||
|
if a.available ~= b.available then return a.available > b.available end
|
||||||
|
return a.wayPointId < b.wayPointId
|
||||||
|
end)
|
||||||
|
return sources
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Runde med paller (UAL): alle kandidater af samme slags med samme mål på én tur.
|
||||||
|
-- Bulk: én vare, men fra alle dens kilder efter tur. Uden runde: én vare, én kilde.
|
||||||
|
local function buildTrip(primary, candidates, input)
|
||||||
|
local trip = {
|
||||||
|
kind = primary.kind,
|
||||||
|
fillTypes = {primary.product.fillType},
|
||||||
|
names = {primary.product.name},
|
||||||
|
sourceIds = {},
|
||||||
|
targetWayPointId = primary.targetWayPointId,
|
||||||
|
targetName = primary.targetName,
|
||||||
|
percent = primary.percent,
|
||||||
|
}
|
||||||
|
local members = {primary}
|
||||||
|
if input.collectRound and input.hasAutoload and primary.product.isPallet then
|
||||||
|
for _, candidate in ipairs(candidates) do
|
||||||
|
if candidate ~= primary and candidate.kind == primary.kind and candidate.targetWayPointId == primary.targetWayPointId and candidate.product.isPallet then
|
||||||
|
table.insert(members, candidate)
|
||||||
|
table.insert(trip.fillTypes, candidate.product.fillType)
|
||||||
|
table.insert(trip.names, candidate.product.name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local seen = {}
|
||||||
|
for _, member in ipairs(members) do
|
||||||
|
local sources = sortedSources(member.product)
|
||||||
|
local limit = input.collectRound and #sources or math.min(1, #sources)
|
||||||
|
for index = 1, limit do
|
||||||
|
local id = sources[index].wayPointId
|
||||||
|
if not seen[id] then
|
||||||
|
seen[id] = true
|
||||||
|
table.insert(trip.sourceIds, id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return trip
|
||||||
|
end
|
||||||
|
|
||||||
|
function ADOutboundPlanner.plan(input)
|
||||||
|
local sell, store, reasons = collectCandidates(input)
|
||||||
|
if sell[1] ~= nil then
|
||||||
|
return buildTrip(sell[1], sell, input), reasons
|
||||||
|
end
|
||||||
|
if store[1] ~= nil then
|
||||||
|
return buildTrip(store[1], store, input), reasons
|
||||||
|
end
|
||||||
|
return nil, reasons
|
||||||
|
end
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
-- Kør: luajit tests/test_adOutboundPlanner.lua (fra repo-roden)
|
||||||
|
-- Ren turvurdering for udkørsel: prisregel, tærskler, politikker, rangering, opsamlingsrunde.
|
||||||
|
|
||||||
|
dofile("FS25_ADSmartPickup/adOutboundPlanner.lua")
|
||||||
|
local P = ADOutboundPlanner
|
||||||
|
|
||||||
|
local failures = 0
|
||||||
|
local function check(name, actual, expected)
|
||||||
|
if actual == expected then
|
||||||
|
print("OK " .. name)
|
||||||
|
else
|
||||||
|
failures = failures + 1
|
||||||
|
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function join(list) return table.concat(list or {}, ",") end
|
||||||
|
|
||||||
|
local SUGAR, FORAGE, BASKET, CRATE, BROOM = 5, 6, 7, 8, 9
|
||||||
|
local function input(products, overrides)
|
||||||
|
local result = {capacity = 20000, hasAutoload = false, sellAtPercent = 90, storeAbovePercent = 80, collectRound = false, products = products}
|
||||||
|
for key, value in pairs(overrides or {}) do result[key] = value end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
local function product(name, fillType, policy, sources, quote, store, isPallet)
|
||||||
|
return {fillType = fillType, name = name, policy = policy, isPallet = isPallet == true, sources = sources, quote = quote, store = store}
|
||||||
|
end
|
||||||
|
local quoteS = {percent = 92, price = 480, wayPointId = 900, stationName = "Grain Elevator"}
|
||||||
|
local storeSilo = {wayPointId = 400, name = "silo 1", free = 500000}
|
||||||
|
|
||||||
|
-- P1: salg ved pris over tærskel, uanset mængde
|
||||||
|
local trip = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 5000, level = 5000, capacity = 180000}}, quoteS, nil)}))
|
||||||
|
check("P1 salg", trip.kind, "sell")
|
||||||
|
check("P1 mål", trip.targetWayPointId, 900)
|
||||||
|
check("P1 målnavn", trip.targetName, "Grain Elevator")
|
||||||
|
check("P1 kilde", join(trip.sourceIds), "1")
|
||||||
|
check("P1 vare", join(trip.fillTypes), "5")
|
||||||
|
check("P1 procent", trip.percent, 92)
|
||||||
|
|
||||||
|
-- P2: pris under tærskel, lager lavt -> ingen tur med begrundelse
|
||||||
|
local low = {percent = 78, price = 400, wayPointId = 900, stationName = "Grain Elevator"}
|
||||||
|
local trip2, reasons2 = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 74000, level = 74000, capacity = 180000}}, low, nil)}))
|
||||||
|
check("P2 ingen tur", trip2, nil)
|
||||||
|
check("P2 begrundelse", reasons2[1], "SUGAR 78 % af top (kræver 90), lager 41 %")
|
||||||
|
|
||||||
|
-- P3: sellElseStore, pris lav, lager 85 % -> lager
|
||||||
|
local trip3 = P.plan(input({product("SUGAR", SUGAR, "sellElseStore", {{wayPointId = 1, available = 153000, level = 153000, capacity = 180000}}, low, storeSilo)}))
|
||||||
|
check("P3 lager", trip3.kind, "store")
|
||||||
|
check("P3 silo", trip3.targetWayPointId, 400)
|
||||||
|
|
||||||
|
-- P4: store med fuldt læs, lager 20 %
|
||||||
|
local trip4 = P.plan(input({product("FORAGE", FORAGE, "store", {{wayPointId = 2, available = 700000, level = 700000, capacity = 3500000}}, nil, storeSilo)}))
|
||||||
|
check("P4 fuldt læs -> lager", trip4.kind, "store")
|
||||||
|
local trip4b = P.plan(input({product("FORAGE", FORAGE, "store", {{wayPointId = 2, available = 15000, level = 15000, capacity = 3500000}}, nil, storeSilo)}))
|
||||||
|
check("P4 for lidt og lager lavt -> nil", trip4b, nil)
|
||||||
|
|
||||||
|
-- P5: rangering: højeste pris-% først
|
||||||
|
local trip5 = P.plan(input({
|
||||||
|
product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 1000, level = 1000, capacity = 180000}}, {percent = 91, price = 1, wayPointId = 900, stationName = "A"}, nil),
|
||||||
|
product("FORAGE", FORAGE, "sell", {{wayPointId = 2, available = 1000, level = 1000, capacity = 3500000}}, {percent = 95, price = 1, wayPointId = 901, stationName = "B"}, nil),
|
||||||
|
}))
|
||||||
|
check("P5 højeste procent vinder", join(trip5.fillTypes), "6")
|
||||||
|
check("P5 uden runde kun én vare", #trip5.fillTypes, 1)
|
||||||
|
|
||||||
|
-- P6: intet nåeligt salgssted
|
||||||
|
local trip6, reasons6 = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 170000, level = 170000, capacity = 180000}}, nil, nil)}))
|
||||||
|
check("P6 ingen tur", trip6, nil)
|
||||||
|
check("P6 begrundelse", reasons6[1], "SUGAR: intet nåeligt salgssted")
|
||||||
|
|
||||||
|
-- P7: ingen silo med plads ved lagerbehov
|
||||||
|
local trip7, reasons7 = P.plan(input({product("FORAGE", FORAGE, "store", {{wayPointId = 2, available = 3000000, level = 3000000, capacity = 3500000}}, nil, nil)}))
|
||||||
|
check("P7 ingen tur", trip7, nil)
|
||||||
|
check("P7 begrundelse", reasons7[1], "FORAGE: ingen silo med plads")
|
||||||
|
|
||||||
|
-- P8: runde, bulk: samme vare fra flere kilder, mest først
|
||||||
|
local trip8 = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 3000, level = 3000, capacity = 180000}, {wayPointId = 3, available = 9000, level = 9000, capacity = 180000}}, quoteS, nil)}, {collectRound = true, capacity = 10000}))
|
||||||
|
check("P8 kilder mest først", join(trip8.sourceIds), "3,1")
|
||||||
|
check("P8 én vare", #trip8.fillTypes, 1)
|
||||||
|
|
||||||
|
-- P9: runde, UAL: alle pallevarer med samme mål
|
||||||
|
local sellS = function(pct) return {percent = pct, price = 1, wayPointId = 950, stationName = "Halm-salg"} end
|
||||||
|
local sellT = {percent = 96, price = 1, wayPointId = 951, stationName = "Andet"}
|
||||||
|
local pallets = {
|
||||||
|
product("STRAW_BASKET", BASKET, "sell", {{wayPointId = 5, available = 8000, level = 8, capacity = 4000}}, sellS(95), nil, true),
|
||||||
|
product("STRAW_CRATE", CRATE, "sell", {{wayPointId = 5, available = 2000, level = 2, capacity = 4000}}, sellS(93), nil, true),
|
||||||
|
product("STRAW_BROOM", BROOM, "sell", {{wayPointId = 6, available = 1000, level = 1, capacity = 4000}}, sellT, nil, true),
|
||||||
|
}
|
||||||
|
local trip9 = P.plan(input(pallets, {collectRound = true, hasAutoload = true}))
|
||||||
|
check("P9 højeste procent = kost alene (andet mål)", join(trip9.fillTypes), "9")
|
||||||
|
local trip9b = P.plan(input({pallets[1], pallets[2]}, {collectRound = true, hasAutoload = true}))
|
||||||
|
check("P9 kurve+kasser samme mål", join(trip9b.fillTypes), "7,8")
|
||||||
|
check("P9 navne", join(trip9b.names), "STRAW_BASKET,STRAW_CRATE")
|
||||||
|
check("P9 kilder unikke", join(trip9b.sourceIds), "5")
|
||||||
|
local trip9c = P.plan(input({pallets[1], pallets[2]}, {collectRound = true, hasAutoload = false}))
|
||||||
|
check("P9 uden UAL kun én pallevare", #trip9c.fillTypes, 1)
|
||||||
|
|
||||||
|
-- P10: uden runde kun én kilde (den med mest)
|
||||||
|
local trip10 = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 3000, level = 3000, capacity = 180000}, {wayPointId = 3, available = 9000, level = 9000, capacity = 180000}}, quoteS, nil)}))
|
||||||
|
check("P10 én kilde, den fyldeste", join(trip10.sourceIds), "3")
|
||||||
|
|
||||||
|
-- P11: intet på lager
|
||||||
|
local trip11, reasons11 = P.plan(input({product("SUGAR", SUGAR, "sell", {}, quoteS, nil)}))
|
||||||
|
check("P11 ingen tur", trip11, nil)
|
||||||
|
check("P11 begrundelse", reasons11[1], "SUGAR: intet på lager")
|
||||||
|
|
||||||
|
-- P12: evaluate
|
||||||
|
local e = P.evaluate(product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 150000, level = 150000, capacity = 180000}}, quoteS, nil), input({}))
|
||||||
|
check("P12 storagePercent", math.floor(e.storagePercent + 0.5), 83)
|
||||||
|
check("P12 storageHigh", e.storageHigh, true)
|
||||||
|
check("P12 fullLoad", e.fullLoad, true)
|
||||||
|
check("P12 priceOk", e.priceOk, true)
|
||||||
|
|
||||||
|
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
|
||||||
|
os.exit(failures == 0 and 0 or 1)
|
||||||
Reference in New Issue
Block a user