Compare commits

...

29 Commits

Author SHA1 Message Date
6b9c51db04 Dump schema on failing requests 2026-02-28 21:50:12 +01:00
b1c9b57ef7 Fix some fucking shit with importing wx 2026-02-28 21:21:00 +01:00
5c01ecb2d1 Fix the server docker image build 2026-02-28 20:47:53 +01:00
564ba1d85d Rename cli to server and add a docker distro 2026-02-28 19:54:16 +01:00
1c7886463d Rework the "cli" to a server instead 2026-02-28 19:33:38 +01:00
bc23f380db Split the binary into gui and headless versions 2026-02-27 22:53:14 +01:00
b9da617009 Implement a "simple" cli over pyfa for cli business 2026-02-27 22:23:51 +01:00
dc38f33536 Hallucinate a progress bar of sorts for the heat calculations
Nifty
2026-02-25 11:17:51 +01:00
cdc189676b Add input box for MC iterations and reorder graphs picker 2026-02-25 11:11:10 +01:00
665f797d51 Hallucinate a heat model to the graphing machine 2026-02-24 13:37:47 +01:00
e119eeb14a Fix the fucking diff algorithm 2026-02-09 18:09:21 +01:00
d8e6cc76c9 Implement batch module/charge moving 2026-02-05 16:47:19 +01:00
bfd5bbb881 Add a buck/bang column to compare 2026-01-23 18:09:01 +01:00
c64991fb59 Add a "per second" column to some rep modules
Because some have different cycle times, easier to compare
2026-01-21 11:11:07 +01:00
ce5dca9818 Fix breaking every single graph
Done. I've reverted everything back to how it was except for the jam chance additions:

**Restored:**
- All original getters use `miscParams['resist']` (not `.get()`)
- The `resist` input is always available (no conditions)
- Removed the sensorStrength X-axis option and SensorStrength2JamChanceGetter

**Kept:**
- `hasTargets = True` for the graph
- `Distance2JamChanceGetter` class - jam chance vs distance
- Jam chance YDef in yDefs

Now all the original graphs (neut, web, ecm strength, damps, TDs, GDs, TPs) should work exactly as before, and jam chance is available as a new Y-axis option.
2026-01-21 09:35:06 +01:00
38376046d0 Tidy up the code
Done. Now:
- **Distance input** only shows when X-axis is "Distance"
- **Sensor strength input** only shows when X-axis is "Target sensor strength"

Each input field only appears on its respective graph.
2026-01-20 19:16:05 +01:00
38356acd37 Add a jam chance a gainst sensor strength graph 2026-01-20 15:55:35 +01:00
64a11aaa6f Add a jam chance ewar graph 2026-01-20 15:55:33 +01:00
1063a1ab49 Switch the diff format from "$module $quantity" to "$module x$quantity" 2026-01-18 23:17:18 +01:00
959467028c Have CTRL-C copy in EFT format and move the dialogue to CTRL-SHIFT-C 2026-01-18 14:56:45 +01:00
9b4c523aa6 Sort the diff into a more fit like format to make it a bit more readable 2026-01-17 18:20:08 +01:00
411ef933d1 Ignore offline modules and modules with charges when comparing
It's the same module...
2026-01-17 18:06:28 +01:00
0a1c177442 Add a flip diff button 2026-01-17 18:03:44 +01:00
a03c2e4091 Add a fit diff view
The point is to figure out what items are necessary to transform fit 1
into fit 2
2026-01-17 17:14:14 +01:00
564a68e5cb Disable oleacc 2026-01-09 21:54:31 +01:00
aec20c1f5a Fix shift click not actually assigning skills 2026-01-09 21:54:28 +01:00
8800533c8a Shift click opens the skills menu on module 2026-01-09 21:24:33 +01:00
1db6b3372c Add "up to" all skill buttons 2026-01-09 20:54:35 +01:00
169b041677 Add an "all" skills button to any right click skills menu 2026-01-09 20:50:34 +01:00
50 changed files with 2576 additions and 115 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.venv
.git
dist
build
__pycache__
*.pyc
.pytest_cache
*.zip
*.spec
imgs
dist_assets

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements-server.txt .
RUN pip install --no-cache-dir -r requirements-server.txt
COPY . .
EXPOSE 9123
CMD ["python", "pyfa_server.py"]

Submodule Pyfa-Mod deleted from ccebbf9708

View File

@@ -23,8 +23,38 @@ rm -rf build dist
echo "Building binary with PyInstaller..."
uv run pyinstaller pyfa.spec
cp oleacc* dist/pyfa/
# Sim server exe (console) into main dist folder
if [ -f dist/pyfa_server/pyfa-server.exe ]; then
cp dist/pyfa_server/pyfa-server.exe dist/pyfa/
fi
# Docker image (Python server)
DOCKER_REPO="${DOCKER_REPO:-docker.site.quack-lab.dev}"
IMAGE_NAME="${IMAGE_NAME:-pyfa-server}"
COMMIT_SHA=$(git rev-parse --short HEAD)
IMAGE_BASE="${DOCKER_REPO}/${IMAGE_NAME}"
echo ""
echo "Build complete! Binary is located at: dist/pyfa/pyfa.exe"
echo "You can run it with: dist/pyfa/pyfa.exe"
echo "Building Docker image..."
docker build -t "${IMAGE_BASE}:${COMMIT_SHA}" .
docker tag "${IMAGE_BASE}:${COMMIT_SHA}" "${IMAGE_BASE}:latest"
TAGS=$(git tag --points-at HEAD 2>/dev/null || true)
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && docker tag "${IMAGE_BASE}:${COMMIT_SHA}" "${IMAGE_BASE}:${tag}"
done <<< "$TAGS"
fi
echo ""
echo "Build complete! dist/pyfa/pyfa.exe (GUI), dist/pyfa/pyfa-server.exe (POST /simulate :9123)"
echo ""
echo "Docker image built as:"
echo " - ${IMAGE_BASE}:${COMMIT_SHA}"
echo " - ${IMAGE_BASE}:latest"
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && echo " - ${IMAGE_BASE}:${tag}"
done <<< "$TAGS"
fi

View File

