From de439c04d2b6426497c11f8ad35a3665c3131b6f Mon Sep 17 00:00:00 2001 From: Cactux Date: Tue, 24 Feb 2026 21:40:49 +0000 Subject: [PATCH] feat(result): warn when simulation may not have reached equilibrium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add is_equilibrated property to SimulationResult that checks whether R(t) has stabilized in the final 10% of time steps. Display a warning in both CLI output and web interface when the simulation appears to still be evolving. This helps catch a common pitfall when comparing distributions with very different characteristic time scales at the same mean transmissibility — the slower distribution may need a larger t_max to reach equilibrium. --- spkmc/cli/display.py | 9 +++++++++ spkmc/models/result.py | 14 ++++++++++++++ spkmc/web/components.py | 9 +++++++++ 3 files changed, 32 insertions(+) diff --git a/spkmc/cli/display.py b/spkmc/cli/display.py index 05b5277..dbd63cb 100644 --- a/spkmc/cli/display.py +++ b/spkmc/cli/display.py @@ -129,12 +129,21 @@ def display_result_statistics(result: "SimulationResult") -> None: Args: result: SimulationResult to display statistics for """ + from spkmc.cli.formatting import log_warning + console.print(format_title("Simulation Statistics")) stats = result.get_statistics() max_inf_str = f"{stats['peak_infected']:.4f} (at t={stats['peak_time']:.2f})" final_recovered_str = f"{stats['final_recovered']:.4f}" console.print(f" {format_param('Peak infected', max_inf_str)}") console.print(f" {format_param('Final recovered', final_recovered_str)}") + + if not result.is_equilibrated: + log_warning( + "R(t) is still changing at the end of the simulation. " + "Results may not reflect equilibrium. Consider increasing --t-max." + ) + console.print() diff --git a/spkmc/models/result.py b/spkmc/models/result.py index d130d8d..d754ce6 100644 --- a/spkmc/models/result.py +++ b/spkmc/models/result.py @@ -68,6 +68,20 @@ def final_recovered(self) -> float: return 0.0 return float(self.R_val[-1]) + @property + def is_equilibrated(self) -> bool: + """Check if the simulation reached equilibrium. + + Compares the R(t) curve over its final 10% of time steps. + Returns False when R is still changing significantly, + which usually means t_max is too short for the chosen parameters. + """ + if self.is_empty or len(self.R_val) < 10: + return True + n = max(2, len(self.R_val) // 10) + delta = abs(float(self.R_val[-1]) - float(self.R_val[-n])) + return delta < 0.01 + @property def final_susceptible(self) -> float: """Get final susceptible proportion. Returns 0.0 for empty results.""" diff --git a/spkmc/web/components.py b/spkmc/web/components.py index d82a8e1..4b9a27c 100644 --- a/spkmc/web/components.py +++ b/spkmc/web/components.py @@ -315,6 +315,15 @@ def result_metric_cards(result_dict: Dict[str, Any]) -> None: help="Proportion of population that was infected", ) + # Warn if simulation may not have reached equilibrium + if len(R_val) >= 10: + n = max(2, len(R_val) // 10) + if abs(float(R_val[-1]) - float(R_val[-n])) >= 0.01: + st.warning( + "⚠️ R(t) is still changing at the end of the simulation. " + "Results may not reflect equilibrium. Consider increasing t_max." + ) + def experiment_status_badge(experiment: Any) -> str: """