↓ Ir para o conteúdo principal

Otimização Aplicada com SciPy

·6968 palavras·33 minutos·
 Author
Autor
Francisco Bustamante
Químico, cientista de dados e programador Python.
Tabela de conteúdos
Do Símbolo ao Número - Este artigo faz parte de uma série de artigos.
Parte 11: Esse Artigo

Um engenheiro de processos quer aumentar a conversão de um reator químico. A receita é simples: subir a temperatura, deixar o reagente mais tempo dentro do reator. Os dois ajustes funcionam: conversões de 99% são perfeitamente alcançáveis só apertando esses dois botões. O problema é que “mais alto” e “mais devagar” custam dinheiro (energia para aquecer, capital para um reator maior) e, em algum ponto, ficam perigosos demais para operar. A pergunta de verdade é “qual o melhor equilíbrio entre conversão, custo e segurança”, e não “qual a conversão máxima possível”.

Essa é a diferença entre um problema de otimização de livro-texto e um problema de otimização real. O artigo anterior estabeleceu os fundamentos do scipy.optimize: minimize, SLSQP (sequential least squares programming, o método padrão para problemas com restrições), bounds, restrições de igualdade. Fez isso com o tanque cilíndrico, um problema elegante mas com uma única restrição de igualdade em duas variáveis. Este artigo e o anterior formam um par: lá, os fundamentos; aqui, esses mesmos fundamentos aplicados a quatro problemas reais, de domínios diferentes. Todos têm uma característica em comum: a solução “ótima” não é óbvia sem calcular, e simplesmente empurrar as variáveis para o extremo nem sempre é a resposta certa.

Por Que Otimização Aplicada Importa?
#

  • Química e engenharia de processos: quase todo projeto de reator, coluna de destilação ou trocador de calor envolve equilibrar desempenho contra custo de energia, custo de capital e limites de segurança, nunca “desempenho puro”.
  • Física e engenharia mecânica: balística, aerodinâmica e projeto de veículos lidam com otimizações onde a função-objetivo só existe como resultado de uma simulação numérica, sem fórmula fechada nenhuma.
  • Engenharia estrutural: dimensionar qualquer peça estrutural (vigas, colunas, cabos) é otimizar massa (custo, peso) sujeita a múltiplos critérios de segurança simultâneos, quase sempre mais de um ativo ao mesmo tempo.
  • Ciência de dados: ajustar hiperparâmetros de um modelo é o mesmo problema em espírito, o de maximizar uma métrica de desempenho sujeita a restrições de tempo de treinamento, memória ou custo computacional.

Recapitulando o Essencial do Artigo Anterior
#

Sem repetir a mecânica básica, scipy.optimize.minimize resolve otimização multivariável, com bounds para limites de caixa e constraints (dicionários com 'type': 'eq'/'ineq' e 'fun') para restrições de igualdade e desigualdade: 'ineq' exige que a função da restrição retorne um valor \(\geq 0\) quando satisfeita. method="SLSQP" é a escolha padrão quando há restrições. Todo esse ferramental reaparece aqui, sem reapresentação.

Uma convenção, porém, merece ser relembrada, porque ela aparece em quase todo bloco de código deste artigo e polui a leitura de quem não a reconhece:

Os nomes com “menos” e os sinais negativos soltos

Neste artigo, três dos quatro problemas são de maximização: lucro, alcance, lucro de novo. Mas o scipy.optimize só minimiza: não existe função maximize. Como vimos no artigo anterior, a saída é sempre a mesma: maximizar \(f\) é minimizar \(-f\). Virar a função de cabeça para baixo troca picos por vales sem mover nenhum deles de lugar.

Daí a convenção de nomes que você vai ver a seguir. Sempre que uma função se chamar menos_alguma_coisa, ou aparecer um - na frente da função-objetivo, é esse truque em ação, não um erro de sinal:

No código Significa
def menos_lucro(v): return -lucro_liquido(*v) o que é minimizado
minimize(menos_lucro, ...) mas o que se quer é o lucro máximo
-res.fun desfaz o sinal para ler o lucro de verdade
minimize_scalar(lambda th: -alcance(th), ...) mesma ideia, em uma variável
linprog(-lucro_ton, ...) mesma ideia, num vetor inteiro de coeficientes

A regra prática, para não se perder: o sinal negativo aparece duas vezes, uma ao definir o objetivo e outra ao ler o resultado. Se você vir só uma das duas, provavelmente há um bug.

Química: Reator Contínuo: Conversão × Custo × Segurança
#

Um reator CSTR (continuous stirred-tank reactor, reator de mistura completa em regime contínuo) processa uma reação de 1ª ordem. A constante de velocidade segue a equação de Arrhenius, \(k(T) = A_0 e^{-E_a/(RT)}\), e a conversão de saída de um CSTR para reação de 1ª ordem tem fórmula fechada simples em função da temperatura \(T\) e do tempo de residência \(\tau\):

$$ X(T,\tau) = \frac{k(T)\tau}{1+k(T)\tau} $$
De onde sai essa fórmula

Ela não é arbitrária: sai de um balanço de massa em três linhas, e vale a pena ver, porque é ela que dá o formato de toda a superfície que vamos otimizar.

Num CSTR em estado estacionário, o que entra do reagente \(A\) menos o que sai é igual ao que a reação consome. Chamando de \(F_{A0}\) a vazão molar de entrada e de \(X\) a fração convertida:

$$F_{A0} X = (-r_A) V$$

Para uma reação de 1ª ordem, \(-r_A = k C_A\), e a concentração de saída de um reator de mistura completa é a mesma de dentro dele, \(C_A = C_{A0}(1-X)\). Substituindo, e escrevendo \(F_{A0} = C_{A0} v_0\) com \(v_0\) a vazão volumétrica:

$$C_{A0} v_0 X = k C_{A0}(1-X) V$$

Os \(C_{A0}\) se cancelam. Dividindo por \(v_0\) e reconhecendo o tempo de residência \(\tau = V/v_0\):

$$X = k\tau(1-X) \quad\Longrightarrow\quad X(1 + k\tau) = k\tau$$

Isolando \(X\), chega-se à fórmula acima. Repare no que ela diz: como \(X = k\tau/(1+k\tau)\), a conversão nunca chega a 1, por maior que seja \(\tau\), ela só se aproxima. É por isso que o problema de “maximizar a conversão” vai sempre empurrar \(\tau\) para o limite superior da busca, como veremos daqui a pouco.

import numpy as np

R = 8.314  # J/(mol.K)
Ea = 50000.0  # J/mol
A0 = 5e6  # 1/min


def k_arrhenius(T):
    return A0 * np.exp(-Ea / (R * T))


def conversao(T, tau):
    kk = k_arrhenius(T)
    return kk * tau / (1 + kk * tau)

O Problema Errado: Maximizar Só a Conversão
#

Se o objetivo for simplesmente maximizar \(X\), sem custo nenhum associado a \(T\) ou \(\tau\), a resposta é sempre “mais alto, mais devagar”, porque não existe ótimo interior, só os limites da busca:

import numpy as np
from scipy.optimize import minimize


