From 7aff2a282e0e4cfad1e9e70ab90acdf16fb26e2c Mon Sep 17 00:00:00 2001 From: Dave Woodruff Date: Fri, 26 Jun 2026 09:02:46 -0700 Subject: [PATCH 1/2] Fix MessagesFileNN.csv to serialize a well-defined propagation forest (#139) MessagesFileNN.csv was written by taking the first `burntCells.size() - nIgnitions` 4-tuples of the append-only FSCell event buffer. FSCell can hold several events per destination (multiple sources reaching it in one period, or repeated reaches across periods) and events for cells that never actually burn, so the retained prefix was an order-dependent subset rather than the intended one-edge-per-burned-cell tree. Add Cell2Fire::buildMessageTree(), which reduces FSCell to exactly one parent edge per burned, non-ignition cell. Selection rule: earliest fire period, ties broken by highest realized ROS, then by lowest source id; rows are sorted by destination id so the file is deterministic. A new ignitionCells set marks the forest roots so they are excluded as message destinations (ignition cells are never cleaned from neighbors' ROSAngleDir and can otherwise appear as targets). This is an output-only change; simulation dynamics are unchanged. Update tests/test_ptree.py golden values to the corrected, now-deterministic output, fix its broken fourth assertion (it compared node2 to a ROS value), and add structural invariants (unique destinations, rows sorted by destination). Co-Authored-By: Claude Opus 4.8 (1M context) --- cell2fire/Cell2FireC/Cell2Fire.cpp | 64 +++++++++++++++++++++++++++++- cell2fire/Cell2FireC/Cell2Fire.h | 2 + tests/test_ptree.py | 13 ++++-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/cell2fire/Cell2FireC/Cell2Fire.cpp b/cell2fire/Cell2FireC/Cell2Fire.cpp index 951fb9b2..5bdaa477 100644 --- a/cell2fire/Cell2FireC/Cell2Fire.cpp +++ b/cell2fire/Cell2FireC/Cell2Fire.cpp @@ -234,6 +234,7 @@ Cell2Fire::Cell2Fire(arguments _args) : CSVWeather(_args.InFolder + "Weather.csv this->nonBurnableCells.clear(); this->burningCells.clear(); this->burntCells.clear(); + this->ignitionCells.clear(); this->harvestCells.clear(); for (i=0; i < this->statusCells.size(); i++){ if(this->statusCells[i] < 3) this->availCells.insert (i+1); @@ -535,6 +536,7 @@ void Cell2Fire::reset(int rnumber, double rnumber2){ this->nonBurnableCells.clear(); this->burningCells.clear(); this->burntCells.clear(); + this->ignitionCells.clear(); this->harvestCells.clear(); // Harvest Cells @@ -697,6 +699,7 @@ bool Cell2Fire::RunIgnition(std::default_random_engine generator){ this->nIgnitions++; this->burningCells.insert(newId); this->burntCells.insert(newId); + this->ignitionCells.insert(newId); // forest root: excluded as a message destination this->availCells.erase(newId); // Print sets information @@ -1049,6 +1052,63 @@ void Cell2Fire::GetMessages(std::unordered_map> sendMessag } +/* + * Reduce the raw FSCell event buffer to the fire-propagation forest written to MessagesFileNN.csv. + * + * FSCell is an append-only stream of (source, destination, period, ROS) reach-to-center events. + * A destination may appear several times (several sources reaching it in one period, or repeated + * reaches across periods) and may even name a cell that never actually burned. The MessagesFile is + * meant to be a spanning forest rooted at the ignition cells, with exactly one parent edge per + * burned, non-ignition cell, so we select a single representative edge per burned destination. + * + * Selection rule (per destination): earliest fire period; ties broken by highest realized ROS, then + * by lowest source id. Output rows are sorted by destination id so the file is deterministic. + */ +std::vector Cell2Fire::buildMessageTree(){ + // destination -> winning edge {source, destination, period, ros} + std::unordered_map> best; + + for (size_t i = 0; i + 3 < this->FSCell.size(); i += 4){ + double src = this->FSCell[i]; + int dst = (int) this->FSCell[i + 1]; + double period = this->FSCell[i + 2]; + double ros = this->FSCell[i + 3]; + + // Keep only edges into cells that actually burned, and never into an ignition root. + if (this->burntCells.find(dst) == this->burntCells.end()) continue; + if (this->ignitionCells.find(dst) != this->ignitionCells.end()) continue; + + auto it = best.find(dst); + if (it == best.end()){ + best[dst] = {src, (double) dst, period, ros}; + continue; + } + + double bSrc = it->second[0], bPeriod = it->second[2], bRos = it->second[3]; + bool better = (period < bPeriod) + || (period == bPeriod && ros > bRos) + || (period == bPeriod && ros == bRos && src < bSrc); + if (better){ + it->second = {src, (double) dst, period, ros}; + } + } + + // Emit rows ordered by destination id for a reproducible file. + std::vector dsts; + dsts.reserve(best.size()); + for (auto & kv : best) dsts.push_back(kv.first); + std::sort(dsts.begin(), dsts.end()); + + std::vector tree; + tree.reserve(dsts.size() * 4); + for (int d : dsts){ + std::vector & e = best[d]; + tree.insert(tree.end(), e.begin(), e.end()); + } + return tree; +} + + // Display results void Cell2Fire::Results(){ /***************************************************************************** @@ -1151,7 +1211,9 @@ void Cell2Fire::Results(){ std::cout << "We are generating the network messages to a csv file " << messagesName << std::endl; } CSVWriter CSVPloter(messagesName, ","); - CSVPloter.printCSVDouble_V2(this->burntCells.size() - this->nIgnitions, 4, this->FSCell); + // Serialize one canonical parent edge per burned, non-ignition cell (see buildMessageTree). + std::vector messageTree = this->buildMessageTree(); + CSVPloter.printCSVDouble_V2(messageTree.size() / 4, 4, messageTree); } diff --git a/cell2fire/Cell2FireC/Cell2Fire.h b/cell2fire/Cell2FireC/Cell2Fire.h index eec17852..4c36ae98 100644 --- a/cell2fire/Cell2FireC/Cell2Fire.h +++ b/cell2fire/Cell2FireC/Cell2Fire.h @@ -88,6 +88,7 @@ class Cell2Fire { std::unordered_set nonBurnableCells; std::unordered_set burningCells; std::unordered_set burntCells; + std::unordered_set ignitionCells; // roots of the propagation forest (no parent edge) std::unordered_set harvestCells; // Cells Dictionary @@ -102,6 +103,7 @@ class Cell2Fire { bool RunIgnition(std::default_random_engine generator); std::unordered_map> SendMessages(); void GetMessages(std::unordered_map> sendMessageList); + std::vector buildMessageTree(); void Results(); void outputGrid(); void updateWeather(); diff --git a/tests/test_ptree.py b/tests/test_ptree.py index 374f165f..7ddd15b5 100644 --- a/tests/test_ptree.py +++ b/tests/test_ptree.py @@ -65,10 +65,15 @@ def test_readme_cmd(self): csv_path = os.path.join(data_path, "..", "results", "Sub40x40", "Messages", "MessagesFile01.csv") df = pd.read_csv(csv_path, names = ["node1", "node2", "time", "ros"]) - self.assertAlmostEqual(df['node1'][10],935,0), "TEST ERROR" - self.assertAlmostEqual(df['node2'][10],896,0), "TEST ERROR" - self.assertAlmostEqual(df['time'][10], 139, 0), "TEST ERROR" - self.assertAlmostEqual(df['node2'][10], 14.1367, 2), "TEST ERROR" + # MessagesFile is the fire-propagation forest: exactly one parent edge per burned, + # non-ignition cell, with rows sorted by destination (node2) so the file is + # deterministic. See Cell2Fire::buildMessageTree in Cell2Fire.cpp. + self.assertEqual(df['node2'].nunique(), len(df)) # one parent per destination + self.assertEqual(list(df['node2']), sorted(df['node2'])) # deterministic ordering + self.assertAlmostEqual(df['node1'][10], 456, 0), "TEST ERROR" + self.assertAlmostEqual(df['node2'][10], 417, 0), "TEST ERROR" + self.assertAlmostEqual(df['time'][10], 402, 0), "TEST ERROR" + self.assertAlmostEqual(df['ros'][10], 27.2931, 2), "TEST ERROR" if __name__ == "__main__": From 524a8d7fbdddc4a08edcaf4bfc3f87c7d197cc4b Mon Sep 17 00:00:00 2001 From: Dave Woodruff Date: Fri, 26 Jun 2026 09:57:59 -0700 Subject: [PATCH 2/2] Fix matplotlib bitrot: replace removed cm.get_cmap with colormaps registry The heuristic CI jobs (test_h1/h2/h3) fail at `cm.get_cmap('RdBu_r')`, which matplotlib deprecated in 3.7 and removed in 3.9. matplotlib is unpinned, so newer CI runners no longer have it (the same failure reproduces on main). Replace the three call sites (Heuristics.py x2, Stats.py x1) with the version-agnostic `matplotlib.colormaps['RdBu_r']` registry lookup, which works on matplotlib 3.6+ and is the documented replacement. Verified by running the test_h1 command (Sub20x20, --heuristic 1 --stats) end to end. Co-Authored-By: Claude Opus 4.8 (1M context) --- cell2fire/utils/Heuristics.py | 6 ++++-- cell2fire/utils/Stats.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cell2fire/utils/Heuristics.py b/cell2fire/utils/Heuristics.py index 970fcf7b..bc1817f4 100644 --- a/cell2fire/utils/Heuristics.py +++ b/cell2fire/utils/Heuristics.py @@ -1530,7 +1530,8 @@ def Global_FPVPlot(self, normalized=False, xticks=50, yticks=50): plt.title(self.Alg + " Heatmap $|R| = 100$", y=1.02) # Modify existing map to have white values - cmap = cm.get_cmap('RdBu_r') + # matplotlib.cm.get_cmap was removed in matplotlib 3.9; use the registry. + cmap = matplotlib.colormaps['RdBu_r'] lower = plt.cm.seismic(np.ones(2)*0.50) # Original is ones range = cmap(np.linspace(0.5, np.max((self._FPVMatrix / np.max(self._FPVMatrix))), 100)) colors = np.vstack((lower,range)) @@ -1616,7 +1617,8 @@ def Ind_FPVPlot(self, nSim, FPVMatrix, normalized=False): plt.title("FPV Heatmap") # Modify existing map to have white values - cmap = cm.get_cmap('RdBu_r') + # matplotlib.cm.get_cmap was removed in matplotlib 3.9; use the registry. + cmap = matplotlib.colormaps['RdBu_r'] lower = plt.cm.seismic(np.ones(100)*0.50) upper = cmap(np.linspace(1-0.5, 1, 90)) colors = np.vstack((lower, upper)) diff --git a/cell2fire/utils/Stats.py b/cell2fire/utils/Stats.py index 12e25303..2d28d7cf 100644 --- a/cell2fire/utils/Stats.py +++ b/cell2fire/utils/Stats.py @@ -354,7 +354,8 @@ def ROSHeatmap(self, ROSM, Path=None, nscen=1, sq=True, namePlot="ROS_HeatMap", plt.title(Title) # Modify existing map to have white values - cmap = cm.get_cmap('RdBu_r') + # matplotlib.cm.get_cmap was removed in matplotlib 3.9; use the registry. + cmap = matplotlib.colormaps['RdBu_r'] lower = plt.cm.seismic(np.ones(1)*0.50) # Original is ones upper = cmap(np.linspace(0.5, 1, 100)) colors = np.vstack((lower,upper))