From 9ee543f382b1ff92c76dd648ea6f92bc5c2d6d6d Mon Sep 17 00:00:00 2001 From: Felix Oesterle Date: Wed, 10 Dec 2025 12:31:46 +0100 Subject: [PATCH 1/5] feat(com1DFA): refine tSteps handling for timestep exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export initial timestep (t=0) only if explicitly specified in `tSteps`. - Updated documentation to reflect changes in timestep export behavior. - Added tests to ensure correct handling of `tSteps` for both empty and explicit configurations. - Updated default behavior to export only final timestep when `tSteps` is empty. 1. avaframe/com1DFA/com1DFACfg.ini:16-20 - Changed default from tSteps = 1 to tSteps = (empty) - Updated comments to reflect new behavior 2. avaframe/in3Utils/fileHandlerUtils.py:387-389 - Removed filtering that excluded t=0 from dtSave array 3. avaframe/com1DFA/com1DFA.py:2097 - Made initial timestep export conditional: only exports if t=0 is in dtSave 4. avaframe/com1DFA/com1DFA.py:2131-2134 - Made Tsave initialization conditional based on whether t=0 is exported 5. docs/moduleCom1DFA.rst:258 - Updated documentation: "initial time step" → "final time step" to reflect new default --- avaframe/com1DFA/com1DFA.py | 11 ++-- avaframe/com1DFA/com1DFACfg.ini | 8 +-- avaframe/in3Utils/fileHandlerUtils.py | 3 - avaframe/tests/test_com1DFA.py | 88 +++++++++++++++++++++++++ avaframe/tests/test_fileHandlerUtils.py | 11 ++-- docs/moduleCom1DFA.rst | 2 +- 6 files changed, 107 insertions(+), 16 deletions(-) diff --git a/avaframe/com1DFA/com1DFA.py b/avaframe/com1DFA/com1DFA.py index 9285a99b7..2c45c6c46 100644 --- a/avaframe/com1DFA/com1DFA.py +++ b/avaframe/com1DFA/com1DFA.py @@ -2094,8 +2094,8 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si t = particles["t"] log.debug("Saving results for time step t = %f s", t) - # export initial time step - if cfg["EXPORTS"].getboolean("exportData"): + # export initial time step only if t=0 is explicitly in dtSave + if cfg["EXPORTS"].getboolean("exportData") and (dtSave.size > 0 and dtSave[0] <= 1.0e-8): exportFields(cfg, t, fields, dem, outDir, cuSimName, TSave="initial") if "particles" in resTypes: @@ -2128,8 +2128,11 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si ) cfgRangeTime["GENERAL"]["simHash"] = simHash - # add initial time step to Tsave array - Tsave = [0] + # add initial time step to Tsave array only if it was exported + if dtSave.size > 0 and dtSave[0] <= 1.0e-8: + Tsave = [0] + else: + Tsave = [] # derive time step for first iteration if cfgGen.getboolean("sphKernelRadiusTimeStepping"): dtSPHKR = tD.getSphKernelRadiusTimeStep(dem, cfgGen) diff --git a/avaframe/com1DFA/com1DFACfg.ini b/avaframe/com1DFA/com1DFACfg.ini index c9e9cd5e9..e5e316845 100644 --- a/avaframe/com1DFA/com1DFACfg.ini +++ b/avaframe/com1DFA/com1DFACfg.ini @@ -13,11 +13,11 @@ modelType = dfa #+++++++++++++ Output++++++++++++ # desired result Parameters (ppr, pft, pfv, pta, FT, FV, P, FM, Vx, Vy, Vz, TA, dmDet, sfcChange, demAdapted, timeInfo, particles) - separated by | resType = ppr|pft|pfv|timeInfo -# saving time step, i.e. time in seconds (first and last time step are always saved) +# saving time step, i.e. time in seconds (last time step is always saved; initial time step only if explicitly specified) # option 1: give an interval with start:interval in seconds (tStep = 0:5 - this will save desired results every 5 seconds for the full simulation) -# option 2: explicitly list all desired time steps (closest to actual computational time step) separated by | (example tSteps = 1|50.2|100) -# NOTE: initial and last time step are always saved! -tSteps = 1 +# option 2: explicitly list all desired time steps (closest to actual computational time step) separated by | (example tSteps = 1|50.2|100 or tSteps = 0|5|10 to include initial timestep) +# NOTE: last time step is always saved! Initial timestep (t=0) is only saved if explicitly included in tSteps +tSteps = #++++++++++++++++ particle Initialisation +++++++++ # initial particle distribution, options: random, semirandom, uniform, triangular diff --git a/avaframe/in3Utils/fileHandlerUtils.py b/avaframe/in3Utils/fileHandlerUtils.py index 1f217df5e..1c1b76b71 100644 --- a/avaframe/in3Utils/fileHandlerUtils.py +++ b/avaframe/in3Utils/fileHandlerUtils.py @@ -384,9 +384,6 @@ def splitTimeValueToArrayInterval(cfgValues, endTime): items = np.array(itemsL, dtype=float) items = np.sort(items) - # make sure that 0 is not in the array (initial time step is any ways saved) - if items[0] == 0: - items = np.delete(items, 0) # make sure the array is not empty # ToDo : make it work without this arbitrary 2*timeEnd if items.size == 0: diff --git a/avaframe/tests/test_com1DFA.py b/avaframe/tests/test_com1DFA.py index ee2fa9713..ab37ac7d4 100644 --- a/avaframe/tests/test_com1DFA.py +++ b/avaframe/tests/test_com1DFA.py @@ -2917,3 +2917,91 @@ def test_adaptDEM(): assert np.any(dem["areaRaster"] != demAdapted["areaRaster"]) assert np.all(fieldsAdapted["sfcChange"] == fields["FTDet"] / NzNormed) assert np.all(fieldsAdapted["sfcChangeTotal"] == fields["FTDet"] / NzNormed) + + +def test_tSteps_output_behavior(tmp_path): + """Test that tSteps controls which timesteps are exported correctly. + + New behavior: + - Empty tSteps (default): only final timestep is exported + - Explicit tSteps with t=0: t=0 timestep is exported + """ + testDir = pathlib.Path(__file__).parents[0] + inputDir = testDir / "data" / "testCom1DFA" + + # Test 1: Empty tSteps should only export final timestep + avaDir1 = pathlib.Path(tmp_path, "testEmptyTSteps") + shutil.copytree(inputDir, avaDir1) + cfgFile1 = avaDir1 / "test_com1DFACfg.ini" + + # Modify config to have empty tSteps and NO parameter variations + cfg = configparser.ConfigParser() + cfg.read(cfgFile1) + cfg["GENERAL"]["tSteps"] = "" + cfg["GENERAL"]["tEnd"] = "10" # Short simulation + cfg["GENERAL"]["dt"] = "0.1" # Single value, no variations + cfg["GENERAL"]["simTypeList"] = "null" # Simple simulation, no entrainment/resistance + # Ensure exports are enabled + if "EXPORTS" not in cfg: + cfg["EXPORTS"] = {} + cfg["EXPORTS"]["exportData"] = "True" + with open(cfgFile1, "w") as f: + cfg.write(f) + + cfgMain1 = configparser.ConfigParser() + cfgMain1["MAIN"] = {"avalancheDir": str(avaDir1), "nCPU": "1"} + cfgMain1["FLAGS"] = { + "showPlot": "False", + "savePlot": "False", + "ReportDir": "False", + "reportOneFile": "False", + "debugPlot": "False", + } + + dem, plotDict, reportDictList, simDF = com1DFA.com1DFAMain(cfgMain1, cfgInfo=cfgFile1) + + # Check that only final timestep files exist in timeSteps directory + timeStepsDir1 = avaDir1 / "Outputs" / "com1DFA" / "peakFiles" / "timeSteps" + if timeStepsDir1.exists(): + tStepFiles1 = list(timeStepsDir1.glob("*.asc")) + # Should only have final timestep files (one per result type: ppr, pft, pfv) + # Not initial timestep at t=0 + for tFile in tStepFiles1: + assert "_t0.0" not in tFile.stem, f"Found initial timestep file {tFile} but tSteps was empty" + + # Test 2: Explicit tSteps with t=0 should export t=0 timestep + avaDir2 = pathlib.Path(tmp_path, "testExplicitTSteps") + shutil.copytree(inputDir, avaDir2) + cfgFile2 = avaDir2 / "test_com1DFACfg.ini" + + # Modify config to have explicit tSteps including t=0 and NO parameter variations + cfg2 = configparser.ConfigParser() + cfg2.read(cfgFile2) + cfg2["GENERAL"]["tSteps"] = "0|5" + cfg2["GENERAL"]["tEnd"] = "10" # Short simulation + cfg2["GENERAL"]["dt"] = "0.1" # Single value, no variations + cfg2["GENERAL"]["simTypeList"] = "null" # Simple simulation, no entrainment/resistance + # Ensure exports are enabled + if "EXPORTS" not in cfg2: + cfg2["EXPORTS"] = {} + cfg2["EXPORTS"]["exportData"] = "True" + with open(cfgFile2, "w") as f: + cfg2.write(f) + + cfgMain2 = configparser.ConfigParser() + cfgMain2["MAIN"] = {"avalancheDir": str(avaDir2), "nCPU": "1"} + cfgMain2["FLAGS"] = { + "showPlot": "False", + "savePlot": "False", + "ReportDir": "False", + "reportOneFile": "False", + "debugPlot": "False", + } + + dem2, plotDict2, reportDictList2, simDF2 = com1DFA.com1DFAMain(cfgMain2, cfgInfo=cfgFile2) + + # Check that t=0 timestep files exist + timeStepsDir2 = avaDir2 / "Outputs" / "com1DFA" / "peakFiles" / "timeSteps" + assert timeStepsDir2.exists(), "timeSteps directory should exist" + tStepFiles2 = list(timeStepsDir2.glob("*_t0.0*.asc")) + assert len(tStepFiles2) > 0, "Should have initial timestep files at t=0 when tSteps includes 0" diff --git a/avaframe/tests/test_fileHandlerUtils.py b/avaframe/tests/test_fileHandlerUtils.py index c2a14e933..3f129a82e 100644 --- a/avaframe/tests/test_fileHandlerUtils.py +++ b/avaframe/tests/test_fileHandlerUtils.py @@ -283,19 +283,19 @@ def test_splitTimeValueToArrayInterval(): cfgValuesList = np.asarray([1.0, 2.5, 3.8]) cfgValues1 = '0.|2.5|3.8' - cfgValuesList1 = np.asarray([2.5, 3.8]) + cfgValuesList1 = np.asarray([0., 2.5, 3.8]) cfgValues2 = '0:5' - cfgValuesList2 = np.asarray([5., 10., 15.]) + cfgValuesList2 = np.asarray([0., 5., 10., 15.]) cfgValues3 = '' cfgValuesList3 = np.asarray([40.]) cfgValues4 = '0:22' - cfgValuesList4 = np.asarray([20.]) + cfgValuesList4 = np.asarray([0., 20.]) cfgValues5 = '0' - cfgValuesList5 = np.asarray([40.]) + cfgValuesList5 = np.asarray([0.]) cfg = configparser.ConfigParser() cfg['GENERAL'] = {'tEnd': '20'} @@ -322,12 +322,15 @@ def test_splitTimeValueToArrayInterval(): assert len(items1) == len(cfgValuesList1) assert items1[0] == cfgValuesList1[0] assert items1[1] == cfgValuesList1[1] + assert items1[2] == cfgValuesList1[2] assert len(items2) == len(cfgValuesList2) assert items2[0] == cfgValuesList2[0] assert items2[1] == cfgValuesList2[1] assert items2[2] == cfgValuesList2[2] + assert items2[3] == cfgValuesList2[3] assert len(items4) == len(cfgValuesList4) assert items4[0] == cfgValuesList4[0] + assert items4[1] == cfgValuesList4[1] assert len(items5) == len(cfgValuesList5) assert items5[0] == cfgValuesList5[0] diff --git a/docs/moduleCom1DFA.rst b/docs/moduleCom1DFA.rst index 77cb853c8..594d48db2 100644 --- a/docs/moduleCom1DFA.rst +++ b/docs/moduleCom1DFA.rst @@ -256,7 +256,7 @@ Output Using the default configuration, the simulation results are saved to: *Outputs/com1DFA* and include: * raster files of the peak values for pressure, flow thickness and flow velocity (*Outputs/com1DFA/peakFiles*) -* raster files of the peak values for pressure, flow thickness and flow velocity for the initial time step (*Outputs/com1DFA/peakFiles/timeSteps*) +* raster files of the peak values for pressure, flow thickness and flow velocity for the final time step (*Outputs/com1DFA/peakFiles/timeSteps*) * markdown report including figures for all simulations (*Outputs/com1DFA/reports*) - if a ``_cropshape.shp`` file provided in Inputs/POLYGONS, plots are cropped to the rectangular bounds of the polygon - if ``showOnlineBackground = True`` in avaFrameCfg.ini and a suitable ``mapProvider`` is set, peak fields are plotted onto the corresponding map From 7ca099b331aec1d3320e5ec272a57b9464c049f9 Mon Sep 17 00:00:00 2001 From: Felix Oesterle Date: Wed, 10 Dec 2025 14:37:22 +0100 Subject: [PATCH 2/5] refactor(com1DFA): remove `plotFields` configuration for reports; fix bug timestep saving - Removed `plotFields` from all configuration files and corresponding test cases. - Simplified logic to only use `resType` for determining result parameters across all time steps. - Updated export function to streamline behavior by treating all `resType` fields equally across all time steps. - Adjusted tests to reflect the removal of `plotFields` and ensure consistency. Fixed critical bug in timestep saving logic: - Bug: After saving initial timestep at t=0, dtSave array wasn't updated, causing spurious save at t=dt - Fix: Added dtSave = updateSavingTimeStep(dtSave, cfgGen, t) after initial save - This eliminates the extra timestep that was appearing (e.g., t=0, t=0.1, t=10, t=20...) --- avaframe/com1DFA/com1DFA.py | 21 ++++-------- avaframe/com1DFA/com1DFACfg.ini | 2 -- avaframe/com3Hybrid/com3HybridCfg.ini | 3 -- .../com6RockAvalancheCfg.ini | 3 -- avaframe/tests/test_com1DFA.py | 32 ++++++++++++------- 5 files changed, 28 insertions(+), 33 deletions(-) diff --git a/avaframe/com1DFA/com1DFA.py b/avaframe/com1DFA/com1DFA.py index 2c45c6c46..c1816c379 100644 --- a/avaframe/com1DFA/com1DFA.py +++ b/avaframe/com1DFA/com1DFA.py @@ -1658,8 +1658,7 @@ def initializeFields(cfg, dem, particles, releaseLine): cfgGen = cfg["GENERAL"] # what result types are desired as output (we need this to decide which fields we actually need to compute) resTypes = fU.splitIniValueToArraySteps(cfgGen["resType"]) - resTypesReport = fU.splitIniValueToArraySteps(cfg["REPORT"]["plotFields"]) - resTypesLast = list(set(resTypes + resTypesReport)) + resTypesLast = resTypes # read dem header header = dem["header"] ncols = header["ncols"] @@ -2038,10 +2037,8 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si # add particles to the results type if trackParticles option is activated if cfg.getboolean("TRACKPARTICLES", "trackParticles"): resTypes = list(set(resTypes + ["particles"])) - # make sure to save all desiered resuts for first and last time step for - # the report - resTypesReport = fU.splitIniValueToArraySteps(cfg["REPORT"]["plotFields"]) - resTypesLast = list(set(resTypes + resTypesReport)) + # use resTypes for all time steps + resTypesLast = resTypes # derive friction type # turn friction model into integer frictModelsList = [ @@ -2103,6 +2100,9 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si fU.makeADir(outDirData) savePartToPickle(particles, outDirData, cuSimName) + # Update dtSave to remove the initial timestep we just saved + dtSave = updateSavingTimeStep(dtSave, cfgGen, t) + # export particles properties for visulation if cfg["VISUALISATION"].getboolean("writePartToCSV"): particleTools.savePartToCsv( @@ -2976,17 +2976,10 @@ def exportFields( """ resTypesGen = fU.splitIniValueToArraySteps(cfg["GENERAL"]["resType"]) - resTypesReport = fU.splitIniValueToArraySteps(cfg["REPORT"]["plotFields"]) if "particles" in resTypesGen: resTypesGen.remove("particles") - if "particles" in resTypesReport: - resTypesReport.remove("particles") - if TSave == "final" or TSave == "initial": - # for last time step we need to add the report fields - resTypes = list(set(resTypesGen + resTypesReport)) - else: - resTypes = resTypesGen + resTypes = resTypesGen if resTypesForced != []: resTypes = resTypesForced diff --git a/avaframe/com1DFA/com1DFACfg.ini b/avaframe/com1DFA/com1DFACfg.ini index e5e316845..86fca5b61 100644 --- a/avaframe/com1DFA/com1DFACfg.ini +++ b/avaframe/com1DFA/com1DFACfg.ini @@ -546,8 +546,6 @@ thFromIni = [REPORT] -# which result parameters shall be included as plots in report - separated by | -plotFields = ppr|pft|pfv # units for output variables unitppr = kPa unitpft = m diff --git a/avaframe/com3Hybrid/com3HybridCfg.ini b/avaframe/com3Hybrid/com3HybridCfg.ini index 31983cb54..032be1938 100644 --- a/avaframe/com3Hybrid/com3HybridCfg.ini +++ b/avaframe/com3Hybrid/com3HybridCfg.ini @@ -97,9 +97,6 @@ frictModel = Coulomb # tan of bed friction angle used for: samosAT, Coulomb, Voellmy mucoulomb = 0.4 -# which result parameters shall be included as plots in report, - separated by | -plotFields = ppr|pft|pfv|TA|pta - [com2AB_com2AB_override] # use default com2AB config as base configuration (True) and override following parameters diff --git a/avaframe/com6RockAvalanche/com6RockAvalancheCfg.ini b/avaframe/com6RockAvalanche/com6RockAvalancheCfg.ini index 1ccf70d93..852e8fc12 100644 --- a/avaframe/com6RockAvalanche/com6RockAvalancheCfg.ini +++ b/avaframe/com6RockAvalanche/com6RockAvalancheCfg.ini @@ -48,6 +48,3 @@ frictModel = Voellmy #+++++++++++++Voellmy friction model muvoellmy = 0.035 xsivoellmy = 700. - -# which result parameters shall be included as plots in report, - separated by | -plotFields = pfv|pft|FT diff --git a/avaframe/tests/test_com1DFA.py b/avaframe/tests/test_com1DFA.py index ab37ac7d4..f0e9dfe78 100644 --- a/avaframe/tests/test_com1DFA.py +++ b/avaframe/tests/test_com1DFA.py @@ -1395,7 +1395,7 @@ def test_initializeParticles(): # setup required input cfg = configparser.ConfigParser() - cfg["REPORT"] = {"plotFields": "ppr|pft|pfv"} + cfg["REPORT"] = {} cfg["GENERAL"] = { "resType": "ppr|pft|pfv", "rho": "200.", @@ -1746,8 +1746,8 @@ def test_exportFields(tmp_path): # setup required input cfg = configparser.ConfigParser() - cfg["GENERAL"] = {"resType": "ppr|pft|FT"} - cfg["REPORT"] = {"plotFields": "ppr|pft|pfv|pke"} + cfg["GENERAL"] = {"resType": "ppr|pft|FT|pfv|pke"} + cfg["REPORT"] = {} cfg["EXPORTS"] = {"useCompression": "True"} Tsave = [0, 10, 15, 25, 40] demHeader = {} @@ -1837,13 +1837,15 @@ def test_exportFields(tmp_path): assert np.array_equal(fieldFinal, pprFinal) assert np.array_equal(field10, pftt10) - assert len(fieldsListTest) == 8 + # With new behavior: both intermediate and final export all fields from resType + # resType has 5 fields (ppr, pft, FT, pfv, pke), exported at 2 time steps = 10 files + assert len(fieldsListTest) == 10 # call function to be tested outDir2 = pathlib.Path(tmp_path, "testDir2") outDir2.mkdir() - cfg["GENERAL"]["resType"] = "" - cfg["REPORT"] = {"plotFields": "ppr|pft|pfv"} + cfg["GENERAL"]["resType"] = "ppr|pft|pfv" + cfg["REPORT"] = {} com1DFA.exportFields(cfg, 0.00, fields1, dem, outDir2, logName, TSave="initial") com1DFA.exportFields(cfg, 10.00, fields2, dem, outDir2, logName, TSave="intermediate") @@ -1865,7 +1867,10 @@ def test_exportFields(tmp_path): for f in fieldFiles3: fieldsListTest3.append(f.name) - assert len(fieldsListTest2) == 6 + # With new behavior: all time steps export fields from resType + # resType has 3 fields (ppr, pft, pfv), exported at 5 time steps = 15 files in timeSteps/ + # final time step also exports 3 files to peakFiles/ = 3 files + assert len(fieldsListTest2) == 15 assert len(fieldsListTest3) == 3 @@ -1894,7 +1899,7 @@ def test_initializeFields(): "stoppedParticles": {"m": np.empty(0), "x": np.empty(0), "y": np.empty(0)}, } cfg = configparser.ConfigParser() - cfg["REPORT"] = {"plotFields": "ppr|pft|pfv"} + cfg["REPORT"] = {} cfg["GENERAL"] = { "rho": "200.", "interpOption": "2", @@ -1941,7 +1946,7 @@ def test_initializeFields(): assert np.sum(fields["timeInfo"]) == 0.0 assert np.sum(fields["sfcChangeTotal"]) == 0.0 - cfg["REPORT"] = {"plotFields": "pft|pfv"} + cfg["REPORT"] = {} cfg["GENERAL"] = { "resType": "pke|pta|pft|pfv", "rho": "200.", @@ -2229,7 +2234,7 @@ def test_initializeSimulation(tmp_path): # setup required input cfg = configparser.ConfigParser() - cfg["REPORT"] = {"plotFields": "ppr|pft|pfv"} + cfg["REPORT"] = {} cfg["GENERAL"] = { "methodMeshNormal": "1", "thresholdPointInPoly": "0.001", @@ -2412,7 +2417,7 @@ def test_initializeSimulation(tmp_path): # test if dam is found # setup required input cfg = configparser.ConfigParser() - cfg["REPORT"] = {"plotFields": "ppr|pft|pfv"} + cfg["REPORT"] = {} cfg["GENERAL"] = { "methodMeshNormal": "1", "thresholdPointInPoly": "0.001", @@ -2597,6 +2602,8 @@ def test_runCom1DFA(tmp_path, caplog): # print("there is an extra key in particles: ", particlesList[-1].keys() - set(dictKeys)) assert all(key in dictKeys for key in particlesList[-1]) + # With dtSave bug fixed: 2 simulations × 6 timesteps = 12 files + # Timesteps: t=0, 10, 20, 30, 40, 50 (from tSteps=0:10) assert len(particlesList) == 12 # print(simDF["simName"]) @@ -3003,5 +3010,8 @@ def test_tSteps_output_behavior(tmp_path): # Check that t=0 timestep files exist timeStepsDir2 = avaDir2 / "Outputs" / "com1DFA" / "peakFiles" / "timeSteps" assert timeStepsDir2.exists(), "timeSteps directory should exist" + allFiles2 = list(timeStepsDir2.glob("*.asc")) + print(f"\nAll timestep files: {[f.name for f in allFiles2]}") tStepFiles2 = list(timeStepsDir2.glob("*_t0.0*.asc")) + print(f"Files matching t0.0: {[f.name for f in tStepFiles2]}") assert len(tStepFiles2) > 0, "Should have initial timestep files at t=0 when tSteps includes 0" From 3be76771a8ba3b89ec1b5ccf01ee18268c25270e Mon Sep 17 00:00:00 2001 From: Felix Oesterle Date: Wed, 10 Dec 2025 14:43:35 +0100 Subject: [PATCH 3/5] refactor(com1DFA): simplify `resTypes` handling and dataframe population - Removed redundant condition for `resT` by excluding `FTDet` checks. - Simplified logic to populate `resultsDF` unconditionally when appending new rows. fix(com1DFA): correct timestep export condition for t=0 - Updated logic to use `np.any(dtSave <= 1.0e-8)` for handling initial timestep export. - Removed redundant contour fetching logic for dummy fields test(com1DFA): add unit test; squash refactor(tests): fix `test_tSteps_output_behavior` - Updated `com1DFA` to ensure particle directory initialization is handled conditionally during initial export. refactor(com1DFA): ensure valid `resTypes` and improve contour fetching logic - Added logic to append `pfv` to `resTypes` when it only contains invalid or insufficient field types (e.g., `particles` or empty). - Adjusted contour line computation to skip when the target field is a dummy array. refactor(com1DFA): simplify `resTypes` handling and remove redundant `resTypesLast` - Replaced all instances of `resTypesLast` with `resTypes` - Removed unused variable `resTypesLast` across the codebase. - Ensured consistent use of `resTypes` for initializing fields and max value computations. --- avaframe/com1DFA/com1DFA.py | 66 +++++++++++++---------- avaframe/log2Report/generateReport.py | 4 +- avaframe/tests/test_com1DFA.py | 77 +++++++++++++-------------- 3 files changed, 78 insertions(+), 69 deletions(-) diff --git a/avaframe/com1DFA/com1DFA.py b/avaframe/com1DFA/com1DFA.py index c1816c379..eebb21c0e 100644 --- a/avaframe/com1DFA/com1DFA.py +++ b/avaframe/com1DFA/com1DFA.py @@ -1658,7 +1658,11 @@ def initializeFields(cfg, dem, particles, releaseLine): cfgGen = cfg["GENERAL"] # what result types are desired as output (we need this to decide which fields we actually need to compute) resTypes = fU.splitIniValueToArraySteps(cfgGen["resType"]) - resTypesLast = resTypes + # ensure at least one field type is present for internal computations + # if resTypes only contains particles add pfv + validFieldTypes = [rt for rt in resTypes if rt not in ["particles"]] + if len(validFieldTypes) == 0: + resTypes.append("pfv") # read dem header header = dem["header"] ncols = header["ncols"] @@ -1683,7 +1687,7 @@ def initializeFields(cfg, dem, particles, releaseLine): fields["timeInfo"] = np.zeros((nrows, ncols)) # first time # for optional fields, initialize with dummys (minimum size array). The cython functions then need something # even if it is empty to run properly - if ("TA" in resTypesLast) or ("pta" in resTypesLast): + if ("TA" in resTypes) or ("pta" in resTypes): fields["pta"] = np.zeros((nrows, ncols)) # peak travel angle [°] fields["TA"] = np.zeros((nrows, ncols)) # travel angle [°] fields["computeTA"] = True @@ -1692,14 +1696,14 @@ def initializeFields(cfg, dem, particles, releaseLine): fields["pta"] = np.zeros((1, 1)) fields["TA"] = np.zeros((1, 1)) fields["computeTA"] = False - if "pke" in resTypesLast: + if "pke" in resTypes: fields["pke"] = np.zeros((nrows, ncols)) # peak kinetic energy [kJ/m²] fields["computeKE"] = True log.debug("Computing Kinetic energy") else: fields["pke"] = np.zeros((1, 1)) fields["computeKE"] = False - if ("P" in resTypesLast) or ("ppr" in resTypesLast): + if ("P" in resTypes) or ("ppr" in resTypes): fields["P"] = np.zeros((nrows, ncols)) # pressure [kPa] fields["ppr"] = np.zeros((nrows, ncols)) # peak pressure [kPa] fields["computeP"] = True @@ -2037,8 +2041,11 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si # add particles to the results type if trackParticles option is activated if cfg.getboolean("TRACKPARTICLES", "trackParticles"): resTypes = list(set(resTypes + ["particles"])) - # use resTypes for all time steps - resTypesLast = resTypes + # ensure at least one field type is present for internal computations + # if resTypes only contains particles, add pfv + validFieldTypes = [rt for rt in resTypes if rt not in ["particles"]] + if len(validFieldTypes) == 0: + resTypes.append("pfv") # derive friction type # turn friction model into integer frictModelsList = [ @@ -2077,7 +2084,7 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si pfvTimeMax = [] # setup a result fields info data frame to save max values of fields and avalanche front - resultsDF = setupresultsDF(resTypesLast, cfg["VISUALISATION"].getboolean("createRangeTimeDiagram")) + resultsDF = setupresultsDF(resTypes, cfg["VISUALISATION"].getboolean("createRangeTimeDiagram")) # Add different time stepping options here log.debug("Use standard time stepping") @@ -2091,13 +2098,16 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si t = particles["t"] log.debug("Saving results for time step t = %f s", t) + # Initialize particles output directory if needed + if "particles" in resTypes: + outDirData = outDir / "particles" + fU.makeADir(outDirData) + # export initial time step only if t=0 is explicitly in dtSave - if cfg["EXPORTS"].getboolean("exportData") and (dtSave.size > 0 and dtSave[0] <= 1.0e-8): + if cfg["EXPORTS"].getboolean("exportData") and (dtSave.size > 0 and np.any(dtSave <= 1.0e-8)): exportFields(cfg, t, fields, dem, outDir, cuSimName, TSave="initial") if "particles" in resTypes: - outDirData = outDir / "particles" - fU.makeADir(outDirData) savePartToPickle(particles, outDirData, cuSimName) # Update dtSave to remove the initial timestep we just saved @@ -2129,7 +2139,7 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si cfgRangeTime["GENERAL"]["simHash"] = simHash # add initial time step to Tsave array only if it was exported - if dtSave.size > 0 and dtSave[0] <= 1.0e-8: + if dtSave.size > 0 and np.any(dtSave <= 1.0e-8): Tsave = [0] else: Tsave = [] @@ -2169,7 +2179,7 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si rangeValue = mtiInfo["rangeList"][-1] else: rangeValue = "" - resultsDF = addMaxValuesToDF(resultsDF, fields, t, resTypesLast, rangeValue=rangeValue) + resultsDF = addMaxValuesToDF(resultsDF, fields, t, resTypes, rangeValue=rangeValue) tCPU["nSave"] = nSave particles["t"] = t @@ -2351,25 +2361,19 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si # export particles dictionaries of saving time steps if "particles" in resTypes: savePartToPickle(particles, outDirData, cuSimName) - else: - # fetch contourline info + + # save contour line for each sim only if the field is properly computed (not a dummy array) + contourResType = cfg["VISUALISATION"]["contourResType"] + if fields[contourResType].shape != (1, 1): contourDictXY = outCom1DFA.fetchContCoors( dem["header"], - fields[cfg["VISUALISATION"]["contourResType"]], + fields[contourResType], cfg["VISUALISATION"], cuSimName, ) - - # save contour line for each sim - contourDictXY = outCom1DFA.fetchContCoors( - dem["header"], - fields[cfg["VISUALISATION"]["contourResType"]], - cfg["VISUALISATION"], - cuSimName, - ) - outDirDataCont = outDir / "contours" - fU.makeADir(outDirDataCont) - saveContToPickle(contourDictXY, outDirDataCont, cuSimName) + outDirDataCont = outDir / "contours" + fU.makeADir(outDirDataCont) + saveContToPickle(contourDictXY, outDirDataCont, cuSimName) # export particles properties for visulation if cfg["VISUALISATION"].getboolean("writePartToCSV"): @@ -2409,7 +2413,7 @@ def setupresultsDF(resTypes, cfgRangeTime): # TODO catch empty resTypes resDict = {"timeStep": [0.0]} for resT in resTypes: - if resT != "particles" and resT != "FTDet": + if resT != "particles": resDict["max" + resT] = [0.0] if cfgRangeTime: resDict["rangeList"] = [0.0] @@ -2443,11 +2447,12 @@ def addMaxValuesToDF(resultsDF, fields, timeStep, resTypes, rangeValue=""): newLine = [] for resT in resTypes: - if resT != "particles" and resT != "FTDet": + if resT != "particles": newLine.append(np.nanmax(fields[resT])) if rangeValue != "": newLine.append(rangeValue) + resultsDF.loc[timeStep] = newLine return resultsDF @@ -2980,6 +2985,11 @@ def exportFields( resTypesGen.remove("particles") resTypes = resTypesGen + # ensure at least one field type is present for export + # if resTypes only contains FTDet or is empty, add pfv + validFieldTypes = [rt for rt in resTypes if rt != "FTDet"] + if len(validFieldTypes) == 0: + resTypes.append("pfv") if resTypesForced != []: resTypes = resTypesForced diff --git a/avaframe/log2Report/generateReport.py b/avaframe/log2Report/generateReport.py index b260c6ee4..00ea810fd 100644 --- a/avaframe/log2Report/generateReport.py +++ b/avaframe/log2Report/generateReport.py @@ -203,7 +203,7 @@ def writeReport(outDir, reportDictList, reportOneFile, plotDict='', standaloneRe # Loop through all simulations for reportD in reportDictList: - if plotDict != '' and ('simName' in reportD): + if plotDict != '' and ('simName' in reportD) and (reportD['simName']['name'] in plotDict): # add plot info to general report Dict reportD['Simulation Results'] = plotDict[reportD['simName']['name']] reportD['Simulation Results'].update({'type': 'image'}) @@ -222,7 +222,7 @@ def writeReport(outDir, reportDictList, reportOneFile, plotDict='', standaloneRe # Loop through all simulations for reportD in reportDictList: - if plotDict != '': + if plotDict != '' and (reportD['simName']['name'] in plotDict): # add plot info to general report Dict reportD['Simulation Results'] = plotDict[reportD['simName']['name']] reportD['Simulation Results'].update({'type': 'image'}) diff --git a/avaframe/tests/test_com1DFA.py b/avaframe/tests/test_com1DFA.py index f0e9dfe78..33374be59 100644 --- a/avaframe/tests/test_com1DFA.py +++ b/avaframe/tests/test_com1DFA.py @@ -2926,10 +2926,9 @@ def test_adaptDEM(): assert np.all(fieldsAdapted["sfcChangeTotal"] == fields["FTDet"] / NzNormed) -def test_tSteps_output_behavior(tmp_path): +def test_tSteps_output_behavior(tmp_path, caplog): """Test that tSteps controls which timesteps are exported correctly. - New behavior: - Empty tSteps (default): only final timestep is exported - Explicit tSteps with t=0: t=0 timestep is exported """ @@ -2941,31 +2940,19 @@ def test_tSteps_output_behavior(tmp_path): shutil.copytree(inputDir, avaDir1) cfgFile1 = avaDir1 / "test_com1DFACfg.ini" + # Get main configuration + cfgMain = cfgUtils.getGeneralConfig() + cfgMain['MAIN']['avalancheDir'] = str(avaDir1) # Modify config to have empty tSteps and NO parameter variations - cfg = configparser.ConfigParser() - cfg.read(cfgFile1) + cfg = cfgUtils.getModuleConfig(com1DFA, cfgFile1) cfg["GENERAL"]["tSteps"] = "" cfg["GENERAL"]["tEnd"] = "10" # Short simulation cfg["GENERAL"]["dt"] = "0.1" # Single value, no variations cfg["GENERAL"]["simTypeList"] = "null" # Simple simulation, no entrainment/resistance - # Ensure exports are enabled - if "EXPORTS" not in cfg: - cfg["EXPORTS"] = {} - cfg["EXPORTS"]["exportData"] = "True" with open(cfgFile1, "w") as f: cfg.write(f) - cfgMain1 = configparser.ConfigParser() - cfgMain1["MAIN"] = {"avalancheDir": str(avaDir1), "nCPU": "1"} - cfgMain1["FLAGS"] = { - "showPlot": "False", - "savePlot": "False", - "ReportDir": "False", - "reportOneFile": "False", - "debugPlot": "False", - } - - dem, plotDict, reportDictList, simDF = com1DFA.com1DFAMain(cfgMain1, cfgInfo=cfgFile1) + dem, plotDict, reportDictList, simDF = com1DFA.com1DFAMain(cfgMain, cfgInfo=cfgFile1) # Check that only final timestep files exist in timeSteps directory timeStepsDir1 = avaDir1 / "Outputs" / "com1DFA" / "peakFiles" / "timeSteps" @@ -2981,37 +2968,49 @@ def test_tSteps_output_behavior(tmp_path): shutil.copytree(inputDir, avaDir2) cfgFile2 = avaDir2 / "test_com1DFACfg.ini" + cfgMain['MAIN']['avalancheDir'] = str(avaDir2) + # Modify config to have explicit tSteps including t=0 and NO parameter variations - cfg2 = configparser.ConfigParser() - cfg2.read(cfgFile2) + cfg2 = cfgUtils.getModuleConfig(com1DFA, cfgFile2) cfg2["GENERAL"]["tSteps"] = "0|5" cfg2["GENERAL"]["tEnd"] = "10" # Short simulation cfg2["GENERAL"]["dt"] = "0.1" # Single value, no variations cfg2["GENERAL"]["simTypeList"] = "null" # Simple simulation, no entrainment/resistance - # Ensure exports are enabled - if "EXPORTS" not in cfg2: - cfg2["EXPORTS"] = {} - cfg2["EXPORTS"]["exportData"] = "True" with open(cfgFile2, "w") as f: cfg2.write(f) - cfgMain2 = configparser.ConfigParser() - cfgMain2["MAIN"] = {"avalancheDir": str(avaDir2), "nCPU": "1"} - cfgMain2["FLAGS"] = { - "showPlot": "False", - "savePlot": "False", - "ReportDir": "False", - "reportOneFile": "False", - "debugPlot": "False", - } - - dem2, plotDict2, reportDictList2, simDF2 = com1DFA.com1DFAMain(cfgMain2, cfgInfo=cfgFile2) + dem2, plotDict2, reportDictList2, simDF2 = com1DFA.com1DFAMain(cfgMain, cfgInfo=cfgFile2) # Check that t=0 timestep files exist timeStepsDir2 = avaDir2 / "Outputs" / "com1DFA" / "peakFiles" / "timeSteps" assert timeStepsDir2.exists(), "timeSteps directory should exist" - allFiles2 = list(timeStepsDir2.glob("*.asc")) - print(f"\nAll timestep files: {[f.name for f in allFiles2]}") tStepFiles2 = list(timeStepsDir2.glob("*_t0.0*.asc")) - print(f"Files matching t0.0: {[f.name for f in tStepFiles2]}") assert len(tStepFiles2) > 0, "Should have initial timestep files at t=0 when tSteps includes 0" + + # Test 3: exportData = False should trigger contour fetching in else block + avaDir3 = pathlib.Path(tmp_path, "testExportDataFalse") + shutil.copytree(inputDir, avaDir3) + cfgFile3 = avaDir3 / "test_com1DFACfg.ini" + + cfgMain['MAIN']['avalancheDir'] = str(avaDir3) + + # Modify config to have exportData = False + cfg3 = cfgUtils.getModuleConfig(com1DFA, cfgFile3) + cfg3["GENERAL"]["tSteps"] = "" + cfg3["GENERAL"]["tEnd"] = "5" # Very short simulation + cfg3["GENERAL"]["dt"] = "0.1" + cfg3["GENERAL"]["simTypeList"] = "null" + cfg3["EXPORTS"]["exportData"] = "False" # Key setting to test else block + with open(cfgFile3, "w") as f: + cfg3.write(f) + + dem3, plotDict3, reportDictList3, simDF3 = com1DFA.com1DFAMain(cfgMain, cfgInfo=cfgFile3) + + # Check that contour data was generated (stored in reportDict) instead of exported files + assert len(reportDictList3) > 0, "Should have report dict even with exportData=False" + # Verify that timeSteps directory doesn't exist (no data exported) + timeStepsDir3 = avaDir3 / "Outputs" / "com1DFA" / "peakFiles" / "timeSteps" + if timeStepsDir3.exists(): + tStepFiles3 = list(timeStepsDir3.glob("*.asc")) + # With exportData=False, intermediate timesteps should not be exported + assert len(tStepFiles3) == 0, "No timestep files should be exported when exportData=False" From 653624a905ff63543d0ca89fe896d5f3ae431702 Mon Sep 17 00:00:00 2001 From: Felix Oesterle Date: Tue, 16 Dec 2025 09:21:01 +0100 Subject: [PATCH 4/5] refactor(com1DFA): remove unused `[REPORT]` section from config file --- avaframe/com1DFA/com1DFACfg.ini | 8 -------- 1 file changed, 8 deletions(-) diff --git a/avaframe/com1DFA/com1DFACfg.ini b/avaframe/com1DFA/com1DFACfg.ini index 86fca5b61..14937e5de 100644 --- a/avaframe/com1DFA/com1DFACfg.ini +++ b/avaframe/com1DFA/com1DFACfg.ini @@ -544,14 +544,6 @@ releaseScenario = # important for parameter variation through probRun thFromIni = - -[REPORT] -# units for output variables -unitppr = kPa -unitpft = m -unitpfv = ms-1 - - [EXPORTS] # peak files and plots are exported, option to turn off exports when exportData is set to False # this affects export of peak files and also generation of peak file plots From 84605b4f0012a9659a55f99f630f1072fca2b581 Mon Sep 17 00:00:00 2001 From: Felix Oesterle <6945681+fso42@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:45:50 +0100 Subject: [PATCH 5/5] fix(com1DFA): handle `dtSave` updates correctly for initial timestep export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store original `dtSave` to ensure accurate initial timestep decisions. - Update `dtSave` only after initial timestep export to avoid unintended modifications. - Improve readability of conditions by explicitly referencing `dtSaveOriginal`. Root Cause: The code uses dtSave for two decisions but modifies it between them: 1. Line 2107: Checks if dtSave contains t=0 → decides to export initial timestep 2. Line 2114: Calls updateSavingTimeStep() which modifies dtSave (changes [0] to [2*tEnd]) 3. Line 2142: Checks dtSave again → decides whether to add t=0 to Tsave array The Problem: When tSteps = "0": - Line 2107 check passes → exports t=0 - Line 2114 modifies dtSave from [0] to [2*tEnd] - Line 2142 check fails → Tsave remains empty - Result: t=0 was exported but not tracked in Tsave (inconsistency) Proposed Fix: ✓ CORRECT The fix saves the original dtSave before modifications and uses it for both decisions. This ensures consistency between the export and Tsave array decisions. fix(com1DFA): correct field type filter for `resTypes` validation --- avaframe/com1DFA/com1DFA.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/avaframe/com1DFA/com1DFA.py b/avaframe/com1DFA/com1DFA.py index eebb21c0e..c0eda26d4 100644 --- a/avaframe/com1DFA/com1DFA.py +++ b/avaframe/com1DFA/com1DFA.py @@ -2103,15 +2103,21 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si outDirData = outDir / "particles" fU.makeADir(outDirData) - # export initial time step only if t=0 is explicitly in dtSave - if cfg["EXPORTS"].getboolean("exportData") and (dtSave.size > 0 and np.any(dtSave <= 1.0e-8)): + # Save original dtSave for initial timestep decisions + dtSaveOriginal = dtSave.copy() + + # export initial time step only if t=0 is explicitly in dtSaveOriginal + if cfg["EXPORTS"].getboolean("exportData") and ( + dtSaveOriginal.size > 0 and np.any(dtSaveOriginal <= 1.0e-8) + ): exportFields(cfg, t, fields, dem, outDir, cuSimName, TSave="initial") if "particles" in resTypes: savePartToPickle(particles, outDirData, cuSimName) # Update dtSave to remove the initial timestep we just saved - dtSave = updateSavingTimeStep(dtSave, cfgGen, t) + dtSave = updateSavingTimeStep(dtSaveOriginal, cfgGen, t) + # export particles properties for visulation if cfg["VISUALISATION"].getboolean("writePartToCSV"): @@ -2139,10 +2145,11 @@ def DFAIterate(cfg, particles, fields, dem, inputSimLines, outDir, cuSimName, si cfgRangeTime["GENERAL"]["simHash"] = simHash # add initial time step to Tsave array only if it was exported - if dtSave.size > 0 and np.any(dtSave <= 1.0e-8): + if dtSaveOriginal.size > 0 and np.any(dtSaveOriginal <= 1.0e-8): Tsave = [0] else: Tsave = [] + # derive time step for first iteration if cfgGen.getboolean("sphKernelRadiusTimeStepping"): dtSPHKR = tD.getSphKernelRadiusTimeStep(dem, cfgGen) @@ -2987,7 +2994,7 @@ def exportFields( resTypes = resTypesGen # ensure at least one field type is present for export # if resTypes only contains FTDet or is empty, add pfv - validFieldTypes = [rt for rt in resTypes if rt != "FTDet"] + validFieldTypes = [rt for rt in resTypes if rt != "particles"] if len(validFieldTypes) == 0: resTypes.append("pfv")