From 665f797d517b69878ab5f596d0e6b1f1902544a8 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Tue, 24 Feb 2026 10:55:02 +0100 Subject: [PATCH] Hallucinate a heat model to the graphing machine --- graphs/data/__init__.py | 19 ++- graphs/data/fitHeat/__init__.py | 25 +++ graphs/data/fitHeat/calc.py | 285 ++++++++++++++++++++++++++++++++ graphs/data/fitHeat/getter.py | 109 ++++++++++++ graphs/data/fitHeat/graph.py | 96 +++++++++++ 5 files changed, 525 insertions(+), 9 deletions(-) create mode 100644 graphs/data/fitHeat/__init__.py create mode 100644 graphs/data/fitHeat/calc.py create mode 100644 graphs/data/fitHeat/getter.py create mode 100644 graphs/data/fitHeat/graph.py diff --git a/graphs/data/__init__.py b/graphs/data/__init__.py index 6453ee583..660810c60 100644 --- a/graphs/data/__init__.py +++ b/graphs/data/__init__.py @@ -18,13 +18,14 @@ # ============================================================================= -from . import fitDamageStats -from . import fitEwarStats -from . import fitRemoteReps -from . import fitShieldRegen -from . import fitCapacitor -from . import fitMobility -from . import fitWarpTime -from . import fitLockTime +from . import fitDamageStats as fitDamageStats +from . import fitEwarStats as fitEwarStats +from . import fitRemoteReps as fitRemoteReps +from . import fitShieldRegen as fitShieldRegen +from . import fitCapacitor as fitCapacitor +from . import fitMobility as fitMobility +from . import fitWarpTime as fitWarpTime +from . import fitLockTime as fitLockTime +from . import fitHeat as fitHeat # Hidden graphs, available via ctrl-alt-g -from . import fitEcmBurstScanresDamps +from . import fitEcmBurstScanresDamps as fitEcmBurstScanresDamps diff --git a/graphs/data/fitHeat/__init__.py b/graphs/data/fitHeat/__init__.py new file mode 100644 index 000000000..a72161255 --- /dev/null +++ b/graphs/data/fitHeat/__init__.py @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (C) 2026 +# +# This file is part of pyfa. +# +# pyfa is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pyfa is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyfa. If not, see . +# ============================================================================= + + +from .graph import FitHeatGraph + + +FitHeatGraph.register() + diff --git a/graphs/data/fitHeat/calc.py b/graphs/data/fitHeat/calc.py new file mode 100644 index 000000000..305ac8f60 --- /dev/null +++ b/graphs/data/fitHeat/calc.py @@ -0,0 +1,285 @@ +# ============================================================================= +# Copyright (C) 2026 +# +# This file is part of pyfa. +# +# pyfa is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pyfa is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyfa. If not, see . +# ============================================================================= + + +import math +import random + +from eos.const import FittingModuleState, FittingSlot + + +_RACK_SUFFIXES = { + FittingSlot.HIGH: "Hi", + FittingSlot.MED: "Med", + FittingSlot.LOW: "Low", +} + +# Cache: (fit_id, rack_slot, max_time_s, iterations) -> list of burnout time samples +_burnout_samples_cache = {} + + +def clear_burnout_samples_cache(fit_id=None): + if fit_id is None: + _burnout_samples_cache.clear() + return + to_drop = [k for k in _burnout_samples_cache if k[0] == fit_id] + for k in to_drop: + del _burnout_samples_cache[k] + + +def _get_rack_suffix(rack_slot): + return _RACK_SUFFIXES[rack_slot] + + +def iter_rack_modules(fit, rack_slot): + for mod in fit.modules: + if mod.isEmpty: + continue + if mod.slot == rack_slot: + yield mod + + +def get_rack_heat_value(fit, rack_slot, time_s): + """ + Deterministic rack heat H(t) for a given rack and time, in [0, 1]. + """ + rack_suffix = _get_rack_suffix(rack_slot) + ship = fit.ship + heat_capacity = ship.getModifiedItemAttr(f"heatCapacity{rack_suffix}") + heat_generation_multiplier = ship.getModifiedItemAttr("heatGenerationMultiplier") + if heat_capacity is None or heat_generation_multiplier is None: + raise ValueError("Missing heat attributes on ship for rack heat calculation") + # Sum heat absorption over all overheated modules in this rack + sum_absorption = 0.0 + for mod in iter_rack_modules(fit, rack_slot): + if mod.state >= FittingModuleState.OVERHEATED: + sum_absorption += mod.getModifiedItemAttr("heatAbsorbtionRateModifier") + argument = -time_s * heat_generation_multiplier * sum_absorption + # Guard against numeric issues + try: + exp_term = math.exp(argument) + except OverflowError: + exp_term = 0.0 if argument < 0 else float("inf") + heat = heat_capacity / 100.0 - exp_term + return heat + + +def _count_online_modules_by_rack(fit): + counts = { + FittingSlot.HIGH: 0, + FittingSlot.MED: 0, + FittingSlot.LOW: 0, + } + for mod in fit.modules: + if mod.isEmpty: + continue + if mod.state >= FittingModuleState.ONLINE and mod.slot in counts: + counts[mod.slot] += 1 + return counts + + +def _get_total_slot_count(fit): + total = 0 + for slot_type in (FittingSlot.HIGH, FittingSlot.MED, FittingSlot.LOW, FittingSlot.RIG): + total += fit.getNumSlots(slot_type) + return total + + +def _get_base_module_hp(mod): + hp = mod.getModifiedItemAttr("hp") + return float(hp) + + +def _get_heat_damage(mod): + dmg = mod.getModifiedItemAttr("heatDamage") + return float(dmg) + + +def _get_cycle_time_s(mod): + cycle_params = mod.getCycleParameters() + if cycle_params is None: + return None + avg_time_ms = cycle_params.averageTime + if not math.isfinite(avg_time_ms) or avg_time_ms <= 0: + return None + return avg_time_ms / 1000.0 + + +def get_first_burnout_samples(fit, rack_slot, max_time_s, iterations): + """ + Monte Carlo simulation of time until the first module in the given rack burns out. + Returns a list of burnout times (seconds). If no burnout happens before max_time_s, + the sample is set to max_time_s for that run. + """ + if max_time_s <= 0 or iterations <= 0: + raise ValueError("max_time_s and iterations must be positive.") + + cache_key = (getattr(fit, "ID", None), int(rack_slot), max_time_s, iterations) + if cache_key in _burnout_samples_cache: + return list(_burnout_samples_cache[cache_key]) + + rack_suffix = _get_rack_suffix(rack_slot) + ship = fit.ship + heat_capacity = ship.getModifiedItemAttr(f"heatCapacity{rack_suffix}") + heat_generation_multiplier = ship.getModifiedItemAttr("heatGenerationMultiplier") + heat_attenuation = ship.getModifiedItemAttr(f"heatAttenuation{rack_suffix}") + if ( + heat_capacity is None + or heat_generation_multiplier is None + or heat_generation_multiplier <= 0 + or heat_attenuation is None + ): + raise ValueError("Missing heat attributes on ship for burnout simulation") + + rack_modules = list(iter_rack_modules(fit, rack_slot)) + if not rack_modules: + raise ValueError("No modules in this rack.") + + overheated_indices = [ + idx for idx, mod in enumerate(rack_modules) if mod.state >= FittingModuleState.OVERHEATED + ] + if not overheated_indices: + raise ValueError( + "No overheated modules in this rack. Overheat at least one module in this rack to see the first-burnout CDF." + ) + + total_slots = _get_total_slot_count(fit) + if total_slots <= 0: + raise ValueError("Ship has no high/mid/low/rig slots.") + base_online_counts = _count_online_modules_by_rack(fit) + + base_hp = [_get_base_module_hp(mod) for mod in rack_modules] + heat_damage = [_get_heat_damage(mod) for mod in rack_modules] + heat_absorption = [ + mod.getModifiedItemAttr("heatAbsorbtionRateModifier") for mod in rack_modules + ] + cycle_times = [_get_cycle_time_s(mod) if idx in overheated_indices else None + for idx, mod in enumerate(rack_modules)] + eligible_targets = [ + mod.state >= FittingModuleState.ONLINE for mod in rack_modules + ] + positions = list(range(len(rack_modules))) + + samples = [] + + for _ in range(iterations): + hp = list(base_hp) + dead = [hp_val <= 0 for hp_val in hp] + online_counts = dict(base_online_counts) + next_times = [None] * len(rack_modules) + + for idx in overheated_indices: + if not dead[idx] and cycle_times[idx] is not None: + next_times[idx] = cycle_times[idx] + + sample_time = max_time_s + + while True: + # Find next event time + candidates = [t for t in next_times if t is not None] + if not candidates: + break + current_time = min(candidates) + if current_time > max_time_s: + break + + # Dynamic sum of heat absorption from still-active overheated modules + sum_absorption = 0.0 + for idx in overheated_indices: + if not dead[idx] and cycle_times[idx] is not None: + sum_absorption += heat_absorption[idx] + if sum_absorption <= 0: + break + + argument = -current_time * heat_generation_multiplier * sum_absorption + try: + exp_term = math.exp(argument) + except OverflowError: + exp_term = 0.0 if argument < 0 else float("inf") + heat = heat_capacity / 100.0 - exp_term + if heat <= 0: + break + + numerator = ( + online_counts[FittingSlot.HIGH] + + online_counts[FittingSlot.MED] + + online_counts[FittingSlot.LOW] + ) + slot_factor = numerator / float(total_slots) + if slot_factor <= 0: + break + + # Sources that complete a cycle at this time + event_sources = [ + idx + for idx in overheated_indices + if not dead[idx] + and next_times[idx] is not None + and abs(next_times[idx] - current_time) <= 1e-9 + ] + if not event_sources: + # No actual events despite candidates, advance all timers and continue + for idx, next_time in enumerate(next_times): + if next_time is not None and cycle_times[idx] is not None: + next_times[idx] = next_time + cycle_times[idx] + continue + + burn_time = None + + for src_idx in event_sources: + dmg = heat_damage[src_idx] + if dmg <= 0: + continue + src_pos = positions[src_idx] + for tgt_idx, tgt_hp in enumerate(hp): + if dead[tgt_idx] or not eligible_targets[tgt_idx]: + continue + distance = abs(positions[tgt_idx] - src_pos) + attenuation_factor = heat_attenuation ** distance + probability = heat * slot_factor * attenuation_factor + if probability <= 0: + continue + if probability >= 1.0 or random.random() < probability: + new_hp = tgt_hp - dmg + hp[tgt_idx] = new_hp + if new_hp <= 0 and not dead[tgt_idx]: + dead[tgt_idx] = True + if rack_modules[tgt_idx].slot in online_counts and rack_modules[ + tgt_idx + ].state >= FittingModuleState.ONLINE: + online_counts[rack_modules[tgt_idx].slot] -= 1 + if tgt_idx in overheated_indices: + next_times[tgt_idx] = None + if burn_time is None or current_time < burn_time: + burn_time = current_time + + if burn_time is not None: + sample_time = burn_time + break + + # Advance timers for all sources that fired at this time + for src_idx in event_sources: + if not dead[src_idx] and cycle_times[src_idx] is not None: + next_times[src_idx] = current_time + cycle_times[src_idx] + + samples.append(sample_time) + + _burnout_samples_cache[cache_key] = samples + return samples + diff --git a/graphs/data/fitHeat/getter.py b/graphs/data/fitHeat/getter.py new file mode 100644 index 000000000..2bfef449e --- /dev/null +++ b/graphs/data/fitHeat/getter.py @@ -0,0 +1,109 @@ +# ============================================================================= +# Copyright (C) 2026 +# +# This file is part of pyfa. +# +# pyfa is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pyfa is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyfa. If not, see . +# ============================================================================= + + +from eos.const import FittingSlot +from graphs.data.base import SmoothPointGetter +from .calc import get_first_burnout_samples, get_rack_heat_value + + +class _BaseTime2RackHeatGetter(SmoothPointGetter): + + rack_slot = None + + def _getCommonData(self, miscParams, src, tgt): + return {"fit": src.item} + + def _calculatePoint(self, x, miscParams, src, tgt, commonData): + fit = commonData["fit"] + heat_value = get_rack_heat_value(fit, self.rack_slot, x) + return heat_value * 100.0 + + +class Time2RackHeatHiGetter(_BaseTime2RackHeatGetter): + rack_slot = FittingSlot.HIGH + + +class Time2RackHeatMedGetter(_BaseTime2RackHeatGetter): + rack_slot = FittingSlot.MED + + +class Time2RackHeatLowGetter(_BaseTime2RackHeatGetter): + rack_slot = FittingSlot.LOW + + +class _BaseTime2BurnoutCdfGetter(SmoothPointGetter): + + rack_slot = None + _iterations = 200 + + def getRange(self, xRange, miscParams, src, tgt): + fit = src.item + # Fixed simulation horizon so CDF does not depend on view range + max_sim_time = self.graph._limiters["time"](src, tgt)[1] + samples = get_first_burnout_samples( + fit=fit, rack_slot=self.rack_slot, max_time_s=max_sim_time, iterations=self._iterations + ) + xs = [] + ys = [] + if not samples: + for x in self._xIterLinear(xRange): + xs.append(x) + ys.append(0.0) + return xs, ys + samples = sorted(samples) + total = float(len(samples)) + index = 0 + for x in self._xIterLinear(xRange): + while index < len(samples) and samples[index] <= x: + index += 1 + xs.append(x) + ys.append(index / total) + return xs, ys + + def _calculatePoint(self, x, miscParams, src, tgt, commonData): + return self.getPoint(x=x, miscParams=miscParams, src=src, tgt=tgt) + + def getPoint(self, x, miscParams, src, tgt): + fit = src.item + max_sim_time = self.graph._limiters["time"](src, tgt)[1] + samples = get_first_burnout_samples( + fit=fit, rack_slot=self.rack_slot, max_time_s=max_sim_time, iterations=self._iterations + ) + if not samples: + return 0.0 + samples = sorted(samples) + total = float(len(samples)) + index = 0 + while index < len(samples) and samples[index] <= x: + index += 1 + return index / total + + +class Time2BurnoutCdfHiGetter(_BaseTime2BurnoutCdfGetter): + rack_slot = FittingSlot.HIGH + + +class Time2BurnoutCdfMedGetter(_BaseTime2BurnoutCdfGetter): + rack_slot = FittingSlot.MED + + +class Time2BurnoutCdfLowGetter(_BaseTime2BurnoutCdfGetter): + rack_slot = FittingSlot.LOW + diff --git a/graphs/data/fitHeat/graph.py b/graphs/data/fitHeat/graph.py new file mode 100644 index 000000000..c9e376065 --- /dev/null +++ b/graphs/data/fitHeat/graph.py @@ -0,0 +1,96 @@ +# ============================================================================= +# Copyright (C) 2026 +# +# This file is part of pyfa. +# +# pyfa is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pyfa is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyfa. If not, see . +# ============================================================================= + + +# noinspection PyPackageRequirements +import wx + +from service.const import GraphCacheCleanupReason +from graphs.data.base import FitGraph, Input, XDef, YDef +from .getter import ( + Time2BurnoutCdfHiGetter, + Time2BurnoutCdfLowGetter, + Time2BurnoutCdfMedGetter, + Time2RackHeatHiGetter, + Time2RackHeatLowGetter, + Time2RackHeatMedGetter, +) + + +_t = wx.GetTranslation + + +_CDF_Y_HANDLES = frozenset(("burnoutCdfHi", "burnoutCdfMed", "burnoutCdfLow")) + + +class FitHeatGraph(FitGraph): + + def getPlotPoints(self, mainInput, miscInputs, xSpec, ySpec, src, tgt=None): + if ySpec.handle in _CDF_Y_HANDLES: + return self._calcPlotPoints( + mainInput=mainInput, miscInputs=miscInputs, + xSpec=xSpec, ySpec=ySpec, src=src, tgt=tgt) + return super().getPlotPoints( + mainInput=mainInput, miscInputs=miscInputs, + xSpec=xSpec, ySpec=ySpec, src=src, tgt=tgt) + + # UI stuff + internalName = "heatGraph" + name = _t("Heat") + xDefs = [ + XDef(handle="time", unit="s", label=_t("Time"), mainInput=("time", "s")), + ] + yDefs = [ + YDef(handle="rackHeatHi", unit="%", label=_t("High rack heat")), + YDef(handle="rackHeatMed", unit="%", label=_t("Mid rack heat")), + YDef(handle="rackHeatLow", unit="%", label=_t("Low rack heat")), + YDef(handle="burnoutCdfHi", unit=None, label=_t("High rack first-burnout CDF")), + YDef(handle="burnoutCdfMed", unit=None, label=_t("Mid rack first-burnout CDF")), + YDef(handle="burnoutCdfLow", unit=None, label=_t("Low rack first-burnout CDF")), + ] + inputs = [ + Input( + handle="time", + unit="s", + label=_t("Time"), + iconID=1392, + defaultValue=300, + defaultRange=(0, 120), + ) + ] + srcExtraCols = () + + # Calculation stuff + _limiters = { + "time": lambda src, tgt: (0, 3600), + } + _getters = { + ("time", "rackHeatHi"): Time2RackHeatHiGetter, + ("time", "rackHeatMed"): Time2RackHeatMedGetter, + ("time", "rackHeatLow"): Time2RackHeatLowGetter, + ("time", "burnoutCdfHi"): Time2BurnoutCdfHiGetter, + ("time", "burnoutCdfMed"): Time2BurnoutCdfMedGetter, + ("time", "burnoutCdfLow"): Time2BurnoutCdfLowGetter, + } + + def clearCache(self, reason, extraData=None): + super().clearCache(reason=reason, extraData=extraData) + from .calc import clear_burnout_samples_cache + if reason in (GraphCacheCleanupReason.fitChanged, GraphCacheCleanupReason.fitRemoved) and extraData is not None: + clear_burnout_samples_cache(fit_id=extraData)