From e2899b2bb3a5d7231d010b99e9381406dc3525d7 Mon Sep 17 00:00:00 2001 From: Miqueas Quintanilla Date: Thu, 12 Nov 2026 01:49:04 -0500 Subject: [PATCH 1/3] feat: preajustes normativos de Colombia y Mexico RegulatoryConfig gana colombia() y mexico(), con sus tablas de gases y la cita de cada concepto, siguiendo el patron del preajuste chileno: cada valor nombra el articulo o numeral leido en el texto vigente y, donde la norma no regula el concepto, la cita lo declara en vez de tomar prestado el articulo de otro pais. Colombia, Decreto 1886 de 2015 (consolidado con el Decreto 944 de 2022, que no modifico los articulos con valores de ventilacion): - 3 m3/min por trabajador hasta 1.500 msnm y 6 de 1.500 msnm en adelante (Art. 54, nums. 1 y 2). La norma tiene un solo escalon. Las dos bandas incluyen el 1.500 en su texto; rige la regla comun de borde y a 1.500 msnm exactos aplica la banda inferior. - 6 m3/min por HP con CO en el escape <= 0,12 % (Art. 54, par. 2, num. 1). El num. 2 admite 4 m3/min por HP si el CO es <= 0,08 %; la cita lo dice. - Gases del Art. 39, oxigeno del Art. 38 (19,5 a 23,5 %) y metano del Art. 53 (1,0 %, el valor mas estricto). Mexico, NOM-023-STPS-2012 (minas subterraneas que no son de carbon): - 1,50 m3/min por trabajador, sin escala por altitud (numeral 8.4.4 a) 1). - 2,13 m3/min por caballo de fuerza diesel (numeral 8.4.4 a) 2). - La norma no trae tabla de gases: remite a la NOM-010-STPS-1999 "o las que las sustituyan", hoy la NOM-010-STPS-2014 (Tabla I.1). - No fija el minimo de oxigeno en labores: la tabla lleva 19,5 % como criterio de ingenieria y la cita lo declara. - Las minas de carbon se rigen por la NOM-032-STPS-2008 y quedan fuera. En ambas normas el tiempo de dilucion tras la voladura, el volumen de gases por kg y el factor de fugas no estan regulados: se conservan 30 min, 0,04 m3/kg y 0,15 como criterio de ingenieria, y la cita remite al articulo de reingreso (Art. 157 colombiano, numeral 8.6.9 g) mexicano). El SO2 solo tiene limite de corto tiempo en las dos tablas: su twa_8h va sin valor. regulation_reference deja el ternario Peru/Chile y pasa a una cita por norma en orden fijo, de modo que agregar un pais es un argumento mas por concepto. La clausula de escala por altitud va vacia en Colombia porque su unico escalon ya esta en la cita del caudal por persona; asi los umbrales de relleno no llegan a la referencia impresa. La CLI acepta --norma colombia|mexico en demanda, lmp y cobertura. De paso, shock_factors() deja de listar x = 0.0 en expansion, contraccion, codos y union, donde el factor sale por formula o es manual: un cero se lee como "sin perdida", asi que x va sin valor en esas filas. La ayuda de GasLimit aclara que un campo que la norma no fija vale None, y la de calculate_diesel_flow que la version simple no evalua la dilucion de CO ni de NOx. Las pruebas nuevas cubren los valores de cada preajuste, cada fila de las dos tablas de gases y un barrido de todo resultado alcanzable con cada norma que falla si alguna cita menciona una norma ajena. --- bindings/bindings.cpp | 49 +++- include/ventpy/atkinson.hpp | 15 +- include/ventpy/limites_gases.hpp | 112 +++++++++ include/ventpy/normativa.hpp | 330 ++++++++++++++++++++------ include/ventpy/types.hpp | 6 +- python/ventpy/cli.py | 7 +- tests/cpp/test_atkinson.cpp | 7 +- tests/cpp/test_citas_normativas.cpp | 173 ++++++++++++++ tests/cpp/test_limites_gases.cpp | 119 ++++++++++ tests/cpp/test_normativa.cpp | 104 ++++++++ tests/python/test_citas_normativas.py | 76 ++++++ tests/python/test_multinorma.py | 68 ++++++ tests/python/test_red_ventilacion.py | 12 + 13 files changed, 979 insertions(+), 99 deletions(-) diff --git a/bindings/bindings.cpp b/bindings/bindings.cpp index 70eeb02..732c969 100644 --- a/bindings/bindings.cpp +++ b/bindings/bindings.cpp @@ -49,7 +49,11 @@ NB_MODULE(_ventpy_core, m) { .value("DS024_Peru", RegulatoryStandard::DS024_Peru, "DS 024-2016-EM / DS 023-2017-EM (Peru)") .value("DS132_Chile", RegulatoryStandard::DS132_Chile, - "DS 132 Reglamento de Seguridad Minera (Chile)"); + "DS 132 Reglamento de Seguridad Minera (Chile)") + .value("D1886_Colombia", RegulatoryStandard::D1886_Colombia, + "Decreto 1886 de 2015, mod. Decreto 944 de 2022 (Colombia)") + .value("NOM023_Mexico", RegulatoryStandard::NOM023_Mexico, + "NOM-023-STPS-2012; gases por NOM-010-STPS-2014 (Mexico)"); nb::enum_(m, "GasType", "Gases regulados en interior mina.") @@ -213,7 +217,8 @@ NB_MODULE(_ventpy_core, m) { .def_rw("explosive_type", &BlastingParams::explosive_type, "Tipo de explosivo (ExplosiveType)") .def_rw("gas_volume_per_kg", &BlastingParams::gas_volume_per_kg, - "Vol. gases [m3/kg], 0 = usar tipo") + "Vol. total de gases [m3/kg] que se reporta; 0 = estimarlo.\n" + "No cambia el caudal: la dilucion usa CO y NOx por kg") .def_rw("dilution_time_min", &BlastingParams::dilution_time_min, "Tiempo maximo de dilucion [min]") .def_rw("face_area_m2", &BlastingParams::face_area_m2, @@ -564,19 +569,20 @@ NB_MODULE(_ventpy_core, m) { "Estructura auditable: cada entrada cita su fuente normativa exacta\n" "en regulation_ref. Los campos de valor son opcionales porque cada\n" "norma define combinaciones distintas (TWA+STEL, solo techo, minimo\n" - "para O2). Unidad canonica: ppm / % vol.") + "para O2). Un campo que la norma no fija vale None: no es cero.\n" + "Unidad canonica: ppm / % vol.") .def_ro("gas", &GasLimit::gas, "Gas regulado (GasType)") .def_ro("unit", &GasLimit::unit, "Unidad del limite (ConcentrationUnit: PPM o porcentaje en volumen)") .def_ro("twa_8h", &GasLimit::twa_8h, - "Promedio ponderado 8 h (TWA / LPP)") + "Promedio ponderado 8 h (TWA / LPP / VLE-PPT); None si la norma no lo fija") .def_ro("stel", &GasLimit::stel, - "Corta duracion (STEL / LPT)") + "Corta duracion (STEL / LPT / VLE-CT); None si la norma no lo fija") .def_ro("ceiling", &GasLimit::ceiling, - "Techo absoluto (C) - o maximo para O2") + "Techo absoluto (C) - o maximo para O2; None si la norma no lo fija") .def_ro("floor_min", &GasLimit::floor_min, - "Minimo permitido (solo O2)") + "Minimo permitido (solo O2); None en los demas gases") .def_ro("regulation_ref", &GasLimit::regulation_ref, "Cita normativa exacta"); @@ -596,7 +602,8 @@ NB_MODULE(_ventpy_core, m) { // ======================================================================== nb::class_(m, "RegulatoryConfig", "Configuracion normativa inmutable para calculo de ventilacion.\n" - "DS 024-2016-EM / DS 023-2017-EM (Peru) o DS 132 (Chile).") + "DS 024-2016-EM / DS 023-2017-EM (Peru), DS 132 (Chile),\n" + "Decreto 1886 de 2015 (Colombia) o NOM-023-STPS-2012 (Mexico).") .def(nb::init< RegulatoryStandard, double, double, double, double, double, double, double, double, double, @@ -620,6 +627,17 @@ NB_MODULE(_ventpy_core, m) { "Preset oficial chileno - DS 132, Reglamento de Seguridad Minera.\n" "3.0 m3/min por persona (Art. 138, sin escalon por altitud) y\n" "2.83 m3/min por HP efectivo al freno (Art. 132).") + .def_static("colombia", &RegulatoryConfig::colombia, + "Preset oficial colombiano - Decreto 1886 de 2015.\n" + "3.0 m3/min por trabajador hasta 1,500 msnm y 6.0 de 1,500 msnm\n" + "en adelante (Art. 54, nums. 1 y 2); 6.0 m3/min por HP con CO en\n" + "el exosto <= 0.12% (Art. 54, par. 2, num. 1). El num. 2 admite\n" + "4.0 m3/min por HP si el CO en el exosto es <= 0.08%.") + .def_static("mexico", &RegulatoryConfig::mexico, + "Preset oficial mexicano - NOM-023-STPS-2012 (minas subterraneas\n" + "que no son de carbon). 1.50 m3/min por trabajador, sin escalon\n" + "por altitud (numeral 8.4.4 a) 1) y 2.13 m3/min por caballo de\n" + "fuerza diesel (numeral 8.4.4 a) 2).") .def_static("for_standard", &RegulatoryConfig::for_standard, nb::arg("standard"), "Construye el preset oficial de la norma indicada.\n" @@ -1065,7 +1083,7 @@ NB_MODULE(_ventpy_core, m) { .def_ro("type", &ShockFactorEntry::type, "Tipo de singularidad (SingularityType)") .def_ro("x", &ShockFactorEntry::x, - "0 si es formula/manual (ver note)") + "Factor de choque; None si sale por formula o es manual (ver note)") .def_ro("biblio_ref", &ShockFactorEntry::biblio_ref, "Cita bibliografica exacta de la entrada") .def_ro("note", &ShockFactorEntry::note, @@ -1365,19 +1383,26 @@ NB_MODULE(_ventpy_core, m) { nb::arg("num_workers"), nb::arg("altitude_masl"), nb::arg("config"), "Calcula Q_Per (caudal por personal) - version simple.\n" - "DS 024-2016-EM, Art. 236."); + "La cita sigue a la norma configurada."); m.def("calculate_diesel_flow", &DieselFlowCalculator::calculate, nb::arg("fleet"), nb::arg("config"), "Calcula Q_Eq (caudal por equipos diesel) - version simple.\n" - "DS 024-2016-EM, Art. 246."); + "Solo aplica el factor normativo por HP de la norma configurada:\n" + "q_for_co_dilution y q_for_nox_dilution quedan en 0 porque no se\n" + "evaluan. La dilucion por contaminante segun el Tier del motor la\n" + "calcula VentilationGovernor."); m.def("calculate_blasting_flow", &BlastingFlowCalculator::calculate, nb::arg("params"), nb::arg("config"), "Calcula Q_Exp (caudal por explosivos) - version simple.\n" - "DS 024-2016-EM, Art. 243-244."); + "Modelo por contaminante (dilucion de CO y NOx segun el tipo de\n" + "explosivo), sin correccion por altitud y sin los criterios de\n" + "seccion si no se dan face_area_m2 y face_length_m. Para corregir\n" + "por altitud use VentilationGovernor. La cita sigue a la norma\n" + "configurada."); m.def("calculate_dust_flow", [](const DustParams& p, const RegulatoryConfig& c) { diff --git a/include/ventpy/atkinson.hpp b/include/ventpy/atkinson.hpp index 74f2910..9f18aef 100644 --- a/include/ventpy/atkinson.hpp +++ b/include/ventpy/atkinson.hpp @@ -61,8 +61,9 @@ inline const std::vector& atkinson_friction_factors() { } /// Tabla informativa de factores de choque — McPherson (2009), Apéndice A5 -/// (p. 5-26 a 5-38). x > 0 solo para los valores exactos; x = 0 indica -/// "por fórmula" (nota) o "manual-only" (nota). NO incluye Manual. +/// (p. 5-26 a 5-38). x solo trae valor en los límites exactos; va sin valor +/// donde el factor sale "por fórmula" o es "manual-only" (ver la nota): un +/// 0 se leería como "sin pérdida". NO incluye Manual. inline const std::vector& shock_factors() { static const std::vector table = { {SingularityType::Exit, 1.0, @@ -71,19 +72,19 @@ inline const std::vector& shock_factors() { {SingularityType::Entrance, 0.5, "McPherson (2009), Ap. A5.2(b), p. 5-28", "Limite exacto de contraccion brusca con A1 -> infinito: X = 0.5(1 - A2/A1)^2 -> 0.5"}, - {SingularityType::Expansion, 0.0, + {SingularityType::Expansion, std::nullopt, "McPherson (2009), Ap. A5.2(a), p. 5-28", "Por formula: X = (1 - area_ratio)^2 con area_ratio = A1/A2 en (0,1)"}, - {SingularityType::Contraction, 0.0, + {SingularityType::Contraction, std::nullopt, "McPherson (2009), Ap. A5.2(b), p. 5-28", "Por formula: X = 0.5*(1 - area_ratio)^2 con area_ratio = A2/A1 en (0,1)"}, - {SingularityType::Bend90, 0.0, + {SingularityType::Bend90, std::nullopt, "McPherson (2009), Figs. A5.1-A5.2, p. 5-27", "Manual-only v1: la fuente publica graficos, no constantes"}, - {SingularityType::Bend45, 0.0, + {SingularityType::Bend45, std::nullopt, "McPherson (2009), Fig. A5.3, p. 5-28", "Manual-only v1: correccion por angulo X_theta = X90 * k (grafico)"}, - {SingularityType::Junction, 0.0, + {SingularityType::Junction, std::nullopt, "McPherson (2009), Ap. A5.3, p. 5-28", "Manual-only: la formula exige velocidades de ramales de la red"}, }; diff --git a/include/ventpy/limites_gases.hpp b/include/ventpy/limites_gases.hpp index f45b31f..fa7e2ae 100644 --- a/include/ventpy/limites_gases.hpp +++ b/include/ventpy/limites_gases.hpp @@ -5,6 +5,8 @@ * Fuentes (validadas contra los textos oficiales): * - Perú: DS 024-2016-EM, Anexo 15 (obligatorio vía Art. 246, mod. DS 023-2017-EM). * - Chile: DS 594 Art. 66 (vía remisión del DS 132 Arts. 135/144) y DS 132. + * - Colombia: Decreto 1886 de 2015, Arts. 38, 39 y 53. + * - México: NOM-010-STPS-2014, Tabla I.1 (vía remisión de la NOM-023-STPS-2012). * * @copyright 2026 VentPy Project */ @@ -99,6 +101,112 @@ inline const std::vector& gas_limits_chile() { return table; } +/** + * @brief Tabla de VLP de Colombia — Decreto 1886 de 2015, Art. 39 (gases), + * Art. 38 (oxígeno) y Art. 53 (metano). + * + * La tabla del Art. 39 está en ppm y no fija valores techo. El artículo exige + * además cumplir «como mínimo» los estándares de la ACGIH y actualizar los + * VLP cada año (par. 1): esta tabla congela los valores del decreto. El SO2 + * solo tiene STEL; el CO, el NO2 y el NO solo tienen TWA. Los VLP no se + * corrigen por altitud; para jornadas de más de 8 h el par. 2 aplica la + * corrección de Brief y Scala — fuera del alcance de esta tabla. + */ +inline const std::vector& gas_limits_colombia() { + static const std::vector table = { + {.gas = GasType::CO, .unit = ConcentrationUnit::PPM, + .twa_8h = 25.0, + .regulation_ref = "Decreto 1886 de 2015, Art. 39 (VLP-TWA; sin STEL)"}, + {.gas = GasType::CO2, .unit = ConcentrationUnit::PPM, + .twa_8h = 5000.0, .stel = 30000.0, + .regulation_ref = "Decreto 1886 de 2015, Art. 39 (VLP-TWA y STEL)"}, + {.gas = GasType::NO2, .unit = ConcentrationUnit::PPM, + .twa_8h = 0.2, + .regulation_ref = "Decreto 1886 de 2015, Art. 39 (VLP-TWA; sin STEL)"}, + {.gas = GasType::SO2, .unit = ConcentrationUnit::PPM, + .stel = 0.25, + .regulation_ref = "Decreto 1886 de 2015, Art. 39 'Anhidrido Sulfuroso' " + "(solo STEL; el decreto no fija TWA)"}, + {.gas = GasType::H2S, .unit = ConcentrationUnit::PPM, + .twa_8h = 1.0, .stel = 5.0, + .regulation_ref = "Decreto 1886 de 2015, Art. 39 'Acido Sulfhidrico' " + "(VLP-TWA y STEL)"}, + {.gas = GasType::CH4, .unit = ConcentrationUnit::PercentVolume, + .ceiling = 1.0, + .regulation_ref = "Decreto 1886 de 2015, Art. 53: 1.0% en frentes de " + "explotacion o avance y en retornos principales " + "(suspender trabajos y evacuar; valor mas estricto " + "adoptado); 1.5% en retorno de tajos y de frentes de " + "preparacion y desarrollo; sobre 2% solo personal de " + "salvamento"}, + {.gas = GasType::NO, .unit = ConcentrationUnit::PPM, + .twa_8h = 25.0, + .regulation_ref = "Decreto 1886 de 2015, Art. 39 'Oxido Nitrico' " + "(VLP-TWA; sin STEL)"}, + {.gas = GasType::O2, .unit = ConcentrationUnit::PercentVolume, + .ceiling = 23.5, .floor_min = 19.5, + .regulation_ref = "Decreto 1886 de 2015, Art. 38 (min 19.5%, max 23.5% " + "en volumen)"}, + }; + return table; +} + +/** + * @brief Tabla de VLE de México — NOM-010-STPS-2014, Apéndice I, Tabla I.1. + * + * La NOM-023-STPS-2012 no trae tabla de gases: sus numerales 7.4 e) y 7.5 f) + * remiten a la NOM-010-STPS-1999 «o las que las sustituyan», hoy la + * NOM-010-STPS-2014. Los valores son VLE-PPT (8 h) y VLE-CT (corto tiempo), + * para 25 °C y 101,3 kPa; la norma no los corrige por altitud. El SO2 solo + * tiene VLE-CT. El metano es un límite higiénico: la NOM-023 no fija + * porcentajes de acción por explosividad (eso es materia de la NOM-032, de + * minas de carbón, que este preset no cubre). + * + * Oxígeno: la NOM-023 no fija el mínimo en labores. El 19,5 % es criterio de + * ingeniería, y la cita lo declara. + */ +inline const std::vector& gas_limits_mexico() { + static const std::vector table = { + {.gas = GasType::CO, .unit = ConcentrationUnit::PPM, + .twa_8h = 25.0, + .regulation_ref = "NOM-010-STPS-2014, Tabla I.1, fila 552 (VLE-PPT), " + "via NOM-023-STPS-2012, numerales 7.4 e) y 7.5 f)"}, + {.gas = GasType::CO2, .unit = ConcentrationUnit::PPM, + .twa_8h = 5000.0, .stel = 30000.0, + .regulation_ref = "NOM-010-STPS-2014, Tabla I.1, fila 318 (VLE-PPT y " + "VLE-CT), via NOM-023-STPS-2012, numerales 7.4 e) y 7.5 f)"}, + {.gas = GasType::NO2, .unit = ConcentrationUnit::PPM, + .twa_8h = 0.2, + .regulation_ref = "NOM-010-STPS-2014, Tabla I.1, fila 320 (VLE-PPT), " + "via NOM-023-STPS-2012, numerales 7.4 e) y 7.5 f)"}, + {.gas = GasType::SO2, .unit = ConcentrationUnit::PPM, + .stel = 0.25, + .regulation_ref = "NOM-010-STPS-2014, Tabla I.1, fila 317 (solo VLE-CT; " + "la norma no fija VLE-PPT), via NOM-023-STPS-2012, " + "numerales 7.4 e) y 7.5 f)"}, + {.gas = GasType::H2S, .unit = ConcentrationUnit::PPM, + .twa_8h = 1.0, .stel = 5.0, + .regulation_ref = "NOM-010-STPS-2014, Tabla I.1, fila 683 (VLE-PPT y " + "VLE-CT), via NOM-023-STPS-2012, numerales 7.4 e) y 7.5 f)"}, + {.gas = GasType::CH4, .unit = ConcentrationUnit::PPM, + .twa_8h = 1000.0, + .regulation_ref = "NOM-010-STPS-2014, Tabla I.1, fila 500 (VLE-PPT, limite " + "higienico = 0.1%); la NOM-023-STPS-2012 no fija " + "porcentajes de accion por explosividad"}, + {.gas = GasType::NO, .unit = ConcentrationUnit::PPM, + .twa_8h = 25.0, + .regulation_ref = "NOM-010-STPS-2014, Tabla I.1, fila 599 (VLE-PPT), " + "via NOM-023-STPS-2012, numerales 7.4 e) y 7.5 f)"}, + {.gas = GasType::O2, .unit = ConcentrationUnit::PercentVolume, + .floor_min = 19.5, + .regulation_ref = "Criterio de ingenieria (19.5%): la NOM-023-STPS-2012 " + "no fija el minimo de oxigeno en labores (solo 18 a 21% " + "en refugios, numeral 8.14.4 d); la NOM-010-STPS-2014, " + "Tabla I.1 nota (D), da 18% para asfixiantes simples"}, + }; + return table; +} + /** * @brief Tabla LMP completa de la norma indicada. * @throws std::invalid_argument si la norma no tiene tabla implementada. @@ -109,6 +217,10 @@ inline const std::vector& gas_limits(RegulatoryStandard standard) { return gas_limits_peru(); case RegulatoryStandard::DS132_Chile: return gas_limits_chile(); + case RegulatoryStandard::D1886_Colombia: + return gas_limits_colombia(); + case RegulatoryStandard::NOM023_Mexico: + return gas_limits_mexico(); } throw std::invalid_argument( "[VentPy] gas_limits: no LMP table implemented for this standard"); diff --git a/include/ventpy/normativa.hpp b/include/ventpy/normativa.hpp index 506af04..f694efc 100644 --- a/include/ventpy/normativa.hpp +++ b/include/ventpy/normativa.hpp @@ -122,12 +122,87 @@ class RegulatoryConfig { }; } + /** + * @brief Preset oficial colombiano — Decreto 1886 de 2015, Reglamento de + * Seguridad en las Labores Mineras Subterráneas. + * + * Valores validados contra el texto consolidado con el Decreto 944 de + * 2022, que no modificó los artículos con valores de ventilación: + * - 3 m³/min por trabajador hasta 1.500 msnm y 6 m³/min de 1.500 msnm en + * adelante: Art. 54, nums. 1 y 2. La norma tiene un solo escalón, así + * que los tres tramos altos llevan el mismo caudal. Las dos bandas + * incluyen el 1.500 en su texto; aquí rige la semántica común de borde + * ('>' estricto): a 1.500 msnm exactos aplica la banda inferior. + * - 6 m³/min por HP: Art. 54, par. 2, num. 1 (CO en el exosto ≤ 0,12 %). + * El num. 2 admite 4 m³/min por HP si el CO en el exosto es ≤ 0,08 %; + * quien lo acredite construye su propia configuración con ese factor. + * Con CO > 0,12 % el decreto no da valor. + * - Tiempo de dilución 30 min: NO regulado (el Art. 157 fija el reingreso, + * no antes de 15 min y con gases dentro de los VLP) — default ingenieril. + * - Volumen de gases 0,04 m³/kg y factor de fugas 0,15: NO regulados — + * defaults ingenieriles. + */ + [[nodiscard]] static RegulatoryConfig colombia() { + return RegulatoryConfig{ + RegulatoryStandard::D1886_Colombia, + /* min_flow_per_person_m3min */ 3.0, // Art. 54, num. 1 + /* altitude_threshold_1_masl */ 1500.0, // Art. 54, num. 2 + /* flow_per_person_above_t1 */ 6.0, // Art. 54, num. 2 + /* altitude_threshold_2_masl */ 3000.0, // sin efecto (un solo escalón) + /* flow_per_person_above_t2 */ 6.0, + /* altitude_threshold_3_masl */ 4000.0, // sin efecto + /* flow_per_person_above_t3 */ 6.0, + /* diesel_hp_factor_m3min */ 6.0, // Art. 54, par. 2, num. 1 + /* max_dilution_time_min */ 30.0, // no regulado por el decreto + /* default_gas_volume_per_kg_m3 */ 0.04, // no regulado por el decreto + /* default_leakage_factor */ 0.15 // no regulado por el decreto + }; + } + + /** + * @brief Preset oficial mexicano — NOM-023-STPS-2012 (minas subterráneas + * que no son de carbón). + * + * Valores validados contra el texto del DOF (11-oct-2012), vigente sin + * modificaciones: + * - 1,50 m³/min por trabajador: numeral 8.4.4 a) 1). La norma NO escala + * por altitud, por lo que los escalones se neutralizan. + * - 2,13 m³/min por caballo de fuerza diésel: numeral 8.4.4 a) 2). La + * norma no precisa si la potencia es nominal o al freno. + * - Tiempo de dilución 30 min: NO regulado (el numeral 8.6.9 g) fija el + * reconocimiento del área después de 30 min y sin humo) — default + * ingenieril, coincidente en número con ese plazo. + * - Volumen de gases 0,04 m³/kg y factor de fugas 0,15: NO regulados — + * defaults ingenieriles. + * + * La NOM-023 exceptúa las minas de carbón (numeral 2.2 b), que se rigen + * por la NOM-032-STPS-2008: este preset no las cubre. + */ + [[nodiscard]] static RegulatoryConfig mexico() { + return RegulatoryConfig{ + RegulatoryStandard::NOM023_Mexico, + /* min_flow_per_person_m3min */ 1.5, // 8.4.4 a) 1) + /* altitude_threshold_1_masl */ 1500.0, // sin efecto (ver Doxygen) + /* flow_per_person_above_t1 */ 1.5, + /* altitude_threshold_2_masl */ 3000.0, // sin efecto + /* flow_per_person_above_t2 */ 1.5, + /* altitude_threshold_3_masl */ 4000.0, // sin efecto + /* flow_per_person_above_t3 */ 1.5, + /* diesel_hp_factor_m3min */ 2.13, // 8.4.4 a) 2) + /* max_dilution_time_min */ 30.0, // no regulado por la norma + /* default_gas_volume_per_kg_m3 */ 0.04, // no regulado por la norma + /* default_leakage_factor */ 0.15 // no regulado por la norma + }; + } + /// Construye el preset oficial de la norma indicada. /// @throws std::invalid_argument si la norma no tiene preset implementado. [[nodiscard]] static RegulatoryConfig for_standard(RegulatoryStandard standard) { switch (standard) { - case RegulatoryStandard::DS024_Peru: return peru(); - case RegulatoryStandard::DS132_Chile: return chile(); + case RegulatoryStandard::DS024_Peru: return peru(); + case RegulatoryStandard::DS132_Chile: return chile(); + case RegulatoryStandard::D1886_Colombia: return colombia(); + case RegulatoryStandard::NOM023_Mexico: return mexico(); } throw std::invalid_argument( "[VentPy] for_standard: unsupported regulatory standard"); @@ -177,6 +252,10 @@ class RegulatoryConfig { return "DS 024-2016-EM / DS 023-2017-EM (Peru)"; case RegulatoryStandard::DS132_Chile: return "DS 132 Reglamento de Seguridad Minera (Chile)"; + case RegulatoryStandard::D1886_Colombia: + return "Decreto 1886 de 2015 (Colombia)"; + case RegulatoryStandard::NOM023_Mexico: + return "NOM-023-STPS-2012 (Mexico)"; default: return "Unknown Standard"; } @@ -262,136 +341,241 @@ enum class RegulatoryTopic { * atribuye el valor a criterio de ingeniería. Nunca se traslada un artículo * peruano a un cálculo chileno ni se cita un artículo sin verificar. * + * La misma regla rige para Colombia (Decreto 1886 de 2015, consolidado con el + * Decreto 944 de 2022) y México (NOM-023-STPS-2012, que remite a la + * NOM-010-STPS-2014 y a la NOM-015-STPS-2001): cada cita nombra el artículo o + * numeral leído en el texto vigente, o declara que la norma no fija el valor. + * * @throws std::invalid_argument si la norma o el concepto no están soportados. */ [[nodiscard]] inline std::string regulation_reference( RegulatoryTopic topic, RegulatoryStandard standard ) { - const bool peru = (standard == RegulatoryStandard::DS024_Peru); - if (!peru && standard != RegulatoryStandard::DS132_Chile) { + // Una cita por norma, siempre en este orden: Perú, Chile, Colombia, México. + const auto cita = [standard](const char* peru, const char* chile, + const char* colombia, + const char* mexico) -> std::string { + switch (standard) { + case RegulatoryStandard::DS024_Peru: return peru; + case RegulatoryStandard::DS132_Chile: return chile; + case RegulatoryStandard::D1886_Colombia: return colombia; + case RegulatoryStandard::NOM023_Mexico: return mexico; + } throw std::invalid_argument( "[VentPy] regulation_reference: norma sin referencias definidas"); - } + }; switch (topic) { case RegulatoryTopic::PersonnelFlow: - return peru - ? "DS 024-2016-EM, Art. 236" - : "DS 132, Art. 138"; - - // El DS 132 fija 3,0 m3/min por persona sin escalon por altitud - // (Art. 138), de modo que no hay clausula de escala que citar. + return cita( + "DS 024-2016-EM, Art. 236", + "DS 132, Art. 138", + "Decreto 1886 de 2015, Art. 54 nums. 1 y 2 (3 m3/min por trabajador hasta " + "1.500 msnm; 6 m3/min de 1.500 msnm en adelante)", + "NOM-023-STPS-2012, numeral 8.4.4 a) 1)"); + + // Solo Peru tiene una clausula de escala aparte (Art. 247). Chile y + // Mexico no escalan por altitud; el unico escalon colombiano ya va + // en la cita del caudal por persona (Art. 54 nums. 1 y 2). case RegulatoryTopic::PersonnelAltitudeScale: - return peru ? "Art. 247 escala altitud" : ""; + return cita("Art. 247 escala altitud", "", "", ""); case RegulatoryTopic::DieselHpFactor: - return peru - ? "DS 024-2016-EM, Art. 246" - : "DS 132, Art. 132"; + return cita( + "DS 024-2016-EM, Art. 246", + "DS 132, Art. 132", + "Decreto 1886 de 2015, Art. 54 par. 2 num. 1 (CO en el exosto <= 0.12%; " + "el num. 2 admite 4 m3/min por HP si CO <= 0.08%)", + "NOM-023-STPS-2012, numeral 8.4.4 a) 2)"); case RegulatoryTopic::BlastingDilution: - return peru - ? "DS 024-2016-EM, Art. 243-244" - : "DS 132, Arts. 156, 571 y 585 (reingreso tras tronadura); " + return cita( + "DS 024-2016-EM, Art. 243-244", + "DS 132, Arts. 156, 571 y 585 (reingreso tras tronadura); " "el DS 132 no fija tiempo de dilucion ni volumen de gases " - "por kg de explosivo - criterio de ingenieria"; + "por kg de explosivo - criterio de ingenieria", + "Decreto 1886 de 2015, Art. 157 (reingreso no antes de 15 min y con " + "gases dentro de los VLP); el decreto no fija tiempo de dilucion " + "ni volumen de gases por kg de explosivo - criterio de ingenieria", + "NOM-023-STPS-2012, numeral 8.6.9 g) (reconocimiento despues de 30 " + "min de la disparada y sin humo); la norma no fija tiempo de " + "dilucion ni volumen de gases por kg de explosivo - criterio de " + "ingenieria"); case RegulatoryTopic::BlastingDilutionTimeLimit: - return peru - ? "max normativo, Art. 243" - : "max configurado, criterio de ingenieria: el DS 132 no fija " - "tiempo de dilucion"; + return cita( + "max normativo, Art. 243", + "max configurado, criterio de ingenieria: el DS 132 no fija " + "tiempo de dilucion", + "max configurado, criterio de ingenieria: el Decreto 1886 de " + "2015 no fija tiempo de dilucion", + "max configurado, criterio de ingenieria: la " + "NOM-023-STPS-2012 no fija tiempo de dilucion"); case RegulatoryTopic::DustRespirableLimit: - return peru - ? "DS 024-2016-EM, Art. 111 (LEO polvo respirable 3 mg/m3, " - "jornada 8 h; paralizacion si se supera)" - : "DS 132: sin limite de polvo respirable verificado para " + return cita( + "DS 024-2016-EM, Art. 111 (LEO polvo respirable 3 mg/m3, " + "jornada 8 h; paralizacion si se supera)", + "DS 132: sin limite de polvo respirable verificado para " "Chile en esta libreria; la concentracion objetivo es " - "criterio de ingenieria del usuario"; + "criterio de ingenieria del usuario", + "Decreto 1886 de 2015, Art. 74: remite a los VLP de la ACGIH y no fija " + "un limite propio de polvo respirable; la concentracion objetivo " + "es criterio de ingenieria del usuario", + "NOM-023-STPS-2012, numerales 7.4 e) y 7.5 f): remite a la " + "NOM-010-STPS-2014 (Tabla I.1, fila 609: particulas no " + "especificadas, 3 mg/m3 fraccion respirable); la concentracion " + "objetivo es criterio de ingenieria del usuario"); case RegulatoryTopic::DustLimitBasis: - return peru - ? "LEO del Art. 111 (DS 024-2016-EM)" - : "referencia ingenieril (el DS 132 no fija un limite de polvo " - "respirable verificado en esta libreria)"; + return cita( + "LEO del Art. 111 (DS 024-2016-EM)", + "referencia ingenieril (el DS 132 no fija un limite de polvo " + "respirable verificado en esta libreria)", + "referencia ingenieril (el Decreto 1886 de 2015, Art. 74, " + "remite a los VLP de la ACGIH y no fija un limite propio)", + "VLE-PPT de particulas no especificadas, fraccion respirable " + "(NOM-010-STPS-2014, Tabla I.1, fila 609)"); case RegulatoryTopic::DustSilicaReferral: - return peru - ? "el Anexo 15 / DS 015-2005-SA" - : "la normativa de higiene ocupacional chilena aplicable (no " - "identificada en esta libreria)"; + return cita( + "el Anexo 15 / DS 015-2005-SA", + "la normativa de higiene ocupacional chilena aplicable (no " + "identificada en esta libreria)", + "los VLP de la ACGIH (Decreto 1886 de 2015, Art. 74)", + "la NOM-010-STPS-2014, Tabla I.1, fila 668 (silice cristalina " + "respirable: 0.025 mg/m3)"); case RegulatoryTopic::ThermalBalance: - return peru - ? "DS 024-2016-EM: balance termico sensible (criterio " + return cita( + "DS 024-2016-EM: balance termico sensible (criterio " "ingenieril; carga = equipos + oxidacion, autocompresion " - "reduce el DT disponible)" - : "DS 132: balance termico sensible (criterio ingenieril; " + "reduce el DT disponible)", + "DS 132: balance termico sensible (criterio ingenieril; " "carga = equipos + oxidacion, autocompresion reduce el DT " "disponible); el DS 132 no fija un criterio de ventilacion " - "termica identificado en esta libreria"; + "termica identificado en esta libreria", + "Decreto 1886 de 2015: balance termico sensible (criterio " + "ingenieril; carga = equipos + oxidacion, autocompresion reduce " + "el DT disponible); el decreto regula el calor por temperatura " + "efectiva y tiempos de permanencia (Arts. 217 y 218), no por " + "caudal", + "NOM-023-STPS-2012: balance termico sensible (criterio " + "ingenieril; carga = equipos + oxidacion, autocompresion reduce " + "el DT disponible); la norma remite a la NOM-015-STPS-2001 " + "(indice TGBH) y no fija un criterio de ventilacion termica"); case RegulatoryTopic::ThermalWithVelocityFloor: - return peru - ? "DS 024-2016-EM, Art. 252.d (velocidad minima 30 m/min con " + return cita( + "DS 024-2016-EM, Art. 252.d (velocidad minima 30 m/min con " "temperatura seca 24-29 C) combinado con balance termico " - "sensible (criterio ingenieril)" - : "DS 132: piso de velocidad de 30 m/min con temperatura seca " + "sensible (criterio ingenieril)", + "DS 132: piso de velocidad de 30 m/min con temperatura seca " "24-29 C aplicado como criterio de ingenieria (el DS 132 no " "fija un criterio de ventilacion termica identificado en " - "esta libreria), combinado con balance termico sensible"; + "esta libreria), combinado con balance termico sensible", + "Decreto 1886 de 2015: piso de velocidad de 30 m/min con " + "temperatura seca 24-29 C aplicado como criterio de ingenieria " + "(el decreto regula el calor por temperatura efectiva, Arts. 217 " + "y 218, y no fija un piso de velocidad termico), combinado con " + "balance termico sensible", + "NOM-023-STPS-2012: piso de velocidad de 30 m/min con " + "temperatura seca 24-29 C aplicado como criterio de ingenieria " + "(la norma remite a la NOM-015-STPS-2001, indice TGBH, y no fija " + "un piso de velocidad termico), combinado con balance termico " + "sensible"); case RegulatoryTopic::ThermalInfeasibleWithFloor: - return peru - ? "DS 024-2016-EM, Art. 252.d (velocidad minima 30 m/min " + return cita( + "DS 024-2016-EM, Art. 252.d (velocidad minima 30 m/min " "con temperatura seca 24-29 C) — balance termico " "sensible INFACTIBLE por autocompresion, se requiere " "refrigeracion mecanica adicional para alcanzar el " - "objetivo termico" - : "DS 132: piso de velocidad de 30 m/min con temperatura seca " + "objetivo termico", + "DS 132: piso de velocidad de 30 m/min con temperatura seca " "24-29 C aplicado como criterio de ingenieria (el DS 132 no " "fija un criterio de ventilacion termica identificado en " "esta libreria) - balance termico sensible INFACTIBLE por " "autocompresion, se requiere refrigeracion mecanica " - "adicional para alcanzar el objetivo termico"; + "adicional para alcanzar el objetivo termico", + "Decreto 1886 de 2015: piso de velocidad de 30 m/min con " + "temperatura seca 24-29 C aplicado como criterio de ingenieria " + "(el decreto regula el calor por temperatura efectiva, Arts. 217 " + "y 218, y no fija un piso de velocidad termico) - balance termico " + "sensible INFACTIBLE por autocompresion, se requiere " + "refrigeracion mecanica adicional para alcanzar el objetivo " + "termico", + "NOM-023-STPS-2012: piso de velocidad de 30 m/min con " + "temperatura seca 24-29 C aplicado como criterio de ingenieria " + "(la norma remite a la NOM-015-STPS-2001, indice TGBH, y no fija " + "un piso de velocidad termico) - balance termico sensible " + "INFACTIBLE por autocompresion, se requiere refrigeracion " + "mecanica adicional para alcanzar el objetivo termico"); case RegulatoryTopic::ThermalInfeasibleNoFloor: - return peru - ? "DS 024-2016-EM: objetivo de diseño no alcanzable por " - "ventilacion (autocompresion agota el ΔT disponible)" - : "DS 132: objetivo de diseño no alcanzable por " - "ventilacion (autocompresion agota el ΔT disponible)"; + return cita( + "DS 024-2016-EM: objetivo de diseño no alcanzable por " + "ventilacion (autocompresion agota el ΔT disponible)", + "DS 132: objetivo de diseño no alcanzable por " + "ventilacion (autocompresion agota el ΔT disponible)", + "Decreto 1886 de 2015: objetivo de diseño no alcanzable por " + "ventilacion (autocompresion agota el ΔT disponible)", + "NOM-023-STPS-2012: objetivo de diseño no alcanzable por " + "ventilacion (autocompresion agota el ΔT disponible)"); case RegulatoryTopic::ThermalStressReferral: - return peru - ? "Art. 104 + Anexo 13" - : "referencia chilena no identificada en esta libreria"; + return cita( + "Art. 104 + Anexo 13", + "referencia chilena no identificada en esta libreria", + "Decreto 1886 de 2015, Arts. 217 y 218: temperatura efectiva y " + "tiempos de permanencia", + "NOM-015-STPS-2001, indice de temperatura de globo bulbo humedo"); case RegulatoryTopic::AirVelocityLimits: - return peru - ? "DS 024-2016-EM, Art. 248" - : "limites configurados en CoverageParams; el DS 132 no fija " + return cita( + "DS 024-2016-EM, Art. 248", + "limites configurados en CoverageParams; el DS 132 no fija " "limites de velocidad del aire identificados en esta " - "libreria"; + "libreria", + "limites configurados en CoverageParams; el Decreto 1886 de " + "2015, Art. 56, fija maxima de 6 m/s (360 m/min) en vias con " + "transito de personal y minimas solo para minas de carbon", + "limites configurados en CoverageParams; la " + "NOM-023-STPS-2012, numeral 8.4.4 b), solo fija minima de 15.24 " + "m/min donde opere maquinaria diesel y no fija maxima"); case RegulatoryTopic::CoverageZone: - return peru - ? "DS 024-2016-EM (mod. DS 023-2017-EM), Art. 252 lit. g - " - "cobertura de la demanda de aire por labor" - : "DS 132: cobertura de la demanda de aire por labor - " + return cita( + "DS 024-2016-EM (mod. DS 023-2017-EM), Art. 252 lit. g - " + "cobertura de la demanda de aire por labor", + "DS 132: cobertura de la demanda de aire por labor - " "criterio de ingenieria; el DS 132 no fija un regimen de " - "evaluacion de cobertura identificado en esta libreria"; + "evaluacion de cobertura identificado en esta libreria", + "Decreto 1886 de 2015: cobertura de la demanda de aire por labor - " + "criterio de ingenieria; el decreto exige aforo semanal de " + "caudales (Art. 57) y no fija un regimen de evaluacion de " + "cobertura", + "NOM-023-STPS-2012: cobertura de la demanda de aire por labor - " + "criterio de ingenieria; la norma exige un plan de ventilacion " + "con los volumenes de aire requeridos (numerales 8.4.1 y 8.4.2) " + "y no fija un regimen de evaluacion de cobertura"); case RegulatoryTopic::CoverageMine: - return peru - ? "DS 024-2016-EM (mod. DS 023-2017-EM), Art. 252: evaluacion " + return cita( + "DS 024-2016-EM (mod. DS 023-2017-EM), Art. 252: evaluacion " "integral semestral; lit. f (cobertura de mina) y lit. g " - "(cobertura por labor)" - : "DS 132: balance de cobertura de la mina - criterio de " + "(cobertura por labor)", + "DS 132: balance de cobertura de la mina - criterio de " "ingenieria; el DS 132 no fija una evaluacion integral de " - "cobertura identificada en esta libreria"; + "cobertura identificada en esta libreria", + "Decreto 1886 de 2015: balance de cobertura de la mina - criterio " + "de ingenieria; el decreto exige aforo semanal de caudales (Art. " + "57) y no fija una evaluacion integral de cobertura", + "NOM-023-STPS-2012: balance de cobertura de la mina - criterio de " + "ingenieria; la norma exige un plan de ventilacion con los " + "volumenes de aire requeridos (numerales 8.4.1 y 8.4.2) y no " + "fija una evaluacion integral de cobertura"); } throw std::invalid_argument( diff --git a/include/ventpy/types.hpp b/include/ventpy/types.hpp index d2e1215..6db62fc 100644 --- a/include/ventpy/types.hpp +++ b/include/ventpy/types.hpp @@ -41,6 +41,8 @@ enum class ZoneType { enum class RegulatoryStandard { DS024_Peru, ///< DS 024-2016-EM + DS 023-2017-EM (Perú) DS132_Chile, ///< DS 132 Reglamento de Seguridad Minera (Chile) + D1886_Colombia, ///< Decreto 1886 de 2015, mod. Decreto 944 de 2022 (Colombia) + NOM023_Mexico, ///< NOM-023-STPS-2012; gases por NOM-010-STPS-2014 (México) }; /** @@ -206,7 +208,7 @@ struct PersonnelParams { struct BlastingParams { double explosive_kg = 0.0; ///< Cantidad de explosivo por voladura [kg] ExplosiveType explosive_type = ExplosiveType::ANFO; - double gas_volume_per_kg = 0.0; ///< Vol. gases [m³/kg], 0 = usar tipo + double gas_volume_per_kg = 0.0; ///< Vol. total de gases reportado [m³/kg], 0 = estimar; no cambia el caudal double dilution_time_min = 0.0; ///< Tiempo máximo de dilución [min] double face_area_m2 = 0.0; ///< Sección del frente [m²] double face_length_m = 0.0; ///< Longitud hasta el frente [m] @@ -351,7 +353,7 @@ struct FrictionFactorEntry { /// Entrada informativa de la tabla de choque. struct ShockFactorEntry { SingularityType type; - double x; ///< 0 si es fórmula/manual + std::optional x; ///< Sin valor si es por fórmula o manual std::string biblio_ref; std::string note; }; diff --git a/python/ventpy/cli.py b/python/ventpy/cli.py index 1b280e7..779ea26 100644 --- a/python/ventpy/cli.py +++ b/python/ventpy/cli.py @@ -14,6 +14,8 @@ _NORMAS = { "peru": ventpy.RegulatoryStandard.DS024_Peru, "chile": ventpy.RegulatoryStandard.DS132_Chile, + "colombia": ventpy.RegulatoryStandard.D1886_Colombia, + "mexico": ventpy.RegulatoryStandard.NOM023_Mexico, } _ALLOWED_ATMOSPHERIC = frozenset({ @@ -103,7 +105,7 @@ # Helpers de presentación (regla 6: sin decisiones de física aquí) # ============================================================================ def _norma(nombre): - """"peru" | "chile" -> RegulatoryConfig (preset oficial de la norma).""" + """Nombre de la norma (clave de _NORMAS) -> RegulatoryConfig (preset oficial).""" try: standard = _NORMAS[nombre] except KeyError: @@ -765,7 +767,8 @@ def build_parser(): parser = argparse.ArgumentParser( prog="ventpy", description="VentPy - calculos de ventilacion de minas subterraneas " - "(DS 024-2016-EM Peru / DS 132 Chile).", + "(DS 024-2016-EM Peru / DS 132 Chile / Decreto 1886 " + "Colombia / NOM-023-STPS-2012 Mexico).", epilog=_EXIT_CODES_EPILOG, ) subparsers = parser.add_subparsers(dest="command", required=True) diff --git a/tests/cpp/test_atkinson.cpp b/tests/cpp/test_atkinson.cpp index 225ebf3..bad0258 100644 --- a/tests/cpp/test_atkinson.cpp +++ b/tests/cpp/test_atkinson.cpp @@ -97,13 +97,14 @@ TEST(TablaChoque, TablaInformativa7EntradasTodasConCita) { int exactos = 0; for (const auto& e : t) { if (e.type == SingularityType::Exit) { - EXPECT_DOUBLE_EQ(e.x, 1.0); + EXPECT_DOUBLE_EQ(e.x.value(), 1.0); ++exactos; } else if (e.type == SingularityType::Entrance) { - EXPECT_DOUBLE_EQ(e.x, 0.5); + EXPECT_DOUBLE_EQ(e.x.value(), 0.5); ++exactos; } else { - EXPECT_DOUBLE_EQ(e.x, 0.0); + // Por fórmula o manual: sin valor, para que no se lea como X = 0. + EXPECT_FALSE(e.x.has_value()); EXPECT_FALSE(e.note.empty()); } } diff --git a/tests/cpp/test_citas_normativas.cpp b/tests/cpp/test_citas_normativas.cpp index 62232aa..49b3c4a 100644 --- a/tests/cpp/test_citas_normativas.cpp +++ b/tests/cpp/test_citas_normativas.cpp @@ -458,3 +458,176 @@ TEST(CitasNormativas, CadaCitaChilenaNombraSuMarco) { EXPECT_TRUE(contiene(d.dust->regulation_ref, "DS 132")); EXPECT_TRUE(contiene(d.thermal->regulation_ref, "DS 132")); } + +// --------------------------------------------------------------------------- +// Colombia (Decreto 1886 de 2015) y México (NOM-023-STPS-2012) +// --------------------------------------------------------------------------- + +namespace { + +struct MarcoNormativo { + RegulatoryConfig config; + std::string marca; ///< Cómo nombra la cita a su norma + std::vector ajenas; ///< Marcas de las otras tres normas +}; + +[[nodiscard]] std::vector marcos_nuevos() { + return { + {RegulatoryConfig::colombia(), "Decreto 1886", + {"DS 024", "DS 023", "DS 015-2005-SA", "Anexo 15", "Anexo 13", + "DS 132", "DS 594", "NOM-"}}, + {RegulatoryConfig::mexico(), "NOM-", + {"DS 024", "DS 023", "DS 015-2005-SA", "Anexo 15", "Anexo 13", + "DS 132", "DS 594", "Decreto 1886"}}, + }; +} + +[[nodiscard]] std::string marca_ajena(const MarcoNormativo& m, + const std::string& texto) { + for (const std::string& a : m.ajenas) { + if (texto.find(a) != std::string::npos) return a; + } + return ""; +} + +} // namespace + +TEST(CitasNormativas, HelperColombiaYMexico) { + EXPECT_TRUE(contiene( + regulation_reference(RegulatoryTopic::PersonnelFlow, + RegulatoryStandard::D1886_Colombia), + "Decreto 1886 de 2015, Art. 54 nums. 1 y 2")); + EXPECT_TRUE(contiene( + regulation_reference(RegulatoryTopic::DieselHpFactor, + RegulatoryStandard::D1886_Colombia), + "Art. 54 par. 2 num. 1")); + EXPECT_EQ(regulation_reference(RegulatoryTopic::PersonnelFlow, + RegulatoryStandard::NOM023_Mexico), + "NOM-023-STPS-2012, numeral 8.4.4 a) 1)"); + EXPECT_EQ(regulation_reference(RegulatoryTopic::DieselHpFactor, + RegulatoryConfig::mexico()), + "NOM-023-STPS-2012, numeral 8.4.4 a) 2)"); +} + +// Ninguna de las dos tiene una cláusula de escala aparte: México no escala y +// el único escalón colombiano ya va en la cita del caudal por persona. Así +// la cita no arrastra los umbrales de relleno (3.000 y 4.000 msnm). +TEST(CitasNormativas, ColombiaYMexicoSinClausulaDeEscalaAparte) { + PersonnelParams per; + per.num_workers = 15; + const auto atm = [] { AtmosphericParams a; a.altitude_masl = 4200.0; return a; }(); + + for (const MarcoNormativo& m : marcos_nuevos()) { + EXPECT_EQ(regulation_reference(RegulatoryTopic::PersonnelAltitudeScale, + m.config), ""); + const auto r = PersonnelFlowCalculator::calculate_full( + per, atm, ZoneType::DevelopmentFace, 12.0, m.config); + EXPECT_TRUE(contiene(r.regulation_ref, m.marca)); + EXPECT_FALSE(contiene(r.regulation_ref, "(>")); + } +} + +TEST(CitasNormativas, NingunaCitaNuevaMencionaOtraNorma) { + const RegulatoryTopic temas[] = { + RegulatoryTopic::PersonnelFlow, + RegulatoryTopic::PersonnelAltitudeScale, + RegulatoryTopic::DieselHpFactor, + RegulatoryTopic::BlastingDilution, + RegulatoryTopic::BlastingDilutionTimeLimit, + RegulatoryTopic::DustRespirableLimit, + RegulatoryTopic::DustLimitBasis, + RegulatoryTopic::DustSilicaReferral, + RegulatoryTopic::ThermalBalance, + RegulatoryTopic::ThermalWithVelocityFloor, + RegulatoryTopic::ThermalInfeasibleWithFloor, + RegulatoryTopic::ThermalInfeasibleNoFloor, + RegulatoryTopic::ThermalStressReferral, + RegulatoryTopic::AirVelocityLimits, + RegulatoryTopic::CoverageZone, + RegulatoryTopic::CoverageMine + }; + for (const MarcoNormativo& m : marcos_nuevos()) { + for (RegulatoryTopic t : temas) { + const std::string ref = regulation_reference(t, m.config); + EXPECT_EQ(marca_ajena(m, ref), "") << ref; + } + } +} + +// Donde la norma no regula, la cita lo declara y no llama "normativo" al tope. +TEST(CitasNormativas, VoladuraNuevaCitaReingresoYDeclaraVacio) { + const auto col = BlastingFlowCalculator::calculate( + params_voladura(), RegulatoryConfig::colombia()); + const auto mex = BlastingFlowCalculator::calculate( + params_voladura(), RegulatoryConfig::mexico()); + + EXPECT_TRUE(contiene(col.regulation_ref, "Decreto 1886 de 2015, Art. 157")); + EXPECT_TRUE(contiene(mex.regulation_ref, "NOM-023-STPS-2012, numeral 8.6.9 g)")); + for (const auto* r : {&col, &mex}) { + EXPECT_TRUE(contiene(r->regulation_ref, "no fija tiempo de dilucion")); + EXPECT_TRUE(contiene(r->regulation_ref, "max configurado")); + EXPECT_FALSE(contiene(r->regulation_ref, "max normativo")); + } +} + +// Los caudales de polvo y térmico no dependen de la norma: solo cambia la cita. +TEST(CitasNormativas, PolvoYTermicoNuevosSoloCambianLaCita) { + const auto atm = atmosfera_2500_16c(); + const auto polvo_pe = DustFlowCalculator::calculate( + params_polvo(), RegulatoryConfig::peru()); + const auto termico_pe = ThermalFlowCalculator::calculate( + params_termicos(), atm, RegulatoryConfig::peru()); + + for (const MarcoNormativo& m : marcos_nuevos()) { + const auto polvo = DustFlowCalculator::calculate(params_polvo(), m.config); + const auto termico = + ThermalFlowCalculator::calculate(params_termicos(), atm, m.config); + + EXPECT_DOUBLE_EQ(polvo.q_dust, polvo_pe.q_dust); + EXPECT_DOUBLE_EQ(termico.q_thermal, termico_pe.q_thermal); + EXPECT_TRUE(contiene(polvo.regulation_ref, m.marca)); + EXPECT_TRUE(contiene(termico.regulation_ref, "criterio de ingenieria")); + EXPECT_FALSE(contiene(termico.regulation_ref, "252")); + EXPECT_FALSE(contiene(polvo.regulation_ref, "Art. 111")); + } +} + +TEST(CitasNormativas, BarridoDelGovernorColombiaYMexico) { + for (const MarcoNormativo& m : marcos_nuevos()) { + const VentilationDemandResult d = + VentilationGovernor{m.config}.calculateTotalDemand(entrada_completa()); + + ASSERT_TRUE(d.personnel.has_value()); + ASSERT_TRUE(d.diesel.has_value()); + ASSERT_TRUE(d.blasting.has_value()); + ASSERT_TRUE(d.dust.has_value()); + ASSERT_TRUE(d.thermal.has_value()); + + for (const std::string& texto : textos_auditables(d)) { + EXPECT_EQ(marca_ajena(m, texto), "") << texto; + } + EXPECT_TRUE(contiene(d.personnel->regulation_ref, m.marca)); + EXPECT_TRUE(contiene(d.diesel->regulation_ref, m.marca)); + EXPECT_TRUE(contiene(d.blasting->regulation_ref, m.marca)); + EXPECT_TRUE(contiene(d.dust->regulation_ref, m.marca)); + EXPECT_TRUE(contiene(d.thermal->regulation_ref, m.marca)); + } +} + +TEST(CitasNormativas, CoberturaColombiaYMexico) { + ZoneMeasurement medida; + medida.zone_name = "Frente N-02"; + medida.stations.push_back({"E-1", 12.0, 0.30}); // 18 m/min: fuera de rango + + for (const MarcoNormativo& m : marcos_nuevos()) { + const auto r = CoverageCalculator::compare_zone( + 100.0, medida, CoverageParams{}, m.config); + + EXPECT_TRUE(contiene(r.regulation_ref, m.marca)); + EXPECT_TRUE(contiene(r.regulation_ref, "criterio de ingenieria")); + EXPECT_EQ(marca_ajena(m, r.regulation_ref), ""); + ASSERT_FALSE(r.stations[0].velocity_ok); + EXPECT_FALSE(contiene(r.stations[0].warning, "248")); + EXPECT_EQ(marca_ajena(m, r.stations[0].warning), ""); + } +} diff --git a/tests/cpp/test_limites_gases.cpp b/tests/cpp/test_limites_gases.cpp index 996371c..b6dee0a 100644 --- a/tests/cpp/test_limites_gases.cpp +++ b/tests/cpp/test_limites_gases.cpp @@ -149,3 +149,122 @@ TEST(LimitesGasesChile, TablaCompleta_7Gases_TodasConRef) { EXPECT_TRUE(g.twa_8h || g.stel || g.ceiling || g.floor_min); } } + +// ============================================================================ +// Colombia — Decreto 1886 de 2015, Arts. 38 (O2), 39 (tabla de VLP) y 53 (CH4) +// ============================================================================ + +TEST(LimitesGasesColombia, TablaDelArt39) { + const auto col = RegulatoryStandard::D1886_Colombia; + + const GasLimit& co = lmp_for(col, GasType::CO); + EXPECT_DOUBLE_EQ(co.twa_8h.value(), 25.0); + EXPECT_FALSE(co.stel.has_value()); + + const GasLimit& co2 = lmp_for(col, GasType::CO2); + EXPECT_DOUBLE_EQ(co2.twa_8h.value(), 5000.0); + EXPECT_DOUBLE_EQ(co2.stel.value(), 30000.0); + + const GasLimit& no2 = lmp_for(col, GasType::NO2); + EXPECT_DOUBLE_EQ(no2.twa_8h.value(), 0.2); + EXPECT_FALSE(no2.stel.has_value()); + + const GasLimit& no = lmp_for(col, GasType::NO); + EXPECT_DOUBLE_EQ(no.twa_8h.value(), 25.0); + + const GasLimit& h2s = lmp_for(col, GasType::H2S); + EXPECT_DOUBLE_EQ(h2s.twa_8h.value(), 1.0); + EXPECT_DOUBLE_EQ(h2s.stel.value(), 5.0); + + for (GasType g : {GasType::CO, GasType::CO2, GasType::NO2, GasType::NO, + GasType::H2S, GasType::SO2}) { + EXPECT_NE(lmp_for(col, g).regulation_ref.find("Art. 39"), + std::string::npos); + EXPECT_EQ(lmp_for(col, g).unit, ConcentrationUnit::PPM); + } +} + +// El decreto solo fija STEL para el SO2: el TWA va vacío, nunca en cero. +TEST(LimitesGasesColombia, SO2_SoloStel) { + const GasLimit& g = lmp_for(RegulatoryStandard::D1886_Colombia, GasType::SO2); + EXPECT_FALSE(g.twa_8h.has_value()); + EXPECT_DOUBLE_EQ(g.stel.value(), 0.25); +} + +TEST(LimitesGasesColombia, CH4_Art53_1PorCientoMasEstricto) { + const GasLimit& g = lmp_for(RegulatoryStandard::D1886_Colombia, GasType::CH4); + EXPECT_EQ(g.unit, ConcentrationUnit::PercentVolume); + EXPECT_DOUBLE_EQ(g.ceiling.value(), 1.0); + EXPECT_NE(g.regulation_ref.find("Art. 53"), std::string::npos); + EXPECT_NE(g.regulation_ref.find("1.5%"), std::string::npos); +} + +TEST(LimitesGasesColombia, O2_Art38_Min19p5Max23p5) { + const GasLimit& g = lmp_for(RegulatoryStandard::D1886_Colombia, GasType::O2); + EXPECT_DOUBLE_EQ(g.floor_min.value(), 19.5); + EXPECT_DOUBLE_EQ(g.ceiling.value(), 23.5); + EXPECT_NE(g.regulation_ref.find("Art. 38"), std::string::npos); +} + +TEST(LimitesGasesColombia, TablaCompleta_8Gases_TodasConRef) { + const auto& table = gas_limits(RegulatoryStandard::D1886_Colombia); + EXPECT_EQ(table.size(), 8u); + for (const GasLimit& g : table) { + EXPECT_NE(g.regulation_ref.find("Decreto 1886 de 2015"), std::string::npos); + EXPECT_TRUE(g.twa_8h || g.stel || g.ceiling || g.floor_min); + } +} + +// ============================================================================ +// México — NOM-010-STPS-2014, Tabla I.1 (vía remisión de la NOM-023-STPS-2012) +// ============================================================================ + +TEST(LimitesGasesMexico, TablaI1PorFila) { + const auto mex = RegulatoryStandard::NOM023_Mexico; + struct Fila { GasType gas; double ppt; const char* fila; }; + const Fila filas[] = { + {GasType::CO, 25.0, "fila 552"}, + {GasType::CO2, 5000.0, "fila 318"}, + {GasType::NO2, 0.2, "fila 320"}, + {GasType::NO, 25.0, "fila 599"}, + {GasType::H2S, 1.0, "fila 683"}, + {GasType::CH4, 1000.0, "fila 500"}, + }; + for (const Fila& f : filas) { + const GasLimit& g = lmp_for(mex, f.gas); + EXPECT_DOUBLE_EQ(g.twa_8h.value(), f.ppt) << f.fila; + EXPECT_EQ(g.unit, ConcentrationUnit::PPM) << f.fila; + EXPECT_NE(g.regulation_ref.find("NOM-010-STPS-2014"), std::string::npos); + EXPECT_NE(g.regulation_ref.find(f.fila), std::string::npos); + } + EXPECT_DOUBLE_EQ(lmp_for(mex, GasType::CO2).stel.value(), 30000.0); + EXPECT_DOUBLE_EQ(lmp_for(mex, GasType::H2S).stel.value(), 5.0); + EXPECT_FALSE(lmp_for(mex, GasType::CO).stel.has_value()); + EXPECT_FALSE(lmp_for(mex, GasType::NO2).stel.has_value()); +} + +TEST(LimitesGasesMexico, SO2_SoloCortoTiempo) { + const GasLimit& g = lmp_for(RegulatoryStandard::NOM023_Mexico, GasType::SO2); + EXPECT_FALSE(g.twa_8h.has_value()); + EXPECT_DOUBLE_EQ(g.stel.value(), 0.25); + EXPECT_NE(g.regulation_ref.find("fila 317"), std::string::npos); +} + +// La NOM-023 no fija el mínimo de oxígeno en labores: el 19,5 % es criterio +// de ingeniería y la cita tiene que decirlo, no atribuirlo a un numeral. +TEST(LimitesGasesMexico, O2_CriterioDeIngenieriaDeclarado) { + const GasLimit& g = lmp_for(RegulatoryStandard::NOM023_Mexico, GasType::O2); + EXPECT_DOUBLE_EQ(g.floor_min.value(), 19.5); + EXPECT_FALSE(g.ceiling.has_value()); + EXPECT_NE(g.regulation_ref.find("Criterio de ingenieria"), std::string::npos); + EXPECT_NE(g.regulation_ref.find("no fija"), std::string::npos); +} + +TEST(LimitesGasesMexico, TablaCompleta_8Gases_TodasConRef) { + const auto& table = gas_limits(RegulatoryStandard::NOM023_Mexico); + EXPECT_EQ(table.size(), 8u); + for (const GasLimit& g : table) { + EXPECT_FALSE(g.regulation_ref.empty()); + EXPECT_TRUE(g.twa_8h || g.stel || g.ceiling || g.floor_min); + } +} diff --git a/tests/cpp/test_normativa.cpp b/tests/cpp/test_normativa.cpp index 8ab9080..87751ac 100644 --- a/tests/cpp/test_normativa.cpp +++ b/tests/cpp/test_normativa.cpp @@ -159,3 +159,107 @@ TEST(RegulatoryPresets, Chile_EndToEnd_Governor) { EXPECT_NEAR(result.q_total_m3min, 981.0, 3.0); EXPECT_EQ(result.standard, RegulatoryStandard::DS132_Chile); } + +// ============================================================================ +// colombia(): valores del Decreto 1886 de 2015 (consolidado con el 944 de 2022) +// ============================================================================ + +TEST(RegulatoryPresets, Colombia_ValuesFromDecreto1886) { + const RegulatoryConfig col = RegulatoryConfig::colombia(); + + EXPECT_EQ(col.standard(), RegulatoryStandard::D1886_Colombia); + // Art. 54, num. 1: 3 m³/min por trabajador hasta 1.500 msnm + EXPECT_DOUBLE_EQ(col.min_flow_per_person(), 3.0); + // Art. 54, num. 2: 6 m³/min de 1.500 msnm en adelante (un solo escalón) + EXPECT_DOUBLE_EQ(col.altitude_threshold_1(), 1500.0); + EXPECT_DOUBLE_EQ(col.flow_above_threshold_1(), 6.0); + EXPECT_DOUBLE_EQ(col.flow_above_threshold_2(), 6.0); + EXPECT_DOUBLE_EQ(col.flow_above_threshold_3(), 6.0); + // Art. 54, par. 2, num. 1: 6 m³/min por HP (CO en el exosto <= 0,12 %) + EXPECT_DOUBLE_EQ(col.diesel_hp_factor(), 6.0); + // No regulados por el decreto — defaults ingenieriles + EXPECT_DOUBLE_EQ(col.max_dilution_time(), 30.0); + EXPECT_DOUBLE_EQ(col.default_gas_volume_per_kg(), 0.04); + EXPECT_DOUBLE_EQ(col.default_leakage_factor(), 0.15); +} + +// Las dos bandas del Art. 54 incluyen el 1.500 en su texto; rige la semántica +// común de borde ('>' estricto): a 1.500 msnm exactos aplica la banda inferior. +TEST(RegulatoryPresets, Colombia_UnSoloEscalonEn1500) { + const RegulatoryConfig col = RegulatoryConfig::colombia(); + const auto caudal = [&col](double altitud) { + return PersonnelFlowCalculator::calculate(10, altitud, col); + }; + + EXPECT_DOUBLE_EQ(caudal(0.0).flow_per_person_base, 3.0); + EXPECT_DOUBLE_EQ(caudal(1500.0).flow_per_person_base, 3.0); + EXPECT_DOUBLE_EQ(caudal(1500.5).flow_per_person_base, 6.0); + EXPECT_DOUBLE_EQ(caudal(2600.0).flow_per_person_base, 6.0); + EXPECT_DOUBLE_EQ(caudal(4200.0).flow_per_person_base, 6.0); + EXPECT_DOUBLE_EQ(caudal(2600.0).q_personnel, 60.0); // 10 × 6 +} + +// ============================================================================ +// mexico(): valores de la NOM-023-STPS-2012 (numeral 8.4.4) +// ============================================================================ + +TEST(RegulatoryPresets, Mexico_ValuesFromNOM023) { + const RegulatoryConfig mex = RegulatoryConfig::mexico(); + + EXPECT_EQ(mex.standard(), RegulatoryStandard::NOM023_Mexico); + // 8.4.4 a) 1): 1,50 m³/min por trabajador, sin escala por altitud + EXPECT_DOUBLE_EQ(mex.min_flow_per_person(), 1.5); + EXPECT_DOUBLE_EQ(mex.flow_above_threshold_1(), 1.5); + EXPECT_DOUBLE_EQ(mex.flow_above_threshold_2(), 1.5); + EXPECT_DOUBLE_EQ(mex.flow_above_threshold_3(), 1.5); + // 8.4.4 a) 2): 2,13 m³/min por caballo de fuerza diésel + EXPECT_DOUBLE_EQ(mex.diesel_hp_factor(), 2.13); + // No regulados por la norma — defaults ingenieriles + EXPECT_DOUBLE_EQ(mex.max_dilution_time(), 30.0); + EXPECT_DOUBLE_EQ(mex.default_gas_volume_per_kg(), 0.04); + EXPECT_DOUBLE_EQ(mex.default_leakage_factor(), 0.15); +} + +TEST(RegulatoryPresets, Mexico_SinEscalaPorAltitud) { + const RegulatoryConfig mex = RegulatoryConfig::mexico(); + const auto bajo = PersonnelFlowCalculator::calculate(15, 500.0, mex); + const auto alto = PersonnelFlowCalculator::calculate(15, 4200.0, mex); + + EXPECT_DOUBLE_EQ(bajo.flow_per_person_base, 1.5); + EXPECT_DOUBLE_EQ(alto.flow_per_person_base, 1.5); + EXPECT_DOUBLE_EQ(alto.q_personnel, 23.0); // ceil(15 × 1,5 = 22,5) +} + +TEST(RegulatoryPresets, ForStandard_DispatchesColombiaYMexico) { + const RegulatoryConfig col = + RegulatoryConfig::for_standard(RegulatoryStandard::D1886_Colombia); + const RegulatoryConfig mex = + RegulatoryConfig::for_standard(RegulatoryStandard::NOM023_Mexico); + + EXPECT_EQ(col.standard(), RegulatoryStandard::D1886_Colombia); + EXPECT_DOUBLE_EQ(col.diesel_hp_factor(), 6.0); + EXPECT_EQ(mex.standard(), RegulatoryStandard::NOM023_Mexico); + EXPECT_DOUBLE_EQ(mex.diesel_hp_factor(), 2.13); +} + +TEST(RegulatoryPresets, StandardName_ColombiaYMexico) { + EXPECT_EQ(RegulatoryConfig::colombia().standard_name(), + "Decreto 1886 de 2015 (Colombia)"); + EXPECT_EQ(RegulatoryConfig::mexico().standard_name(), + "NOM-023-STPS-2012 (Mexico)"); +} + +// El factor por HP de cada norma llega al desglose del diésel. Flota: +// 200 HP × 0,85 × 0,70 = 119 HP efectivos. +TEST(RegulatoryPresets, DieselPorNorma_FactorVisibleEnElDesglose) { + DieselFleet fleet; + fleet.add_equipment("Scooptram", 200.0, 0.85, 0.70); + + const auto col = DieselFlowCalculator::calculate(fleet, RegulatoryConfig::colombia()); + const auto mex = DieselFlowCalculator::calculate(fleet, RegulatoryConfig::mexico()); + + EXPECT_DOUBLE_EQ(col.hp_factor_base, 6.0); + EXPECT_NEAR(col.q_diesel, 714.0, 1.0); // 119 × 6 + EXPECT_DOUBLE_EQ(mex.hp_factor_base, 2.13); + EXPECT_NEAR(mex.q_diesel, 254.0, 1.0); // ceil(119 × 2,13 = 253,47) +} diff --git a/tests/python/test_citas_normativas.py b/tests/python/test_citas_normativas.py index a76d0b2..b6eabd3 100644 --- a/tests/python/test_citas_normativas.py +++ b/tests/python/test_citas_normativas.py @@ -6,6 +6,8 @@ el DS 132 no fija el valor; en ningun caso aparece la normativa peruana. """ +import pytest + import ventpy @@ -282,3 +284,77 @@ def test_cada_cita_chilena_nombra_su_marco(self): for sub in (demanda.personnel, demanda.diesel, demanda.blasting, demanda.dust, demanda.thermal): assert "DS 132" in sub.regulation_ref + + +# --------------------------------------------------------------------------- +# Colombia (Decreto 1886 de 2015) y Mexico (NOM-023-STPS-2012): cada cita +# nombra su norma y ninguna toma prestado un articulo de las otras tres. +# --------------------------------------------------------------------------- + +_PERU_Y_CHILE = MARCAS_PERU + ("DS 132", "DS 594") + +MARCOS_NUEVOS = [ + pytest.param("colombia", "Decreto 1886", + _PERU_Y_CHILE + ("NOM-",), id="colombia"), + pytest.param("mexico", "NOM-", + _PERU_Y_CHILE + ("Decreto 1886",), id="mexico"), +] + + +@pytest.fixture +def config(norma): + return getattr(ventpy.RegulatoryConfig, norma)() + + +@pytest.mark.parametrize("norma, marca, ajenas", MARCOS_NUEVOS) +class TestColombiaYMexico: + def test_governor_cita_solo_su_norma(self, config, marca, ajenas): + demanda = ventpy.VentilationGovernor(config).calculate_total_demand( + entrada_completa()) + subs = (demanda.personnel, demanda.diesel, demanda.blasting, + demanda.dust, demanda.thermal) + assert all(s is not None for s in subs) + for sub in subs: + assert marca in sub.regulation_ref + + infracciones = [(a, t) for t in textos_auditables(demanda) + for a in ajenas if a in t] + assert infracciones == [] + + def test_voladura_declara_que_el_tope_no_es_normativo(self, config, marca, ajenas): + r = ventpy.calculate_blasting_flow(params_voladura(), config) + assert "no fija tiempo de dilucion" in r.regulation_ref + assert "max configurado" in r.regulation_ref + assert "max normativo" not in r.regulation_ref + + def test_personal_sin_clausula_de_escala_aparte(self, config, marca, ajenas): + # Los umbrales de relleno (3.000 y 4.000 msnm) no deben llegar a la cita. + demanda = ventpy.VentilationGovernor(config).calculate_total_demand( + entrada_completa()) + assert "(>" not in demanda.personnel.regulation_ref + + def test_cobertura_declara_criterio_de_ingenieria(self, config, marca, ajenas): + m = ventpy.ZoneMeasurement() + m.zone_name = "Frente N-02" + m.q_measured_m3min = 90.0 + r = ventpy.CoverageCalculator.compare_zone(100.0, m, config=config) + assert marca in r.regulation_ref + assert "criterio de ingenieria" in r.regulation_ref + assert [a for a in ajenas if a in r.regulation_ref] == [] + + +def test_citas_puntuales_colombia_y_mexico(): + col = ventpy.RegulatoryConfig.colombia() + mex = ventpy.RegulatoryConfig.mexico() + assert "Art. 54 nums. 1 y 2" in ventpy.calculate_personnel_flow( + 15, 2600.0, col).regulation_ref + assert "Art. 54 par. 2 num. 1" in ventpy.calculate_diesel_flow( + flota_diesel(), col).regulation_ref + assert "Art. 157" in ventpy.calculate_blasting_flow( + params_voladura(), col).regulation_ref + assert "numeral 8.4.4 a) 1)" in ventpy.calculate_personnel_flow( + 15, 2600.0, mex).regulation_ref + assert "numeral 8.4.4 a) 2)" in ventpy.calculate_diesel_flow( + flota_diesel(), mex).regulation_ref + assert "numeral 8.6.9 g)" in ventpy.calculate_blasting_flow( + params_voladura(), mex).regulation_ref diff --git a/tests/python/test_multinorma.py b/tests/python/test_multinorma.py index e2bb378..99a4aa4 100644 --- a/tests/python/test_multinorma.py +++ b/tests/python/test_multinorma.py @@ -20,6 +20,38 @@ def test_for_standard_dispatch(self): assert ventpy.RegulatoryConfig.for_standard( ventpy.RegulatoryStandard.DS132_Chile).diesel_hp_factor == 2.83 + def test_colombia_values(self): + c = ventpy.RegulatoryConfig.colombia() + assert c.standard == ventpy.RegulatoryStandard.D1886_Colombia + assert c.min_flow_per_person == 3.0 # Art. 54, num. 1 + assert (c.altitude_threshold_1, c.flow_above_threshold_1) == (1500.0, 6.0) + assert c.flow_above_threshold_3 == 6.0 # un solo escalón + assert c.diesel_hp_factor == 6.0 # Art. 54, par. 2, num. 1 + + def test_colombia_escalon_unico_en_1500(self): + c = ventpy.RegulatoryConfig.colombia() + por_persona = lambda alt: ventpy.calculate_personnel_flow( + 10, alt, c).flow_per_person_base + assert por_persona(1500.0) == 3.0 # borde: rige la banda inferior + assert por_persona(1500.5) == 6.0 + assert por_persona(4200.0) == 6.0 + + def test_mexico_values(self): + m = ventpy.RegulatoryConfig.mexico() + assert m.standard == ventpy.RegulatoryStandard.NOM023_Mexico + assert m.min_flow_per_person == 1.5 # 8.4.4 a) 1) + assert m.flow_above_threshold_3 == 1.5 # sin escalón por altitud + assert m.diesel_hp_factor == 2.13 # 8.4.4 a) 2) + r = ventpy.calculate_personnel_flow(15, 4200.0, m) + assert r.q_personnel == 23.0 # ceil(15 × 1,5) + + def test_for_standard_dispatch_colombia_mexico(self): + for_standard = ventpy.RegulatoryConfig.for_standard + assert for_standard( + ventpy.RegulatoryStandard.D1886_Colombia).diesel_hp_factor == 6.0 + assert for_standard( + ventpy.RegulatoryStandard.NOM023_Mexico).diesel_hp_factor == 2.13 + class TestLmp: def test_peru_co(self): @@ -40,3 +72,39 @@ def test_chile_no_not_regulated(self): def test_tables_complete(self): assert len(ventpy.gas_limits(ventpy.RegulatoryStandard.DS024_Peru)) == 8 assert len(ventpy.gas_limits(ventpy.RegulatoryStandard.DS132_Chile)) == 7 + assert len(ventpy.gas_limits(ventpy.RegulatoryStandard.D1886_Colombia)) == 8 + assert len(ventpy.gas_limits(ventpy.RegulatoryStandard.NOM023_Mexico)) == 8 + + @pytest.mark.parametrize("standard, marca", [ + (ventpy.RegulatoryStandard.D1886_Colombia, "Decreto 1886 de 2015, Art. 39"), + (ventpy.RegulatoryStandard.NOM023_Mexico, "NOM-010-STPS-2014, Tabla I.1"), + ]) + def test_colombia_y_mexico_misma_tabla_de_gases(self, standard, marca): + valores = { + ventpy.GasType.CO: (25.0, None), + ventpy.GasType.CO2: (5000.0, 30000.0), + ventpy.GasType.NO2: (0.2, None), + ventpy.GasType.NO: (25.0, None), + ventpy.GasType.H2S: (1.0, 5.0), + ventpy.GasType.SO2: (None, 0.25), # solo corto tiempo: TWA vacío + } + for gas, (twa, stel) in valores.items(): + limite = ventpy.lmp_for(standard, gas) + assert (limite.twa_8h, limite.stel) == (twa, stel), gas + assert marca in limite.regulation_ref + + def test_colombia_ch4_y_o2(self): + col = ventpy.RegulatoryStandard.D1886_Colombia + ch4 = ventpy.lmp_for(col, ventpy.GasType.CH4) + assert ch4.unit == ventpy.ConcentrationUnit.PercentVolume + assert ch4.ceiling == 1.0 and "Art. 53" in ch4.regulation_ref + o2 = ventpy.lmp_for(col, ventpy.GasType.O2) + assert (o2.floor_min, o2.ceiling) == (19.5, 23.5) + + def test_mexico_ch4_higienico_y_o2_declarado(self): + mex = ventpy.RegulatoryStandard.NOM023_Mexico + ch4 = ventpy.lmp_for(mex, ventpy.GasType.CH4) + assert ch4.twa_8h == 1000.0 and ch4.ceiling is None + o2 = ventpy.lmp_for(mex, ventpy.GasType.O2) + assert o2.floor_min == 19.5 and o2.ceiling is None + assert "Criterio de ingenieria" in o2.regulation_ref diff --git a/tests/python/test_red_ventilacion.py b/tests/python/test_red_ventilacion.py index 70d7867..5a41471 100644 --- a/tests/python/test_red_ventilacion.py +++ b/tests/python/test_red_ventilacion.py @@ -48,6 +48,18 @@ def test_tabla_informativa_7_entradas(self): for entry in tabla: assert "A5" in entry.biblio_ref + def test_factor_por_formula_o_manual_va_sin_valor(self): + # Un 0.0 se leería como "sin pérdida"; solo los límites exactos traen x. + x = {e.type: e.x for e in ventpy.shock_factors()} + assert x[ventpy.SingularityType.Exit] == pytest.approx(1.0) + assert x[ventpy.SingularityType.Entrance] == pytest.approx(0.5) + for tipo in (ventpy.SingularityType.Expansion, + ventpy.SingularityType.Contraction, + ventpy.SingularityType.Bend90, + ventpy.SingularityType.Bend45, + ventpy.SingularityType.Junction): + assert x[tipo] is None + def test_resolve_shock_factor_exit_entrance(self): exit_s = ventpy.AirwaySingularity() exit_s.type = ventpy.SingularityType.Exit From fe9a235c904911eb931e8a165cc63361e77d700c Mon Sep 17 00:00:00 2001 From: Miqueas Quintanilla Date: Thu, 12 Nov 2026 01:49:05 -0500 Subject: [PATCH 2/3] fix: el caudal de voladura se calcula siempre por dilucion de contaminantes Declarar gas_volume_per_kg en BlastingParams desviaba el calculo a una formula que dividia el volumen total de gases entre el tiempo de dilucion. Ese cociente es el ritmo al que se generan los gases, no el caudal que los diluye hasta un limite de exposicion: con 50 kg, 0,04 m3/kg y 30 min daba 0,067 m3/min, que el redondeo de seguridad subia a 1. La funcion suelta calculate_blasting_flow devolvia ese mismo valor aunque el parametro no se declarara, y VentilationGovernor tomaba la misma rama cuando la entrada lo traia. En una libreria que dimensiona ventilacion, un caudal de voladura de 1 m3/min es un defecto de seguridad, no un caso simplificado. Queda un solo modelo: dilucion de CO y de NOx segun el tipo de explosivo, recambio volumetrico y velocidad minima de barrido. gas_volume_per_kg ya no cambia el caudal; solo fija el volumen total de gases que se reporta. - VentilationGovernor usa siempre el modelo con correccion por altitud, y por eso exige seccion y longitud del frente para toda voladura (las toma de VentilationInput si la voladura no las trae). - calculate_blasting_flow usa el mismo modelo sin correccion por altitud, porque no recibe atmosfera, y omite los criterios de seccion si no se dan face_area_m2 y face_length_m. Los resultados cambian para quien declaraba gas_volume_per_kg. Con 50 kg de ANFO en 30 min a 4.200 msnm y un scoop de 150 HP, Q_Exp pasa de 1 a 6.751 m3/min, la voladura pasa a gobernar y el total sube de 981 a 7.764 m3/min. Quien no declaraba el parametro ya obtenia este modelo en el Governor. Las pruebas que fijaban el caudal de 1 m3/min se recalculan con su derivacion. El ejemplo 01 deja de incluir voladura para seguir mostrando el caso gobernado por el diesel, y su README explica el cambio. --- examples/01-demanda-peru/README.md | 16 +++- examples/01-demanda-peru/input.json | 9 +-- examples/01-demanda-peru/run.py | 12 +-- include/ventpy/caudal_explosivos.hpp | 105 ++++++++++++++------------- include/ventpy/governor.hpp | 12 +-- tests/cpp/test_caudal_explosivos.cpp | 80 +++++++++++++++++--- tests/cpp/test_governor.cpp | 33 +++++---- tests/python/test_cli.py | 38 +++++++++- tests/python/test_ventpy.py | 25 ++++++- 9 files changed, 218 insertions(+), 112 deletions(-) diff --git a/examples/01-demanda-peru/README.md b/examples/01-demanda-peru/README.md index 08251eb..18cb6eb 100644 --- a/examples/01-demanda-peru/README.md +++ b/examples/01-demanda-peru/README.md @@ -7,7 +7,7 @@ La demanda total de ventilacion (`Q_total`) de un frente de desarrollo - 15 trabajadores, a 4200 msnm - 1 scoop diesel de 150 HP (disponibilidad 0.85, utilizacion 0.70) -- 1 disparo de 50 kg de ANFO (0.04 m3 de gases/kg, 30 min de dilucion) +- sin voladura (ver la nota al final) usando la norma peruana `DS 024-2016-EM` (el preset por defecto de `RegulatoryConfig`). @@ -25,7 +25,7 @@ ventpy demanda examples/01-demanda-peru/input.json --json "standard": "DS024_Peru", "q_personnel_m3min": 180.0, "q_diesel_m3min": 853.0, - "q_blasting_m3min": 1.0, + "q_blasting_m3min": 0.0, "governing_factor": "diesel (Q_Eq)", "q_total_m3min": 981.0, "velocity_at_face_mps": 1.1847222222222222, @@ -40,7 +40,7 @@ ventpy demanda examples/01-demanda-peru/input.json --json El Governor toma el `max()` de los caudales de personal, diesel y explosivos para una zona de tipo frente/rampa (`select_governing_flow`, `include/ventpy/governor.hpp`). Aqui `Q_Eq` (853 m3/min) es mayor que -`Q_Per` (180) y `Q_Exp` (1), asi que **"diesel (Q_Eq)"** es el criterio +`Q_Per` (180) y no hay voladura, asi que **"diesel (Q_Eq)"** es el criterio gobernante. Dentro del calculo de `Q_Eq` (`DieselFlowCalculator::calculate_full`, @@ -80,6 +80,16 @@ ejemplo, pero forma parte de la auditoria obligatoria de todo resultado (criterio de auditoria del proyecto: estructura de auditoria completa, sin `double` suelto). +## Nota: por que este ejemplo ya no incluye voladura + +Hasta la 0.2.1 este caso llevaba un disparo de 50 kg de ANFO con +`gas_volume_per_kg = 0.04`, y `Q_Exp` salia en 1 m3/min: la libreria dividia +el volumen de gases entre el tiempo de dilucion, sin llevarlo a un limite de +exposicion. Desde la 0.3.0 la voladura se calcula siempre por dilucion de CO +y NOx. Con ese mismo disparo, `Q_Exp` vale 6751 m3/min y pasa a gobernar +(`Q_total` = 7764 m3/min). Se quito del ejemplo para que siga mostrando el +caso gobernado por el diesel; el detalle del cambio esta en `CHANGELOG.md`. + ## Ejecutar ```bash diff --git a/examples/01-demanda-peru/input.json b/examples/01-demanda-peru/input.json index e7cf578..2e1cc93 100644 --- a/examples/01-demanda-peru/input.json +++ b/examples/01-demanda-peru/input.json @@ -9,12 +9,5 @@ "availability": 0.85, "utilization": 0.70 } - ], - "blasting_params": { - "explosive_kg": 50, - "gas_volume_per_kg": 0.04, - "dilution_time_min": 30, - "face_area_m2": 12, - "face_length_m": 200 - } + ] } diff --git a/examples/01-demanda-peru/run.py b/examples/01-demanda-peru/run.py index 3ef8c69..05735aa 100644 --- a/examples/01-demanda-peru/run.py +++ b/examples/01-demanda-peru/run.py @@ -1,8 +1,8 @@ """Ejemplo 1 — Demanda de ventilacion de un frente de desarrollo (norma Peru). Reproduce, usando SOLO el API publico de `ventpy` (sin pasar por `ventpy.cli`), -el caso de 15 trabajadores / 4200 msnm / scoop diesel 150 HP / voladura ANFO -50 kg documentado en README.md de este ejemplo. Este script ES su propia +el caso de 15 trabajadores / 4200 msnm / scoop diesel 150 HP documentado en +README.md de este ejemplo (sin voladura: ver la nota del README). Este script ES su propia verificacion: si algun `assert` falla, el numero de referencia (981 m3/min) dejo de sostenerse. @@ -28,14 +28,6 @@ fleet.add_equipment(scoop) inp.diesel_fleet = fleet -blast = ventpy.BlastingParams() -blast.explosive_kg = 50 -blast.gas_volume_per_kg = 0.04 -blast.dilution_time_min = 30 -blast.face_area_m2 = 12 -blast.face_length_m = 200 -inp.blasting_params = blast - result = governor.calculate_total_demand(inp) print(f"standard = {result.standard.name}") diff --git a/include/ventpy/caudal_explosivos.hpp b/include/ventpy/caudal_explosivos.hpp index 54b1447..b937a30 100644 --- a/include/ventpy/caudal_explosivos.hpp +++ b/include/ventpy/caudal_explosivos.hpp @@ -78,7 +78,54 @@ class BlastingFlowCalculator { "face_area_m2 [m2] - Seccion del frente"); validation::require_positive(params.face_length_m, "face_length_m [m] - Longitud de la labor"); + validation::require_non_negative(params.gas_volume_per_kg, + "gas_volume_per_kg [m3/kg] - Volumen de gases por kg (0 = estimar)"); + + return by_contaminant( + params, + AtmosphereCalculator::calculate_volume_correction_factor( + atm.altitude_masl), + config); + } + /** + * @brief Versión simplificada (compatibilidad con API anterior). + * + * Mismo modelo por contaminante que `calculate_full`, sin corrección por + * altitud (no recibe atmósfera) y sin los criterios de sección si + * `face_area_m2` o `face_length_m` vienen en 0. + * + * `gas_volume_per_kg` no cambia el caudal: solo fija el volumen total de + * gases que se reporta. Hasta la 0.2.1 seleccionaba una fórmula que + * dividía ese volumen entre el tiempo de dilución, sin llevarlo a un + * límite de exposición, y devolvía caudales del orden de 1 m³/min. + */ + [[nodiscard]] static BlastingFlowResult calculate( + const BlastingParams& params, + const RegulatoryConfig& config + ) { + validation::require_positive(params.explosive_kg, + "explosive_kg [kg] - Cantidad de explosivo por disparo"); + validation::require_positive(params.dilution_time_min, + "dilution_time_min [min] - Tiempo de dilucion"); + validation::require_non_negative(params.gas_volume_per_kg, + "gas_volume_per_kg [m3/kg] - Volumen de gases por kg (0 = estimar)"); + return by_contaminant(params, 1.0, config); + } + +private: + /** + * @brief Modelo por contaminante: dilución de CO y NOx, recambio + * volumétrico y velocidad mínima de barrido. + * + * Los dos criterios de sección valen 0 si la sección o la longitud no se + * dieron; `calculate_full` las exige antes de llegar aquí. + */ + [[nodiscard]] static BlastingFlowResult by_contaminant( + const BlastingParams& params, + double volume_correction, + const RegulatoryConfig& config + ) { BlastingFlowResult result; result.explosive_type = params.explosive_type; result.explosive_kg = params.explosive_kg; @@ -91,19 +138,17 @@ class BlastingFlowCalculator { result.co_generated_liters = params.explosive_kg * co_per_kg; result.nox_generated_liters = params.explosive_kg * nox_per_kg; - // Volumen total de gases (CO + NOx + otros) + // Volumen total de gases: el declarado por el usuario o, si no, + // CO + NOx + otros. Es un dato de auditoría: no entra en el caudal. double other_gases = params.explosive_kg * 20.0; // N2, H2O vapor, etc. - result.total_gas_volume_m3 = (result.co_generated_liters + - result.nox_generated_liters + - other_gases) / 1000.0; + result.total_gas_volume_m3 = (params.gas_volume_per_kg > 0.0) + ? params.explosive_kg * params.gas_volume_per_kg + : (result.co_generated_liters + result.nox_generated_liters + + other_gases) / 1000.0; // Volumen de la labor result.face_volume_m3 = params.face_area_m2 * params.face_length_m; - // Factor de corrección por altitud - double volume_correction = AtmosphereCalculator::calculate_volume_correction_factor( - atm.altitude_masl); - // --- Criterio 1: Dilución de CO a TLV --- result.q_for_co_dilution = calculate_q_for_gas( result.co_generated_liters, @@ -156,50 +201,6 @@ class BlastingFlowCalculator { return result; } - /** - * @brief Versión simplificada (compatibilidad con API anterior). - */ - [[nodiscard]] static BlastingFlowResult calculate( - const BlastingParams& params, - const RegulatoryConfig& config - ) { - validation::require_positive(params.explosive_kg, - "explosive_kg [kg] - Cantidad de explosivo por disparo"); - validation::require_positive(params.dilution_time_min, - "dilution_time_min [min] - Tiempo de dilucion"); - // 0 = sentinel "usar factores del tipo de explosivo"; negativo es dato inválido - validation::require_non_negative(params.gas_volume_per_kg, - "gas_volume_per_kg [m3/kg] - Volumen de gases por kg (0 = usar tipo)"); - - BlastingFlowResult result; - result.explosive_type = params.explosive_type; - result.explosive_kg = params.explosive_kg; - result.dilution_time_min = params.dilution_time_min; - - double gas_volume_per_kg = params.gas_volume_per_kg; - if (gas_volume_per_kg <= 0.0) { - auto [co, nox] = get_gas_factors(params); - gas_volume_per_kg = (co + nox) / 1000.0; - } - - result.co_generated_liters = 0.0; - result.nox_generated_liters = 0.0; - result.total_gas_volume_m3 = params.explosive_kg * gas_volume_per_kg; - result.face_volume_m3 = params.face_area_m2 * params.face_length_m; - result.q_for_co_dilution = 0.0; - result.q_for_nox_dilution = 0.0; - result.q_for_volume_exchange = - result.total_gas_volume_m3 / params.dilution_time_min; - result.q_for_min_velocity = 0.0; - result.q_blasting = safety_ceil(result.q_for_volume_exchange); - result.governing_criterion = "volumen total"; - // Misma referencia auditable que calculate_full: incluye la ADVERTENCIA - // si el tiempo de dilución excede el máximo normativo (Art. 243). - result.regulation_ref = build_regulation_ref(params, config, result); - return result; - } - -private: /** * @brief Obtiene factores de gases según tipo de explosivo. * diff --git a/include/ventpy/governor.hpp b/include/ventpy/governor.hpp index b66b0e2..ea47289 100644 --- a/include/ventpy/governor.hpp +++ b/include/ventpy/governor.hpp @@ -154,15 +154,11 @@ class VentilationGovernor { if (blast.face_area_m2 <= 0) blast.face_area_m2 = input.face_area_m2; if (blast.face_length_m <= 0) blast.face_length_m = input.face_length_m; - // Compatibilidad: si el usuario usa la parametrización simple - // basada en volumen total de gases, mantener el comportamiento - // legacy en lugar del modelo detallado por contaminantes. + // Un solo modelo: dilución por contaminante con corrección de + // altitud. `gas_volume_per_kg` ya no desvía a la fórmula de + // volumen total entre tiempo, que no diluía a ningún límite. auto blast_result = - (blast.gas_volume_per_kg > 0.0 && - blast.co_per_kg_liters <= 0.0 && - blast.nox_per_kg_liters <= 0.0) - ? BlastingFlowCalculator::calculate(blast, config_) - : BlastingFlowCalculator::calculate_full(blast, atm, config_); + BlastingFlowCalculator::calculate_full(blast, atm, config_); result.q_blasting_m3min = blast_result.q_blasting; result.blasting = blast_result; } diff --git a/tests/cpp/test_caudal_explosivos.cpp b/tests/cpp/test_caudal_explosivos.cpp index 3bbb049..926debb 100644 --- a/tests/cpp/test_caudal_explosivos.cpp +++ b/tests/cpp/test_caudal_explosivos.cpp @@ -28,14 +28,37 @@ TEST_F(BlastingFlowTest, TypicalBlasting) { auto result = BlastingFlowCalculator::calculate(params, default_config); - // Q_Exp crudo = (50 × 0.04) / 30 = 2.0 / 30 = 0.0667 m³/min - EXPECT_NEAR(result.q_for_volume_exchange, 50.0 * 0.04 / 30.0, 0.0001); - // Salida final con redondeo de seguridad: ceil(0.0667) = 1 - EXPECT_DOUBLE_EQ(result.q_blasting, 1.0); + // Dilución de NOx: 50 × 10 = 500 L → 500 / (5e-6 × 30) / 1000 × 1,2 = 4.000 + // Dilución de CO: 50 × 40 = 2.000 L → 2.000 / (25e-6 × 30) / 1000 × 1,2 = 3.200 + EXPECT_NEAR(result.q_for_nox_dilution, 4000.0, 0.01); + EXPECT_NEAR(result.q_for_co_dilution, 3200.0, 0.01); + // Recambio: 12 × 200 = 2.400 m³ × 5 (labor de más de 100 m) / 30 = 400 + EXPECT_NEAR(result.q_for_volume_exchange, 400.0, 0.0001); + EXPECT_DOUBLE_EQ(result.q_blasting, 4000.0); + EXPECT_EQ(result.governing_criterion, "dilucion NOx"); EXPECT_DOUBLE_EQ(result.explosive_kg, 50.0); + // gas_volume_per_kg solo fija el volumen reportado: 50 × 0,04 = 2 m³ EXPECT_DOUBLE_EQ(result.total_gas_volume_m3, 2.0); } +// --- gas_volume_per_kg no cambia el caudal --- +// Hasta la 0.2.1 declararlo desviaba a volumen total / tiempo (1 m³/min). +TEST_F(BlastingFlowTest, GasVolumeNoCambiaElCaudal) { + BlastingParams sin_volumen{.explosive_kg = 50.0, .dilution_time_min = 30.0, + .face_area_m2 = 12.0, .face_length_m = 200.0}; + BlastingParams con_volumen = sin_volumen; + con_volumen.gas_volume_per_kg = 0.04; + + auto a = BlastingFlowCalculator::calculate(sin_volumen, default_config); + auto b = BlastingFlowCalculator::calculate(con_volumen, default_config); + + EXPECT_DOUBLE_EQ(a.q_blasting, b.q_blasting); + EXPECT_EQ(a.governing_criterion, b.governing_criterion); + EXPECT_DOUBLE_EQ(b.total_gas_volume_m3, 2.0); + // Sin declararlo se estima: (2.000 + 500 + 50 × 20) L = 3,5 m³ + EXPECT_DOUBLE_EQ(a.total_gas_volume_m3, 3.5); +} + // --- Voladura grande: 200 kg --- TEST_F(BlastingFlowTest, LargeBlasting) { BlastingParams params{ @@ -48,9 +71,10 @@ TEST_F(BlastingFlowTest, LargeBlasting) { auto result = BlastingFlowCalculator::calculate(params, default_config); - // Q_Exp crudo = (200 × 0.04) / 20 = 8.0 / 20 = 0.4 → safety_ceil = 1 - EXPECT_NEAR(result.q_for_volume_exchange, 0.4, 0.0001); - EXPECT_DOUBLE_EQ(result.q_blasting, 1.0); + // NOx: 200 × 10 = 2.000 L → 2.000 / (5e-6 × 20) / 1000 × 1,2 = 24.000 + // Recambio: 16 × 150 = 2.400 m³ × 5 / 20 = 600 + EXPECT_NEAR(result.q_for_volume_exchange, 600.0, 0.0001); + EXPECT_DOUBLE_EQ(result.q_blasting, 24000.0); } // --- Tiempo de dilución excede el máximo normativo: advertencia --- @@ -102,14 +126,15 @@ TEST_F(BlastingFlowTest, NegativeGasVolume_ThrowsException) { } // --- Área de frente = 0: válido en la API simplificada --- -// (0 = "completar después": el Governor la rellena con input.face_area_m2; -// la fórmula legacy no depende de la sección. calculate_full sí la exige > 0.) +// (0 = "completar después": el Governor la rellena con input.face_area_m2. +// Sin sección no se evalúan recambio ni velocidad; calculate_full sí la exige > 0.) TEST_F(BlastingFlowTest, ZeroFaceArea_AllowedInSimplifiedApi) { BlastingParams params{.explosive_kg = 50.0, .gas_volume_per_kg = 0.04, .dilution_time_min = 30.0, .face_area_m2 = 0.0, .face_length_m = 200.0}; auto result = BlastingFlowCalculator::calculate(params, default_config); - EXPECT_DOUBLE_EQ(result.q_blasting, 1.0); // ceil(2.0 / 30) + EXPECT_DOUBLE_EQ(result.q_blasting, 4000.0); // dilución de NOx + EXPECT_DOUBLE_EQ(result.q_for_min_velocity, 0.0); EXPECT_DOUBLE_EQ(result.face_volume_m3, 0.0); } @@ -121,3 +146,38 @@ TEST_F(BlastingFlowTest, ResultContainsArticle243) { auto result = BlastingFlowCalculator::calculate(params, default_config); EXPECT_NE(result.regulation_ref.find("Art. 243"), std::string::npos); } + +// --- Sin sección ni longitud: solo los criterios de dilución --- +// 60 kg de ANFO, 30 min: +// NOx = 60 × 10 = 600 L → 600 / (5e-6 × 30) / 1000 × 1,2 = 4.800 m³/min +// CO = 60 × 40 = 2.400 L → 2.400 / (25e-6 × 30) / 1000 × 1,2 = 3.840 m³/min +TEST_F(BlastingFlowTest, SinVolumenDeGases_ModeloPorContaminante) { + BlastingParams params{.explosive_kg = 60.0, .dilution_time_min = 30.0}; + + auto result = BlastingFlowCalculator::calculate(params, default_config); + + EXPECT_NEAR(result.q_for_co_dilution, 3840.0, 0.01); + EXPECT_NEAR(result.q_for_nox_dilution, 4800.0, 0.01); + EXPECT_DOUBLE_EQ(result.q_blasting, 4800.0); + EXPECT_EQ(result.governing_criterion, "dilucion NOx"); + // Sin sección ni longitud no hay criterios de frente que evaluar. + EXPECT_DOUBLE_EQ(result.q_for_volume_exchange, 0.0); + EXPECT_DOUBLE_EQ(result.q_for_min_velocity, 0.0); +} + +// La versión simple coincide con calculate_full al nivel del mar: la única +// diferencia entre ambas es la corrección por altitud. +TEST_F(BlastingFlowTest, SinVolumenDeGases_CoincideConCompletoAlNivelDelMar) { + BlastingParams params{.explosive_kg = 60.0, .dilution_time_min = 30.0, + .face_area_m2 = 12.0, .face_length_m = 100.0}; + AtmosphericParams nivel_del_mar; + nivel_del_mar.altitude_masl = 0.0; + + auto simple = BlastingFlowCalculator::calculate(params, default_config); + auto completo = BlastingFlowCalculator::calculate_full( + params, nivel_del_mar, default_config); + + EXPECT_NEAR(simple.q_blasting, completo.q_blasting, 1.0); + EXPECT_EQ(simple.governing_criterion, completo.governing_criterion); + EXPECT_GT(simple.q_for_min_velocity, 0.0); +} diff --git a/tests/cpp/test_governor.cpp b/tests/cpp/test_governor.cpp index d2e55a4..b907125 100644 --- a/tests/cpp/test_governor.cpp +++ b/tests/cpp/test_governor.cpp @@ -51,17 +51,21 @@ TEST_F(GovernorTest, DevelopmentFace_FullCalculation) { .face_length_m = 200.0 }; input.blasting_params = blast; - // Q_Exp = (50 × 0.04) / 30 = 0.0667 + // Dilución de NOx tras la voladura (gobierna dentro de Q_Exp): + // 50 × 10 = 500 L → 500 / (5e-6 × 30) / 1000 × 1,2 = 4.000 + // × corr_vol(4200 = 1.6876) = 6750.4 → Q_Exp = safety_ceil = 6751 auto result = governor.calculateTotalDemand(input); // Q_Per = 180 (piso de velocidad: 12 m² × 0.25 m/s × 60; ver PersonnelOnly) - // Gobernante: max(180, 853, 0.0667) = 853 (diésel) - EXPECT_EQ(result.governing_factor, "diesel (Q_Eq)"); - EXPECT_DOUBLE_EQ(result.q_governing_m3min, 853.0); + // Gobernante: max(180, 853, 6751) = 6751 (voladura) + EXPECT_DOUBLE_EQ(result.q_diesel_m3min, 853.0); + EXPECT_DOUBLE_EQ(result.q_blasting_m3min, 6751.0); + EXPECT_EQ(result.governing_factor, "blasting (Q_Exp)"); + EXPECT_DOUBLE_EQ(result.q_governing_m3min, 6751.0); - // Con fugas 15%: 853 × 1.15 = 980.95 → ceil = 981 - EXPECT_DOUBLE_EQ(result.q_total_m3min, 981.0); + // Con fugas 15%: 6751 × 1.15 = 7763.65 → ceil = 7764 + EXPECT_DOUBLE_EQ(result.q_total_m3min, 7764.0); // Verificar que todos los componentes están presentes EXPECT_TRUE(result.personnel.has_value()); @@ -319,14 +323,15 @@ TEST_F(GovernorPolvoTermico, TermicoGobiernaE2E) { // rederivar aqui el modelo Tier3 de dilucion NOx del diesel. // q_per = 180 (piso velocidad; igual que TermicoGobiernaE2E) // q_eq = 0 (sin flota diesel; simplificacion documentada) -// q_exp = 1 (blasting simple 50 kg, 0.04 m3/kg, 30 min: -// ceil((50*0.04)/30) = ceil(0.0667) = 1) +// q_exp = 5428 (50 kg de ANFO en 30 min, dilucion de NOx: +// 500 L / (5e-6 x 30) / 1000 x 1.2 = 4000; +// x corr_vol(2500 = 1.3569) = 5427.6 => ceil) // q_dust = 300 (caso A de DilucionConSupresionExacta en // test_caudal_polvo.cpp: 50 mg/s con supresion 0.7, // target 3.0 => exacto, sin resto) // q_thermal = 9391 (mismo caso de test_caudal_termico.cpp que TermicoGobiernaE2E) -// suma = 180+0+1+300+9391 = 9872. -// total = ceil(9872 x 1.15) = ceil(11352.8) = 11353. +// suma = 180+0+5428+300+9391 = 15299. +// total = ceil(15299 x 1.15) = ceil(17593.85) = 17594. TEST_F(GovernorPolvoTermico, GeneralMineSumaSinFlota) { VentilationInput input; input.zone_type = ZoneType::GeneralMine; @@ -365,15 +370,15 @@ TEST_F(GovernorPolvoTermico, GeneralMineSumaSinFlota) { EXPECT_DOUBLE_EQ(result.q_personnel_m3min, 180.0); EXPECT_DOUBLE_EQ(result.q_diesel_m3min, 0.0); - EXPECT_DOUBLE_EQ(result.q_blasting_m3min, 1.0); + EXPECT_DOUBLE_EQ(result.q_blasting_m3min, 5428.0); EXPECT_DOUBLE_EQ(result.q_dust_m3min, 300.0); EXPECT_DOUBLE_EQ(result.q_thermal_m3min, 9391.0); double suma_manual = result.q_personnel_m3min + result.q_diesel_m3min + result.q_blasting_m3min + result.q_dust_m3min + result.q_thermal_m3min; - EXPECT_DOUBLE_EQ(suma_manual, 9872.0); + EXPECT_DOUBLE_EQ(suma_manual, 15299.0); EXPECT_EQ(result.governing_factor, "summation (mine total)"); - EXPECT_DOUBLE_EQ(result.q_governing_m3min, 9872.0); - EXPECT_DOUBLE_EQ(result.q_total_m3min, 11353.0); + EXPECT_DOUBLE_EQ(result.q_governing_m3min, 15299.0); + EXPECT_DOUBLE_EQ(result.q_total_m3min, 17594.0); } diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py index 4ecf77c..dbaf355 100644 --- a/tests/python/test_cli.py +++ b/tests/python/test_cli.py @@ -37,15 +37,19 @@ def _write_json(tmp_path, name, data): class TestCmdDemanda: - def test_demanda_devface_981(self, tmp_path, capsys): + def test_demanda_devface_voladura_gobierna_7764(self, tmp_path, capsys): archivo = _write_json(tmp_path, "input.json", _DEVFACE_15W_4200_SCOOP150) exit_code = cli.main(["demanda", archivo, "--json"]) assert exit_code == 0 data = json.loads(capsys.readouterr().out) - assert data["q_total_m3min"] == 981.0 - assert data["governing_factor"] == "diesel (Q_Eq)" + # 50 kg de ANFO en 30 min a 4200 msnm: dilucion de NOx = 6751 m3/min, + # por encima del diesel (853). Total = ceil(6751 x 1.15) = 7764. + assert data["q_diesel_m3min"] == 853.0 + assert data["q_blasting_m3min"] == 6751.0 + assert data["q_total_m3min"] == 7764.0 + assert data["governing_factor"] == "blasting (Q_Exp)" def test_demanda_norma_chile(self, tmp_path, capsys): archivo = _write_json(tmp_path, "input.json", _CHILE_10W_4200_SCOOP150) @@ -58,6 +62,20 @@ def test_demanda_norma_chile(self, tmp_path, capsys): assert data["q_total_m3min"] == 981.0 assert data["governing_factor"] == "diesel (Q_Eq)" + @pytest.mark.parametrize("norma, standard", [ + ("colombia", "D1886_Colombia"), + ("mexico", "NOM023_Mexico"), + ]) + def test_demanda_norma_colombia_y_mexico(self, tmp_path, capsys, norma, standard): + archivo = _write_json(tmp_path, "input.json", _CHILE_10W_4200_SCOOP150) + + exit_code = cli.main(["demanda", archivo, "--norma", norma, "--json"]) + + assert exit_code == 0 + data = json.loads(capsys.readouterr().out) + assert data["standard"] == standard + assert data["q_total_m3min"] > 0 + def test_demanda_clave_desconocida_exit1(self, tmp_path, capsys): archivo = _write_json(tmp_path, "input.json", {"trabajadores": 5}) @@ -145,6 +163,20 @@ def test_lmp_chile_co(self, capsys): assert data["twa_8h"] == 44.0 assert "40 ppm" in data["regulation_ref"] + @pytest.mark.parametrize("norma, standard, so2_ref", [ + ("colombia", "D1886_Colombia", "Decreto 1886 de 2015, Art. 39"), + ("mexico", "NOM023_Mexico", "NOM-010-STPS-2014, Tabla I.1, fila 317"), + ]) + def test_lmp_colombia_y_mexico_so2_sin_twa(self, capsys, norma, standard, so2_ref): + exit_code = cli.main(["lmp", "--norma", norma, "--gas", "SO2", "--json"]) + + assert exit_code == 0 + data = json.loads(capsys.readouterr().out) + assert data["standard"] == standard + assert data["twa_8h"] is None # la norma solo fija corto tiempo + assert data["stel"] == 0.25 + assert so2_ref in data["regulation_ref"] + def test_lmp_gas_no_regulado_exit1(self, capsys): exit_code = cli.main(["lmp", "--norma", "chile", "--gas", "NO", "--json"]) diff --git a/tests/python/test_ventpy.py b/tests/python/test_ventpy.py index 4154ed4..ca17696 100644 --- a/tests/python/test_ventpy.py +++ b/tests/python/test_ventpy.py @@ -159,8 +159,22 @@ def test_typical_blasting(self, default_config): params.face_length_m = 200.0 result = ventpy.calculate_blasting_flow(params, default_config) - # Basic formula: (50 * 0.04) / 30 = 0.0667 m3/min (minimum) - assert result.q_blasting > 0 + # NOx dilution: 50 kg * 10 L/kg / (5e-6 * 30 min) / 1000 * 1.2 = 4000 + assert result.q_blasting == 4000.0 + assert result.governing_criterion == "dilucion NOx" + # gas_volume_per_kg only sets the reported gas volume: 50 * 0.04 + assert result.total_gas_volume_m3 == 2.0 + + def test_without_face_section_only_dilution_criteria(self, default_config): + # 60 kg de ANFO en 30 min: NOx 600 L / (5e-6 * 30) / 1000 * 1.2 = 4800. + params = ventpy.BlastingParams() + params.explosive_kg = 60.0 + params.dilution_time_min = 30.0 + + result = ventpy.calculate_blasting_flow(params, default_config) + + assert result.q_blasting == 4800.0 + assert result.governing_criterion == "dilucion NOx" def test_zero_dilution_time_raises(self, default_config): params = ventpy.BlastingParams() @@ -322,8 +336,11 @@ def test_full_development_face(self, governor): result = governor.calculate_total_demand(inp) - # Diesel should govern (larger than personnel or blasting) - assert "diesel" in result.governing_factor.lower() + # Blasting governs: NOx dilution of a 50 kg ANFO round at 4200 masl + # (6751 m3/min) exceeds the diesel requirement (853 m3/min). + assert result.q_diesel_m3min == 853.0 + assert result.q_blasting_m3min == 6751.0 + assert result.governing_factor == "blasting (Q_Exp)" assert result.q_total_m3min > 0 assert result.q_total_cfm > 0 From b0f0c1be760f996a66dce22d955e4ba198decd7d Mon Sep 17 00:00:00 2001 From: Miqueas Quintanilla Date: Thu, 12 Nov 2026 01:49:06 -0500 Subject: [PATCH 3/3] build: version 0.3.0 y documentacion de las normas de Colombia y Mexico pyproject.toml, el paquete Python y CMakeLists.txt quedan en 0.3.0. Los README en ingles y espanol describen los cuatro preajustes, avisan del cambio en el caudal de voladura y actualizan el conteo de pruebas (245 de C++ y 199 de Python). El registro de cambios detalla a quien afecta cada cambio. El roadmap marca Colombia y Mexico como implementados y deja abiertos las minas de carbon mexicanas (NOM-032-STPS-2008) y el calculo del calor por temperatura efectiva o indice TGBH, que hoy solo se cita. --- CHANGELOG.md | 88 ++++++++++++++++++++++++++++++++++++++- CMakeLists.txt | 2 +- CONTRIBUTING.md | 13 +++--- README.es.md | 36 ++++++++++------ README.md | 36 ++++++++++------ ROADMAP.md | 55 ++++++++++++++---------- pyproject.toml | 2 +- python/ventpy/__init__.py | 2 +- 8 files changed, 179 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d0478..86bff12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,46 @@ Todas las versiones publicadas de VentPy. El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y el versionado es [semántico](https://semver.org/lang/es/). -## [Sin publicar] +## [0.3.0] — 2026-11-12 + +### El caudal de voladura cambia + +Hasta la 0.2.1, declarar `gas_volume_per_kg` en `BlastingParams` desviaba el +cálculo a una fórmula que dividía el volumen total de gases entre el tiempo de +dilución. Ese cociente es el ritmo al que se generan los gases, no el caudal +que los diluye hasta un límite de exposición. Con 50 kg de explosivo, +0,04 m³/kg y 30 min daba 0,067 m³/min, que el redondeo de seguridad subía a 1. +El defecto alcanzaba a `calculate_blasting_flow` y también a +`VentilationGovernor`, y estaba presente desde la 0.1.0. + +Desde la 0.3.0 hay un solo modelo: dilución de CO y de NOx según el tipo de +explosivo, recambio volumétrico y velocidad mínima de barrido. +`gas_volume_per_kg` ya no cambia el caudal; solo fija el volumen total de +gases que se reporta en `total_gas_volume_m3`. + +**A quién afecta:** a quien haya calculado una demanda con voladura declarando +`gas_volume_per_kg`. Conviene recalcular: el caudal de voladura sube varios +órdenes de magnitud y puede pasar a ser el criterio gobernante. + +| Caso (50 kg de ANFO, 30 min, frente de 12 m²) | 0.2.1 | 0.3.0 | +|---|---|---| +| `calculate_blasting_flow`, nivel del mar | 1 m³/min | 4.000 m³/min (dilución de NOx) | +| `VentilationGovernor`, 4.200 msnm, con un scoop de 150 HP | `Q_Exp` = 1; gobierna el diésel; total 981 | `Q_Exp` = 6.751; gobierna la voladura; total 7.764 | + +Quien no declaraba `gas_volume_per_kg` ya obtenía en el Governor el modelo por +contaminante: sus resultados no cambian. + +- `VentilationGovernor` exige ahora sección y longitud del frente para toda + voladura (las toma de `VentilationInput` si la voladura no las trae). Antes, + con `gas_volume_per_kg`, no las necesitaba. +- `calculate_blasting_flow` usa el mismo modelo sin corrección por altitud, + porque no recibe atmósfera, y omite los dos criterios de sección si no se + dan `face_area_m2` y `face_length_m`. Sin `gas_volume_per_kg` también + devolvía 1 m³/min. +- El ejemplo `01-demanda-peru` ya no incluye voladura, para seguir mostrando + el caso gobernado por el diésel. Su README explica el cambio. +- El modelo por contaminante es conservador: diluye todo el CO a 25 ppm y + todo el NOx a 5 ppm dentro del tiempo declarado, con 20 % de margen. ### Metadatos del paquete @@ -12,6 +51,53 @@ Todas las versiones publicadas de VentPy. El formato sigue mismas versiones para las que `cibuildwheel` ya compila ruedas. Así PyPI las muestra en la ficha del paquete. No cambia el código. +### Normas de Colombia y México + +Dos preajustes nuevos, con el mismo patrón que el chileno: cada valor cita el +artículo o numeral leído en el texto vigente, y donde la norma no regula un +concepto la cita lo declara en vez de tomar prestado el artículo de otro país. + +| Concepto | Colombia (Decreto 1886 de 2015) | México (NOM-023-STPS-2012) | +|---|---|---| +| Caudal por persona | 3 m³/min hasta 1.500 msnm y 6 de 1.500 en adelante (Art. 54, nums. 1 y 2) | 1,50 m³/min, sin escala por altitud (numeral 8.4.4 a) 1) | +| Caudal por HP diésel | 6 m³/min con CO en el escape ≤ 0,12 % (Art. 54, par. 2, num. 1) | 2,13 m³/min (numeral 8.4.4 a) 2) | +| Voladura | Sin tiempo de dilución; reingreso no antes de 15 min (Art. 157) | Sin tiempo de dilución; reconocimiento a los 30 min y sin humo (numeral 8.6.9 g) | +| Gases | Tabla del Art. 39; oxígeno en el Art. 38 y metano en el Art. 53 | NOM-010-STPS-2014, Tabla I.1, por remisión de la NOM-023 | +| Polvo | Remite a los VLP de la ACGIH (Art. 74) | NOM-010-STPS-2014, Tabla I.1 | +| Carga térmica | Temperatura efectiva (Arts. 217 y 218): el piso de 30 m/min queda como criterio de ingeniería | Índice TGBH de la NOM-015-STPS-2001: el piso de 30 m/min queda como criterio de ingeniería | + +- `RegulatoryConfig.colombia()` y `RegulatoryConfig.mexico()`, los miembros + `RegulatoryStandard.D1886_Colombia` y `RegulatoryStandard.NOM023_Mexico`, y + `--norma colombia|mexico` en `ventpy demanda`, `ventpy lmp` y + `ventpy cobertura`. +- **Colombia, borde de 1.500 msnm.** Las dos bandas del Art. 54 incluyen el + 1.500 en su texto. Rige la regla común de la librería: a 1.500 msnm exactos + aplica la banda inferior (3 m³/min). +- **Colombia, factor diésel.** El preajuste usa 6 m³/min por HP. Quien acredite + CO en el escape ≤ 0,08 % puede construir su configuración con 4 (Art. 54, + par. 2, num. 2). Con CO sobre 0,12 % el decreto no da valor. +- **México, oxígeno.** La NOM-023 no fija el mínimo de oxígeno en labores. La + tabla trae 19,5 % como criterio de ingeniería y la cita lo dice. +- **México, carbón.** La NOM-023 exceptúa las minas de carbón, que se rigen por + la NOM-032-STPS-2008. Este preajuste no las cubre. +- En las dos tablas de gases el SO2 solo tiene límite de corto tiempo: su + `twa_8h` es `None`, no cero. +- Colombia se verificó contra el texto consolidado con el Decreto 944 de 2022 + y México contra el DOF del 11-oct-2012 y del 28-abr-2014. + +### Correcciones + +- `shock_factors()` listaba `x = 0.0` en expansión, contracción, codos y unión, + donde el factor sale por fórmula o es manual. Un cero se lee como «sin + pérdida»: ahora `x` es `None` en esas filas. + +### Documentación de la API + +- Los campos de `GasLimit` que la norma no fija valen `None`, y la ayuda lo + dice. +- `calculate_diesel_flow` aclara que solo aplica el factor por HP de la norma; + la dilución de CO y NOx por contaminante la calcula `VentilationGovernor`. + ## [0.2.1] — 2026-08-20 ### La cita normativa sigue a la norma configurada diff --git a/CMakeLists.txt b/CMakeLists.txt index 4401584..0332581 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) project(ventpy - VERSION 0.2.1 + VERSION 0.3.0 DESCRIPTION "High-performance mine ventilation calculation library" LANGUAGES CXX ) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27a5ef5..66346c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,9 @@ # Contributing to VentPy · Cómo contribuir a VentPy VentPy is an open-source library for **mine ventilation calculations**. It started with the -Peruvian standard (DS 024-2016-EM) and is growing toward **multi-standard support across Latin -America**: Chile, Colombia and Mexico. +Peruvian standard (DS 024-2016-EM), now also covers Chile (DS 132), Colombia (Decreto 1886) +and Mexico (NOM-023-STPS-2012), and is growing toward **multi-standard support across Latin +America**. > 🇪🇸 **Este documento está en inglés y español.** Baja para la versión en español. > **No necesitas ser programador experto.** Si eres ingeniero de minas y conoces la norma de tu @@ -38,7 +39,7 @@ pip install -e ".[test,viz]" pytest tests/python -q # 4. Create a branch, make your change, run tests again -git checkout -b add-chile-standard +git checkout -b add-bolivia-standard pytest tests/python -q # 5. Commit and open a Pull Request @@ -65,8 +66,8 @@ clear to you, it wasn't clear enough. **No necesitas escribir C++.** En orden de lo que más falta: -1. **Agregar la norma de tu país.** ¿Conoces el reglamento de ventilación de Chile, Colombia o - México? Esa es la contribución que más necesitamos. Mira `ROADMAP.md`. +1. **Agregar la norma de tu país.** ¿Conoces el reglamento de ventilación de Bolivia, Ecuador, + Argentina u otro país? Esa es la contribución que más necesitamos. Mira `ROADMAP.md`. 2. **Casos resueltos.** Un caso real de ventilación (anonimizado) con sus números, como test. 3. **Documentación.** Corregir un párrafo confuso, traducir, agregar un diagrama. 4. **Reportar un número equivocado.** Si un resultado no calza con tu experiencia en campo, @@ -87,7 +88,7 @@ pip install -e ".[test,viz]" pytest tests/python -q # 4. Crea una rama, haz tu cambio, corre los tests otra vez -git checkout -b agregar-norma-chile +git checkout -b agregar-norma-bolivia pytest tests/python -q # 5. Commitea y abre un Pull Request diff --git a/README.es.md b/README.es.md index cbfcfc3..64ca93d 100644 --- a/README.es.md +++ b/README.es.md @@ -13,7 +13,7 @@ Cálculos de ventilación subterránea de alto rendimiento para Python, respaldados por un núcleo en C++20. -VentPy ayuda a ingenieros de minas a estimar la demanda de ventilación en operaciones subterráneas mediante una API auditable que cubre personal, flotas diesel, voladura, polvo, carga térmica, correcciones atmosféricas, diseño de ductos/red y selección de ventilador, actualmente bajo los marcos regulatorios peruano (`DS 024-2016-EM` / `DS 023-2017-EM`) y chileno (`DS 132` / `DS 594`). Los cálculos corren en un núcleo C++ tipado y probado en vez de una hoja de cálculo: cada resultado trae su criterio gobernante y su cita normativa, no solo un número. +VentPy ayuda a ingenieros de minas a estimar la demanda de ventilación en operaciones subterráneas mediante una API auditable que cubre personal, flotas diesel, voladura, polvo, carga térmica, correcciones atmosféricas, diseño de ductos/red y selección de ventilador, actualmente bajo los marcos regulatorios peruano (`DS 024-2016-EM` / `DS 023-2017-EM`), chileno (`DS 132` / `DS 594`), colombiano (`Decreto 1886 de 2015`) y mexicano (`NOM-023-STPS-2012` / `NOM-010-STPS-2014`). Los cálculos corren en un núcleo C++ tipado y probado en vez de una hoja de cálculo: cada resultado trae su criterio gobernante y su cita normativa, no solo un número. ## Qué hace VentPy @@ -33,35 +33,47 @@ VentPy ayuda a ingenieros de minas a estimar la demanda de ventilación en opera ## Soporte multi-norma -VentPy trae preajustes oficiales para dos marcos regulatorios, cada constante citando su artículo: +VentPy trae preajustes oficiales para cuatro marcos regulatorios, cada constante citando su artículo: ```python import ventpy peru = ventpy.calculate_personnel_flow(15, 4200.0, ventpy.RegulatoryConfig.peru()) chile = ventpy.calculate_personnel_flow(15, 4200.0, ventpy.RegulatoryConfig.chile()) +colombia = ventpy.calculate_personnel_flow(15, 4200.0, ventpy.RegulatoryConfig.colombia()) +mexico = ventpy.calculate_personnel_flow(15, 4200.0, ventpy.RegulatoryConfig.mexico()) -print(f"Peru: {peru.q_personnel} m3/min ({peru.flow_per_person_base} m3/min/person)") -print(f"Chile: {chile.q_personnel} m3/min ({chile.flow_per_person_base} m3/min/person)") +print(f"Peru: {peru.q_personnel} m3/min ({peru.flow_per_person_base} m3/min/person)") +print(f"Chile: {chile.q_personnel} m3/min ({chile.flow_per_person_base} m3/min/person)") +print(f"Colombia: {colombia.q_personnel} m3/min ({colombia.flow_per_person_base} m3/min/person)") +print(f"Mexico: {mexico.q_personnel} m3/min ({mexico.flow_per_person_base} m3/min/person)") ``` Salida: ``` -Peru: 90.0 m3/min (6.0 m3/min/person) -Chile: 45.0 m3/min (3.0 m3/min/person) +Peru: 90.0 m3/min (6.0 m3/min/person) +Chile: 45.0 m3/min (3.0 m3/min/person) +Colombia: 90.0 m3/min (6.0 m3/min/person) +Mexico: 23.0 m3/min (1.5 m3/min/person) ``` - `RegulatoryConfig.peru()` — `DS 024-2016-EM` / `DS 023-2017-EM`. - `RegulatoryConfig.chile()` — `DS 132`, Reglamento de Seguridad Minera (los límites de gases también usan el `DS 594`). -- `RegulatoryConfig.for_standard(standard)` — construye cualquiera de los dos preajustes a partir de un miembro del enum `RegulatoryStandard`; es lo que usa internamente el flag `--norma peru|chile` de la CLI. +- `RegulatoryConfig.colombia()` — `Decreto 1886 de 2015`, Reglamento de Seguridad en las Labores Mineras Subterráneas (consolidado con el `Decreto 944 de 2022`). Un solo escalón de altitud a los 1.500 msnm; 6 m³/min por HP, o 4 si el CO en el escape no pasa de 0,08 %, caso en que se construye una configuración propia. +- `RegulatoryConfig.mexico()` — `NOM-023-STPS-2012`, para minas subterráneas que no son de carbón. No escala por altitud; los límites de gases llegan por remisión a la `NOM-010-STPS-2014`. Las minas de carbón se rigen por la `NOM-032-STPS-2008`, que este preajuste no cubre. +- `RegulatoryConfig.for_standard(standard)` — construye cualquiera de los cuatro preajustes a partir de un miembro del enum `RegulatoryStandard`; es lo que usa internamente el flag `--norma` de la CLI. + +Donde una norma no regula un concepto (tiempo de dilución tras la voladura, fugas en ductos, velocidad mínima fuera del carbón en Colombia, oxígeno mínimo en labores en México), la cita impresa lo declara y atribuye el valor a criterio de ingeniería. Agregar la norma de otro país es la contribución que más necesita VentPy — ver [Contribuir](#contribuir). ## Estado del proyecto -VentPy se encuentra en **beta**. Los cálculos de personal, diesel, voladura, polvo, térmico, cobertura, red, ventilador y atmósfera están implementados, probados (219 pruebas en C++ y 176 en Python, incluidas pruebas basadas en propiedades y de escala hasta 500 ramales) y expuestos a Python para ambos preajustes. +VentPy se encuentra en **beta**. Los cálculos de personal, diesel, voladura, polvo, térmico, cobertura, red, ventilador y atmósfera están implementados, probados (245 pruebas en C++ y 199 en Python, incluidas pruebas basadas en propiedades y de escala hasta 500 ramales) y expuestos a Python para los cuatro preajustes. +> **En la 0.3.0 cambia el caudal de voladura.** Hasta la 0.2.1, declarar `gas_volume_per_kg` hacía que la voladura se calculara como volumen de gases entre tiempo de dilución, sin llevarlo a un límite de exposición: el resultado era del orden de 1 m³/min. Desde la 0.3.0 la voladura se calcula siempre por dilución de CO y NOx, y `gas_volume_per_kg` solo fija el volumen de gases que se reporta. Si calculaste una demanda con voladura y ese parámetro, recalcúlala: el caudal de voladura sube varios órdenes de magnitud y puede pasar a gobernar. Detalle en el [`CHANGELOG.md`](https://github.com/Miqueas7/VentPy/blob/master/CHANGELOG.md). +> > **En la 0.2.1 cambian las citas, no los números.** Hasta la 0.2.0, todo resultado citaba el marco peruano aunque se construyera con `RegulatoryConfig.chile()`: el caudal era correcto, pero la referencia legal impresa junto a él no. Desde la 0.2.1 la cita sigue a la norma configurada, y donde el `DS 132` no regula un concepto, la cita lo declara explícitamente en vez de tomar prestado un artículo peruano. Si emitiste informes con el preajuste chileno, conviene reemitirlos. > **Los resultados cambian entre 0.1.0 y 0.2.0.** La versión 0.2.0 corrige tres citas normativas del núcleo. Si calculaste algo con 0.1.0, conviene rehacerlo, especialmente por encima de 1.500 msnm. @@ -118,9 +130,9 @@ Cinco ejemplos resueltos y auto-verificables (cada `run.py` afirma sus propios n Instalar VentPy también instala el comando `ventpy`, una capa de presentación delgada sobre la misma API pública (sin lógica de cálculo propia) con 5 subcomandos: -- `ventpy demanda [--norma peru|chile] [--json]` — demanda total de ventilación de una zona/frente -- `ventpy lmp [--norma peru|chile] [--gas GAS] [--json]` — límites máximos permisibles (LMP) de gases regulados -- `ventpy cobertura [--norma peru|chile] [--json]` — análisis de déficit/cobertura de un levantamiento de zonas +- `ventpy demanda [--norma peru|chile|colombia|mexico] [--json]` — demanda total de ventilación de una zona/frente +- `ventpy lmp [--norma peru|chile|colombia|mexico] [--gas GAS] [--json]` — límites máximos permisibles (LMP) de gases regulados +- `ventpy cobertura [--norma peru|chile|colombia|mexico] [--json]` — análisis de déficit/cobertura de un levantamiento de zonas - `ventpy red [--json]` — balance de una red de ventilación (Hardy Cross) - `ventpy ventilador [--json]` — punto de operación de un ventilador (independiente o acoplado a una red) @@ -142,7 +154,7 @@ regulation_ref: DS 024-2016-EM, Anexo 15, fila 33 (via Art. 246) VentPy expone 87 nombres públicos (`ventpy.__all__`). Puntos de entrada principales: -- `RegulatoryConfig` (`.peru()`, `.chile()`, `.for_standard(...)`) +- `RegulatoryConfig` (`.peru()`, `.chile()`, `.colombia()`, `.mexico()`, `.for_standard(...)`) - `VentilationGovernor`, `VentilationInput` - `DieselFleet`, `AtmosphericParams`, `PersonnelParams`, `BlastingParams`, `DustParams`, `ThermalParams` diff --git a/README.md b/README.md index 4918bd4..d07ba0b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ High-performance underground mine ventilation calculations for Python, backed by a C++20 core. -VentPy helps mining engineers estimate ventilation demand for underground operations through an auditable API covering personnel, diesel fleets, blasting, dust, thermal load, atmospheric corrections, duct/network design and fan selection, currently under the Peruvian (`DS 024-2016-EM` / `DS 023-2017-EM`) and Chilean (`DS 132` / `DS 594`) regulatory frameworks. Calculations run in a typed, tested C++ core instead of a spreadsheet: every result carries its governing criterion and its regulatory citation, not just a number. +VentPy helps mining engineers estimate ventilation demand for underground operations through an auditable API covering personnel, diesel fleets, blasting, dust, thermal load, atmospheric corrections, duct/network design and fan selection, currently under the Peruvian (`DS 024-2016-EM` / `DS 023-2017-EM`), Chilean (`DS 132` / `DS 594`), Colombian (`Decreto 1886 de 2015`) and Mexican (`NOM-023-STPS-2012` / `NOM-010-STPS-2014`) regulatory frameworks. Calculations run in a typed, tested C++ core instead of a spreadsheet: every result carries its governing criterion and its regulatory citation, not just a number. ## What VentPy Does @@ -33,35 +33,47 @@ VentPy helps mining engineers estimate ventilation demand for underground operat ## Multi-Standard Support -VentPy ships official presets for two regulatory frameworks, each constant citing its article: +VentPy ships official presets for four regulatory frameworks, each constant citing its article: ```python import ventpy peru = ventpy.calculate_personnel_flow(15, 4200.0, ventpy.RegulatoryConfig.peru()) chile = ventpy.calculate_personnel_flow(15, 4200.0, ventpy.RegulatoryConfig.chile()) +colombia = ventpy.calculate_personnel_flow(15, 4200.0, ventpy.RegulatoryConfig.colombia()) +mexico = ventpy.calculate_personnel_flow(15, 4200.0, ventpy.RegulatoryConfig.mexico()) -print(f"Peru: {peru.q_personnel} m3/min ({peru.flow_per_person_base} m3/min/person)") -print(f"Chile: {chile.q_personnel} m3/min ({chile.flow_per_person_base} m3/min/person)") +print(f"Peru: {peru.q_personnel} m3/min ({peru.flow_per_person_base} m3/min/person)") +print(f"Chile: {chile.q_personnel} m3/min ({chile.flow_per_person_base} m3/min/person)") +print(f"Colombia: {colombia.q_personnel} m3/min ({colombia.flow_per_person_base} m3/min/person)") +print(f"Mexico: {mexico.q_personnel} m3/min ({mexico.flow_per_person_base} m3/min/person)") ``` Output: ``` -Peru: 90.0 m3/min (6.0 m3/min/person) -Chile: 45.0 m3/min (3.0 m3/min/person) +Peru: 90.0 m3/min (6.0 m3/min/person) +Chile: 45.0 m3/min (3.0 m3/min/person) +Colombia: 90.0 m3/min (6.0 m3/min/person) +Mexico: 23.0 m3/min (1.5 m3/min/person) ``` - `RegulatoryConfig.peru()` — `DS 024-2016-EM` / `DS 023-2017-EM`. - `RegulatoryConfig.chile()` — `DS 132`, Reglamento de Seguridad Minera (gas limits also draw on `DS 594`). -- `RegulatoryConfig.for_standard(standard)` — builds either preset from a `RegulatoryStandard` enum member; used internally by the CLI's `--norma peru|chile` flag. +- `RegulatoryConfig.colombia()` — `Decreto 1886 de 2015`, Reglamento de Seguridad en las Labores Mineras Subterráneas (as amended by `Decreto 944 de 2022`). A single altitude step at 1,500 masl; 6 m³/min per HP, or 4 when exhaust CO does not exceed 0.08%, in which case you build your own configuration. +- `RegulatoryConfig.mexico()` — `NOM-023-STPS-2012`, for underground mines other than coal. No altitude scale; gas limits come by reference from `NOM-010-STPS-2014`. Coal mines fall under `NOM-032-STPS-2008`, which this preset does not cover. +- `RegulatoryConfig.for_standard(standard)` — builds any of the four presets from a `RegulatoryStandard` enum member; used internally by the CLI's `--norma` flag. + +Where a standard does not regulate a concept (post-blast dilution time, duct leakage, minimum velocity outside coal mines in Colombia, minimum oxygen in workings in Mexico), the printed citation says so and attributes the value to engineering criteria. Adding another country's standard is the contribution VentPy needs most — see [Contributing](#contributing). ## Project Status -VentPy is in **beta**. Personnel, diesel, blasting, dust, thermal, coverage, network, fan and atmospheric calculations are implemented, tested (219 C++ tests and 176 Python tests, including property-based and 500-branch scale tests) and exposed to Python for both presets. +VentPy is in **beta**. Personnel, diesel, blasting, dust, thermal, coverage, network, fan and atmospheric calculations are implemented, tested (245 C++ tests and 199 Python tests, including property-based and 500-branch scale tests) and exposed to Python for all four presets. +> **Blasting airflow changed in 0.3.0.** Until 0.2.1, setting `gas_volume_per_kg` made blasting airflow equal to gas volume divided by dilution time, never diluted to an exposure limit: the result was in the order of 1 m³/min. From 0.3.0 blasting is always computed by CO and NOx dilution, and `gas_volume_per_kg` only sets the reported gas volume. If you computed a demand with blasting and that parameter, recompute it: blasting airflow rises by several orders of magnitude and may become the governing factor. Details in [`CHANGELOG.md`](https://github.com/Miqueas7/VentPy/blob/master/CHANGELOG.md). +> > **Citations changed in 0.2.1 — the numbers did not.** Until 0.2.0, every result cited the Peruvian framework even when built with `RegulatoryConfig.chile()`: the airflow was correct, the legal reference printed next to it was not. From 0.2.1 the citation follows the configured standard, and where `DS 132` does not regulate a concept the citation says so explicitly instead of borrowing a Peruvian article. If you issued reports using the Chilean preset, reissue them. > **Results changed between 0.1.0 and 0.2.0.** Version 0.2.0 corrects three normative citations in the core. If you calculated anything with 0.1.0, recompute it, especially above 1,500 masl. @@ -118,9 +130,9 @@ Five worked, self-verifying examples (each `run.py` asserts its own documented n Installing VentPy also installs the `ventpy` command, a thin presentation layer over the same public API (no calculation logic of its own) with 5 subcommands: -- `ventpy demanda [--norma peru|chile] [--json]` — total ventilation demand for a zone/face -- `ventpy lmp [--norma peru|chile] [--gas GAS] [--json]` — permissible exposure limits (LMP) for regulated gases -- `ventpy cobertura [--norma peru|chile] [--json]` — coverage/deficit analysis of a zone survey +- `ventpy demanda [--norma peru|chile|colombia|mexico] [--json]` — total ventilation demand for a zone/face +- `ventpy lmp [--norma peru|chile|colombia|mexico] [--gas GAS] [--json]` — permissible exposure limits (LMP) for regulated gases +- `ventpy cobertura [--norma peru|chile|colombia|mexico] [--json]` — coverage/deficit analysis of a zone survey - `ventpy red [--json]` — ventilation network balance (Hardy Cross) - `ventpy ventilador [--json]` — fan operating point (standalone or coupled to a network) @@ -142,7 +154,7 @@ regulation_ref: DS 024-2016-EM, Anexo 15, fila 33 (via Art. 246) VentPy exposes 87 public names (`ventpy.__all__`). Main entry points: -- `RegulatoryConfig` (`.peru()`, `.chile()`, `.for_standard(...)`) +- `RegulatoryConfig` (`.peru()`, `.chile()`, `.colombia()`, `.mexico()`, `.for_standard(...)`) - `VentilationGovernor`, `VentilationInput` - `DieselFleet`, `AtmosphericParams`, `PersonnelParams`, `BlastingParams`, `DustParams`, `ThermalParams` diff --git a/ROADMAP.md b/ROADMAP.md index 73df3a0..87635f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,8 @@ # Roadmap · VentPy -VentPy nació con la norma peruana (**DS 024-2016-EM**). El objetivo es que sirva en toda -Latinoamérica y cubra el cálculo de ventilación completo. +VentPy nació con la norma peruana (**DS 024-2016-EM**), desde la 0.2.0 incluye la chilena +(**DS 132**) y desde la 0.3.0 la colombiana y la mexicana. El objetivo es que sirva en +toda Latinoamérica y cubra el cálculo de ventilación completo. > **¿Quieres contribuir?** Lee [`CONTRIBUTING.md`](CONTRIBUTING.md). Las tareas marcadas > **`buena primera tarea`** están pensadas para quien nunca ha contribuido a open source: @@ -9,47 +10,59 @@ Latinoamérica y cubra el cálculo de ventilación completo. --- -## 1. Multi-norma — la prioridad +## 1. Multi-norma: la prioridad -Hoy las constantes normativas están fijas a Perú. El diseño objetivo es un -`RegulatoryConfig` por país, seleccionable, con cada valor citando su artículo. +Cada país es un preajuste de `RegulatoryConfig` (`peru()`, `chile()`, `colombia()`, +`mexico()`), seleccionable con `for_standard(...)` o con `--norma` en la línea de comandos. +Cada valor cita su artículo y la cita impresa en el resultado sigue a la norma configurada. | País | Norma | Estado | Etiqueta | |---|---|---|---| -| 🇵🇪 Perú | DS 024-2016-EM | ✅ implementado | — | -| 🇨🇱 Chile | DS 132 · Reglamento de Seguridad Minera | ⬜ **abierto** | `buena primera tarea` | -| 🇨🇴 Colombia | Decreto 1886 de 2015 | ⬜ **abierto** | `buena primera tarea` | -| 🇲🇽 México | NOM-023-STPS-2012 | ⬜ **abierto** | `buena primera tarea` | +| 🇵🇪 Perú | DS 024-2016-EM / DS 023-2017-EM | ✅ implementado | | +| 🇨🇱 Chile | DS 132 · Reglamento de Seguridad Minera (gases: DS 594) | ✅ implementado (0.2.0) | | +| 🇨🇴 Colombia | Decreto 1886 de 2015 (mod. Decreto 944 de 2022) | ✅ implementado (0.3.0) | | +| 🇲🇽 México | NOM-023-STPS-2012 (gases: NOM-010-STPS-2014) | ✅ implementado (0.3.0) | | +| 🇲🇽 México, minas de carbón | NOM-032-STPS-2008 (con proyecto de reemplazo en consulta) | ⬜ **abierto** | | +| Otros países | Bolivia, Ecuador, Argentina, Brasil… | ⬜ **abierto** | `buena primera tarea` | -**Qué implica agregar una norma** (esto es todo — no hay que tocar C++ avanzado): +Ningún valor entra al código antes de que su tabla valor → artículo → cita esté validada +contra el texto vigente. Si conoces la norma de tu país, armar o revisar esa tabla es la +ayuda que más sirve. + +**Qué implica agregar una norma** (los preajustes existentes son el modelo a copiar): 1. Ubicar en el reglamento los valores de: caudal mínimo por persona, factor por equipo - diésel (m³/min por HP o kW), tiempo/caudal de dilución post-voladura, velocidades - mínimas y máximas permitidas, y factor de fugas. -2. Añadirlos como un preset de configuración, **citando el artículo de cada valor**. + diésel (m³/min por HP o kW), tiempo o caudal de dilución tras la voladura, velocidades + mínimas y máximas permitidas, factor de fugas y límites de gases. +2. Añadirlos como un preajuste en `normativa.hpp` y su tabla de gases en `limites_gases.hpp`, + **citando el artículo de cada valor**. Si la norma no regula un concepto, la cita lo dice; + no se toma prestado el artículo de otro país. 3. Escribir un test con un caso conocido de ese país. -Si no sabes programar en C++, **abre un issue con los valores y la cita del artículo** — eso +Si no sabes programar en C++, **abre un issue con los valores y la cita del artículo**: eso ya es la mitad del trabajo y alguien más lo integra. --- -## 2. Módulos por madurar +## 2. Módulos | Módulo | Estado | |---|---| | Personal, diésel, voladura, atmósfera, fugas, gobernador | ✅ maduro | -| **Polvo** (`dust`) | 🟡 modelo de resultado existe, implementación por madurar | -| **Térmico** (`thermal`) | 🟡 igual que polvo | +| Polvo y térmico | ✅ implementados (0.2.0). Fuera de Perú la cita declara qué fija la norma y qué queda como criterio de ingeniería | +| Calor por temperatura efectiva (Colombia) o índice TGBH (México) | ⬜ abierto: hoy solo se cita | +| Cobertura y déficit (caudal medido frente al requerido) | ✅ implementado (0.2.0) | +| Resistencia de Atkinson, ducto, red por Hardy Cross, ventilador | ✅ implementado (0.2.0) | +| Límites de gases por norma | ✅ implementado (0.2.0) | --- ## 3. Buenas primeras tareas (no normativas) -- ⬜ **Ejemplos resueltos** en la documentación: un caso de mina superficial y uno subterránea, - con sus números, ejecutables como test. `buena primera tarea` -- ⬜ **Traducir el README** a inglés técnico revisado / portugués. `buena primera tarea` -- ⬜ **Interfaz de línea de comandos** (`ventpy calcular --personal 20 --diesel 300`). +- ✅ **Ejemplos resueltos**: cinco casos en [`examples/`](examples/), cada uno verifica sus + propios números. Falta todavía un caso con datos de campo. `buena primera tarea` +- ✅ **Interfaz de línea de comandos**: `ventpy demanda`, `ventpy lmp`, `ventpy cobertura` y más. +- ⬜ **Traducir el README** al portugués (ya está en español e inglés). `buena primera tarea` - ⬜ **Gráficos**: mejorar `visualization` con un diagrama de red de ventilación. - ⬜ **Reportar un número que no calce** con tu experiencia de campo (¡con el valor esperado!). `buena primera tarea` diff --git a/pyproject.toml b/pyproject.toml index b19662a..61355f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "ventpy" -version = "0.2.1" +version = "0.3.0" description = "High-performance mine ventilation library: airflow demand, coverage, network solving and fan selection (Peru DS 024, Chile DS 132)" readme = "README.md" license = "MIT" diff --git a/python/ventpy/__init__.py b/python/ventpy/__init__.py index dcdc2b0..5d827f4 100644 --- a/python/ventpy/__init__.py +++ b/python/ventpy/__init__.py @@ -167,7 +167,7 @@ def __getattr__(name): raise AttributeError(f"module 'ventpy' has no attribute '{name}'") -__version__ = "0.2.1" +__version__ = "0.3.0" __all__ = [ # Version