Vai al contenuto

Copertura del segnale con interferenza

Classe: BIP · Legami: se e solo se (soglia + interferenza) · Script: python/fam08_3_copertura.py

Difficoltà: ★★★★☆ · Tempo: 30–45 min

Apri in Colab

Problema 8.3

Un operatore sceglie al più \(k \in \mathbb{Z}_{\ge 1}\) sedi, fra \(m \in \mathbb{Z}_{\ge 1}\) candidate, per servire \(n \in \mathbb{Z}_{\ge 1}\) clienti. \(s_{lc} \in \mathbb{Q}_{\ge 0}\) è il segnale ricevuto dal cliente \(c\) se \(l\) è installata. Un cliente è coperto se e solo se il segnale totale è almeno \(t \in \mathbb{Q}_{>0}\) e al più una sede genera per lui un segnale \(\ge b \in \mathbb{Q}_{>0}\). \(p_c \in \mathbb{Q}_{>0}\) è il profitto se coperto. Si vuole massimizzare il profitto totale.

Il problema a parole. Decidiamo quali sedi installare (al più \(k\)). L'obiettivo: profitto totale massimo. I vincoli: un cliente è coperto se e solo se riceve segnale sufficiente e non troppa interferenza; al più \(k\) sedi installate.

Modello

Dati. \(m\), \(n\), \(s_{lc} \in \mathbb{Q}_{\ge 0}\), \(p_c \in \mathbb{Q}_{>0}\), soglia \(t\), limite di interferenza \(b\), budget \(k\). Per ogni cliente \(c\): \(\mathscr{L}_c = \{l : s_{lc} \ge b\}\).

Variabili decisionali. \(m\) binarie \(x_l\) (sede installata), \(n\) binarie \(y_c\) (cliente coperto).

\[ \begin{aligned} \max ~~ \sum_{c=1}^{n} p_c\, y_c & & \\ \text{soggetto a} \quad -\sum_{l=1}^{m} s_{lc}\, x_l + t\, y_c &\le 0, & \forall c \in \{1, 2, \dots, n\}, \\ \sum_{l \in \mathscr{L}_c} x_l + (m-1)\, y_c &\le m, & \forall c \in \{1, 2, \dots, n\}, \\ \sum_{l=1}^{m} x_l &\le k, & & \\ x_l &\in \{0, 1\}, & \forall l \in \{1, 2, \dots, m\}, \\ y_c &\in \{0, 1\}, & \forall c \in \{1, 2, \dots, n\}. \end{aligned} \]
  • l'obiettivo massimizza il profitto totale;
  • il primo vincolo lega copertura e segnale ricevuto (\(n\) vincoli);
  • il secondo lega copertura e interferenza (\(n\) vincoli);
  • il terzo limita a \(k\) le sedi installate (un vincolo).

Il legame: un se e solo se. Un verso — \(y_c=1 \Rightarrow\) segnale \(\ge t\) e al più una sede forte — è imposto direttamente dai due vincoli. L'altro verso — se entrambe le condizioni valgono, il cliente è coperto — non è imposto dai vincoli (che ammettono anche \(y_c=0\)), ma segue dall'ottimalità: poiché \(p_c>0\) e \(y_c\) compare solo in questi due vincoli, alzarla a \(1\) resta ammissibile e aumenta l'obiettivo. Lo stesso schema del problema 7.6.

Il modello in gurobipy

mod = gp.Model("copertura_interferenza")
x = mod.addVars(m, vtype=GRB.BINARY, name="x")
y = mod.addVars(n, vtype=GRB.BINARY, name="y")
mod.setObjective(gp.quicksum(p[c] * y[c] for c in range(n)), GRB.MAXIMIZE)
mod.addConstrs((-gp.quicksum(s[l][c] * x[l] for l in range(m)) + t * y[c] <= 0
                for c in range(n)), name="soglia")
mod.addConstrs((gp.quicksum(x[l] for l in L[c]) + (m - 1) * y[c] <= m
                for c in range(n)), name="interferenza")
mod.addConstr(x.sum() <= k, name="budget")

L'istanza

\(m=3\), \(n=5\), \(t=5\), \(b=4\), \(k=2\):