R = 8.314  # J/(mol.K)
Ea = 50000.0  # J/mol
A0 = 5e6  # 1/min


def k_arrhenius(T):
    return A0 * np.exp(-Ea / (R * T))


def conversao(T, tau):
    kk = k_arrhenius(T)
    return kk * tau / (1 + kk * tau)


bounds_reator = [(280, 500), (0.01, 50)]


def menos_conversao(v):
    T, tau = v
    return -conversao(T, tau)


res_trivial = minimize(menos_conversao, x0=[340, 2.0], method="SLSQP", bounds=bounds_reator)
print(res_trivial.x, -res_trivial.fun)
[500.  50.] 0.9993310063263992

É o truque em ação, pela primeira vez: definimos menos_conversao (com o sinal invertido) para poder minimizar, e lemos o resultado como -res_trivial.fun para desfazer a inversão e recuperar a conversão de verdade, \(0{,}999\). Sem esse segundo sinal, o print mostraria \(-0{,}999\), e a “conversão máxima” pareceria negativa.

\(T = 500\) K e \(\tau = 50\) min: os dois limites superiores de bounds, batidos com força. Isso não é uma falha do otimizador: é a resposta matematicamente correta para a pergunta errada. Um reator a \(500\) K com cinquenta minutos de residência é impraticável: quente demais, grande demais, caro demais. A pergunta certa nunca foi “maximizar conversão”.

O Problema Certo: Lucro Líquido
#

Receita proporcional à conversão, custo de capital do reator proporcional a \(\tau\) (reatores maiores custam mais), custo de energia proporcional ao quanto \(T\) sobe acima de uma referência \(T_{ref}\):

$$ \text{Lucro}(T,\tau) = \text{valor} \cdot X(T,\tau) - c_v \tau - c_e (T - T_{ref}) $$
Exemplo 1: Ponto ótimo de operação do reator

Enunciado: Todos os três termos são taxas, em reais por minuto de operação: receita de \(100\) R$/min à conversão completa, custo de capital de \(5\) R$/min para cada minuto de tempo de residência (reator maior custa mais para amortizar), e custo de energia de \(0{,}50\) R$/min para cada kelvin acima de \(300\) K. Encontre a combinação \((T,\tau)\) que maximiza o lucro líquido, em R$/min.

Solução:

import numpy as np
from scipy.optimize import minimize


R = 8.314  # J/(mol.K)
Ea = 50000.0  # J/mol
A0 = 5e6  # 1/min


def k_arrhenius(T):
    return A0 * np.exp(-Ea / (R * T))


def conversao(T, tau):
    kk = k_arrhenius(T)
    return kk * tau / (1 + kk * tau)


bounds_reator = [(280, 500), (0.01, 50)]
T_ref = 300.0
valor = 100.0  # R$/min à conversão completa
c_v = 5.0      # R$/min por minuto de tempo de residência (capital)
c_e = 0.5      # R$/min por kelvin acima de T_ref (energia)


def lucro_liquido(T, tau):
    return valor * conversao(T, tau) - c_v * tau - c_e * (T - T_ref)


def menos_lucro(v):
    T, tau = v
    return -lucro_liquido(T, tau)


res_economico = minimize(menos_lucro, x0=[340, 2.0], method="SLSQP", bounds=bounds_reator)
print(res_economico.x, -res_economico.fun)
print("conversão:", conversao(*res_economico.x))
[406.18242318   2.74348304] 16.782824870189145
conversão: 0.8359145165639981

Um ótimo interior de verdade: \(T^\ast \approx 406\) K, \(\tau^\ast \approx 2{,}74\) min, conversão de \(83{,}6\%\), nem perto dos \(99{,}9\%\) da solução trivial, mas o lucro líquido agora é o que realmente importa. A conversão marginal que faltaria para chegar a \(99{,}9\%\) custaria mais em energia e capital do que valeria em receita.

A Restrição de Segurança
#

Reatores exotérmicos têm um limite real de temperatura de operação: acima dele, o risco de disparo térmico (runaway) deixa de ser aceitável. Impor essa restrição de segurança:

import numpy as np
from scipy.optimize import minimize


R = 8.314  # J/(mol.K)
Ea = 50000.0  # J/mol
A0 = 5e6  # 1/min


def k_arrhenius(T):
    return A0 * np.exp(-Ea / (R * T))


def conversao(T, tau):
    kk = k_arrhenius(T)
    return kk * tau / (1 + kk * tau)


bounds_reator = [(280, 500), (0.01, 50)]
T_ref = 300.0
valor = 100.0  # R$/min à conversão completa
c_v = 5.0      # R$/min por minuto de tempo de residência (capital)
c_e = 0.5      # R$/min por kelvin acima de T_ref (energia)


def lucro_liquido(T, tau):
    return valor * conversao(T, tau) - c_v * tau - c_e * (T - T_ref)


def menos_lucro(v):
    T, tau = v
    return -lucro_liquido(T, tau)


T_max_seguranca = 380.0

res_seguro = minimize(
    menos_lucro,
    x0=[340, 2.0],
    method="SLSQP",
    bounds=bounds_reator,
    constraints={"type": "ineq", "fun": lambda v: T_max_seguranca - v[0]},
)
print(res_seguro.x, -res_seguro.fun)
print("conversão:", conversao(*res_seguro.x))
[380.           3.97133099] 12.811670365630604
conversão: 0.7266832532268612

A restrição de segurança está expressa como constraints, não como bounds, de propósito: é uma limitação de processo, não um limite arbitrário de busca, e vale deixar essa distinção explícita no código. O otimizador reage do jeito esperado: como \(380\) K é menor que o ótimo irrestrito (\(\approx406\) K), a restrição passa a valer no limite (binding), e o otimizador compensa aumentando \(\tau\) (de \(2{,}74\) para \(3{,}97\) min) para recuperar parte da conversão perdida. Mesmo assim, o lucro cai de \(16{,}78\) para \(12{,}81\), uma queda de \(23{,}7\%\). Segurança tem custo real, e esse custo agora está quantificado.

bounds ou constraints: a diferença não é só de estilo

Poderíamos ter escrito \(T \le 380\) K como bounds=[(280, 380), ...] em vez de constraints. Aqui os dois dão exatamente o mesmo resultado, e pelo mesmo preço: \(10\) iterações e \(32\) avaliações nos dois casos. Mas eles não são intercambiáveis, e a diferença aparece quando se registra onde o otimizador pisou durante a busca:

import numpy as np
from scipy.optimize import minimize


R = 8.314  # J/(mol.K)
Ea = 50000.0  # J/mol
A0 = 5e6  # 1/min


def k_arrhenius(T):
    return A0 * np.exp(-Ea / (R * T))


def conversao(T, tau):
    kk = k_arrhenius(T)
    return kk * tau / (1 + kk * tau)


bounds_reator = [(280, 500), (0.01, 50)]
T_ref = 300.0
valor = 100.0  # R$/min à conversão completa
c_v = 5.0      # R$/min por minuto de tempo de residência (capital)
c_e = 0.5      # R$/min por kelvin acima de T_ref (energia)


