walopy 0.2.2__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {walopy-0.2.2/src/walopy.egg-info → walopy-0.2.5}/PKG-INFO +295 -4
  2. {walopy-0.2.2 → walopy-0.2.5}/README.md +294 -3
  3. {walopy-0.2.2 → walopy-0.2.5}/pyproject.toml +1 -1
  4. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/__init__.py +46 -0
  5. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/advanced.py +207 -0
  6. walopy-0.2.5/src/walopy/inventory.py +1282 -0
  7. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/queuing.py +146 -0
  8. {walopy-0.2.2 → walopy-0.2.5/src/walopy.egg-info}/PKG-INFO +295 -4
  9. {walopy-0.2.2 → walopy-0.2.5}/src/walopy.egg-info/SOURCES.txt +2 -0
  10. walopy-0.2.5/tests/test_inventory_new.py +304 -0
  11. walopy-0.2.5/tests/test_mg1.py +141 -0
  12. walopy-0.2.2/src/walopy/inventory.py +0 -407
  13. {walopy-0.2.2 → walopy-0.2.5}/LICENSE +0 -0
  14. {walopy-0.2.2 → walopy-0.2.5}/setup.cfg +0 -0
  15. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/__main__.py +0 -0
  16. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/_utils.py +0 -0
  17. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/bottleneck.py +0 -0
  18. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/fitting.py +0 -0
  19. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/kpi.py +0 -0
  20. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/network.py +0 -0
  21. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/operations.py +0 -0
  22. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/plotting.py +0 -0
  23. {walopy-0.2.2 → walopy-0.2.5}/src/walopy/solver.py +0 -0
  24. {walopy-0.2.2 → walopy-0.2.5}/src/walopy.egg-info/dependency_links.txt +0 -0
  25. {walopy-0.2.2 → walopy-0.2.5}/src/walopy.egg-info/requires.txt +0 -0
  26. {walopy-0.2.2 → walopy-0.2.5}/src/walopy.egg-info/top_level.txt +0 -0
  27. {walopy-0.2.2 → walopy-0.2.5}/tests/test_advanced.py +0 -0
  28. {walopy-0.2.2 → walopy-0.2.5}/tests/test_bottleneck.py +0 -0
  29. {walopy-0.2.2 → walopy-0.2.5}/tests/test_fitting.py +0 -0
  30. {walopy-0.2.2 → walopy-0.2.5}/tests/test_inventory.py +0 -0
  31. {walopy-0.2.2 → walopy-0.2.5}/tests/test_kpi.py +0 -0
  32. {walopy-0.2.2 → walopy-0.2.5}/tests/test_network.py +0 -0
  33. {walopy-0.2.2 → walopy-0.2.5}/tests/test_new_models.py +0 -0
  34. {walopy-0.2.2 → walopy-0.2.5}/tests/test_operations.py +0 -0
  35. {walopy-0.2.2 → walopy-0.2.5}/tests/test_queuing.py +0 -0
  36. {walopy-0.2.2 → walopy-0.2.5}/tests/test_solver.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: walopy
3
- Version: 0.2.2
3
+ Version: 0.2.5
4
4
  Summary: Queuing theory, operations analysis, OEE, bottleneck analysis and KPI trees for Python
5
5
  Author-email: LeoSanta15 <angelsanta1@gmail.com>
6
6
  License-Expression: MIT
@@ -200,6 +200,49 @@ r = wl.kingman(**fit.to_model_kwargs())
200
200
 
201
201
  ---
202
202
 
203
+ ### `mg1(lam, mu, cs2)` — M/G/1 (Pollaczek-Khinchine exacto)
204
+
205
+ 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.
206
+
207
+ **Fórmula P-K:**
208
+ Wq = λ · E[S²] / (2 · (1 − ρ)), donde E[S²] = (1 + cs²) / μ²
209
+
210
+ ```python
211
+ # Servicio con distribución triangular: calcular cs² primero
212
+ cs2 = wl.cv2_triangular(a=2, m=5, b=10) # a=mín, m=moda, b=máx
213
+ r = wl.mg1(lam=0.15, mu=1/5, cs2=cs2)
214
+
215
+ # Equivalencia con M/M/1 (cs²=1) y M/D/1 (cs²=0)
216
+ r_mm1 = wl.mg1(lam=3.0, mu=5.0, cs2=1.0) # idéntico a mm1(3, 5)
217
+ r_md1 = wl.mg1(lam=3.0, mu=5.0, cs2=0.0) # idéntico a md1(3, 5)
218
+ ```
219
+
220
+ #### Helpers `cv2_*` — CV² por distribución
221
+
222
+ El CV² (varianza / media²) es el único parámetro de distribución que M/G/1 necesita:
223
+
224
+ | Función | Distribución | CV² |
225
+ |---|---|---|
226
+ | `cv2_normal(mean, std)` | Normal | (σ/μ)² |
227
+ | `cv2_triangular(a, m, b)` | Triangular | fórmula exacta |
228
+ | `cv2_uniform(a, b)` | Uniforme | (b−a)² / (12·μ²) |
229
+ | `cv2_erlang(k)` | Erlang-k | 1/k |
230
+ | `cv2_gamma(shape)` | Gamma | 1/shape |
231
+ | `cv2_lognormal(mean, std)` | Lognormal | (σ/μ)² |
232
+ | `cv2_weibull(shape)` | Weibull | Γ(1+2/k)/Γ(1+1/k)²−1 |
233
+
234
+ ```python
235
+ # Servicio con distribución Weibull de forma 2 (rayleigh)
236
+ cs2 = wl.cv2_weibull(shape=2.0) # ≈ 0.273
237
+ r = wl.mg1(lam=1.0, mu=2.0, cs2=cs2)
238
+ print(r.Wq) # tiempo medio de espera en cola
239
+
240
+ # Servicio Erlang-3 (menos variable que exponencial)
241
+ r = wl.mg1(lam=3.0, mu=5.0, cs2=wl.cv2_erlang(3))
242
+ ```
243
+
244
+ ---
245
+
203
246
  ### `littles_law(*, L, lam, W)` — Ley de Little