\(s_{lc}\) \(c=1\) \(c=2\) \(c=3\) \(c=4\) \(c=5\)
\(l=1\) 6 0 5 3 1
\(l=2\) 4 5 2 0 0
\(l=3\) 0 7 5 4 2
\(c=1\) \(c=2\) \(c=3\) \(c=4\) \(c=5\)
\(p_c\) 10 20 5 15 25

Con \(b=4\): \(\mathscr{L}_1=\{1,2\}\), \(\mathscr{L}_2=\{2,3\}\), \(\mathscr{L}_3=\{1,3\}\), \(\mathscr{L}_4=\{3\}\), \(\mathscr{L}_5=\emptyset\).

Il modello scritto sui dati dell'istanza:

\[ \begin{array}{rrrrrrrrr c l} \max & & & & 10y_1 & +20y_2 & +5y_3 & +15y_4 & +25y_5 & & \\ \text{soggetto a} & -6x_1 & -4x_2 & & +5y_1 & & & & & \le & 0\\ & & -5x_2 & -7x_3 & & +5y_2 & & & & \le & 0\\ & -5x_1 & -2x_2 & -5x_3 & & & +5y_3 & & & \le & 0\\ & -3x_1 & & -4x_3 & & & & +5y_4 & & \le & 0\\ & -x_1 & & -2x_3 & & & & & +5y_5 & \le & 0\\ & x_1 & +x_2 & & +2y_1 & & & & & \le & 3\\ & & x_2 & +x_3 & & +2y_2 & & & & \le & 3\\ & x_1 & & +x_3 & & & +2y_3 & & & \le & 3\\ & & & x_3 & & & & +2y_4 & & \le & 3\\ & & & & & & & & 2y_5 & \le & 3\\ & x_1 & +x_2 & +x_3 & & & & & & \le & 2\\ & x_1, & x_2, & x_3 & & & & & & \in & \{0, 1\}\\ & & & & y_1, & y_2, & y_3, & y_4, & y_5 & \in & \{0, 1\} \end{array} \]

Euristica costruttiva: il bound primale

Si aprono le prime \(k\) sedi. Cliente 1: segnale \(10\ge5\) ma 2 sedi forti (\(>1\)): non coperto. Cliente 2: segnale \(5\ge5\), 1 sede forte: coperto. Cliente 3: segnale \(7\ge5\), 1 sede forte: coperto. Clienti 4 e 5: segnale insufficiente: non coperti. Valore \(20+5=25\): \(z(\mathit{MILP}) \ge \mathit{LB} = 25\).

Rilassamento LP e duale: il bound duale

Il duale del rilassamento lineare, una variabile per vincolo del primale:

\[ \begin{aligned} \min ~~ m \sum_{c=1}^{n} \lambda_c + k\, \mu & & \\ \text{soggetto a} \quad -\sum_{c=1}^{n} s_{lc}\, \pi_c + \sum_{c\, :\, l \in \mathscr{L}_c} \lambda_c + \mu &\ge 0, & \forall l \in \{1, 2, \dots, m\}, \\ t\, \pi_c + (m-1)\, \lambda_c &\ge p_c, & \forall c \in \{1, 2, \dots, n\}, \\ \pi_c &\ge 0, & \forall c \in \{1, 2, \dots, n\}, \\ \lambda_c &\ge 0, & \forall c \in \{1, 2, \dots, n\}, \\ \mu &\ge 0. & & \end{aligned} \]

Lo stesso duale, scritto sui dati dell'istanza:

\[ \begin{array}{rrrrrrrrrrrr c l} \min & & & & & & 3\lambda_1 & +3\lambda_2 & +3\lambda_3 & +3\lambda_4 & +3\lambda_5 & +2\mu & & \\ \text{soggetto a} & -6\pi_1 & & -5\pi_3 & -3\pi_4 & -\pi_5 & +\lambda_1 & & +\lambda_3 & & & +\mu & \ge & 0\\ & -4\pi_1 & -5\pi_2 & -2\pi_3 & & & +\lambda_1 & +\lambda_2 & & & & +\mu & \ge & 0\\ & & -7\pi_2 & -5\pi_3 & -4\pi_4 & -2\pi_5 & & +\lambda_2 & +\lambda_3 & +\lambda_4 & & +\mu & \ge & 0\\ & 5\pi_1 & & & & & +2\lambda_1 & & & & & & \ge & 10\\ & & 5\pi_2 & & & & & +2\lambda_2 & & & & & \ge & 20\\ & & & 5\pi_3 & & & & & +2\lambda_3 & & & & \ge & 5\\ & & & & 5\pi_4 & & & & & +2\lambda_4 & & & \ge & 15\\ & & & & & 5\pi_5 & & & & & +2\lambda_5 & & \ge & 25\\ & \pi_1, & \pi_2, & \pi_3, & \pi_4, & \pi_5 & & & & & & & \ge & 0\\ & & & & & & \lambda_1, & \lambda_2, & \lambda_3, & \lambda_4, & \lambda_5 & & \ge & 0\\ & & & & & & & & & & & \mu & \ge & 0 \end{array} \]

