From fa3faba18b5119c97e809b5e9d887286f0bdad0b Mon Sep 17 00:00:00 2001 From: masterdraco Date: Wed, 23 Sep 2026 09:49:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20adOutboundPlanner=20=E2=80=94=20turvurd?= =?UTF-8?q?ering,=20prisregel,=20politikker,=20opsamlingsrunde=20(ren)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FS25_ADSmartPickup/adOutboundPlanner.lua | 168 +++++++++++++++++++++++ tests/test_adOutboundPlanner.lua | 113 +++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 FS25_ADSmartPickup/adOutboundPlanner.lua create mode 100644 tests/test_adOutboundPlanner.lua diff --git a/FS25_ADSmartPickup/adOutboundPlanner.lua b/FS25_ADSmartPickup/adOutboundPlanner.lua new file mode 100644 index 0000000..a198675 --- /dev/null +++ b/FS25_ADSmartPickup/adOutboundPlanner.lua @@ -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 diff --git a/tests/test_adOutboundPlanner.lua b/tests/test_adOutboundPlanner.lua new file mode 100644 index 0000000..647d484 --- /dev/null +++ b/tests/test_adOutboundPlanner.lua @@ -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)