Compare commits

..

3 Commits

11 changed files with 1099 additions and 167 deletions

View File

@@ -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

View File

@@ -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 <http://www.gnu.org/licenses/>.
# =============================================================================
from .graph import FitHeatGraph
FitHeatGraph.register()

285
graphs/data/fitHeat/calc.py Normal file
View File

@@ -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 <http://www.gnu.org/licenses/>.
# =============================================================================
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

View File

@@ -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 <http://www.gnu.org/licenses/>.
# =============================================================================
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

View File

@@ -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 <http://www.gnu.org/licenses/>.
# =============================================================================
# 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)

View File

@@ -159,11 +159,24 @@ class CargoView(d.Display):
else:
dstCargoItemID = None
self.mainFrame.command.Submit(cmd.GuiLocalModuleToCargoCommand(
fitID=self.mainFrame.getActiveFit(),
modPosition=modIdx,
cargoItemID=dstCargoItemID,
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL))
modifiers = wx.GetMouseState().GetModifiers()
isCopy = modifiers == wx.MOD_CONTROL
isBatch = modifiers == wx.MOD_SHIFT
if isBatch:
self.mainFrame.command.Submit(
cmd.GuiBatchLocalModuleToCargoCommand(
fitID=self.mainFrame.getActiveFit(), modPosition=modIdx, copy=isCopy
)
)
else:
self.mainFrame.command.Submit(
cmd.GuiLocalModuleToCargoCommand(
fitID=self.mainFrame.getActiveFit(),
modPosition=modIdx,
cargoItemID=dstCargoItemID,
copy=isCopy,
)
)
def fitChanged(self, event):
event.Skip()

View File

@@ -497,11 +497,27 @@ class FittingView(d.Display):
fit = Fit.getInstance().getFit(fitID)
if mod in fit.modules:
position = fit.modules.index(mod)
self.mainFrame.command.Submit(cmd.GuiCargoToLocalModuleCommand(
fitID=fitID,
cargoItemID=cargoItemID,
modPosition=position,
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL))
modifiers = wx.GetMouseState().GetModifiers()
isCopy = modifiers == wx.MOD_CONTROL
isBatch = modifiers == wx.MOD_SHIFT
if isBatch:
self.mainFrame.command.Submit(
cmd.GuiBatchCargoToLocalModuleCommand(
fitID=fitID,
cargoItemID=cargoItemID,
targetPosition=position,
copy=isCopy,
)
)
else:
self.mainFrame.command.Submit(
cmd.GuiCargoToLocalModuleCommand(
fitID=fitID,
cargoItemID=cargoItemID,
modPosition=position,
copy=isCopy,
)
)
def swapItems(self, x, y, srcIdx):
"""Swap two modules in fitting window"""
@@ -795,7 +811,6 @@ class FittingView(d.Display):
del mod.restrictionOverridden
hasRestrictionOverriden = not hasRestrictionOverriden
if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red
self.SetItemBackgroundColour(i, errColorDark if isDark() else errColor)
elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled

View File

@@ -59,6 +59,12 @@ from .gui.localModule.mutatedRevert import GuiRevertMutatedLocalModuleCommand
from .gui.localModule.remove import GuiRemoveLocalModuleCommand
from .gui.localModule.replace import GuiReplaceLocalModuleCommand
from .gui.localModule.swap import GuiSwapLocalModulesCommand
from .gui.localModuleCargo.batchCargoToLocalModule import (
GuiBatchCargoToLocalModuleCommand,
)
from .gui.localModuleCargo.batchLocalModuleToCargo import (
GuiBatchLocalModuleToCargoCommand,
)
from .gui.localModuleCargo.cargoToLocalModule import GuiCargoToLocalModuleCommand
from .gui.localModuleCargo.localModuleToCargo import GuiLocalModuleToCargoCommand
from .gui.projectedChangeProjectionRange import GuiChangeProjectedItemsProjectionRangeCommand

View File