Con \(\bar\pi_c=0\), \(\bar\mu=0\) e \(\bar\lambda_c = p_c/(m-1) = p_c/2\):

\[ \bar\lambda_1=5,\ \bar\lambda_2=10,\ \bar\lambda_3=5/2,\ \bar\lambda_4=15/2,\ \bar\lambda_5=25/2, \]

di valore \(m\sum_c\bar\lambda_c = 3\cdot75/2=225/2\). Per la dualità debole (problema di massimo: l'euristica dà il lower bound, il duale l'upper bound), \(\mathit{LB}=25 \le z(\mathit{MILP}) \le z(\mathit{LP}) \le \mathit{UB}=225/2\).

Quello che dice il solver. \(z(\mathit{LP}) = 41925/646 \approx 64{,}9\), \(z(\mathit{LP}^+) = 125/2 = 62{,}5\). \(z(\mathit{MILP}) = 45\), con le sedi 1 e 3 installate e i clienti 1, 2, 4 coperti (non 3 né 5): diverso da quanto trovato dall'euristica. Gap euristica \(44{,}4\%\).

\(LB\) \(UB\) (duale) \(z(\mathit{LP})\) \(z(\mathit{LP}^+)\) \(z(\mathit{MILP})\) gap dell'euristica
25 \(225/2\) \(41925/646\) \(125/2\) 45 \(44{,}4\%\)

Soluzione ottima

Considerazioni aggiuntive

  • Il cliente 5 non può mai essere coperto: segnale massimo \(1+0+2=3<5\) anche aprendo tutte le sedi.
  • Per i clienti con \(|\mathscr{L}_c|\le1\) (4 e 5) il vincolo di interferenza è ridondante.

Domande di modellazione aggiuntive

8.3.1 — Installazione condizionata

La sede 1 può essere installata solo se lo è anche la sede 3. Come si modella? Qual è il nuovo ottimo?

Una variante svolta: copertura minima garantita

Per contratto, almeno \(3\) clienti devono essere coperti.

Si aggiunge il vincolo lineare

\[ \sum_{c=1}^{n} y_c \ge 3 \]

(un vincolo lineare). Sull'istanza l'ottimo del problema 8.3 copre già \(3\) clienti, quindi il vincolo non è vincolante e l'ottimo resta \(45\).

«Almeno tre clienti coperti» aggiunge al duale \(\omega \le 0\). Conviene lasciarlo a zero, e il conto dice perché: abbassarlo costringe ogni \(\lambda_c\) a salire di \(-\omega/(m-1)\), e nell'obiettivo quelle \(\lambda\) pesano più di quanto il termine noto faccia risparmiare. L'euristica, con poche sedi, prova ogni scelta di \(k\) e tiene la migliore fra quelle ammissibili.

valore che cos'è
\(\mathit{UB}\) \(\frac{225}{2}\) certificato duale costruito a mano
\(\mathit{LB}\) \(45\) soluzione euristica
\(z(\mathit{LP})\) \(\frac{41925}{646}\) rilassamento senza i bound
\(z(\mathit{LP}^+)\) \(\frac{125}{2}\) rilassamento con i bound
\(z(\mathit{MILP})\) \(45\) ottimo del MILP

Codice

Script completo — python/fam08_3_copertura.py (riproducibile con python3 python/fam08_3_copertura.py dalla cartella python/). Notebook — notebooks/fam08_3_copertura.ipynb — che si apre in Colab dal badge in cima alla pagina.

Mostra lo script completo — python/fam08_3_copertura.py (227 righe)
"""Problema 8.3 -- Copertura del segnale con interferenza (massimo profitto).

Un «se e solo se» come nel problema 7.6: un verso (soglia+interferenza
=> coperto) è imposto da due famiglie di vincoli di link; l'altro verso
(coperto => condizioni soddisfatte) segue dall'obiettivo.
"""
from itertools import combinations as combinazioni

