Compare commits

...

8 Commits

27 changed files with 1763 additions and 186 deletions

View File

@@ -23,8 +23,10 @@ rm -rf build dist
echo "Building binary with PyInstaller..." echo "Building binary with PyInstaller..."
uv run pyinstaller pyfa.spec uv run pyinstaller pyfa.spec
# cp oleacc* dist/pyfa/ # Headless CLI exe (console) into main dist folder
if [ -f dist/pyfa_headless/pyfa-headless.exe ]; then
cp dist/pyfa_headless/pyfa-headless.exe dist/pyfa/
fi
echo "" echo ""
echo "Build complete! Binary is located at: dist/pyfa/pyfa.exe" echo "Build complete! dist/pyfa/pyfa.exe (GUI), dist/pyfa/pyfa-headless.exe (HTTP server POST /simulate :9123)"
echo "You can run it with: dist/pyfa/pyfa.exe"

View File

@@ -18,13 +18,14 @@
# ============================================================================= # =============================================================================
from . import fitDamageStats from . import fitDamageStats as fitDamageStats
from . import fitEwarStats from . import fitEwarStats as fitEwarStats
from . import fitRemoteReps from . import fitRemoteReps as fitRemoteReps
from . import fitShieldRegen from . import fitShieldRegen as fitShieldRegen
from . import fitCapacitor from . import fitCapacitor as fitCapacitor
from . import fitMobility from . import fitMobility as fitMobility
from . import fitWarpTime from . import fitWarpTime as fitWarpTime
from . import fitLockTime from . import fitLockTime as fitLockTime
from . import fitHeat as fitHeat
# Hidden graphs, available via ctrl-alt-g # 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()

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