@@ -0,0 +1,325 @@
import wx
import eos.db
import gui.mainFrame
from gui import globalEvents as GE
from gui.fitCommands.calc.cargo.add import CalcAddCargoCommand
from gui.fitCommands.calc.cargo.remove import CalcRemoveCargoCommand
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
from gui.fitCommands.calc.module.localReplace import CalcReplaceLocalModuleCommand
from gui.fitCommands.helpers import (
CargoInfo,
InternalCommandHistory,
ModuleInfo,
restoreRemovedDummies,
)
from service.fit import Fit
class GuiBatchCargoToLocalModuleCommand(wx.Command):
def __init__(self, fitID, cargoItemID, targetPosition, copy):
wx.Command.__init__(self, True, "Batch Cargo to Local Modules")
self.internalHistory = InternalCommandHistory()
self.fitID = fitID
self.srcCargoItemID = cargoItemID
self.targetPosition = targetPosition
self.copy = copy
self.replacedModItemIDs = []
self.savedRemovedDummies = None
def Do(self):
sFit = Fit.getInstance()
fit = sFit.getFit(self.fitID)
if fit is None:
return False
srcCargo = next((c for c in fit.cargo if c.itemID == self.srcCargoItemID), None)
if srcCargo is None:
return False
if srcCargo.item.isCharge:
return self._handleCharges(fit, srcCargo)
if not srcCargo.item.isModule:
return False
if self.targetPosition >= len(fit.modules):
return False
targetMod = fit.modules[self.targetPosition]
if targetMod.isEmpty:
return self._fillEmptySlots(fit, srcCargo, targetMod.slot)
else:
return self._replaceSimilarModules(fit, srcCargo, targetMod)
def _getSimilarModulePositions(self, fit, targetMod):
targetItemID = targetMod.itemID
matchingPositions = []
for position, mod in enumerate(fit.modules):
if mod.isEmpty:
continue
if mod.itemID == targetItemID:
matchingPositions.append(position)
return matchingPositions
def _replaceSimilarModules(self, fit, srcCargo, targetMod):
availableAmount = srcCargo.amount if not self.copy else float("inf")
matchingPositions = self._getSimilarModulePositions(fit, targetMod)
if not matchingPositions:
return False
positionsToReplace = matchingPositions[: int(availableAmount)]
if not positionsToReplace:
return False
self.replacedModItemIDs = []
commands = []
cargoToRemove = 0
for position in positionsToReplace:
mod = fit.modules[position]
if mod.isEmpty:
continue
dstModItemID = mod.itemID
newModInfo = ModuleInfo.fromModule(mod, unmutate=True)
newModInfo.itemID = self.srcCargoItemID
newCargoModItemID = ModuleInfo.fromModule(mod, unmutate=True).itemID
commands.append(
CalcAddCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(itemID=newCargoModItemID, amount=1),
)
)
cmdReplace = CalcReplaceLocalModuleCommand(
fitID=self.fitID,
position=position,
newModInfo=newModInfo,
unloadInvalidCharges=True,
)
commands.append(cmdReplace)
self.replacedModItemIDs.append(dstModItemID)
cargoToRemove += 1
if not self.copy and cargoToRemove > 0:
commands.insert(
0,
CalcRemoveCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(
itemID=self.srcCargoItemID, amount=cargoToRemove
),
),
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = []
for removedModItemID in self.replacedModItemIDs:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="moddel", typeID=removedModItemID
)
)
if self.srcCargoItemID is not None:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="modadd", typeID=self.srcCargoItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return True
def _fillEmptySlots(self, fit, srcCargo, targetSlot):
availableAmount = srcCargo.amount if not self.copy else float("inf")
emptyPositions = []
for position, mod in enumerate(fit.modules):
if mod.isEmpty and mod.slot == targetSlot:
emptyPositions.append(position)
if not emptyPositions:
return False
positionsToFill = emptyPositions[: int(availableAmount)]
if not positionsToFill:
return False
commands = []
cargoToRemove = 0
for position in positionsToFill:
newModInfo = ModuleInfo(itemID=self.srcCargoItemID)
cmdReplace = CalcReplaceLocalModuleCommand(
fitID=self.fitID,
position=position,
newModInfo=newModInfo,
unloadInvalidCharges=True,
)
commands.append(cmdReplace)
cargoToRemove += 1
if not self.copy and cargoToRemove > 0:
commands.insert(
0,
CalcRemoveCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(
itemID=self.srcCargoItemID, amount=cargoToRemove
),
),
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = []
if self.srcCargoItemID is not None:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="modadd", typeID=self.srcCargoItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return True
def _handleCharges(self, fit, srcCargo):
availableAmount = srcCargo.amount if not self.copy else float("inf")
targetMod = fit.modules[self.targetPosition]
if targetMod.isEmpty:
return False
targetItemID = targetMod.itemID
matchingPositions = []
for position, mod in enumerate(fit.modules):
if mod.isEmpty:
continue
if mod.itemID == targetItemID:
matchingPositions.append(position)
if not matchingPositions:
return False
positionsToReplace = matchingPositions[: int(availableAmount)]
if not positionsToReplace:
return False
commands = []
chargeMap = {}
totalChargesNeeded = 0
for position in positionsToReplace:
mod = fit.modules[position]
if mod.isEmpty:
continue
oldChargeID = mod.chargeID
oldChargeAmount = mod.numCharges
newChargeAmount = mod.getNumCharges(srcCargo.item)
if oldChargeID is not None and oldChargeID != srcCargo.itemID:
commands.append(
CalcAddCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(itemID=oldChargeID, amount=oldChargeAmount),
)
)
chargeMap[position] = srcCargo.itemID
totalChargesNeeded += newChargeAmount
if not self.copy and totalChargesNeeded > 0:
commands.append(
CalcRemoveCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(
itemID=srcCargo.itemID, amount=totalChargesNeeded
),
)
)
commands.append(
CalcChangeModuleChargesCommand(
fitID=self.fitID, projected=False, chargeMap=chargeMap
)
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = [GE.FitChanged(fitIDs=(self.fitID,))]
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return True
def Undo(self):
sFit = Fit.getInstance()
fit = sFit.getFit(self.fitID)
restoreRemovedDummies(fit, self.savedRemovedDummies)
success = self.internalHistory.undoAll()
eos.db.flush()
sFit.recalc(self.fitID)
sFit.fill(self.fitID)
eos.db.commit()
events = []
if self.srcCargoItemID is not None:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="moddel", typeID=self.srcCargoItemID
)
)
for removedModItemID in self.replacedModItemIDs:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="modadd", typeID=removedModItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return success