import gurobipy as gp
import pandas as pd
from gurobipy import GRB

from mip import (ammissibile, dualita_forte, due_rilassamenti, frazione,
                 nuovo_modello, registra_bound, rilassamenti, risolvi,
                 stampa_soluzione, valuta)
from stile import intestazione, plt, salva_dati, salva_figura
from esteso import salva_modello

R = range

# ---------- 1. MODELLO E ISTANZA ----------

intestazione("3. Copertura con interferenza: soglia di segnale e al più una sede forte")
s3 = [[6, 0, 5, 3, 1], [4, 5, 2, 0, 0], [0, 7, 5, 4, 2]]   # segnale sede l -> cliente c
p3 = [10, 20, 5, 15, 25]     # profitto se il cliente c è coperto
t3, b3, k3 = 5, 4, 2         # soglia di segnale, limite di interferenza, budget sedi
m, n = 3, 5
L3 = [[l for l in R(m) if s3[l][c] >= b3] for c in R(n)]   # L_c: sedi "forti" per il cliente c
salva_dati(pd.DataFrame([{"sede": l + 1, "cliente": c + 1, "s": s3[l][c]}
                         for l in R(m) for c in R(n)]), "fam08_3_segnale")
salva_dati(pd.DataFrame({"cliente": R(1, n + 1), "p": p3}), "fam08_3_clienti")


def modello_3(s, p, t, b, k):
    m, n = len(s), len(p)
    L = [[l for l in R(m) if s[l][c] >= b] for c in R(n)]
    mod = nuovo_modello("copertura_interferenza")
    x = mod.addVars(m, vtype=GRB.BINARY, name="x")
    y = mod.addVars(n, vtype=GRB.BINARY, name="y")
    mod.setObjective(gp.quicksum(p[c] * y[c] for c in R(n)), GRB.MAXIMIZE)
    mod.addConstrs((-gp.quicksum(s[l][c] * x[l] for l in R(m)) + t * y[c] <= 0 for c in R(n)),
                   name="soglia")
    mod.addConstrs((gp.quicksum(x[l] for l in L[c]) + (m - 1) * y[c] <= m for c in R(n)),
                   name="interferenza")
    mod.addConstr(x.sum() <= k, name="budget")
    return mod, x, y, L


def duale_3(s, p, t, b, k):
    """min sum m lam_c + k mu;  -sum_c s_lc pi_c + sum_{c in C_l} lam_c + mu >= 0;
    t pi_c + (m-1) lam_c >= p_c;  pi,lam,mu >= 0."""
    m, n = len(s), len(p)
    L = [[l for l in R(m) if s[l][c] >= b] for c in R(n)]
    C = [[c for c in R(n) if l in L[c]] for l in R(m)]
    dl = nuovo_modello("duale_copertura")
    pi = dl.addVars(n, name="pi")
    lam = dl.addVars(n, name="lam")
    mu = dl.addVar(name="mu")
    dl.setObjective(m * lam.sum() + k * mu, GRB.MINIMIZE)
    dl.addConstrs((-gp.quicksum(s[l][c] * pi[c] for c in R(n)) + gp.quicksum(lam[c] for c in C[l]) + mu >= 0
                  for l in R(m)), name="rc_x")
    dl.addConstrs((t * pi[c] + (m - 1) * lam[c] >= p[c] for c in R(n)), name="rc_y")
    return dl


m3, x3, y3, L3m = modello_3(s3, p3, t3, b3, k3)
salva_modello(m3, "fam08_3_primale")

# ---------- 2. IL RILASSAMENTO LP ----------
zlp3, zlp3r, _ = rilassamenti(m3)

# ---------- 3. IL DUALE DEL RILASSAMENTO (UPPER BOUND: E' UN MASSIMO) ----------

d3 = duale_3(s3, p3, t3, b3, k3)
salva_modello(d3, "fam08_3_duale")
mano = {"mu": 0.0}
mano.update({f"pi[{c}]": 0.0 for c in R(n)})
mano.update({f"lam[{c}]": p3[c] / 2 for c in R(n)})
ub3, viol = valuta(d3, mano)
assert viol <= 1e-9, viol
print("Soluzione duale a mano: pi = 0, mu = 0, lam_c = p_c/2 = "
      + ", ".join(frazione(p3[c] / 2) for c in R(n)) + f"  ->  ub = {frazione(ub3)}")