def lucro_liquido(T, tau):
    return valor * conversao(T, tau) - c_v * tau - c_e * (T - T_ref)


def menos_lucro(v):
    T, tau = v
    return -lucro_liquido(T, tau)


T_max_seguranca = 380.0
vistos = []


def menos_lucro_espiao(v):
    vistos.append(v[0])          # registra cada T avaliado
    return menos_lucro(v)


minimize(menos_lucro_espiao, x0=[340, 2.0], method="SLSQP",
         bounds=bounds_reator,
         constraints={"type": "ineq", "fun": lambda v: T_max_seguranca - v[0]})
acima = [T for T in vistos if T > T_max_seguranca]
print(f"avaliações acima de 380 K: {len(acima)} de {len(vistos)}")
avaliações acima de 380 K: 4 de 32

Com constraints, o SLSQP avaliou a função quatro vezes acima do limite (por \(1{,}5 \times 10^{-8}\) K; são os passos da derivada por diferenças finitas na fronteira). Trocando para bounds=[(280, 380), (0.01, 50)], esse número vai a zero: aqui, bounds funcionou como uma caixa que o otimizador não atravessou.

A regra prática que sai daí:

  • Se a função não existe fora da região (raiz de número negativo, logaritmo de zero, um modelo que devolve nan ou levanta exceção), use bounds. É a única das duas que garante que ela jamais será chamada lá fora.
  • Se a restrição é uma limitação real do processo e a função continua perfeitamente calculável do outro lado (é o nosso caso: nada impede de calcular o lucro a \(400\) K, só não é seguro operar assim), use constraints. Além de funcionar, documenta a intenção, e é a única forma de expressar restrições que misturam variáveis, como \(h \le 6b\) na viga mais adiante, que nenhuma caixa consegue representar.
import matplotlib.pyplot as plt
import numpy as np
from scipy.optimize import minimize


R = 8.314  # J/(mol.K)
Ea = 50000.0  # J/mol
A0 = 5e6  # 1/min


def k_arrhenius(T):
    return A0 * np.exp(-Ea / (R * T))


def conversao(T, tau):
    kk = k_arrhenius(T)
    return kk * tau / (1 + kk * tau)


bounds_reator = [(280, 500), (0.01, 50)]
T_ref = 300.0
valor = 100.0  # R$/min à conversão completa
c_v = 5.0      # R$/min por minuto de tempo de residência (capital)
c_e = 0.5      # R$/min por kelvin acima de T_ref (energia)


def lucro_liquido(T, tau):
    return valor * conversao(T, tau) - c_v * tau - c_e * (T - T_ref)


def menos_lucro(v):
    T, tau = v
    return -lucro_liquido(T, tau)


T_max_seguranca = 380.0

res_economico = minimize(menos_lucro, x0=[340, 2.0], method="SLSQP", bounds=bounds_reator)
res_seguro = minimize(
    menos_lucro,
    x0=[340, 2.0],
    method="SLSQP",
    bounds=bounds_reator,
    constraints={"type": "ineq", "fun": lambda v: T_max_seguranca - v[0]},
)

T_grid = np.linspace(300, 450, 300)
tau_grid = np.linspace(0.1, 10, 300)
TT, TAU = np.meshgrid(T_grid, tau_grid)
NET = lucro_liquido(TT, TAU)

fig, ax = plt.subplots()
cf = ax.contourf(TT, TAU, NET, levels=50, cmap="viridis")
fig.colorbar(cf, ax=ax, label="lucro líquido (R$/min)")
ax.axvline(T_max_seguranca, color="red", linestyle="--", label="limite de segurança (T=380K)")
ax.plot(*res_economico.x, "*", color="white", markersize=16, markeredgecolor="black", label="ótimo sem restrição")
ax.plot(*res_seguro.x, "o", color="orange", markersize=12, markeredgecolor="black", label="ótimo com T<=380K")
ax.set_xlabel("T (K)")
ax.set_ylabel(r"$\tau$ (min)")
ax.legend()

O mapa de contorno a seguir mostra o pico de lucro (estrela), o limite de segurança cortando o espaço de busca (linha tracejada), e como o ótimo restrito (círculo laranja) desliza para cima ao longo dessa linha, compensando com mais tempo de residência o que perde em temperatura:

Mapa de contorno do lucro líquido em função de T e tau, com um pico amarelo em torno de T=406K e tau=2,7min; uma linha tracejada vermelha vertical em T=380K corta a região; uma estrela branca marca o ótimo sem restrição e um círculo laranja, sobre a linha tracejada, marca o ótimo restrito
O limite de segurança corta o espaço de busca; o ótimo restrito desliza ao longo dessa fronteira, compensando temperatura mais baixa com tempo de residência maior.

Não Existe Um Ótimo: Existe Uma Fronteira
#

Tudo até aqui devolveu um ponto. Mas “o melhor equilíbrio entre conversão, custo e segurança” pressupõe que alguém já decidiu quanto vale um ponto percentual de conversão, foi o que fizemos ao fixar valor = 100. Se essa escolha mudar, o ótimo muda de lugar.

A pergunta mais honesta é outra: para cada conversão que eu queira exigir, qual é o menor custo que a atinge? Isso é um problema de otimização diferente, o de minimizar custo com a conversão como restrição, resolvido uma vez para cada alvo:

import numpy as np
from scipy.optimize import minimize


R = 8.314  # J/(mol.K)
Ea = 50000.0  # J/mol
A0 = 5e6  # 1/min


def k_arrhenius(T):
    return A0 * np.exp(-Ea / (R * T))


def conversao(T, tau):
    kk = k_arrhenius(T)
    return kk * tau / (1 + kk * tau)


bounds_reator = [(280, 500), (0.01, 50)]

T_ref = 300.0
valor = 100.0  # R$/min à conversão completa
c_v = 5.0      # R$/min por minuto de tempo de residência (capital)
c_e = 0.5      # R$/min por kelvin acima de T_ref (energia)
T_max_seguranca = 380.0


def custo_operacao(T, tau):
    return c_v * tau + c_e * (T - T_ref)


def custo_minimo_para(X_alvo):
    return minimize(
        lambda v: custo_operacao(*v),
        x0=[350.0, 3.0],
        method="SLSQP",
        bounds=bounds_reator,
        constraints=[
            {"type": "ineq", "fun": lambda v: conversao(v[0], v[1]) - X_alvo},
            {"type": "ineq", "fun": lambda v: T_max_seguranca - v[0]},
        ],
    )


alvos = np.linspace(0.40, 0.95, 60)
custos = np.array([custo_minimo_para(a).fun for a in alvos])
lucros = valor * alvos - custos

O conjunto de pares \((X, \text{custo mínimo})\) é a fronteira de Pareto do problema: a curva abaixo da qual não existe operação possível. Cada ponto dela é um compromisso legítimo, e nenhum é “o certo” sem que alguém diga quanto vale a conversão.

