diff --git a/.gitignore b/.gitignore
index 8f34001cbd..71ed4233a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+eve.db
#Python specific
*.pyc
diff --git a/_development/helpers_fits.py b/_development/helpers_fits.py
index b5dd280fef..8df2f0b8da 100644
--- a/_development/helpers_fits.py
+++ b/_development/helpers_fits.py
@@ -6,11 +6,13 @@
# noinspection PyShadowingNames
@pytest.fixture
def RifterFit(DB, Gamedata, Saveddata):
+ from eos.const import ImplantLocation
print("Creating Rifter")
item = DB['gamedata_session'].query(Gamedata['Item']).filter(Gamedata['Item'].name == "Rifter").first()
ship = Saveddata['Ship'](item)
# setup fit
fit = Saveddata['Fit'](ship, "My Rifter Fit")
+ fit.implantLocation = ImplantLocation.FIT
return fit
@@ -18,11 +20,13 @@ def RifterFit(DB, Gamedata, Saveddata):
# noinspection PyShadowingNames
@pytest.fixture
def KeepstarFit(DB, Gamedata, Saveddata):
+ from eos.const import ImplantLocation
print("Creating Keepstar")
item = DB['gamedata_session'].query(Gamedata['Item']).filter(Gamedata['Item'].name == "Keepstar").first()
ship = Saveddata['Structure'](item)
# setup fit
fit = Saveddata['Fit'](ship, "Keepstar Fit")
+ fit.implantLocation = ImplantLocation.FIT
return fit
@@ -30,11 +34,13 @@ def KeepstarFit(DB, Gamedata, Saveddata):
# noinspection PyShadowingNames
@pytest.fixture
def CurseFit(DB, Gamedata, Saveddata):
+ from eos.const import ImplantLocation
print("Creating Curse - With Neuts")
item = DB['gamedata_session'].query(Gamedata['Item']).filter(Gamedata['Item'].name == "Curse").first()
ship = Saveddata['Ship'](item)
# setup fit
fit = Saveddata['Fit'](ship, "Curse - With Neuts")
+ fit.implantLocation = ImplantLocation.FIT
mod = Saveddata['Module'](DB['db'].getItem("Medium Energy Neutralizer II"))
mod.state = Saveddata['State'].ONLINE
@@ -49,11 +55,13 @@ def CurseFit(DB, Gamedata, Saveddata):
# noinspection PyShadowingNames
@pytest.fixture
def HeronFit(DB, Gamedata, Saveddata):
+ from eos.const import ImplantLocation
print("Creating Heron - RemoteSebo")
item = DB['gamedata_session'].query(Gamedata['Item']).filter(Gamedata['Item'].name == "Heron").first()
ship = Saveddata['Ship'](item)
# setup fit
fit = Saveddata['Fit'](ship, "Heron - RemoteSebo")
+ fit.implantLocation = ImplantLocation.FIT
mod = Saveddata['Module'](DB['db'].getItem("Remote Sensor Booster II"))
mod.state = Saveddata['State'].ONLINE
diff --git a/_development/helpers_locale.py b/_development/helpers_locale.py
index 27983278d5..1035e7c35d 100644
--- a/_development/helpers_locale.py
+++ b/_development/helpers_locale.py
@@ -82,7 +82,8 @@ def GetPath(root, file=None, codec=None):
path = os.path.join(path, file)
if codec:
- path = path.decode(codec)
+ # path = path.decode(codec)
+ pass
return path
diff --git a/eos/db/migrations/upgrade50.py b/eos/db/migrations/upgrade50.py
new file mode 100644
index 0000000000..bc655db948
--- /dev/null
+++ b/eos/db/migrations/upgrade50.py
@@ -0,0 +1,14 @@
+"""
+Migration 50
+
+- add pulseInterval column to modules table
+"""
+
+import sqlalchemy
+
+
+def upgrade(saveddata_engine):
+ try:
+ saveddata_engine.execute("SELECT pulseInterval FROM modules LIMIT 1;")
+ except sqlalchemy.exc.DatabaseError:
+ saveddata_engine.execute("ALTER TABLE modules ADD COLUMN pulseInterval FLOAT;")
diff --git a/eos/db/saveddata/module.py b/eos/db/saveddata/module.py
index 83196f7af3..0e27fa65c0 100644
--- a/eos/db/saveddata/module.py
+++ b/eos/db/saveddata/module.py
@@ -43,6 +43,7 @@
Column("spoolType", Integer, nullable=True),
Column("spoolAmount", Float, nullable=True),
Column("projectionRange", Float, nullable=True),
+ Column("pulseInterval", Float, nullable=True),
CheckConstraint('("dummySlot" = NULL OR "itemID" = NULL) AND "dummySlot" != "itemID"'))
diff --git a/eos/effects.py b/eos/effects.py
index 4d727f30f8..d55b8c94b3 100644
--- a/eos/effects.py
+++ b/eos/effects.py
@@ -37,6 +37,34 @@ class DummyEffect(BaseEffect):
pass
+def _get_effect_cycle_time_ms(container, reloadOverride=None):
+ try:
+ cycle_params = container.getCycleParameters(reloadOverride=reloadOverride)
+ except AttributeError:
+ cycle_params = None
+ if cycle_params is not None:
+ return cycle_params.averageTime
+ return getattr(container, 'pulseAdjustedCycleTime', 0)
+
+
+def _get_projected_rr_cycle_time_s(container, fit):
+ reload_override = None
+ try:
+ reload_override = container.owner.factorReload
+ except AttributeError:
+ reload_override = None
+ if reload_override is None:
+ reload_override = getattr(fit, "factorReload", None)
+ cycle_ms = _get_effect_cycle_time_ms(container, reloadOverride=reload_override)
+ if reload_override and getattr(container, "charge", None) and getattr(container, "reloadTime", 0):
+ shots = getattr(container, "numShots", 0) or 0
+ if shots == 0:
+ base_ms = getattr(container, "pulseAdjustedCycleTime", 0) or getattr(container, "rawCycleTime", 0)
+ if base_ms:
+ cycle_ms = base_ms + container.reloadTime
+ return cycle_ms / 1000.0
+
+
class Effect100000(BaseEffect):
"""
pyfaCustomSuppressionTackleRange
@@ -89,7 +117,7 @@ class Effect4(BaseEffect):
@staticmethod
def handler(fit, module, context, projectionRange, **kwargs):
amount = module.getModifiedItemAttr('shieldBonus')
- speed = module.getModifiedItemAttr('duration') / 1000.0
+ speed = _get_effect_cycle_time_ms(module) / 1000.0
fit.extraAttributes.increase('shieldRepair', amount / speed, **kwargs)
@@ -181,7 +209,7 @@ class Effect26(BaseEffect):
@staticmethod
def handler(fit, module, context, projectionRange, **kwargs):
amount = module.getModifiedItemAttr('structureDamageAmount')
- speed = module.getModifiedItemAttr('duration') / 1000.0
+ speed = _get_effect_cycle_time_ms(module) / 1000.0
fit.extraAttributes.increase('hullRepair', amount / speed, **kwargs)
@@ -199,7 +227,7 @@ class Effect27(BaseEffect):
@staticmethod
def handler(fit, module, context, projectionRange, **kwargs):
amount = module.getModifiedItemAttr('armorDamageAmount')
- speed = module.getModifiedItemAttr('duration') / 1000.0
+ speed = _get_effect_cycle_time_ms(module) / 1000.0
rps = amount / speed
fit.extraAttributes.increase('armorRepair', rps, **kwargs)
fit.extraAttributes.increase('armorRepairPreSpool', rps, **kwargs)
@@ -16586,7 +16614,7 @@ class Effect4936(BaseEffect):
@staticmethod
def handler(fit, module, context, projectionRange, **kwargs):
amount = module.getModifiedItemAttr('shieldBonus')
- speed = module.getModifiedItemAttr('duration') / 1000.0
+ speed = _get_effect_cycle_time_ms(module) / 1000.0
fit.extraAttributes.increase('shieldRepair', amount / speed, **kwargs)
@@ -18792,7 +18820,7 @@ def handler(fit, module, context, projectionRange, **kwargs):
multiplier = 1
amount = module.getModifiedItemAttr('armorDamageAmount') * multiplier
- speed = module.getModifiedItemAttr('duration') / 1000.0
+ speed = _get_effect_cycle_time_ms(module) / 1000.0
rps = amount / speed
fit.extraAttributes.increase('armorRepair', rps, **kwargs)
fit.extraAttributes.increase('armorRepairPreSpool', rps, **kwargs)
@@ -24762,7 +24790,7 @@ def handler(fit, src, context, projectionRange, **kwargs):
if src.getModifiedItemAttr('maxRange', 0) < (projectionRange or 0):
return
amount = src.getModifiedItemAttr('powerTransferAmount')
- duration = src.getModifiedItemAttr('duration')
+ duration = _get_effect_cycle_time_ms(src)
if 'effect' in kwargs:
from eos.modifiedAttributeDict import ModifiedAttributeDict
amount *= ModifiedAttributeDict.getResistance(fit, kwargs['effect'])
@@ -24791,7 +24819,7 @@ def handler(fit, module, context, projectionRange, **kwargs):
srcOptimalRange=module.getModifiedItemAttr('maxRange'),
srcFalloffRange=module.getModifiedItemAttr('falloffEffectiveness'),
distance=projectionRange)
- duration = module.getModifiedItemAttr('duration') / 1000.0
+ duration = _get_projected_rr_cycle_time_s(module, fit)
fit._hullRr.append((bonus, duration))
@@ -24816,7 +24844,7 @@ def handler(fit, container, context, projectionRange, **kwargs):
srcOptimalRange=container.getModifiedItemAttr('maxRange'),
srcFalloffRange=container.getModifiedItemAttr('falloffEffectiveness'),
distance=projectionRange)
- duration = container.getModifiedItemAttr('duration') / 1000.0
+ duration = _get_projected_rr_cycle_time_s(container, fit)
fit._shieldRr.append((bonus, duration))
@@ -24844,7 +24872,7 @@ def handler(fit, src, context, projectionRange, **kwargs):
if 'effect' in kwargs:
from eos.modifiedAttributeDict import ModifiedAttributeDict
amount *= ModifiedAttributeDict.getResistance(fit, kwargs['effect'])
- time = src.getModifiedItemAttr('duration')
+ time = _get_effect_cycle_time_ms(src)
fit.addDrain(src, time, amount, 0)
@@ -24870,7 +24898,7 @@ def handler(fit, container, context, projectionRange, **kwargs):
srcOptimalRange=container.getModifiedItemAttr('maxRange'),
srcFalloffRange=container.getModifiedItemAttr('falloffEffectiveness'),
distance=projectionRange)
- duration = container.getModifiedItemAttr('duration') / 1000.0
+ duration = _get_projected_rr_cycle_time_s(container, fit)
fit._armorRr.append((bonus, duration))
fit._armorRrPreSpool.append((bonus, duration))
fit._armorRrFullSpool.append((bonus, duration))
@@ -24908,7 +24936,7 @@ class Effect6197(BaseEffect):
@staticmethod
def handler(fit, src, context, projectionRange, **kwargs):
amount = src.getModifiedItemAttr('powerTransferAmount')
- time = src.getModifiedItemAttr('duration')
+ time = _get_effect_cycle_time_ms(src)
if 'projected' in context:
if 'effect' in kwargs:
from eos.modifiedAttributeDict import ModifiedAttributeDict
@@ -24996,7 +25024,7 @@ def handler(fit, src, context, projectionRange, **kwargs):
if 'effect' in kwargs:
from eos.modifiedAttributeDict import ModifiedAttributeDict
amount *= ModifiedAttributeDict.getResistance(fit, kwargs['effect'])
- time = src.getModifiedItemAttr('duration')
+ time = src.pulseAdjustedCycleTime
fit.addDrain(src, time, amount, 0)
@@ -29865,8 +29893,8 @@ def handler(fit, module, context, projectionRange, **kwargs):
srcOptimalRange=module.getModifiedItemAttr('maxRange'),
srcFalloffRange=module.getModifiedItemAttr('falloffEffectiveness'),
distance=projectionRange)
- speed = module.getModifiedItemAttr('duration') / 1000.0
- fit._shieldRr.append((amount, speed))
+ duration = _get_projected_rr_cycle_time_s(module, fit)
+ fit._shieldRr.append((amount, duration))
class Effect6653(BaseEffect):
@@ -35103,6 +35131,10 @@ def handler(fit, container, context, projectionRange, **kwargs):
repSpoolPerCycle = container.getModifiedItemAttr('repairMultiplierBonusPerCycle')
defaultSpoolValue = eos.config.settings['globalDefaultSpoolupPercentage']
spoolType, spoolAmount = resolveSpoolOptions(SpoolOptions(SpoolType.SPOOL_SCALE, defaultSpoolValue, False), container)
+
+ if container.pulseInterval is not None and container.pulseInterval > cycleTime + 0.001:
+ spoolAmount = 0
+
amount = repAmountBase * (1 + calculateSpoolup(repSpoolMax, repSpoolPerCycle, cycleTime, spoolType, spoolAmount)[0])
amountPreSpool = repAmountBase * (1 + calculateSpoolup(repSpoolMax, repSpoolPerCycle, cycleTime, SpoolType.SPOOL_SCALE, 0)[0])
amountFullSpool = repAmountBase * (1 + calculateSpoolup(repSpoolMax, repSpoolPerCycle, cycleTime, SpoolType.SPOOL_SCALE, 1)[0])
diff --git a/eos/modifiedAttributeDict.py b/eos/modifiedAttributeDict.py
index 0c6f1dc0c9..01359f706b 100644
--- a/eos/modifiedAttributeDict.py
+++ b/eos/modifiedAttributeDict.py
@@ -32,6 +32,8 @@
cappingAttrKeyCache = {}
resistanceCache = {}
+_debug_max_velocity_count = 0
+
def getAttrDefault(key, fallback=None):
try:
@@ -217,12 +219,32 @@ def getExtended(self, key, extraMultipliers=None, ignoreAfflictors=None, default
multiplierAdjustment = 1
ignorePenalizedMultipliers = {}
postIncreaseAdjustment = 0
- for fit, afflictors in self.getAfflictions(key).items():
+ afflictions = self.getAfflictions(key)
+ ignored_zero_multiplier = False
+ global _debug_max_velocity_count
+ if key == 'maxVelocity' and _debug_max_velocity_count < 8:
+ ignore_names = []
+ if ignoreAfflictors:
+ ignore_names = [getattr(getattr(a, 'item', None), 'name', str(a)) for a in ignoreAfflictors]
+ affliction_names = []
+ for fit, afflictors in afflictions.items():
+ for afflictor, operator, stackingGroup, preResAmount, postResAmount, used in afflictors:
+ name = getattr(getattr(afflictor, 'item', None), 'name', str(afflictor))
+ affliction_names.append((name, operator, stackingGroup, preResAmount, postResAmount, used))
+ print(
+ "[AttrDebug] key=maxVelocity ignores={} afflictions={}".format(ignore_names, affliction_names),
+ flush=True)
+ _debug_max_velocity_count += 1
+
+ for fit, afflictors in afflictions.items():
for afflictor, operator, stackingGroup, preResAmount, postResAmount, used in afflictors:
if afflictor in ignoreAfflictors:
if operator == Operator.MULTIPLY:
if stackingGroup is None:
- multiplierAdjustment /= postResAmount
+ if postResAmount == 0:
+ ignored_zero_multiplier = True
+ else:
+ multiplierAdjustment /= postResAmount
else:
ignorePenalizedMultipliers.setdefault(stackingGroup, []).append(postResAmount)
elif operator == Operator.PREINCREASE:
@@ -230,6 +252,92 @@ def getExtended(self, key, extraMultipliers=None, ignoreAfflictors=None, default
elif operator == Operator.POSTINCREASE:
postIncreaseAdjustment -= postResAmount
+ if ignored_zero_multiplier and self.__multipliers.get(key, 1) == 0:
+ recomputed_multiplier = 1
+ for fit, afflictors in afflictions.items():
+ for afflictor, operator, stackingGroup, preResAmount, postResAmount, used in afflictors:
+ if operator != Operator.MULTIPLY or stackingGroup is not None:
+ continue
+ if afflictor in ignoreAfflictors:
+ continue
+ recomputed_multiplier *= postResAmount
+ # Recalculate value without applying stored zero multiplier
+ try:
+ cappingKey = cappingAttrKeyCache[key]
+ except KeyError:
+ attrInfo = getAttributeInfo(key)
+ if attrInfo is None:
+ cappingId = cappingAttrKeyCache[key] = None
+ else:
+ cappingId = attrInfo.maxAttributeID
+ if cappingId is None:
+ cappingKey = None
+ else:
+ cappingAttrInfo = getAttributeInfo(cappingId)
+ cappingKey = None if cappingAttrInfo is None else cappingAttrInfo.name
+ cappingAttrKeyCache[key] = cappingKey
+ if cappingKey:
+ cappingValue = self[cappingKey]
+ cappingValue = cappingValue.value if hasattr(cappingValue, "value") else cappingValue
+ else:
+ cappingValue = None
+
+ preIncrease = self.__preIncreases.get(key, 0)
+ postIncrease = self.__postIncreases.get(key, 0)
+ penalizedMultiplierGroups = self.__penalizedMultipliers.get(key, {})
+ if extraMultipliers is not None:
+ penalizedMultiplierGroups = copy(penalizedMultiplierGroups)
+ for stackGroup, operationsData in extraMultipliers.items():
+ multipliers = []
+ for mult, resAttrID in operationsData:
+ if not resAttrID:
+ multipliers.append(mult)
+ continue
+ resAttrInfo = getAttributeInfo(resAttrID)
+ if not resAttrInfo:
+ multipliers.append(mult)
+ continue
+ resMult = self.fit.ship.itemModifiedAttributes[resAttrInfo.attributeName]
+ if resMult is None or resMult == 1:
+ multipliers.append(mult)
+ continue
+ mult = (mult - 1) * resMult + 1
+ multipliers.append(mult)
+ penalizedMultiplierGroups[stackGroup] = penalizedMultiplierGroups.get(stackGroup, []) + multipliers
+
+ default = getAttrDefault(key, fallback=0.0)
+ val = self.__intermediary.get(key, self.__preAssigns.get(key, self.getOriginal(key, default)))
+ val += preIncrease
+ if preIncreaseAdjustment is not None:
+ val += preIncreaseAdjustment
+ val *= recomputed_multiplier
+ for penaltyGroup, penalizedMultipliers in penalizedMultiplierGroups.items():
+ if ignorePenalizedMultipliers is not None and penaltyGroup in ignorePenalizedMultipliers:
+ penalizedMultipliers = penalizedMultipliers[:]
+ for ignoreMult in ignorePenalizedMultipliers[penaltyGroup]:
+ try:
+ penalizedMultipliers.remove(ignoreMult)
+ except ValueError:
+ pass
+ l1 = [_val for _val in penalizedMultipliers if _val > 1]
+ l2 = [_val for _val in penalizedMultipliers if _val < 1]
+ abssort = lambda _val: -abs(_val - 1)
+ l1.sort(key=abssort)
+ l2.sort(key=abssort)
+ for l in (l1, l2):
+ for i in range(len(l)):
+ bonus = l[i]
+ val *= 1 + (bonus - 1) * exp(- i ** 2 / 7.1289)
+ val += postIncrease
+ if postIncreaseAdjustment is not None:
+ val += postIncreaseAdjustment
+
+ if cappingValue is not None:
+ val = min(val, cappingValue)
+ if key in ("cpu", "power", "cpuOutput", "powerOutput"):
+ val = round(val, 2)
+ return val
+
# If we apply no customizations - use regular getter
if (
not extraMultipliers and
diff --git a/eos/saveddata/fit.py b/eos/saveddata/fit.py
index 324fea1207..c4a0d548f4 100644
--- a/eos/saveddata/fit.py
+++ b/eos/saveddata/fit.py
@@ -41,6 +41,7 @@
from eos.saveddata.targetProfile import TargetProfile
from eos.utils.float import floatUnerr
from eos.utils.stats import DmgTypes, RRTypes
+from eos.utils.timeline import ModuleTimeline
pyfalog = Logger(__name__)
@@ -165,6 +166,7 @@ def build(self):
self._armorRrPreSpool = []
self._armorRrFullSpool = []
self._shieldRr = []
+ self.__timelineCache = {}
def clearFactorReloadDependentData(self):
# Here we clear all data known to rely on cycle parameters
@@ -561,6 +563,7 @@ def clear(self, projected=False, command=False):
self._armorRrPreSpool.clear()
self._armorRrFullSpool.clear()
self._shieldRr.clear()
+ self.__timelineCache.clear()
# If this is the active fit that we are clearing, not a projected fit,
# then this will run and clear the projected ships and flag the next
@@ -1099,6 +1102,7 @@ def calculateModifiedAttributes(self, targetFit=None, type=CalcType.LOCAL):
for fit in self.projectedFits:
projInfo = fit.getProjectionInfo(self.ID)
if projInfo.active:
+ fit.factorReload = self.factorReload
if fit == self:
# If doing self projection, no need to run through the recursion process. Simply run the
# projection effects on ourselves
@@ -1434,9 +1438,7 @@ def __generateDrain(self):
capAdded = 0
for mod in self.activeModulesIter():
if (mod.getModifiedItemAttr("capacitorNeed") or 0) != 0:
- cycleTime = mod.rawCycleTime or 0
- reactivationTime = mod.getModifiedItemAttr("moduleReactivationDelay") or 0
- fullCycleTime = cycleTime + reactivationTime
+ fullCycleTime = mod.pulseAdjustedCycleTime or 0
reloadTime = mod.reloadTime
if fullCycleTime > 0:
capNeed = mod.capUse
@@ -1649,13 +1651,15 @@ def calculateSustainableTank(self):
# Normal Repairers
if usesCap and not afflictor.charge:
- cycleTime = afflictor.rawCycleTime
+ cycle_params = afflictor.getCycleParameters()
+ cycleTime = (cycle_params.averageTime if cycle_params is not None else afflictor.pulseAdjustedCycleTime)
amount = afflictor.getModifiedItemAttr(groupAttrMap[afflictor.item.group.name])
localAdjustment[tankType] -= amount / (cycleTime / 1000.0)
repairers.append(afflictor)
# Ancillary Armor reps etc
elif usesCap and afflictor.charge:
- cycleTime = afflictor.rawCycleTime
+ cycle_params = afflictor.getCycleParameters()
+ cycleTime = (cycle_params.averageTime if cycle_params is not None else afflictor.pulseAdjustedCycleTime)
amount = afflictor.getModifiedItemAttr(groupAttrMap[afflictor.item.group.name])
if afflictor.charge.name == "Nanite Repair Paste":
multiplier = afflictor.getModifiedItemAttr("chargedArmorDamageMultiplier") or 1
@@ -1665,7 +1669,8 @@ def calculateSustainableTank(self):
repairers.append(afflictor)
# Ancillary Shield boosters etc
elif not usesCap and afflictor.item.group.name in ("Ancillary Shield Booster", "Ancillary Remote Shield Booster"):
- cycleTime = afflictor.rawCycleTime
+ cycle_params = afflictor.getCycleParameters()
+ cycleTime = (cycle_params.averageTime if cycle_params is not None else afflictor.pulseAdjustedCycleTime)
amount = afflictor.getModifiedItemAttr(groupAttrMap[afflictor.item.group.name])
if self.factorReload and afflictor.charge:
reloadtime = afflictor.reloadTime
@@ -1692,7 +1697,8 @@ def calculateSustainableTank(self):
else:
reloadtime = 0.0
- cycleTime = afflictor.rawCycleTime
+ cycle_params = afflictor.getCycleParameters()
+ cycleTime = (cycle_params.averageTime if cycle_params is not None else afflictor.pulseAdjustedCycleTime)
capPerSec = afflictor.capUse
if capPerSec is not None and cycleTime is not None:
@@ -1845,6 +1851,47 @@ def activeFighterAbilityIter(self):
if ability.active:
yield fighter, ability
+ def getInactiveModulesAt(self, timeMs, exclude=()):
+ if timeMs is None or timeMs < 0:
+ return []
+ inactive = []
+ exclude_set = set(exclude) if exclude else set()
+ for mod in self.activeModulesIter():
+ if mod in exclude_set:
+ continue
+ timeline = self.__timelineCache.get(mod)
+ if timeline is None:
+ cycleParams = mod.getCycleParameters(reloadOverride=True)
+ timeline = ModuleTimeline(mod, cycleParams)
+ self.__timelineCache[mod] = timeline
+ if not timeline.is_active_at(timeMs):
+ inactive.append(mod)
+ return inactive
+
+ def getPulseInactiveAfflictorsAt(self, timeMs, exclude=()):
+ """Return list of pulsed modules inactive at a given time in ms."""
+ if timeMs is None:
+ return []
+ if timeMs < 0:
+ return []
+ exclude_set = set(exclude) if exclude else set()
+ inactive_afflictors = []
+ for mod in self.activeModulesIter():
+ if mod in exclude_set:
+ continue
+ if mod.pulseInterval is None:
+ continue
+ cycle_time = mod.rawCycleTime
+ if not cycle_time:
+ continue
+ full_cycle = mod.pulseAdjustedCycleTime
+ if full_cycle <= cycle_time:
+ continue
+ time_in_cycle = timeMs % full_cycle
+ if time_in_cycle >= cycle_time:
+ inactive_afflictors.append(mod)
+ return inactive_afflictors
+
def getDampMultScanRes(self):
damps = []
for mod in self.activeModulesIter():
diff --git a/eos/saveddata/module.py b/eos/saveddata/module.py
index 99a9eaa61b..013d31a81d 100644
--- a/eos/saveddata/module.py
+++ b/eos/saveddata/module.py
@@ -84,6 +84,7 @@ def __init__(self, item, baseItem=None, mutaplasmid=None):
self.projected = False
self.projectionRange = None
+ self.pulseInterval = None
self.state = FittingModuleState.ONLINE
self.build()
@@ -111,6 +112,9 @@ def init(self):
pyfalog.error("Item (id: {0}) is not a Module", self.itemID)
return
+ if not hasattr(self, "pulseInterval"):
+ self.pulseInterval = None
+
if self.chargeID:
self.__charge = eos.db.getItem(self.chargeID)
@@ -474,59 +478,81 @@ def canDealDamage(self, ignoreState=False):
return True
return False
- def getVolleyParameters(self, spoolOptions=None, targetProfile=None, ignoreState=False):
+ def getVolleyParameters(self, spoolOptions=None, targetProfile=None, ignoreState=False, ignoreAfflictors=()):
if self.isEmpty or (self.state < FittingModuleState.ACTIVE and not ignoreState):
return {0: DmgTypes.default()}
- if self.__baseVolley is None:
- self.__baseVolley = {}
+ def get_item_attr(name, default=0):
+ if ignoreAfflictors:
+ return self.getModifiedItemAttrExtended(name, ignoreAfflictors=ignoreAfflictors, default=default)
+ return self.getModifiedItemAttr(name, default)
+
+ def get_charge_attr(name, default=0):
+ if ignoreAfflictors:
+ return self.getModifiedChargeAttrExtended(name, ignoreAfflictors=ignoreAfflictors, default=default)
+ return self.getModifiedChargeAttr(name, default)
+
+ baseVolleys = self.__baseVolley
+ if ignoreAfflictors:
+ baseVolleys = None
+ if baseVolleys is None:
+ baseVolleys = {}
if self.isBreacher:
dmgDelay = 1
- subcycles = math.floor(self.getModifiedChargeAttr("dotDuration", 0) / 1000)
+ subcycles = math.floor(get_charge_attr("dotDuration", 0) / 1000)
breacher_info = BreacherInfo(
- absolute=self.getModifiedChargeAttr("dotMaxDamagePerTick", 0),
- relative=self.getModifiedChargeAttr("dotMaxHPPercentagePerTick", 0) / 100)
+ absolute=get_charge_attr("dotMaxDamagePerTick", 0),
+ relative=get_charge_attr("dotMaxHPPercentagePerTick", 0) / 100)
for i in range(subcycles):
volley = DmgTypes.default()
volley.add_breacher(dmgDelay + i, breacher_info)
- self.__baseVolley[dmgDelay + i] = volley
+ baseVolleys[dmgDelay + i] = volley
else:
- dmgGetter = self.getModifiedChargeAttr if self.charge else self.getModifiedItemAttr
- dmgMult = self.getModifiedItemAttr("damageMultiplier", 1)
+ dmgGetter = get_charge_attr if self.charge else get_item_attr
+ dmgMult = get_item_attr("damageMultiplier", 1)
# Some delay attributes have non-0 default value, so we have to pick according to effects
if {'superWeaponAmarr', 'superWeaponCaldari', 'superWeaponGallente', 'superWeaponMinmatar', 'lightningWeapon'}.intersection(self.item.effects):
- dmgDelay = self.getModifiedItemAttr("damageDelayDuration", 0)
+ dmgDelay = get_item_attr("damageDelayDuration", 0)
elif {'doomsdayBeamDOT', 'doomsdaySlash', 'doomsdayConeDOT', 'debuffLance'}.intersection(self.item.effects):
- dmgDelay = self.getModifiedItemAttr("doomsdayWarningDuration", 0)
+ dmgDelay = get_item_attr("doomsdayWarningDuration", 0)
else:
dmgDelay = 0
- dmgDuration = self.getModifiedItemAttr("doomsdayDamageDuration", 0)
- dmgSubcycle = self.getModifiedItemAttr("doomsdayDamageCycleTime", 0)
+ dmgDuration = get_item_attr("doomsdayDamageDuration", 0)
+ dmgSubcycle = get_item_attr("doomsdayDamageCycleTime", 0)
# Reaper DD can damage each target only once
if dmgDuration != 0 and dmgSubcycle != 0 and 'doomsdaySlash' not in self.item.effects:
subcycles = math.floor(floatUnerr(dmgDuration / dmgSubcycle))
else:
subcycles = 1
for i in range(subcycles):
- self.__baseVolley[dmgDelay + dmgSubcycle * i] = DmgTypes(
+ baseVolleys[dmgDelay + dmgSubcycle * i] = DmgTypes(
em=(dmgGetter("emDamage", 0)) * dmgMult,
thermal=(dmgGetter("thermalDamage", 0)) * dmgMult,
kinetic=(dmgGetter("kineticDamage", 0)) * dmgMult,
explosive=(dmgGetter("explosiveDamage", 0)) * dmgMult)
- spoolType, spoolAmount = resolveSpoolOptions(spoolOptions, self)
+ if not ignoreAfflictors:
+ self.__baseVolley = baseVolleys
+ if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE:
+ spoolType, spoolAmount = None, None
+ else:
+ spoolType, spoolAmount = resolveSpoolOptions(spoolOptions, self)
spoolBoost = calculateSpoolup(
- self.getModifiedItemAttr("damageMultiplierBonusMax", 0),
- self.getModifiedItemAttr("damageMultiplierBonusPerCycle", 0),
+ get_item_attr("damageMultiplierBonusMax", 0),
+ get_item_attr("damageMultiplierBonusPerCycle", 0),
self.rawCycleTime / 1000, spoolType, spoolAmount)[0]
spoolMultiplier = 1 + spoolBoost
adjustedVolleys = {}
- for volleyTime, baseVolley in self.__baseVolley.items():
+ for volleyTime, baseVolley in baseVolleys.items():
adjustedVolley = baseVolley * spoolMultiplier
adjustedVolley.profile = targetProfile
adjustedVolleys[volleyTime] = adjustedVolley
return adjustedVolleys
- def getVolley(self, spoolOptions=None, targetProfile=None, ignoreState=False):
- volleyParams = self.getVolleyParameters(spoolOptions=spoolOptions, targetProfile=targetProfile, ignoreState=ignoreState)
+ def getVolley(self, spoolOptions=None, targetProfile=None, ignoreState=False, ignoreAfflictors=()):
+ volleyParams = self.getVolleyParameters(
+ spoolOptions=spoolOptions,
+ targetProfile=targetProfile,
+ ignoreState=ignoreState,
+ ignoreAfflictors=ignoreAfflictors)
if len(volleyParams) == 0:
return DmgTypes.default()
return volleyParams[min(volleyParams)]
@@ -589,7 +615,10 @@ def getRepAmountParameters(self, spoolOptions=None, ignoreState=False):
capacitorAmount += self.getModifiedItemAttr("powerTransferAmount", 0)
rrDelay = 0 if rrType == "Shield" else self.rawCycleTime
self.__baseRRAmount[rrDelay] = RRTypes(shield=shieldAmount, armor=armorAmount, hull=hullAmount, capacitor=capacitorAmount)
- spoolType, spoolAmount = resolveSpoolOptions(spoolOptions, self)
+ if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE:
+ spoolType, spoolAmount = None, None
+ else:
+ spoolType, spoolAmount = resolveSpoolOptions(spoolOptions, self)
spoolBoost = calculateSpoolup(
self.getModifiedItemAttr("repairMultiplierBonusMax", 0),
self.getModifiedItemAttr("repairMultiplierBonusPerCycle", 0),
@@ -619,6 +648,8 @@ def getRemoteReps(self, spoolOptions=None, ignoreState=False, reloadOverride=Non
return rps
def getSpoolData(self, spoolOptions=None):
+ if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE:
+ return 0, 0
weaponMultMax = self.getModifiedItemAttr("damageMultiplierBonusMax", 0)
weaponMultPerCycle = self.getModifiedItemAttr("damageMultiplierBonusPerCycle", 0)
if weaponMultMax and weaponMultPerCycle:
@@ -981,6 +1012,10 @@ def getCycleParameters(self, reloadOverride=None):
if active_time == 0:
return None
forced_inactive_time = self.reactivationDelay
+ if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE:
+ pulse_interval_ms = max(active_time, self.pulseInterval * 1000)
+ pulse_inactive_time = max(0, pulse_interval_ms - active_time)
+ forced_inactive_time = max(forced_inactive_time, pulse_inactive_time)
reload_time = self.reloadTime
# Effects which cannot be reloaded have the same processing whether
# caller wants to take reload time into account or not
@@ -1035,6 +1070,17 @@ def rawCycleTime(self):
)
return speed
+ @property
+ def pulseAdjustedCycleTime(self):
+ cycle_time = self.rawCycleTime
+ if cycle_time == 0:
+ return 0
+ inactive_time = self.reactivationDelay
+ if self.pulseInterval is not None and self.state >= FittingModuleState.ACTIVE:
+ pulse_interval_ms = max(cycle_time, self.pulseInterval * 1000)
+ inactive_time = max(inactive_time, pulse_interval_ms - cycle_time)
+ return cycle_time + inactive_time
+
@property
def disallowRepeatingAction(self):
return self.getModifiedItemAttr("disallowRepeatingActivation", 0)
diff --git a/eos/utils/timeline.py b/eos/utils/timeline.py
new file mode 100644
index 0000000000..243a6a97f3
--- /dev/null
+++ b/eos/utils/timeline.py
@@ -0,0 +1,95 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of eos.
+#
+# eos is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 2 of the License, or
+# (at your option) any later version.
+#
+# eos 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 Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with eos. If not, see .
+# =============================================================================
+
+import math
+
+from eos.utils.cycles import CycleInfo, CycleSequence
+
+
+class ModuleTimeline:
+ """Represents repeating ON/OFF activation windows for a module."""
+
+ def __init__(self, module, cycleParams):
+ self._module = module
+ self._cycleParams = cycleParams
+ self._pattern = [] # list of (active_ms, inactive_ms)
+ self._pattern_time = 0
+ self._repeat_inf = False
+ self._repeat_count = 1
+ self._build_pattern()
+
+ def _build_pattern(self):
+ if self._cycleParams is None:
+ return
+
+ pulse_forced = self._module.pulseInterval is not None
+ repeat_allowed = pulse_forced or not self._module.disallowRepeatingAction
+
+ if isinstance(self._cycleParams, CycleInfo):
+ active = self._cycleParams.activeTime
+ inactive = self._cycleParams.inactiveTime
+ quantity = self._cycleParams.quantity
+ if quantity is math.inf:
+ self._repeat_inf = repeat_allowed
+ self._repeat_count = 1 if not repeat_allowed else math.inf
+ else:
+ self._repeat_count = quantity if repeat_allowed else 1
+ self._pattern = [(active, inactive)]
+ self._pattern_time = active + inactive
+ return
+
+ if isinstance(self._cycleParams, CycleSequence):
+ # Build a single sequence pattern
+ sequence_pattern = []
+ for cycleInfo in self._cycleParams.sequence:
+ for active, inactive, _ in cycleInfo.iterCycles():
+ sequence_pattern.append((active, inactive))
+ self._pattern = sequence_pattern
+ self._pattern_time = sum(active + inactive for active, inactive in sequence_pattern)
+ if self._cycleParams.quantity is math.inf:
+ self._repeat_inf = repeat_allowed
+ self._repeat_count = 1 if not repeat_allowed else math.inf
+ else:
+ self._repeat_count = self._cycleParams.quantity if repeat_allowed else 1
+
+ def is_active_at(self, timeMs):
+ if timeMs is None or timeMs < 0:
+ return False
+ if not self._pattern:
+ return True
+ if self._pattern_time <= 0:
+ return True
+
+ if self._repeat_inf:
+ time_in_period = timeMs % self._pattern_time
+ else:
+ total_time = self._pattern_time * self._repeat_count
+ if timeMs >= total_time:
+ return False
+ time_in_period = timeMs % self._pattern_time
+
+ cursor = 0
+ for active, inactive in self._pattern:
+ if time_in_period < cursor + active:
+ return True
+ cursor += active
+ if time_in_period < cursor + inactive:
+ return False
+ cursor += inactive
+ return False
diff --git a/graphs/data/fitCapacitor/getter.py b/graphs/data/fitCapacitor/getter.py
index 7a71eb8c7c..8e5244753f 100644
--- a/graphs/data/fitCapacitor/getter.py
+++ b/graphs/data/fitCapacitor/getter.py
@@ -46,14 +46,27 @@ def getRange(self, xRange, miscParams, src, tgt):
# When time range lies to the right of last cap sim data point, return nothing
if len(capSimDataBefore) > 0 and max(capSimDataBefore) == capSimDataMaxTime:
return xs, ys
- maxCapAmount = src.item.ship.getModifiedItemAttr('capacitorCapacity')
- capRegenTime = src.item.ship.getModifiedItemAttr('rechargeRate') / 1000
+ defaultMaxCapAmount = src.item.ship.getModifiedItemAttr('capacitorCapacity')
+ defaultCapRegenTime = src.item.ship.getModifiedItemAttr('rechargeRate') / 1000
+
+ def getCapAttrs(atTime):
+ if getattr(src, 'isFit', False):
+ ignore_afflictors = src.item.getInactiveModulesAt(atTime * 1000)
+ maxCap = src.item.ship.getModifiedItemAttrExtended(
+ 'capacitorCapacity',
+ ignoreAfflictors=ignore_afflictors)
+ capRegen = src.item.ship.getModifiedItemAttrExtended(
+ 'rechargeRate',
+ ignoreAfflictors=ignore_afflictors) / 1000
+ return maxCap, capRegen
+ return defaultMaxCapAmount, defaultCapRegenTime
def plotCapRegen(prevTime, prevCap, currentTime):
subrangeAmount = math.ceil((currentTime - prevTime) / maxPointXDistance)
subrangeLength = (currentTime - prevTime) / subrangeAmount
for i in range(1, subrangeAmount + 1):
subrangeTime = prevTime + subrangeLength * i
+ maxCapAmount, capRegenTime = getCapAttrs(subrangeTime)
subrangeCap = calculateCapAmount(
maxCapAmount=maxCapAmount,
capRegenTime=capRegenTime,
@@ -66,12 +79,14 @@ def plotCapRegen(prevTime, prevCap, currentTime):
if capSimDataBefore:
timeBefore = max(capSimDataBefore)
capBefore = capSimDataBefore[timeBefore]
+ maxCapAmount, capRegenTime = getCapAttrs(prevTime)
prevCap = calculateCapAmount(
maxCapAmount=maxCapAmount,
capRegenTime=capRegenTime,
capAmountT0=capBefore,
time=prevTime - timeBefore)
else:
+ maxCapAmount, capRegenTime = getCapAttrs(prevTime)
prevCap = calculateCapAmount(
maxCapAmount=maxCapAmount,
capRegenTime=capRegenTime,
@@ -106,8 +121,17 @@ def getPoint(self, x, miscParams, src, tgt):
# When time range lies to the right of last cap sim data point, return nothing
if len(capSimDataBefore) > 0 and max(capSimDataBefore) == capSimDataMaxTime:
return None
- maxCapAmount = src.item.ship.getModifiedItemAttr('capacitorCapacity')
- capRegenTime = src.item.ship.getModifiedItemAttr('rechargeRate') / 1000
+ if getattr(src, 'isFit', False):
+ ignore_afflictors = src.item.getInactiveModulesAt(currentTime * 1000)
+ maxCapAmount = src.item.ship.getModifiedItemAttrExtended(
+ 'capacitorCapacity',
+ ignoreAfflictors=ignore_afflictors)
+ capRegenTime = src.item.ship.getModifiedItemAttrExtended(
+ 'rechargeRate',
+ ignoreAfflictors=ignore_afflictors) / 1000
+ else:
+ maxCapAmount = src.item.ship.getModifiedItemAttr('capacitorCapacity')
+ capRegenTime = src.item.ship.getModifiedItemAttr('rechargeRate') / 1000
if capSimDataBefore:
timeBefore = max(capSimDataBefore)
capBefore = capSimDataBefore[timeBefore]
@@ -134,9 +158,20 @@ def _getCommonData(self, miscParams, src, tgt):
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
time = x
+ if getattr(src, 'isFit', False):
+ ignore_afflictors = src.item.getInactiveModulesAt(time * 1000)
+ maxCapAmount = src.item.ship.getModifiedItemAttrExtended(
+ 'capacitorCapacity',
+ ignoreAfflictors=ignore_afflictors)
+ capRegenTime = src.item.ship.getModifiedItemAttrExtended(
+ 'rechargeRate',
+ ignoreAfflictors=ignore_afflictors) / 1000
+ else:
+ maxCapAmount = commonData['maxCapAmount']
+ capRegenTime = commonData['capRegenTime']
capAmount = calculateCapAmount(
- maxCapAmount=commonData['maxCapAmount'],
- capRegenTime=commonData['capRegenTime'],
+ maxCapAmount=maxCapAmount,
+ capRegenTime=capRegenTime,
capAmountT0=miscParams['capAmountT0'] or 0,
time=time)
return capAmount
@@ -151,14 +186,25 @@ def _getCommonData(self, miscParams, src, tgt):
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
time = x
+ if getattr(src, 'isFit', False):
+ ignore_afflictors = src.item.getInactiveModulesAt(time * 1000)
+ maxCapAmount = src.item.ship.getModifiedItemAttrExtended(
+ 'capacitorCapacity',
+ ignoreAfflictors=ignore_afflictors)
+ capRegenTime = src.item.ship.getModifiedItemAttrExtended(
+ 'rechargeRate',
+ ignoreAfflictors=ignore_afflictors) / 1000
+ else:
+ maxCapAmount = commonData['maxCapAmount']
+ capRegenTime = commonData['capRegenTime']
capAmount = calculateCapAmount(
- maxCapAmount=commonData['maxCapAmount'],
- capRegenTime=commonData['capRegenTime'],
+ maxCapAmount=maxCapAmount,
+ capRegenTime=capRegenTime,
capAmountT0=miscParams['capAmountT0'] or 0,
time=time)
capRegen = calculateCapRegen(
- maxCapAmount=commonData['maxCapAmount'],
- capRegenTime=commonData['capRegenTime'],
+ maxCapAmount=maxCapAmount,
+ capRegenTime=capRegenTime,
currentCapAmount=capAmount)
return capRegen
diff --git a/graphs/data/fitDamageStats/cache/projected.py b/graphs/data/fitDamageStats/cache/projected.py
index 8b0dbaad0d..9f8bcb22c2 100644
--- a/graphs/data/fitDamageStats/cache/projected.py
+++ b/graphs/data/fitDamageStats/cache/projected.py
@@ -30,15 +30,22 @@
class ProjectedDataCache(FitDataCache):
- def getProjModData(self, src):
+ def getProjModData(self, src, timeMs=None):
try:
- projectedData = self._data[src.item.ID]['modules']
+ if timeMs is None:
+ projectedData = self._data[src.item.ID]['modules']
+ else:
+ raise KeyError
except KeyError:
# Format of items for both: (boost strength, optimal, falloff, stacking group, resistance attr ID)
webMods = []
tpMods = []
- projectedData = self._data.setdefault(src.item.ID, {})['modules'] = (webMods, tpMods)
+ projectedData = (webMods, tpMods)
for mod in src.item.activeModulesIter():
+ if timeMs is not None and mod.pulseInterval is not None:
+ inactive = src.item.getInactiveModulesAt(timeMs, exclude=())
+ if mod in inactive:
+ continue
for webEffectName in ('remoteWebifierFalloff', 'structureModuleEffectStasisWebifier'):
if webEffectName in mod.item.effects:
webMods.append(ModProjData(
@@ -69,6 +76,8 @@ def getProjModData(self, src):
mod.falloff or 0,
'default',
getResistanceAttrID(modifyingItem=mod, effect=mod.item.effects['doomsdayAOEPaint'])))
+ if timeMs is None:
+ self._data.setdefault(src.item.ID, {})['modules'] = projectedData
return projectedData
def getProjDroneData(self, src):
diff --git a/graphs/data/fitDamageStats/cache/time.py b/graphs/data/fitDamageStats/cache/time.py
index 72d8aaacd2..db3f388f9b 100644
--- a/graphs/data/fitDamageStats/cache/time.py
+++ b/graphs/data/fitDamageStats/cache/time.py
@@ -20,6 +20,8 @@
from copy import copy
+from logbook import Logger
+
from eos.utils.float import floatUnerr
from eos.utils.spoolSupport import SpoolOptions, SpoolType
from eos.utils.stats import DmgTypes
@@ -27,6 +29,7 @@
class TimeCache(FitDataCache):
+ pyfalog = Logger(__name__)
# Whole data getters
def getDpsData(self, src):
@@ -66,6 +69,7 @@ def prepareDmgData(self, src, maxTime):
# we do not need cache for that
if maxTime is None:
return
+ print("[DamageStats] prepareDmgData called", flush=True)
self._generateInternalForm(src=src, maxTime=maxTime)
fitCache = self._data[src.item.ID]
# Final cache has been generated already, don't do anything
@@ -98,6 +102,7 @@ def _prepareDpsVolleyData(self, src, maxTime):
# we do not need cache for that
if maxTime is None:
return True
+ print("[DamageStats] prepareDpsVolleyData called", flush=True)
self._generateInternalForm(src=src, maxTime=maxTime)
fitCache = self._data[src.item.ID]
# Final cache has been generated already, don't do anything
@@ -150,9 +155,12 @@ def _prepareDpsVolleyData(self, src, maxTime):
def _generateInternalForm(self, src, maxTime):
if self._isTimeCacheValid(src=src, maxTime=maxTime):
return
+ self.pyfalog.debug("DamageStats TimeCache: generating for fit %s up to %.3fs", getattr(src.item, 'ID', None), maxTime)
+ print("[DamageStats] TimeCache generating for fit {} up to {:.3f}s".format(getattr(src.item, 'ID', None), maxTime), flush=True)
fitCache = self._data[src.item.ID] = {'maxTime': maxTime}
intCacheDpsVolley = fitCache['internalDpsVolley'] = {}
intCacheDmg = fitCache['internalDmg'] = {}
+ dmgPrintCount = 0
def addDpsVolley(ddKey, addedTimeStart, addedTimeFinish, addedVolleys):
if not addedVolleys:
@@ -170,6 +178,10 @@ def addDpsVolley(ddKey, addedTimeStart, addedTimeFinish, addedVolleys):
def addDmg(ddKey, addedTime, addedDmg):
if addedDmg.total == 0:
return
+ nonlocal dmgPrintCount
+ if dmgPrintCount < 5:
+ print("[DamageStats] Dmg event key={} t={:.3f}s total={}".format(ddKey, addedTime, addedDmg.total), flush=True)
+ dmgPrintCount += 1
addedDmg._breachers = {addedTime + k: v for k, v in addedDmg._breachers.items()}
addedDmg._clear_cached()
intCacheDmg.setdefault(ddKey, {})[addedTime] = addedDmg
@@ -186,9 +198,18 @@ def addDmg(ddKey, addedTime, addedDmg):
isBreacher = mod.isBreacher
for cycleTimeMs, inactiveTimeMs, isInactivityReload in cycleParams.iterCycles():
cycleVolleys = []
- volleyParams = mod.getVolleyParameters(spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True))
-
- for volleyTimeMs, volley in volleyParams.items():
+ baseVolleyParams = mod.getVolleyParameters(
+ spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True))
+ if not baseVolleyParams:
+ self.pyfalog.debug("DamageStats TimeCache: no base volleys for mod %s at t=%.3fs", mod, currentTime)
+ print("[DamageStats] No base volleys for mod {} at t={:.3f}s".format(mod, currentTime), flush=True)
+ for volleyTimeMs in baseVolleyParams:
+ inactive_afflictors = src.item.getInactiveModulesAt(
+ currentTime * 1000 + volleyTimeMs, exclude=(mod,))
+ volleyParams = mod.getVolleyParameters(
+ spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True),
+ ignoreAfflictors=inactive_afflictors)
+ volley = volleyParams.get(volleyTimeMs, DmgTypes.default())
cycleVolleys.append(volley)
time = currentTime + volleyTimeMs / 1000
if isBreacher:
@@ -209,6 +230,12 @@ def addDmg(ddKey, addedTime, addedDmg):
if currentTime > maxTime:
break
currentTime += cycleTimeMs / 1000 + inactiveTimeMs / 1000
+ self.pyfalog.debug("DamageStats TimeCache: generated %d dps segments and %d dmg events",
+ sum(len(v) for v in intCacheDpsVolley.values()),
+ sum(len(v) for v in intCacheDmg.values()))
+ print("[DamageStats] Generated {} dps segments and {} dmg events".format(
+ sum(len(v) for v in intCacheDpsVolley.values()),
+ sum(len(v) for v in intCacheDmg.values())), flush=True)
# Drones
for drone in src.item.activeDronesIter():
if not drone.isDealingDamage():
diff --git a/graphs/data/fitDamageStats/getter.py b/graphs/data/fitDamageStats/getter.py
index 5c66593be7..3de18db74f 100644
--- a/graphs/data/fitDamageStats/getter.py
+++ b/graphs/data/fitDamageStats/getter.py
@@ -19,6 +19,7 @@
import eos.config
+from logbook import Logger
from eos.saveddata.targetProfile import TargetProfile
from eos.utils.spoolSupport import SpoolOptions, SpoolType
from eos.utils.stats import DmgTypes
@@ -27,6 +28,8 @@
from .calc.application import getApplicationPerKey
from .calc.projected import getScramRange, getScrammables, getTackledSpeed, getSigRadiusMult
+pyfalog = Logger(__name__)
+
def applyDamage(dmgMap, applicationMap, tgtResists, tgtFullHp):
total = DmgTypes.default()
@@ -194,13 +197,13 @@ def _calculatePoint(self, x, miscParams, src, tgt, commonData):
class XTimeMixin(PointGetter):
- def _prepareApplicationMap(self, miscParams, src, tgt):
+ def _prepareApplicationMap(self, miscParams, src, tgt, timeMs=None):
tgtSpeed = miscParams['tgtSpeed']
tgtSigRadius = tgt.getSigRadius()
if GraphSettings.getInstance().get('applyProjected'):
srcScramRange = getScramRange(src=src)
tgtScrammables = getScrammables(tgt=tgt)
- webMods, tpMods = self.graph._projectedCache.getProjModData(src)
+ webMods, tpMods = self.graph._projectedCache.getProjModData(src, timeMs=timeMs)
webDrones, tpDrones = self.graph._projectedCache.getProjDroneData(src)
webFighters, tpFighters = self.graph._projectedCache.getProjFighterData(src)
tgtSpeed = getTackledSpeed(
@@ -236,53 +239,92 @@ def _prepareApplicationMap(self, miscParams, src, tgt):
return applicationMap
def getRange(self, xRange, miscParams, src, tgt):
+ print("[DamageStats] TimeGraph getRange called", flush=True)
xs = []
ys = []
minTime, maxTime = xRange
# Prepare time cache and various shared data
self._prepareTimeCache(src=src, maxTime=maxTime)
timeCache = self._getTimeCacheData(src=src)
- applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt)
- # Custom iteration for time graph to show all data points
- currentDmg = None
- currentTime = None
- for currentTime in sorted(timeCache):
- prevDmg = currentDmg
- currentDmgData = timeCache[currentTime]
- currentDmg = applyDamage(
- dmgMap=currentDmgData,
- applicationMap=applicationMap,
- tgtResists=tgt.getResists(),
- tgtFullHp=tgt.getFullHp()).total
- if currentTime < minTime:
- continue
- # First set of data points
- if not xs:
- # Start at exactly requested time, at last known value
- initialDmg = prevDmg or 0
- xs.append(minTime)
- ys.append(initialDmg)
- # If current time is bigger then starting, extend plot to that time with old value
- if currentTime > minTime:
- xs.append(currentTime)
+ pyfalog.debug("DamageStats TimeGraph: time cache keys=%s", list(timeCache.keys())[:10])
+ print("[DamageStats] TimeGraph cache keys (first 10): {}".format(list(timeCache.keys())[:10]), flush=True)
+ if timeCache:
+ print("[DamageStats] TimeGraph timeCache min={} max={} maxTime={}".format(min(timeCache), max(timeCache), maxTime), flush=True)
+ if not timeCache:
+ xs.append(minTime)
+ ys.append(0)
+ xs.append(maxTime)
+ ys.append(0)
+ return xs, ys
+ if min(timeCache) > maxTime:
+ pyfalog.debug("DamageStats TimeGraph: first damage time %.3fs beyond maxTime %.3fs", min(timeCache), maxTime)
+ print("[DamageStats] First damage time {:.3f}s beyond maxTime {:.3f}s".format(min(timeCache), maxTime), flush=True)
+ xs.append(minTime)
+ ys.append(0)
+ xs.append(maxTime)
+ ys.append(0)
+ return xs, ys
+ print("[DamageStats] TimeGraph passed cache checks", flush=True)
+ try:
+ applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt, timeMs=minTime * 1000)
+ # Custom iteration for time graph to show all data points
+ currentDmg = None
+ currentTime = None
+ maxDmg = 0
+ sampleCount = 0
+ print("[DamageStats] TimeGraph entering loop, points={}".format(len(timeCache)), flush=True)
+ for currentTime in sorted(timeCache):
+ prevDmg = currentDmg
+ currentDmgData = timeCache[currentTime]
+ applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt, timeMs=currentTime * 1000)
+ if sampleCount < 5:
+ missing_keys = [k for k in currentDmgData if k not in applicationMap]
+ if missing_keys:
+ print("[DamageStats] TimeGraph missing app keys: {} of {}".format(len(missing_keys), len(currentDmgData)), flush=True)
+ currentDmg = applyDamage(
+ dmgMap=currentDmgData,
+ applicationMap=applicationMap,
+ tgtResists=tgt.getResists(),
+ tgtFullHp=tgt.getFullHp()).total
+ if currentDmg > maxDmg:
+ maxDmg = currentDmg
+ if sampleCount < 5:
+ print("[DamageStats] TimeGraph sample t={:.3f}s dmg={}".format(currentTime, currentDmg), flush=True)
+ sampleCount += 1
+ if currentTime < minTime:
+ continue
+ # First set of data points
+ if not xs:
+ # Start at exactly requested time, at last known value
+ initialDmg = prevDmg or 0
+ xs.append(minTime)
ys.append(initialDmg)
- # If new value is different, extend it with new point to the new value
+ # If current time is bigger then starting, extend plot to that time with old value
+ if currentTime > minTime:
+ xs.append(currentTime)
+ ys.append(initialDmg)
+ # If new value is different, extend it with new point to the new value
+ if currentDmg != prevDmg:
+ xs.append(currentTime)
+ ys.append(currentDmg)
+ continue
+ # Last data point
+ if currentTime >= maxTime:
+ xs.append(maxTime)
+ ys.append(prevDmg if prevDmg is not None else 0)
+ break
+ # Anything in-between
if currentDmg != prevDmg:
+ if prevDmg is not None:
+ xs.append(currentTime)
+ ys.append(prevDmg)
xs.append(currentTime)
ys.append(currentDmg)
- continue
- # Last data point
- if currentTime >= maxTime:
- xs.append(maxTime)
- ys.append(prevDmg)
- break
- # Anything in-between
- if currentDmg != prevDmg:
- if prevDmg is not None:
- xs.append(currentTime)
- ys.append(prevDmg)
- xs.append(currentTime)
- ys.append(currentDmg)
+ except Exception as exc:
+ print("[DamageStats] TimeGraph exception: {}".format(exc), flush=True)
+ xs = [minTime, maxTime]
+ ys = [0, 0]
+ return xs, ys
# Special case - there are no damage dealers
if currentDmg is None and currentTime is None:
xs.append(minTime)
@@ -291,6 +333,10 @@ def getRange(self, xRange, miscParams, src, tgt):
if maxTime > (currentTime or 0):
xs.append(maxTime)
ys.append(currentDmg or 0)
+ print("[DamageStats] TimeGraph exiting loop, max dmg observed: {}".format(maxDmg), flush=True)
+ print("[DamageStats] TimeGraph series length xs={} ys={} first=({},{}) last=({},{})".format(
+ len(xs), len(ys), xs[0] if xs else None, ys[0] if ys else None,
+ xs[-1] if xs else None, ys[-1] if ys else None), flush=True)
return xs, ys
def getPoint(self, x, miscParams, src, tgt):
@@ -298,7 +344,7 @@ def getPoint(self, x, miscParams, src, tgt):
# Prepare time cache and various data
self._prepareTimeCache(src=src, maxTime=time)
dmgData = self._getTimeCacheDataPoint(src=src, time=time)
- applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt)
+ applicationMap = self._prepareApplicationMap(miscParams=miscParams, src=src, tgt=tgt, timeMs=time * 1000)
y = applyDamage(
dmgMap=dmgData,
applicationMap=applicationMap,
@@ -328,7 +374,8 @@ def _calculatePoint(self, x, miscParams, src, tgt, commonData):
if commonData['applyProjected']:
srcScramRange = getScramRange(src=src)
tgtScrammables = getScrammables(tgt=tgt)
- webMods, tpMods = self.graph._projectedCache.getProjModData(src)
+ timeMs = miscParams['time'] * 1000 if miscParams.get('time') is not None else None
+ webMods, tpMods = self.graph._projectedCache.getProjModData(src, timeMs=timeMs)
webDrones, tpDrones = self.graph._projectedCache.getProjDroneData(src)
webFighters, tpFighters = self.graph._projectedCache.getProjFighterData(src)
tgtSpeed = getTackledSpeed(
@@ -379,7 +426,8 @@ def _getCommonData(self, miscParams, src, tgt):
if GraphSettings.getInstance().get('applyProjected'):
srcScramRange = getScramRange(src=src)
tgtScrammables = getScrammables(tgt=tgt)
- webMods, tpMods = self.graph._projectedCache.getProjModData(src)
+ timeMs = miscParams['time'] * 1000 if miscParams.get('time') is not None else None
+ webMods, tpMods = self.graph._projectedCache.getProjModData(src, timeMs=timeMs)
webDrones, tpDrones = self.graph._projectedCache.getProjDroneData(src)
webFighters, tpFighters = self.graph._projectedCache.getProjFighterData(src)
tgtSpeed = getTackledSpeed(
diff --git a/graphs/data/fitMobility/getter.py b/graphs/data/fitMobility/getter.py
index 49552abc94..46db4864da 100644
--- a/graphs/data/fitMobility/getter.py
+++ b/graphs/data/fitMobility/getter.py
@@ -27,15 +27,20 @@ class Time2DistanceGetter(SmoothPointGetter):
def _getCommonData(self, miscParams, src, tgt):
return {
- 'maxSpeed': src.getMaxVelocity(),
'mass': src.item.ship.getModifiedItemAttr('mass'),
'agility': src.item.ship.getModifiedItemAttr('agility')}
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
time = x
- maxSpeed = commonData['maxSpeed']
- mass = commonData['mass']
- agility = commonData['agility']
+ if getattr(src, 'isFit', False):
+ ignore_afflictors = src.item.getInactiveModulesAt(time * 1000)
+ maxSpeed = src.getMaxVelocity(ignoreAfflictors=ignore_afflictors)
+ mass = src.item.ship.getModifiedItemAttrExtended('mass', ignoreAfflictors=ignore_afflictors)
+ agility = src.item.ship.getModifiedItemAttrExtended('agility', ignoreAfflictors=ignore_afflictors)
+ else:
+ maxSpeed = src.getMaxVelocity()
+ mass = commonData['mass']
+ agility = commonData['agility']
# Definite integral of:
# https://wiki.eveuniversity.org/Acceleration#Mathematics_and_formulae
distance_t = maxSpeed * time + (maxSpeed * agility * mass * math.exp((-time * 1000000) / (agility * mass)) / 1000000)
@@ -48,15 +53,20 @@ class Time2SpeedGetter(SmoothPointGetter):
def _getCommonData(self, miscParams, src, tgt):
return {
- 'maxSpeed': src.getMaxVelocity(),
'mass': src.item.ship.getModifiedItemAttr('mass'),
'agility': src.item.ship.getModifiedItemAttr('agility')}
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
time = x
- maxSpeed = commonData['maxSpeed']
- mass = commonData['mass']
- agility = commonData['agility']
+ if getattr(src, 'isFit', False):
+ ignore_afflictors = src.item.getInactiveModulesAt(time * 1000)
+ maxSpeed = src.getMaxVelocity(ignoreAfflictors=ignore_afflictors)
+ mass = src.item.ship.getModifiedItemAttrExtended('mass', ignoreAfflictors=ignore_afflictors)
+ agility = src.item.ship.getModifiedItemAttrExtended('agility', ignoreAfflictors=ignore_afflictors)
+ else:
+ maxSpeed = src.getMaxVelocity()
+ mass = commonData['mass']
+ agility = commonData['agility']
# https://wiki.eveuniversity.org/Acceleration#Mathematics_and_formulae
speed = maxSpeed * (1 - math.exp((-time * 1000000) / (agility * mass)))
return speed
diff --git a/graphs/data/fitRemoteReps/cache.py b/graphs/data/fitRemoteReps/cache.py
index eb7a41bfa4..e29562f9ed 100644
--- a/graphs/data/fitRemoteReps/cache.py
+++ b/graphs/data/fitRemoteReps/cache.py
@@ -164,7 +164,10 @@ def addRepAmount(rrKey, addedTime, addedRepAmount):
for cycleTimeMs, inactiveTimeMs, isInactivityReload in cycleParams.iterCycles():
cyclesWithoutReload += 1
cycleRepAmounts = []
- repAmountParams = mod.getRepAmountParameters(spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True))
+ inactive_afflictors = src.item.getInactiveModulesAt(currentTime * 1000, exclude=(mod,))
+ repAmountParams = mod.getRepAmountParameters(
+ spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True),
+ ignoreAfflictors=inactive_afflictors)
for repTimeMs, repAmount in repAmountParams.items():
# Loaded ancillary armor rep can keep running at less efficiency if we decide to not reload
if isAncArmor and mod.charge and not ancReload and cyclesWithoutReload > cyclesUntilReload:
diff --git a/graphs/data/fitShieldRegen/getter.py b/graphs/data/fitShieldRegen/getter.py
index 71f19f7efd..4eed066cf2 100644
--- a/graphs/data/fitShieldRegen/getter.py
+++ b/graphs/data/fitShieldRegen/getter.py
@@ -32,9 +32,20 @@ def _getCommonData(self, miscParams, src, tgt):
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
time = x
+ if getattr(src, 'isFit', False):
+ ignore_afflictors = src.item.getInactiveModulesAt(time * 1000)
+ maxShieldAmount = src.item.ship.getModifiedItemAttrExtended(
+ 'shieldCapacity',
+ ignoreAfflictors=ignore_afflictors)
+ shieldRegenTime = src.item.ship.getModifiedItemAttrExtended(
+ 'shieldRechargeRate',
+ ignoreAfflictors=ignore_afflictors) / 1000
+ else:
+ maxShieldAmount = commonData['maxShieldAmount']
+ shieldRegenTime = commonData['shieldRegenTime']
shieldAmount = calculateShieldAmount(
- maxShieldAmount=commonData['maxShieldAmount'],
- shieldRegenTime=commonData['shieldRegenTime'],
+ maxShieldAmount=maxShieldAmount,
+ shieldRegenTime=shieldRegenTime,
shieldAmountT0=miscParams['shieldAmountT0'] or 0,
time=time)
return shieldAmount
@@ -49,14 +60,25 @@ def _getCommonData(self, miscParams, src, tgt):
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
time = x
+ if getattr(src, 'isFit', False):
+ ignore_afflictors = src.item.getInactiveModulesAt(time * 1000)
+ maxShieldAmount = src.item.ship.getModifiedItemAttrExtended(
+ 'shieldCapacity',
+ ignoreAfflictors=ignore_afflictors)
+ shieldRegenTime = src.item.ship.getModifiedItemAttrExtended(
+ 'shieldRechargeRate',
+ ignoreAfflictors=ignore_afflictors) / 1000
+ else:
+ maxShieldAmount = commonData['maxShieldAmount']
+ shieldRegenTime = commonData['shieldRegenTime']
shieldAmount = calculateShieldAmount(
- maxShieldAmount=commonData['maxShieldAmount'],
- shieldRegenTime=commonData['shieldRegenTime'],
+ maxShieldAmount=maxShieldAmount,
+ shieldRegenTime=shieldRegenTime,
shieldAmountT0=miscParams['shieldAmountT0'] or 0,
time=time)
shieldRegen = calculateShieldRegen(
- maxShieldAmount=commonData['maxShieldAmount'],
- shieldRegenTime=commonData['shieldRegenTime'],
+ maxShieldAmount=maxShieldAmount,
+ shieldRegenTime=shieldRegenTime,
currentShieldAmount=shieldAmount)
return shieldRegen
diff --git a/graphs/gui/canvasPanel.py b/graphs/gui/canvasPanel.py
index 4c862f6001..6586097516 100644
--- a/graphs/gui/canvasPanel.py
+++ b/graphs/gui/canvasPanel.py
@@ -175,7 +175,7 @@ def draw(self, accurateMarks=True):
except (KeyboardInterrupt, SystemExit):
raise
except Exception:
- pyfalog.warning('Failed to plot "{}" vs "{}"'.format(source.name, '' if target is None else target.name))
+ pyfalog.exception('Failed to plot "{}" vs "{}"'.format(source.name, '' if target is None else target.name))
self.canvas.draw()
self.Refresh()
return
@@ -250,7 +250,7 @@ def addYMark(val):
except (KeyboardInterrupt, SystemExit):
raise
except Exception:
- pyfalog.warning('Failed to get X mark for "{}" vs "{}"'.format(source.name, '' if target is None else target.name))
+ pyfalog.exception('Failed to get X mark for "{}" vs "{}"'.format(source.name, '' if target is None else target.name))
# Silently skip this mark, otherwise other marks and legend display will fail
continue
# Otherwise just do linear interpolation between two points
diff --git a/gui/builtinViewColumns/pulse.py b/gui/builtinViewColumns/pulse.py
new file mode 100644
index 0000000000..e6b8e30738
--- /dev/null
+++ b/gui/builtinViewColumns/pulse.py
@@ -0,0 +1,59 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+# noinspection PyPackageRequirements
+import wx
+
+from eos.saveddata.module import Module, Rack
+from gui.utils.numberFormatter import formatAmount
+from gui.viewColumn import ViewColumn
+
+
+class Pulse(ViewColumn):
+ name = "Pulse"
+
+ def __init__(self, fittingView, params):
+ ViewColumn.__init__(self, fittingView)
+ self.columnText = "Pulse"
+ self.resizable = False
+ self.size = 58
+ self.maxsize = self.size
+ self.mask = wx.LIST_MASK_TEXT
+
+ def getText(self, mod):
+ if isinstance(mod, Rack):
+ return ""
+ if isinstance(mod, Module) and not mod.isEmpty:
+ if mod.pulseInterval is None:
+ return ""
+ return formatAmount(mod.pulseInterval, prec=3, unitName="s")
+ return ""
+
+ def getToolTip(self, mod):
+ if isinstance(mod, Module) and not mod.isEmpty:
+ if mod.pulseInterval is None:
+ return "Pulse disabled"
+ return "Pulse every {} seconds".format(formatAmount(mod.pulseInterval, prec=3))
+ return None
+
+ def getImageId(self, mod):
+ return -1
+
+
+Pulse.register()
diff --git a/gui/builtinViews/fittingView.py b/gui/builtinViews/fittingView.py
index 8df91591c7..28579c72f2 100644
--- a/gui/builtinViews/fittingView.py
+++ b/gui/builtinViews/fittingView.py
@@ -36,6 +36,7 @@
from gui.builtinMarketBrowser.events import ITEM_SELECTED
from gui.builtinShipBrowser.events import EVT_FIT_SELECTED, FitSelected
from gui.builtinViewColumns.state import State
+from gui.builtinViewColumns.pulse import Pulse
from gui.chrome_tabs import EVT_NOTEBOOK_PAGE_CHANGED
from gui.contextMenu import ContextMenu
from gui.utils.staticHelpers import DragDropHelper
@@ -138,6 +139,7 @@ def OnData(self, x, y, t):
class FittingView(d.Display):
DEFAULT_COLS = ["State",
+ "Pulse",
"Ammo Icon",
"Base Icon",
"Base Name",
@@ -682,6 +684,26 @@ def click(self, event):
clickedRow, _, col = self.HitTestSubItem(event.Position)
+ # handle pulse column clicks
+ if clickedRow != -1 and clickedRow not in self.blanks and col == self.getColIndex(Pulse):
+ selectedRows = []
+ currentRow = self.GetFirstSelected()
+
+ while currentRow != -1 and clickedRow not in self.blanks:
+ selectedRows.append(currentRow)
+ currentRow = self.GetNextSelected(currentRow)
+
+ if clickedRow not in selectedRows:
+ try:
+ selectedMods = [self.mods[clickedRow]]
+ except IndexError:
+ return
+ else:
+ selectedMods = self.getSelectedMods()
+
+ self._setPulseInterval(selectedMods)
+ return
+
# only do State column and ignore invalid rows
if clickedRow != -1 and clickedRow not in self.blanks and col == self.getColIndex(State):
selectedRows = []
@@ -733,6 +755,74 @@ def click(self, event):
else:
event.Skip()
+ def _setPulseInterval(self, selectedMods):
+ mods = [mod for mod in selectedMods if isinstance(mod, Module) and not mod.isEmpty]
+ if len(mods) == 0:
+ return
+
+ raw_cycle_times = [mod.rawCycleTime for mod in mods]
+ if any(cycle_time == 0 for cycle_time in raw_cycle_times):
+ wx.MessageBox(
+ _t("Some selected modules do not have a cycle time."),
+ _t("Pulse Interval"),
+ wx.OK | wx.ICON_ERROR)
+ return
+
+ min_cycle_seconds = max(raw_cycle_times) / 1000.0
+ existing = mods[0].pulseInterval
+ if any(mod.pulseInterval != existing for mod in mods):
+ initial_value = ""
+ else:
+ initial_value = "" if existing is None else "{:.3f}".format(existing)
+
+ message = _t("Pulse every (seconds). Minimum {min:.3f}, maximum 999. Leave blank to disable.").format(
+ min=min_cycle_seconds)
+ dlg = wx.TextEntryDialog(self, message, _t("Set Pulse Interval"), initial_value)
+ if dlg.ShowModal() != wx.ID_OK:
+ dlg.Destroy()
+ return
+ value_text = dlg.GetValue().strip()
+ dlg.Destroy()
+
+ if value_text == "":
+ pulse_interval = None
+ else:
+ try:
+ pulse_interval = float(value_text)
+ except ValueError:
+ wx.MessageBox(
+ _t("Please enter a valid number of seconds."),
+ _t("Pulse Interval"),
+ wx.OK | wx.ICON_ERROR)
+ return
+ if pulse_interval < min_cycle_seconds:
+ wx.MessageBox(
+ _t("Pulse interval must be at least {min:.3f} seconds.").format(min=min_cycle_seconds),
+ _t("Pulse Interval"),
+ wx.OK | wx.ICON_ERROR)
+ return
+ if pulse_interval > 999:
+ wx.MessageBox(
+ _t("Pulse interval must be 999 seconds or less."),
+ _t("Pulse Interval"),
+ wx.OK | wx.ICON_ERROR)
+ return
+
+ fitID = self.mainFrame.getActiveFit()
+ fit = Fit.getInstance().getFit(fitID)
+ positions = []
+ for mod in mods:
+ if mod in fit.modules:
+ positions.append(fit.modules.index(mod))
+
+ if len(positions) == 0:
+ return
+
+ self.mainFrame.command.Submit(cmd.GuiChangeLocalModulePulseIntervalCommand(
+ fitID=fitID,
+ positions=positions,
+ pulseInterval=pulse_interval))
+
def slotColour(self, slot):
if isDark():
return slotColourMapDark.get(slot) or self.GetBackgroundColour()
diff --git a/gui/fitCommands/__init__.py b/gui/fitCommands/__init__.py
index 78536054a0..84fdfb4c0f 100644
--- a/gui/fitCommands/__init__.py
+++ b/gui/fitCommands/__init__.py
@@ -48,6 +48,7 @@
from .gui.localModule.changeCharges import GuiChangeLocalModuleChargesCommand
from .gui.localModule.changeMetas import GuiChangeLocalModuleMetasCommand
from .gui.localModule.changeMutation import GuiChangeLocalModuleMutationCommand
+from .gui.localModule.changePulseInterval import GuiChangeLocalModulePulseIntervalCommand
from .gui.localModule.changeSpool import GuiChangeLocalModuleSpoolCommand
from .gui.localModule.changeStates import GuiChangeLocalModuleStatesCommand
from .gui.localModule.clone import GuiCloneLocalModuleCommand
diff --git a/gui/fitCommands/calc/module/changePulseInterval.py b/gui/fitCommands/calc/module/changePulseInterval.py
new file mode 100644
index 0000000000..db8840a833
--- /dev/null
+++ b/gui/fitCommands/calc/module/changePulseInterval.py
@@ -0,0 +1,41 @@
+import wx
+from logbook import Logger
+
+from service.fit import Fit
+
+
+pyfalog = Logger(__name__)
+
+
+class CalcChangeLocalModulePulseIntervalCommand(wx.Command):
+
+ def __init__(self, fitID, positions, pulseInterval):
+ wx.Command.__init__(self, True, 'Change Module Pulse Interval')
+ self.fitID = fitID
+ self.positions = positions
+ self.pulseInterval = pulseInterval
+ self.savedIntervals = {}
+
+ def Do(self):
+ pyfalog.debug('Doing change of module pulse interval on fit {} positions {} to {}'.format(
+ self.fitID, self.positions, self.pulseInterval))
+ fit = Fit.getInstance().getFit(self.fitID)
+ positions = [pos for pos in self.positions if not fit.modules[pos].isEmpty]
+ if len(positions) == 0:
+ return False
+ self.savedIntervals = {pos: fit.modules[pos].pulseInterval for pos in positions}
+ changed = False
+ for position in positions:
+ mod = fit.modules[position]
+ if mod.pulseInterval != self.pulseInterval:
+ mod.pulseInterval = self.pulseInterval
+ changed = True
+ return changed
+
+ def Undo(self):
+ pyfalog.debug('Undoing change of module pulse interval on fit {} positions {} to {}'.format(
+ self.fitID, self.positions, self.pulseInterval))
+ fit = Fit.getInstance().getFit(self.fitID)
+ for position, interval in self.savedIntervals.items():
+ fit.modules[position].pulseInterval = interval
+ return True
diff --git a/gui/fitCommands/gui/localModule/changePulseInterval.py b/gui/fitCommands/gui/localModule/changePulseInterval.py
new file mode 100644
index 0000000000..f4aff9d1de
--- /dev/null
+++ b/gui/fitCommands/gui/localModule/changePulseInterval.py
@@ -0,0 +1,42 @@
+import wx
+
+import eos.db
+import gui.mainFrame
+from gui import globalEvents as GE
+from gui.fitCommands.calc.module.changePulseInterval import CalcChangeLocalModulePulseIntervalCommand
+from gui.fitCommands.helpers import InternalCommandHistory
+from service.fit import Fit
+
+
+class GuiChangeLocalModulePulseIntervalCommand(wx.Command):
+
+ def __init__(self, fitID, positions, pulseInterval):
+ wx.Command.__init__(self, True, 'Change Local Module Pulse Interval')
+ self.internalHistory = InternalCommandHistory()
+ self.fitID = fitID
+ self.positions = positions
+ self.pulseInterval = pulseInterval
+
+ def Do(self):
+ cmd = CalcChangeLocalModulePulseIntervalCommand(
+ fitID=self.fitID,
+ positions=self.positions,
+ pulseInterval=self.pulseInterval)
+ success = self.internalHistory.submit(cmd)
+ eos.db.flush()
+ sFit = Fit.getInstance()
+ sFit.recalc(self.fitID)
+ sFit.fill(self.fitID)
+ eos.db.commit()
+ wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), GE.FitChanged(fitIDs=(self.fitID,)))
+ return success
+
+ def Undo(self):
+ success = self.internalHistory.undoAll()
+ eos.db.flush()
+ sFit = Fit.getInstance()
+ sFit.recalc(self.fitID)
+ sFit.fill(self.fitID)
+ eos.db.commit()
+ wx.PostEvent(gui.mainFrame.MainFrame.getInstance(), GE.FitChanged(fitIDs=(self.fitID,)))
+ return success
diff --git a/gui/viewColumn.py b/gui/viewColumn.py
index 0c904fa278..80692f2f4c 100644
--- a/gui/viewColumn.py
+++ b/gui/viewColumn.py
@@ -88,6 +88,7 @@ def delayedText(self, display, colItem):
price,
projectionRange,
propertyDisplay,
+ pulse,
state,
sideEffects,
targetResists)
diff --git a/service/fit.py b/service/fit.py
index 4e3a1aa7bc..ac94461e0c 100644
--- a/service/fit.py
+++ b/service/fit.py
@@ -269,10 +269,11 @@ def toggleFactorReload(self, value=None):
for fit in set(self._loadedFits):
if fit is None:
continue
- if fit.calculated:
- fit.factorReload = self.serviceFittingOptions['useGlobalForceReload']
- fit.clearFactorReloadDependentData()
- fitIDs.add(fit.ID)
+ if fit.isInvalid:
+ continue
+ fit.factorReload = self.serviceFittingOptions['useGlobalForceReload']
+ self.recalc(fit)
+ fitIDs.add(fit.ID)
return fitIDs
def processOverrideToggle(self):
@@ -336,6 +337,7 @@ def getFit(self, fitID, projected=False, basic=False):
return None
self._loadedFits.add(fit)
+ fit.factorReload = self.serviceFittingOptions["useGlobalForceReload"]
if basic:
return fit
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000..8f2430f69b
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,16 @@
+import sys
+import os
+import pytest
+
+# Ensure we modify sys environment for tests
+sys._called_from_test = True
+
+# Add root folder to python paths
+script_dir = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(os.path.realpath(os.path.join(script_dir, '..')))
+
+# Import fixtures from helpers
+# DBInMemory is nicknamed DB in tests usually
+from _development.helpers import DBInMemory as DB, Gamedata, Saveddata
+from _development.helpers_fits import RifterFit, KeepstarFit, CurseFit, HeronFit
+from _development.helpers_items import StrongBluePillBooster
diff --git a/tests/test_modules/test_eos/test_modifiedAttributeDict.py b/tests/test_modules/test_eos/test_modifiedAttributeDict.py
index 71bc5d892d..c4dac52de7 100644
--- a/tests/test_modules/test_eos/test_modifiedAttributeDict.py
+++ b/tests/test_modules/test_eos/test_modifiedAttributeDict.py
@@ -19,7 +19,7 @@ def test_multiply_stacking_penalties(DB, Saveddata, RifterFit):
RifterFit.character = char0
starting_em_resist = RifterFit.ship.getModifiedItemAttr("shieldEmDamageResonance")
- mod = Saveddata['Module'](DB['db'].getItem("EM Ward Amplifier II"))
+ mod = Saveddata['Module'](DB['db'].getItem("EM Shield Amplifier II"))
item_modifer = mod.item.getAttribute("emDamageResistanceBonus")
RifterFit.calculateModifiedAttributes()
diff --git a/tests/test_modules/test_eos/test_saveddata/test_pulse.py b/tests/test_modules/test_eos/test_saveddata/test_pulse.py
new file mode 100644
index 0000000000..a4696344de
--- /dev/null
+++ b/tests/test_modules/test_eos/test_saveddata/test_pulse.py
@@ -0,0 +1,131 @@
+import os
+import sys
+import mock
+from unittest.mock import MagicMock
+
+# Add root folder to python paths
+script_dir = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(os.path.realpath(os.path.join(script_dir, '..', '..', '..', '..')))
+
+from eos.effects import Effect7166
+from eos.const import FittingModuleState
+
+def test_pulse_inactive_afflictors(RifterFit):
+ """
+ Test that getPulseInactiveAfflictorsAt correctly identifies inactive modules
+ during the wait period of a pulse cycle.
+ """
+ fit = RifterFit
+
+ # Get a module (e.g. 200mm Steel Plates, though that's passive. Let's find an active one or add one)
+ # RifterFit usually comes with some modules. Let's assume we can add one.
+
+ # We will mock a module for simplicity of testing strictly the time logic
+ # or we can use a real module if we knew the ID.
+ # Let's inspect RifterFit modules in the test? No, let's just make a mock module inside the fit.
+
+ # Create a mock module structure
+ mod = MagicMock()
+ mod.state = FittingModuleState.ACTIVE
+ mod.pulseInterval = 10.0 # Pulse every 10 seconds
+ mod.getModifiedItemAttr = MagicMock(return_value=5000.0) # 5s duration
+
+ # Set properties used by getPulseInactiveAfflictorsAt
+ mod.rawCycleTime = 5000
+ mod.pulseAdjustedCycleTime = 10000
+
+ # The method calls self.getModifiedItemAttr('duration')
+ # wait, getPulseInactiveAfflictorsAt calls:
+ # duration = mod.getModifiedItemAttr('duration')
+ # which returns milliseconds? usually traits are in ms.
+
+ # Let's verify unit of duration in fit.py
+ # "cycleTime = mod.getModifiedItemAttr('duration')"
+
+ mod.getModifiedItemAttr.side_effect = lambda x: 5000.0 if x == 'duration' else 0
+ mod.fit = fit
+
+ # Replace fit modules with our mock list for this test
+ # We patch the property 'modules' on the Fit class to return our list.
+ with mock.patch('eos.saveddata.fit.Fit.modules', new_callable=mock.PropertyMock) as mock_modules:
+ mock_modules.return_value = [mod]
+
+ # Case 1: Time = 1s (Inside Cycle)
+ # 1000 % 10000 = 1000. 1000 < 5000. Active.
+ inactive = fit.getPulseInactiveAfflictorsAt(1000)
+ assert mod not in inactive
+
+ # Case 2: Time = 6s (Wait Phase)
+ # 6000 % 10000 = 6000. 6000 > 5000. Inactive.
+ inactive = fit.getPulseInactiveAfflictorsAt(6000)
+ assert mod in inactive
+
+ # Case 3: Time = 11s (Next Cycle)
+ # 11000 % 10000 = 1000. 1000 < 5000. Active.
+ inactive = fit.getPulseInactiveAfflictorsAt(11000)
+ assert mod not in inactive
+
+
+def test_projected_spool_reset_logic():
+ """
+ Test that Effect7166 (Mutadaptive Remote Armor Repairer) forces SpoolAmount to 0
+ when the source module has a pulse interval set (and gap exists).
+ """
+
+ # Mock Fit and Ship
+ mock_fit = MagicMock()
+ mock_fit.ship.getModifiedItemAttr.return_value = False # disallowAssistance = False
+ mock_fit._armorRr = []
+ mock_fit._armorRrPreSpool = []
+ mock_fit._armorRrFullSpool = []
+
+ # Mock Container (The Module)
+ mock_container = MagicMock()
+ mock_container.getModifiedItemAttr.side_effect = lambda x: {
+ 'armorDamageAmount': 100,
+ 'maxRange': 10000,
+ 'falloffEffectiveness': 1000,
+ 'duration': 5000, # 5s
+ 'repairMultiplierBonusMax': 1.0, # 100% bonus
+ 'repairMultiplierBonusPerCycle': 0.1 # 10% per cycle
+ }.get(x, 0)
+
+ # Mock Context
+ context = {'projected': True}
+
+ # Mock Config
+ # Also mock resolveSpoolOptions to return a known high spool amount (1.0 = 100% bonus)
+ # This isolates the test from user config/defaults/options logic.
+ from eos.utils.spoolSupport import SpoolType
+ with mock.patch('eos.config.settings', {'globalDefaultSpoolupPercentage': 1.0}), \
+ mock.patch('eos.effects.resolveSpoolOptions', return_value=(SpoolType.SPOOL_SCALE, 1.0)):
+
+ # Scenario 1: No Pulsing
+ # Spool should default to Max (1.0) because we mocked resolveSpoolOptions
+ mock_container.pulseInterval = None
+ Effect7166.handler(mock_fit, mock_container, context, 5000)
+
+ # Resolution should be full spool
+ # Base 100 * (1 + 1.0) = 200
+ result_amount = mock_fit._armorRr[-1][0]
+ # Check tolerance (float math)
+ assert abs(result_amount - 200.0) < 0.1
+
+ # Scenario 2: Pulsing Active (With Gap)
+ # Pulse 10s (Duration 5s). Gap = 5s. Spool should reset to 0.
+ mock_container.pulseInterval = 10.0
+ Effect7166.handler(mock_fit, mock_container, context, 5000)
+
+ # Resolution should be 0 spool
+ # Base 100 * (1 + 0) = 100
+ result_amount = mock_fit._armorRr[-1][0]
+ assert abs(result_amount - 100.0) < 0.1
+
+ # Scenario 3: Pulsing Active (No Gap / Constant)
+ # Pulse 5s (Duration 5s). Gap = 0s. Spool should NOT reset.
+ mock_container.pulseInterval = 5.0
+ Effect7166.handler(mock_fit, mock_container, context, 5000)
+
+ # Resolution should be full spool
+ result_amount = mock_fit._armorRr[-1][0]
+ assert abs(result_amount - 200.0) < 0.1
diff --git a/tests/test_modules/test_eos/test_utils/test_stats.py b/tests/test_modules/test_eos/test_utils/test_stats.py
index dc81adccc1..0e196afc04 100644
--- a/tests/test_modules/test_eos/test_utils/test_stats.py
+++ b/tests/test_modules/test_eos/test_utils/test_stats.py
@@ -22,7 +22,7 @@ def test_dmgtypes_names():
def test_dmgtypes__repr(setup_damage_types):
- assert setup_damage_types.__repr__() == ''
+ assert setup_damage_types.__repr__() == ''
def test_dmgtypes_names_lambda():
diff --git a/tests/test_unread_desc.py b/tests/test_unread_desc.py
index beb08001a5..f15f5ac445 100644
--- a/tests/test_unread_desc.py
+++ b/tests/test_unread_desc.py
@@ -17,7 +17,7 @@
# This import is here to hack around circular import issues
import gui.mainFrame
# noinspection PyPep8
-from service.port import Port, IPortUser
+from service.port import Port
"""
NOTE:
@@ -47,11 +47,22 @@
py.test --cov=./ --cov-report=html
"""
-class PortUser(IPortUser):
+class PortUser:
+
+ def __init__(self):
+ self.userCancelled = False
+ self.message = ""
+ self.error = None
+ self.current = 0
+ self.workerWorking = False
+ self.cbArgs = []
def on_port_processing(self, action, data=None):
print(data)
return True
+
+ def on_port_process_start(self):
+ pass
#stpw = Stopwatch('test measurementer')
@@ -74,9 +85,8 @@ def test_import_xml(print_db_info):
xml_file = "jeffy_ja-en[99].xml"
fit_count = int(re.search(r"\[(\d+)\]", xml_file).group(1))
fits = None
- with open(os.path.join(script_dir, xml_file), "r") as file_:
+ with open(os.path.join(script_dir, xml_file), "r", encoding="utf-8") as file_:
srcString = file_.read()
- srcString = str(srcString, "utf-8")
# (basestring, IPortUser, basestring) -> list[eos.saveddata.fit.Fit]
usr.on_port_process_start()
#stpw.reset()