dualita_forte(d3, zlp3)

# ---------- 4. EURISTICA COSTRUTTIVA (LOWER BOUND: E' UN MASSIMO) ----------

print("Euristica: si aprono le prime k sedi; un cliente è coperto se il segnale totale")
print("raggiunge la soglia e al più una sede forte lo raggiunge.")


def euristica_3(s, p, t, b, k):
    m, n = len(s), len(p)
    x = [1 if l < k else 0 for l in R(m)]
    y, passi = [0] * n, []
    for c in R(n):
        ts = sum(s[l][c] for l in R(k))
        ni = sum(1 for l in R(k) if s[l][c] >= b)
        y[c] = 1 if (ts >= t and ni <= 1) else 0
        passi.append(f"Cliente {c + 1}: segnale totale = {ts}, sedi forti = {ni}; "
                     f"{'coperto' if y[c] else 'non coperto'}.")
    return x, y, passi


xe, ye, passi = euristica_3(s3, p3, t3, b3, k3)
print(f"  Si aprono le prime k = {k3} sedi: x = {xe}.")
for i, s in enumerate(passi, 1):
    print(f"  Passo {i}. {s}")
lb3 = sum(p3[c] * ye[c] for c in R(n))
print(f"  lb = {lb3}")

# ---------- 5. SOLUZIONE OTTIMA DEL MILP ----------

z3 = risolvi(m3)
print("Soluzione ottima del MILP:")
stampa_soluzione(m3, solo_non_nulle=True)
riga = registra_bound("3 copertura", ub3, lb3, zlp3, zlp3r, z3, senso="max")
salva_dati(pd.DataFrame([riga]), "fam08_3_bound")

# ---------- 6. DOMANDE DI MODELLAZIONE AGGIUNTIVE ----------

varianti = {}


def variante(nome, mod):
    z = risolvi(mod)
    print(f"  {nome:70s} z = {frazione(z)}")
    return z


# 3a: almeno 3 clienti devono essere coperti
mod, x, y, L = modello_3(s3, p3, t3, b3, k3)
mod.addConstr(y.sum() >= 3, name="copertura_minima")
varianti["3a"] = variante("3a. Almeno 3 clienti coperti (sum y_c >= 3)", mod)
# 3b: se si apre la sede 1 si apre anche la sede 3
mod, x, y, L = modello_3(s3, p3, t3, b3, k3)
mod.addConstr(x[0] <= x[2], name="1_implica_3")
varianti["3b"] = variante("3b. Se si apre la sede 1 si apre anche la 3 (x_1 <= x_3)", mod)
salva_dati(pd.DataFrame({"variante": list(varianti), "z": list(varianti.values())}), "fam08_3_varianti")

# ---------- 7. IL SANDWICH SULLA VARIANTE 3a ----------
intestazione("3a. Il sandwich sulla variante: almeno 3 clienti coperti")


def modello_3a(s, p, t, b, k, minimo=3):
    mod_, xx, yy, LL = modello_3(s, p, t, b, k)
    mod_.addConstr(yy.sum() >= minimo, name="copertura_minima")
    return mod_, xx, yy, LL


def duale_3a(s, p, t, b, k, minimo=3):
    """Al duale di 8.3 si aggiunge omega <= 0 per il vincolo sum_c y_c >= minimo
    (verso >= in un massimo): entra nell'obiettivo con il suo termine noto e
    nelle colonne delle y_c."""
    mm, nn = len(s), len(p)
    L = [[l for l in R(mm) if s[l][c] >= b] for c in R(nn)]
    C = [[c for c in R(nn) if l in L[c]] for l in R(mm)]
    dl = nuovo_modello("duale_copertura_3a")
    pi = dl.addVars(nn, name="pi")
    lam = dl.addVars(nn, name="lam")
    mu = dl.addVar(name="mu")
    om = dl.addVar(lb=-GRB.INFINITY, ub=0.0, name="omega")
    dl.setObjective(mm * lam.sum() + k * mu + minimo * om, GRB.MINIMIZE)
    dl.addConstrs((-gp.quicksum(s[l][c] * pi[c] for c in R(nn))
                   + gp.quicksum(lam[c] for c in C[l]) + mu >= 0 for l in R(mm)), name="rc_x")
    dl.addConstrs((t * pi[c] + (mm - 1) * lam[c] + om >= p[c] for c in R(nn)), name="rc_y")
    return dl