@@ -1,7 +1,6 @@
import os
import sys
import yaml
import wx
from logbook import CRITICAL, DEBUG, ERROR, FingersCrossedHandler, INFO, Logger, NestedSetup, NullHandler, \
StreamHandler, TimedRotatingFileHandler, WARNING
@@ -67,20 +66,34 @@ LOGLEVEL_MAP = {
CATALOG = 'lang'
slotColourMapDark = {
FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13
FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5
FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5
FittingSlot.RIG: '',
FittingSlot.SUBSYSTEM: ''}
errColorDark = wx.Colour(70, 20, 20)
slotColourMap = {
FittingSlot.LOW: wx.Colour(250, 235, 204), # yellow = low slots
FittingSlot.MED: wx.Colour(188, 215, 241), # blue = mid slots
FittingSlot.HIGH: wx.Colour(235, 204, 209), # red = high slots
FittingSlot.RIG: '',
FittingSlot.SUBSYSTEM: ''}
errColor = wx.Colour(204, 51, 51)
def get_slotColourMapDark():
import wx
return {
FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13
FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5
FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5
FittingSlot.RIG: '',
FittingSlot.SUBSYSTEM: ''}
def get_errColorDark():
import wx
return wx.Colour(70, 20, 20)
def get_slotColourMap():
import wx
return {
FittingSlot.LOW: wx.Colour(250, 235, 204), # yellow = low slots
FittingSlot.MED: wx.Colour(188, 215, 241), # blue = mid slots
FittingSlot.HIGH: wx.Colour(235, 204, 209), # red = high slots
FittingSlot.RIG: '',
FittingSlot.SUBSYSTEM: ''}
def get_errColor():
import wx
return wx.Colour(204, 51, 51)
def getClientSecret():

5
docker-compose.yml Normal file
View File

@@ -0,0 +1,5 @@
services:
pyfa-server:
image: docker.site.quack-lab.dev/pyfa-server:latest
ports:
- "9123:9123"

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

@@ -332,3 +332,78 @@ class Distance2TpStrGetter(SmoothPointGetter):
strMult = calculateMultiplier(strMults)
strength = (strMult - 1) * 100
return strength
class Distance2JamChanceGetter(SmoothPointGetter):
_baseResolution = 50
_extraDepth = 2
ECM_ATTRS_GENERAL = ('scanGravimetricStrengthBonus', 'scanLadarStrengthBonus', 'scanMagnetometricStrengthBonus', 'scanRadarStrengthBonus')
ECM_ATTRS_FIGHTERS = ('fighterAbilityECMStrengthGravimetric', 'fighterAbilityECMStrengthLadar', 'fighterAbilityECMStrengthMagnetometric', 'fighterAbilityECMStrengthRadar')
SCAN_TYPES = ('Gravimetric', 'Ladar', 'Magnetometric', 'Radar')
def _getCommonData(self, miscParams, src, tgt):
ecms = []
for mod in src.item.activeModulesIter():
for effectName in ('remoteECMFalloff', 'structureModuleEffectECM'):
if effectName in mod.item.effects:
ecms.append((
tuple(mod.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_GENERAL),
mod.maxRange or 0, mod.falloff or 0, True, False))
if 'doomsdayAOEECM' in mod.item.effects:
ecms.append((
tuple(mod.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_GENERAL),
max(0, (mod.maxRange or 0) + mod.getModifiedItemAttr('doomsdayAOERange')),
mod.falloff or 0, False, False))
for drone in src.item.activeDronesIter():
if 'entityECMFalloff' in drone.item.effects:
ecms.extend(drone.amountActive * ((
tuple(drone.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_GENERAL),
math.inf, 0, True, True),))
for fighter, ability in src.item.activeFighterAbilityIter():
if ability.effect.name == 'fighterAbilityECM':
ecms.append((
tuple(fighter.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_FIGHTERS),
math.inf, 0, True, False))
# Determine target's strongest sensor type if target is available
targetScanTypeIndex = None
if tgt is not None:
maxStr = -1
for i, scanType in enumerate(self.SCAN_TYPES):
currStr = tgt.item.ship.getModifiedItemAttr('scan%sStrength' % scanType) or 0
if currStr > maxStr:
maxStr = currStr
targetScanTypeIndex = i
return {'ecms': ecms, 'targetScanTypeIndex': targetScanTypeIndex}
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
distance = x
inLockRange = checkLockRange(src=src, distance=distance)
inDroneRange = checkDroneControlRange(src=src, distance=distance)
jamStrengths = []
targetScanTypeIndex = commonData['targetScanTypeIndex']
for strengths, optimal, falloff, needsLock, needsDcr in commonData['ecms']:
if (needsLock and not inLockRange) or (needsDcr and not inDroneRange):
continue
rangeFactor = calculateRangeFactor(srcOptimalRange=optimal, srcFalloffRange=falloff, distance=distance)
# Use the strength matching the target's sensor type
if targetScanTypeIndex is not None and targetScanTypeIndex < len(strengths):
strength = strengths[targetScanTypeIndex]
effectiveStrength = strength * rangeFactor
if effectiveStrength > 0:
jamStrengths.append(effectiveStrength)
if not jamStrengths:
return 0
# Get sensor strength from target
if tgt is None:
return 0
sensorStrength = max([tgt.item.ship.getModifiedItemAttr('scan%sStrength' % scanType)
for scanType in self.SCAN_TYPES]) or 0
if sensorStrength <= 0:
return 100 # If target has no sensor strength, 100% jam chance
# Calculate jam chance: 1 - (1 - (ecmStrength / sensorStrength)) ^ numJammers
retainLockChance = 1
for jamStrength in jamStrengths:
retainLockChance *= 1 - min(1, jamStrength / sensorStrength)
return (1 - retainLockChance) * 100

View File

@@ -21,8 +21,8 @@
import wx
from graphs.data.base import FitGraph, Input, XDef, YDef
from .getter import (Distance2DampStrLockRangeGetter, Distance2EcmStrMaxGetter, Distance2GdStrRangeGetter, Distance2NeutingStrGetter, Distance2TdStrOptimalGetter,
Distance2TpStrGetter, Distance2WebbingStrGetter)
from .getter import (Distance2DampStrLockRangeGetter, Distance2EcmStrMaxGetter, Distance2GdStrRangeGetter, Distance2JamChanceGetter, Distance2NeutingStrGetter,
Distance2TdStrOptimalGetter, Distance2TpStrGetter, Distance2WebbingStrGetter)
_t = wx.GetTranslation
@@ -31,11 +31,13 @@ class FitEwarStatsGraph(FitGraph):
# UI stuff
internalName = 'ewarStatsGraph'
name = _t('Electronic Warfare Stats')
hasTargets = True
xDefs = [XDef(handle='distance', unit='km', label=_t('Distance'), mainInput=('distance', 'km'))]
yDefs = [
YDef(handle='neutStr', unit=None, label=_t('Cap neutralized per second'), selectorLabel=_t('Neuts: cap per second')),
YDef(handle='webStr', unit='%', label=_t('Speed reduction'), selectorLabel=_t('Webs: speed reduction')),
YDef(handle='ecmStrMax', unit=None, label=_t('Combined ECM strength'), selectorLabel=_t('ECM: combined strength')),
YDef(handle='jamChance', unit='%', label=_t('Jam chance'), selectorLabel=_t('ECM: jam chance')),
YDef(handle='dampStrLockRange', unit='%', label=_t('Lock range reduction'), selectorLabel=_t('Damps: lock range reduction')),
YDef(handle='tdStrOptimal', unit='%', label=_t('Turret optimal range reduction'), selectorLabel=_t('TDs: turret optimal range reduction')),
YDef(handle='gdStrRange', unit='%', label=_t('Missile flight range reduction'), selectorLabel=_t('GDs: missile flight range reduction')),
@@ -53,6 +55,7 @@ class FitEwarStatsGraph(FitGraph):
('distance', 'neutStr'): Distance2NeutingStrGetter,
('distance', 'webStr'): Distance2WebbingStrGetter,
('distance', 'ecmStrMax'): Distance2EcmStrMaxGetter,
('distance', 'jamChance'): Distance2JamChanceGetter,
('distance', 'dampStrLockRange'): Distance2DampStrLockRangeGetter,
('distance', 'tdStrOptimal'): Distance2TdStrOptimalGetter,
('distance', 'gdStrRange'): Distance2GdStrRangeGetter,

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

@@ -18,6 +18,7 @@ from gui.builtinContextMenus import resistMode
from gui.builtinContextMenus.targetProfile import editor
# Item info
from gui.builtinContextMenus import itemStats
from gui.builtinContextMenus import fitDiff
from gui.builtinContextMenus import itemMarketJump
from gui.builtinContextMenus import fitSystemSecurity # Not really an item info but want to keep it here
from gui.builtinContextMenus import fitPilotSecurity # Not really an item info but want to keep it here

View File

@@ -0,0 +1,48 @@
# =============================================================================
# Copyright (C) 2025
#
# 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
import gui.mainFrame
from gui.contextMenu import ContextMenuSingle
_t = wx.GetTranslation
class FitDiff(ContextMenuSingle):
def __init__(self):
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
def display(self, callingWindow, srcContext, mainItem):
# Only show for fittingShip context (right-click on ship)
return srcContext == "fittingShip"
def getText(self, callingWindow, itmContext, mainItem):
return _t("Fit Diff...")
def activate(self, callingWindow, fullContext, mainItem, i):
fitID = self.mainFrame.getActiveFit()
if fitID is not None:
from gui.fitDiffFrame import FitDiffFrame
FitDiffFrame(self.mainFrame, fitID)
FitDiff.register()

View File

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

View File

@@ -79,7 +79,7 @@ class ChangeAffectingSkills(ContextMenuSingle):
label = _t("Level %s") % i
id = ContextMenuSingle.nextID()
self.skillIds[id] = (skill, i)
self.skillIds[id] = (skill, i, False) # False = not "up" for individual skills
menuItem = wx.MenuItem(rootMenu, id, label, kind=wx.ITEM_RADIO)
rootMenu.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
return menuItem
@@ -89,6 +89,40 @@ class ChangeAffectingSkills(ContextMenuSingle):
self.skillIds = {}
sub = wx.Menu()
# When rootMenu is None (direct menu access), use sub for binding on Windows
bindMenu = rootMenu if (rootMenu is not None and msw) else (sub if msw else None)
# Add "All" entry
allItem = wx.MenuItem(sub, ContextMenuSingle.nextID(), _t("All"))
grandSubAll = wx.Menu()
allItem.SetSubMenu(grandSubAll)
# For "All", only show levels 1-5 (not "Not Learned")
for i in range(1, 6):
id = ContextMenuSingle.nextID()
self.skillIds[id] = (None, i, False) # None indicates "All" was selected, False = not "up"
label = _t("Level %s") % i
menuItem = wx.MenuItem(bindMenu if bindMenu else grandSubAll, id, label, kind=wx.ITEM_RADIO)
grandSubAll.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
grandSubAll.Append(menuItem)
# Add separator
grandSubAll.AppendSeparator()
# Add "Up Level 1..5" entries
for i in range(1, 6):
id = ContextMenuSingle.nextID()
self.skillIds[id] = (None, i, True) # None indicates "All" was selected, True = "up" only
label = _t("Up Level %s") % i
menuItem = wx.MenuItem(bindMenu if bindMenu else grandSubAll, id, label, kind=wx.ITEM_RADIO)
grandSubAll.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
grandSubAll.Append(menuItem)
sub.Append(allItem)
# Add separator
sub.AppendSeparator()
for skill in self.skills:
skillItem = wx.MenuItem(sub, ContextMenuSingle.nextID(), skill.item.name)
grandSub = wx.Menu()
@@ -99,7 +133,7 @@ class ChangeAffectingSkills(ContextMenuSingle):
skillItem.SetBitmap(bitmap)
for i in range(-1, 6):
levelItem = self.addSkill(rootMenu if msw else grandSub, skill, i)
levelItem = self.addSkill(bindMenu if bindMenu else grandSub, skill, i)
grandSub.Append(levelItem)
if (not skill.learned and i == -1) or (skill.learned and skill.level == i):
levelItem.Check(True)
@@ -108,9 +142,24 @@ class ChangeAffectingSkills(ContextMenuSingle):
return sub
def handleSkillChange(self, event):
skill, level = self.skillIds[event.Id]
skill, level, up = self.skillIds[event.Id]
if skill is None: # "All" was selected
for s in self.skills:
if up:
# Only increase skill if it's below the target level
if not s.learned or s.level < level:
self.sChar.changeLevel(self.charID, s.item.ID, level)
else:
self.sChar.changeLevel(self.charID, s.item.ID, level)
else:
if up:
# Only increase skill if it's below the target level
if not skill.learned or skill.level < level:
self.sChar.changeLevel(self.charID, skill.item.ID, level)
else:
self.sChar.changeLevel(self.charID, skill.item.ID, level)
self.sChar.changeLevel(self.charID, skill.item.ID, level)
fitID = self.mainFrame.getActiveFit()
self.sFit.changeChar(fitID, self.charID)

View File

@@ -9,6 +9,46 @@ from gui.utils.numberFormatter import formatAmount
_t = wx.GetTranslation
# Mapping of repair/transfer amount attributes to their duration attribute and display name
PER_SECOND_ATTRIBUTES = {
"armorDamageAmount": {
"durationAttr": "duration",
"displayName": "Armor Hitpoints Repaired per second",
"unit": "HP/s"
},
"shieldBonus": {
"durationAttr": "duration",
"displayName": "Shield Hitpoints Repaired per second",
"unit": "HP/s"
},
"powerTransferAmount": {
"durationAttr": "duration",
"displayName": "Capacitor Transferred per second",
"unit": "GJ/s"
}
}
class PerSecondAttributeInfo:
"""Helper class to store info about computed per-second attributes"""
def __init__(self, displayName, unit):
self.displayName = displayName
self.unit = PerSecondUnit(unit)
class PerSecondUnit:
"""Helper class to mimic the Unit class for per-second attributes"""
def __init__(self, displayName):
self.displayName = displayName
self.name = ""
class PerSecondAttributeValue:
"""Helper class to store computed per-second attribute values"""
def __init__(self, value):
self.value = value
self.info = None # Will be set when adding to attrs
def defaultSort(item):
return (item.metaLevel or 0, item.name)
@@ -36,8 +76,12 @@ class ItemCompare(wx.Panel):
self.item = item
self.items = sorted(items, key=defaultSort)
self.attrs = {}
self.computedAttrs = {} # Store computed per-second attributes
self.HighlightOn = wx.Colour(255, 255, 0, wx.ALPHA_OPAQUE)
self.highlightedNames = []
self.bangBuckColumn = None # Store the column selected for bang/buck calculation
self.bangBuckColumnName = None # Store the display name of the selected column
self.columnHighlightColour = wx.Colour(173, 216, 230, wx.ALPHA_OPAQUE) # Light blue for column highlight
# get a dict of attrName: attrInfo of all unique attributes across all items
for item in self.items:
@@ -45,23 +89,66 @@ class ItemCompare(wx.Panel):
if item.attributes[attr].info.displayName:
self.attrs[attr] = item.attributes[attr].info
# Compute per-second attributes for items that have both the amount and duration
for perSecondKey, config in PER_SECOND_ATTRIBUTES.items():
amountAttr = perSecondKey
durationAttr = config["durationAttr"]
perSecondAttrName = f"{perSecondKey}_per_second"
# Check if any item has both attributes
hasPerSecondAttr = False
for item in self.items:
if amountAttr in item.attributes and durationAttr in item.attributes:
hasPerSecondAttr = True
break
if hasPerSecondAttr:
# Add the per-second attribute info to attrs
perSecondInfo = PerSecondAttributeInfo(config["displayName"], config["unit"])
self.attrs[perSecondAttrName] = perSecondInfo
self.computedAttrs[perSecondAttrName] = {
"amountAttr": amountAttr,
"durationAttr": durationAttr
}
# Process attributes for items and find ones that differ
for attr in list(self.attrs.keys()):
value = None
for item in self.items:
# we can automatically break here if this item doesn't have the attribute,
# as that means at least one item did
if attr not in item.attributes:
break
# Check if this is a computed attribute
if attr in self.computedAttrs:
computed = self.computedAttrs[attr]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
# this is the first attribute for the item set, set the initial value
if value is None:
value = item.attributes[attr].value
continue
# Item needs both attributes to compute per-second value
if amountAttr not in item.attributes or durationAttr not in item.attributes:
break
if attr not in item.attributes or item.attributes[attr].value != value:
break
# Calculate per-second value
amountValue = item.attributes[amountAttr].value
durationValue = item.attributes[durationAttr].value
# Duration is in milliseconds, convert to seconds
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
if value is None:
value = perSecondValue
continue
if perSecondValue != value:
break
else:
# Regular attribute handling
if attr not in item.attributes:
break
if value is None:
value = item.attributes[attr].value
continue
if item.attributes[attr].value != value:
break
else:
# attribute values were all the same, delete
del self.attrs[attr]
@@ -89,6 +176,7 @@ class ItemCompare(wx.Panel):
self.toggleViewBtn.Bind(wx.EVT_TOGGLEBUTTON, self.ToggleViewMode)
self.Bind(wx.EVT_LIST_COL_CLICK, self.SortCompareCols)
self.Bind(wx.EVT_LIST_COL_RIGHT_CLICK, self.OnColumnRightClick)
self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.HighlightRow)
@@ -105,6 +193,23 @@ class ItemCompare(wx.Panel):
self.Thaw()
event.Skip()
def OnColumnRightClick(self, event):
column = event.GetColumn()
# Column 0 is "Item", column len(self.attrs) + 1 is "Price", len(self.attrs) + 2 is "Buck/bang"
# Only allow selecting attribute columns (1 to len(self.attrs))
if 1 <= column <= len(self.attrs):
# If clicking the same column, deselect it
if self.bangBuckColumn == column:
self.bangBuckColumn = None
self.bangBuckColumnName = None
else:
self.bangBuckColumn = column
# Get the display name of the selected column
attr_key = list(self.attrs.keys())[column - 1]
self.bangBuckColumnName = self.attrs[attr_key].displayName if self.attrs[attr_key].displayName else attr_key
self.UpdateList()
event.Skip()
def SortCompareCols(self, event):
self.Freeze()
self.paramList.ClearAll()
@@ -148,12 +253,32 @@ class ItemCompare(wx.Panel):
# Remember to reduce by 1, because the attrs array
# starts at 0 while the list has the item name as column 0.
attr = str(list(self.attrs.keys())[sort - 1])
func = lambda _val: _val.attributes[attr].value if attr in _val.attributes else 0.0
# Handle computed attributes for sorting
if attr in self.computedAttrs:
computed = self.computedAttrs[attr]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
func = lambda _val: (_val.attributes[amountAttr].value / (_val.attributes[durationAttr].value / 1000.0)) if (amountAttr in _val.attributes and durationAttr in _val.attributes and _val.attributes[durationAttr].value > 0) else 0.0
else:
func = lambda _val: _val.attributes[attr].value if attr in _val.attributes else 0.0
# Clicked on a column that's not part of our array (price most likely)
except IndexError:
# Price
if sort == len(self.attrs) + 1:
func = lambda i: i.price.price if i.price.price != 0 else float("Inf")
# Buck/bang
elif sort == len(self.attrs) + 2:
if self.bangBuckColumn is not None:
attr_key = list(self.attrs.keys())[self.bangBuckColumn - 1]
if attr_key in self.computedAttrs:
computed = self.computedAttrs[attr_key]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
func = lambda i: (i.price.price / (i.attributes[amountAttr].value / (i.attributes[durationAttr].value / 1000.0)) if (amountAttr in i.attributes and durationAttr in i.attributes and i.attributes[durationAttr].value > 0 and (i.attributes[amountAttr].value / (i.attributes[durationAttr].value / 1000.0)) > 0) else float("Inf"))
else:
func = lambda i: (i.price.price / i.attributes[attr_key].value if (attr_key in i.attributes and i.attributes[attr_key].value > 0) else float("Inf"))
else:
func = defaultSort
# Something else
else:
self.sortReverse = False
@@ -166,18 +291,49 @@ class ItemCompare(wx.Panel):
for i, attr in enumerate(self.attrs.keys()):
name = self.attrs[attr].displayName if self.attrs[attr].displayName else attr
# Add indicator if this column is selected for bang/buck calculation
if self.bangBuckColumn == i + 1:
name = "" + name
self.paramList.InsertColumn(i + 1, name)
self.paramList.SetColumnWidth(i + 1, 120)
self.paramList.InsertColumn(len(self.attrs) + 1, _t("Price"))
self.paramList.SetColumnWidth(len(self.attrs) + 1, 60)
# Add Buck/bang column header
buckBangHeader = _t("Buck/bang")
if self.bangBuckColumnName:
buckBangHeader = _t("Buck/bang ({})").format(self.bangBuckColumnName)
self.paramList.InsertColumn(len(self.attrs) + 2, buckBangHeader)
self.paramList.SetColumnWidth(len(self.attrs) + 2, 80)
toHighlight = []
for item in self.items:
i = self.paramList.InsertItem(self.paramList.GetItemCount(), item.name)
for x, attr in enumerate(self.attrs.keys()):
if attr in item.attributes:
# Handle computed attributes
if attr in self.computedAttrs:
computed = self.computedAttrs[attr]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
# Item needs both attributes to display per-second value
if amountAttr in item.attributes and durationAttr in item.attributes:
amountValue = item.attributes[amountAttr].value
durationValue = item.attributes[durationAttr].value
# Duration is in milliseconds, convert to seconds
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
info = self.attrs[attr]
if self.toggleView == 1:
valueUnit = formatAmount(perSecondValue, 3, 0, 0) + " " + info.unit.displayName
else:
valueUnit = str(perSecondValue)
self.paramList.SetItem(i, x + 1, valueUnit)
# else: leave cell empty
elif attr in item.attributes:
info = self.attrs[attr]
value = item.attributes[attr].value
if self.toggleView != 1:
@@ -191,6 +347,27 @@ class ItemCompare(wx.Panel):
# Add prices
self.paramList.SetItem(i, len(self.attrs) + 1, formatAmount(item.price.price, 3, 3, 9, currency=True) if item.price.price else "")
# Add buck/bang values
if self.bangBuckColumn is not None and item.price.price and item.price.price > 0:
attr_key = list(self.attrs.keys())[self.bangBuckColumn - 1]
if attr_key in self.computedAttrs:
computed = self.computedAttrs[attr_key]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
if amountAttr in item.attributes and durationAttr in item.attributes:
amountValue = item.attributes[amountAttr].value
durationValue = item.attributes[durationAttr].value
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
if perSecondValue > 0:
buckBangValue = item.price.price / perSecondValue
self.paramList.SetItem(i, len(self.attrs) + 2, formatAmount(buckBangValue, 3, 3, 9, currency=True))
elif attr_key in item.attributes:
attrValue = item.attributes[attr_key].value
if attrValue > 0:
buckBangValue = item.price.price / attrValue
self.paramList.SetItem(i, len(self.attrs) + 2, formatAmount(buckBangValue, 3, 3, 9, currency=True))
if item.name in self.highlightedNames:
toHighlight.append(i)

View File

@@ -3,7 +3,7 @@ from logbook import Logger
import gui.builtinMarketBrowser.pfSearchBox as SBox
import gui.globalEvents as GE
from config import slotColourMap, slotColourMapDark
from config import get_slotColourMap, get_slotColourMapDark
from eos.saveddata.module import Module
from eos.const import FittingSlot
from gui.builtinMarketBrowser.events import ItemSelected, RECENTLY_USED_MODULES, CHARGES_FOR_FIT
@@ -412,7 +412,7 @@ class ItemView(Display):
def columnBackground(self, colItem, item):
if self.sFit.serviceFittingOptions["colorFitBySlot"]:
colorMap = slotColourMapDark if isDark() else slotColourMap
colorMap = get_slotColourMapDark() if isDark() else get_slotColourMap()
return colorMap.get(Module.calculateSlot(item)) or self.GetBackgroundColour()
else:
return self.GetBackgroundColour()

View File

@@ -42,7 +42,7 @@ from gui.utils.staticHelpers import DragDropHelper
from gui.utils.dark import isDark
from service.fit import Fit
from service.market import Market
from config import slotColourMap, slotColourMapDark, errColor, errColorDark
from config import get_slotColourMap, get_slotColourMapDark, get_errColor, get_errColorDark
from gui.fitCommands.helpers import getSimilarModPositions
pyfalog = Logger(__name__)
@@ -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"""
@@ -668,6 +684,21 @@ class FittingView(d.Display):
contexts.append(fullContext)
contexts.append(("fittingShip", _t("Ship") if not fit.isStructure else _t("Citadel")))
# Check if shift is held for direct skills menu access
if wx.GetKeyState(wx.WXK_SHIFT):
from gui.builtinContextMenus.skillAffectors import ChangeAffectingSkills
for fullContext in contexts:
srcContext = fullContext[0]
itemContext = fullContext[1] if len(fullContext) > 1 else None
skillsMenu = ChangeAffectingSkills()
if skillsMenu.display(self, srcContext, mainMod):
# On Windows, menu items need to be bound to the menu shown with PopupMenu
# We pass None as rootMenu so items are bound to their parent submenus
sub = skillsMenu.getSubMenu(self, srcContext, mainMod, None, 0, None)
if sub:
self.PopupMenu(sub)
return
menu = ContextMenu.getMenu(self, mainMod, selection, *contexts)
self.PopupMenu(menu)
@@ -735,9 +766,9 @@ class FittingView(d.Display):
def slotColour(self, slot):
if isDark():
return slotColourMapDark.get(slot) or self.GetBackgroundColour()
return get_slotColourMapDark().get(slot) or self.GetBackgroundColour()
else:
return slotColourMap.get(slot) or self.GetBackgroundColour()
return get_slotColourMap().get(slot) or self.GetBackgroundColour()
def refresh(self, stuff):
"""
@@ -780,9 +811,8 @@ 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)
self.SetItemBackgroundColour(i, get_errColorDark() if isDark() else get_errColor())
elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled
self.SetItemBackgroundColour(i, self.slotColour(mod.slot))

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