Dois gráficos lado a lado com a conversão exigida no eixo horizontal. À esquerda, o menor custo possível sobe devagar até cerca de 75% e depois dispara quase verticalmente rumo a 95%; à direita, o lucro correspondente forma um arco que sobe, atinge o máximo perto de 73% e despenca para valores fortemente negativos acima de 85%. Uma estrela amarela marca em ambos o ótimo econômico calculado antes
A fronteira de Pareto do reator. O ótimo de lucro calculado antes (estrela) cai exatamente sobre ela, como tem de cair. À direita se vê por que exigir conversão alta destrói o lucro: o custo cresce mais rápido que a receita.

Os números explicam o formato:

Conversão exigida Menor custo (R$/min) Lucro (R$/min)
50% 46,57 +3,68
70% 57,29 +12,54
72,7% (ótimo) 59,86 +12,81
80% 70,03 +10,05
90% 109,84 −19,50
95% 181,90 −86,90

Duas leituras valem o esforço. A primeira: a estrela do ótimo econômico cai exatamente sobre a fronteira, e tinha de cair, porque o ponto de lucro máximo é necessariamente eficiente. Serve de conferência para todo o exemplo.

A segunda é a lição de engenharia de processo. Subir de \(72{,}7\%\) para \(95\%\) de conversão triplica o custo, de \(59{,}86\) para \(181{,}90\) R$/min, e leva o lucro de \(+12{,}81\) a \(-86{,}90\). O motivo está na própria fórmula deduzida no começo: como \(X = k\tau/(1+k\tau)\) se aproxima de 1 sem nunca chegar, cada ponto percentual a mais perto do topo exige um \(\tau\) desproporcionalmente maior, ou seja, um reator desproporcionalmente maior. “Rendimento quase total” costuma ser uma decisão econômica ruim, e a fronteira mostra exatamente onde ela passa a ser.

Física: Alcance Máximo de um Projétil com Arrasto Quadrático
#

O artigo de equações diferenciais desta série resolveu a queda de um objeto sob arrasto proporcional ao quadrado da velocidade, mas só na direção vertical. Estendendo para duas dimensões, um projétil de massa \(m\) lançado com velocidade inicial \(v_0\) e ângulo \(\theta\) segue

$$ \begin{aligned} \frac{dx}{dt} &= v_x \\ \frac{dy}{dt} &= v_y \\ m\frac{dv_x}{dt} &= -kvv_x \\ m\frac{dv_y}{dt} &= -mg - kvv_y \end{aligned} $$

com \(v = \sqrt{v_x^2+v_y^2}\) a velocidade total. O arrasto aponta na direção oposta à velocidade (por isso os termos \(-k v v_x\) e \(-k v v_y\), não só \(-k v_x\)/\(-k v_y\)) e tem magnitude proporcional ao quadrado da velocidade total, \(kv \cdot v = kv^2\). Dessa vez, sem simplificação nenhuma para a vertical, temos a equação diferencial ordinária (EDO) bidimensional completa:

import numpy as np
from scipy.integrate import solve_ivp

g = 9.81
m_bola = 0.5  # kg
k_arrasto = 0.01  # kg/m
v0 = 40.0  # m/s, lançamento forte


def campo_projetil(t, y):
    x, alt, vx, vy = y
    v = np.hypot(vx, vy)
    return [vx, vy, -(k_arrasto / m_bola) * v * vx, -g - (k_arrasto / m_bola) * v * vy]


def evento_solo(t, y):
    return y[1]


evento_solo.terminal = True
evento_solo.direction = -1

A mesma convenção campo(t, y) do artigo de equações diferenciais, agora com quatro componentes (posição \(x\), altura, e as duas componentes da velocidade) em vez de duas. evento_solo é um evento do solve_ivp que interrompe a integração assim que a altura volta a zero: o instante em que o projétil toca o chão.

Exemplo 2: O ângulo de \(45°\) deixa de ser ótimo

Enunciado: Sem resistência do ar, o ângulo que maximiza o alcance de um projétil é sempre \(45°\), resultado clássico, fórmula fechada. Com arrasto quadrático, encontre o ângulo ótimo de verdade.

Solução: A função-objetivo aqui não tem fórmula nenhuma: cada avaliação dela resolve a EDO do zero. E, como queremos o alcance máximo, aparece de novo o mesmo truque de sinal: em vez de criar uma função menos_alcance, basta um lambda th: -alcance(th) na hora da chamada, e o -res_angulo.fun no fim para ler o alcance de verdade.

import numpy as np
from scipy.integrate import solve_ivp
from scipy.optimize import minimize_scalar

g = 9.81
m_bola = 0.5  # kg
k_arrasto = 0.01  # kg/m
v0 = 40.0  # m/s, lançamento forte


def campo_projetil(t, y):
    x, alt, vx, vy = y
    v = np.hypot(vx, vy)
    return [vx, vy, -(k_arrasto / m_bola) * v * vx, -g - (k_arrasto / m_bola) * v * vy]


def evento_solo(t, y):
    return y[1]


evento_solo.terminal = True
evento_solo.direction = -1


def alcance(theta_graus):
    theta = np.radians(theta_graus)
    y0 = [0.0, 0.0, v0 * np.cos(theta), v0 * np.sin(theta)]
    sol = solve_ivp(campo_projetil, [0, 60], y0, events=evento_solo, rtol=1e-9, atol=1e-11)
    return sol.y[0, -1]


res_angulo = minimize_scalar(lambda th: -alcance(th), bounds=(10, 80), method="bounded")
print(res_angulo.x, -res_angulo.fun)
36.981129256500815 55.69219963290741

O ângulo ótimo, \(\approx37°\), fica bem abaixo dos \(45°\) clássicos: o arrasto penaliza mais as trajetórias mais altas (mais tempo no ar, mais distância total percorrida sujeita ao arrasto) do que as mais rasas.

Comparando com o alcance a \(45°\) com o mesmo arrasto:

import numpy as np
from scipy.integrate import solve_ivp
from scipy.optimize import minimize_scalar

g = 9.81
m_bola = 0.5  # kg
k_arrasto = 0.01  # kg/m
v0 = 40.0  # m/s, lançamento forte


def campo_projetil(t, y):
    x, alt, vx, vy = y
    v = np.hypot(vx, vy)
    return [vx, vy, -(k_arrasto / m_bola) * v * vx, -g - (k_arrasto / m_bola) * v * vy]


def evento_solo(t, y):
    return y[1]


evento_solo.terminal = True
evento_solo.direction = -1


def alcance(theta_graus):
    theta = np.radians(theta_graus)
    y0 = [0.0, 0.0, v0 * np.cos(theta), v0 * np.sin(theta)]
    sol = solve_ivp(campo_projetil, [0, 60], y0, events=evento_solo, rtol=1e-9, atol=1e-11)
    return sol.y[0, -1]


res_angulo = minimize_scalar(lambda th: -alcance(th), bounds=(10, 80), method="bounded")

alcance_45 = alcance(45)
print("alcance a 45°:", alcance_45)
print("melhora:", (-res_angulo.fun - alcance_45) / alcance_45 * 100, "%")
alcance a 45°: 54.24944183149212
melhora: 2.659488748099452 %