m3a, x3a, y3a, L3a = modello_3a(s3, p3, t3, b3, k3)
salva_modello(m3a, "fam08_3a_primale")

# -- euristica ammissibile: si prova ogni scelta di k sedi e si tiene la migliore --
print("Euristica costruttiva: le sedi sono poche, quindi si prova ogni scelta di k sedi e")
print("si tiene quella che copre almeno 3 clienti con il profitto piu' alto.")
migliore_3a = None
for scelta in combinazioni(R(m), k3):
    cop = []
    for c in R(n):
        segnale = sum(s3[l][c] for l in scelta)
        forti = sum(1 for l in scelta if s3[l][c] >= b3)
        if segnale >= t3 and forti <= 1:
            cop.append(c)
    valore = sum(p3[c] for c in cop)
    print(f"  sedi {[l + 1 for l in scelta]}: clienti coperti {[c + 1 for c in cop]}, "
          f"profitto {valore}" + ("" if len(cop) >= 3 else "  (meno di 3: non ammissibile)"))
    if len(cop) >= 3 and (migliore_3a is None or valore > migliore_3a[0]):
        migliore_3a = (valore, scelta, cop)
lb3a, sedi_3a, cop_3a = migliore_3a
sol_3a = ({f"x[{l}]": (1 if l in sedi_3a else 0) for l in R(m)}
          | {f"y[{c}]": (1 if c in cop_3a else 0) for c in R(n)})
assert ammissibile(m3a, sol_3a), "la soluzione euristica della variante deve essere ammissibile"
print(f"  la migliore e' {[l + 1 for l in sedi_3a]}  ->  lb = {frazione(lb3a)}")

# -- certificato duale: omega resta a zero, e si vede dal conto --
d3a = duale_3a(s3, p3, t3, b3, k3)
salva_modello(d3a, "fam08_3a_duale")
mano_3a = {"mu": 0.0, "omega": 0.0}
mano_3a.update({f"pi[{c}]": 0.0 for c in R(n)})
mano_3a.update({f"lam[{c}]": p3[c] / (m - 1) for c in R(n)})
ub3a, viol_3a = valuta(d3a, mano_3a)
assert viol_3a <= 1e-9, viol_3a
print("Soluzione duale a mano: pi = 0, mu = 0 e lam_c = p_c/(m-1) come nel problema base.")
print("  Il nuovo omega conviene lasciarlo a zero: abbassarlo costringe lam_c a salire di")
print("  -omega/(m-1) per ciascuno degli n clienti, e nell'obiettivo quelle m lam pesano")
print("  m*n/(m-1) volte piu' di quanto il termine noto faccia risparmiare.")
print(f"  ->  ub = {frazione(ub3a)}")
zlp3a, zlp3ar, _ = due_rilassamenti(m3a, d3a)
z3a = risolvi(m3a)
riga_3a = registra_bound("3a almeno 3 clienti coperti", ub3a, lb3a, zlp3a, zlp3ar, z3a, senso="max")
salva_dati(pd.DataFrame([riga_3a]), "fam08_3a_bound")
assert lb3a <= z3a <= zlp3a + 1e-9 <= ub3a + 1e-9

# ---------- 8. FIGURE ----------

fig, ax = plt.subplots(figsize=(7.2, 3.2))
ott_x = [l for l in R(m) if x3[l].X > 0.5]
larghezza = 0.6
for c in R(n):
    colore = "#1E8449" if y3[c].X > 0.5 else "#C0392B"
    ax.bar(c, p3[c], color=colore, width=larghezza)
    ax.text(c, p3[c] + 0.5, "coperto" if y3[c].X > 0.5 else "non coperto", ha="center", fontsize=8)
ax.set_xticks(R(n))
ax.set_xticklabels([f"cliente {c + 1}" for c in R(n)])
ax.set_ylabel("profitto $p_c$")
ax.set_title(f"Copertura: soluzione ottima con sedi aperte {[l + 1 for l in ott_x]} (z = {frazione(z3)})")
salva_figura(fig, "cap08_copertura_ottimo")
print("Fine.")