Tři sklenice, pět snídaní a víkend v Budějovicích¶
Vzor zápočtového projektu 01LIP pro společný průchod na cvičení. Každé ráno si dva lidé dají toast s domácím hummusem a klíčky. Mají tři nakličovací sklenice Eschenfelder, každou o objemu 750 ml. V pátek po snídani odjíždějí do Českých Budějovic, vracejí se v neděli večer. O víkendu nemá kdo klíčky promývat. Hotové klíčky ale mohou čekat v lednici.
Co chceme rozhodnout: co a kdy založit, kolik semen nasypat a jak sklizně rozdělit do pěti domácích snídaní. Chceme co nejméně práce, každý pracovní den dost klíčků a žádné vyhozené zbytky.
1. Zadání a model¶
Kolik toho sníme a jak dlouho budeme čekat?¶
Recept Sprouts & Hummus Sandwich obsahuje na jednu porci dva plátky chleba, dvě lžíce hummusu a asi 28,35 g klíčků. Zaokrouhlíme na 30 g klíčků na člověka: potřebujeme 60 g denně, tedy 300 g od pondělí do pátku. Chléb a hummus do modelu nevstupují. Druhy klíčků pro tuto ukázku nahrazujeme ve stejném hmotnostním poměru.
Doby vycházejí z návodu Eschenfelder. Pro plán zvolíme dolní hranice uvedených dob a namáčení přičteme zvlášť. Je to výslovná plánovací konvence; skutečné doby ověříme doma.
| Druh | Namáčení | Klíčení podle návodu | Doba rezervace sklenice v modelu |
|---|---|---|---|
| Čočka | 12 h | 2–3 dny | 2,5 dne |
| Hrách | 12 h | 2–3 dny | 2,5 dne |
| Mungo | 12 h | 4–10 dní | 4,5 dne |
| Brokolice | 8 h | 7–10 dní | 7,5 dne |
| Vojtěška | 8 h | 7–10 dní | 7,5 dne |
U posledních dvou druhů zaokrouhlujeme osm hodin na půlden pouze pro rezervaci sklenice. Není to pokyn namáčet je dvanáct hodin. Dlouhé druhy by potřebovaly víkendovou péči, takže je do plánu nepřipustíme.
Zjednodušení: z nejvýše 25 g suchých semen dostaneme nejvýše 120 g hotových klíčků, tedy výtěžnost 4,8 g/g. Menší množství semen dá úměrně menší sklizeň. Stejný odhad použijeme pro čočku, hrách i mungo.
Lednice: dovolíme skladovat každou sklizeň nejvýše 4 dny, tedy 96 h.
Jednoduchý týdenní režim¶
Dávky zakládáme večer, sklízíme ráno před snídaní. Péče je vždy ráno a večer, v modelu v 8:00 a 20:00. Klíčení nepřerušujeme. Hotovou sklizeň přendáme do samostatné krabičky v lednici; sklenici umyjeme. Lednice má dost místa a několik minut mytí při plánování zanedbáme.
Od neděle večer do pátku ráno máme 4,5 dne. Dvě nejrychlejší dávky za sebou potřebují 5 dní. V jedné sklenici tedy dokončíme nejvýše jednu dávku týdně. Nemusíme zavádět proměnnou pro každý okamžik obsazení: každá vybraná dávka prostě dostane svou vlastní sklenici. Večerní začátky a ranní sklizně jsou zjednodušením této ukázky.
import pulp
import matplotlib.pyplot as plt
# Základní údaje. Změny zadání později uděláme na stejném modelu.
pocet_sklenic = 3
denni_spotreba = 60 # gramy klíčků pro oba dohromady
nejvetsi_davka = 120 # gramy hotových klíčků z jedné sklenice
vynos = 4.8 # gramy klíčků z jednoho gramu semen
trvanlivost = 4 # dny od sklizně
dny = ["Po", "Út", "St", "Čt", "Pá", "So", "Ne"]
pracovni_dny = range(5) # pondělí má číslo 0, pátek číslo 4
# Stejné barvy použijeme ve všech grafech druhů.
barvy = {"Čočka": "#9b641e", "Hrách": "#526a36", "Mungo": "#335c81"}
plt.rcParams.update({"font.size": 12, "font.family": "DejaVu Sans",
"axes.spines.top": False, "axes.spines.right": False})
print("PuLP:", pulp.__version__, "| řešič: CBC")
PuLP: 3.3.2 | řešič: CBC
Obrázek 1: co se vejde mezi návrat a odjezd?¶
Svislá čára ukazuje dostupných 4,5 dne. Doby v obrázku jsou zvolené plánovací hodnoty z tabulky, včetně namáčení.
druhy = ["Čočka", "Hrách", "Mungo", "Brokolice", "Vojtěška"]
doby = [2.5, 2.5, 4.5, 7.5, 7.5]
fig, ax = plt.subplots(figsize=(10, 3.6), layout="constrained")
ax.barh(druhy, doby, color=[barvy.get(druh, "#aaaaaa") for druh in druhy])
ax.axvline(4.5, color="#333333", linestyle="--", label="Doma: 4,5 dne")
for radek, doba in enumerate(doby):
ax.text(doba + 0.12, radek, f"{doba:g} dne".replace(".", ","), va="center")
ax.invert_yaxis()
ax.set_xlim(0, 9)
ax.set_xlabel("Namáčení a klíčení [dny]")
ax.set_title("Které druhy zvládneme bez víkendového promývání?")
ax.legend(loc="upper right", frameon=False)
plt.show()
Sedm možností, ze kterých budeme vybírat¶
Začátek i sklizeň zapíšeme číslem dne: pondělí ráno je 0, středa ráno 2. Neděle večer před tímto pondělím je −0,5, pondělí večer 0,5. Čočka a hrách mají po třech možnostech, mungo jedinou. U každé možnosti smíme použít i dvě nebo tři sklenice.
Do péče počítáme založení, následné slití a promývání až do sklizně. Rychlá dávka má 6 zásahů: Ne večer, Po ráno/večer, Út ráno/večer a St ráno. Mungo má 10 zásahů. Počítáme zásahy do sklenic, nikoli návštěvy kuchyně nebo minuty práce.
# Každý řádek je jeden možný druh a termín dávky.
davky = [
{"druh": "Čočka", "zacatek": -0.5, "sklizen": 2, "pece": 6},
{"druh": "Čočka", "zacatek": 0.5, "sklizen": 3, "pece": 6},
{"druh": "Čočka", "zacatek": 1.5, "sklizen": 4, "pece": 6},
{"druh": "Hrách", "zacatek": -0.5, "sklizen": 2, "pece": 6},
{"druh": "Hrách", "zacatek": 0.5, "sklizen": 3, "pece": 6},
{"druh": "Hrách", "zacatek": 1.5, "sklizen": 4, "pece": 6},
{"druh": "Mungo", "zacatek": -0.5, "sklizen": 4, "pece": 10},
]
cisla_davek = range(len(davky))
# Pro každou dvojici dávka–snídaně spočítáme stáří sklizně.
stari = {}
for b in cisla_davek:
sklizen = davky[b]["sklizen"]
for den in pracovni_dny:
if den >= sklizen:
stari[b, den] = den - sklizen
else:
# Pondělí či úterý čerpá ze sklizně MINULÉHO týdne.
stari[b, den] = den + 7 - sklizen
Proměnné a úplný model¶
Pro každou možnost $b$ zavedeme:
- $z_b$: počet sklenic, ve kterých tuto dávku založíme; nezáporné celé číslo. Sklenici nelze rozdělit na poloviny.
- $u_{bd}$: gramy klíčků z této týdenní sklizně určené na snídani $d$; nezáporné spojité číslo.
Hodnoty $u_{bd}$ si představme jako tabulku: řádek $b$ je druh s termínem sklizně, sloupec $d$ je den snídaně. Čísla jsou porce v gramech. Tento příklad vybírá tři možnosti, každou v jedné sklenici ($z_b=1$); ostatní možnosti mají nulové hodnoty.
| Možnost $b$ (den sklizně) | Po | Út | St | Čt | Pá | Celá sklizeň [g] |
|---|---|---|---|---|---|---|
| 0: Čočka (St) | 0 | 0 | 60 | 60 | 0 | 120 |
| 2: Čočka (Pá) | 0 | 0 | 0 | 0 | 60 | 60 |
| 5: Hrách (Pá) | 60 | 60 | 0 | 0 | 0 | 120 |
| Celkem na snídani [g] | 60 | 60 | 60 | 60 | 60 | 300 |
Součet řádku je celá sklizeň a nesmí překročit $120z_b$; součet sloupce musí být 60 g. Porce starší než čtyři dny mají hodnotu nula. Sklenic smí být dohromady nejvýše tři. Pondělní a úterní hrách v tabulce pochází z minulého pátku.
Celá sklizeň dané možnosti je $m_b=\sum_d u_{bd}$ gramů. Vyrobíme přesně toto množství, tedy použijeme $m_b/4{,}8$ gramů semen. Pokud má možnost více sklenic, rozdělíme semena i porce rovnoměrně mezi ně. Každá tak obsahuje jeden druh a nejvýše 25 g semen. Celá sklizeň je přiřazena ke snídaním; nic dalšího nepěstujeme ani nevyhazujeme.
Označme $B=\{0,\ldots,6\}$, $D=\{0,\ldots,4\}$, $c_b$ počet zásahů a $a_{bd}$ stáří sklizně ve dnech. Model má jedinou účelovou funkci:
$$\begin{aligned} \min\quad &\sum_{b\in B} c_b z_b &&\text{co nejméně péče},\\ \sum_{b\in B} z_b &\le 3 &&\text{nejvýše tři sklenice},\\ \sum_{d\in D}u_{bd} &\le 120z_b &&\text{kapacita sklizně }(b\in B),\\ \sum_{b\in B}u_{bd} &=60 &&\text{každá snídaně }(d\in D),\\ u_{bd}&=0 &&\text{příliš staré klíčky }(a_{bd}>4),\\ z_b&\in\mathbb Z_{\ge0},\quad u_{bd}\ge0. \end{aligned}$$
Jde o smíšené celočíselné programování (MILP): počty sklenic jsou celočíselné, hmotnosti spojité. Plán se bude opakovat každý týden. Stáří počítáme po jednotlivých sklizních, takže páteční klíčky nelze přejmenovat na čerstvé pondělní.
2. Výpočet¶
Potřebujeme Python, PuLP a Matplotlib. V terminálu je lze nainstalovat
příkazem python -m pip install pulp==3.3.2 matplotlib jupyterlab.
V Jupyteru spouštíme buňky shora dolů; data jsou zde, nic nestahujeme.
Použijeme CBC s běžným nastavením bez časového limitu; při přípravě
tohoto notebooku šlo o PuLP 3.3.2 a CBC 2.10.3.
Vytvoříme proměnné a účelovou funkci¶
model = pulp.LpProblem("Plan_klicku", pulp.LpMinimize)
# Celá čísla: kolik sklenic dostane každá možnost.
pocet = pulp.LpVariable.dicts("pocet_sklenic", cisla_davek,
lowBound=0, cat="Integer")
# Spojité hmotnosti: kolik z každé sklizně sníme při jednotlivých snídaních.
snedeno = pulp.LpVariable.dicts("snedeno_g", (cisla_davek, pracovni_dny),
lowBound=0)
# lpSum je součet výrazů obsahujících rozhodovací proměnné.
model += pulp.lpSum(davky[b]["pece"] * pocet[b] for b in cisla_davek)
Přidáme omezení¶
První řádek odpovídá počtu sklenic. Další cykly přidají kapacitu jednotlivých sklizní, pět přesných snídaní a zákaz starých klíčků.
# V každé sklenici smí být nejvýše jedna dávka týdně.
model += pulp.lpSum(pocet[b] for b in cisla_davek) <= pocet_sklenic, "sklenice"
for b in cisla_davek:
# Celá sklizeň musí být snědena a nesmí přesáhnout kapacitu sklenic.
model += pulp.lpSum(snedeno[b][den] for den in pracovni_dny) <= (
nejvetsi_davka * pocet[b])
for den in pracovni_dny:
# Každá domácí snídaně má právě 60 g, ani více, ani méně.
model += pulp.lpSum(snedeno[b][den] for b in cisla_davek) == denni_spotreba
for b in cisla_davek:
for den in pracovni_dny:
# Příliš stará sklizeň nesmí do této snídaně přispět.
if stari[b, den] > trvanlivost:
model += snedeno[b][den] == 0
Spustíme řešič a přečteme doporučení¶
Stav Optimal znamená nalezené optimum. Stav Infeasible by znamenal,
že podmínky nelze splnit současně; z takového výpočtu nepřebíráme plán.
U našich základních dat řešič najde optimum. Jeho hodnoty uložíme do
obyčejných seznamů, aby je pozdější změna zadání nepřepsala.
resic = pulp.PULP_CBC_CMD(msg=False) # nevypisujeme podrobný protokol řešiče
model.solve(resic)
print("Stav výpočtu:", pulp.LpStatus[model.status])
# Bez optima nemáme plán, který by šlo dále číst a kreslit.
if model.status != pulp.LpStatusOptimal:
raise RuntimeError("Nejprve upravte zadání tak, aby měl model optimální řešení.")
# value() přečte vypočtenou hodnotu proměnné.
pocty = [pocet[b].value() for b in cisla_davek]
porce = [[snedeno[b][den].value() for den in pracovni_dny] for b in cisla_davek]
sklizne = [sum(porce[b]) for b in cisla_davek]
# Každé dávce přiřadíme vlastní sklenici. Více stejných dávek rozdělíme stejně.
plan = []
zacatky = {-0.5: "Ne večer před týdnem", 0.5: "Po večer", 1.5: "Út večer"}
for b in cisla_davek:
if pocty[b] > 0:
for kopie in range(round(pocty[b])):
hmotnost = sklizne[b] / pocty[b]
plan.append({"b": b, "hmotnost": hmotnost})
print(f"Sklenice {len(plan)}: {davky[b]['druh']}, "
f"{zacatky[davky[b]['zacatek']]} → {dny[davky[b]['sklizen']]} ráno; "
f"{hmotnost / vynos:.2f} g semen → {hmotnost:.0f} g klíčků.")
print("Péče za týden:", sum(davky[b]["pece"] * pocty[b] for b in cisla_davek), "zásahů.")
Stav výpočtu: Optimal Sklenice 1: Čočka, Ne večer před týdnem → St ráno; 25.00 g semen → 120 g klíčků. Sklenice 2: Čočka, Út večer → Pá ráno; 12.50 g semen → 60 g klíčků. Sklenice 3: Hrách, Út večer → Pá ráno; 25.00 g semen → 120 g klíčků. Péče za týden: 18.0 zásahů.
Obrázek 2: co je v každé sklenici?¶
Pruh začíná založením dávky a končí sklizní. Značka označuje okamžik, kdy klíčky přendáme do lednice. Před pátečním odjezdem jsou sklenice prázdné.
fig, ax = plt.subplots(figsize=(11, 3.8), layout="constrained")
# Víkend začíná po páteční snídani a končí v neděli večer.
ax.axvspan(4.04, 6.5, color="#eeeeee")
ax.text(5.25, 0.12, "Budějovice\nbez promývání", ha="center", va="center", color="#555555")
for radek, sklenice in enumerate(plan):
davka = davky[sklenice["b"]]
zacatek = davka["zacatek"]
konec = davka["sklizen"]
ax.barh(radek, konec - zacatek, left=zacatek, height=0.6,
color=barvy[davka["druh"]])
ax.plot(konec, radek, "o", color="#222222")
ax.text((zacatek + konec) / 2, radek,
f"{davka['druh']} · {sklenice['hmotnost']:.0f} g",
ha="center", va="center", color="white", fontweight="bold")
ax.set_yticks(range(len(plan)), [f"Sklenice {j + 1}" for j in range(len(plan))])
ax.set_xticks([-0.5, 0, 1, 2, 3, 4, 5, 6, 6.5],
["Ne\nvečer", "Po\nráno", "Út\nráno", "St\nráno", "Čt\nráno",
"Pá\nráno", "So\nráno", "Ne\nráno", "Ne\nvečer"])
ax.set_xlim(-0.7, 6.7)
ax.invert_yaxis()
ax.set_title("Namáčení a klíčení: každá dávka má vlastní sklenici")
plt.show()
Obrázek 3: odkud je každá snídaně?¶
Pod pondělím a úterým je vidět stáří klíčků z minulého týdne. Horní sloupce ukazují přesnou porci pro oba; spodní průměrné stáří porce. Šrafování rozlišuje termín sklizně téhož druhu. Pokud se při jedné snídani smíchají dvě hotové sklizně, stáří vážíme jejich hmotnostmi. Druhy se při pěstování v jedné sklenici nemíchají.
fig, (ax, ax_stari) = plt.subplots(2, 1, figsize=(10, 6), layout="constrained")
spodek = [0] * 5
for b in cisla_davek:
if pocty[b] > 0:
popisek = f"{davky[b]['druh']} · sklizeň {dny[davky[b]['sklizen']]}"
ax.bar(list(pracovni_dny), porce[b], bottom=spodek,
color=barvy[davky[b]["druh"]], label=popisek,
hatch={2: "", 3: "..", 4: "//"}[davky[b]["sklizen"]],
edgecolor="white", linewidth=1.5)
spodek = [spodek[den] + porce[b][den] for den in pracovni_dny]
prumerne_stari = []
for den in pracovni_dny:
soucet = sum(stari[b, den] * porce[b][den] for b in cisla_davek)
prumerne_stari.append(soucet / denni_spotreba)
ax.set_ylim(0, 90)
ax.set_ylabel("Klíčky pro oba [g]")
ax.set_xticks(list(pracovni_dny), dny[:5])
ax.set_title("Pět snídaní, každá přesně 60 g")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, 1.0), ncol=2, frameon=False)
ax_stari.bar(list(pracovni_dny), prumerne_stari, color="#335c81", width=0.6)
for den in pracovni_dny:
ax_stari.text(den, prumerne_stari[den] + 0.1,
f"{prumerne_stari[den]:g} d", ha="center")
ax_stari.set_xticks(list(pracovni_dny), dny[:5])
ax_stari.set_ylim(0, 4.9)
ax_stari.set_ylabel("Průměrné stáří [dny]")
ax_stari.set_title("Pondělní a úterní zásoba vznikla před víkendem")
plt.show()
Obrázek 4: zásoby v lednici včetně víkendu¶
Nejdříve uložíme ranní sklizeň, potom připravíme snídani. Světlý sloupec ukazuje zásobu před snídaní, tmavý po snídani. O víkendu ze zásob nic nejíme. První pondělní zásobu odvodíme z předchozí týdenní sklizně, nikoli z libovolně doplněné krabičky.
# Kolik klíčků sklidíme v jednotlivých dnech týdne?
ranni_sklizne = [0] * 7
for b in cisla_davek:
ranni_sklizne[davky[b]["sklizen"]] += sklizne[b]
# Porce před dnem sklizně jsou ve skutečnosti z předchozího týdne.
zasoba = sum(porce[b][den] for b in cisla_davek for den in pracovni_dny
if den < davky[b]["sklizen"])
print(f"Na začátku pondělí máme z minulého týdne {zasoba:.0f} g.")
pred_snidani = []
po_snidani = []
for den in range(8): # jeden týden a ještě následující pondělí
den_v_tydnu = den % 7
zasoba += ranni_sklizne[den_v_tydnu]
pred_snidani.append(zasoba)
if den_v_tydnu < 5: # sobotu a neděli doma nesnídáme
zasoba -= sum(porce[b][den_v_tydnu] for b in cisla_davek)
po_snidani.append(zasoba)
fig, ax = plt.subplots(figsize=(11, 4), layout="constrained")
ax.bar([den - 0.18 for den in range(8)], pred_snidani, width=0.36,
color="#d8e1eb", edgecolor="#335c81", label="Před snídaní (po sklizni)")
ax.bar([den + 0.18 for den in range(8)], po_snidani, width=0.36,
color="#335c81", label="Po snídani")
for den in range(8):
ax.text(den - 0.18, pred_snidani[den] + 3, f"{pred_snidani[den]:.0f}", ha="center")
ax.text(den + 0.18, po_snidani[den] + 3, f"{po_snidani[den]:.0f}", ha="center")
ax.set_xticks(range(8), dny + ["Po\npříští týden"])
ax.set_ylim(0, max(pred_snidani) + 65)
ax.set_ylabel("Hotové klíčky v lednici [g]")
ax.set_title("Lednice drží zásobu přes víkend až do dalšího pondělí")
ax.legend(loc="upper center", ncol=2, frameon=False)
plt.show()
Na začátku pondělí máme z minulého týdne 120 g.
3. Kontrola a interpretace¶
Kontrolu uděláme dosazením a společným čtením výsledku. Níže znovu sečteme původní hodnoty: sklenice, sklizně, snídaně a účelovou funkci. Pro hmotnosti přijmeme odchylku nejvýše 0,01 g, pro celočíselné počty nejvýše $10^{-6}$.
print("Použité sklenice:", pocty, "| celkem:", sum(pocty), "z povolených", pocet_sklenic)
print("Sklizeno:", sum(sklizne), "g | snědeno:", sum(sum(radek) for radek in porce), "g")
print("Péče přepočtená z dávek:", sum(davky[b]["pece"] * pocty[b] for b in cisla_davek))
for den in pracovni_dny:
print(f"{dny[den]}: {sum(porce[b][den] for b in cisla_davek):.2f} g z požadovaných 60 g")
for b in cisla_davek:
if pocty[b] > 0:
print(f"{davky[b]['druh']}, sklizeň {dny[davky[b]['sklizen']]}: "
f"{sklizne[b]:.2f} g z kapacity {nejvetsi_davka * pocty[b]:.2f} g")
for den in pracovni_dny:
if porce[b][den] > 0:
print(f" {dny[den]}: {porce[b][den]:.2f} g, stáří {stari[b, den]} dní")
Použité sklenice: [1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0] | celkem: 3.0 z povolených 3 Sklizeno: 300.0 g | snědeno: 300.0 g Péče přepočtená z dávek: 18.0 Po: 60.00 g z požadovaných 60 g Út: 60.00 g z požadovaných 60 g St: 60.00 g z požadovaných 60 g Čt: 60.00 g z požadovaných 60 g Pá: 60.00 g z požadovaných 60 g Čočka, sklizeň St: 120.00 g z kapacity 120.00 g St: 60.00 g, stáří 0 dní Čt: 60.00 g, stáří 1 dní Čočka, sklizeň Pá: 60.00 g z kapacity 120.00 g Pá: 60.00 g, stáří 0 dní Hrách, sklizeň Pá: 120.00 g z kapacity 120.00 g Po: 60.00 g, stáří 3 dní Út: 60.00 g, stáří 4 dní
Výpis dokládá celé nezáporné počty sklenic, dodržené kapacity, všech pět porcí po 60 g a spotřebu každé sklizně nejvýše do čtyř dnů. Nevybrané možnosti mají nulovou sklizeň. U vybraných možností rozdělíme semena i porce do jejich sklenic rovným dílem.
Je to optimum? Dvě sklenice dají nejvýše 240 g, proto potřebujeme alespoň tři dávky. Každá vyžaduje alespoň 6 zásahů včetně založení. Dolní mez je tedy $3\cdot6=18$. Náš plán ji dosáhl.
Proč chybí mungo? Trvá déle a má více zásahů. Když chceme určitou chuť nebo pestrost, museli bychom to přidat do zadání. Čočka a hrách mají v této ukázce stejná data; řešič mezi nimi nemá důvod rozlišovat. Existuje více stejně dobrých plánů a při jiném řešiči může vyjít jiný.
Proč jsou pondělní zásoby skutečné? Každý týden znovu vypěstujeme a ponecháme zásobu na příští týden. Pokud začínáme úplně poprvé v neděli večer, pro první pondělí a úterý potřebujeme 120 g z vnějšího zdroje, nebo začneme tento režim hodnotit až po prvním výrobním týdnu.
Nulový odpad je výsledek modelu s pevnými dobami a výnosy. Neověřuje mikrobiologickou nezávadnost; promývání ani vzhled nejsou zárukou bezpečnosti (FDA: klíčky, FDA: skladování). Pro domácí použití nejprve změříme hmotnosti, dobu růstu a ověříme skladování. Zvlášť úterní porce využívá celý limit 96 h.
LP relaxace: co kdyby šlo použít část sklenice?¶
V LP relaxaci odstraníme požadavek celočíselnosti: místo $z_b\in\mathbb Z_{\ge0}$ dovolíme $z_b\ge0$. Ostatní podmínky i účelová funkce zůstanou stejné. Přípustných řešení tak může přibýt. Protože minimalizujeme, optimum LP je dolní mezí optima MILP.
Volba mip=False řekne CBC, aby v tomto výpočtu ignoroval celočíselnost.
Nemusíme sestavovat nový model. Původní celočíselný plán máme uložený
v seznamech pocty a porce.
# Vyřešíme stejný model bez požadavku celočíselnosti počtů sklenic.
model.solve(pulp.PULP_CBC_CMD(msg=False, mip=False))
print("Stav LP relaxace:", pulp.LpStatus[model.status])
if model.status == pulp.LpStatusOptimal:
pocty_lp = [pocet[b].value() for b in cisla_davek]
# Porce a sklizně LP uložíme pro nový graf zásob.
porce_lp = [[snedeno[b][den].value() for den in pracovni_dny] for b in cisla_davek]
sklizne_lp = [sum(porce_lp[b]) for b in cisla_davek]
pece_lp = pulp.value(model.objective)
print("Počet sklenic v LP:", sum(pocty_lp))
print("Hodnota účelové funkce LP:", pece_lp)
for b in cisla_davek:
if pocty_lp[b] > 0:
hmotnost = sklizne_lp[b]
print(f"{davky[b]['druh']}, sklizeň {dny[davky[b]['sklizen']]}: "
f"počet sklenic {pocty_lp[b]:g}, sklizeň {hmotnost:g} g.")
Stav LP relaxace: Optimal Počet sklenic v LP: 2.5 Hodnota účelové funkce LP: 15.0 Čočka, sklizeň St: počet sklenic 0.5, sklizeň 60 g. Čočka, sklizeň Pá: počet sklenic 0.5, sklizeň 60 g. Hrách, sklizeň St: počet sklenic 1, sklizeň 120 g. Hrách, sklizeň Pá: počet sklenic 0.5, sklizeň 60 g.
LP relaxace našla 2,5 sklenice a hodnotu účelové funkce 15, zatímco MILP potřebuje 3 sklenice a 18 zásahů týdně. Rozdíl mezi optimy je 3. Zlomkový počet sklenic není proveditelným domácím plánem; hodnota 15 slouží jako dolní mez potřebné péče.
Výsledek ověříme krátkým výpočtem: ze součtu kapacit plyne $300\le120\sum_b z_b$, tedy $\sum_b z_b\ge2{,}5$. Každá možnost má cenu alespoň 6, takže cíl je alespoň $6\cdot2{,}5=15$. Tato mez je dosažitelná v LP: 1,5 rychlé středeční dávky dá 180 g pro středu, čtvrtek a pátek; jedna páteční dávka dá 120 g na příští pondělí a úterý. Všechny porce mají nejvýše čtyři dny.
Obrázek 5: LP relaxace a skutečné sklenice¶
Levý panel srovnává počty sklenic, pravý hodnoty účelové funkce. Čísla pro LP popisují matematickou relaxaci, čísla pro MILP skutečný plán.
# Celočíselný výsledek přečteme z uloženého plánu, nikoli z proměnných LP.
pece_milp = sum(davky[b]["pece"] * pocty[b] for b in cisla_davek)
srovnani = ["LP relaxace", "MILP"]
fig, (ax_pocet, ax_pece) = plt.subplots(1, 2, figsize=(10, 3.6), layout="constrained")
for ax, hodnoty, nadpis in [
(ax_pocet, [sum(pocty_lp), sum(pocty)], "Počet sklenic v modelu"),
(ax_pece, [pece_lp, pece_milp], "Účelová funkce: zásahy týdně"),
]:
ax.bar(srovnani, hodnoty, color=["#9b641e", "#335c81"], width=0.6)
ax.set_ylim(0, max(hodnoty) * 1.25)
ax.set_title(nadpis)
for sloupec, hodnota in enumerate(hodnoty):
ax.text(sloupec, hodnota + max(hodnoty) * 0.04,
f"{hodnota:g}".replace(".", ","), ha="center")
plt.show()
Obrázek 6: zásoby v lednici pro LP relaxaci¶
Průběh spočítáme ze sklizní a porcí nalezeného LP řešení. Stejně jako u MILP ukazuje světlý sloupec zásobu po sklizni před snídaní, tmavý po snídani. Posledním dnem je opět následující pondělí.
# Ranní sklizně tentokrát čerpáme z LP řešení.
ranni_sklizne_lp = [0] * 7
for b in cisla_davek:
ranni_sklizne_lp[davky[b]["sklizen"]] += sklizne_lp[b]
# Zásobu na první pondělí tvoří porce z předchozího týdne.
zasoba_lp = sum(porce_lp[b][den] for b in cisla_davek for den in pracovni_dny
if den < davky[b]["sklizen"])
pred_snidani_lp = []
po_snidani_lp = []
for den in range(8):
den_v_tydnu = den % 7
zasoba_lp += ranni_sklizne_lp[den_v_tydnu]
pred_snidani_lp.append(zasoba_lp)
if den_v_tydnu < 5: # o víkendu není domácí snídaně
zasoba_lp -= sum(porce_lp[b][den_v_tydnu] for b in cisla_davek)
po_snidani_lp.append(zasoba_lp)
fig, ax = plt.subplots(figsize=(11, 4), layout="constrained")
ax.bar([den - 0.18 for den in range(8)], pred_snidani_lp, width=0.36,
color="#d8e1eb", edgecolor="#335c81", label="Před snídaní (po sklizni)")
ax.bar([den + 0.18 for den in range(8)], po_snidani_lp, width=0.36,
color="#335c81", label="Po snídani")
for den in range(8):
ax.text(den - 0.18, pred_snidani_lp[den] + 3, f"{pred_snidani_lp[den]:.0f}", ha="center")
ax.text(den + 0.18, po_snidani_lp[den] + 3, f"{po_snidani_lp[den]:.0f}", ha="center")
ax.set_xticks(range(8), dny + ["Po\npříští týden"])
ax.set_ylim(0, max(pred_snidani_lp) + 65)
ax.set_ylabel("Hotové klíčky v lednici [g]")
ax.set_title("LP relaxace: zásoba klíčků během týdne a přes víkend")
ax.legend(loc="upper center", ncol=2, frameon=False)
plt.show()
4. Jedna změna zadání¶
Co když stejná dávka semen dá o 20 % méně klíčků? Kapacita jedné sklenice klesne ze 120 g na 96 g. Přidáme tuto přísnější mez a vyřešíme stejný model znovu.
mensi_davka = 0.8 * nejvetsi_davka
for b in cisla_davek:
# Přidáváme přísnější omezení; původní mez 120 g tím není potřeba mazat.
model += pulp.lpSum(snedeno[b][den] for den in pracovni_dny) <= (
mensi_davka * pocet[b])
# Původní řešič resic znovu vyžaduje celočíselné počty sklenic.
model.solve(resic)
print("Výnos −20 %, tři sklenice:", pulp.LpStatus[model.status])
# Nepřípustný výpočet nemá použitelný plán, proto jeho proměnné nevypisujeme.
Výnos −20 %, tři sklenice: Infeasible
Stav Infeasible je zde očekávaný: $3\cdot96=288<300$ g.
Pomůže čtvrtá sklenice? Změníme jen pravou stranu omezení
pojmenovaného sklenice. Menší výnos zůstává součástí modelu.
model.constraints["sklenice"].changeRHS(4)
model.solve(resic)
print("Výnos −20 %, čtyři sklenice:", pulp.LpStatus[model.status])
# Hodnoty čteme jen tehdy, když jsme opravdu našli optimum.
if model.status == pulp.LpStatusOptimal:
print("Péče:", pulp.value(model.objective), "zásahů týdně.")
for b in cisla_davek:
if pocet[b].value() > 0:
hmotnost = sum(snedeno[b][den].value() for den in pracovni_dny)
print(f"{davky[b]['druh']}, sklizeň {dny[davky[b]['sklizen']]}: "
f"počet sklenic {pocet[b].value():g}, celkem {hmotnost:.0f} g klíčků.")
Výnos −20 %, čtyři sklenice: Optimal Péče: 24.0 zásahů týdně. Čočka, sklizeň St: počet sklenic 1, celkem 84 g klíčků. Čočka, sklizeň Pá: počet sklenic 1, celkem 96 g klíčků. Hrách, sklizeň St: počet sklenic 1, celkem 96 g klíčků. Hrách, sklizeň Pá: počet sklenic 1, celkem 24 g klíčků.
Obrázek 7: proč další sklenice pomohla?¶
Sloupce jsou horní meze výroby, nikoli skutečně vyrobené množství. Přerušovaná čára je spotřeba 300 g. Samotný dostatek kapacity nestačí; právě nový výpočet potvrdil i termíny a trvanlivost.
varianty = ["3 sklenice\n120 g na dávku", "3 sklenice\n96 g na dávku", "4 sklenice\n96 g na dávku"]
kapacity = [3 * nejvetsi_davka, 3 * mensi_davka, 4 * mensi_davka]
fig, ax = plt.subplots(figsize=(10, 4), layout="constrained")
ax.bar(varianty, kapacity, color=["#335c81", "#9b641e", "#335c81"], width=0.6)
ax.axhline(5 * denni_spotreba, color="#333333", linestyle="--", label="Snídaně: 300 g týdně")
for radek, kapacita in enumerate(kapacity):
ax.text(radek, kapacita - 25, f"{kapacita:.0f} g", ha="center", color="white")
ax.set_ylim(0, 470)
ax.set_ylabel("Největší možná týdenní sklizeň [g]")
ax.set_title("Menší výnos: tři sklenice nestačí, čtvrtá obnoví přípustnost")
ax.legend(frameon=False)
plt.show()
| Varianta | Stav | Péče včetně založení |
|---|---|---|
| 3 sklenice, nejvýše 120 g z dávky | Optimální řešení | 18 zásahů týdně |
| 3 sklenice, nejvýše 96 g z dávky | Nepřípustná úloha | — |
| 4 sklenice, nejvýše 96 g z dávky | Optimální řešení | 24 zásahů týdně |
Při menším výnosu potřebujeme alespoň čtyři dávky. Nalezené řešení se čtyřmi rychlými dávkami dosáhlo dolní meze $4\cdot6=24$ zásahů. Další sklenice tedy pomáhá s kapacitou, ale přidává péči.