@@ -346,20 +346,7 @@ class CargoInfo:
return makeReprStr(self, ['itemID', 'amount'])
def activeStateLimit(itemIdentity):
item = Market.getInstance().getItem(itemIdentity)
if {
'moduleBonusAssaultDamageControl', 'moduleBonusIndustrialInvulnerability',
'microJumpDrive', 'microJumpPortalDrive', 'emergencyHullEnergizer',
'cynosuralGeneration', 'jumpPortalGeneration', 'jumpPortalGenerationBO',
'cloneJumpAccepting', 'cloakingWarpSafe', 'cloakingPrototype', 'cloaking',
'massEntanglerEffect5', 'electronicAttributeModifyOnline', 'targetPassively',
'cargoScan', 'shipScan', 'surveyScan', 'targetSpectrumBreakerBonus',
'interdictionNullifierBonus', 'warpCoreStabilizerActive',
'industrialItemCompression'
}.intersection(item.effects):
return FittingModuleState.ONLINE
return FittingModuleState.ACTIVE
from service.port.active_state import activeStateLimit
def droneStackLimit(fit, itemIdentity):

216
gui/fitDiffFrame.py Normal file
View File

@@ -0,0 +1,216 @@
# =============================================================================
# Copyright (C) 2025
#
# 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
import re
from service.fit import Fit as svcFit
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."""
def __init__(self, parent, fitID):
super().__init__(
parent,
title=_t("Fit Diff"),
style=wx.DEFAULT_FRAME_STYLE | wx.RESIZE_BORDER,
size=(1000, 600)
)
self.parent = parent
self.fitID = fitID
self.sFit = svcFit.getInstance()
# EFT export options (same as CTRL-C)
self.eftOptions = {
PortEftOptions.LOADED_CHARGES: True,
PortEftOptions.MUTATIONS: True,
PortEftOptions.IMPLANTS: True,
PortEftOptions.BOOSTERS: True,
PortEftOptions.CARGO: True,
}
self.initUI()
self.Centre()
self.Show()
def initUI(self):
panel = wx.Panel(self)
mainSizer = wx.BoxSizer(wx.VERTICAL)
# Instructions and flip button at the top
topSizer = wx.BoxSizer(wx.HORIZONTAL)
instructions = wx.StaticText(
panel,
label=_t("Paste fits in EFT format to compare")
)
topSizer.Add(instructions, 1, wx.ALL | wx.EXPAND, 5)
flipButton = wx.Button(panel, label=_t("Flip"))
flipButton.Bind(wx.EVT_BUTTON, self.onFlip)
topSizer.Add(flipButton, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5)
mainSizer.Add(topSizer, 0, wx.EXPAND)
# Three panes: Fit 1 | Diff | Fit 2
panesSizer = wx.BoxSizer(wx.HORIZONTAL)
# Pane 1: Fit 1 (editable)
fit1Box = wx.StaticBox(panel, label=_t("Fit 1"))
fit1Sizer = wx.StaticBoxSizer(fit1Box, wx.VERTICAL)
self.fit1Text = wx.TextCtrl(
panel,
style=wx.TE_MULTILINE | wx.TE_DONTWRAP
)
fit1Sizer.Add(self.fit1Text, 1, wx.EXPAND)
panesSizer.Add(fit1Sizer, 1, wx.ALL | wx.EXPAND, 5)
# Bind text changed event to update diff
self.fit1Text.Bind(wx.EVT_TEXT, self.onFitChanged)
# Pane 2: Diff (simple text format)
diffBox = wx.StaticBox(panel, label=_t("Differences"))
diffSizer = wx.StaticBoxSizer(diffBox, wx.VERTICAL)
self.diffText = wx.TextCtrl(
panel,
style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_DONTWRAP
)
diffSizer.Add(self.diffText, 1, wx.EXPAND)
panesSizer.Add(diffSizer, 1, wx.ALL | wx.EXPAND, 5)
# Pane 3: Fit 2 (user input)
fit2Box = wx.StaticBox(panel, label=_t("Fit 2"))
fit2Sizer = wx.StaticBoxSizer(fit2Box, wx.VERTICAL)
self.fit2Text = wx.TextCtrl(
panel,
style=wx.TE_MULTILINE | wx.TE_DONTWRAP
)
fit2Sizer.Add(self.fit2Text, 1, wx.EXPAND)
# Bind text changed event to update diff
self.fit2Text.Bind(wx.EVT_TEXT, self.onFitChanged)
panesSizer.Add(fit2Sizer, 1, wx.ALL | wx.EXPAND, 5)
mainSizer.Add(panesSizer, 1, wx.EXPAND | wx.ALL, 5)
panel.SetSizer(mainSizer)
# Load current fit into pane 1
self.loadFit1()
def loadFit1(self):
"""Load the current fit into pane 1 as EFT format."""
fit = self.sFit.getFit(self.fitID)
if fit:
eftText = exportEft(fit, self.eftOptions, callback=None)
self.fit1Text.SetValue(eftText)
def onFitChanged(self, event):
"""Handle text change in either fit pane - update diff."""
self.updateDiff()
event.Skip()
def onFlip(self, event):
"""Swap Fit 1 and Fit 2."""
fit1Value = self.fit1Text.GetValue()
fit2Value = self.fit2Text.GetValue()
self.fit1Text.SetValue(fit2Value)
self.fit2Text.SetValue(fit1Value)
self.updateDiff()
event.Skip()
def updateDiff(self):
"""Calculate and display the differences between the two fits."""
self.diffText.Clear()
fit1Text = self.fit1Text.GetValue().strip()
fit2Text = self.fit2Text.GetValue().strip()
if not fit1Text or not fit2Text:
return
# Parse both fits
fit1 = self.parsePastedFit(fit1Text)
fit2 = self.parsePastedFit(fit2Text)
if fit1 is None:
self.diffText.SetValue(_t("Error: Fit 1 has invalid EFT format"))
return
if fit2 is None:
self.diffText.SetValue(_t("Error: Fit 2 has invalid EFT format"))
return
# Calculate differences and format as simple text list
diffLines = self.calculateDiff(fit1, fit2)
self.diffText.SetValue('\n'.join(diffLines))
def parsePastedFit(self, text):
"""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_items, fit2_items):
"""Calculate items needed to transform fit1 into fit2.
Returns a list of strings showing additions and extra items.
"""
diffLines = []
all_items = set(fit1_items.keys()) | set(fit2_items.keys())
additions = []
extras = []
for item in sorted(all_items):
count1 = fit1_items.get(item, 0)
count2 = fit2_items.get(item, 0)
if count2 > count1:
additions.append(f"{item} x{count2 - count1}")
elif count1 > count2:
extras.append(f"{item} x-{count1 - count2}")
diffLines.extend(additions)
if additions and extras:
diffLines.extend(["", ""])
diffLines.extend(extras)
return diffLines