@@ -0,0 +1,295 @@
# =============================================================================
# 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 has_burnout_samples(fit, rack_slot, max_time_s, iterations):
cache_key = (getattr(fit, "ID", None), int(rack_slot), max_time_s, iterations)
return cache_key in _burnout_samples_cache
def get_first_burnout_samples(fit, rack_slot, max_time_s, iterations, progress_cb=None):
"""
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 i 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)
if progress_cb is not None:
# progress_cb should return True to continue, False to cancel
if not progress_cb(i + 1):
break
_burnout_samples_cache[cache_key] = samples
return samples

View File

@@ -0,0 +1,159 @@
# =============================================================================
# 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
import wx
from .calc import get_first_burnout_samples, get_rack_heat_value, has_burnout_samples
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]
iterations = miscParams.get("iterations", self._iterations)
try:
iterations = int(iterations)
except (TypeError, ValueError):
iterations = self._iterations
if iterations <= 0:
iterations = self._iterations
samples = None
# Show a progress dialog only on cache miss for expensive runs
if iterations >= 1000 and not has_burnout_samples(fit, self.rack_slot, max_sim_time, iterations):
app = wx.GetApp()
parent = app.GetTopWindow() if app is not None else None
dlg = wx.ProgressDialog(
"Computing burnout CDF",
"Running overheating simulations...",
maximum=iterations,
parent=parent,
style=wx.PD_APP_MODAL | wx.PD_ELAPSED_TIME | wx.PD_AUTO_HIDE,
)
def progress_cb(done):
# dlg.Update returns (continue, skip)
cont, _ = dlg.Update(done)
return cont
try:
samples = get_first_burnout_samples(
fit=fit,
rack_slot=self.rack_slot,
max_time_s=max_sim_time,
iterations=iterations,
progress_cb=progress_cb,
)
finally:
dlg.Destroy()
else:
samples = get_first_burnout_samples(
fit=fit,
rack_slot=self.rack_slot,
max_time_s=max_sim_time,
iterations=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]
iterations = miscParams.get("iterations", self._iterations)
try:
iterations = int(iterations)
except (TypeError, ValueError):
iterations = self._iterations
if iterations <= 0:
iterations = self._iterations
samples = get_first_burnout_samples(
fit=fit,
rack_slot=self.rack_slot,
max_time_s=max_sim_time,
iterations=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,104 @@
# =============================================================================
# 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="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")),
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")),
]
inputs = [
Input(
handle="time",
unit="s",
label=_t("Time"),
iconID=1392,
defaultValue=300,
defaultRange=(0, 120),
),
Input(
handle="iterations",
unit=None,
label=_t("Iterations"),
iconID=1392,
defaultValue=10000,
defaultRange=(100, 50000),
),
]
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: else:
dstCargoItemID = None dstCargoItemID = None
self.mainFrame.command.Submit(cmd.GuiLocalModuleToCargoCommand( modifiers = wx.GetMouseState().GetModifiers()
fitID=self.mainFrame.getActiveFit(), isCopy = modifiers == wx.MOD_CONTROL
modPosition=modIdx, isBatch = modifiers == wx.MOD_SHIFT
cargoItemID=dstCargoItemID, if isBatch:
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL)) 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): def fitChanged(self, event):
event.Skip() event.Skip()

View File

@@ -5,9 +5,6 @@ import wx
import gui.mainFrame import gui.mainFrame
from gui.contextMenu import ContextMenuSingle from gui.contextMenu import ContextMenuSingle
from gui.fitCommands import (
GuiConvertMutatedLocalModuleCommand, GuiRevertMutatedLocalModuleCommand,
GuiConvertMutatedLocalDroneCommand, GuiRevertMutatedLocalDroneCommand)
from service.fit import Fit from service.fit import Fit
_t = wx.GetTranslation _t = wx.GetTranslation
@@ -65,6 +62,8 @@ class ChangeItemMutation(ContextMenuSingle):
return sub return sub
def handleMenu(self, event): def handleMenu(self, event):
from gui.fitCommands import (
GuiConvertMutatedLocalModuleCommand, GuiConvertMutatedLocalDroneCommand)
mutaplasmid, item = self.eventIDs[event.Id] mutaplasmid, item = self.eventIDs[event.Id]
fitID = self.mainFrame.getActiveFit() fitID = self.mainFrame.getActiveFit()
fit = Fit.getInstance().getFit(fitID) fit = Fit.getInstance().getFit(fitID)
@@ -78,6 +77,8 @@ class ChangeItemMutation(ContextMenuSingle):
fitID=fitID, position=position, mutaplasmid=mutaplasmid)) fitID=fitID, position=position, mutaplasmid=mutaplasmid))
def activate(self, callingWindow, fullContext, mainItem, i): def activate(self, callingWindow, fullContext, mainItem, i):
from gui.fitCommands import (
GuiRevertMutatedLocalModuleCommand, GuiRevertMutatedLocalDroneCommand)
fitID = self.mainFrame.getActiveFit() fitID = self.mainFrame.getActiveFit()
fit = Fit.getInstance().getFit(fitID) fit = Fit.getInstance().getFit(fitID)
if mainItem in fit.modules: if mainItem in fit.modules:

View File

@@ -497,11 +497,27 @@ class FittingView(d.Display):
fit = Fit.getInstance().getFit(fitID) fit = Fit.getInstance().getFit(fitID)
if mod in fit.modules: if mod in fit.modules:
position = fit.modules.index(mod) position = fit.modules.index(mod)
self.mainFrame.command.Submit(cmd.GuiCargoToLocalModuleCommand( modifiers = wx.GetMouseState().GetModifiers()
fitID=fitID, isCopy = modifiers == wx.MOD_CONTROL
cargoItemID=cargoItemID, isBatch = modifiers == wx.MOD_SHIFT
modPosition=position, if isBatch:
copy=wx.GetMouseState().GetModifiers() == wx.MOD_CONTROL)) 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): def swapItems(self, x, y, srcIdx):
"""Swap two modules in fitting window""" """Swap two modules in fitting window"""
@@ -795,7 +811,6 @@ class FittingView(d.Display):
del mod.restrictionOverridden del mod.restrictionOverridden
hasRestrictionOverriden = not hasRestrictionOverriden hasRestrictionOverriden = not hasRestrictionOverriden
if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red
self.SetItemBackgroundColour(i, errColorDark if isDark() else errColor) self.SetItemBackgroundColour(i, errColorDark if isDark() else errColor)
elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled 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.remove import GuiRemoveLocalModuleCommand
from .gui.localModule.replace import GuiReplaceLocalModuleCommand from .gui.localModule.replace import GuiReplaceLocalModuleCommand
from .gui.localModule.swap import GuiSwapLocalModulesCommand 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.cargoToLocalModule import GuiCargoToLocalModuleCommand
from .gui.localModuleCargo.localModuleToCargo import GuiLocalModuleToCargoCommand from .gui.localModuleCargo.localModuleToCargo import GuiLocalModuleToCargoCommand
from .gui.projectedChangeProjectionRange import GuiChangeProjectedItemsProjectionRangeCommand 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 # noinspection PyPackageRequirements
import wx import wx
from collections import Counter import re
from eos.const import FittingSlot
from service.fit import Fit as svcFit 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 from service.const import PortEftOptions
_t = wx.GetTranslation _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): class FitDiffFrame(wx.Frame):
"""A frame to display differences between two fits.""" """A frame to display differences between two fits."""
@@ -169,158 +173,44 @@ class FitDiffFrame(wx.Frame):
self.diffText.SetValue('\n'.join(diffLines)) self.diffText.SetValue('\n'.join(diffLines))
def parsePastedFit(self, text): def parsePastedFit(self, text):
"""Parse pasted EFT text into a fit object.""" """Parse pasted EFT text into a map of item name to count."""
try: items = {}
lines = _importPrepare(text.splitlines()) for line in text.splitlines():
if not lines: line = line.strip()
return None if not line or line.startswith("["):
return importEft(lines) continue
except Exception: match = ITEM_REGEX.match(line)
return None 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. """Calculate items needed to transform fit1 into fit2.
Returns a list of strings in the format: "<item> <quantity>" Returns a list of strings showing additions and extra items.
Only shows items that need to be added (no negative values).
""" """
diffLines = [] diffLines = []
# Get module counts by type for each fit (grouped by slot type) all_items = set(fit1_items.keys()) | set(fit2_items.keys())
fit1_modules = self.getModuleCounts(fit1) additions = []
fit2_modules = self.getModuleCounts(fit2) extras = []
# Slot order for item in sorted(all_items):
slotOrder = [ count1 = fit1_items.get(item, 0)
FittingSlot.HIGH, count2 = fit2_items.get(item, 0)
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)
if count2 > count1: 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 diffLines.extend(additions)
fit1_fighters = self.getFighterCounts(fit1) if additions and extras:
fit2_fighters = self.getFighterCounts(fit2) diffLines.extend(["", ""])
diffLines.extend(extras)
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")
return diffLines 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

