walopy 0.4.2__tar.gz → 0.4.3__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.
- {walopy-0.4.2/src/walopy.egg-info → walopy-0.4.3}/PKG-INFO +1 -1
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/__init__.py +1 -1
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/_catalogo_en.py +4 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/_catalogo_es.py +4 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/solver.py +40 -9
- {walopy-0.4.2 → walopy-0.4.3/src/walopy.egg-info}/PKG-INFO +1 -1
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy.egg-info/SOURCES.txt +6 -0
- walopy-0.4.3/tests/test_dibujo_plotly.py +153 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_inventario_i18n.py +4 -0
- walopy-0.4.3/tests/test_propiedades_algoritmos.py +194 -0
- walopy-0.4.3/tests/test_propiedades_inventario_fiabilidad.py +221 -0
- walopy-0.4.3/tests/test_servidores_cmax.py +59 -0
- walopy-0.4.3/tests/test_servidores_minimos.py +79 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_solver.py +1 -0
- walopy-0.4.3/tests/test_solver_contratos.py +142 -0
- {walopy-0.4.2 → walopy-0.4.3}/LICENSE +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/README.md +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/pyproject.toml +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/setup.cfg +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/__main__.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/_i18n.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/_utils.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/advanced.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/bottleneck.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/fitting.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/inventory.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/kpi.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/network.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/operations.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/plotting.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/project.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/py.typed +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/queuing.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/reliability.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy/scheduling.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy.egg-info/dependency_links.txt +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy.egg-info/requires.txt +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/src/walopy.egg-info/top_level.txt +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_abc_xyz.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_advanced.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_bottleneck.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_cli.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_complejidad.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_contrato_entradas.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_cpm_pert.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_documentacion.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_documentacion_ingles.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_ejemplos.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_exchange_curve.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_fitting.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_i18n.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_i18n_paridad.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_idioma.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_inventory.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_inventory_new.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_kpi.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_kpi_grafica.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_mg1.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_mmc_estable.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_mrp.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_neh_flowshop.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_network.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_new_models.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_notas_release.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_operations.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_plotting.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_politicas_inventario.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_propiedades_diferenciales.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_queuing.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_readme.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_referencias_externas.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_regresion_validacion.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_reliability.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_safety_stock_curve.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_salida_identica.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_salida_por_idioma.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_scheduling.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_scorecard.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_trazabilidad.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_version.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_wagner_whitin_ceros.py +0 -0
- {walopy-0.4.2 → walopy-0.4.3}/tests/test_weibull.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: walopy
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Herramientas de investigación de operaciones: colas, inventarios, scheduling, CPM/PERT, confiabilidad, OEE, cuellos de botella y árboles de KPI
|
|
5
5
|
Author-email: LeoSanta15 <angelsanta1@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -164,7 +164,7 @@ from .solver import (
|
|
|
164
164
|
solve_servers,
|
|
165
165
|
)
|
|
166
166
|
|
|
167
|
-
__version__ = "0.4.
|
|
167
|
+
__version__ = "0.4.3" # fuente única de la versión; pyproject.toml la lee con [tool.setuptools.dynamic]
|
|
168
168
|
|
|
169
169
|
__all__ = [
|
|
170
170
|
# idioma de los textos
|
|
@@ -584,6 +584,10 @@ EN: dict[str, str] = {
|
|
|
584
584
|
"solver.error.solve_lam.supera_incluso_muy_baja_objetivo": "{target_metric} exceeds {target_value} even with a very low λ. The target may be infeasible for this model / μ.",
|
|
585
585
|
"solver.error.solve_mu.sigue_superando_objetivo_puede_ser": "{target_metric} still exceeds {target_value} with μ = {mu_max:.4g}. The target may be numerically infeasible.",
|
|
586
586
|
"solver.error.solve_servers.pudo_cumplir_hasta_servidores_considere": "Could not meet {target_metric} ≤ {target_value} with up to {c_max} servers. Consider increasing μ or relaxing the target.",
|
|
587
|
+
"solver.error.optimize_servers.sin_servidores_estables_hasta_c_max": "With λ={lam} and μ={mu} at least {c_min} servers are needed for the system to be stable, but c_max={c_max}. Increase c_max.",
|
|
588
|
+
"solver.error.sensitivity.values_no_puede_estar_vacio": "values cannot be empty.",
|
|
589
|
+
"solver.error.sensitivity.param_no_es_parametro_modelo": "{param!r} is not a parameter of {modelo}.",
|
|
590
|
+
"solver.error.compare.sin_resultados": "compare needs at least one result.",
|
|
587
591
|
"solver.etiqueta.compare.escenario": "scenario_{expr}",
|
|
588
592
|
"solver.etiqueta.summary.convergio": "did NOT converge",
|
|
589
593
|
"solver.etiqueta.summary.convergio_2": "converged",
|
|
@@ -584,6 +584,10 @@ ES: dict[str, str] = {
|
|
|
584
584
|
"solver.error.solve_lam.supera_incluso_muy_baja_objetivo": "{target_metric} supera {target_value} incluso con λ muy baja. El objetivo puede ser infactible para este modelo / μ.",
|
|
585
585
|
"solver.error.solve_mu.sigue_superando_objetivo_puede_ser": "{target_metric} sigue superando {target_value} con μ = {mu_max:.4g}. El objetivo puede ser numéricamente infactible.",
|
|
586
586
|
"solver.error.solve_servers.pudo_cumplir_hasta_servidores_considere": "No se pudo cumplir {target_metric} ≤ {target_value} con hasta {c_max} servidores. Considere aumentar μ o relajar el objetivo.",
|
|
587
|
+
"solver.error.optimize_servers.sin_servidores_estables_hasta_c_max": "Con λ={lam} y μ={mu} hacen falta al menos {c_min} servidores para que el sistema sea estable, pero c_max={c_max}. Aumente c_max.",
|
|
588
|
+
"solver.error.sensitivity.values_no_puede_estar_vacio": "values no puede estar vacío.",
|
|
589
|
+
"solver.error.sensitivity.param_no_es_parametro_modelo": "{param!r} no es un parámetro de {modelo}.",
|
|
590
|
+
"solver.error.compare.sin_resultados": "compare necesita al menos un resultado.",
|
|
587
591
|
"solver.etiqueta.compare.escenario": "escenario_{expr}",
|
|
588
592
|
"solver.etiqueta.summary.convergio": "NO convergió",
|
|
589
593
|
"solver.etiqueta.summary.convergio_2": "convergió",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Solvers: encuentran parámetros óptimos o metas definidas por la persona usuaria en modelos de colas y operaciones."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import inspect
|
|
4
5
|
from collections.abc import Sequence
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from typing import TYPE_CHECKING, Any, Callable
|
|
@@ -9,7 +10,7 @@ import numpy as np
|
|
|
9
10
|
import pandas as pd
|
|
10
11
|
|
|
11
12
|
from ._i18n import t as _t
|
|
12
|
-
from ._utils import as_positive
|
|
13
|
+
from ._utils import MAX_SERVIDORES, as_int_positive, as_positive
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
15
16
|
import plotly.graph_objects as go
|
|
@@ -316,6 +317,14 @@ def solve_mu(
|
|
|
316
317
|
# solve_servers — minimum c to meet a target
|
|
317
318
|
# ---------------------------------------------------------------------------
|
|
318
319
|
|
|
320
|
+
def _min_servidores_estables(lam: float, mu: float) -> int:
|
|
321
|
+
"""Menor número de servidores *c* con ρ = λ/(c·μ) < 1 (la misma condición que exige ``mmc``)."""
|
|
322
|
+
c = max(1, int(lam / mu))
|
|
323
|
+
while lam / (c * mu) >= 1.0:
|
|
324
|
+
c += 1
|
|
325
|
+
return c
|
|
326
|
+
|
|
327
|
+
|
|
319
328
|
def solve_servers(
|
|
320
329
|
target_metric: str,
|
|
321
330
|
target_value: float,
|
|
@@ -355,15 +364,16 @@ def solve_servers(
|
|
|
355
364
|
--------
|
|
356
365
|
>>> r = solve_servers(target_metric='Wq', target_value=0.5, lam=4.0, mu=3.0)
|
|
357
366
|
>>> round(r.value, 4)
|
|
358
|
-
|
|
367
|
+
2.0
|
|
359
368
|
"""
|
|
360
369
|
from .queuing import mmc
|
|
361
370
|
|
|
362
371
|
lam = as_positive(lam, "lam")
|
|
363
372
|
mu = as_positive(mu, "mu")
|
|
364
373
|
target_value = as_positive(target_value, "target_value")
|
|
374
|
+
c_max = as_int_positive(c_max, "c_max", max=MAX_SERVIDORES)
|
|
365
375
|
|
|
366
|
-
c_min_stable =
|
|
376
|
+
c_min_stable = _min_servidores_estables(lam, mu)
|
|
367
377
|
|
|
368
378
|
for c in range(c_min_stable, c_max + 1):
|
|
369
379
|
result = mmc(lam, mu, c)
|
|
@@ -428,7 +438,7 @@ def optimize_servers(
|
|
|
428
438
|
--------
|
|
429
439
|
>>> r = optimize_servers(lam=4.0, mu=3.0, cost_per_server=10.0, cost_per_wait=5.0)
|
|
430
440
|
>>> round(r.min_cost, 4)
|
|
431
|
-
|
|
441
|
+
25.3333
|
|
432
442
|
"""
|
|
433
443
|
from .queuing import mmc
|
|
434
444
|
|
|
@@ -436,8 +446,11 @@ def optimize_servers(
|
|
|
436
446
|
mu = as_positive(mu, "mu")
|
|
437
447
|
cost_per_server = as_positive(cost_per_server, "cost_per_server")
|
|
438
448
|
cost_per_wait = as_positive(cost_per_wait, "cost_per_wait")
|
|
449
|
+
c_max = as_int_positive(c_max, "c_max", max=MAX_SERVIDORES)
|
|
439
450
|
|
|
440
|
-
c_min =
|
|
451
|
+
c_min = _min_servidores_estables(lam, mu)
|
|
452
|
+
if c_min > c_max:
|
|
453
|
+
raise ValueError(_t("solver.error.optimize_servers.sin_servidores_estables_hasta_c_max", lam=lam, mu=mu, c_min=c_min, c_max=c_max))
|
|
441
454
|
rows: list[dict[str, Any]] = []
|
|
442
455
|
|
|
443
456
|
for c in range(c_min, c_max + 1):
|
|
@@ -518,6 +531,15 @@ def sensitivity(
|
|
|
518
531
|
>>> import numpy as np
|
|
519
532
|
>>> df = sensitivity(mm1, "lam", np.linspace(0.5, 4.5, 20), mu=5.0)
|
|
520
533
|
"""
|
|
534
|
+
if len(values) == 0:
|
|
535
|
+
raise ValueError(_t("solver.error.sensitivity.values_no_puede_estar_vacio"))
|
|
536
|
+
try:
|
|
537
|
+
parametros = inspect.signature(model_fn).parameters
|
|
538
|
+
except (TypeError, ValueError): # callables sin firma inspeccionable: se intenta igualmente
|
|
539
|
+
parametros = None
|
|
540
|
+
if parametros is not None and param not in parametros and not any(p.kind is p.VAR_KEYWORD for p in parametros.values()):
|
|
541
|
+
nombre_modelo = repr(model_fn) if not hasattr(model_fn, "__name__") else model_fn.__name__
|
|
542
|
+
raise ValueError(_t("solver.error.sensitivity.param_no_es_parametro_modelo", param=param, modelo=nombre_modelo))
|
|
521
543
|
rows: list[dict[str, Any]] = []
|
|
522
544
|
for v in values:
|
|
523
545
|
try:
|
|
@@ -601,6 +623,8 @@ def batch_model(
|
|
|
601
623
|
|
|
602
624
|
# Pre-compute integer columns so iterrows() float-upcast can be reversed
|
|
603
625
|
int_cols = {col for col in df.columns if pd.api.types.is_integer_dtype(df[col].dtype)}
|
|
626
|
+
# Una columna entera con NaN (DataFrame disperso) pasa a float64: sus valores enteros vuelven a ser int al llamar al modelo
|
|
627
|
+
sparse_int_cols = {col for col in df.columns if pd.api.types.is_float_dtype(df[col].dtype) and df[col].isna().any()}
|
|
604
628
|
|
|
605
629
|
for _, row_series in df.iterrows():
|
|
606
630
|
# Merge: fixed_kwargs as base, row values (non-NaN) take precedence
|
|
@@ -609,7 +633,10 @@ def batch_model(
|
|
|
609
633
|
if isinstance(v, float) and np.isnan(v):
|
|
610
634
|
continue
|
|
611
635
|
# iterrows() upcasts int64 columns to float64; reverse that cast
|
|
612
|
-
|
|
636
|
+
if k in int_cols or (k in sparse_int_cols and isinstance(v, (float, np.floating)) and float(v).is_integer()):
|
|
637
|
+
kwargs[k] = int(v)
|
|
638
|
+
else:
|
|
639
|
+
kwargs[k] = v
|
|
613
640
|
|
|
614
641
|
try:
|
|
615
642
|
result = model_fn(**kwargs)
|
|
@@ -655,7 +682,8 @@ def compare(
|
|
|
655
682
|
----------
|
|
656
683
|
*results
|
|
657
684
|
Cualquier objeto de resultado de walopy (``QueueResult``, ``SimulationResult``,
|
|
658
|
-
``EOQResult``, etc.) o diccionarios simples.
|
|
685
|
+
``EOQResult``, etc.) o diccionarios simples. Si la tabla ``to_frame()`` de un resultado tiene varias filas
|
|
686
|
+
(pedidos, actividades…), se comparan sus campos escalares (costo total, duración del proyecto…).
|
|
659
687
|
labels : sequence of str, optional
|
|
660
688
|
Etiquetas de las filas. Por defecto ``'escenario_1'``, ``'escenario_2'``, …
|
|
661
689
|
|
|
@@ -680,11 +708,14 @@ def compare(
|
|
|
680
708
|
>>> tabla["c (servidores)"].tolist()
|
|
681
709
|
[1, 2]
|
|
682
710
|
"""
|
|
711
|
+
if len(results) == 0:
|
|
712
|
+
raise ValueError(_t("solver.error.compare.sin_resultados"))
|
|
683
713
|
rows: list[dict[str, Any]] = []
|
|
684
714
|
for i, r in enumerate(results):
|
|
685
715
|
label = labels[i] if (labels and i < len(labels)) else _t("solver.etiqueta.compare.escenario", expr=i + 1)
|
|
686
|
-
if hasattr(r, "to_frame")
|
|
687
|
-
|
|
716
|
+
tabla = r.to_frame() if hasattr(r, "to_frame") else None
|
|
717
|
+
if tabla is not None and len(tabla) == 1:
|
|
718
|
+
row = tabla.iloc[0].to_dict()
|
|
688
719
|
elif isinstance(r, dict):
|
|
689
720
|
row = dict(r)
|
|
690
721
|
elif hasattr(r, "__dict__"):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: walopy
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Herramientas de investigación de operaciones: colas, inventarios, scheduling, CPM/PERT, confiabilidad, OEE, cuellos de botella y árboles de KPI
|
|
5
5
|
Author-email: LeoSanta15 <angelsanta1@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -33,6 +33,7 @@ tests/test_cli.py
|
|
|
33
33
|
tests/test_complejidad.py
|
|
34
34
|
tests/test_contrato_entradas.py
|
|
35
35
|
tests/test_cpm_pert.py
|
|
36
|
+
tests/test_dibujo_plotly.py
|
|
36
37
|
tests/test_documentacion.py
|
|
37
38
|
tests/test_documentacion_ingles.py
|
|
38
39
|
tests/test_ejemplos.py
|
|
@@ -56,7 +57,9 @@ tests/test_notas_release.py
|
|
|
56
57
|
tests/test_operations.py
|
|
57
58
|
tests/test_plotting.py
|
|
58
59
|
tests/test_politicas_inventario.py
|
|
60
|
+
tests/test_propiedades_algoritmos.py
|
|
59
61
|
tests/test_propiedades_diferenciales.py
|
|
62
|
+
tests/test_propiedades_inventario_fiabilidad.py
|
|
60
63
|
tests/test_queuing.py
|
|
61
64
|
tests/test_readme.py
|
|
62
65
|
tests/test_referencias_externas.py
|
|
@@ -67,7 +70,10 @@ tests/test_salida_identica.py
|
|
|
67
70
|
tests/test_salida_por_idioma.py
|
|
68
71
|
tests/test_scheduling.py
|
|
69
72
|
tests/test_scorecard.py
|
|
73
|
+
tests/test_servidores_cmax.py
|
|
74
|
+
tests/test_servidores_minimos.py
|
|
70
75
|
tests/test_solver.py
|
|
76
|
+
tests/test_solver_contratos.py
|
|
71
77
|
tests/test_trazabilidad.py
|
|
72
78
|
tests/test_version.py
|
|
73
79
|
tests/test_wagner_whitin_ceros.py
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Las figuras de Plotly se dibujan, no solo existen como objeto (W-26).
|
|
2
|
+
|
|
3
|
+
El árbol de KPI de 0.4.0 tenía una ``Figure`` correcta para los tests y salía **en blanco** en el navegador. Aquí hay dos capas:
|
|
4
|
+
|
|
5
|
+
* **Siempre** (sin navegador): cada traza de cada figura tiene datos finitos que dibujar.
|
|
6
|
+
* **Con Chromium** (``WALOPY_DIBUJO=1``; ``make dibujo`` y el job «Dibujo» del CI): se renderiza la figura en un navegador sin interfaz y se compara
|
|
7
|
+
la **zona del gráfico** con la misma figura con las trazas invisibles (``opacity=0``). Medir solo esa zona y contra una base con los mismos ejes y
|
|
8
|
+
cuadrículas es lo que separa «dibujada» de «en blanco»: una captura completa tiene título y ejes aunque no haya datos (el árbol roto ocupa 1,1 %
|
|
9
|
+
de píxeles distintos de blanco en pantalla completa, y 0,00 % de píxeles distintos de la base en la zona del gráfico; los correctos, entre 1,3 % y 66 %).
|
|
10
|
+
|
|
11
|
+
Si ``WALOPY_DIBUJO=1`` y no hay navegador, los tests **fallan** (no se saltan), para que el CI no pase en silencio. Ruta explícita: ``WALOPY_CHROME``.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import glob
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess # noqa: S404
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
import walopy as wl
|
|
26
|
+
from walopy import plotting
|
|
27
|
+
|
|
28
|
+
go = pytest.importorskip("plotly.graph_objects")
|
|
29
|
+
np = pytest.importorskip("numpy")
|
|
30
|
+
|
|
31
|
+
ANCHO, ALTO = 800, 500
|
|
32
|
+
MARGEN = {"l": 60, "r": 30, "t": 60, "b": 60}
|
|
33
|
+
UMBRAL = 0.002 # fracción mínima de píxeles de la zona del gráfico que cambian al quitar los datos
|
|
34
|
+
EXIGIDO = os.environ.get("WALOPY_DIBUJO") == "1"
|
|
35
|
+
|
|
36
|
+
pytestmark = pytest.mark.filterwarnings("ignore::UserWarning")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ebitda() -> wl.KPINode:
|
|
40
|
+
a = wl.KPINode("Producto A", value=120_000, unit="€")
|
|
41
|
+
b = wl.KPINode("Producto B", value=80_000, unit="€")
|
|
42
|
+
ingresos = wl.KPINode("Ingresos", value=200_000, unit="€", children=[a, b])
|
|
43
|
+
costos = wl.KPINode("Costos de operación", value=100_000, unit="€")
|
|
44
|
+
return wl.KPINode("EBITDA", value=100_000, unit="€", children=[ingresos, costos])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _contrato(nombre: str):
|
|
48
|
+
from tests.test_contrato_entradas import BASE
|
|
49
|
+
|
|
50
|
+
return getattr(wl, nombre)(**copy.deepcopy(BASE[nombre]))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
FIGURAS = {
|
|
54
|
+
"kpi_ebitda_treemap": lambda: _ebitda().plot(),
|
|
55
|
+
"kpi_ebitda_sunburst": lambda: _ebitda().plot(kind="sunburst"),
|
|
56
|
+
"kpi_oee_treemap": lambda: wl.oee_kpi_tree(0.9, 0.8, 0.95).plot(),
|
|
57
|
+
"kpi_roi_sunburst": lambda: wl.roi_kpi_tree(50_000.0, 10_000.0, 8.0, 2_000.0, 20_000.0).plot(kind="sunburst"),
|
|
58
|
+
"optimize_servers": lambda: _contrato("optimize_servers").plot(),
|
|
59
|
+
"monte_carlo_gg1": lambda: _contrato("monte_carlo_gg1").plot(),
|
|
60
|
+
"line_balance": lambda: _contrato("line_balance").plot(),
|
|
61
|
+
"break_even": lambda: _contrato("break_even").plot(),
|
|
62
|
+
"break_even_sales": lambda: _contrato("break_even_sales").plot(),
|
|
63
|
+
"sensitivity": lambda: plotting.plot_sensitivity(wl.sensitivity(wl.mm1, "lam", [1.0, 1.5, 2.0, 2.5], mu=3.0), "lam"),
|
|
64
|
+
"queue_distribution": lambda: plotting.plot_queue_distribution(
|
|
65
|
+
wl.queue_length_pmf(2.0, 3.0, n_max=10), wl.sojourn_cdf(2.0, 3.0, n_points=20)
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _arreglos(traza) -> list:
|
|
71
|
+
"""Matrices que la traza dibuja: ``x``/``y`` (barras, líneas) o ``values`` (árboles)."""
|
|
72
|
+
return [getattr(traza, k) for k in ("x", "y", "values") if getattr(traza, k, None) is not None]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize("nombre", sorted(FIGURAS))
|
|
76
|
+
def test_cada_traza_tiene_datos_finitos_que_dibujar(nombre):
|
|
77
|
+
fig = FIGURAS[nombre]()
|
|
78
|
+
assert len(fig.data) > 0, nombre
|
|
79
|
+
for i, traza in enumerate(fig.data):
|
|
80
|
+
valores = [float(v) for a in _arreglos(traza) for v in a if isinstance(v, (int, float, np.integer, np.floating))]
|
|
81
|
+
assert any(math.isfinite(v) for v in valores), f"{nombre}: la traza {i} ({traza.type}) no tiene valores finitos"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------------------------- navegador
|
|
85
|
+
def _chrome() -> str | None:
|
|
86
|
+
if os.environ.get("WALOPY_CHROME"):
|
|
87
|
+
return os.environ["WALOPY_CHROME"]
|
|
88
|
+
for cmd in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
|
|
89
|
+
ruta = shutil.which(cmd)
|
|
90
|
+
if ruta:
|
|
91
|
+
return ruta
|
|
92
|
+
candidatos = sorted(glob.glob("/opt/pw-browsers/chromium-*/chrome-linux/chrome"))
|
|
93
|
+
return candidatos[-1] if candidatos else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture(scope="module")
|
|
97
|
+
def navegador() -> str:
|
|
98
|
+
if not EXIGIDO:
|
|
99
|
+
pytest.skip("renderizado en navegador: ejecuta con WALOPY_DIBUJO=1 (make dibujo)")
|
|
100
|
+
ruta = _chrome()
|
|
101
|
+
assert ruta, "WALOPY_DIBUJO=1 pero no hay Chromium/Chrome (define WALOPY_CHROME)"
|
|
102
|
+
return ruta
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _preparar(fig):
|
|
106
|
+
f = go.Figure(fig)
|
|
107
|
+
f.update_layout(
|
|
108
|
+
width=ANCHO, height=ALTO, margin=MARGEN, showlegend=False, title=None, paper_bgcolor="white", plot_bgcolor="white"
|
|
109
|
+
)
|
|
110
|
+
return f
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _invisible(fig):
|
|
114
|
+
f = go.Figure(fig)
|
|
115
|
+
for traza in f.data:
|
|
116
|
+
traza.opacity = 0
|
|
117
|
+
return f
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _renderizar(chrome: str, fig, carpeta: Path) -> np.ndarray:
|
|
121
|
+
import matplotlib.image as mpimg
|
|
122
|
+
|
|
123
|
+
html, png = carpeta / "figura.html", carpeta / "figura.png"
|
|
124
|
+
png.unlink(missing_ok=True)
|
|
125
|
+
fig.write_html(str(html), include_plotlyjs=True, full_html=True, config={"displayModeBar": False, "staticPlot": True})
|
|
126
|
+
orden = [
|
|
127
|
+
chrome, "--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", f"--window-size={ANCHO},{ALTO}",
|
|
128
|
+
"--virtual-time-budget=4000", f"--screenshot={png}", html.as_uri(),
|
|
129
|
+
]
|
|
130
|
+
subprocess.run(orden, capture_output=True, timeout=120, check=True) # noqa: S603
|
|
131
|
+
return np.asarray(mpimg.imread(str(png)))[..., :3]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _fraccion_dibujada(chrome: str, fig, carpeta: Path) -> float:
|
|
135
|
+
con = _renderizar(chrome, _preparar(fig), carpeta)
|
|
136
|
+
sin = _renderizar(chrome, _preparar(_invisible(fig)), carpeta)
|
|
137
|
+
zona = (slice(MARGEN["t"], ALTO - MARGEN["b"]), slice(MARGEN["l"], ANCHO - MARGEN["r"]))
|
|
138
|
+
return float((np.abs(con[zona] - sin[zona]).max(axis=2) > 0.1).mean())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.parametrize("nombre", sorted(FIGURAS))
|
|
142
|
+
def test_la_figura_se_dibuja_en_el_navegador(nombre, navegador, tmp_path):
|
|
143
|
+
fraccion = _fraccion_dibujada(navegador, FIGURAS[nombre](), tmp_path)
|
|
144
|
+
assert fraccion > UMBRAL, f"{nombre}: la zona del gráfico solo cambia un {fraccion:.4%} respecto de la figura sin datos (¿en blanco?)"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_el_detector_ve_en_blanco_el_arbol_de_kpi_de_0_4_0(navegador, tmp_path):
|
|
148
|
+
"""Control: con un padre menor que la suma de sus hijos (el defecto W-26) Plotly no dibuja y la medida tiene que quedar bajo el umbral."""
|
|
149
|
+
fig = go.Figure(_ebitda().plot())
|
|
150
|
+
valores = list(fig.data[0].values)
|
|
151
|
+
valores[0] = 1e-9 # raíz menor que la suma de sus hijos
|
|
152
|
+
fig.data[0].values = valores
|
|
153
|
+
assert _fraccion_dibujada(navegador, fig, tmp_path) <= UMBRAL
|
|
@@ -131,6 +131,10 @@ def test_el_catalogo_es_contiene_todas_las_claves_de_la_base_con_su_texto():
|
|
|
131
131
|
# Textos añadidos después de v0.3.0 (no están en la línea base congelada): una clave nueva debe declararse aquí a propósito.
|
|
132
132
|
AÑADIDAS_TRAS_V030 = {
|
|
133
133
|
"i18n.error.validar.idioma_no_admitido",
|
|
134
|
+
"solver.error.optimize_servers.sin_servidores_estables_hasta_c_max", # W-29
|
|
135
|
+
"solver.error.sensitivity.values_no_puede_estar_vacio", # W-32
|
|
136
|
+
"solver.error.sensitivity.param_no_es_parametro_modelo",
|
|
137
|
+
"solver.error.compare.sin_resultados", # W-31
|
|
134
138
|
"project.cabecera.to_frame.ht", # «HT»/«HL» (holgura total/libre): acrónimos españoles que el extractor no veía; ya existían en v0.3.0
|
|
135
139
|
"project.cabecera.to_frame.hl",
|
|
136
140
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Algoritmos de secuenciación, redes, colas finitas, lotes y MRP contra oráculos independientes (escenarios aleatorios con semilla fija).
|
|
2
|
+
|
|
3
|
+
Cada oráculo está escrito aparte del código de ``walopy`` (fuerza bruta, camino más largo, ecuaciones de balance, fórmula cerrada), y se comprueba que
|
|
4
|
+
discrimina: NEH, que es una heurística, se compara con el óptimo solo como cota. Carga moderada (``N`` escenarios por familia).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import itertools
|
|
9
|
+
import math
|
|
10
|
+
import random
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
import walopy as wl
|
|
14
|
+
|
|
15
|
+
N = 120
|
|
16
|
+
SEMILLA = 20261008
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cerca(a: float, b: float, rel: float = 1e-7) -> bool:
|
|
20
|
+
return math.isclose(a, b, rel_tol=rel, abs_tol=1e-9)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _escenarios(familia: str):
|
|
24
|
+
r = random.Random(f"{SEMILLA}-{familia}")
|
|
25
|
+
return [random.Random(r.random()) for _ in range(N)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _makespan(tiempos, perm):
|
|
29
|
+
c = [0.0] * len(tiempos[0])
|
|
30
|
+
for j in perm:
|
|
31
|
+
c[0] += tiempos[j][0]
|
|
32
|
+
for k in range(1, len(c)):
|
|
33
|
+
c[k] = max(c[k], c[k - 1]) + tiempos[j][k]
|
|
34
|
+
return c[-1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _indices(secuencia):
|
|
38
|
+
return [int(s[1:]) - 1 for s in secuencia]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_neh_devuelve_una_permutacion_coherente_y_no_mejor_que_el_optimo():
|
|
42
|
+
for r in _escenarios("neh"):
|
|
43
|
+
n, m = r.randint(2, 6), r.randint(2, 4)
|
|
44
|
+
T = [[r.randint(1, 20) for _ in range(m)] for _ in range(n)]
|
|
45
|
+
res = wl.neh_flowshop(T)
|
|
46
|
+
sec = _indices(res.sequence)
|
|
47
|
+
optimo = min(_makespan(T, p) for p in itertools.permutations(range(n)))
|
|
48
|
+
assert sorted(sec) == list(range(n)), T
|
|
49
|
+
assert _cerca(res.makespan, _makespan(T, sec)), T
|
|
50
|
+
assert res.makespan >= optimo - 1e-9, T
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_johnson_es_optimo_con_dos_maquinas():
|
|
54
|
+
for r in _escenarios("johnson"):
|
|
55
|
+
n = r.randint(2, 6)
|
|
56
|
+
a, b = [r.randint(1, 20) for _ in range(n)], [r.randint(1, 20) for _ in range(n)]
|
|
57
|
+
T = [[x, y] for x, y in zip(a, b)]
|
|
58
|
+
res = wl.johnson_flowshop(a, b)
|
|
59
|
+
optimo = min(_makespan(T, p) for p in itertools.permutations(range(n)))
|
|
60
|
+
assert _cerca(res.makespan, optimo), (a, b)
|
|
61
|
+
assert _cerca(_makespan(T, _indices(res.sequence)), optimo), (a, b)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _terminaciones(p, perm):
|
|
65
|
+
t, out = 0, {}
|
|
66
|
+
for j in perm:
|
|
67
|
+
t += p[j]
|
|
68
|
+
out[j] = t
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _camino_mas_largo(acts, i, memo):
|
|
73
|
+
if i not in memo:
|
|
74
|
+
previos = [_camino_mas_largo(acts, int(q[1:]), memo) for q in acts[i]["predecessors"]]
|
|
75
|
+
memo[i] = acts[i]["duration"] + max(previos or [0])
|
|
76
|
+
return memo[i]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_reglas_de_secuenciacion_en_una_maquina_son_optimas_en_su_criterio():
|
|
80
|
+
for r in _escenarios("una_maquina"):
|
|
81
|
+
n = r.randint(2, 6)
|
|
82
|
+
p = [r.randint(1, 15) for _ in range(n)]
|
|
83
|
+
d = [r.randint(1, 40) for _ in range(n)]
|
|
84
|
+
w = [r.randint(1, 5) for _ in range(n)]
|
|
85
|
+
perms = list(itertools.permutations(range(n)))
|
|
86
|
+
|
|
87
|
+
total = min(sum(_terminaciones(p, q).values()) for q in perms)
|
|
88
|
+
ponderado = min(sum(w[j] * c for j, c in _terminaciones(p, q).items()) for q in perms)
|
|
89
|
+
retraso = min(max(c - d[j] for j, c in _terminaciones(p, q).items()) for q in perms)
|
|
90
|
+
assert _cerca(wl.schedule_single(p, rule="SPT").total_completion_time, total), p
|
|
91
|
+
assert _cerca(wl.schedule_single(p, rule="WSPT", weights=w).total_weighted_completion_time, ponderado), (p, w)
|
|
92
|
+
assert _cerca(wl.schedule_single(p, rule="EDD", due_dates=d).max_lateness, retraso), (p, d)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_cpm_la_duracion_es_el_camino_mas_largo():
|
|
96
|
+
for r in _escenarios("cpm"):
|
|
97
|
+
na = r.randint(1, 9)
|
|
98
|
+
acts = [
|
|
99
|
+
{"name": f"A{i}", "duration": r.randint(1, 12), "predecessors": [f"A{j}" for j in range(i) if r.random() < 0.35]}
|
|
100
|
+
for i in range(na)
|
|
101
|
+
]
|
|
102
|
+
memo: dict[int, int] = {}
|
|
103
|
+
assert _cerca(wl.cpm(acts).project_duration, max(_camino_mas_largo(acts, i, memo) for i in range(na))), acts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_colas_finitas_coinciden_con_las_ecuaciones_de_balance():
|
|
107
|
+
for r in _escenarios("mmck"):
|
|
108
|
+
c = r.randint(1, 5)
|
|
109
|
+
K = c + r.randint(0, 10)
|
|
110
|
+
mu = r.uniform(0.5, 10)
|
|
111
|
+
lam = r.uniform(0.1, 2.0) * c * mu
|
|
112
|
+
pesos = [1.0]
|
|
113
|
+
for k in range(1, K + 1):
|
|
114
|
+
pesos.append(pesos[-1] * lam / (min(k, c) * mu))
|
|
115
|
+
pi = [x / sum(pesos) for x in pesos]
|
|
116
|
+
L = sum(k * pi[k] for k in range(K + 1))
|
|
117
|
+
Lq = sum((k - c) * pi[k] for k in range(c + 1, K + 1))
|
|
118
|
+
q = wl.mmck(lam, mu, c, K)
|
|
119
|
+
assert _cerca(q.L, L, 1e-6) and _cerca(q.Lq, Lq, 1e-6), (lam, mu, c, K)
|
|
120
|
+
assert _cerca(q.W, L / (lam * (1 - pi[K])), 1e-6), (lam, mu, c, K)
|
|
121
|
+
q1, qc = wl.mm1k(lam, mu, K), wl.mmck(lam, mu, 1, K)
|
|
122
|
+
assert _cerca(q1.L, qc.L) and _cerca(q1.W, qc.W), (lam, mu, K)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_prioridades_conservan_el_numero_total_en_el_sistema():
|
|
126
|
+
for r in _escenarios("prioridad"):
|
|
127
|
+
lams = [r.uniform(0.05, 0.8) for _ in range(r.randint(2, 4))]
|
|
128
|
+
mu = sum(lams) / r.uniform(0.1, 0.95)
|
|
129
|
+
pr = wl.mm1_priority(lams, mu)
|
|
130
|
+
assert _cerca(sum(c["L"] for c in pr.classes), wl.mm1(sum(lams), mu).L, 1e-6), (lams, mu)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _phi(z: float) -> float:
|
|
134
|
+
return math.exp(-z * z / 2) / math.sqrt(2 * math.pi)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _gran_phi(z: float) -> float:
|
|
138
|
+
return 0.5 * (1 + math.erf(z / math.sqrt(2)))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ganancia(Q, media, sd, precio, costo, rescate):
|
|
142
|
+
"""Ganancia esperada con demanda normal: precio·E[ventas] + rescate·E[sobrante] − costo·Q."""
|
|
143
|
+
z = (Q - media) / sd
|
|
144
|
+
ventas = media - sd * (_phi(z) - z * (1 - _gran_phi(z)))
|
|
145
|
+
return precio * ventas + rescate * (Q - ventas) - costo * Q
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_newsvendor_el_optimo_maximiza_la_ganancia_esperada():
|
|
149
|
+
for r in _escenarios("newsvendor"):
|
|
150
|
+
media = r.uniform(20, 500)
|
|
151
|
+
sd = media * r.uniform(0.05, 0.3)
|
|
152
|
+
precio = r.uniform(5, 30)
|
|
153
|
+
costo = precio * r.uniform(0.1, 0.8)
|
|
154
|
+
rescate = costo * r.uniform(0, 0.6)
|
|
155
|
+
|
|
156
|
+
datos = (media, sd, precio, costo, rescate)
|
|
157
|
+
res = wl.newsvendor(media, sd, precio, costo, salvage=rescate)
|
|
158
|
+
rejilla = [media * (0.2 + i * 0.01) for i in range(300)]
|
|
159
|
+
assert _ganancia(res.optimal_qty, *datos) >= max(_ganancia(g, *datos) for g in rejilla) - 1e-6, datos
|
|
160
|
+
assert _cerca(res.expected_profit, _ganancia(res.optimal_qty, *datos), 1e-6), datos
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_politicas_de_lotes_son_coherentes_con_sus_pedidos():
|
|
164
|
+
for r in _escenarios("lotes"):
|
|
165
|
+
dem = [r.choice([0, 0, r.uniform(1, 200)]) for _ in range(r.randint(1, 12))]
|
|
166
|
+
S, h = r.uniform(1, 500), r.uniform(0.05, 5)
|
|
167
|
+
for fn in (wl.silver_meal, wl.lot_for_lot):
|
|
168
|
+
res = fn(dem, S, h)
|
|
169
|
+
costo = len(res.orders) * S + h * sum(
|
|
170
|
+
dem[t - 1] * (t - o["period"]) for o in res.orders for t in o["covers_periods"]
|
|
171
|
+
)
|
|
172
|
+
assert all(o["order_qty"] > 0 for o in res.orders), (fn.__name__, dem)
|
|
173
|
+
assert _cerca(sum(o["order_qty"] for o in res.orders), sum(dem), 1e-9), (fn.__name__, dem)
|
|
174
|
+
assert _cerca(res.total_cost, costo, 1e-9), (fn.__name__, dem, S, h)
|
|
175
|
+
assert res.n_orders == len(res.orders), (fn.__name__, dem)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_mrp_cumple_el_balance_de_inventario():
|
|
179
|
+
for r in _escenarios("mrp"):
|
|
180
|
+
T = r.randint(2, 10)
|
|
181
|
+
bruto = [r.choice([0, r.randint(1, 50)]) for _ in range(T)]
|
|
182
|
+
inicial, lt, ss = r.randint(0, 60), r.randint(0, 3), r.randint(0, 10)
|
|
183
|
+
with warnings.catch_warnings():
|
|
184
|
+
warnings.simplefilter("ignore", UserWarning) # liberaciones vencidas (W-16): aviso esperado con lead_time largo
|
|
185
|
+
res = wl.mrp(bruto, initial_on_hand=inicial, lead_time=lt, safety_stock=ss)
|
|
186
|
+
previo = inicial
|
|
187
|
+
for t in range(T):
|
|
188
|
+
esperado = previo + res.scheduled_receipts[t] + res.planned_receipts[t] - bruto[t]
|
|
189
|
+
assert _cerca(res.projected_on_hand[t], esperado, 1e-9), (bruto, inicial, lt, ss, t)
|
|
190
|
+
assert res.projected_on_hand[t] >= ss - 1e-9, (bruto, inicial, lt, ss, t)
|
|
191
|
+
sin_recepcion = previo + res.scheduled_receipts[t] - bruto[t]
|
|
192
|
+
if res.planned_receipts[t] > 1e-9:
|
|
193
|
+
assert sin_recepcion < ss - 1e-9, (bruto, inicial, lt, ss, t) # solo se recibe lo necesario (LFL)
|
|
194
|
+
previo = res.projected_on_hand[t]
|