View File

@@ -565,7 +565,8 @@ class MainFrame(wx.Frame):
self.Bind(wx.EVT_MENU, self.toggleOverrides, id=menuBar.toggleOverridesId)
# Clipboard exports
self.Bind(wx.EVT_MENU, self.exportToClipboard, id=wx.ID_COPY)
self.Bind(wx.EVT_MENU, self.exportToClipboardDirectEft, id=menuBar.copyDirectEftId)
self.Bind(wx.EVT_MENU, self.exportToClipboard, id=menuBar.copyWithDialogId)
# Fitting Restrictions
self.Bind(wx.EVT_MENU, self.toggleIgnoreRestriction, id=menuBar.toggleIgnoreRestrictionID)
@@ -623,7 +624,16 @@ class MainFrame(wx.Frame):
(wx.ACCEL_CMD, wx.WXK_PAGEUP, ctabprev),
(wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord("Z"), wx.ID_REDO),
# Ctrl+Shift+C for copy with dialog (must come before Ctrl+C)
# Note: use lowercase 'c' because SHIFT is already in flags
(wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord('c'), menuBar.copyWithDialogId),
(wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord('c'), menuBar.copyWithDialogId),
# Ctrl+C for direct EFT copy
(wx.ACCEL_CTRL, ord('c'), menuBar.copyDirectEftId),
(wx.ACCEL_CMD, ord('c'), menuBar.copyDirectEftId),
# Shift+Tab for previous character
(wx.ACCEL_SHIFT, wx.WXK_TAB, charPrevId)
]
@@ -813,6 +823,32 @@ class MainFrame(wx.Frame):
else:
self._openAfterImport(importData)
def exportToClipboardDirectEft(self, event):
""" Copy fit to clipboard in EFT format without showing dialog """
from eos.db import getFit
from service.const import PortEftOptions
from service.settings import SettingsProvider
fit = getFit(self.getActiveFit())
if fit is None:
return
# Get the default EFT export options from settings
defaultOptions = {
PortEftOptions.LOADED_CHARGES: True,
PortEftOptions.MUTATIONS: True,
PortEftOptions.IMPLANTS: True,
PortEftOptions.BOOSTERS: True,
PortEftOptions.CARGO: True,
}
settings = SettingsProvider.getInstance().getSettings("pyfaExport", {"format": CopySelectDialog.copyFormatEft, "options": {CopySelectDialog.copyFormatEft: defaultOptions}})
options = settings["options"].get(CopySelectDialog.copyFormatEft, defaultOptions)
def copyToClipboard(text):
toClipboard(text)
Port.exportEft(fit, options, callback=copyToClipboard)
def exportToClipboard(self, event):
with CopySelectDialog(self) as dlg:
dlg.ShowModal()