204
247
 
205
248
  Resuelve L = λ · W para la variable que falte. Exactamente uno de los tres parámetros debe ser `None`.
@@ -654,6 +697,204 @@ print(r_bajo.optimal_qty < 100) # True
654
697
 
655
698
  ---
656
699
 
700
+ ### `ebq(demand_rate, setup_cost, holding_cost, production_rate)` — Lote Económico de Producción (EBQ/EPQ)
701
+
702
+ Extiende el EOQ al caso en que la producción y el consumo ocurren **simultáneamente** (tasa de producción finita P > D).
703
+
704
+ **Fórmula:**
705
+ ```
706
+ Q* = √(2 · D · S / (h · (1 − D/P)))
707
+ Inventario máximo = Q* · (1 − D/P)
708
+ Inventario medio = Inventario máximo / 2
709
+ ```
710
+
711
+ ```python
712
+ r = wl.ebq(
713
+ demand_rate=1000, # D: unidades/período
714
+ setup_cost=50, # S: costo fijo de setup por lote
715
+ holding_cost=2, # h: costo/unidad/período
716
+ production_rate=4000, # P: tasa de producción (debe ser > D)
717
+ )
718
+ print(r)
719
+ # EBQ (batch size) : 258.2 units
720
+ # Max inventory : 193.6
721
+ # Avg inventory : 96.82
722
+ # Order frequency : 3.873 runs/period
723
+ # Cycle time : 0.2582 periods
724
+ # Production time/cyc : 0.06455 periods
725
+ # Total cost : 193.6
726
+ # Holding cost : 96.82
727
+ # Setup cost : 96.82
728
+ ```
729
+
730
+ **`EBQResult` — atributos:**
731
+
732
+ | Atributo | Descripción |
733
+ |---|---|
734
+ | `ebq` | Lote óptimo Q* |
735
+ | `max_inventory` | Nivel máximo de inventario Q*(1−D/P) |
736
+ | `avg_inventory` | Inventario promedio = max/2 |
737
+ | `production_time` | Tiempo de producción por ciclo = Q*/P |
738
+ | `cycle_time` | Duración del ciclo = Q*/D |
739
+ | `total_cost` | Costo total mínimo |
740
+
741
+ ---
742
+
743
+ ### `eoq_multi(demand_rates, ordering_costs, holding_costs, *, names)` — EOQ multi-ítem independiente
744
+
745
+ Resuelve el EOQ para cada ítem por separado sin restricciones compartidas.
746
+
747
+ ```python
748
+ r = wl.eoq_multi(
749
+ demand_rates = [1000, 500, 800],
750
+ ordering_costs = [50, 30, 40 ],
751
+ holding_costs = [2, 1, 1.5],
752
+ names = ["A", "B", "C"],
753
+ )
754
+ print(r.total_cost) # suma de costos óptimos individuales
755
+ df = r.to_frame() # DataFrame con EOQ, costos y frecuencias por ítem
756
+ ```
757
+
758
+ ---
759
+
760
+ ### `ebq_multi(demand_rates, setup_costs, holding_costs, production_rates, *, names)` — EBQ multi-ítem independiente
761
+
762
+ ```python
763
+ r = wl.ebq_multi(
764
+ demand_rates = [1000, 500],
765
+ setup_costs = [50, 30 ],
766
+ holding_costs = [2, 1 ],
767
+ production_rates= [4000, 2000],
768
+ )
769
+ df = r.to_frame() # EBQ, inventario máximo/medio, costo por ítem
770
+ ```
771
+
772
+ ---
773
+
774
+ ### `eoq_multi_constrained(demand_rates, ordering_costs, holding_costs, ...)` — EOQ multi-ítem con restricciones
775
+
776
+ 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).
777
+
778
+ **Restricciones soportadas:**
779
+
780
+ | Parámetro | Restricción |
781
+ |---|---|
782
+ | `budget`, `budget_unit_costs` | Σ(c_i · Q_i / 2) ≤ budget — valor promedio en inventario |
783
+ | `space`, `space_per_unit` | Σ(s_i · Q_i / 2) ≤ space — espacio promedio ocupado |
784
+ | `constraints` | Lista de `{"name", "weights", "bound"}` — Σ(a_i · Q_i) ≤ B |
785
+
786
+ ```python
787
+ # Restricción de presupuesto
788
+ r = wl.eoq_multi_constrained(
789
+ [1000, 500, 800], [50, 30, 40], [2, 1, 1.5],
790
+ budget=5000,
791
+ budget_unit_costs=[10, 8, 12], # costo unitario de compra
792
+ )
793
+ print(r.binding_constraints) # ["budget"] si la restricción está activa
794
+ print(r.lagrange_multipliers) # {"budget": λ}
795
+ df = r.to_frame() # Q*, holding cost, ordering cost por ítem
796
+
797
+ # Restricción de espacio
798
+ r2 = wl.eoq_multi_constrained(
799
+ [1000, 500, 800], [50, 30, 40], [2, 1, 1.5],
800
+ space=200,
801
+ space_per_unit=[0.5, 0.3, 0.4],
802
+ )
803
+
804
+ # Restricción genérica: suma total de unidades pedidas ≤ 400
805
+ r3 = wl.eoq_multi_constrained(
806
+ [1000, 500, 800], [50, 30, 40], [2, 1, 1.5],
807
+ constraints=[{"name": "total_qty", "weights": [1, 1, 1], "bound": 400}],
808
+ )
809
+ ```
810
+
811
+ **`ConstrainedMultiEOQResult` — atributos:**
812
+
813
+ | Atributo | Descripción |
814
+ |---|---|
815
+ | `items` | Lista de dicts por ítem: nombre, Q*, costos |
816
+ | `total_cost` | Costo total bajo las restricciones |
817
+ | `unconstrained_total_cost` | Costo sin restricciones (cota inferior) |
818
+ | `lagrange_multipliers` | Diccionario {nombre_restricción: λ} |
819
+ | `binding_constraints` | Nombres de restricciones activas (λ > 0) |
820
+
821
+ ---
822
+
823
+ ### `lot_for_lot(demands, setup_cost, holding_cost)` — Lote por Lote
824
+
825
+ 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.
826
+
827
+ ```python
828
+ demands = [100, 80, 0, 120, 60] # demandas por período
829
+ r = wl.lot_for_lot(demands, setup_cost=200, holding_cost=1)
830
+ print(r.total_holding_cost) # 0.0 — sin inventario residual
831
+ print(r.n_orders) # 4 — un pedido por período con demanda > 0
832
+ print(r)
833
+ # Method : Lot-for-Lot
834
+ # Number of orders : 4
835
+ # Total cost : 800.0
836
+ ```
837
+
838
+ ---
839
+
840
+ ### `silver_meal(demands, setup_cost, holding_cost)` — Silver-Meal
841
+
842
+ Heurística de dimensionado dinámico que **agrupa períodos** mientras el costo promedio por período decrece, reduciendo el número de setups.
843
+
844
+ ```python
845
+ r = wl.silver_meal(demands, setup_cost=200, holding_cost=1)
846
+ print(r.total_cost <= wl.lot_for_lot(demands, 200, 1).total_cost) # True
847
+ df = r.to_frame() # pedidos: período, cantidad, períodos cubiertos
848
+ ```
849
+
850
+ **`LotSizingResult` — atributos (Lot-for-Lot y Silver-Meal):**
851
+
852
+ | Atributo | Descripción |
853
+ |---|---|
854
+ | `orders` | Lista de pedidos: período, cantidad, períodos cubiertos |
855
+ | `total_cost` | Costo total (setup + almacenamiento) |
856
+ | `total_setup_cost` | Costo total de setups |
857
+ | `total_holding_cost` | Costo total de almacenamiento |
858
+ | `n_orders` | Número de pedidos emitidos |
859
+ | `method` | Nombre de la heurística usada |
860
+
861
+ ---
862
+
863
+ ### `eoq_quantity_discount(demand_rate, ordering_cost, holding_cost_rate, price_breaks)` — EOQ con descuentos por cantidad
864
+
865
+ 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.
866
+
867
+ ```python
868
+ r = wl.eoq_quantity_discount(
869
+ demand_rate=1000,
870
+ ordering_cost=50,
871
+ holding_cost_rate=0.20, # 20% del precio unitario por año
872
+ price_breaks=[
873
+ (0, 10.00), # precio base
874
+ (500, 9.50), # ≥ 500 unidades → $9.50
875
+ (1000, 9.00), # ≥ 1000 unidades → $9.00
876
+ ],
877
+ )
878
+ print(r.optimal_qty) # cantidad óptima seleccionada
879
+ print(r.unit_price) # precio en ese tramo
880
+ print(r.total_cost) # costo anual mínimo
881
+ df = r.to_frame() # todos los candidatos evaluados
882
+ ```
883
+
884
+ **`QuantityDiscountResult` — atributos:**
885
+
886
+ | Atributo | Descripción |
887
+ |---|---|
888
+ | `optimal_qty` | Cantidad óptima ajustada al tramo |
889
+ | `unit_price` | Precio unitario en el tramo seleccionado |
890
+ | `total_cost` | Costo total anual (compra + pedido + almacenamiento) |
891
+ | `purchase_cost` | Costo de compra anual D · precio |
892
+ | `ordering_cost_total` | Costo de pedido anual (D/Q) · K |
893
+ | `holding_cost_total` | Costo de almacenamiento anual (Q/2) · h · precio |
894
+ | `candidates` | Lista de todos los tramos evaluados |
895
+
896
+ ---
897
+
657
898
  ## 8. Análisis de operaciones