A melhora é modesta, \(2{,}7\%\), porque a curva de alcance por ângulo é bem achatada perto do máximo (o mesmo fenômeno já visto perto de qualquer ótimo suave). Mas o deslocamento do ângulo em si é bem mais que modesto: mais de \(8°\) abaixo do valor clássico. Para efeito de contraste, o alcance ideal a \(45°\) sem nenhum arrasto seria de \(v_0^2/g \approx 163\) m, quase o triplo do que o arrasto permite aqui, um lembrete de quanto a resistência do ar realmente pesa em velocidades de lançamento maiores.

import matplotlib.pyplot as plt
import numpy as np
from scipy.integrate import solve_ivp
from scipy.optimize import minimize_scalar

g = 9.81
m_bola = 0.5  # kg
k_arrasto = 0.01  # kg/m
v0 = 40.0  # m/s, lançamento forte


def campo_projetil(t, y):
    x, alt, vx, vy = y
    v = np.hypot(vx, vy)
    return [vx, vy, -(k_arrasto / m_bola) * v * vx, -g - (k_arrasto / m_bola) * v * vy]


def evento_solo(t, y):
    return y[1]


evento_solo.terminal = True
evento_solo.direction = -1


def alcance(theta_graus):
    theta = np.radians(theta_graus)
    y0 = [0.0, 0.0, v0 * np.cos(theta), v0 * np.sin(theta)]
    sol = solve_ivp(campo_projetil, [0, 60], y0, events=evento_solo, rtol=1e-9, atol=1e-11)
    return sol.y[0, -1]


res_angulo = minimize_scalar(lambda th: -alcance(th), bounds=(10, 80), method="bounded")

thetas = np.linspace(15, 70, 200)
alcances = [alcance(t) for t in thetas]

fig, ax = plt.subplots()
ax.plot(thetas, alcances)
ax.axvline(45, color="C1", linestyle="--", label="45° (ótimo sem arrasto)")
ax.plot(res_angulo.x, -res_angulo.fun, "*", color="red", markersize=16, markeredgecolor="black",
        label=f"ótimo com arrasto ({res_angulo.x:.1f}°)")
ax.set_xlabel("ângulo de lançamento (graus)")
ax.set_ylabel("alcance (m)")
ax.legend()

O gráfico a seguir compara o alcance em cada ângulo com o pico encontrado pelo otimizador:

Gráfico de alcance em função do ângulo de lançamento, uma curva em formato de arco suave com pico deslocado à esquerda de uma linha tracejada vertical em 45 graus; uma estrela vermelha marca o pico real, por volta de 37 graus
O pico da curva de alcance (estrela) fica visivelmente à esquerda dos 45° clássicos (linha tracejada): o efeito do arrasto quadrático.

Por Que o Ângulo Caiu: Olhando as Trajetórias
#

A explicação de que “o arrasto penaliza as trajetórias mais altas” fica mais convincente quando se olha as trajetórias em si, e, principalmente, quando se mede o comprimento do caminho percorrido, que é o que o arrasto de fato cobra. Como o solve_ivp já devolve a solução contínua com dense_output=True, dá para reconstruir cada trajetória e somar os pedacinhos:

import numpy as np
from scipy.integrate import solve_ivp

g = 9.81
m_bola = 0.5  # kg
k_arrasto = 0.01  # kg/m
v0 = 40.0  # m/s, lançamento forte


def campo_projetil(t, y):
    x, alt, vx, vy = y
    v = np.hypot(vx, vy)
    return [vx, vy, -(k_arrasto / m_bola) * v * vx, -g - (k_arrasto / m_bola) * v * vy]


def evento_solo(t, y):
    return y[1]


evento_solo.terminal = True
evento_solo.direction = -1


def trajetoria(theta_graus):
    theta = np.radians(theta_graus)
    y0 = [0.0, 0.0, v0 * np.cos(theta), v0 * np.sin(theta)]
    return solve_ivp(campo_projetil, [0, 60], y0, events=evento_solo,
                     dense_output=True, rtol=1e-9, atol=1e-11)


print(f"{'ângulo':>7} {'alcance':>9} {'t de voo':>9} {'altura máx':>11} {'arco':>8}")
for ang in (30, 36.98, 45):
    s = trajetoria(ang)
    tt = np.linspace(0, s.t[-1], 600)
    xs, ys = s.sol(tt)[0], s.sol(tt)[1]
    arco = np.sum(np.hypot(np.diff(xs), np.diff(ys)))
    print(f"{ang:>6.0f}° {xs[-1]:>8.2f}m {s.t[-1]:>8.2f}s {ys.max():>10.2f}m {arco:>7.2f}m")
 ângulo   alcance  t de voo  altura máx     arco
    30°    54.51m     3.00s      11.35m   60.45m
    37°    55.69m     3.50s      15.40m   65.80m
    45°    54.25m     4.01s      20.11m   70.47m

A tabela fecha o argumento. Compare as duas pontas: o lançamento a \(45°\) percorre 70,5 m de caminho para chegar a apenas \(54{,}2\) m de distância; o de \(37°\) percorre \(65{,}8\) m e chega mais longe, a \(55{,}7\) m. Cada metro de arco extra é um metro a mais sob a ação do arrasto, e a \(45°\) esse gasto é desperdiçado subindo: \(20{,}1\) m de altura máxima contra \(15{,}4\) m.

E por que não descer ainda mais o ângulo, então? Porque o outro lado da troca aparece a \(30°\). É a trajetória mais econômica em arco (\(60{,}5\) m, a menor das três), mas fica só \(3{,}0\) s no ar contra \(4{,}0\) s da de \(45°\): pouco tempo para converter velocidade em distância horizontal, e ela também chega mais perto. O ótimo em \(\approx37°\) é exatamente o ponto onde essas duas pressões opostas se equilibram.

Desenhando as três lado a lado, a troca fica visível de uma vez:

Três trajetórias parabólicas sobrepostas partindo da mesma origem: a de 30 graus é a mais baixa e cai primeiro, a de 45 graus sobe ao dobro da altura e cai antes da de 37 graus, e a de 37 graus, intermediária em altura, é a que alcança o ponto mais distante no eixo horizontal; um marcador circular assinala o ponto de queda de cada uma
As três trajetórias lado a lado. A de 45° sobe até 20 m e percorre 70,5 m de caminho para cair mais perto que a de 37°, que sobe 15 m e percorre 65,8 m. É o arrasto cobrando por distância percorrida.

Engenharia: Dimensionamento de Viga com Três Restrições
#

O último exemplo troca “poucas restrições, ótimo suave” por “várias restrições, ótimo definido pela interseção delas”. Uma viga de seção retangular (largura \(b\), altura \(h\)), simplesmente apoiada, com uma carga central \(P\) e vão \(L\), precisa ser dimensionada com a menor área de seção possível (proxy direto de massa e custo de material), sujeita a três limites de engenharia simultâneos:

  1. Tensão de flexão não pode exceder a tensão admissível do material: \(\sigma = \dfrac{6M}{bh^2} \leq \sigma_{max}\), onde \(M = PL/4\) é o momento fletor máximo.
  2. Flecha (deflexão no meio do vão) não pode exceder um limite de serviço, tipicamente entre \(L/240\) e \(L/360\), conforme a norma e o uso (aqui, \(L/300\)): \(\delta = \dfrac{PL^3}{48EI} \leq \delta_{max}\), com \(I = bh^3/12\).
  3. Esbeltez não pode exceder uma razão máxima entre altura e largura (\(h/b \leq r_{max}\)), porque vigas altas e finas demais flambam lateralmente antes mesmo de atingir a tensão de flexão limite.