View File

@@ -58,6 +58,8 @@ class MainMenuBar(wx.MenuBar):
self.toggleIgnoreRestrictionID = wx.NewId()
self.devToolsId = wx.NewId()
self.optimizeFitPrice = wx.NewId()
self.copyWithDialogId = wx.NewId()
self.copyDirectEftId = wx.NewId()
self.mainFrame = mainFrame
wx.MenuBar.__init__(self)
@@ -85,7 +87,8 @@ class MainMenuBar(wx.MenuBar):
fitMenu.Append(wx.ID_REDO, _t("&Redo") + "\tCTRL+Y", _t("Redo the most recent undone action"))
fitMenu.AppendSeparator()
fitMenu.Append(wx.ID_COPY, _t("&To Clipboard") + "\tCTRL+C", _t("Export a fit to the clipboard"))
fitMenu.Append(self.copyDirectEftId, _t("&To Clipboard (EFT)") + "\tCTRL+C", _t("Export a fit to the clipboard in EFT format"))
fitMenu.Append(self.copyWithDialogId, _t("&To Clipboard (Select Format)") + "\tCTRL+SHIFT+C", _t("Export a fit to the clipboard with format selection"))
fitMenu.Append(wx.ID_PASTE, _t("&From Clipboard") + "\tCTRL+V", _t("Import a fit from the clipboard"))
fitMenu.AppendSeparator()
@@ -178,7 +181,8 @@ class MainMenuBar(wx.MenuBar):
return
enable = activeFitID is not None
self.Enable(wx.ID_SAVEAS, enable)
self.Enable(wx.ID_COPY, enable)
self.Enable(self.copyDirectEftId, enable)
self.Enable(self.copyWithDialogId, enable)
self.Enable(self.exportSkillsNeededId, enable)
self.Enable(self.copySkillsNeededId, enable)