View File

@@ -79,8 +79,20 @@ a = Analysis(['pyfa.py'],
win_private_assemblies=False, win_private_assemblies=False,
cipher=block_cipher) cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) a_headless = Analysis(['pyfa_headless.py'],
pathex=pathex,
binaries=[],
datas=added_files,
hiddenimports=import_these,
hookspath=['dist_assets/pyinstaller_hooks'],
runtime_hooks=[],
excludes=['Tkinter'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
pyz_headless = PYZ(a_headless.pure, a_headless.zipped_data, cipher=block_cipher)
exe = EXE( exe = EXE(
pyz, pyz,
@@ -96,6 +108,19 @@ exe = EXE(
contents_directory='app', contents_directory='app',
) )
# Headless: server only. POST /simulate on port 9123.
exe_headless = EXE(
pyz_headless,
a_headless.scripts,
exclude_binaries=True,
name='pyfa-headless',
debug=debug,
strip=False,
upx=upx,
console=True,
contents_directory='app',
)
coll = COLLECT( coll = COLLECT(
exe, exe,
a.binaries, a.binaries,
@@ -106,6 +131,16 @@ coll = COLLECT(
name='pyfa', name='pyfa',
) )
coll_headless = COLLECT(
exe_headless,
a_headless.binaries,
a_headless.zipfiles,
a_headless.datas,
strip=False,
upx=upx,
name='pyfa_headless',
)
if platform.system() == 'Darwin': if platform.system() == 'Darwin':
info_plist = { info_plist = {
'NSHighResolutionCapable': 'True', 'NSHighResolutionCapable': 'True',

5
pyfa_headless.py Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env python3
# Headless sim daemon only. POST /simulate with JSON body.
from scripts.pyfa_cli_stats import _run_http_server
_run_http_server(9123, None)

View File

@@ -22,3 +22,6 @@ dependencies = [
"sqlalchemy==1.4.50", "sqlalchemy==1.4.50",
"wxpython==4.2.1", "wxpython==4.2.1",
] ]
[project.optional-dependencies]
dev = ["pytest"]

414
scripts/pyfa_cli_stats.py Normal file
View File

@@ -0,0 +1,414 @@
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
import config
import eos.config
from eos.const import FittingHardpoint
from eos.utils.spoolSupport import SpoolOptions, SpoolType
def _init_pyfa(savepath: str | None) -> None:
config.debug = False
config.loggingLevel = config.LOGLEVEL_MAP["error"]
config.defPaths(savepath)
config.defLogging()
import eos.db # noqa: F401
eos.db.saveddata_meta.create_all() # type: ignore[name-defined]
import eos.events # noqa: F401
from service import prefetch # noqa: F401
def _parse_drone_markers(text: str) -> tuple[str, set[str]]:
active_names: set[str] = set()
cleaned_lines: list[str] = []
for raw_line in text.splitlines():
line = raw_line.rstrip()
if " xx" in line:
idx = line.find(" xx")
marker_segment = line[idx + 1 :].strip()
if marker_segment == "xx":
payload = line[:idx].rstrip()
parts = payload.split(" x", 1)
if len(parts) == 2:
type_name = parts[0].strip()
if type_name:
active_names.add(type_name)
line = payload
cleaned_lines.append(line)
return "\n".join(cleaned_lines), active_names
def _import_single_fit(raw_text: str) -> tuple[object, set[str]]:
from service.port.port import Port
cleaned_text, active_names = _parse_drone_markers(raw_text)
import_type, import_data = Port.importFitFromBuffer(cleaned_text)
if not import_data or len(import_data) != 1:
raise ValueError("Expected exactly one fit in input; got %d" % (len(import_data) if import_data else 0))
fit = import_data[0]
if active_names:
for drone in fit.drones:
if getattr(drone.item, "typeName", None) in active_names:
drone.amountActive = drone.amount
for fighter in fit.fighters:
if getattr(fighter.item, "typeName", None) in active_names:
fighter.amount = fighter.amount
return fit, active_names
def _add_projected_fit(s_fit, target_fit, projected_fit, amount: int) -> None:
fit = s_fit.getFit(target_fit.ID)
projected = s_fit.getFit(projected_fit.ID, projected=True)
if projected is None:
raise ValueError("Projected fit %s is not available" % projected_fit.ID)
if projected in fit.projectedFits and projected.ID in fit.projectedFitDict:
projection_info = projected.getProjectionInfo(fit.ID)
if projection_info is None:
raise ValueError("Projection info missing for projected fit %s" % projected_fit.ID)
else:
fit.projectedFitDict[projected.ID] = projected
eos.db.saveddata_session.flush()
eos.db.saveddata_session.refresh(projected)
projection_info = projected.getProjectionInfo(fit.ID)
if projection_info is None:
raise ValueError("Projection info missing after linking projected fit %s" % projected_fit.ID)
projection_info.amount = amount
projection_info.active = True
def _add_command_fit(s_fit, target_fit, command_fit) -> None:
fit = s_fit.getFit(target_fit.ID)
command = s_fit.getFit(command_fit.ID)
if command is None:
raise ValueError("Command fit %s is not available" % command_fit.ID)
if command in fit.commandFits or command.ID in fit.commandFitDict:
return
fit.commandFitDict[command.ID] = command
eos.db.saveddata_session.flush()
eos.db.saveddata_session.refresh(command)
info = command.getCommandInfo(fit.ID)
if info is None:
raise ValueError("Command info missing for command fit %s" % command_fit.ID)
info.active = True
def _collect_resources(fit) -> dict:
ship = fit.ship
resources: dict = {}
resources["hardpoints"] = {
"turret": {
"used": fit.getHardpointsUsed(FittingHardpoint.TURRET),
"total": ship.getModifiedItemAttr("turretSlotsLeft"),
},
"launcher": {
"used": fit.getHardpointsUsed(FittingHardpoint.MISSILE),
"total": ship.getModifiedItemAttr("launcherSlotsLeft"),
},
}
resources["drones"] = {
"active": fit.activeDrones,
"max_active": fit.extraAttributes.get("maxActiveDrones"),
"bay_used": fit.droneBayUsed,
"bay_capacity": ship.getModifiedItemAttr("droneCapacity"),
"bandwidth_used": fit.droneBandwidthUsed,
"bandwidth_capacity": ship.getModifiedItemAttr("droneBandwidth"),
}
resources["fighters"] = {
"tubes_used": fit.fighterTubesUsed,
"tubes_total": fit.fighterTubesTotal,
"bay_used": fit.fighterBayUsed,
"bay_capacity": ship.getModifiedItemAttr("fighterCapacity"),
}
resources["calibration"] = {
"used": fit.calibrationUsed,
"total": ship.getModifiedItemAttr("upgradeCapacity"),
}
resources["powergrid"] = {
"used": fit.pgUsed,
"output": ship.getModifiedItemAttr("powerOutput"),
}
resources["cpu"] = {
"used": fit.cpuUsed,
"output": ship.getModifiedItemAttr("cpuOutput"),
}
resources["cargo"] = {
"used": fit.cargoBayUsed,
"capacity": ship.getModifiedItemAttr("capacity"),
}
return resources
def _collect_defense(fit) -> dict:
ship = fit.ship
def _res(attr_name):
return ship.getModifiedItemAttr(attr_name)
resonance = {
"armor": {"em": _res("armorEmDamageResonance"), "exp": _res("armorExplosiveDamageResonance"), "kin": _res("armorKineticDamageResonance"), "therm": _res("armorThermalDamageResonance")},
"shield": {"em": _res("shieldEmDamageResonance"), "exp": _res("shieldExplosiveDamageResonance"), "kin": _res("shieldKineticDamageResonance"), "therm": _res("shieldThermalDamageResonance")},
"hull": {"em": _res("emDamageResonance"), "exp": _res("explosiveDamageResonance"), "kin": _res("kineticDamageResonance"), "therm": _res("thermalDamageResonance")},
}
defense: dict = {}
defense["hp"] = fit.hp
defense["ehp"] = fit.ehp
defense["resonance"] = resonance
defense["tank"] = fit.tank
defense["effective_tank"] = fit.effectiveTank
defense["sustainable_tank"] = fit.sustainableTank
defense["effective_sustainable_tank"] = fit.effectiveSustainableTank
return defense
def _collect_capacitor(fit) -> dict:
ship = fit.ship
cap: dict = {}
cap["capacity"] = ship.getModifiedItemAttr("capacitorCapacity")
cap["recharge"] = fit.capRecharge
cap["use"] = fit.capUsed
cap["delta"] = fit.capDelta
cap["stable"] = fit.capStable
cap["state"] = fit.capState
cap["neutralizer_resistance"] = ship.getModifiedItemAttr("energyWarfareResistance", 1)
return cap
def _collect_firepower(fit) -> dict:
default_spool = eos.config.settings["globalDefaultSpoolupPercentage"]
spool = SpoolOptions(SpoolType.SPOOL_SCALE, default_spool, False)
wdps = fit.getWeaponDps(spoolOptions=spool)
ddps = fit.getDroneDps()
wvol = fit.getWeaponVolley(spoolOptions=spool)
dvol = fit.getDroneVolley()
return {
"weapon_dps": wdps.total,
"drone_dps": ddps.total,
"total_dps": fit.getTotalDps(spoolOptions=spool).total,
"weapon_volley": wvol.total,
"drone_volley": dvol.total,
"total_volley": fit.getTotalVolley(spoolOptions=spool).total,
"weapons": [],
}
def _collect_remote_reps(fit) -> dict:
default_spool = eos.config.settings["globalDefaultSpoolupPercentage"]
pre = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, 0, True))
full = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, 1, True))
current = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, default_spool, False))
return {
"current": {
"capacitor": current.capacitor,
"shield": current.shield,
"armor": current.armor,
"hull": current.hull,
},
"pre_spool": {
"capacitor": pre.capacitor,
"shield": pre.shield,
"armor": pre.armor,
"hull": pre.hull,
},
"full_spool": {
"capacitor": full.capacitor,
"shield": full.shield,
"armor": full.armor,
"hull": full.hull,
},
}
def _collect_targeting_misc(fit) -> dict:
ship = fit.ship
misc: dict = {}
misc["targets_max"] = fit.maxTargets
misc["target_range"] = fit.maxTargetRange
misc["scan_resolution"] = ship.getModifiedItemAttr("scanResolution")
misc["scan_strength"] = fit.scanStrength
misc["scan_type"] = fit.scanType
misc["jam_chance"] = fit.jamChance
misc["drone_control_range"] = fit.extraAttributes.get("droneControlRange")
misc["speed"] = fit.maxSpeed
misc["align_time"] = fit.alignTime
misc["signature_radius"] = ship.getModifiedItemAttr("signatureRadius")
misc["warp_speed"] = fit.warpSpeed
misc["max_warp_distance"] = fit.maxWarpDistance
misc["probe_size"] = fit.probeSize
misc["cargo_capacity"] = ship.getModifiedItemAttr("capacity")
misc["cargo_used"] = fit.cargoBayUsed
return misc
def _collect_price(fit) -> dict:
ship_price = 0.0
module_price = 0.0
drone_price = 0.0
fighter_price = 0.0
cargo_price = 0.0
booster_price = 0.0
implant_price = 0.0
if fit:
ship_price = getattr(fit.ship.item.price, "price", 0.0)
for module in fit.modules:
if not module.isEmpty:
module_price += getattr(module.item.price, "price", 0.0)
for drone in fit.drones:
drone_price += getattr(drone.item.price, "price", 0.0) * drone.amount
for fighter in fit.fighters:
fighter_price += getattr(fighter.item.price, "price", 0.0) * fighter.amount
for cargo in fit.cargo:
cargo_price += getattr(cargo.item.price, "price", 0.0) * cargo.amount
for booster in fit.boosters:
booster_price += getattr(booster.item.price, "price", 0.0)
for implant in fit.appliedImplants:
implant_price += getattr(implant.item.price, "price", 0.0)
total_price = ship_price + module_price + drone_price + fighter_price + cargo_price + booster_price + implant_price
return {
"ship": ship_price,
"fittings": module_price,
"drones_and_fighters": drone_price + fighter_price,
"cargo": cargo_price,
"character": booster_price + implant_price,
"total": total_price,
}
def _build_output(main_fit) -> dict:
return {
"fit": {
"id": main_fit.ID,
"name": main_fit.name,
"ship_type": main_fit.ship.item.typeName,
},
"resources": _collect_resources(main_fit),
"defense": _collect_defense(main_fit),
"capacitor": _collect_capacitor(main_fit),
"firepower": _collect_firepower(main_fit),
"remote_reps_outgoing": _collect_remote_reps(main_fit),
"targeting_misc": _collect_targeting_misc(main_fit),
"price": _collect_price(main_fit),
}
def compute_stats(payload: dict, savepath: str | None = None) -> dict:
if "fit" not in payload or not isinstance(payload["fit"], str):
raise ValueError("Payload must contain a 'fit' field with EFT/text export")
_init_pyfa(savepath)
from service.fit import Fit as FitService
s_fit = FitService.getInstance()
if s_fit.character is None:
from eos.saveddata.character import Character as saveddata_Character
s_fit.character = saveddata_Character.getAll5()
main_fit, _ = _import_single_fit(payload["fit"])
projected_defs = payload.get("projected_fits", [])
command_defs = payload.get("command_fits", [])
projected_fits: list[tuple[object, int]] = []
for entry in projected_defs:
if not isinstance(entry, dict):
raise ValueError("Each projected_fits entry must be an object")
fit_text = entry.get("fit")
count = entry.get("count")
if not isinstance(fit_text, str):
raise ValueError("Each projected_fits entry must contain a string 'fit'")
if not isinstance(count, int) or count <= 0:
raise ValueError("Each projected_fits entry must contain a positive integer 'count'")
pf, _ = _import_single_fit(fit_text)
projected_fits.append((pf, count))
command_fits: list[object] = []
for entry in command_defs:
if not isinstance(entry, dict):
raise ValueError("Each command_fits entry must be an object")
fit_text = entry.get("fit")
if not isinstance(fit_text, str):
raise ValueError("Each command_fits entry must contain a string 'fit'")
cf, _ = _import_single_fit(fit_text)
command_fits.append(cf)
for pf, count in projected_fits:
_add_projected_fit(s_fit, main_fit, pf, count)
for cf in command_fits:
_add_command_fit(s_fit, main_fit, cf)
s_fit.recalc(main_fit)
s_fit.fill(main_fit)
return _build_output(main_fit)
def _run_http_server(port: int, savepath: str | None) -> None:
class SimulateHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/simulate":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
self.send_response(400)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(("Invalid JSON: %s" % exc).encode("utf-8"))
return
if not isinstance(payload, dict):
self.send_response(400)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"Top-level JSON must be an object")
return
try:
output = compute_stats(payload, savepath)
except ValueError as e:
self.send_response(400)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(str(e).encode("utf-8"))
return
except Exception as e:
self.send_response(500)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(str(e).encode("utf-8"))
return
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(output, indent=2, sort_keys=True).encode("utf-8"))
self.wfile.write(b"\n")
with HTTPServer(("", port), SimulateHandler) as httpd:
print("POST /simulate on http://127.0.0.1:%s" % port, file=sys.stderr, flush=True)
httpd.serve_forever()
if __name__ == "__main__":
_run_http_server(9123, None)