Duas simplificações que um projeto real não faria

A esbeltez. A física invocada está certa: uma viga alta e fina realmente perde estabilidade lateral antes de atingir a tensão limite. Mas \(h/b \leq 6\) é uma regra de bolso, não um critério normativo. A flambagem lateral-torsional de verdade depende do comprimento não travado da viga (a distância entre os pontos onde ela é contida lateralmente) e de propriedades da seção que \(h/b\) não captura. Usamos a regra simples aqui porque ela cumpre o papel didático: ser uma terceira restrição, de natureza diferente das outras duas.

A seção. Vigas de aço reais são perfis I, justamente porque a seção retangular maciça é ineficiente: ela põe muito material perto da linha neutra, onde ele quase não contribui para resistir à flexão. Uma seção retangular mantém o exemplo com duas variáveis limpas e visualizáveis num plano, mas guarde a pergunta, porque ela é o passo seguinte natural: e se a própria forma da seção fosse variável de projeto?

O peso próprio também é ignorado: a única carga é a de \(20\) kN no meio do vão. Para uma viga deste porte a diferença é pequena, mas num dimensionamento real ele entra.

Exemplo 3: Dimensionamento com três restrições ativas

Enunciado: Viga de aço (\(E=200\) GPa), vão de \(4\) m, carga central de \(20\) kN, tensão admissível de \(150\) MPa, flecha máxima de \(L/300\), razão \(h/b\) máxima de \(6\). Encontre as dimensões \(b\) e \(h\) que minimizam a área da seção.

Solução:

import numpy as np
from scipy.optimize import minimize

E_aco = 200e9
L_viga = 4.0
P_carga = 20000.0
M_fletor = P_carga * L_viga / 4
sigma_max = 150e6
delta_max = L_viga / 300
r_max = 6.0


def area_secao(v):
    b, h = v
    return b * h


def restr_tensao(v):
    b, h = v
    return sigma_max - 6 * M_fletor / (b * h**2)


def restr_flecha(v):
    b, h = v
    I = b * h**3 / 12
    return delta_max - P_carga * L_viga**3 / (48 * E_aco * I)


def restr_esbeltez(v):
    b, h = v
    return r_max * b - h


res_viga = minimize(
    area_secao,
    x0=[0.1, 0.2],
    method="SLSQP",
    bounds=[(0.01, 1), (0.01, 1)],
    constraints=[
        {"type": "ineq", "fun": restr_tensao},
        {"type": "ineq", "fun": restr_flecha},
        {"type": "ineq", "fun": restr_esbeltez},
    ],
)
print(res_viga.x, res_viga.fun)
[0.02811442 0.16868653] 0.004742524405986762

\(b\approx28{,}1\) mm, \(h\approx168{,}7\) mm, área \(\approx47{,}4\) cm². Verificando cada restrição na solução: a tensão fica em \(150{,}0\) MPa (exatamente no limite), a esbeltez \(h/b\) fica em \(6{,}0\) (exatamente no limite), e a flecha fica em \(11{,}9\) mm, contra um limite de \(13{,}3\) mm, \(11\%\) de folga. Duas das três restrições estão ativas simultaneamente; a terceira tem folga.

O resultado é o mesmo para vários chutes iniciais espalhados: \((0{,}05, 0{,}05)\), \((0{,}5, 0{,}5)\), \((0{,}02, 0{,}5)\) convergem todos para a mesma dimensão, sinal de que este problema (diferente do Himmelblau do artigo anterior) tem um único ótimo local, que é também o global.

O gráfico a seguir mostra a região viável (verde) delimitada pelas três curvas de restrição, com o ótimo exatamente no canto formado pelo encontro entre o limite de tensão e o de esbeltez.

A região viável sai de avaliar as três restrições sobre uma malha de \((b, h)\) e ficar com os pontos onde as três valem ao mesmo tempo. Por cima dela, desenhamos a fronteira de cada restrição isolada, cada uma obtida isolando \(h\) em função de \(b\) na igualdade correspondente:

import matplotlib.pyplot as plt
import numpy as np
from scipy.optimize import minimize

E_aco = 200e9
L_viga = 4.0
P_carga = 20000.0
M_fletor = P_carga * L_viga / 4
sigma_max = 150e6
delta_max = L_viga / 300
r_max = 6.0


def area_secao(v):
    b, h = v
    return b * h


def restr_tensao(v):
    b, h = v
    return sigma_max - 6 * M_fletor / (b * h**2)


def restr_flecha(v):
    b, h = v
    I = b * h**3 / 12
    return delta_max - P_carga * L_viga**3 / (48 * E_aco * I)


def restr_esbeltez(v):
    b, h = v
    return r_max * b - h


res_viga = minimize(
    area_secao,
    x0=[0.1, 0.2],
    method="SLSQP",
    bounds=[(0.01, 1), (0.01, 1)],
    constraints=[
        {"type": "ineq", "fun": restr_tensao},
        {"type": "ineq", "fun": restr_flecha},
        {"type": "ineq", "fun": restr_esbeltez},
    ],
)

b_vals = np.linspace(0.005, 0.08, 400)
h_vals = np.linspace(0.02, 0.35, 400)
B, H = np.meshgrid(b_vals, h_vals)
viavel = (
    (6 * M_fletor / (B * H**2) <= sigma_max)
    & (P_carga * L_viga**3 / (48 * E_aco * (B * H**3 / 12)) <= delta_max)
    & (H / B <= r_max)
)

b_otimo, h_otimo = res_viga.x

fig, ax = plt.subplots()
ax.contourf(B * 1000, H * 1000, viavel, levels=[-0.5, 0.5, 1.5],
            colors=["white", "#cde9d8"])

b_linha = np.linspace(0.005, 0.08, 200)
# tensão no limite:  6M/(b h²) = sigma_max   ->   h = sqrt(6M / (sigma_max b))
ax.plot(b_linha * 1000, np.sqrt(6 * M_fletor / (sigma_max * b_linha)) * 1000,
        color="C1", linewidth=2, label="limite de tensão")
# flecha no limite:  PL³/(48 E b h³/12) = delta_max   ->   h = (12 C / b)^(1/3)
C_flecha = P_carga * L_viga**3 / (48 * E_aco * delta_max)
ax.plot(b_linha * 1000, (12 * C_flecha / b_linha) ** (1 / 3) * 1000,
        color="C3", linewidth=2, label="limite de flecha")
# esbeltez no limite:  h/b = r_max   ->   h = 6b
ax.plot(b_linha * 1000, r_max * b_linha * 1000,
        color="C0", linewidth=2, label="limite de esbeltez (h = 6b)")

ax.plot(b_otimo * 1000, h_otimo * 1000, "*", color="black", markersize=18,
        markeredgecolor="white", label="ótimo")
