Migra C1 de Set Covering para Set Partitioning e documenta limitacoes (v0.10)

- C1 alterado: z[nid] <= 1 -> z[nid] == 1 para todas as missoes; toda missao
  deve ser coberta por exatamente 1 aeronave (sem missoes descobertas).
- n_missoes padrao reduzido de 50 para 20 (n_evam de 5 para 4): garante
  factibilidade do MIP com Set Partitioning no envelope de 4 aeronaves.
- Time limit padrao do solver CBC fixado em 120 s (era ilimitado): evita
  travamento ao tentar provar infeasibility.
- Corrigido display "Not Solved": status de L1 preservado quando PuLP
  redefine mdl.status apos setObjective() em L2.
- f0_cenario_insp_grande.json revisado: apenas 2803 com f0=595h em INSP 2A
  (F_max=600h, downtime=4 dias); demais matriculas com f0=0.
- Gerado planejamento_insp_grande.html: Gantt exibe INSP 2A visivelmente;
  20/20 missoes cobertas; CBC Optimal em < 15 s.
- changelog.md e LOG.md atualizados com limitacoes detalhadas para
  contribuidores: escopo validado, bugs de display, riscos numericos do
  big-M com LRT < 5h e comportamento de 2811 ociosa.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cesa-V
2026-06-16 11:09:24 -03:00
parent 6433055e9e
commit a34ed6b340
6 changed files with 1202 additions and 75 deletions

View File

@@ -474,9 +474,7 @@ def resolver(
y[(k, a.id)] for a in arcs if (k, a.id) in y
)
# --- C1: Cobertura e definição de z ---
# Missões normais: Σ_k y_entrada ≤ 1 (cobertura opcional)
# Missões obrigatórias (M_obr / EVAM): Σ_k y_entrada == 1
# --- C1: Cobertura — Set Partitioning: toda missão coberta por exatamente 1 aeronave ---
for nid, n in missoes_nos.items():
fluxo_entrada = pulp.lpSum(
y[(k, a.id)]
@@ -485,10 +483,7 @@ def resolver(
if (k, a.id) in y
)
mdl += fluxo_entrada == z[nid], f"def_z_{nid}"
if n.missao.obrigatoria:
mdl += z[nid] == 1, f"hard_{nid}"
else:
mdl += z[nid] <= 1, f"cob_{nid}"
mdl += z[nid] == 1, f"part_{nid}"
# --- C2: Conservação de fluxo por (aeronave, nó) ---
for k in ks:
@@ -676,14 +671,13 @@ def resolver(
# já impõe o limite LRT por ciclo corretamente. A versão simplificada somava
# horas totais sem respeitar resets de inspeção, super-restringindo o modelo.
time_limit = kwargs.get("time_limit", 0)
solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=time_limit if time_limit > 0 else None)
time_limit = kwargs.get("time_limit", 120)
solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=time_limit)
mdl.solve(solver)
status_l1 = mdl.status
prio_otima = int(round(pulp.value(mdl.objective) or 0))
# L2: com prioridade fixada, minimiza folga de horas ao fim do horizonte.
# folga_k = orcamento_k horas_voadas_k
# Minimizar Σ_k folga_k ⟺ maximizar Σ_k horas_voadas_k (orcamento é constante).
# L2: com prioridade fixada, maximiza horas voadas.
mdl += (
pulp.lpSum(n.missao.prioridade * z[nid] for nid, n in missoes_nos.items())
== prio_otima,
@@ -698,7 +692,10 @@ def resolver(
)
mdl.sense = pulp.LpMaximize
mdl.setObjective(horas_voadas)
mdl.solve(pulp.PULP_CBC_CMD(msg=False, timeLimit=time_limit if time_limit > 0 else None))
mdl.solve(pulp.PULP_CBC_CMD(msg=False, timeLimit=time_limit))
# PuLP redefine mdl.status após setObjective; preservar o status real (L1).
if mdl.status == 0 and status_l1 == 1:
mdl.status = status_l1
return {
"modelo": mdl,
@@ -1312,11 +1309,11 @@ _PESOS_PRIO = [35, 30, 20, 10, 5]
def gerar_missoes_sinteticas(
n: int = 50,
n: int = 20,
seed: Optional[int] = 42,
inicio: Optional[datetime] = None,
horizonte_h: float = 744.0, # janeiro = 31 × 24
n_evam: int = 5,
n_evam: int = 4,
bases: Optional[list[str]] = None,
ofrag_path: Optional[Path] = None,
) -> tuple[list[Missao], datetime]:
@@ -1498,20 +1495,25 @@ def main() -> None:
help="Usa missões sintéticas cross-base em vez do CSV real.",
)
parser.add_argument(
"--n-missoes", type=int, default=50,
help="Número de missões sintéticas a gerar (padrão: 50).",
"--n-missoes", type=int, default=20,
help="Número de missões sintéticas a gerar (padrão: 20).",
)
parser.add_argument(
"--n-evam", type=int, default=5,
help="Missões obrigatórias (EVAM/hard) nas sintéticas (padrão: 5).",
"--n-evam", type=int, default=4,
help="Missões obrigatórias (EVAM/hard) nas sintéticas (padrão: 4).",
)
parser.add_argument(
"--ofrag", type=Path, default=None, metavar="CSV",
help="CSV de OFRAG com prioridades por missão (missao_id, prioridade).",
)
parser.add_argument(
"--time-limit", type=int, default=300, dest="time_limit",
help="Limite de tempo em segundos para o solver CBC (padrão: 60). 0 = sem limite.",
"--time-limit", type=int, default=120, dest="time_limit",
help="Limite de tempo em segundos para o solver CBC (padrão: 120).",
)
parser.add_argument(
"--insp-seqs", type=int, nargs="+", default=None, dest="insp_seqs",
metavar="SEQ",
help="Filtra catálogo para apenas estes seq de inspeção (ex: --insp-seqs 3).",
)
args = parser.parse_args()
@@ -1519,6 +1521,8 @@ def main() -> None:
fim = datetime.fromisoformat(args.fim).replace(tzinfo=timezone.utc)
catalogo = carregar_inspecoes(args.inspecoes)
if args.insp_seqs:
catalogo = {s: v for s, v in catalogo.items() if s in args.insp_seqs}
if args.escala:
missoes, horizonte_dt = carregar_escala(args.escala, inicio)