Files

249 lines
11 KiB
Lua

-- 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/palleskur med plads, nåelig
-- reserve = liter | nil, reservedFor = {navne} | nil } ... } } -- gårdens eget behov (sælges ikke)
-- 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
-- Paller: en autoload-bil opgiver ingen liter-kapacitet, så "fuldt læs" = mindst så mange paller klar.
ADOutboundPlanner.PALLET_FULL_LOAD_COUNT = 4
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, pallets = 0, 0, 0
for _, source in ipairs(product.sources or {}) do
total = total + (source.available or 0)
pallets = pallets + (source.palletCount 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, pallets
end
-- Fodring: flowet har "Lager til" (et bestemt anlæg der bruger varen, fx gylle til en biogas-fermentor).
-- Så køres der så snart kilden har så meget — at vente på fuldt læs/80 % sulter anlægget.
ADOutboundPlanner.FEED_MIN_LITERS = 10000
function ADOutboundPlanner.evaluate(product, input)
local available, storagePercent, palletCount = sumSources(product)
local percent = product.quote ~= nil and product.quote.percent or nil
local reserve = math.max(0, product.reserve or 0)
local fullLoad
if input.capacity ~= nil and input.capacity > 0 then
fullLoad = available >= input.capacity
else
fullLoad = product.isPallet == true and palletCount >= ADOutboundPlanner.PALLET_FULL_LOAD_COUNT
end
return {
available = available,
reserve = reserve,
sellable = math.max(0, available - reserve),
palletCount = palletCount,
fullLoad = fullLoad,
storagePercent = storagePercent,
storageHigh = storagePercent >= (input.storeAbovePercent or 80),
percent = percent,
-- sælg fra 0 % = "Altid": uanset pris (også uden prisdata)
priceOk = input.sellAtPercent == 0 or (percent ~= nil and percent >= (input.sellAtPercent or 90)),
-- "sælg altid over X %": kilden er så fuld, at der sælges uanset pris
overflow = (input.sellAbovePercent or 0) > 0 and storagePercent >= input.sellAbovePercent,
}
end
local function formatLiters(liters)
if liters >= 1000000 then
return string.format("%.1fM", liters / 1000000)
elseif liters >= 1000 then
return string.format("%.1fk", liters / 1000)
end
return string.format("%d", liters)
end
-- Salg må ikke røre reserven: enten er intet reserveret, eller et helt læs kan tages uden om den
-- (AutoDrive læsser altid vognen fuld, så en delvis mængde kan ikke styres).
local function canSellWithoutReserve(e, input)
if e.reserve <= 0 then
return e.sellable >= ADOutboundPlanner.MIN_AVAILABLE_LITERS
end
local capacity = input.capacity or 0
return e.sellable >= ADOutboundPlanner.MIN_AVAILABLE_LITERS and (capacity <= 0 or e.sellable >= capacity)
end
local function reasonFor(product, e, input)
if e.available < ADOutboundPlanner.MIN_AVAILABLE_LITERS then
return product.name .. ": intet på lager"
end
if wantsSell(product.policy) and e.priceOk and product.quote ~= nil and not canSellWithoutReserve(e, input) then
local names = table.concat(product.reservedFor or {}, ", ")
return string.format("%s: %s reserveret til %s", product.name, formatLiters(e.reserve), names ~= "" and names or "gårdens eget forbrug")
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
if product.storeReason ~= nil then
return product.name .. ": " .. product.storeReason
end
return product.name .. ": intet lager med plads (silo eller egen fabrik der bruger varen)"
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 or e.overflow) and product.quote ~= nil and canSellWithoutReserve(e, input) 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 or (input.feedMode and e.available >= ADOutboundPlanner.FEED_MIN_LITERS)) 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
-- Kilder hvis læssestationer trækker fra samme lagre (fx mælketanke der er udvidelser inden for hinandens
-- storageRadius) er ét stop: læsser man ved én, tømmes de alle. Den første i rækkefølgen beholdes, de
-- øvrige lægges ind under den. storagesOf(source) -> liste af lager-objekter (nil = ukendt, beholdes).
-- Returnerer kept (rækkefølge bevaret) og merged {[beholdt wayPointId] = {sammenlagte wayPointIds}}.
function ADOutboundPlanner.dedupeSharedSources(sources, storagesOf)
local kept, merged, groups = {}, {}, {}
for _, source in ipairs(sources or {}) do
local storages = storagesOf(source)
local owner = nil
if storages ~= nil then
for _, group in ipairs(groups) do
for _, storage in ipairs(storages) do
if group.storages[storage] then owner = group break end
end
if owner ~= nil then break end
end
end
if owner ~= nil then
for _, storage in ipairs(storages) do owner.storages[storage] = true end
merged[owner.source.wayPointId] = merged[owner.source.wayPointId] or {}
table.insert(merged[owner.source.wayPointId], source.wayPointId)
else
table.insert(kept, source)
if storages ~= nil then
local set = {}
for _, storage in ipairs(storages) do set[storage] = true end
table.insert(groups, {source = source, storages = set})
end
end
end
return kept, merged
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