658
899
 
659
900
  ### `oee(availability, performance, quality)` — OEE
@@ -762,6 +1003,56 @@ print(be.profit) # 5000.0 — beneficio con actual_units
762
1003
  be.plot() # líneas de ingresos/costos con BEP y zona de beneficio
763
1004
  ```
764
1005
 
1006
+ ### `break_even_multi(fixed_cost, prices, variable_costs, sales_mix)` — Multi-producto
1007
+
1008
+ Calcula el punto de equilibrio para una mezcla de varios productos usando la **contribución marginal ponderada (WACM)**.
1009
+
1010
+ **Fórmula:** WACM = Σ(CMᵢ × mixᵢ) / Σmixᵢ, luego BEP_total = FC / WACM
1011
+
1012
+ ```python
1013
+ r = wl.break_even_multi(
1014
+ fixed_cost=120_000,
1015
+ prices=[50, 80, 120],
1016
+ variable_costs=[30, 50, 70],
1017
+ sales_mix=[3, 2, 1], # proporción de ventas
1018
+ )
1019
+ print(r.weighted_avg_cm) # WACM ≈ 36.67
1020
+ print(r.bep_units_total) # unidades totales en el punto de equilibrio
1021
+ print(r.bep_revenue_total) # ingresos totales en el punto de equilibrio
1022
+
1023
+ for item in r.items:
1024
+ print(item["Name"], item["BEP units"])
1025
+
1026
+ r.to_frame() # DataFrame con una fila por producto
1027
+ ```
1028
+
1029
+ | Atributo | Descripción |
1030
+ |---|---|
1031
+ | `weighted_avg_cm` | Contribución marginal ponderada |
1032
+ | `bep_units_total` | Unidades totales en el BEP |
1033
+ | `bep_revenue_total` | Ingresos totales en el BEP |
1034
+ | `items` | Lista de dicts por producto con `BEP units`, `BEP revenue` |
1035
+
1036
+ ---
1037
+
1038
+ ### `break_even_sales(fixed_cost, variable_cost_ratio, *, actual_revenue)` — Punto de equilibrio en ingresos
1039
+
1040
+ Calcula el BEP en términos de ingresos cuando los costos variables se expresan como porcentaje de las ventas.
1041
+
1042
+ **Fórmula:** BEP = FC / (1 − RCV)
1043
+
1044
+ ```python
1045
+ r = wl.break_even_sales(
1046
+ fixed_cost=50_000,
1047
+ variable_cost_ratio=0.60, # 60% de los ingresos son costos variables
1048
+ actual_revenue=200_000,
1049
+ )
1050
+ print(r.bep_revenue) # 125 000 — ingresos en el BEP
1051
+ print(r.contribution_margin_ratio) # 0.40 — margen de contribución
1052
+ print(r.margin_of_safety_units) # 75 000 — margen de seguridad en ingresos
1053
+ print(r.margin_of_safety_pct) # 0.375 — porcentaje del margen de seguridad
1054
+ ```
1055
+
765
1056
  ---
766
1057
 
767
1058
  ## 12. Árboles de KPI
@@ -1037,10 +1328,10 @@ Wq : 0.3 (avg wait time in queue)
1037
1328
 
1038
1329
  | Módulo | Funciones y clases principales |
1039
1330
  |---|---|
1040
- | `queuing` | `mm1`, `mmc`, `md1`, `kingman`, `littles_law`, `QueueResult` |
1041
- | `advanced` | `mm1k`, `mmck`, `erlang_b`, `mm1_priority`, `monte_carlo_gg1`, `takt_time`, `line_balance`, `break_even`, `queue_length_pmf`, `sojourn_cdf`, `PriorityQueueResult`, `SimulationResult`, `LineBalanceResult`, `BreakEvenResult` |
1331
+ | `queuing` | `mm1`, `mmc`, `md1`, `kingman`, `mg1`, `littles_law`, `QueueResult`, `cv2_normal`, `cv2_triangular`, `cv2_uniform`, `cv2_erlang`, `cv2_gamma`, `cv2_lognormal`, `cv2_weibull` |
1332
+ | `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` |
1042
1333
  | `fitting` | `fit_from_data`, `FitResult` |
1043
- | `inventory` | `eoq`, `reorder_point`, `newsvendor`, `EOQResult`, `ReorderResult`, `NewsvendorResult` |
1334
+ | `inventory` | `eoq`, `ebq`, `eoq_multi`, `ebq_multi`, `eoq_multi_constrained`, `lot_for_lot`, `silver_meal`, `eoq_quantity_discount`, `reorder_point`, `newsvendor`, `EOQResult`, `EBQResult`, `MultiItemResult`, `ConstrainedMultiEOQResult`, `LotSizingResult`, `QuantityDiscountResult`, `ReorderResult`, `NewsvendorResult` |
1044
1335
  | `network` | `jackson_network`, `JacksonResult`, `StationMetrics` |
1045
1336
  | `operations` | `oee`, `utilization_efficiency`, `unit_cost`, `OEEResult`, `UtilizationResult`, `UnitCostResult` |
1046
1337
  | `bottleneck` | `bottleneck_analysis`, `BottleneckResult`, `StationResult` |
@@ -163,6 +163,49 @@ r = wl.kingman(**fit.to_model_kwargs())
163
163
 
164
164
  ---
165
165
 
166
+ ### `mg1(lam, mu, cs2)` — M/G/1 (Pollaczek-Khinchine exacto)
167
+
168
+ 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.
169
+
170
+ **Fórmula P-K:**
171
+ Wq = λ · E[S²] / (2 · (1 − ρ)), donde E[S²] = (1 + cs²) / μ²
172
+
173
+ ```python
174
+ # Servicio con distribución triangular: calcular cs² primero
175
+ cs2 = wl.cv2_triangular(a=2, m=5, b=10) # a=mín, m=moda, b=máx
176
+ r = wl.mg1(lam=0.15, mu=1/5, cs2=cs2)
177
+
178
+ # Equivalencia con M/M/1 (cs²=1) y M/D/1 (cs²=0)
179
+ r_mm1 = wl.mg1(lam=3.0, mu=5.0, cs2=1.0) # idéntico a mm1(3, 5)
180
+ r_md1 = wl.mg1(lam=3.0, mu=5.0, cs2=0.0) # idéntico a md1(3, 5)
181
+ ```
182
+
183
+ #### Helpers `cv2_*` — CV² por distribución
184
+
185
+ El CV² (varianza / media²) es el único parámetro de distribución que M/G/1 necesita:
186
+
187
+ | Función | Distribución | CV² |
188
+ |---|---|---|
189
+ | `cv2_normal(mean, std)` | Normal | (σ/μ)² |
190
+ | `cv2_triangular(a, m, b)` | Triangular | fórmula exacta |
191
+ | `cv2_uniform(a, b)` | Uniforme | (b−a)² / (12·μ²) |
192
+ | `cv2_erlang(k)` | Erlang-k | 1/k |
193
+ | `cv2_gamma(shape)` | Gamma | 1/shape |
194
+ | `cv2_lognormal(mean, std)` | Lognormal | (σ/μ)² |
195
+ | `cv2_weibull(shape)` | Weibull | Γ(1+2/k)/Γ(1+1/k)²−1 |
196
+
197
+ ```python
198
+ # Servicio con distribución Weibull de forma 2 (rayleigh)
199
+ cs2 = wl.cv2_weibull(shape=2.0) # ≈ 0.273
200
+ r = wl.mg1(lam=1.0, mu=2.0, cs2=cs2)
201
+ print(r.Wq) # tiempo medio de espera en cola
202
+
203
+ # Servicio Erlang-3 (menos variable que exponencial)
204
+ r = wl.mg1(lam=3.0, mu=5.0, cs2=wl.cv2_erlang(3))
205
+ ```
206
+
207
+ ---
208
+
166
209
  ### `littles_law(*, L, lam, W)` — Ley de Little
167
210
 
168
211
  Resuelve L = λ · W para la variable que falte. Exactamente uno de los tres parámetros debe ser `None`.
@@ -617,6 +660,204 @@ print(r_bajo.optimal_qty < 100) # True
617
660
 
618
661
  ---
619
662
 
663
+ ### `ebq(demand_rate, setup_cost, holding_cost, production_rate)` — Lote Económico de Producción (EBQ/EPQ)
664
+
665
+ Extiende el EOQ al caso en que la producción y el consumo ocurren **simultáneamente** (tasa de producción finita P > D).
666
+
667
+ **Fórmula:**
668
+ ```
669
+ Q* = √(2 · D · S / (h · (1 − D/P)))
670
+ Inventario máximo = Q* · (1 − D/P)
671
+ Inventario medio = Inventario máximo / 2
672
+ ```
673
+
674
+ ```python
675
+ r = wl.ebq(
676
+ demand_rate=1000, # D: unidades/período
677
+ setup_cost=50, # S: costo fijo de setup por lote
678
+ holding_cost=2, # h: costo/unidad/período
679
+ production_rate=4000, # P: tasa de producción (debe ser > D)
680
+ )
681
+ print(r)
682
+ # EBQ (batch size) : 258.2 units
683
+ # Max inventory : 193.6
684
+ # Avg inventory : 96.82
685
+ # Order frequency : 3.873 runs/period
686
+ # Cycle time : 0.2582 periods
687
+ # Production time/cyc : 0.06455 periods
688
+ # Total cost : 193.6
689
+ # Holding cost : 96.82
690
+ # Setup cost : 96.82
691
+ ```
692
+
693
+ **`EBQResult` — atributos:**
694
+
695
+ | Atributo | Descripción |
696
+ |---|---|
697
+ | `ebq` | Lote óptimo Q* |
698
+ | `max_inventory` | Nivel máximo de inventario Q*(1−D/P) |
699
+ | `avg_inventory` | Inventario promedio = max/2 |
700
+ | `production_time` | Tiempo de producción por ciclo = Q*/P |
701
+ | `cycle_time` | Duración del ciclo = Q*/D |
702
+ | `total_cost` | Costo total mínimo |
703
+
704
+ ---
705
+
706
+ ### `eoq_multi(demand_rates, ordering_costs, holding_costs, *, names)` — EOQ multi-ítem independiente
707
+
708
+ Resuelve el EOQ para cada ítem por separado sin restricciones compartidas.
709
+
710
+ ```python
711
+ r = wl.eoq_multi(
712
+ demand_rates = [1000, 500, 800],
713
+ ordering_costs = [50, 30, 40 ],
714
+ holding_costs = [2, 1, 1.5],
715
+ names = ["A", "B", "C"],
716
+ )
717
+ print(r.total_cost) # suma de costos óptimos individuales
718
+ df = r.to_frame() # DataFrame con EOQ, costos y frecuencias por ítem
719
+ ```
720
+
721
+ ---
722
+
723
+ ### `ebq_multi(demand_rates, setup_costs, holding_costs, production_rates, *, names)` — EBQ multi-ítem independiente
724
+
725
+ ```python
726
+ r = wl.ebq_multi(
727
+ demand_rates = [1000, 500],
728
+ setup_costs = [50, 30 ],
729
+ holding_costs = [2, 1 ],
730
+ production_rates= [4000, 2000],
731
+ )
732
+ df = r.to_frame() # EBQ, inventario máximo/medio, costo por ítem
733
+ ```
734
+
735
+ ---
736
+
737
+ ### `eoq_multi_constrained(demand_rates, ordering_costs, holding_costs, ...)` — EOQ multi-ítem con restricciones
738
+
739
+ 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).
740
+
741
+ **Restricciones soportadas:**
742
+
743
+ | Parámetro | Restricción |
744
+ |---|---|
745
+ | `budget`, `budget_unit_costs` | Σ(c_i · Q_i / 2) ≤ budget — valor promedio en inventario |
746
+ | `space`, `space_per_unit` | Σ(s_i · Q_i / 2) ≤ space — espacio promedio ocupado |
747
+ | `constraints` | Lista de `{"name", "weights", "bound"}` — Σ(a_i · Q_i) ≤ B |
748
+
749
+ ```python
750
+ # Restricción de presupuesto
751
+ r = wl.eoq_multi_constrained(
752
+ [1000, 500, 800], [50, 30, 40], [2, 1, 1.5],
753
+ budget=5000,
754
+ budget_unit_costs=[10, 8, 12], # costo unitario de compra
755
+ )
756
+ print(r.binding_constraints) # ["budget"] si la restricción está activa
757
+ print(r.lagrange_multipliers) # {"budget": λ}
758
+ df = r.to_frame() # Q*, holding cost, ordering cost por ítem
759
+
760
+ # Restricción de espacio
761
+ r2 = wl.eoq_multi_constrained(
762
+ [1000, 500, 800], [50, 30, 40], [2, 1, 1.5],
763
+ space=200,
764
+ space_per_unit=[0.5, 0.3, 0.4],
765
+ )
766
+
767
+ # Restricción genérica: suma total de unidades pedidas ≤ 400
768
+ r3 = wl.eoq_multi_constrained(
769
+ [1000, 500, 800], [50, 30, 40], [2, 1, 1.5],
770
+ constraints=[{"name": "total_qty", "weights": [1, 1, 1], "bound": 400}],
771
+ )
772
+ ```
773
+
774
+ **`ConstrainedMultiEOQResult` — atributos:**
775
+
776
+ | Atributo | Descripción |
777
+ |---|---|
778
+ | `items` | Lista de dicts por ítem: nombre, Q*, costos |
779
+ | `total_cost` | Costo total bajo las restricciones |
780
+ | `unconstrained_total_cost` | Costo sin restricciones (cota inferior) |
781
+ | `lagrange_multipliers` | Diccionario {nombre_restricción: λ} |
782
+ | `binding_constraints` | Nombres de restricciones activas (λ > 0) |
783
+
784
+ ---
785
+
786
+ ### `lot_for_lot(demands, setup_cost, holding_cost)` — Lote por Lote
787
+
788
+ 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.
789
+
790
+ ```python
791
+ demands = [100, 80, 0, 120, 60] # demandas por período
792
+ r = wl.lot_for_lot(demands, setup_cost=200, holding_cost=1)
793
+ print(r.total_holding_cost) # 0.0 — sin inventario residual
794
+ print(r.n_orders) # 4 — un pedido por período con demanda > 0
795
+ print(r)
796
+ # Method : Lot-for-Lot
797
+ # Number of orders : 4
798
+ # Total cost : 800.0
799
+ ```
800
+
801
+ ---
802
+
803
+ ### `silver_meal(demands, setup_cost, holding_cost)` — Silver-Meal
804
+
805
+ Heurística de dimensionado dinámico que **agrupa períodos** mientras el costo promedio por período decrece, reduciendo el número de setups.
806
+
807
+ ```python
808
+ r = wl.silver_meal(demands, setup_cost=200, holding_cost=1)
809
+ print(r.total_cost <= wl.lot_for_lot(demands, 200, 1).total_cost) # True
810
+ df = r.to_frame() # pedidos: período, cantidad, períodos cubiertos
811
+ ```
812
+
813
+ **`LotSizingResult` — atributos (Lot-for-Lot y Silver-Meal):**
814
+
815
+ | Atributo | Descripción |
816
+ |---|---|
817
+ | `orders` | Lista de pedidos: período, cantidad, períodos cubiertos |
818
+ | `total_cost` | Costo total (setup + almacenamiento) |
819
+ | `total_setup_cost` | Costo total de setups |
820
+ | `total_holding_cost` | Costo total de almacenamiento |
821
+ | `n_orders` | Número de pedidos emitidos |
822
+ | `method` | Nombre de la heurística usada |
823
+
824
+ ---
825
+
826
+ ### `eoq_quantity_discount(demand_rate, ordering_cost, holding_cost_rate, price_breaks)` — EOQ con descuentos por cantidad
827
+
828
+ 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.
829
+
830
+ ```python
831
+ r = wl.eoq_quantity_discount(
832
+ demand_rate=1000,
833
+ ordering_cost=50,
834
+ holding_cost_rate=0.20, # 20% del precio unitario por año
835
+ price_breaks=[
836
+ (0, 10.00), # precio base
837
+ (500, 9.50), # ≥ 500 unidades → $9.50
838
+ (1000, 9.00), # ≥ 1000 unidades → $9.00
839
+ ],
840
+ )
841
+ print(r.optimal_qty) # cantidad óptima seleccionada
842
+ print(r.unit_price) # precio en ese tramo
843
+ print(r.total_cost) # costo anual mínimo
844
+ df = r.to_frame() # todos los candidatos evaluados
845
+ ```
846
+
847
+ **`QuantityDiscountResult` — atributos:**
848
+
849
+ | Atributo | Descripción |
850
+ |---|---|
851
+ | `optimal_qty` | Cantidad óptima ajustada al tramo |
852
+ | `unit_price` | Precio unitario en el tramo seleccionado |
853
+ | `total_cost` | Costo total anual (compra + pedido + almacenamiento) |
854
+ | `purchase_cost` | Costo de compra anual D · precio |
855
+ | `ordering_cost_total` | Costo de pedido anual (D/Q) · K |
856
+ | `holding_cost_total` | Costo de almacenamiento anual (Q/2) · h · precio |
857
+ | `candidates` | Lista de todos los tramos evaluados |
858
+
859
+ ---
860
+
620
861
  ## 8. Análisis de operaciones
621
862
 
622
863
  ### `oee(availability, performance, quality)` — OEE
@@ -725,6 +966,56 @@ print(be.profit) # 5000.0 — beneficio con actual_units
725
966
  be.plot() # líneas de ingresos/costos con BEP y zona de beneficio
726
967
  ```