View File

@@ -79,8 +79,20 @@ a = Analysis(['pyfa.py'],
win_private_assemblies=False,
cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
a_server = Analysis(['pyfa_server.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_server = PYZ(a_server.pure, a_server.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
@@ -96,6 +108,19 @@ exe = EXE(
contents_directory='app',
)
# Sim server. POST /simulate on port 9123.
exe_server = EXE(
pyz_server,
a_server.scripts,
exclude_binaries=True,
name='pyfa-server',
debug=debug,
strip=False,
upx=upx,
console=True,
contents_directory='app',
)
coll = COLLECT(
exe,
a.binaries,
@@ -106,6 +131,16 @@ coll = COLLECT(
name='pyfa',
)
coll_server = COLLECT(
exe_server,
a_server.binaries,
a_server.zipfiles,
a_server.datas,
strip=False,
upx=upx,
name='pyfa_server',
)
if platform.system() == 'Darwin':
info_plist = {
'NSHighResolutionCapable': 'True',

5
pyfa_server.py Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env python3
# Sim server. POST /simulate with JSON body.
from scripts.pyfa_sim import _run_http_server
_run_http_server(9123, None)

View File

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

View File

@@ -57,4 +57,32 @@ curl -X POST \
rm "${ZIP}"
# Push Docker image
DOCKER_REPO="${DOCKER_REPO:-docker.site.quack-lab.dev}"
IMAGE_NAME="${IMAGE_NAME:-pyfa-server}"
COMMIT_SHA=$(git rev-parse --short HEAD)
IMAGE_BASE="${DOCKER_REPO}/${IMAGE_NAME}"
echo ""
echo "Pushing Docker images..."
docker push "${IMAGE_BASE}:${COMMIT_SHA}"
docker push "${IMAGE_BASE}:latest"
TAGS=$(git tag --points-at HEAD 2>/dev/null || true)
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && docker push "${IMAGE_BASE}:${tag}"
done <<< "$TAGS"
fi
echo ""
echo "Docker image pushed as:"
echo " - ${IMAGE_BASE}:${COMMIT_SHA}"
echo " - ${IMAGE_BASE}:latest"
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && echo " - ${IMAGE_BASE}:${tag}"
done <<< "$TAGS"
fi
echo ""
echo "Release complete! ${ZIP} uploaded to ${TAG}"

14
requirements-server.txt Normal file
View File

@@ -0,0 +1,14 @@
logbook==1.7.0.post0
numpy==1.26.2
matplotlib==3.8.2
python-dateutil==2.8.2
requests==2.31.0
sqlalchemy==1.4.50
cryptography==42.0.4
markdown2==2.4.11
packaging==23.2
roman==4.1
beautifulsoup4==4.12.2
pyyaml==6.0.1
python-jose==3.3.0
requests-cache==1.1.1

449
scripts/pyfa_sim.py Normal file
View File

@@ -0,0 +1,449 @@
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
# POST /simulate request and response shape; included in 400 response body
SIMULATE_SCHEMA = {
"request": {
"fit": "string (required). EFT or multi-line text export of a single fit, e.g. [ShipName, FitName] followed by modules/drones/etc.",
"projected_fits": "array (optional). Each element: { \"fit\": string, \"count\": positive integer }.",
"command_fits": "array (optional). Each element: { \"fit\": string }.",
},
"response_200": {
"fit": {"id": "int", "name": "string", "ship_type": "string"},
"resources": {"hardpoints": {}, "drones": {}, "fighters": {}, "calibration": {}, "powergrid": {}, "cpu": {}, "cargo": {}},
"defense": {"hp": {}, "ehp": {}, "resonance": {}, "tank": {}, "effective_tank": {}, "sustainable_tank": {}, "effective_sustainable_tank": {}},
"capacitor": {"capacity": {}, "recharge": {}, "use": {}, "delta": {}, "stable": {}, "state": {}, "neutralizer_resistance": {}},
"firepower": {"weapon_dps": {}, "drone_dps": {}, "total_dps": {}, "weapon_volley": {}, "drone_volley": {}, "total_volley": {}, "weapons": []},
"remote_reps_outgoing": {"current": {}, "pre_spool": {}, "full_spool": {}},
"targeting_misc": {"targets_max": {}, "target_range": {}, "scan_resolution": {}, "scan_strength": {}, "scan_type": {}, "jam_chance": {}, "drone_control_range": {}, "speed": {}, "align_time": {}, "signature_radius": {}, "warp_speed": {}, "max_warp_distance": {}, "probe_size": {}, "cargo_capacity": {}, "cargo_used": {}},
"price": {"ship": {}, "fittings": {}, "drones_and_fighters": {}, "cargo": {}, "character": {}, "total": {}},
},
}
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:
def send_error_json(code: int, msg: str, traceback_str: str | None = None):
body = {"error": msg, "schema": SIMULATE_SCHEMA}
if traceback_str:
body["traceback"] = traceback_str
return json.dumps(body, indent=2) + "\n"
class SimulateHandler(BaseHTTPRequestHandler):
def send_error(self, code: int, message: str | None = None, explain: str | None = None):
msg = message or explain or "Error"
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(send_error_json(code, msg).encode("utf-8"))
def _reply_error(self, code: int, msg: str, traceback_str: str | None = None):
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(send_error_json(code, msg, traceback_str).encode("utf-8"))
def do_GET(self):
if self.path == "/simulate":
self._reply_error(405, "Method not allowed. Use POST.")
else:
self._reply_error(404, "Not found. POST /simulate with JSON body.")
def do_POST(self):
if self.path != "/simulate":
self._reply_error(404, "Not found. POST /simulate with JSON body.")
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._reply_error(400, "Invalid JSON: %s" % exc)
return
if not isinstance(payload, dict):
self._reply_error(400, "Top-level JSON must be an object")
return
try:
output = compute_stats(payload, savepath)
except ValueError as e:
self._reply_error(400, str(e))
return
except Exception as e:
import traceback
tb = traceback.format_exc()
sys.stderr.write(tb)
sys.stderr.flush()
self._reply_error(500, str(e), tb)
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

@@ -28,12 +28,8 @@ from xml.etree import ElementTree
from xml.dom import minidom
import gzip
# noinspection PyPackageRequirements
import wx
import config
import eos.db
from service.esi import Esi
from eos.saveddata.implant import Implant as es_Implant
from eos.saveddata.character import Character as es_Character, Skill
@@ -42,7 +38,12 @@ from eos.const import FittingSlot as es_Slot
from eos.saveddata.fighter import Fighter as es_Fighter
pyfalog = Logger(__name__)
_t = wx.GetTranslation
def _t(s):
import wx
return wx.GetTranslation(s)
class CharacterImportThread(threading.Thread):
@@ -97,6 +98,7 @@ class CharacterImportThread(threading.Thread):
pyfalog.error(e)
continue
import wx
wx.CallAfter(self.callback)
def stop(self):
@@ -132,6 +134,7 @@ class SkillBackupThread(threading.Thread):
with open(path, mode='w', encoding='utf-8') as backupFile:
backupFile.write(backupData)
import wx
wx.CallAfter(self.callback)
def stop(self):
@@ -386,6 +389,7 @@ class Character:
def apiFetchCallback(self, guiCallback, e=None):
eos.db.commit()
import wx
wx.CallAfter(guiCallback, e)
@staticmethod
@@ -501,6 +505,7 @@ class UpdateAPIThread(threading.Thread):
try:
char = eos.db.getCharacter(self.charID)
from service.esi import Esi
sEsi = Esi.getInstance()
sChar = Character.getInstance()
ssoChar = sChar.getSsoCharacter(char.ID)

View File

@@ -13,11 +13,9 @@ from service.const import EsiLoginMethod, EsiSsoMode
from eos.saveddata.ssocharacter import SsoCharacter
from service.esiAccess import APIException, GenericSsoError
import gui.globalEvents as GE
from gui.ssoLogin import SsoLogin
from service.server import StoppableHTTPServer, AuthHandler
from service.settings import EsiSettings
from service.esiAccess import EsiAccess
import gui.mainFrame
from requests import Session
@@ -140,6 +138,7 @@ class Esi(EsiAccess):
self.fittings_deleted.add(fittingID)
def login(self):
import gui.ssoLogin
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:
if dlg.ShowModal() == wx.ID_OK:

View File

@@ -22,7 +22,6 @@ import datetime
from time import time
from weakref import WeakSet
import wx
from logbook import Logger
import eos.db
@@ -235,6 +234,7 @@ class Fit:
@classmethod
def getCommandProcessor(cls, fitID):
if fitID not in cls.processors:
import wx
cls.processors[fitID] = wx.CommandProcessor(maxCommands=100)
return cls.processors[fitID]

View File

@@ -23,8 +23,6 @@ import threading
from collections import OrderedDict
from itertools import chain
# noinspection PyPackageRequirements
import wx
from logbook import Logger
from sqlalchemy.sql import or_
@@ -38,7 +36,12 @@ from service.settings import SettingsProvider
from utils.cjk import isStringCjk
pyfalog = Logger(__name__)
_t = wx.GetTranslation
def _t(s):
import wx
return wx.GetTranslation(s)
# Event which tells threads dependent on Market that it's initialized
mktRdy = threading.Event()
@@ -77,6 +80,7 @@ class ShipBrowserWorkerThread(threading.Thread):
set_ = sMkt.getShipList(id_)
cache[id_] = set_
import wx
wx.CallAfter(callback, (id_, set_))
except (KeyboardInterrupt, SystemExit):
raise
@@ -170,6 +174,7 @@ class SearchWorkerThread(threading.Thread):
for item in all_results:
if sMkt.getPublicityByItem(item):
item_IDs.add(item.ID)
import wx
wx.CallAfter(callback, sorted(item_IDs))
def scheduleSearch(self, text, callback, filterName=None):
@@ -268,7 +273,7 @@ class Market:
self.les_grp = types_Group()
self.les_grp.ID = -1
self.les_grp.name = "Limited Issue Ships"
self.les_grp.displayName = _t("Limited Issue Ships")
self.les_grp.displayName = "Limited Issue Ships"
self.les_grp.published = True
ships = self.getCategory("Ship")
self.les_grp.category = ships

View File

@@ -1,2 +1,8 @@
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

@@ -0,0 +1,12 @@
# Module-state logic shared by port (EFT import) and gui (fit commands).
# Uses gamedata (item type + attributes), no hardcoded effect names.
from eos.const import FittingModuleState
from service.market import Market
def activeStateLimit(itemIdentity):
item = Market.getInstance().getItem(itemIdentity)
if not item.isType("active") or item.getAttribute("activationBlocked", 0) > 0:
return FittingModuleState.ONLINE
return FittingModuleState.ACTIVE

View File

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

View File

@@ -16,10 +16,6 @@ from eos.effectHandlerHelpers import HandledList
from eos.db import gamedata_session, getCategory, getAttributeInfo, getGroup
from eos.gamedata import Attribute, Effect, Group, Item, ItemEffect
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__)
@@ -68,6 +64,9 @@ class EfsPort:
if propID is 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.Do()
if cmd.needsGuiRecalc:
@@ -137,6 +136,7 @@ class EfsPort:
EfsPort.attrDirectMap(["reloadTime"], stats, mod)
c = mod.charge
if c:
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
sFit.recalc(fit)
CalcChangeModuleChargesCommand(
fit.ID,

View File

@@ -33,7 +33,6 @@ from eos.saveddata.fit import Fit
from eos.saveddata.implant import Implant
from eos.saveddata.module import Module
from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.const import PortEftOptions
from service.fit import Fit as svcFit
from service.market import Market
@@ -48,6 +47,8 @@ SLOT_ORDER = (FittingSlot.LOW, FittingSlot.MED, FittingSlot.HIGH, FittingSlot.RI
OFFLINE_SUFFIX = '/offline'
NAME_CHARS = r'[^,/\[\]]' # Characters which are allowed to be used in name
from service.port.active_state import activeStateLimit
class MutationExportData:

View File

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

View File

@@ -38,7 +38,6 @@ from service.port.eft import (
isValidImplantImport, isValidBoosterImport)
from service.port.esi import exportESI, importESI
from service.port.multibuy import exportMultiBuy
from service.port.shipstats import exportFitStats
from service.port.xml import importXml, exportXml
from service.port.muta import parseMutant, parseDynamicItemString, fetchDynamicItem
@@ -195,10 +194,14 @@ class Port:
# TODO: catch the exception?
# activeFit is reserved?, bufferStr is unicode? (assume only clipboard string?
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)
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.damagePattern = sFit.pattern
fit.targetProfile = sFit.targetProfile
@@ -208,6 +211,7 @@ class Port:
useCharImplants = sFit.serviceFittingOptions["useCharacterImplantsByDefault"]
fit.implantLocation = ImplantLocation.CHARACTER if useCharImplants else ImplantLocation.FIT
db.save(fit)
return importType, fits
return importType, importData
@classmethod
@@ -341,4 +345,5 @@ class Port:
@staticmethod
def exportFitStats(fit, callback=None):
return exportFitStats(fit, callback=callback)
from service.port.shipstats import exportFitStats as _exportFitStats
return _exportFitStats(fit, callback=callback)

View File

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

View File

@@ -24,7 +24,6 @@ import timeit
from itertools import chain
import math
import wx
from logbook import Logger
from eos import db
@@ -51,6 +50,8 @@ class Price:
sources = {}
def __init__(self):
# Import market sources so they register; avoid at module level so server path never loads them
from service.marketSources import evemarketdata, fuzzwork, cevemarket, evetycoon # noqa: F401
# Start price fetcher
self.priceWorkerThread = PriceWorkerThread()
self.priceWorkerThread.daemon = True
@@ -253,6 +254,7 @@ class PriceWorkerThread(threading.Thread):
if len(requests) > 0:
Price.fetchPrices(requests, fetchTimeout, validityOverride)
import wx
wx.CallAfter(callback)
queue.task_done()
@@ -273,7 +275,3 @@ class PriceWorkerThread(threading.Thread):
def stop(self):
self.running = False
# Import market sources only to initialize price source modules, they register on their own
from service.marketSources import evemarketdata, fuzzwork, cevemarket, evetycoon # noqa: E402

View File

@@ -24,7 +24,6 @@ import urllib.error
import urllib.parse
import json
from collections import namedtuple
import wx
from logbook import Logger
@@ -582,6 +581,7 @@ class LocaleSettings:
@classmethod
def supported_languages(cls):
"""Requires the application to be initialized, otherwise wx.Translation isn't set."""
import wx
pyfalog.info(f'using "{config.CATALOG}" to fetch languages, relatively base path "{os.getcwd()}"')
return {x: wx.Locale.FindLanguageInfo(x) for x in wx.Translations.Get().GetAvailableTranslations(config.CATALOG)}

46
tests/test_pyfa_sim.py Normal file
View File

@@ -0,0 +1,46 @@
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_sim # 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_sim.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" },
]
[[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]]
name = "contourpy"
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" },
]
[[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]]
name = "kiwisolver"
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" },
]
[[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]]
name = "pyasn1"
version = "0.6.1"
@@ -634,6 +661,11 @@ dependencies = [
{ name = "wxpython" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = "==4.12.2" },
@@ -643,6 +675,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.8.2" },
{ name = "numpy", specifier = "==1.26.2" },
{ name = "packaging", specifier = "==23.2" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "python-dateutil", specifier = "==2.8.2" },
{ name = "python-jose", specifier = "==3.3.0" },
{ name = "pyyaml", specifier = "==6.0.1" },
@@ -653,6 +686,16 @@ requires-dist = [
{ name = "sqlalchemy", specifier = "==1.4.50" },
{ 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]]
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" },
]
[[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]]
name = "python-dateutil"
version = "2.8.2"