ax.set_xlabel("b (mm)")
ax.set_ylabel("h (mm)")
ax.set_xlim(5, 80)
ax.set_ylim(20, 350)
ax.legend(fontsize=9, loc="upper right")

Gráfico com uma região sombreada em verde claro delimitada por três curvas: uma vermelha e uma verde-oliva descendentes que quase se sobrepõem na esquerda, e uma linha azul reta crescente; uma estrela preta marca o ótimo exatamente onde a curva vermelha encontra a linha azul
A região viável (verde) fica delimitada pelas três restrições. O ótimo cai exatamente onde o limite de tensão encontra o limite de esbeltez, a menor área dentro da região viável.

Pesquisa Operacional: Alocando Recursos com linprog
#

Os três exemplos anteriores têm algo em comum: a função-objetivo é não-linear (uma exponencial de Arrhenius, uma EDO integrada, uma divisão por \(bh^2\)). Existe uma família inteira de problemas em que tanto o objetivo quanto as restrições são lineares, e para ela, usar minimize é a ferramenta errada.

O caso clássico é o mix de produção. Uma planta fabrica dois produtos que disputam os mesmos equipamentos:

Produto A Produto B Disponível
Lucro R$ 40/t R$ 30/t
Horas de reator 2 h/t 1 h/t 100 h
Horas de destilação 1 h/t 1 h/t 80 h
Demanda máxima 65 t de B

Quanto produzir de cada um? Em notação matricial, maximizar \(40x_A + 30x_B\) sujeito a \(A\mathbf{x} \le \mathbf{b}\), com \(x_A, x_B \ge 0\).

Exemplo 4: Mix de produção

Enunciado: Determine as quantidades de A e B que maximizam o lucro, respeitando a capacidade dos dois equipamentos e o teto de demanda de B.

Solução: O scipy.optimize.linprog resolve isso com algoritmos feitos para problemas lineares, hoje, por padrão, o HiGHS, que implementa simplex e pontos interiores. E ele cobra o mesmo pedágio de sinal que já apareceu três vezes neste artigo: linprog só minimiza, então maximizar lucro significa passar o vetor de lucros negado.

import numpy as np
from scipy.optimize import linprog

lucro_ton = np.array([40.0, 30.0])          # R$/t de A, de B
A_recursos = np.array([
    [2.0, 1.0],   # reator:     h/t de A, h/t de B
    [1.0, 1.0],   # destilação: h/t de A, h/t de B
    [0.0, 1.0],   # demanda: só B tem teto
])
b_recursos = np.array([100.0, 80.0, 65.0])
nomes = ["reator (h)", "destilação (h)", "demanda de B (t)"]

res_lp = linprog(-lucro_ton, A_ub=A_recursos, b_ub=b_recursos,
                 bounds=[(0, None), (0, None)])

print(f"produzir: A = {res_lp.x[0]:.1f} t, B = {res_lp.x[1]:.1f} t")
print(f"lucro = R$ {-res_lp.fun:.2f}")
for nome, folga in zip(nomes, res_lp.slack):
    estado = "ATIVA (esgotado)" if abs(folga) < 1e-9 else f"folga de {folga:.1f}"
    print(f"  {nome:>18}: {estado}")
produzir: A = 20.0 t, B = 60.0 t
lucro = R$ 2600.00
          reator (h): ATIVA (esgotado)
      destilação (h): ATIVA (esgotado)
    demanda de B (t): folga de 5.0

Repare no atributo slack, que não tem equivalente nos exemplos anteriores e é metade do valor prático da resposta: ele diz quanta sobra cada restrição tem no ótimo. Reator e destilação saem com folga zero: estão esgotados, e são eles que travam o lucro. A demanda de B sobra \(5\) t. A leitura gerencial é imediata: investir em mais capacidade de destilação ou de reator aumenta o lucro; fazer propaganda para vender mais B, não.

O gráfico a seguir mostra a região viável, as retas de lucro constante e onde o ótimo foi parar:

Gráfico com produto A no eixo horizontal e produto B no vertical. Uma região azul clara em formato de polígono ocupa o canto inferior esquerdo, delimitada por três retas tracejadas de cores diferentes; linhas pontilhadas finas e paralelas atravessam o gráfico na diagonal representando níveis de lucro constante, crescendo para o canto superior direito. Círculos brancos marcam os cinco vértices do polígono e uma estrela amarela grande marca o vértice onde as retas do reator e da destilação se cruzam
A região viável e as retas de lucro constante (pontilhadas), que crescem em direção ao canto superior direito. O ótimo é o último vértice que a região ainda alcança nessa direção, nunca um ponto do interior.

A figura mostra o fato que define programação linear: como objetivo e restrições são todos lineares, o ótimo está sempre num vértice do polígono viável. Empurrando a reta de lucro constante na direção em que ele cresce, o último ponto da região viável que ela toca é um canto. Conferindo os cinco vértices na mão:

Vértice \((A, B)\) Lucro
\((0, 0)\) R$ 0
\((0, 65)\) R$ 1.950
\((15, 65)\) R$ 2.550
\((20, 60)\) R$ 2.600
\((50, 0)\) R$ 2.000

É por isso que existe algoritmo dedicado. O simplex não varre o interior da região: ele caminha de vértice em vértice, sempre para um vizinho melhor, o que resolve problemas com milhares de variáveis em tempo que nenhum método de propósito geral alcançaria. Usar minimize aqui funcionaria no brinquedo de duas variáveis e seria inviável numa refinaria de verdade.

Não confunda as duas famílias

Se objetivo e restrições são lineares, use linprog: é mais rápido, e devolve o ótimo global com certificado, coisa que minimize não oferece em geral. Basta um termo não-linear em qualquer lugar (um \(x^2\), um \(e^{-E_a/RT}\), uma razão \(P/(bh^2)\)) e linprog deixa de servir: aí é minimize com SLSQP, como nos três exemplos anteriores. Para o caso intermediário, linear mas com variáveis obrigatoriamente inteiras (“não posso produzir 20,3 reatores”), o linprog aceita integrality, e o problema passa a ser de programação inteira, bem mais caro de resolver.

Bônus: Visualizando a Trajetória do Otimizador
#

minimize aceita um parâmetro callback: uma função chamada a cada iteração, recebendo o ponto atual. É a chance de ver, não só o resultado final, mas o caminho que o otimizador percorreu, algo que não existe no mundo do SymPy, onde solve devolve a resposta direto, sem rastro de busca nenhum. Reaproveitando o reator como pano de fundo:

import numpy as np
from scipy.optimize import minimize


R = 8.314  # J/(mol.K)
Ea = 50000.0  # J/mol
A0 = 5e6  # 1/min


def k_arrhenius(T):
    return A0 * np.exp(-Ea / (R * T))


def conversao(T, tau):
    kk = k_arrhenius(T)
    return kk * tau / (1 + kk * tau)


bounds_reator = [(280, 500), (0.01, 50)]
T_ref = 300.0
valor = 100.0  # R$/min à conversão completa
c_v = 5.0      # R$/min por minuto de tempo de residência (capital)
c_e = 0.5      # R$/min por kelvin acima de T_ref (energia)


