Metadata-Version: 2.4
Name: walopy
Version: 0.2.7
Summary: Queuing theory, operations analysis, OEE, bottleneck analysis and KPI trees for Python
Author-email: LeoSanta15 <angelsanta1@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/LeoSanta15/walopy
Project-URL: Issues, https://github.com/LeoSanta15/walopy/issues
Project-URL: Changelog, https://github.com/LeoSanta15/walopy/blob/main/CHANGELOG.md
Keywords: queuing theory,operations research,little's law,kingman,bottleneck,OEE,efficiency,utilization,unit cost,KPI
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Science/Research
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.22
Requires-Dist: pandas>=1.4
Requires-Dist: matplotlib>=3.5
Requires-Dist: plotly>=5.0
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-cov>=4; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Provides-Extra: docs
Requires-Dist: sphinx>=7; extra == "docs"
Requires-Dist: sphinx-rtd-theme>=2; extra == "docs"
Requires-Dist: myst-parser>=2; extra == "docs"
Dynamic: license-file

# walopy

**Teoría de colas, análisis de operaciones, modelos de inventario, árboles de KPI y más para Python.**

[![PyPI version](https://img.shields.io/pypi/v/walopy.svg)](https://pypi.org/project/walopy/)
[![Python](https://img.shields.io/pypi/pyversions/walopy.svg)](https://pypi.org/project/walopy/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![CI](https://github.com/LeoSanta15/walopy/actions/workflows/ci.yml/badge.svg)](https://github.com/LeoSanta15/walopy/actions/workflows/ci.yml)

```bash
pip install walopy
```

---

## Tabla de contenidos

1. [Modelos de colas clásicos](#1-modelos-de-colas-clásicos)
2. [Modelos avanzados y de capacidad finita](#2-modelos-avanzados-y-de-capacidad-finita)
3. [Colas con prioridad](#3-colas-con-prioridad)
4. [Simulación Monte Carlo G/G/1](#4-simulación-monte-carlo-gg1)
5. [Ajuste de parámetros desde datos reales](#5-ajuste-de-parámetros-desde-datos-reales)
6. [Redes de Jackson](#6-redes-de-jackson)
7. [Inventarios](#7-inventarios)
8. [Análisis de operaciones](#8-análisis-de-operaciones)
9. [Análisis de cuellos de botella](#9-análisis-de-cuellos-de-botella)
10. [Balance de línea y tiempo takt](#10-balance-de-línea-y-tiempo-takt)
11. [Análisis de punto de equilibrio](#11-análisis-de-punto-de-equilibrio)
12. [Curvas de intercambio](#12-curvas-de-intercambio)
13. [Programación de producción (scheduling)](#13-programación-de-producción-scheduling)
14. [Confiabilidad](#14-confiabilidad--reliability)
15. [Árboles de KPI](#15-árboles-de-kpi)
16. [Solvers y optimización](#16-solvers-y-optimización)
17. [Análisis de sensibilidad](#17-análisis-de-sensibilidad)
18. [Escenarios en lote (batch_model)](#18-escenarios-en-lote-batch_model)
19. [Comparar múltiples resultados](#19-comparar-múltiples-resultados)
20. [Gráficas](#20-gráficas)
21. [CLI](#21-cli)
22. [Referencia de módulos](#22-referencia-de-módulos)
23. [Requisitos](#23-requisitos)

---

## 1. Modelos de colas clásicos

Todos los modelos devuelven un `QueueResult` con los indicadores estándar de teoría de colas.

### `QueueResult` — atributos principales

| Atributo | Descripción |
|---|---|
| `model` | Nombre del modelo (ej. `"M/M/1"`) |
| `lam` | Tasa de llegada λ |
| `mu` | Tasa de servicio μ por servidor |
| `servers` | Número de servidores *c* |
| `rho` | Utilización por servidor ρ = λ/(c·μ) |
| `L` | Número promedio de unidades en el sistema |
| `Lq` | Número promedio de unidades en la cola |
| `W` | Tiempo promedio en el sistema |
| `Wq` | Tiempo promedio de espera en cola |
| `params` | Parámetros adicionales específicos del modelo |

Métodos disponibles en todos los resultados:
- `.summary()` / `print(r)` — tabla de indicadores
- `.to_frame()` — exporta a `pd.DataFrame` de una fila
- `.plot()` — gráfica interactiva de sensibilidad

---

### `mm1(lam, mu)` — M/M/1

Un servidor, llegadas Poisson, servicio exponencial. El modelo más simple de cola.

**Fórmulas clave:**
- ρ = λ/μ (debe ser < 1 para estabilidad)
- Lq = ρ² / (1 − ρ)
- L = ρ / (1 − ρ)
- Wq = Lq / λ,   W = L / λ

```python
import walopy as wl

r = wl.mm1(lam=3.0, mu=5.0)
print(r)
# Model : M/M/1
# λ     : 3.0   (arrival rate)
# μ     : 5.0   (service rate per server)
# ρ     : 0.6   (utilization)
# L     : 1.5   (avg units in system)
# Lq    : 0.9   (avg units in queue)
# W     : 0.5   (avg time in system)
# Wq    : 0.3   (avg wait time in queue)

df = r.to_frame()   # exportar a DataFrame
r.plot()            # gráfica Wq y Lq vs ρ
```

---

### `mmc(lam, mu, c)` — M/M/c

Multi-servidor con llegadas Poisson y servicio exponencial. Usa la fórmula de Erlang-C para calcular la probabilidad de espera C(c, a).

**Fórmulas:**
- ρ = λ/(c·μ) (utilización por servidor, debe ser < 1)
- a = λ/μ (carga ofrecida)
- C(c,a) = P(esperar) — fórmula de Erlang-C
- Lq = C(c,a) · ρ / (1 − ρ)

```python
r = wl.mmc(lam=8.0, mu=5.0, c=2)
print(r.params["C(c,a) Erlang-C"])  # probabilidad de espera

r_1 = wl.mmc(lam=4.0, mu=5.0, c=1)  # equivalente a mm1
r_3 = wl.mmc(lam=4.0, mu=5.0, c=3)
print(r_3.Wq < r_1.Wq)              # True: más servidores → menor espera
```

---

### `md1(lam, mu)` — M/D/1

Llegadas Poisson, servicio **determinístico** (tiempo de servicio constante = 1/μ). Produce exactamente la mitad de cola que M/M/1 bajo la misma carga.

**Fórmulas:**
- Lq = ρ² / (2·(1 − ρ))   ← mitad que M/M/1
- L = ρ + Lq

```python
r_mm1 = wl.mm1(lam=3.0, mu=5.0)
r_md1 = wl.md1(lam=3.0, mu=5.0)
print(r_md1.Lq / r_mm1.Lq)   # ≈ 0.5 — M/D/1 tiene la mitad de cola
```

---

### `kingman(lam, mu, ca2, cs2)` — G/G/1 (aproximación VUT)

Modelo general G/G/1 mediante la **aproximación de Kingman** (también llamada VUT o fórmula de Pollaczek). Acepta cualquier distribución de llegadas y servicio a través de sus coeficientes de variación al cuadrado.

**Fórmula:**
```
Wq ≈ (ρ / (1 − ρ)) · ((ca² + cs²) / 2) · (1/μ)
```

| Parámetro | Descripción |
|---|---|
| `ca2` | CV² de los tiempos entre llegadas (1.0 para Poisson) |
| `cs2` | CV² de los tiempos de servicio (1.0 exponencial, 0.0 determinístico) |

```python
# Proceso de llegada más regular que Poisson (ca2 < 1)
r = wl.kingman(lam=3.0, mu=5.0, ca2=0.5, cs2=1.0)

# Servicio muy variable (ca2 >> 1)
r = wl.kingman(lam=3.0, mu=5.0, ca2=1.0, cs2=3.0)

# Recuperar parámetros ajustados desde datos reales y usarlos directamente
fit = wl.fit_from_data(inter_arrivals=..., service_times=...)
r   = wl.kingman(**fit.to_model_kwargs())
```

> **Equivalencias:**  
> `ca2=1, cs2=1` → M/M/1  
> `ca2=1, cs2=0` → M/D/1 (aproximado)

---

### `mg1(lam, mu, cs2)` — M/G/1 (Pollaczek-Khinchine exacto)

Llegadas Poisson, servicio de **distribución arbitraria** parametrizado por su media (1/μ) y su variabilidad (cs²). La fórmula P-K es **exacta**, no una aproximación.

**Fórmula P-K:**  
Wq = λ · E[S²] / (2 · (1 − ρ)),   donde E[S²] = (1 + cs²) / μ²

```python
# Servicio con distribución triangular: calcular cs² primero
cs2 = wl.cv2_triangular(a=2, m=5, b=10)   # a=mín, m=moda, b=máx
r   = wl.mg1(lam=0.15, mu=1/5, cs2=cs2)

# Equivalencia con M/M/1 (cs²=1) y M/D/1 (cs²=0)
r_mm1 = wl.mg1(lam=3.0, mu=5.0, cs2=1.0)  # idéntico a mm1(3, 5)
r_md1 = wl.mg1(lam=3.0, mu=5.0, cs2=0.0)  # idéntico a md1(3, 5)
```

#### Helpers `cv2_*` — CV² por distribución

El CV² (varianza / media²) es el único parámetro de distribución que M/G/1 necesita:

| Función | Distribución | CV² |
|---|---|---|
| `cv2_normal(mean, std)` | Normal | (σ/μ)² |
| `cv2_triangular(a, m, b)` | Triangular | fórmula exacta |
| `cv2_uniform(a, b)` | Uniforme | (b−a)² / (12·μ²) |
| `cv2_erlang(k)` | Erlang-k | 1/k |
| `cv2_gamma(shape)` | Gamma | 1/shape |
| `cv2_lognormal(mean, std)` | Lognormal | (σ/μ)² |
| `cv2_weibull(shape)` | Weibull | Γ(1+2/k)/Γ(1+1/k)²−1 |

```python
# Servicio con distribución Weibull de forma 2 (rayleigh)
cs2 = wl.cv2_weibull(shape=2.0)      # ≈ 0.273
r   = wl.mg1(lam=1.0, mu=2.0, cs2=cs2)
print(r.Wq)   # tiempo medio de espera en cola

# Servicio Erlang-3 (menos variable que exponencial)
r = wl.mg1(lam=3.0, mu=5.0, cs2=wl.cv2_erlang(3))
```

---

### `littles_law(*, L, lam, W)` — Ley de Little

Resuelve L = λ · W para la variable que falte. Exactamente uno de los tres parámetros debe ser `None`.

```python
L   = wl.littles_law(lam=5.0, W=0.4)     # L = 2.0
W   = wl.littles_law(L=2.0, lam=5.0)     # W = 0.4
lam = wl.littles_law(L=3.0, W=0.6)       # λ = 5.0
```

---

## 2. Modelos avanzados y de capacidad finita

### `mm1k(lam, mu, K)` — M/M/1/K

Cola con **capacidad finita** K (servidor + sala de espera). Los clientes que llegan cuando el sistema está lleno se pierden. El sistema es siempre estable incluso si λ > μ.

**Parámetros adicionales en `result.params`:**
- `PK (blocking prob)` — probabilidad de rechazo (sistema lleno)
- `lam_eff` — tasa de llegada efectiva = λ · (1 − P_K)

```python
r = wl.mm1k(lam=5.0, mu=3.0, K=10)
print(r.params["PK (blocking prob)"])  # fracción de clientes rechazados
print(r.params["lam_eff"])             # tasa real de entrada al sistema
```

---

### `mmck(lam, mu, c, K)` — M/M/c/K

Multi-servidor con capacidad finita. Generaliza tanto M/M/c (K→∞) como M/M/1/K (c=1). Las probabilidades de estado se calculan en espacio logarítmico para evitar desbordamientos con K grande.

```python
r = wl.mmck(lam=8.0, mu=3.0, c=2, K=20)
print(r.params["PK (blocking prob)"])

# Verificación: con K muy grande se acerca a M/M/c
r_inf  = wl.mmc(lam=3.0, mu=5.0, c=2)
r_fin  = wl.mmck(lam=3.0, mu=5.0, c=2, K=500)
print(abs(r_fin.Lq - r_inf.Lq) < 0.01)  # True
```

**Restricción:** K debe ser ≥ c (la capacidad total no puede ser menor que el número de servidores).

---

### `erlang_b(lam, mu, c)` — Erlang B

Probabilidad de bloqueo para un **sistema de pérdidas M/M/c/c** (sin sala de espera: si todos los servidores están ocupados el cliente se va). Usado en dimensionamiento de líneas telefónicas y redes de comunicación.

```python
pb = wl.erlang_b(lam=5.0, mu=1.0, c=8)
print(f"Blocking probability: {pb:.2%}")  # ej. 1.3%

# Cuántos servidores para bloqueo < 2%?
for c in range(1, 20):
    if wl.erlang_b(5.0, 1.0, c) < 0.02:
        print(f"Need c = {c} servers")
        break
```

---

### `queue_length_pmf(lam, mu, n_max)` — PMF de longitud de cola

Distribución de probabilidad del número de clientes en el sistema para M/M/1.
Devuelve un `pd.DataFrame` con columnas `n`, `P(N=n)`, `P(N<=n)`.

```python
df = wl.queue_length_pmf(lam=3.0, mu=5.0, n_max=20)
print(df.head())
# n  P(N=n)  P(N<=n)
# 0  0.400   0.400
# 1  0.240   0.640
# 2  0.144   0.784
# ...

# ¿Probabilidad de tener más de 5 clientes en sistema?
prob_gt5 = 1 - df.loc[df["n"] == 5, "P(N<=n)"].iloc[0]
```

---

### `sojourn_cdf(lam, mu, t_max, n_points)` — CDF del tiempo de permanencia

Distribución acumulada del tiempo total en el sistema (sojourn time) para M/M/1.
Devuelve un `pd.DataFrame` con columnas `t`, `F(t)`, `f(t)`.

```python
df = wl.sojourn_cdf(lam=3.0, mu=5.0)
# ¿Cuánto tiempo cubre el 95% de los clientes?
t95 = df.loc[df["F(t)"] >= 0.95, "t"].iloc[0]
print(f"95% of customers leave before t = {t95:.3f}")
```

---

## 3. Colas con prioridad

### `mm1_priority(lam_list, mu, *, class_names)` — M/M/1 HOL no-preemptiva

Cola de un servidor con múltiples clases de prioridad. La clase 0 tiene la prioridad más alta. La disciplina es **Head-Of-Line (HOL) no-preemptiva**: un cliente de alta prioridad que llega no interrumpe al que está siendo atendido, pero sí pasa delante de todos los que esperan.

**Fórmula de Kleinrock:**
```
Wq_k = R / ((1 − σ_{k−1}) · (1 − σ_k))
```
donde R = ρ_total / μ es el tiempo residual de servicio y σ_k = Σ_{i=0}^{k} ρ_i.

```python
# 3 clases: alta (λ=2), media (λ=1.5), baja (λ=0.5), μ=5 compartido
pri = wl.mm1_priority([2.0, 1.5, 0.5], mu=5.0)
print(pri)

for cls in pri.classes:
    print(f"Clase {cls['class_id']}: Wq = {cls['Wq']:.4f}")

# Con nombres de clase personalizados
pri = wl.mm1_priority(
    [2.0, 1.0],
    mu=5.0,
    class_names=["VIP", "Estándar"],
)
print(pri.classes[0]["Wq"])   # VIP espera mucho menos
print(pri.classes[1]["Wq"])   # Estándar espera más que en cola FIFO

df = pri.to_frame()  # DataFrame con una fila por clase
```

**`PriorityQueueResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `classes` | Lista de dicts con `class_id`, `lam`, `rho`, `Wq`, `W`, `Lq`, `L` por clase |
| `rho_total` | Utilización total del servidor |
| `mu` | Tasa de servicio compartida |

**Restricción:** la suma de todas las tasas de llegada debe ser < μ (sistema estable).

---

## 4. Simulación Monte Carlo G/G/1

### `monte_carlo_gg1(lam, mu, ca2, cs2, n_customers, seed)` — Simulación event-driven

Simula una cola G/G/1 con distribuciones gamma calibradas para reproducir cualquier ca² y cs². Útil para validar aproximaciones analíticas y obtener percentiles que los modelos cerrados no dan.

```python
sim = wl.monte_carlo_gg1(
    lam=3.0, mu=5.0,
    ca2=1.0, cs2=0.5,    # ca2=1 → Poisson, cs2=0.5 → Erlang-2
    n_customers=50_000,
    seed=42,
)

print(sim.Wq_mean)   # promedio
print(sim.Wq_p50)    # mediana
print(sim.Wq_p90)    # percentil 90
print(sim.Wq_p95)    # percentil 95
print(sim.Wq_p99)    # percentil 99

sim.plot()           # histograma + CDF empírica con percentiles anotados
```

**`SimulationResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `Wq_mean` | Tiempo promedio de espera simulado |
| `Wq_p50/p90/p95/p99` | Percentiles de la distribución de espera |
| `L_mean`, `Lq_mean` | Longitudes de sistema y cola promedio |
| `model` | Descripción del modelo simulado |

---

## 5. Ajuste de parámetros desde datos reales

### `fit_from_data(inter_arrivals, service_times, *, arrival_timestamps)` — Estimación de parámetros

Estima λ, μ, ca², cs² directamente desde datos observados. Útil para calibrar modelos cuando los parámetros no se conocen teóricamente.

**Formas de uso:**

```python
import numpy as np
import walopy as wl

rng = np.random.default_rng(42)

# 1. Desde tiempos entre llegadas y tiempos de servicio
ia  = rng.exponential(scale=0.2, size=1000)   # media = 0.2 → λ ≈ 5
svc = rng.exponential(scale=0.1, size=1000)   # media = 0.1 → μ ≈ 10

fit = wl.fit_from_data(ia, svc)
print(fit)
# λ ≈ 5.0   ca² ≈ 1.0   (exponencial → CV² = 1)
# μ ≈ 10.0  cs² ≈ 1.0

# 2. Solo tiempos entre llegadas (sin datos de servicio)
fit_ia = wl.fit_from_data(ia)
print(fit_ia.lam, fit_ia.ca2)
print(fit_ia.mu)     # None

# 3. Desde timestamps crudos (no se tienen los intervalos directamente)
ts  = np.cumsum(rng.exponential(0.2, 1000))   # timestamps acumulados
fit = wl.fit_from_data(arrival_timestamps=ts)

# 4. Conectar directamente con un modelo
r = wl.kingman(**fit.to_model_kwargs())  # usa lam, mu, ca2, cs2
```

**`FitResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `lam` | Tasa de llegada estimada (None si no hay datos de llegada) |
| `mu` | Tasa de servicio estimada (None si no hay datos de servicio) |
| `ca2` | CV² de tiempos entre llegadas |
| `cs2` | CV² de tiempos de servicio |
| `n_arrivals` | Número de observaciones de llegada |
| `n_services` | Número de observaciones de servicio |
| `mean_ia` | Media de tiempos entre llegadas |
| `mean_svc` | Media de tiempos de servicio |

Métodos: `.to_model_kwargs()` → dict con `lam`, `mu`, `ca2`, `cs2` listos para `kingman()`. `.to_frame()` → DataFrame.

---

## 6. Redes de Jackson

### `jackson_network(station_names, mu, gamma, routing, *, servers)` — Red abierta de colas

Analiza una red de colas de Jackson abierta. Por el **Teorema de Jackson**, cada estación se comporta como una cola M/M/c independiente con una tasa de llegada efectiva que satisface las ecuaciones de tráfico:

```
(I − P^T) · λ = γ
```

donde γ_i es la tasa de llegada externa a la estación i y P_ij es la probabilidad de routing de i a j.

```python
# Red de 3 estaciones en serie con estación final de 2 servidores
net = wl.jackson_network(
    station_names=["Recepción", "Control de calidad", "Empaque"],
    mu=[10.0, 8.0, 12.0],                  # tasa de servicio por servidor
    gamma=[5.0, 0.0, 0.0],                 # solo llegan clientes externos a Recepción
    routing=[
        [0.0, 1.0, 0.0],   # Recepción → 100% a QC
        [0.0, 0.0, 1.0],   # QC → 100% a Empaque
        [0.0, 0.0, 0.0],   # Empaque → sale del sistema
    ],
    servers=[1, 1, 2],                     # servidores por estación
)

print(net)
print(net.to_frame())   # DataFrame con métricas por estación

# Acceder a métricas por estación
for st in net.stations:
    print(f"{st.name}: λ={st.lam_total:.2f}, Wq={st.Wq:.4f}, ρ={st.rho:.2f}")

# Indicadores del sistema completo
print(f"L_sistema = {net.L_system:.4f}")  # clientes totales en toda la red
print(f"W_sistema = {net.W_system:.4f}")  # tiempo total en la red

# Red con bifurcación
net2 = wl.jackson_network(
    station_names=["Entrada", "Ruta A", "Ruta B"],
    mu=[20.0, 8.0, 10.0],
    gamma=[10.0, 0.0, 0.0],
    routing=[
        [0.0, 0.6, 0.4],  # 60% va a Ruta A, 40% a Ruta B
        [0.0, 0.0, 0.0],
        [0.0, 0.0, 0.0],
    ],
)
```

**`JacksonResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `stations` | Lista de `StationMetrics` por estación |
| `L_system` | Número total promedio de clientes en toda la red |
| `W_system` | Tiempo total promedio en la red (Little: L = λ_ext · W) |

**`StationMetrics` — atributos:**

| Atributo | Descripción |
|---|---|
| `name` | Nombre de la estación |
| `lam_total` | Tasa de llegada total (externa + interna) |
| `lam_external` | Tasa de llegada externa γ_i |
| `mu` | Tasa de servicio por servidor |
| `servers` | Número de servidores |
| `rho` | Utilización ρ = λ / (c·μ) |
| `L`, `Lq`, `W`, `Wq` | Indicadores estándar de cola |

**Restricciones:** todas las estaciones deben ser estables (ρ < 1). La suma de probabilidades de routing por fila debe ser ≤ 1.

---

## 7. Inventarios

### `eoq(demand_rate, ordering_cost, holding_cost)` — Cantidad Económica de Pedido

Fórmula de Wilson/Harris: minimiza la suma de costo de ordenar y costo de almacenar.

**Fórmula:**
```
Q* = √(2 · D · K / h)
```

En el óptimo: costo de mantener = costo de ordenar (propiedad de cruce).

```python
r = wl.eoq(
    demand_rate=1000,    # D: unidades por período
    ordering_cost=50,    # K: costo fijo por orden
    holding_cost=2,      # h: costo por unidad por período
)
print(r)
# EOQ (order quantity): 223.6 units
# Order frequency     : 4.472 orders/period
# Cycle time          : 0.2236 periods
# Total cost          : 447.2
#   Holding cost      : 223.6
#   Ordering cost     : 223.6   ← iguales en el óptimo

r.plot()            # gráfica de curvas de costo
df = r.to_frame()
```

**`EOQResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `eoq` | Cantidad óptima Q* |
| `total_cost` | Costo total mínimo por período |
| `holding_cost_total` | Componente de almacenamiento en Q* |
| `ordering_cost_total` | Componente de pedido en Q* |
| `order_frequency` | Número de pedidos por período D/Q* |
| `cycle_time` | Tiempo entre pedidos 1/(D/Q*) |

---

### `reorder_point(demand_rate, lead_time, *, demand_std, lead_time_std, service_level)` — Punto de reorden

Calcula el nivel de inventario al que se debe lanzar una orden de reposición, incluyendo **stock de seguridad** para cubrir la variabilidad de la demanda y del tiempo de entrega.

**Fórmulas:**
```
σ_DLT = √(L̄ · σ_D² + D̄² · σ_L²)
ROP   = D̄ · L̄ + z · σ_DLT
```

```python
r = wl.reorder_point(
    demand_rate=50,      # demanda media por período
    lead_time=2,         # tiempo de entrega medio (en mismas unidades)
    demand_std=10,       # desviación estándar de la demanda
    lead_time_std=0.5,   # desviación estándar del lead time
    service_level=0.95,  # nivel de servicio deseado
)
print(r)
# Reorder point   : 132.4 units
# Safety stock    : 32.4 units
# Service level   : 95.00%
# z-score         : 1.645
# Mean demand LT  : 100.0
# Std demand LT   : 19.72

# Sin variabilidad → solo demanda esperada durante LT
r_det = wl.reorder_point(demand_rate=50, lead_time=2,
                          demand_std=0, lead_time_std=0)
print(r_det.safety_stock)   # 0.0 — no se necesita buffer

# Comparar niveles de servicio
r_90 = wl.reorder_point(50, 2, demand_std=10, service_level=0.90)
r_99 = wl.reorder_point(50, 2, demand_std=10, service_level=0.99)
print(r_99.safety_stock > r_90.safety_stock)  # True
```

**`ReorderResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `reorder_point` | Nivel ROP en unidades |
| `safety_stock` | Stock de seguridad = z · σ_DLT |
| `service_level` | Nivel de servicio de ciclo |
| `z_score` | Factor de seguridad z |
| `mean_demand_lt` | Demanda esperada durante el lead time |
| `std_demand_lt` | Desviación estándar de la demanda durante LT |

---

### `newsvendor(demand_mean, demand_std, price, cost, *, salvage)` — Modelo del vendedor de periódicos

Optimiza la cantidad a ordenar para un producto **perecedero o de temporada** bajo demanda incierta. Equilibra el costo de quedarse corto (Cu) con el costo de sobrar (Co).

**Fórmulas:**
```
Cu = precio − costo           (costo de substock: venta perdida)
Co = costo − salvage          (costo de sobrestock: unidad no vendida)
CR = Cu / (Cu + Co)           (ratio crítico)
Q* = F⁻¹(CR)                  (cuantil CR de la demanda)
```

```python
r = wl.newsvendor(
    demand_mean=100,
    demand_std=20,
    price=10,        # precio de venta
    cost=6,          # costo de compra
    salvage=2,       # valor residual de unidades no vendidas
)
print(r)
# Optimal quantity   : 100.0 units
# Critical ratio     : 0.5
# Expected profit    : 320.0
# Expected sales     : 100.0
# Expected leftover  : 0.0
# Expected stockout  : 0.0
# Underage cost Cu   : 4.0
# Overage cost  Co   : 4.0

# Producto con alto margen → pedir por encima de la media
r_alto = wl.newsvendor(demand_mean=100, demand_std=20,
                        price=50, cost=6, salvage=0)
print(r_alto.optimal_qty > 100)   # True

# Producto con bajo margen → pedir por debajo de la media
r_bajo = wl.newsvendor(demand_mean=100, demand_std=20,
                        price=7, cost=6, salvage=2)
print(r_bajo.optimal_qty < 100)   # True
```

**`NewsvendorResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `optimal_qty` | Q* óptimo |
| `critical_ratio` | CR = Cu/(Cu+Co) |
| `expected_profit` | Beneficio esperado en Q* |
| `expected_sales` | Ventas esperadas E[min(D, Q*)] |
| `expected_leftover` | Sobrante esperado E[max(Q*−D, 0)] |
| `expected_stockout` | Rotura esperada E[max(D−Q*, 0)] |
| `underage_cost` | Cu — costo de substock |
| `overage_cost` | Co — costo de sobrestock |

---

### `ebq(demand_rate, setup_cost, holding_cost, production_rate)` — Lote Económico de Producción (EBQ/EPQ)

Extiende el EOQ al caso en que la producción y el consumo ocurren **simultáneamente** (tasa de producción finita P > D).

**Fórmula:**
```
Q* = √(2 · D · S / (h · (1 − D/P)))
Inventario máximo = Q* · (1 − D/P)
Inventario medio  = Inventario máximo / 2
```

```python
r = wl.ebq(
    demand_rate=1000,       # D: unidades/período
    setup_cost=50,          # S: costo fijo de setup por lote
    holding_cost=2,         # h: costo/unidad/período
    production_rate=4000,   # P: tasa de producción (debe ser > D)
)
print(r)
# EBQ (batch size)    : 258.2 units
# Max inventory       : 193.6
# Avg inventory       : 96.82
# Order frequency     : 3.873 runs/period
# Cycle time          : 0.2582 periods
# Production time/cyc : 0.06455 periods
# Total cost          : 193.6
#   Holding cost      : 96.82
#   Setup cost        : 96.82
```

**`EBQResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `ebq` | Lote óptimo Q* |
| `max_inventory` | Nivel máximo de inventario Q*(1−D/P) |
| `avg_inventory` | Inventario promedio = max/2 |
| `production_time` | Tiempo de producción por ciclo = Q*/P |
| `cycle_time` | Duración del ciclo = Q*/D |
| `total_cost` | Costo total mínimo |

---

### `eoq_multi(demand_rates, ordering_costs, holding_costs, *, names)` — EOQ multi-ítem independiente

Resuelve el EOQ para cada ítem por separado sin restricciones compartidas.

```python
r = wl.eoq_multi(
    demand_rates   = [1000, 500, 800],
    ordering_costs = [50,   30,  40 ],
    holding_costs  = [2,    1,   1.5],
    names          = ["A", "B", "C"],
)
print(r.total_cost)   # suma de costos óptimos individuales
df = r.to_frame()     # DataFrame con EOQ, costos y frecuencias por ítem
```

---

### `ebq_multi(demand_rates, setup_costs, holding_costs, production_rates, *, names)` — EBQ multi-ítem independiente

```python
r = wl.ebq_multi(
    demand_rates    = [1000, 500],
    setup_costs     = [50,   30 ],
    holding_costs   = [2,    1  ],
    production_rates= [4000, 2000],
)
df = r.to_frame()   # EBQ, inventario máximo/medio, costo por ítem
```

---

### `eoq_multi_constrained(demand_rates, ordering_costs, holding_costs, ...)` — EOQ multi-ítem con restricciones

Minimiza el costo total de inventario sujeto a **restricciones lineales** sobre las cantidades de pedido mediante **relajación Lagrangiana** (búsqueda binaria sobre el multiplicador).

**Restricciones soportadas:**

| Parámetro | Restricción |
|---|---|
| `budget`, `budget_unit_costs` | Σ(c_i · Q_i / 2) ≤ budget — valor promedio en inventario |
| `space`, `space_per_unit` | Σ(s_i · Q_i / 2) ≤ space — espacio promedio ocupado |
| `constraints` | Lista de `{"name", "weights", "bound"}` — Σ(a_i · Q_i) ≤ B |

```python
# Restricción de presupuesto
r = wl.eoq_multi_constrained(
    [1000, 500, 800], [50, 30, 40], [2, 1, 1.5],
    budget=5000,
    budget_unit_costs=[10, 8, 12],   # costo unitario de compra
)
print(r.binding_constraints)   # ["budget"] si la restricción está activa
print(r.lagrange_multipliers)  # {"budget": λ}
df = r.to_frame()              # Q*, holding cost, ordering cost por ítem

# Restricción de espacio
r2 = wl.eoq_multi_constrained(
    [1000, 500, 800], [50, 30, 40], [2, 1, 1.5],
    space=200,
    space_per_unit=[0.5, 0.3, 0.4],
)

# Restricción genérica: suma total de unidades pedidas ≤ 400
r3 = wl.eoq_multi_constrained(
    [1000, 500, 800], [50, 30, 40], [2, 1, 1.5],
    constraints=[{"name": "total_qty", "weights": [1, 1, 1], "bound": 400}],
)
```

**`ConstrainedMultiEOQResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `items` | Lista de dicts por ítem: nombre, Q*, costos |
| `total_cost` | Costo total bajo las restricciones |
| `unconstrained_total_cost` | Costo sin restricciones (cota inferior) |
| `lagrange_multipliers` | Diccionario {nombre_restricción: λ} |
| `binding_constraints` | Nombres de restricciones activas (λ > 0) |

---

### `lot_for_lot(demands, setup_cost, holding_cost)` — Lote por Lote

Heurística de dimensionado dinámico: ordena **exactamente la demanda de cada período**. Minimiza el costo de almacenamiento (cero inventario en tránsito) a cambio de un setup por período.

```python
demands = [100, 80, 0, 120, 60]   # demandas por período
r = wl.lot_for_lot(demands, setup_cost=200, holding_cost=1)
print(r.total_holding_cost)   # 0.0 — sin inventario residual
print(r.n_orders)             # 4 — un pedido por período con demanda > 0
print(r)
# Method              : Lot-for-Lot
# Number of orders    : 4
# Total cost          : 800.0
```

---

### `silver_meal(demands, setup_cost, holding_cost)` — Silver-Meal

Heurística de dimensionado dinámico que **agrupa períodos** mientras el costo promedio por período decrece, reduciendo el número de setups.

```python
r = wl.silver_meal(demands, setup_cost=200, holding_cost=1)
print(r.total_cost <= wl.lot_for_lot(demands, 200, 1).total_cost)  # True
df = r.to_frame()   # pedidos: período, cantidad, períodos cubiertos
```

**`LotSizingResult` — atributos (Lot-for-Lot y Silver-Meal):**

| Atributo | Descripción |
|---|---|
| `orders` | Lista de pedidos: período, cantidad, períodos cubiertos |
| `total_cost` | Costo total (setup + almacenamiento) |
| `total_setup_cost` | Costo total de setups |
| `total_holding_cost` | Costo total de almacenamiento |
| `n_orders` | Número de pedidos emitidos |
| `method` | Nombre de la heurística usada |

---

### `eoq_quantity_discount(demand_rate, ordering_cost, holding_cost_rate, price_breaks)` — EOQ con descuentos por cantidad

Evalúa todos los tramos de precio (all-units) y selecciona la cantidad que **minimiza el costo total anual** (compra + pedido + almacenamiento), ajustando el EOQ al tramo válido cuando cae fuera de rango.

```python
r = wl.eoq_quantity_discount(
    demand_rate=1000,
    ordering_cost=50,
    holding_cost_rate=0.20,   # 20% del precio unitario por año
    price_breaks=[
        (0,    10.00),   # precio base
        (500,   9.50),   # ≥ 500 unidades → $9.50
        (1000,  9.00),   # ≥ 1000 unidades → $9.00
    ],
)
print(r.optimal_qty)    # cantidad óptima seleccionada
print(r.unit_price)     # precio en ese tramo
print(r.total_cost)     # costo anual mínimo
df = r.to_frame()       # todos los candidatos evaluados
```

**`QuantityDiscountResult` — atributos:**

| Atributo | Descripción |
|---|---|
| `optimal_qty` | Cantidad óptima ajustada al tramo |
| `unit_price` | Precio unitario en el tramo seleccionado |
| `total_cost` | Costo total anual (compra + pedido + almacenamiento) |
| `purchase_cost` | Costo de compra anual D · precio |
| `ordering_cost_total` | Costo de pedido anual (D/Q) · K |
| `holding_cost_total` | Costo de almacenamiento anual (Q/2) · h · precio |
| `candidates` | Lista de todos los tramos evaluados |

---

## 8. Análisis de operaciones

### `oee(availability, performance, quality)` — OEE

Calcula la **Eficiencia Global de los Equipos** (OEE = Availability × Performance × Quality).

```python
r = wl.oee(
    availability=0.90,   # tiempo disponible / tiempo planificado
    performance=0.80,    # producción real / producción teórica
    quality=0.95,        # unidades buenas / unidades totales
)
print(r)
# OEE = 68.4%
# Clase mundial: ≥ 85%

r.plot()    # gráfica horizontal con referencia world-class (85%)
```

### `utilization_efficiency(actual_output, max_output)` — Utilización

```python
r = wl.utilization_efficiency(actual_output=750, max_output=1000)
print(r.utilization)         # 0.75 — 75% de utilización
print(r.idle_fraction)       # 0.25 — 25% capacidad ociosa
```

### `unit_cost(fixed_cost, variable_cost, units)` — Costo unitario

```python
r = wl.unit_cost(fixed_cost=10_000, variable_cost=5_000, units=500)
print(r.unit_cost)           # 30.0 $/unidad
print(r.fixed_cost_per_unit) # 20.0
print(r.var_cost_per_unit)   # 10.0
```

---

## 9. Análisis de cuellos de botella

### `bottleneck_analysis(station_names, capacities, demand_rate)` — Teoría de restricciones

Identifica el cuello de botella de una línea de producción comparando la capacidad de cada estación con la demanda.

```python
r = wl.bottleneck_analysis(
    station_names=["Corte", "Soldadura", "Pintura"],
    capacities=[120.0, 80.0, 100.0],  # unidades/hora por estación
    demand_rate=70.0,                  # demanda requerida
)
print(r)
# Bottleneck: Soldadura  (capacidad 80 < demanda 70 con menor margen)
# Utilization: Corte=58.3%, Soldadura=87.5%, Pintura=70.0%

r.plot()   # barras de utilización con cuello de botella en rojo
```

---

## 10. Balance de línea y tiempo takt

### `takt_time(available_time, demand)` — Tiempo takt

Ritmo de producción requerido para satisfacer la demanda del cliente.

```python
takt = wl.takt_time(available_time=480, demand=60)
print(takt)   # 8.0 min/unidad
```

### `line_balance(names, cycle_times, takt)` — Balance de línea

Analiza si cada estación puede cumplir con el tiempo takt y calcula la eficiencia de balance.

```python
lb = wl.line_balance(
    names=["A", "B", "C"],
    cycle_times=[5.0, 9.0, 4.0],
    takt=10.0,
)
print(lb.bottleneck)           # "B" — estación más lenta
print(lb.balance_efficiency)   # 0.60 — 60% de eficiencia
print(lb.total_idle_time)      # tiempo ocioso total

lb.plot()  # barras de cycle time vs takt
```

---

## 11. Análisis de punto de equilibrio

### `break_even(fixed_cost, price_per_unit, variable_cost_per_unit, *, actual_units)` — Punto de equilibrio

```python
be = wl.break_even(
    fixed_cost=10_000,
    price_per_unit=25,
    variable_cost_per_unit=15,
    actual_units=1_500,
)
print(be.bep_units)              # 1000.0 — punto de equilibrio en unidades
print(be.bep_revenue)            # 25000.0 — ingresos en el punto de equilibrio
print(be.margin_of_safety_pct)   # 33.3% — margen de seguridad sobre ventas actuales
print(be.profit)                 # 5000.0 — beneficio con actual_units

be.plot()   # líneas de ingresos/costos con BEP y zona de beneficio
```

### `break_even_multi(fixed_cost, prices, variable_costs, sales_mix)` — Multi-producto

Calcula el punto de equilibrio para una mezcla de varios productos usando la **contribución marginal ponderada (WACM)**.

**Fórmula:** WACM = Σ(CMᵢ × mixᵢ) / Σmixᵢ, luego BEP_total = FC / WACM

```python
r = wl.break_even_multi(
    fixed_cost=120_000,
    prices=[50, 80, 120],
    variable_costs=[30, 50, 70],
    sales_mix=[3, 2, 1],          # proporción de ventas
)
print(r.weighted_avg_cm)          # WACM ≈ 36.67
print(r.bep_units_total)          # unidades totales en el punto de equilibrio
print(r.bep_revenue_total)        # ingresos totales en el punto de equilibrio

for item in r.items:
    print(item["Name"], item["BEP units"])

r.to_frame()    # DataFrame con una fila por producto
```

| Atributo | Descripción |
|---|---|
| `weighted_avg_cm` | Contribución marginal ponderada |
| `bep_units_total` | Unidades totales en el BEP |
| `bep_revenue_total` | Ingresos totales en el BEP |
| `items` | Lista de dicts por producto con `BEP units`, `BEP revenue` |

---

### `break_even_sales(fixed_cost, variable_cost_ratio, *, actual_revenue)` — Punto de equilibrio en ingresos

Calcula el BEP en términos de ingresos cuando los costos variables se expresan como porcentaje de las ventas.

**Fórmula:** BEP = FC / (1 − RCV)

```python
r = wl.break_even_sales(
    fixed_cost=50_000,
    variable_cost_ratio=0.60,     # 60% de los ingresos son costos variables
    actual_revenue=200_000,
)
print(r.bep_revenue)              # 125 000 — ingresos en el BEP
print(r.contribution_margin_ratio) # 0.40 — margen de contribución
print(r.margin_of_safety_units)   # 75 000 — margen de seguridad en ingresos
print(r.margin_of_safety_pct)     # 0.375 — porcentaje del margen de seguridad
```

---

## 15. Árboles de KPI

Los árboles de KPI crean jerarquías interactivas visualizadas como **treemap** o **sunburst** con Plotly.

### `oee_kpi_tree(availability, performance, quality)`

```python
tree = wl.oee_kpi_tree(0.90, 0.80, 0.95)
tree.plot()                     # treemap (por defecto)
tree.plot(kind="sunburst")      # diagrama radial
```

### `throughput_kpi_tree(actual_throughput, capacity, defect_rate)`

```python
tree = wl.throughput_kpi_tree(
    actual_throughput=750,
    capacity=1000,
    defect_rate=0.05,
)
tree.plot()
```

### `roi_kpi_tree(revenue, fixed_cost, variable_cost_per_unit, units_sold, investment)`

```python
tree = wl.roi_kpi_tree(
    revenue=50_000,
    fixed_cost=10_000,
    variable_cost_per_unit=8,
    units_sold=2_000,
    investment=20_000,
)
print(tree.value)   # ROI = 0.2 (20%)
tree.plot()
```

### `KPINode` — árbol personalizado

```python
root = wl.KPINode("EBITDA", value=100_000, unit="€")
rev  = root.add_child("Revenue",        value=200_000, unit="€")
cost = root.add_child("Operating costs", value=100_000, unit="€")
rev.add_child("Product A", value=120_000, unit="€")
rev.add_child("Product B", value=80_000,  unit="€")

from walopy.plotting import plot_kpi_tree
fig = plot_kpi_tree(root, kind="treemap")
fig.show()
```

---

## 16. Solvers y optimización

### `solve_lam(metric, target, *, mu, model)` — Máxima tasa de llegada

Encuentra la mayor λ tal que una métrica no supere el objetivo.

```python
r = wl.solve_lam("Wq", target=0.5, mu=5.0, model="mm1")
print(r.value)          # λ máxima ≈ 3.33
print(r.achieved_value) # Wq ≈ 0.5
print(r.model_result)   # QueueResult completo en la solución
```

### `solve_mu(metric, target, *, lam, model)` — Mínima tasa de servicio

```python
r = wl.solve_mu("Wq", target=0.3, lam=3.0, model="mm1")
print(r.value)   # μ mínima necesaria ≈ 5.0
```

### `solve_servers(metric, target, *, lam, mu)` — Mínimo número de servidores

```python
r = wl.solve_servers("Wq", target=0.1, lam=8.0, mu=5.0)
print(r.value)         # c mínimo = 3
print(r.model_result)  # QueueResult para M/M/3
```

### `optimize_servers(lam, mu, cost_per_server, cost_per_wait)` — Optimización de costo total

Minimiza cost_per_server × c + cost_per_wait × λ × Wq evaluando todos los c viables.

```python
r = wl.optimize_servers(
    lam=6.0,
    mu=5.0,
    cost_per_server=10.0,   # costo fijo por servidor por unidad de tiempo
    cost_per_wait=5.0,      # costo por unidad de tiempo de espera de cada cliente
)
print(r.optimal_servers)   # c óptimo
print(r.optimal_cost)      # costo mínimo

r.plot()   # gráfica de costo vs número de servidores
```

---

## 17. Análisis de sensibilidad

### `sensitivity(model_fn, param, values, **fixed_kwargs)` — Barrido paramétrico

Evalúa un modelo sobre un rango de valores de un parámetro.

```python
import numpy as np

df = wl.sensitivity(
    wl.mm1,
    param="lam",
    values=np.linspace(0.5, 4.5, 20),
    mu=5.0,       # parámetros fijos
)
print(df.columns.tolist())  # ['lam', 'rho', 'L', 'Lq', 'W', 'Wq', ...]

# Barrido sobre μ
df2 = wl.sensitivity(wl.mm1, "mu", np.linspace(4.0, 10.0, 15), lam=3.0)

# Barrido sobre número de servidores en M/M/c
df3 = wl.sensitivity(wl.mmc, "c", range(1, 6), lam=8.0, mu=5.0)

# Visualizar
from walopy.plotting import plot_sensitivity
fig = plot_sensitivity(df, "lam", metrics=["Wq", "Lq"])
fig.show()
```

---

## 18. Escenarios en lote (batch_model)

### `batch_model(model_fn, df, **fixed_kwargs)` — Aplicar modelo a un DataFrame

Aplica cualquier función de walopy a cada fila de un DataFrame de escenarios.

```python
import pandas as pd

# Varios escenarios con distintas tasas de llegada
escenarios = pd.DataFrame({
    "lam": [1.0, 2.0, 3.0, 4.0]
})
resultado = wl.batch_model(wl.mm1, escenarios, mu=5.0)
print(resultado[["lam", "rho", "Wq", "L"]])

# Escenarios mixtos (lambda y mu variables)
escenarios2 = pd.DataFrame({
    "lam": [2.0, 3.0, 4.0],
    "mu":  [5.0, 6.0, 7.0],
})
resultado2 = wl.batch_model(wl.mm1, escenarios2)

# Con número de servidores variable (columna int)
escenarios3 = pd.DataFrame({
    "c": [1, 2, 3, 4],
})
resultado3 = wl.batch_model(wl.mmc, escenarios3, lam=8.0, mu=5.0)

# Las filas con error quedan marcadas en "_error"
print(resultado[resultado["_error"].notna()])  # filas fallidas
```

---

## 19. Comparar múltiples resultados

### `compare(*results, labels)` — DataFrame comparativo

Construye un DataFrame con una fila por resultado para facilitar la comparación.

```python
df = wl.compare(
    wl.mm1(3.0, 5.0),
    wl.mmc(3.0, 5.0, 2),
    wl.md1(3.0, 5.0),
    labels=["M/M/1", "M/M/2", "M/D/1"],
)
print(df[["label", "ρ (utilization)", "Wq (wait time)", "L (system)"]])
#    label  ρ (utilization)  Wq (wait time)  L (system)
# 0  M/M/1          0.6         0.3000         1.50
# 1  M/M/2          0.3         0.0302         1.02
# 2  M/D/1          0.6         0.1500         1.05

# Sin etiquetas → scenario_1, scenario_2, ...
df2 = wl.compare(wl.mm1(1, 5), wl.mm1(2, 5), wl.mm1(3, 5))
```

---

## 20. Gráficas

Todos los resultados exponen `.plot()` que devuelve una figura de Matplotlib o Plotly. También se pueden llamar directamente desde `walopy.plotting`.

| Función `.plot()` | Tipo | Descripción |
|---|---|---|
| `QueueResult.plot()` | Matplotlib | Curvas Wq y Lq vs ρ con punto operativo |
| `OEEResult.plot()` | Matplotlib | Barras horizontales OEE con referencia 85% |
| `BottleneckResult.plot()` | Matplotlib | Utilización por estación, cuello de botella en rojo |
| `EOQResult.plot()` | Matplotlib | Curvas de costo de mantener, ordenar y total |
| `LineBalanceResult.plot()` | Plotly | Cycle time vs takt por estación |
| `BreakEvenResult.plot()` | Plotly | Líneas ingreso/costo con BEP y zona de beneficio |
| `SimulationResult.plot()` | Plotly | Histograma de Wq + CDF empírica con percentiles |
| `OptimizeResult.plot()` | Plotly | Costo por servidor, costo de espera y total vs c |
| `KPINode.plot(kind=)` | Plotly | Treemap o sunburst interactivo |

```python
# Guardar como HTML interactivo (Plotly)
fig = r.plot()
fig.write_html("resultado.html")

# Guardar como imagen (Matplotlib)
fig = wl.oee(0.9, 0.8, 0.95).plot()
fig.savefig("oee.png", dpi=150)
```

Para la curva de sensibilidad:
```python
from walopy.plotting import plot_sensitivity
df = wl.sensitivity(wl.mm1, "lam", np.linspace(0.5, 4.5, 20), mu=5.0)
fig = plot_sensitivity(df, "lam", metrics=["Wq", "Lq", "L"])
fig.show()
```

---

## 21. CLI

walopy incluye una interfaz de línea de comandos para uso rápido sin escribir código.

```bash
# M/M/1
python -m walopy mm1 --lam 3 --mu 5

# M/M/c
python -m walopy mmc --lam 8 --mu 5 --c 2

# M/D/1
python -m walopy md1 --lam 3 --mu 5

# G/G/1 (Kingman)
python -m walopy gg1 --lam 3 --mu 5 --ca2 1.2 --cs2 0.8

# Ley de Little (resolver W)
python -m walopy littles --L 2.5 --lam 5.0

# EOQ
python -m walopy eoq --demand 1000 --ordering 50 --holding 2

# Versión
python -m walopy --version
```

Ejemplo de salida:
```
Model : M/M/1
λ     : 3.0   (arrival rate)
μ     : 5.0   (service rate per server)
ρ     : 0.6   (utilization)
L     : 1.5   (avg units in system)
Lq    : 0.9   (avg units in queue)
W     : 0.5   (avg time in system)
Wq    : 0.3   (avg wait time in queue)
  P0      : 0.4
```

---

## 12. Curvas de intercambio

Herramientas para tomar decisiones de política agregada en familias de artículos.

### Tipo 1 — ciclo: `exchange_curve`

Traza la hipérbola entre número de pedidos por año (N) e inversión promedio en inventario de ciclo (I):

```
N(k) = N* / k      I(k) = k · I*      →      N · I = N* · I*  (constante)
```

Dado un target de N o de I, resuelve el multiplicador k óptimo y recalcula Q para cada artículo.

```python
import walopy as wl

items = [
    {"name": "A", "demand": 1000, "ordering_cost": 50,  "holding_cost": 2,  "unit_value": 10},
    {"name": "B", "demand":  500, "ordering_cost": 30,  "holding_cost": 1,  "unit_value":  5},
    {"name": "C", "demand": 2000, "ordering_cost": 100, "holding_cost": 4,  "unit_value":  8},
]

# Sin target → punto EOQ (k = 1)
r = wl.exchange_curve(items)
print(r)
```

```
Target                  : eoq
Multiplier k            : 1.0000
N orders/yr  (EOQ)      : 87.27
N orders/yr  (optimal)  : 87.27
Investment   (EOQ)      : 5430
Investment   (optimal)  : 5430
```

```python
# Reducir a 20 pedidos/año (k > 1 → Q más grandes → más inversión)
r = wl.exchange_curve(items, target_orders=20)

# Limitar inversión a 3 000 (k < 1 → Q más pequeños → más pedidos)
r = wl.exchange_curve(items, target_investment=3000)

# Tabla por artículo
df = r.to_frame()
# columnas: name | Q_eoq | Q_optimal | n_orders | investment

# Hipérbola para graficar
curve = r.curve_to_frame()
# columnas: N | I
```

**Parámetros de cada artículo**

| Clave | Requerido | Descripción |
|-------|-----------|-------------|
| `demand` | ✓ | Demanda anual Di |
| `ordering_cost` | ✓ | Costo de pedido Ki |
| `holding_cost` | ✓ | Costo de mantenimiento hi |
| `unit_value` | — | Valor unitario vi (default 1) |
| `name` | — | Etiqueta (default I1, I2, …) |

---

### Tipo 2 — seguridad: `safety_stock_curve`

Traza la curva entre inversión en stock de seguridad e nivel de servicio por ciclo usando una **política z común** para toda la familia:

```
SS_i(z) = z · σᵢ_DLT · vᵢ          σᵢ_DLT = √(Lᵢ·σ²_Di + D²ᵢ·σ²_Li)
SS_total(z) = z · Σ σᵢ_DLT · vᵢ     SL(z) = Φ(z)
```

```python
items = [
    {"name": "A", "demand_rate": 100, "demand_std": 10, "lead_time": 2, "unit_value": 10},
    {"name": "B", "demand_rate":  50, "demand_std":  5, "lead_time": 1, "unit_value":  5},
    {"name": "C", "demand_rate": 200, "demand_std": 20, "lead_time": 3, "unit_value":  8},
]

# Target: nivel de servicio 95%
r = wl.safety_stock_curve(items, target_service_level=0.95)
print(r)
```

```
Target          : service_level=95.0000%
z               : 1.6449
Service level   : 95.0000%
SS investment   : 1 847
```

```python
# Target: presupuesto máximo de SS
r = wl.safety_stock_curve(items, target_ss_investment=1000)

# Tabla por artículo (incluye punto de reorden cuando se da demand_rate)
df = r.to_frame()
# columnas: name | sigma_dlt | safety_stock | investment | reorder_point

# Curva completa (z de −2 a 4)
curve = r.curve_to_frame()
# columnas: z | service_level | ss_investment
```

**Parámetros de cada artículo**

| Clave | Requerido | Descripción |
|-------|-----------|-------------|
| `demand_std` | ✓ | Desviación estándar de la demanda |
| `lead_time` | ✓ | Lead time medio |
| `demand_rate` | — | Demanda media; activa columna `reorder_point` |
| `lead_time_std` | — | Desviación estándar del lead time (default 0) |
| `unit_value` | — | Valor unitario (default 1) |
| `name` | — | Etiqueta (default I1, I2, …) |

---

### Relación entre ambas curvas

| | Tipo 1 (ciclo) | Tipo 2 (seguridad) |
|---|---|---|
| **Función** | `exchange_curve` | `safety_stock_curve` |
| **Decide** | Tamaño de lote Q | Punto de reorden r |
| **Tradeoff** | N pedidos/año ↔ Inversión ciclo | Nivel de servicio ↔ SS |
| **Resultado** | `ExchangeCurveResult` | `SafetyStockCurveResult` |
| **Parámetro** | Multiplicador k | Factor z |

Usadas juntas cubren la política completa `(Q, r)` para toda la familia.

---

## 13. Programación de producción (scheduling)

### Máquina única — `schedule_single`

Asigna *n* trabajos a una sola máquina según una regla de prioridad.

```python
import walopy as wl

p = [3, 1, 4, 1, 5]          # tiempos de proceso
d = [8, 3, 10, 4, 12]         # fechas de entrega
w = [2, 5, 1, 4, 1]           # pesos de importancia

r = wl.schedule_single(p, rule="SPT", due_dates=d, weights=w,
                        names=["A","B","C","D","E"])
print(r)
```

```
Rule                    : SPT
Sequence                : B → D → A → C → E
Makespan (Cmax)         : 14
Total completion ΣCj    : 37
Weighted completion ΣwCj: 89
Max lateness            : 2
Total tardiness ΣTj     : 2
Tardy jobs              : 1
```

**Reglas disponibles**

| `rule` | Criterio minimizado | Datos requeridos |
|--------|---------------------|-----------------|
| `'SPT'` | ΣCj (tiempo total de completación) | — |
| `'EDD'` | Lmax (máxima tardanza) | `due_dates` |
| `'WSPT'` | ΣwjCj (completación ponderada) | `weights` |
| `'CR'` | Ratio crítico dj/pj | `due_dates` |
| `'FIFO'` | Orden de llegada (línea base) | — |

**Atributos de `ScheduleResult`**

| Atributo | Descripción |
|----------|-------------|
| `rule` | Regla utilizada |
| `sequence` | Lista de nombres en orden de procesamiento |
| `jobs` | Lista de `JobSchedule` (por trabajo) |
| `makespan` | Cmax = suma de tiempos de proceso |
| `total_completion_time` | ΣCj |
| `total_weighted_completion_time` | ΣwjCj |
| `max_lateness` | max(Lj) |
| `total_tardiness` | ΣTj |
| `n_tardy` | Número de trabajos tarde |

```python
df = r.to_frame()  # columnas: Name, p, d, w, Start, C, L, T, Tardy
```

---

### Flow-shop de 2 máquinas — `johnson_flowshop`

El algoritmo de Johnson encuentra la secuencia óptima que minimiza el makespan cuando todos los trabajos pasan primero por M1 y luego por M2.

```python
m1 = [3, 8, 5, 7, 2]   # tiempos en máquina 1
m2 = [5, 2, 8, 4, 6]   # tiempos en máquina 2

r = wl.johnson_flowshop(m1, m2)
print(r)
```

```
Sequence : J1 → J5 → J4 → J3 → J2
Makespan : 34
```

```python
df = r.to_frame()
# columnas: Job | M1 start | M1 end | M2 start | M2 end
```

---

## 14. Confiabilidad — `reliability`

Modelos de confiabilidad con distribución exponencial (tasa de falla constante).

### Componente individual — `mtbf_analysis`

```python
r = wl.mtbf_analysis(failure_rate=0.01, mttr=5.0, t=100)
print(r)
```

```
Topology        : component
Components      : 1
Failure rates λ : ['0.01']
MTBF            : 100
R(t=100)        : 0.367879
Availability    : 95.2381%
```

### Sistema en serie — `series_system`

El sistema falla si **cualquier** componente falla. λ_sys = Σλi.

```python
r = wl.series_system([0.01, 0.02, 0.03], t=10)
print(r.mtbf)      # 1/0.06 ≈ 16.67
print(r.R_t)       # e^{-0.06·10} ≈ 0.549
```

### Sistema en paralelo — `parallel_system`

El sistema opera si **al menos uno** de los componentes opera.  
R_sys(t) = 1 − ∏(1 − e^{−λi·t}). MTBF calculado numéricamente.

```python
r = wl.parallel_system([0.01, 0.02], t=50)
print(r.R_t)        # mayor que el sistema en serie
print(r.mtbf)       # mayor que cualquier componente individual
```

### Sistema k-de-n — `koon_system`

Opera si al menos **k** de **n** componentes idénticos están en servicio.  
MTBF exacto = (1/λ) · Σ_{j=k}^{n} (1/j).

```python
# 2-de-3: requiere al menos 2 de 3 componentes idénticos
r = wl.koon_system(n=3, k=2, failure_rate=0.01, t=50)
print(r.mtbf)   # (1/0.01) * (1/2 + 1/3) ≈ 83.33
print(r.R_t)
```

**Casos especiales**
- `k=1` → equivale a `parallel_system` con n componentes idénticos.
- `k=n` → equivale a `series_system` con n componentes idénticos.

**Atributos de `ReliabilityResult`**

| Atributo | Descripción |
|----------|-------------|
| `topology` | `'component'`, `'series'`, `'parallel'`, `'k-of-n'` |
| `n_components` | Número de componentes |
| `failure_rates` | Lista de λi |
| `mtbf` | MTBF del sistema |
| `R_t` | Confiabilidad R(t) si se proporcionó `t` |
| `availability` | Disponibilidad estacionaria A si se proporcionó `mttr` |
| `mttr` | Tiempo medio de reparación (si se proporcionó) |

```python
df = r.to_frame()   # columnas: Topology, Components, MTBF[, R(t), Availability]
r.R(t=200)          # evalúa R(t) en cualquier tiempo posterior
```

---

## 22. Referencia de módulos

| Módulo | Funciones y clases principales |
|---|---|
| `queuing` | `mm1`, `mmc`, `md1`, `kingman`, `mg1`, `littles_law`, `QueueResult`, `cv2_normal`, `cv2_triangular`, `cv2_uniform`, `cv2_erlang`, `cv2_gamma`, `cv2_lognormal`, `cv2_weibull` |
| `advanced` | `mm1k`, `mmck`, `erlang_b`, `mm1_priority`, `monte_carlo_gg1`, `takt_time`, `line_balance`, `break_even`, `break_even_multi`, `break_even_sales`, `queue_length_pmf`, `sojourn_cdf`, `PriorityQueueResult`, `SimulationResult`, `LineBalanceResult`, `BreakEvenResult`, `BreakEvenMultiResult` |
| `fitting` | `fit_from_data`, `FitResult` |
| `inventory` | `eoq`, `ebq`, `eoq_multi`, `ebq_multi`, `eoq_multi_constrained`, `lot_for_lot`, `silver_meal`, `eoq_quantity_discount`, `wagner_whitin`, `rq_policy`, `rs_policy`, `exchange_curve`, `safety_stock_curve`, `reorder_point`, `newsvendor`, `EOQResult`, `EBQResult`, `MultiItemResult`, `ConstrainedMultiEOQResult`, `LotSizingResult`, `QuantityDiscountResult`, `ReorderResult`, `NewsvendorResult`, `RQPolicyResult`, `RSPolicyResult`, `ExchangeCurveResult`, `SafetyStockCurveResult` |
| `scheduling` | `schedule_single`, `johnson_flowshop`, `ScheduleResult`, `JobSchedule`, `FlowShopResult` |
| `reliability` | `mtbf_analysis`, `series_system`, `parallel_system`, `koon_system`, `ReliabilityResult` |
| `network` | `jackson_network`, `JacksonResult`, `StationMetrics` |
| `operations` | `oee`, `utilization_efficiency`, `unit_cost`, `OEEResult`, `UtilizationResult`, `UnitCostResult` |
| `bottleneck` | `bottleneck_analysis`, `BottleneckResult`, `StationResult` |
| `kpi` | `KPINode`, `oee_kpi_tree`, `throughput_kpi_tree`, `roi_kpi_tree` |
| `solver` | `solve_lam`, `solve_mu`, `solve_servers`, `optimize_servers`, `sensitivity`, `batch_model`, `compare`, `SolverResult`, `OptimizeResult` |
| `plotting` | `plot_queue_sensitivity`, `plot_queue_metrics`, `plot_oee`, `plot_bottleneck`, `plot_eoq`, `plot_kpi_tree`, `plot_sensitivity`, `plot_optimize_servers`, `plot_simulation`, `plot_line_balance`, `plot_break_even`, `plot_queue_distribution` |

---

## 23. Requisitos

```
Python ≥ 3.9
numpy ≥ 1.22
pandas ≥ 1.4
matplotlib ≥ 3.5
plotly ≥ 5.0
```

No se requiere `scipy`. Los cálculos estadísticos usan `statistics.NormalDist` de la biblioteca estándar de Python.

## Licencia

MIT — ver [LICENSE](LICENSE).

## Contribuciones

Pull requests bienvenidas. Asegúrate de que `pytest tests/` pase antes de abrir un PR.
