diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fe59f71 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,120 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Suite C++ en las tres plataformas y en ambas configuraciones. Debug y + # Release se ejecutan por separado porque la optimizacion puede alterar el + # comportamiento en coma flotante, y varias pruebas verifican fronteras + # normativas exactas. + cpp: + name: C++ ${{ matrix.os }} / ${{ matrix.build_type }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + build_type: [Debug, Release] + steps: + - uses: actions/checkout@v4 + - name: Configurar + run: cmake -S . -B build -DVENTPY_BUILD_TESTS=ON -DVENTPY_BUILD_PYTHON=OFF -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} + - name: Compilar + run: cmake --build build --config ${{ matrix.build_type }} --parallel + - name: Ejecutar pruebas + run: ctest --test-dir build -C ${{ matrix.build_type }} --output-on-failure + + # AddressSanitizer y UndefinedBehaviorSanitizer. El compilador de Windows no + # ofrece UBSan, de modo que este trabajo es el unico lugar donde se detecta + # comportamiento indefinido (lecturas sin inicializar, desbordes, division + # entre cero, conversiones invalidas). + sanitizers: + name: Sanitizers (ASan + UBSan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configurar con sanitizers + env: + FLAGS: -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=undefined -g -O1 + run: | + cmake -S . -B build-san \ + -DVENTPY_BUILD_TESTS=ON -DVENTPY_BUILD_PYTHON=OFF \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_FLAGS="$FLAGS" -DCMAKE_EXE_LINKER_FLAGS="$FLAGS" + - name: Compilar + run: cmake --build build-san --parallel + - name: Ejecutar pruebas bajo sanitizers + env: + ASAN_OPTIONS: detect_leaks=1:abort_on_error=1:strict_string_checks=1 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + run: ctest --test-dir build-san --output-on-failure + + # Cobertura de lineas y ramas del nucleo. El umbral evita que la cobertura + # caiga sin que nadie lo note; se mide solo include/ventpy (la libreria), + # excluyendo pruebas y dependencias externas. + coverage: + name: Cobertura + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Instalar lcov + run: sudo apt-get update && sudo apt-get install -y lcov + - name: Configurar con instrumentacion + run: | + cmake -S . -B build-cov \ + -DVENTPY_BUILD_TESTS=ON -DVENTPY_BUILD_PYTHON=OFF \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="--coverage -O0 -g" \ + -DCMAKE_EXE_LINKER_FLAGS="--coverage" + - name: Compilar y ejecutar + run: | + cmake --build build-cov --parallel + ctest --test-dir build-cov --output-on-failure + - name: Medir y comprobar umbral + run: | + lcov --capture --directory build-cov --output-file cov.info \ + --rc branch_coverage=1 --ignore-errors mismatch,gcov,source + lcov --extract cov.info "*/include/ventpy/*" --output-file cov-core.info \ + --rc branch_coverage=1 --ignore-errors unused + lcov --list cov-core.info --rc branch_coverage=1 + LINES=$(lcov --summary cov-core.info --rc branch_coverage=1 2>&1 | grep -oP 'lines\.+: \K[0-9.]+') + echo "Cobertura de lineas del nucleo: $LINES %" + awk -v v="$LINES" 'BEGIN { if (v+0 < 85.0) { print "Por debajo del umbral (85%)"; exit 1 } }' + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cobertura + path: cov-core.info + + # Suite Python contra la extension compilada, en las tres plataformas. + # Incluye las pruebas basadas en propiedades, que generan sus propios casos. + python: + name: Python ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Instalar el paquete con sus extras de prueba + run: python -m pip install --upgrade pip && python -m pip install ".[test]" + - name: Ejecutar la suite + run: python -m pytest tests/python -q + - name: Ejecutar las pruebas de escala + run: python -m pytest tests/python -q -m slow + - name: Comprobar la interfaz de linea de comandos + run: | + ventpy --help + ventpy lmp --norma peru diff --git a/.gitignore b/.gitignore index e79dd7b..b165735 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build artifacts build/ build-tests/ +build-release/ _build/ dist/ wheelhouse/ diff --git a/pyproject.toml b/pyproject.toml index 4042159..2c43a43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ Issues = "https://github.com/Miqueas7/VentPy/issues" ventpy = "ventpy.cli:main" [project.optional-dependencies] -test = ["pytest>=7.0"] +test = ["pytest>=7.0", "hypothesis>=6.0"] viz = ["matplotlib>=3.7", "numpy>=1.24"] [tool.scikit-build] @@ -42,6 +42,9 @@ wheel.packages = ["python/ventpy"] [tool.pytest.ini_options] testpaths = ["tests/python"] +markers = [ + "slow: pruebas de escala que tardan mas de lo habitual", +] [tool.cibuildwheel] build = "cp39-* cp310-* cp311-* cp312-* cp313-*" diff --git a/tests/python/test_escala.py b/tests/python/test_escala.py new file mode 100644 index 0000000..889856c --- /dev/null +++ b/tests/python/test_escala.py @@ -0,0 +1,156 @@ +"""Pruebas de escala y condicionamiento del solver de red. + +Los tests funcionales usan redes de 2 a 5 ramales. Un modelo real de mina +tiene cientos. Aqui se generan topologias de mina por niveles (rampa de +ingreso, galerias por nivel y retorno) de tamano creciente para verificar +que el balance converge, que lo hace en un tiempo razonable, y que cuando +no converge lo reporta honestamente en vez de entregar numeros invalidos. +""" + +import math +import time + +import pytest + +import ventpy + + +def _rama(bid, desde, hasta, r, fan=0.0): + b = ventpy.NetworkBranch() + b.branch_id = bid + b.from_node = desde + b.to_node = hasta + b.r_manual = r + b.fan_pressure_pa = fan + return b + + +def red_por_niveles(n_niveles, r_rampa=0.02, r_galeria=0.35, fan_pa=2500.0): + """Mina de `n_niveles`: ingreso por el lado A, galeria por nivel, retorno + por el lado B. Genera 3*n_niveles + 1 ramales y n_niveles + 1 mallas.""" + ramas = [_rama("FAN", "S", "A0", r_rampa, fan=fan_pa)] + for i in range(n_niveles): + ramas.append(_rama(f"GAL{i}", f"A{i}", f"B{i}", r_galeria)) + if i + 1 < n_niveles: + ramas.append(_rama(f"IN{i}", f"A{i}", f"A{i+1}", r_rampa)) + ramas.append(_rama(f"OUT{i}", f"B{i+1}", f"B{i}", r_rampa)) + ramas.append(_rama("RET", "B0", "S", r_rampa)) + + d = ventpy.NetworkDefinition() + d.branches = ramas + return d + + +def _kirchhoff_ok(resultado, tol=1e-6): + nodos = set() + for b in resultado.branches: + nodos.add(b.from_node) + nodos.add(b.to_node) + for nodo in nodos: + balance = 0.0 + for b in resultado.branches: + if b.to_node == nodo: + balance += b.q_m3min + if b.from_node == nodo: + balance -= b.q_m3min + if abs(balance) > tol: + return False, nodo, balance + return True, None, 0.0 + + +def _resolver(red, max_iter=2000): + sp = ventpy.SolverParams() + sp.tolerance_m3min = 0.06 + sp.max_iterations = max_iter + t0 = time.perf_counter() + r = ventpy.NetworkSolver.solve(red, ventpy.AtmosphericParams(), sp) + return r, time.perf_counter() - t0 + + +class TestEscala: + def test_red_mediana_50_ramales(self): + red = red_por_niveles(17) # ~50 ramales + assert len(red.branches) >= 45 + r, seg = _resolver(red) + assert r.converged, f"no convergio: residual {r.max_residual_m3min}" + ok, nodo, bal = _kirchhoff_ok(r) + assert ok, f"Kirchhoff roto en {nodo}: {bal}" + assert all(math.isfinite(b.q_m3min) for b in r.branches) + assert seg < 10.0, f"demasiado lento: {seg:.2f} s" + + @pytest.mark.slow + def test_red_grande_200_ramales(self): + red = red_por_niveles(67) # ~200 ramales + r, seg = _resolver(red) + if r.converged: + ok, nodo, bal = _kirchhoff_ok(r) + assert ok, f"Kirchhoff roto en {nodo}: {bal}" + else: + # falla honesta: residual y advertencia presentes + assert r.max_residual_m3min > 0.0 + assert any("NO CONVERGIO" in w for w in r.warnings) + assert seg < 60.0, f"demasiado lento: {seg:.2f} s" + + @pytest.mark.slow + def test_red_muy_grande_500_ramales(self): + red = red_por_niveles(167) # ~500 ramales + r, seg = _resolver(red) + assert all(math.isfinite(b.q_m3min) for b in r.branches) + if r.converged: + ok, nodo, bal = _kirchhoff_ok(r) + assert ok, f"Kirchhoff roto en {nodo}: {bal}" + else: + assert any("NO CONVERGIO" in w for w in r.warnings) + assert seg < 120.0, f"demasiado lento: {seg:.2f} s" + + +class TestCondicionamiento: + def test_resistencias_en_rangos_extremos(self): + """Resistencias que difieren en 12 ordenes de magnitud dentro de la + misma red: el solver debe converger o reportar el fallo, nunca + entregar valores no finitos.""" + d = ventpy.NetworkDefinition() + d.branches = [ + _rama("FAN", "S", "A", 1e-6, fan=3000.0), + _rama("FINA", "A", "B", 1e6), # labor practicamente cerrada + _rama("ANCHA", "A", "B", 1e-6), # by-pass de resistencia minima + _rama("RET", "B", "S", 0.1), + ] + r, _ = _resolver(d) + for b in r.branches: + assert math.isfinite(b.q_m3min), f"{b.branch_id} no finito" + assert math.isfinite(b.pressure_drop_pa) + if r.converged: + ok, nodo, bal = _kirchhoff_ok(r) + assert ok, f"Kirchhoff roto en {nodo}: {bal}" + # el caudal prefiere abrumadoramente la rama de baja resistencia + fina = next(b for b in r.branches if b.branch_id == "FINA") + ancha = next(b for b in r.branches if b.branch_id == "ANCHA") + assert abs(ancha.q_m3min) > abs(fina.q_m3min) + else: + assert any("NO CONVERGIO" in w for w in r.warnings) + + @pytest.mark.parametrize("tolerancia", [0.6, 0.06, 0.006, 0.0006]) + def test_red_sin_ventilador_tiende_a_caudal_nulo(self, tolerancia): + """Sin fuente de presion no puede haber circulacion sostenida. + + El solver converge cuando la correccion de malla cae por debajo de la + tolerancia, no cuando el caudal es exactamente cero, de modo que queda + un residuo acotado por esa tolerancia: pedir una tolerancia diez veces + menor deja un caudal diez veces menor. Se verifica esa relacion, que + es lo que el algoritmo garantiza. + """ + d = ventpy.NetworkDefinition() + d.branches = [ + _rama("A", "N1", "N2", 0.5), + _rama("B", "N2", "N1", 0.5), + ] + sp = ventpy.SolverParams() + sp.tolerance_m3min = tolerancia + sp.max_iterations = 5000 + r = ventpy.NetworkSolver.solve(d, ventpy.AtmosphericParams(), sp) + + assert r.converged + for b in r.branches: + assert abs(b.q_m3min) <= tolerancia, ( + f"{b.branch_id}: {b.q_m3min} excede la tolerancia {tolerancia}") diff --git a/tests/python/test_properties.py b/tests/python/test_properties.py new file mode 100644 index 0000000..b580a7c --- /dev/null +++ b/tests/python/test_properties.py @@ -0,0 +1,327 @@ +"""Pruebas basadas en propiedades (Hypothesis). + +A diferencia de los tests de caso puntual, aqui se declaran invariantes que +deben cumplirse para CUALQUIER entrada dentro del dominio fisico, y la +libreria de generacion busca contraejemplos. + +Los rangos de las estrategias son fisicamente razonables para mineria +subterranea: altitudes hasta 5.000 msnm, potencias 10-1000 HP, secciones +1-100 m2, resistencias 1e-3 a 10 Ns2/m8. +""" + +import math + +import pytest +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +import ventpy + +# Perfil comun: las llamadas al nucleo son rapidas, pero construir structs +# nanobind tiene coste; se limita el numero de ejemplos por propiedad. +PROP = settings(max_examples=60, deadline=None, + suppress_health_check=[HealthCheck.too_slow]) + +altitudes = st.floats(min_value=0.0, max_value=5000.0, allow_nan=False, + allow_infinity=False) +resistencias = st.floats(min_value=1e-3, max_value=10.0, allow_nan=False, + allow_infinity=False) +areas = st.floats(min_value=1.0, max_value=100.0, allow_nan=False, + allow_infinity=False) + + +# --------------------------------------------------------------------------- +# Monotonias fisicas +# --------------------------------------------------------------------------- + +class TestMonotonia: + @given(n=st.integers(min_value=1, max_value=500), alt=altitudes) + @settings(PROP) + def test_caudal_personal_no_decrece_con_trabajadores(self, n, alt): + """Un trabajador mas nunca puede exigir menos aire.""" + cfg = ventpy.RegulatoryConfig.peru() + q_n = ventpy.calculate_personnel_flow(n, alt, cfg).q_personnel + q_n1 = ventpy.calculate_personnel_flow(n + 1, alt, cfg).q_personnel + assert q_n1 >= q_n + + @given(n=st.integers(min_value=1, max_value=200), + alt=st.floats(min_value=0.0, max_value=4000.0, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_caudal_personal_no_decrece_con_altitud(self, n, alt): + """La escala del Art. 247 es no decreciente en altitud.""" + cfg = ventpy.RegulatoryConfig.peru() + q_bajo = ventpy.calculate_personnel_flow(n, alt, cfg).q_personnel + q_alto = ventpy.calculate_personnel_flow(n, alt + 500.0, cfg).q_personnel + assert q_alto >= q_bajo + + @given(r1=resistencias, r2=resistencias, delta=st.floats( + min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False)) + @settings(PROP) + def test_mas_resistencia_no_aumenta_su_caudal(self, r1, r2, delta): + """En dos ramas en paralelo, subir la resistencia de una no puede + aumentar el caudal que pasa por ella.""" + def resolver(ra): + d = ventpy.NetworkDefinition() + d.branches = [ + _rama("F", "S", "A", 0.05, fan=800.0), + _rama("P1", "A", "B", ra), + _rama("P2", "A", "B", r2), + _rama("R", "B", "S", 0.1), + ] + sp = ventpy.SolverParams() + sp.tolerance_m3min = 0.006 + sp.max_iterations = 500 + return ventpy.NetworkSolver.solve(d, ventpy.AtmosphericParams(), sp) + + base = resolver(r1) + subida = resolver(r1 + delta) + assume(base.converged and subida.converged) + assert abs(subida.branches[1].q_m3min) <= abs(base.branches[1].q_m3min) + 1e-6 + # y la rama gemela no puede perder caudal cuando su competidora empeora + assert abs(subida.branches[2].q_m3min) >= abs(base.branches[2].q_m3min) - 1e-6 + + +# --------------------------------------------------------------------------- +# Redondeo de seguridad +# --------------------------------------------------------------------------- + +class TestRedondeoSeguridad: + @given(gen=st.floats(min_value=0.1, max_value=500.0, allow_nan=False, + allow_infinity=False), + target=st.floats(min_value=0.5, max_value=3.0, allow_nan=False, + allow_infinity=False), + eficiencia=st.floats(min_value=0.0, max_value=0.95, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_q_dust_nunca_por_debajo_del_crudo(self, gen, target, eficiencia): + """El caudal reportado cubre siempre la dilucion teorica, y no la + excede en mas de 1 m3/min (redondeo hacia arriba, no inflado).""" + p = ventpy.DustParams() + p.dust_generation_rate_mg_s = gen + p.target_concentration_mg_m3 = target + p.water_suppression = True + p.suppression_efficiency = eficiencia + r = ventpy.calculate_dust_flow(p, ventpy.RegulatoryConfig.peru()) + + crudo = gen * (1.0 - eficiencia) / target * 60.0 + assert r.q_dust >= crudo - 1e-6 + assert r.q_dust < crudo + 1.0 + + +# --------------------------------------------------------------------------- +# Conservacion de masa en la red (invariante mas fuerte) +# --------------------------------------------------------------------------- + +def _rama(bid, desde, hasta, r, fan=0.0): + b = ventpy.NetworkBranch() + b.branch_id = bid + b.from_node = desde + b.to_node = hasta + b.r_manual = r + b.fan_pressure_pa = fan + return b + + +@st.composite +def redes_conexas(draw): + """Genera una red conexa con al menos una malla: un ciclo base de n nodos + mas cuerdas aleatorias entre nodos existentes.""" + n_nodos = draw(st.integers(min_value=3, max_value=8)) + n_cuerdas = draw(st.integers(min_value=1, max_value=4)) + nodos = [f"N{i}" for i in range(n_nodos)] + + ramas = [] + for i in range(n_nodos): # ciclo base + r = draw(st.floats(min_value=0.01, max_value=5.0, allow_nan=False, + allow_infinity=False)) + ramas.append(_rama(f"C{i}", nodos[i], nodos[(i + 1) % n_nodos], r)) + + for k in range(n_cuerdas): # cuerdas: crean mallas adicionales + i = draw(st.integers(min_value=0, max_value=n_nodos - 1)) + j = draw(st.integers(min_value=0, max_value=n_nodos - 1)) + assume(i != j) + r = draw(st.floats(min_value=0.01, max_value=5.0, allow_nan=False, + allow_infinity=False)) + ramas.append(_rama(f"X{k}", nodos[i], nodos[j], r)) + + fan = draw(st.floats(min_value=100.0, max_value=3000.0, allow_nan=False, + allow_infinity=False)) + ramas[0].fan_pressure_pa = fan + + d = ventpy.NetworkDefinition() + d.branches = ramas + return d + + +class TestKirchhoff: + @given(red=redes_conexas()) + @settings(max_examples=80, deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large]) + def test_conservacion_en_todos_los_nodos(self, red): + """Para CUALQUIER red que converja, la suma de caudales en cada nodo + debe anularse (primera ley de Kirchhoff).""" + sp = ventpy.SolverParams() + sp.tolerance_m3min = 0.006 + sp.max_iterations = 500 + r = ventpy.NetworkSolver.solve(red, ventpy.AtmosphericParams(), sp) + assume(r.converged) + + nodos = set() + for b in r.branches: + nodos.add(b.from_node) + nodos.add(b.to_node) + + for nodo in nodos: + balance = 0.0 + for b in r.branches: + if b.to_node == nodo: + balance += b.q_m3min + if b.from_node == nodo: + balance -= b.q_m3min + assert abs(balance) < 1e-6, f"nodo {nodo}: balance {balance}" + + @given(red=redes_conexas()) + @settings(max_examples=40, deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large]) + def test_resultados_finitos_o_no_convergido(self, red): + """Nunca se entregan NaN/infinito: o los numeros son finitos, o el + resultado viene marcado como no convergido.""" + sp = ventpy.SolverParams() + sp.tolerance_m3min = 0.006 + sp.max_iterations = 200 + r = ventpy.NetworkSolver.solve(red, ventpy.AtmosphericParams(), sp) + if r.converged: + for b in r.branches: + assert math.isfinite(b.q_m3min) + assert math.isfinite(b.pressure_drop_pa) + + +# --------------------------------------------------------------------------- +# Cobertura: relaciones internas del resultado +# --------------------------------------------------------------------------- + +class TestCoberturaInvariantes: + @given(req=st.floats(min_value=1.0, max_value=1e5, allow_nan=False, + allow_infinity=False), + med=st.floats(min_value=0.0, max_value=1e5, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_relaciones_del_resultado(self, req, med): + m = ventpy.ZoneMeasurement() + m.zone_name = "Z" + m.q_measured_m3min = med + r = ventpy.CoverageCalculator.compare_zone(req, m) + + assert r.coverage_ratio == pytest.approx(med / req, rel=1e-12) + assert r.compliant == (med >= req) + assert (r.deficit_m3min > 0.0) == (not r.compliant) + if not r.compliant: + assert r.deficit_m3min >= req - med - 1e-9 # redondeo hacia arriba + + +# --------------------------------------------------------------------------- +# Escalado dimensional de Atkinson +# --------------------------------------------------------------------------- + +class TestAtkinsonEscalado: + @given(largo=st.floats(min_value=10.0, max_value=2000.0, allow_nan=False, + allow_infinity=False), + area=areas, + k=st.floats(min_value=0.002, max_value=0.05, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_resistencia_proporcional_a_longitud(self, largo, area, k): + def r_de(L): + p = ventpy.AirwayParams() + p.airway_id = "A" + p.length_m = L + p.perimeter_m = 4.0 * math.sqrt(area) + p.area_m2 = area + p.lining = ventpy.AirwayLining.Manual + p.atkinson_k = k + return ventpy.AtkinsonCalculator.calculate_resistance( + p, ventpy.AtmosphericParams()).r_friction + + assert r_de(2.0 * largo) == pytest.approx(2.0 * r_de(largo), rel=1e-9) + + @given(area=areas, k=st.floats(min_value=0.002, max_value=0.05, + allow_nan=False, allow_infinity=False)) + @settings(PROP) + def test_resistencia_inversa_al_cubo_del_area(self, area, k): + def r_de(A): + p = ventpy.AirwayParams() + p.airway_id = "A" + p.length_m = 500.0 + p.perimeter_m = 15.0 + p.area_m2 = A + p.lining = ventpy.AirwayLining.Manual + p.atkinson_k = k + return ventpy.AtkinsonCalculator.calculate_resistance( + p, ventpy.AtmosphericParams()).r_friction + + assert r_de(2.0 * area) == pytest.approx(r_de(area) / 8.0, rel=1e-9) + + +# --------------------------------------------------------------------------- +# Curva de ventilador: interpolacion acotada +# --------------------------------------------------------------------------- + +class TestCurvaVentilador: + @given(q=st.floats(min_value=600.0, max_value=3000.0, allow_nan=False, + allow_infinity=False), + rho=st.floats(min_value=0.6, max_value=1.3, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_interpolacion_dentro_del_rango_de_catalogo(self, q, rho): + c = ventpy.FanCurve() + c.fan_id = "AX" + c.points = [ventpy.FanCurvePoint(), ventpy.FanCurvePoint(), + ventpy.FanCurvePoint()] + for punto, (qq, pp) in zip(c.points, [(600.0, 2000.0), (1800.0, 1400.0), + (3000.0, 200.0)]): + punto.q_m3min = qq + punto.pressure_pa = pp + + p = ventpy.FanCalculator.pressure_at(c, q, rho) + factor = rho / c.rated_density_kg_m3 + assert 200.0 * factor - 1e-9 <= p <= 2000.0 * factor + 1e-9 + + +# --------------------------------------------------------------------------- +# Determinismo y rechazo de valores no finitos +# --------------------------------------------------------------------------- + +class TestDeterminismoYDominio: + @given(n=st.integers(min_value=1, max_value=300), alt=altitudes) + @settings(PROP) + def test_misma_entrada_mismo_resultado(self, n, alt): + cfg = ventpy.RegulatoryConfig.peru() + a = ventpy.calculate_personnel_flow(n, alt, cfg) + b = ventpy.calculate_personnel_flow(n, alt, cfg) + assert a.q_personnel == b.q_personnel + assert a.flow_per_person_base == b.flow_per_person_base + + @given(malo=st.sampled_from([float("nan"), float("inf"), float("-inf")])) + @settings(PROP) + def test_altitud_no_finita_es_rechazada(self, malo): + cfg = ventpy.RegulatoryConfig.peru() + with pytest.raises(ValueError): + ventpy.calculate_personnel_flow(10, malo, cfg) + + @given(malo=st.sampled_from([float("nan"), float("inf"), float("-inf")])) + @settings(PROP) + def test_medicion_no_finita_es_rechazada(self, malo): + m = ventpy.ZoneMeasurement() + m.zone_name = "Z" + m.q_measured_m3min = malo + with pytest.raises(ValueError): + ventpy.CoverageCalculator.compare_zone(100.0, m) + + @given(malo=st.sampled_from([float("nan"), float("inf"), float("-inf")])) + @settings(PROP) + def test_resistencia_no_finita_es_rechazada(self, malo): + d = ventpy.NetworkDefinition() + d.branches = [_rama("A", "N1", "N2", malo), _rama("B", "N2", "N1", 0.5)] + with pytest.raises(ValueError): + ventpy.NetworkSolver.solve(d, ventpy.AtmosphericParams())