def lucro_liquido(T, tau):
    return valor * conversao(T, tau) - c_v * tau - c_e * (T - T_ref)


def menos_lucro(v):
    T, tau = v
    return -lucro_liquido(T, tau)


trajetoria = [np.array([340.0, 2.0])]


def registra_passo(xk):
    trajetoria.append(np.copy(xk))


res_callback = minimize(
    menos_lucro, x0=[340.0, 2.0], method="SLSQP", bounds=bounds_reator, callback=registra_passo
)
traj = np.array(trajetoria)
print(len(traj), "passos registrados")
13 passos registrados
import matplotlib.pyplot as plt
import numpy as np
from scipy.optimize import minimize


R = 8.314  # J/(mol.K)
Ea = 50000.0  # J/mol
A0 = 5e6  # 1/min


def k_arrhenius(T):
    return A0 * np.exp(-Ea / (R * T))


def conversao(T, tau):
    kk = k_arrhenius(T)
    return kk * tau / (1 + kk * tau)


bounds_reator = [(280, 500), (0.01, 50)]
T_ref = 300.0
valor = 100.0  # R$/min à conversão completa
c_v = 5.0      # R$/min por minuto de tempo de residência (capital)
c_e = 0.5      # R$/min por kelvin acima de T_ref (energia)


def lucro_liquido(T, tau):
    return valor * conversao(T, tau) - c_v * tau - c_e * (T - T_ref)


def menos_lucro(v):
    T, tau = v
    return -lucro_liquido(T, tau)


trajetoria = [np.array([340.0, 2.0])]


def registra_passo(xk):
    trajetoria.append(np.copy(xk))


res_callback = minimize(
    menos_lucro, x0=[340.0, 2.0], method="SLSQP", bounds=bounds_reator, callback=registra_passo
)
traj = np.array(trajetoria)

T_grid = np.linspace(300, 450, 300)
tau_grid = np.linspace(0.1, 10, 300)
TT, TAU = np.meshgrid(T_grid, tau_grid)
NET = lucro_liquido(TT, TAU)

fig, ax = plt.subplots()
cf = ax.contourf(TT, TAU, NET, levels=50, cmap="viridis")
fig.colorbar(cf, ax=ax, label="lucro líquido (R$/min)")
ax.plot(traj[:, 0], traj[:, 1], "o-", color="white", markeredgecolor="black", linewidth=1.5,
        markersize=6, label="trajetória do otimizador")
ax.plot(*traj[0], "s", color="red", markersize=12, markeredgecolor="black", label="chute inicial")
ax.plot(*res_callback.x, "*", color="yellow", markersize=18, markeredgecolor="black", label="ótimo")
ax.set_xlabel("T (K)")
ax.set_ylabel(r"$\tau$ (min)")
ax.legend()

Sobrepondo a trajetória ao mesmo mapa de contorno do lucro:

Mapa de contorno do lucro líquido com uma trajetória de pontos brancos conectados por linha, começando num quadrado vermelho e terminando numa estrela amarela no pico; a trajetória não segue caminho reto, sobe demais em tau antes de descer e convergir ao pico
A trajetória do SLSQP não é uma linha reta até o topo: ela ultrapassa o alvo em tau (chega a 10,7 min) antes de corrigir e convergir.

A trajetória está longe de ser uma linha reta até o pico: o otimizador chega a subir \(\tau\) até \(10{,}7\) min (bem além do valor ótimo, \(2{,}74\) min) antes de corrigir o rumo. SLSQP resolve, a cada passo, uma aproximação quadrática local do problema; quando essa aproximação erra a mão longe do ótimo, o passo seguinte pode “ultrapassar” bastante antes de as iterações se estabilizarem. O resultado final está correto, mas o caminho até ele raramente é óbvio.

Tabela-Resumo
#

Aplicação Ferramenta principal Restrições usadas
Reator CSTR (química) minimize com SLSQP Nenhuma (ótimo econômico); 'ineq' (limite de segurança)
Alcance de projétil (física) minimize_scalar + solve_ivp bounds no ângulo; função-objetivo sem fórmula fechada
Viga (engenharia) minimize com SLSQP Três 'ineq' simultâneas (tensão, flecha, esbeltez)
Mix de produção (pesquisa operacional) linprog (HiGHS) A_ub/b_ub: objetivo e restrições todos lineares
Trajetória de busca callback em minimize Nenhuma
Conceito Observação
Maximizar via \(-f\) scipy.optimize só minimiza. Funções menos_alguma_coisa e sinais negativos soltos são esse truque, não erro, e o - tem que aparecer duas vezes: ao definir o objetivo e ao ler res.fun
Maximizar sem custo Sem trade-off, a solução tende a um canto dos bounds; nem sempre é a pergunta certa
Restrição de segurança como constraints Distingue uma limitação de processo real de um limite arbitrário de busca
Restrição binding × com folga Numa solução com várias restrições, checar quais estão exatamente no limite e quais sobram
bounds × constraints Não é só semântica: bounds garante que a função nunca é avaliada fora da caixa; constraints permite excursões. Use bounds quando a função não existe do lado de fora
Fronteira de Pareto Quando há mais de um critério, o resultado honesto não é um ponto, e sim a curva dos compromissos eficientes
linprog × minimize Tudo linear → linprog, mais rápido e com ótimo global certificado. Um único termo não-linear → minimize
slack do linprog Diz quais recursos estão esgotados (folga zero) e quais sobram; é onde está a leitura gerencial da resposta
Robustez a múltiplos chutes Convergir para o mesmo ponto a partir de chutes diferentes é evidência de ótimo único
callback Expõe o caminho percorrido pelo otimizador, não só o resultado final

Próximos Passos
#

As quatro aplicações deste artigo cobrem otimização com restrições de igualdade, de desigualdade, funções-objetivo baseadas em EDOs, múltiplas restrições simultâneas e programação linear. Em todas elas, porém, os parâmetros do modelo eram dados: a energia de ativação do reator, o coeficiente de arrasto do projétil, o módulo de elasticidade da viga.

No laboratório é o contrário. Esses parâmetros são justamente o que se quer descobrir, e o que se tem é um punhado de medidas ruidosas. O próximo artigo trata desse caso, que é, ele próprio, um problema de otimização disfarçado, e responde à pergunta que a série vinha adiando: como acompanhar cada parâmetro ajustado da sua barra de erro.

Do Símbolo ao Número - Este artigo faz parte de uma série de artigos.
Parte 11: Esse Artigo

Relacionados

Otimização com SciPy

·6492 palavras·31 minutos
Derivar e igualar a zero resolve a otimização de livro-texto. Com várias variáveis, restrições ou função sem derivada tratável, a conta trava. Veremos minimize, SLSQP e bounds do scipy.optimize, os mínimos locais e a busca global com differential_evolution.

Equações Diferenciais com SciPy

·7231 palavras·35 minutos
A fórmula clássica do período do pêndulo só vale para oscilações pequenas, e a equação exata não tem solução em forma fechada. Com o solve_ivp do SciPy, medimos o período de verdade e resolvemos sistemas rígidos, Lotka-Volterra, queda com arrasto e reações consecutivas.