727
968
 
969
+ ### `break_even_multi(fixed_cost, prices, variable_costs, sales_mix)` — Multi-producto
970
+
971
+ Calcula el punto de equilibrio para una mezcla de varios productos usando la **contribución marginal ponderada (WACM)**.
972
+
973
+ **Fórmula:** WACM = Σ(CMᵢ × mixᵢ) / Σmixᵢ, luego BEP_total = FC / WACM
974
+
975
+ ```python
976
+ r = wl.break_even_multi(
977
+ fixed_cost=120_000,
978
+ prices=[50, 80, 120],
979
+ variable_costs=[30, 50, 70],
980
+ sales_mix=[3, 2, 1], # proporción de ventas
981
+ )
982
+ print(r.weighted_avg_cm) # WACM ≈ 36.67
983
+ print(r.bep_units_total) # unidades totales en el punto de equilibrio
984
+ print(r.bep_revenue_total) # ingresos totales en el punto de equilibrio
985
+
986
+ for item in r.items:
987
+ print(item["Name"], item["BEP units"])
988
+
989
+ r.to_frame() # DataFrame con una fila por producto
990
+ ```
991
+
992
+ | Atributo | Descripción |
993
+ |---|---|
994
+ | `weighted_avg_cm` | Contribución marginal ponderada |
995
+ | `bep_units_total` | Unidades totales en el BEP |
996
+ | `bep_revenue_total` | Ingresos totales en el BEP |
997
+ | `items` | Lista de dicts por producto con `BEP units`, `BEP revenue` |
998
+
999
+ ---
1000
+
1001
+ ### `break_even_sales(fixed_cost, variable_cost_ratio, *, actual_revenue)` — Punto de equilibrio en ingresos
1002
+
1003
+ Calcula el BEP en términos de ingresos cuando los costos variables se expresan como porcentaje de las ventas.
1004
+
1005
+ **Fórmula:** BEP = FC / (1 − RCV)
1006
+
1007
+ ```python
1008
+ r = wl.break_even_sales(
1009
+ fixed_cost=50_000,
1010
+ variable_cost_ratio=0.60, # 60% de los ingresos son costos variables
1011
+ actual_revenue=200_000,
1012
+ )
1013
+ print(r.bep_revenue) # 125 000 — ingresos en el BEP
1014
+ print(r.contribution_margin_ratio) # 0.40 — margen de contribución
1015
+ print(r.margin_of_safety_units) # 75 000 — margen de seguridad en ingresos
1016
+ print(r.margin_of_safety_pct) # 0.375 — porcentaje del margen de seguridad
1017
+ ```
1018
+
728
1019
  ---