View File

@@ -0,0 +1,167 @@
import wx
import eos.db
import gui.mainFrame
from gui import globalEvents as GE
from gui.fitCommands.calc.cargo.add import CalcAddCargoCommand
from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand
from gui.fitCommands.helpers import (
CargoInfo,
InternalCommandHistory,
ModuleInfo,
restoreRemovedDummies,
)
from service.fit import Fit
class GuiBatchLocalModuleToCargoCommand(wx.Command):
def __init__(self, fitID, modPosition, copy):
wx.Command.__init__(self, True, "Batch Local Module to Cargo")
self.internalHistory = InternalCommandHistory()
self.fitID = fitID
self.srcModPosition = modPosition
self.copy = copy
self.removedModItemIDs = []
self.savedRemovedDummies = None
def Do(self):
fit = Fit.getInstance().getFit(self.fitID)
srcMod = fit.modules[self.srcModPosition]
if srcMod.isEmpty:
return False
if srcMod.chargeID is not None:
return self._unloadCharges(fit, srcMod)
else:
return self._moveModulesToCargo(fit, srcMod)
def _getSimilarModulePositions(self, fit, targetMod):
targetItemID = targetMod.itemID
matchingPositions = []
for position, mod in enumerate(fit.modules):
if mod.isEmpty:
continue
if mod.itemID == targetItemID:
matchingPositions.append(position)
return matchingPositions
def _unloadCharges(self, fit, srcMod):
matchingPositions = self._getSimilarModulePositions(fit, srcMod)
if not matchingPositions:
return False
commands = []
for position in matchingPositions:
mod = fit.modules[position]
if mod.isEmpty:
continue
if mod.chargeID is not None:
commands.append(
CalcAddCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(itemID=mod.chargeID, amount=mod.numCharges),
)
)
if not self.copy:
from gui.fitCommands.calc.module.changeCharges import (
CalcChangeModuleChargesCommand,
)
chargeMap = {pos: None for pos in matchingPositions}
commands.append(
CalcChangeModuleChargesCommand(
fitID=self.fitID, projected=False, chargeMap=chargeMap
)
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = [GE.FitChanged(fitIDs=(self.fitID,))]
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return success
def _moveModulesToCargo(self, fit, srcMod):
matchingPositions = self._getSimilarModulePositions(fit, srcMod)
if not matchingPositions:
return False
commands = []
for position in matchingPositions:
mod = fit.modules[position]
if mod.isEmpty:
continue
commands.append(
CalcAddCargoCommand(
fitID=self.fitID,
cargoInfo=CargoInfo(
itemID=ModuleInfo.fromModule(mod, unmutate=True).itemID,
amount=1,
),
)
)
self.removedModItemIDs.append(mod.itemID)
if not self.copy:
commands.append(
CalcRemoveLocalModulesCommand(
fitID=self.fitID, positions=matchingPositions
)
)
success = self.internalHistory.submitBatch(*commands)
if not success:
self.internalHistory.undoAll()
return False
eos.db.flush()
sFit = Fit.getInstance()
sFit.recalc(self.fitID)
self.savedRemovedDummies = sFit.fill(self.fitID)
eos.db.commit()
events = []
for removedModItemID in self.removedModItemIDs:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="moddel", typeID=removedModItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return success
def Undo(self):
sFit = Fit.getInstance()
fit = sFit.getFit(self.fitID)
restoreRemovedDummies(fit, self.savedRemovedDummies)
success = self.internalHistory.undoAll()
eos.db.flush()
sFit.recalc(self.fitID)
sFit.fill(self.fitID)
eos.db.commit()
events = []
for removedModItemID in self.removedModItemIDs:
events.append(
GE.FitChanged(
fitIDs=(self.fitID,), action="modadd", typeID=removedModItemID
)
)
if not events:
events.append(GE.FitChanged(fitIDs=(self.fitID,)))
for event in events:
wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), event)
return success