View File

@@ -13,11 +13,9 @@ from service.const import EsiLoginMethod, EsiSsoMode
from eos.saveddata.ssocharacter import SsoCharacter from eos.saveddata.ssocharacter import SsoCharacter
from service.esiAccess import APIException, GenericSsoError from service.esiAccess import APIException, GenericSsoError
import gui.globalEvents as GE import gui.globalEvents as GE
from gui.ssoLogin import SsoLogin
from service.server import StoppableHTTPServer, AuthHandler from service.server import StoppableHTTPServer, AuthHandler
from service.settings import EsiSettings from service.settings import EsiSettings
from service.esiAccess import EsiAccess from service.esiAccess import EsiAccess
import gui.mainFrame
from requests import Session from requests import Session
@@ -140,6 +138,7 @@ class Esi(EsiAccess):
self.fittings_deleted.add(fittingID) self.fittings_deleted.add(fittingID)
def login(self): def login(self):
import gui.ssoLogin
start_server = self.settings.get('loginMode') == EsiLoginMethod.SERVER and self.server_base.supports_auto_login start_server = self.settings.get('loginMode') == EsiLoginMethod.SERVER and self.server_base.supports_auto_login
with gui.ssoLogin.SsoLogin(self.server_base, start_server) as dlg: with gui.ssoLogin.SsoLogin(self.server_base, start_server) as dlg:
if dlg.ShowModal() == wx.ID_OK: if dlg.ShowModal() == wx.ID_OK:

View File

@@ -1,2 +1,8 @@
from .efs import EfsPort from .efs import EfsPort
from .port import Port
def __getattr__(name):
if name == "Port":
from service.port.port import Port
return Port
raise AttributeError("module %r has no attribute %r" % (__name__, name))

View File

@@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter
from eos.saveddata.fit import Fit from eos.saveddata.fit import Fit
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.saveddata.ship import Ship from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.const import PortDnaOptions from service.const import PortDnaOptions
from service.fit import Fit as svcFit from service.fit import Fit as svcFit
from service.market import Market from service.market import Market
@@ -80,6 +79,7 @@ def importDnaAlt(string, fitName=None):
return processImportInfo(info, fitName, "*") return processImportInfo(info, fitName, "*")
def processImportInfo(info, fitName, amountSeparator): def processImportInfo(info, fitName, amountSeparator):
from gui.fitCommands.helpers import activeStateLimit
sMkt = Market.getInstance() sMkt = Market.getInstance()
f = Fit() f = Fit()
try: try:

View File

@@ -16,10 +16,6 @@ from eos.effectHandlerHelpers import HandledList
from eos.db import gamedata_session, getCategory, getAttributeInfo, getGroup from eos.db import gamedata_session, getCategory, getAttributeInfo, getGroup
from eos.gamedata import Attribute, Effect, Group, Item, ItemEffect from eos.gamedata import Attribute, Effect, Group, Item, ItemEffect
from eos.utils.spoolSupport import SpoolType, SpoolOptions from eos.utils.spoolSupport import SpoolType, SpoolOptions
from gui.fitCommands.calc.module.localAdd import CalcAddLocalModuleCommand
from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
from gui.fitCommands.helpers import ModuleInfo
pyfalog = Logger(__name__) pyfalog = Logger(__name__)
@@ -68,6 +64,9 @@ class EfsPort:
if propID is None: if propID is None:
return None return None
from gui.fitCommands.calc.module.localAdd import CalcAddLocalModuleCommand
from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand
from gui.fitCommands.helpers import ModuleInfo
cmd = CalcAddLocalModuleCommand(fitID, ModuleInfo(itemID=propID)) cmd = CalcAddLocalModuleCommand(fitID, ModuleInfo(itemID=propID))
cmd.Do() cmd.Do()
if cmd.needsGuiRecalc: if cmd.needsGuiRecalc:
@@ -137,6 +136,7 @@ class EfsPort:
EfsPort.attrDirectMap(["reloadTime"], stats, mod) EfsPort.attrDirectMap(["reloadTime"], stats, mod)
c = mod.charge c = mod.charge
if c: if c:
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
sFit.recalc(fit) sFit.recalc(fit)
CalcChangeModuleChargesCommand( CalcChangeModuleChargesCommand(
fit.ID, fit.ID,

View File

@@ -33,7 +33,6 @@ from eos.saveddata.fit import Fit
from eos.saveddata.implant import Implant from eos.saveddata.implant import Implant
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.saveddata.ship import Ship from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.const import PortEftOptions from service.const import PortEftOptions
from service.fit import Fit as svcFit from service.fit import Fit as svcFit
from service.market import Market from service.market import Market
@@ -241,6 +240,7 @@ def exportCargo(cargos):
def importEft(lines): def importEft(lines):
from gui.fitCommands.helpers import activeStateLimit
lines = _importPrepare(lines) lines = _importPrepare(lines)
try: try:
fit = _importCreateFit(lines) fit = _importCreateFit(lines)
@@ -877,6 +877,7 @@ class AbstractFit:
self.getContainerBySlot(m.slot).append(m) self.getContainerBySlot(m.slot).append(m)
def __makeModule(self, itemSpec): def __makeModule(self, itemSpec):
from gui.fitCommands.helpers import activeStateLimit
# Mutate item if needed # Mutate item if needed
m = None m = None
if itemSpec.mutationIdx in self.mutations: if itemSpec.mutationIdx in self.mutations:

View File

@@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter
from eos.saveddata.fit import Fit from eos.saveddata.fit import Fit
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.saveddata.ship import Ship from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.fit import Fit as svcFit from service.fit import Fit as svcFit
from service.market import Market from service.market import Market
@@ -161,6 +160,7 @@ def exportESI(ofit, exportCharges, exportImplants, exportBoosters, callback):
def importESI(string): def importESI(string):
from gui.fitCommands.helpers import activeStateLimit
sMkt = Market.getInstance() sMkt = Market.getInstance()
fitobj = Fit() fitobj = Fit()

View File

@@ -195,10 +195,14 @@ class Port:
# TODO: catch the exception? # TODO: catch the exception?
# activeFit is reserved?, bufferStr is unicode? (assume only clipboard string? # activeFit is reserved?, bufferStr is unicode? (assume only clipboard string?
sFit = svcFit.getInstance() sFit = svcFit.getInstance()
if sFit.character is None:
from eos.saveddata.character import Character as saveddata_Character
sFit.character = saveddata_Character.getAll5()
importType, makesNewFits, importData = Port.importAuto(bufferStr, activeFit=activeFit) importType, makesNewFits, importData = Port.importAuto(bufferStr, activeFit=activeFit)
if makesNewFits: if makesNewFits:
for fit in importData: fits = [f for f in importData if f is not None]
for fit in fits:
fit.character = sFit.character fit.character = sFit.character
fit.damagePattern = sFit.pattern fit.damagePattern = sFit.pattern
fit.targetProfile = sFit.targetProfile fit.targetProfile = sFit.targetProfile
@@ -208,6 +212,7 @@ class Port:
useCharImplants = sFit.serviceFittingOptions["useCharacterImplantsByDefault"] useCharImplants = sFit.serviceFittingOptions["useCharacterImplantsByDefault"]
fit.implantLocation = ImplantLocation.CHARACTER if useCharImplants else ImplantLocation.FIT fit.implantLocation = ImplantLocation.CHARACTER if useCharImplants else ImplantLocation.FIT
db.save(fit) db.save(fit)
return importType, fits
return importType, importData return importType, importData
@classmethod @classmethod

View File

@@ -32,7 +32,6 @@ from eos.saveddata.fighter import Fighter
from eos.saveddata.fit import Fit from eos.saveddata.fit import Fit
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.saveddata.ship import Ship from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.fit import Fit as svcFit from service.fit import Fit as svcFit
from service.market import Market from service.market import Market
from service.port.muta import renderMutantAttrs, parseMutantAttrs from service.port.muta import renderMutantAttrs, parseMutantAttrs
@@ -155,6 +154,7 @@ def _resolve_module(hardware, sMkt, b_localized):
def importXml(text, progress): def importXml(text, progress):
from gui.fitCommands.helpers import activeStateLimit
from .port import Port from .port import Port
sMkt = Market.getInstance() sMkt = Market.getInstance()
doc = xml.dom.minidom.parseString(text) doc = xml.dom.minidom.parseString(text)

View File

@@ -0,0 +1,47 @@
import json
import os
import sys
import tempfile
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.realpath(os.path.join(script_dir, "..")))
from scripts import pyfa_cli_stats # noqa: E402
ISHTAR_SPIDER_FIT = """[Ishtar, Spider]
Capacitor Power Relay II
Drone Damage Amplifier II
Explosive Armor Hardener II
Multispectrum Energized Membrane II
Reactive Armor Hardener
Shadow Serpentis EM Armor Hardener
Cap Recharger II
Omnidirectional Tracking Link II, Tracking Speed Script
Medium Compact Pb-Acid Cap Battery
Republic Fleet Large Cap Battery
Medium Remote Armor Repairer II
Medium Remote Armor Repairer II
Medium Remote Armor Repairer II
Medium Remote Armor Repairer II
Medium Explosive Armor Reinforcer II
Medium Thermal Armor Reinforcer II
Valkyrie II x5
Berserker II x5
"""
def test_ishtar_spider_remote_armor_reps():
payload = {"fit": ISHTAR_SPIDER_FIT}
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
data = pyfa_cli_stats.compute_stats(payload, tmp)
armor_rps = data["remote_reps_outgoing"]["current"]["armor"]
assert armor_rps is not None
assert int(round(armor_rps)) == 171

59
uv.lock generated
View File

@@ -159,6 +159,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
] ]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]] [[package]]
name = "contourpy" name = "contourpy"
version = "1.3.3" version = "1.3.3"
@@ -369,6 +378,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "kiwisolver" name = "kiwisolver"
version = "1.4.9" version = "1.4.9"
@@ -593,6 +611,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "pyasn1" name = "pyasn1"
version = "0.6.1" version = "0.6.1"
@@ -634,6 +661,11 @@ dependencies = [
{ name = "wxpython" }, { name = "wxpython" },
] ]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "beautifulsoup4", specifier = "==4.12.2" }, { name = "beautifulsoup4", specifier = "==4.12.2" },
@@ -643,6 +675,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.8.2" }, { name = "matplotlib", specifier = "==3.8.2" },
{ name = "numpy", specifier = "==1.26.2" }, { name = "numpy", specifier = "==1.26.2" },
{ name = "packaging", specifier = "==23.2" }, { name = "packaging", specifier = "==23.2" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "python-dateutil", specifier = "==2.8.2" }, { name = "python-dateutil", specifier = "==2.8.2" },
{ name = "python-jose", specifier = "==3.3.0" }, { name = "python-jose", specifier = "==3.3.0" },
{ name = "pyyaml", specifier = "==6.0.1" }, { name = "pyyaml", specifier = "==6.0.1" },
@@ -653,6 +686,16 @@ requires-dist = [
{ name = "sqlalchemy", specifier = "==1.4.50" }, { name = "sqlalchemy", specifier = "==1.4.50" },
{ name = "wxpython", specifier = "==4.2.1" }, { name = "wxpython", specifier = "==4.2.1" },
] ]
provides-extras = ["dev"]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
@@ -663,6 +706,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" },
] ]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.8.2" version = "2.8.2"