729
1020
 
730
1021
  ## 12. Árboles de KPI
@@ -1000,10 +1291,10 @@ Wq : 0.3 (avg wait time in queue)
1000
1291
 
1001
1292
  | Módulo | Funciones y clases principales |
1002
1293
  |---|---|
1003
- | `queuing` | `mm1`, `mmc`, `md1`, `kingman`, `littles_law`, `QueueResult` |
1004
- | `advanced` | `mm1k`, `mmck`, `erlang_b`, `mm1_priority`, `monte_carlo_gg1`, `takt_time`, `line_balance`, `break_even`, `queue_length_pmf`, `sojourn_cdf`, `PriorityQueueResult`, `SimulationResult`, `LineBalanceResult`, `BreakEvenResult` |
1294
+ | `queuing` | `mm1`, `mmc`, `md1`, `kingman`, `mg1`, `littles_law`, `QueueResult`, `cv2_normal`, `cv2_triangular`, `cv2_uniform`, `cv2_erlang`, `cv2_gamma`, `cv2_lognormal`, `cv2_weibull` |
1295
+ | `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` |
1005
1296
  | `fitting` | `fit_from_data`, `FitResult` |
1006
- | `inventory` | `eoq`, `reorder_point`, `newsvendor`, `EOQResult`, `ReorderResult`, `NewsvendorResult` |
1297
+ | `inventory` | `eoq`, `ebq`, `eoq_multi`, `ebq_multi`, `eoq_multi_constrained`, `lot_for_lot`, `silver_meal`, `eoq_quantity_discount`, `reorder_point`, `newsvendor`, `EOQResult`, `EBQResult`, `MultiItemResult`, `ConstrainedMultiEOQResult`, `LotSizingResult`, `QuantityDiscountResult`, `ReorderResult`, `NewsvendorResult` |
1007
1298
  | `network` | `jackson_network`, `JacksonResult`, `StationMetrics` |
1008
1299
  | `operations` | `oee`, `utilization_efficiency`, `unit_cost`, `OEEResult`, `UtilizationResult`, `UnitCostResult` |
1009
1300
  | `bottleneck` | `bottleneck_analysis`, `BottleneckResult`, `StationResult` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "walopy"
7
- version = "0.2.2"
7
+ version = "0.2.5"
8
8
  description = "Queuing theory, operations analysis, OEE, bottleneck analysis and KPI trees for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"