View File

@@ -20,15 +20,19 @@
# noinspection PyPackageRequirements
import wx
from collections import Counter
import re
from eos.const import FittingSlot
from service.fit import Fit as svcFit
from service.port.eft import exportEft, importEft, _importPrepare
from service.port.eft import exportEft
from service.const import PortEftOptions
_t = wx.GetTranslation
# Regex for parsing items: itemName x? quantity?, ,? chargeName?
ITEM_REGEX = re.compile(
r"^(?P<itemName>[-\'\w\s]+?)x?\s*(?P<quantity>\d+)?\s*(?:,\s*(?P<chargeName>[-\'\w\s]+))?$"
)
class FitDiffFrame(wx.Frame):
"""A frame to display differences between two fits."""
@@ -169,158 +173,44 @@ class FitDiffFrame(wx.Frame):
self.diffText.SetValue('\n'.join(diffLines))
def parsePastedFit(self, text):
"""Parse pasted EFT text into a fit object."""
try:
lines = _importPrepare(text.splitlines())
if not lines:
return None
return importEft(lines)
except Exception:
return None
"""Parse pasted EFT text into a map of item name to count."""
items = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("["):
continue
match = ITEM_REGEX.match(line)
if match:
item_name = match.group("itemName").strip()
quantity = match.group("quantity")
count = int(quantity) if quantity else 1
if item_name not in items:
items[item_name] = 0
items[item_name] += count
return items
def calculateDiff(self, fit1, fit2):
def calculateDiff(self, fit1_items, fit2_items):
"""Calculate items needed to transform fit1 into fit2.
Returns a list of strings in the format: "<item> <quantity>"
Only shows items that need to be added (no negative values).
Returns a list of strings showing additions and extra items.
"""
diffLines = []
# Get module counts by type for each fit (grouped by slot type)
fit1_modules = self.getModuleCounts(fit1)
fit2_modules = self.getModuleCounts(fit2)
all_items = set(fit1_items.keys()) | set(fit2_items.keys())
additions = []
extras = []
# Slot order
slotOrder = [
FittingSlot.HIGH,
FittingSlot.MED,
FittingSlot.LOW,
FittingSlot.RIG,
FittingSlot.SUBSYSTEM,
FittingSlot.SERVICE,
]
# Diff modules by slot - only show items needed to add
for slot in slotOrder:
fit1_slot_modules = fit1_modules.get(slot, Counter())
fit2_slot_modules = fit2_modules.get(slot, Counter())
all_module_types = set(fit1_slot_modules.keys()) | set(fit2_slot_modules.keys())
slot_diff_lines = []
for module_type in sorted(all_module_types):
count1 = fit1_slot_modules.get(module_type, 0)
count2 = fit2_slot_modules.get(module_type, 0)
if count2 > count1:
slot_diff_lines.append(f"{module_type} x{count2 - count1}")
if slot_diff_lines:
if diffLines:
diffLines.append("")
diffLines.extend(slot_diff_lines)
# Get drone counts
fit1_drones = self.getDroneCounts(fit1)
fit2_drones = self.getDroneCounts(fit2)
all_drone_types = set(fit1_drones.keys()) | set(fit2_drones.keys())
for drone_type in sorted(all_drone_types):
count1 = fit1_drones.get(drone_type, 0)
count2 = fit2_drones.get(drone_type, 0)
for item in sorted(all_items):
count1 = fit1_items.get(item, 0)
count2 = fit2_items.get(item, 0)
if count2 > count1:
diffLines.append(f"{drone_type} x{count2 - count1}")
additions.append(f"{item} x{count2 - count1}")
elif count1 > count2:
extras.append(f"{item} x-{count1 - count2}")
# Get fighter counts
fit1_fighters = self.getFighterCounts(fit1)
fit2_fighters = self.getFighterCounts(fit2)
all_fighter_types = set(fit1_fighters.keys()) | set(fit2_fighters.keys())
for fighter_type in sorted(all_fighter_types):
count1 = fit1_fighters.get(fighter_type, 0)
count2 = fit2_fighters.get(fighter_type, 0)
if count2 > count1:
diffLines.append(f"{fighter_type} x{count2 - count1}")
# Get cargo counts
fit1_cargo = self.getCargoCounts(fit1)
fit2_cargo = self.getCargoCounts(fit2)
all_cargo_types = set(fit1_cargo.keys()) | set(fit2_cargo.keys())
for cargo_type in sorted(all_cargo_types):
count1 = fit1_cargo.get(cargo_type, 0)
count2 = fit2_cargo.get(cargo_type, 0)
if count2 > count1:
diffLines.append(f"{cargo_type} x{count2 - count1}")
# Get implants
fit1_implants = self.getImplantNames(fit1)
fit2_implants = self.getImplantNames(fit2)
for implant in sorted(fit2_implants - fit1_implants):
diffLines.append(f"{implant} x1")
# Get boosters
fit1_boosters = self.getBoosterNames(fit1)
fit2_boosters = self.getBoosterNames(fit2)
for booster in sorted(fit2_boosters - fit1_boosters):
diffLines.append(f"{booster} x1")
diffLines.extend(additions)
if additions and extras:
diffLines.extend(["", ""])
diffLines.extend(extras)
return diffLines
def getModuleCounts(self, fit):
"""Get a counter of module types for a fit, grouped by slot type.
Returns a dict mapping FittingSlot -> Counter of module names.
Position doesn't matter, just counts by module name.
"""
counts_by_slot = {}
for module in fit.modules:
if module.isEmpty:
continue
slot = module.slot
if slot not in counts_by_slot:
counts_by_slot[slot] = Counter()
# Use item type name for comparison
name = module.item.typeName if module.item else ""
counts_by_slot[slot][name] += 1
return counts_by_slot
def getDroneCounts(self, fit):
"""Get a counter of drone types for a fit."""
counts = Counter()
for drone in fit.drones:
if drone.item:
counts[drone.item.typeName] += drone.amount
return counts
def getFighterCounts(self, fit):
"""Get a counter of fighter types for a fit."""
counts = Counter()
for fighter in fit.fighters:
if fighter.item:
counts[fighter.item.typeName] += fighter.amount
return counts
def getCargoCounts(self, fit):
"""Get a counter of cargo items for a fit."""
counts = Counter()
for cargo in fit.cargo:
if cargo.item:
counts[cargo.item.typeName] += cargo.amount
return counts
def getImplantNames(self, fit):
"""Get a set of implant names for a fit."""
names = set()
for implant in fit.implants:
if implant.item:
names.add(implant.item.typeName)
return names
def getBoosterNames(self, fit):
"""Get a set of booster names for a fit."""
names = set()
for booster in fit.boosters:
if booster.item:
names.add(booster.item.typeName)
return names