Add RR graphs

This commit is contained in:
DarkPhoenix
2019-08-18 21:27:20 +03:00
parent 3c967ba9eb
commit edd261c677
10 changed files with 461 additions and 27 deletions

View File

@@ -1575,12 +1575,16 @@ class Fit:
if drone.amountActive > 0:
yield drone
def activeFighterAbilityIter(self):
def activeFightersIter(self):
for fighter in self.fighters:
if fighter.active:
for ability in fighter.abilities:
if ability.active:
yield fighter, ability
yield fighter
def activeFighterAbilityIter(self):
for fighter in self.activeFightersIter():
for ability in fighter.abilities:
if ability.active:
yield fighter, ability
def __deepcopy__(self, memo=None):
fitCopy = Fit()

View File

@@ -549,8 +549,8 @@ class Module(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut):
for rrAmount in repAmountParams.values():
rrDuringCycle += rrAmount
rrFactor = 1 / (avgCycleTime / 1000)
rrDuringCycle *= rrFactor
return rrDuringCycle
rps = rrDuringCycle * rrFactor
return rps
def getSpoolData(self, spoolOptions=None):
weaponMultMax = self.getModifiedItemAttr("damageMultiplierBonusMax", 0)

View File

@@ -173,7 +173,7 @@ class TimeCache(FitDataCache):
intCacheDmg.setdefault(ddKey, {})[addedTime] = addedDmg
# Modules
for mod in src.item.modules:
for mod in src.item.activeModulesIter():
if not mod.isDealingDamage():
continue
cycleParams = mod.getCycleParameters(reloadOverride=True)
@@ -196,7 +196,7 @@ class TimeCache(FitDataCache):
break
currentTime += cycleTimeMs / 1000 + inactiveTimeMs / 1000
# Drones
for drone in src.item.drones:
for drone in src.item.activeDronesIter():
if not drone.isDealingDamage():
continue
cycleParams = drone.getCycleParameters(reloadOverride=True)
@@ -214,7 +214,7 @@ class TimeCache(FitDataCache):
break
currentTime += cycleTimeMs / 1000 + inactiveTimeMs / 1000
# Fighters
for fighter in src.item.fighters:
for fighter in src.item.activeFightersIter():
if not fighter.isDealingDamage():
continue
cycleParams = fighter.getCycleParametersPerEffectOptimizedDps(reloadOverride=True)

View File

@@ -30,7 +30,7 @@ from service.settings import GraphSettings
def getApplicationPerKey(src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius):
applicationMap = {}
for mod in src.item.modules:
for mod in src.item.activeModulesIter():
if not mod.isDealingDamage():
continue
if mod.hardpoint == FittingHardpoint.TURRET:
@@ -74,7 +74,7 @@ def getApplicationPerKey(src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAn
tgt=tgt,
distance=distance,
tgtSigRadius=tgtSigRadius)
for drone in src.item.drones:
for drone in src.item.activeDronesIter():
if not drone.isDealingDamage():
continue
applicationMap[drone] = getDroneMult(
@@ -87,7 +87,7 @@ def getApplicationPerKey(src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAn
tgtSpeed=tgtSpeed,
tgtAngle=tgtAngle,
tgtSigRadius=tgtSigRadius)
for fighter in src.item.fighters:
for fighter in src.item.activeFightersIter():
if not fighter.isDealingDamage():
continue
for ability in fighter.abilities:
@@ -112,8 +112,8 @@ def getTurretMult(mod, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngl
atkSpeed=atkSpeed,
atkAngle=atkAngle,
atkRadius=src.getRadius(),
atkOptimalRange=mod.maxRange,
atkFalloffRange=mod.falloff,
atkOptimalRange=mod.maxRange or 0,
atkFalloffRange=mod.falloff or 0,
atkTracking=mod.getModifiedItemAttr('trackingSpeed'),
atkOptimalSigRadius=mod.getModifiedItemAttr('optimalSigRadius'),
distance=distance,
@@ -224,8 +224,8 @@ def getDroneMult(drone, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAng
atkSpeed=min(atkSpeed, droneSpeed),
atkAngle=atkAngle,
atkRadius=droneRadius,
atkOptimalRange=drone.maxRange,
atkFalloffRange=drone.falloff,
atkOptimalRange=drone.maxRange or 0,
atkFalloffRange=drone.falloff or 0,
atkTracking=drone.getModifiedItemAttr('trackingSpeed'),
atkOptimalSigRadius=drone.getModifiedItemAttr('optimalSigRadius'),
distance=cthDistance,

View File

@@ -51,15 +51,15 @@ class YDpsMixin:
# Compose map ourselves using current fit settings if time is not specified
dpsMap = {}
defaultSpoolValue = eos.config.settings['globalDefaultSpoolupPercentage']
for mod in src.item.modules:
for mod in src.item.activeModulesIter():
if not mod.isDealingDamage():
continue
dpsMap[mod] = mod.getDps(spoolOptions=SpoolOptions(SpoolType.SCALE, defaultSpoolValue, False))
for drone in src.item.drones:
for drone in src.item.activeDronesIter():
if not drone.isDealingDamage():
continue
dpsMap[drone] = drone.getDps()
for fighter in src.item.fighters:
for fighter in src.item.activeFightersIter():
if not fighter.isDealingDamage():
continue
for effectID, effectDps in fighter.getDpsPerEffect().items():
@@ -85,15 +85,15 @@ class YVolleyMixin:
# Compose map ourselves using current fit settings if time is not specified
volleyMap = {}
defaultSpoolValue = eos.config.settings['globalDefaultSpoolupPercentage']
for mod in src.item.modules:
for mod in src.item.activeModulesIter():
if not mod.isDealingDamage():
continue
volleyMap[mod] = mod.getVolley(spoolOptions=SpoolOptions(SpoolType.SCALE, defaultSpoolValue, False))
for drone in src.item.drones:
for drone in src.item.activeDronesIter():
if not drone.isDealingDamage():
continue
volleyMap[drone] = drone.getVolley()
for fighter in src.item.fighters:
for fighter in src.item.activeFightersIter():
if not fighter.isDealingDamage():
continue
for effectID, effectVolley in fighter.getVolleyPerEffect().items():

View File

@@ -0,0 +1,205 @@
# =============================================================================
# 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 <http://www.gnu.org/licenses/>.
# =============================================================================
from copy import copy
from eos.utils.float import floatUnerr
from eos.utils.spoolSupport import SpoolOptions, SpoolType
from eos.utils.stats import RRTypes
from graphs.data.base import FitDataCache
class TimeCache(FitDataCache):
# Whole data getters
def getRpsData(self, src):
"""Return RPS data in {time: {key: rps}} format."""
return self._data[src.item.ID]['finalRps']
def getRepAmountData(self, src):
"""Return rep amount data in {time: {key: amount}} format."""
return self._data[src.item.ID]['finalRepAmount']
# Specific data point getters
def getRpsDataPoint(self, src, time):
"""Get RPS data by specified time in {key: rps} format."""
return self._getDataPoint(src=src, time=time, dataFunc=self.getRpsData)
def getRepAmountDataPoint(self, src, time):
"""Get rep amount data by specified time in {key: amount} format."""
return self._getDataPoint(src=src, time=time, dataFunc=self.getRepAmountData)
# Preparation functions
def prepareRpsData(self, src, maxTime):
# Time is none means that time parameter has to be ignored,
# we do not need cache for that
if maxTime is None:
return True
self._generateInternalForm(src=src, maxTime=maxTime)
fitCache = self._data[src.item.ID]
# Final cache has been generated already, don't do anything
if 'finalRps' in fitCache:
return
# Convert cache from segments with assigned values into points
# which are located at times when rps value changes
pointCache = {}
for key, rpsList in fitCache['internalRps'].items():
pointData = pointCache[key] = {}
prevRps = None
prevTimeEnd = None
for timeStart, timeEnd, rps in rpsList:
# First item
if not pointData:
pointData[timeStart] = rps
# Gap between items
elif floatUnerr(prevTimeEnd) < floatUnerr(timeStart):
pointData[prevTimeEnd] = RRTypes(0, 0, 0, 0)
pointData[timeStart] = rps
# Changed value
elif rps != prevRps:
pointData[timeStart] = rps
prevRps = rps
prevTimeEnd = timeEnd
# We have data in another form, do not need old one any longer
del fitCache['internalRps']
changesByTime = {}
for key, rpsMap in pointCache.items():
for time in rpsMap:
changesByTime.setdefault(time, []).append(key)
# Here we convert cache to following format:
# {time: {key: rps}
finalRpsCache = fitCache['finalRps'] = {}
timeRpsData = {}
for time in sorted(changesByTime):
timeRpsData = copy(timeRpsData)
for key in changesByTime[time]:
timeRpsData[key] = pointCache[key][time]
finalRpsCache[time] = timeRpsData
def prepareRepAmountData(self, src, maxTime):
# Time is none means that time parameter has to be ignored,
# we do not need cache for that
if maxTime is None:
return
self._generateInternalForm(src=src, maxTime=maxTime)
fitCache = self._data[src.item.ID]
# Final cache has been generated already, don't do anything
if 'finalRepAmount' in fitCache:
return
intCache = fitCache['internalRepAmount']
changesByTime = {}
for key, remAmountMap in intCache.items():
for time in remAmountMap:
changesByTime.setdefault(time, []).append(key)
# Here we convert cache to following format:
# {time: {key: hp repaired by key at this time}}
finalCache = fitCache['finalRepAmount'] = {}
timeRepAmountData = {}
for time in sorted(changesByTime):
timeRepAmountData = copy(timeRepAmountData)
for key in changesByTime[time]:
keyRepAmount = intCache[key][time]
if key in timeRepAmountData:
timeRepAmountData[key] = timeRepAmountData[key] + keyRepAmount
else:
timeRepAmountData[key] = keyRepAmount
finalCache[time] = timeRepAmountData
# We do not need internal cache once we have final
del fitCache['internalRepAmount']
# Private stuff
def _generateInternalForm(self, src, maxTime):
if self._isTimeCacheValid(src=src, maxTime=maxTime):
return
fitCache = self._data[src.item.ID] = {'maxTime': maxTime}
intCacheRps = fitCache['internalRps'] = {}
intCacheRepAmount = fitCache['internalRepAmount'] = {}
def addRps(rrKey, addedTimeStart, addedTimeFinish, addedRepAmounts):
if not addedRepAmounts:
return
repAmountSum = sum(addedRepAmounts, RRTypes(0, 0, 0, 0))
if repAmountSum.shield > 0 or repAmountSum.armor > 0 or repAmountSum.hull > 0:
addedRps = repAmountSum / (addedTimeFinish - addedTimeStart)
rrCacheRps = intCacheRps.setdefault(rrKey, [])
rrCacheRps.append((addedTimeStart, addedTimeFinish, addedRps))
def addRepAmount(rrKey, addedTime, addedRepAmount):
if addedRepAmount.shield > 0 or addedRepAmount.armor > 0 or addedRepAmount.hull > 0:
intCacheRepAmount.setdefault(rrKey, {})[addedTime] = addedRepAmount
# Modules
for mod in src.item.activeModulesIter():
if not mod.isRemoteRepping():
continue
cycleParams = mod.getCycleParameters(reloadOverride=True)
if cycleParams is None:
continue
currentTime = 0
nonstopCycles = 0
for cycleTimeMs, inactiveTimeMs in cycleParams.iterCycles():
cycleRepAmounts = []
repAmountParams = mod.getRepAmountParameters(spoolOptions=SpoolOptions(SpoolType.CYCLES, nonstopCycles, True))
for repTimeMs, repAmount in repAmountParams.items():
cycleRepAmounts.append(repAmount)
addRepAmount(mod, currentTime + repTimeMs / 1000, repAmount)
addRps(mod, currentTime, currentTime + cycleTimeMs / 1000, cycleRepAmounts)
if inactiveTimeMs > 0:
nonstopCycles = 0
else:
nonstopCycles += 1
if currentTime > maxTime:
break
currentTime += cycleTimeMs / 1000 + inactiveTimeMs / 1000
# Drones
for drone in src.item.activeDronesIter():
if not drone.isRemoteRepping():
continue
cycleParams = drone.getCycleParameters(reloadOverride=True)
if cycleParams is None:
continue
currentTime = 0
repAmountParams = drone.getRepAmountParameters()
for cycleTimeMs, inactiveTimeMs in cycleParams.iterCycles():
cycleRepAmounts = []
for repTimeMs, repAmount in repAmountParams.items():
cycleRepAmounts.append(repAmount)
addRepAmount(drone, currentTime + repTimeMs / 1000, repAmount)
addRps(drone, currentTime, currentTime + cycleTimeMs / 1000, cycleRepAmounts)
if currentTime > maxTime:
break
currentTime += cycleTimeMs / 1000 + inactiveTimeMs / 1000
def _isTimeCacheValid(self, src, maxTime):
try:
cacheMaxTime = self._data[src.item.ID]['maxTime']
except KeyError:
return False
return maxTime <= cacheMaxTime
def _getDataPoint(self, src, time, dataFunc):
data = dataFunc(src)
timesBefore = [t for t in data if floatUnerr(t) <= floatUnerr(time)]
try:
time = max(timesBefore)
except ValueError:
return {}
else:
return data[time]

View File

@@ -0,0 +1,41 @@
# =============================================================================
# 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 <http://www.gnu.org/licenses/>.
# =============================================================================
from eos.utils.float import floatUnerr
from graphs.calc import calculateRangeFactor
def getApplicationPerKey(src, distance):
applicationMap = {}
for mod in src.item.activeModulesIter():
if not mod.isRemoteRepping():
continue
applicationMap[mod] = 1 if distance is None else calculateRangeFactor(
srcOptimalRange=mod.maxRange or 0,
srcFalloffRange=mod.falloff or 0,
distance=distance)
for drone in src.item.activeDronesIter():
if not drone.isRemoteRepping():
continue
applicationMap[drone] = 1 if distance is None or distance <= src.item.extraAttributes['droneControlRange'] else 0
# Ensure consistent results - round off a little to avoid float errors
for k, v in applicationMap.items():
applicationMap[k] = floatUnerr(v)
return applicationMap

View File

@@ -18,5 +18,169 @@
# =============================================================================
from graphs.data.base import SmoothPointGetter
import eos.config
from eos.utils.spoolSupport import SpoolOptions, SpoolType
from eos.utils.stats import RRTypes
from graphs.data.base import PointGetter, SmoothPointGetter
from .calc import getApplicationPerKey
def applyReps(rrMap, applicationMap):
totalAmount = RRTypes(shield=0, armor=0, hull=0, capacitor=0)
for key, repAmount in rrMap.items():
totalAmount += repAmount * applicationMap.get(key, 0)
# We do not want to include energy transfers into final value
totalReps = totalAmount.shield + totalAmount.armor + totalAmount.hull
return totalReps
# Y mixins
class YRpsMixin:
def _getRepsPerKey(self, src, time):
# Use data from time cache if time was not specified
if time is not None:
return self._getTimeCacheDataPoint(src=src, time=time)
# Compose map ourselves using current fit settings if time is not specified
rpsMap = {}
defaultSpoolValue = eos.config.settings['globalDefaultSpoolupPercentage']
for mod in src.item.activeModulesIter():
if not mod.isRemoteRepping():
continue
rpsMap[mod] = mod.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SCALE, defaultSpoolValue, False))
for drone in src.item.activeDronesIter():
if not drone.isRemoteRepping():
continue
rpsMap[drone] = drone.getRemoteReps()
return rpsMap
def _prepareTimeCache(self, src, maxTime):
self.graph._timeCache.prepareRpsData(src=src, maxTime=maxTime)
def _getTimeCacheData(self, src):
return self.graph._timeCache.getRpsData(src=src)
def _getTimeCacheDataPoint(self, src, time):
return self.graph._timeCache.getRpsDataPoint(src=src, time=time)
class YRepAmountMixin:
def _getRepsPerKey(self, src, time):
# Total reps given makes no sense without time specified
if time is None:
raise ValueError
return self._getTimeCacheDataPoint(src=src, time=time)
def _prepareTimeCache(self, src, maxTime):
self.graph._timeCache.prepareRepAmountData(src=src, maxTime=maxTime)
def _getTimeCacheData(self, src):
return self.graph._timeCache.getRepAmountData(src=src)
def _getTimeCacheDataPoint(self, src, time):
return self.graph._timeCache.getRepAmountDataPoint(src=src, time=time)
# X mixins
class XDistanceMixin(SmoothPointGetter):
_baseResolution = 50
_extraDepth = 2
def _getCommonData(self, miscParams, src, tgt):
# Prepare time cache here because we need to do it only once,
# and this function is called once per point info fetch
self._prepareTimeCache(src=src, maxTime=miscParams['time'])
return {'rrMap': self._getRepsPerKey(src=src, time=miscParams['time'])}
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
distance = x
applicationMap = getApplicationPerKey(src=src, distance=distance)
y = applyReps(
rrMap=commonData['rrMap'],
applicationMap=applicationMap)
return y
class XTimeMixin(PointGetter):
def getRange(self, xRange, miscParams, src, tgt):
xs = []
ys = []
minTime, maxTime = xRange
# Prepare time cache and various shared data
self._prepareTimeCache(src=src, maxTime=maxTime)
timeCache = self._getTimeCacheData(src=src)
applicationMap = getApplicationPerKey(src=src, distance=miscParams['distance'])
# Custom iteration for time graph to show all data points
currentRepAmount = None
currentTime = None
for currentTime in sorted(timeCache):
prevRepAmount = currentRepAmount
currentRepAmountData = timeCache[currentTime]
currentRepAmount = applyReps(rrMap=currentRepAmountData, applicationMap=applicationMap)
if currentTime < minTime:
continue
# First set of data points
if not xs:
# Start at exactly requested time, at last known value
initialRepAmount = prevRepAmount or 0
xs.append(minTime)
ys.append(initialRepAmount)
# If current time is bigger then starting, extend plot to that time with old value
if currentTime > minTime:
xs.append(currentTime)
ys.append(initialRepAmount)
# If new value is different, extend it with new point to the new value
if currentRepAmount != prevRepAmount:
xs.append(currentTime)
ys.append(currentRepAmount)
continue
# Last data point
if currentTime >= maxTime:
xs.append(maxTime)
ys.append(prevRepAmount)
break
# Anything in-between
if currentRepAmount != prevRepAmount:
if prevRepAmount is not None:
xs.append(currentTime)
ys.append(prevRepAmount)
xs.append(currentTime)
ys.append(currentRepAmount)
# Special case - there are no remote reppers
if currentRepAmount is None and currentTime is None:
xs.append(minTime)
ys.append(0)
# Make sure that last data point is always at max time
if maxTime > (currentTime or 0):
xs.append(maxTime)
ys.append(currentRepAmount or 0)
return xs, ys
def getPoint(self, x, miscParams, src, tgt):
time = x
# Prepare time cache and various data
self._prepareTimeCache(src=src, maxTime=time)
repAmountData = self._getTimeCacheDataPoint(src=src, time=time)
applicationMap = getApplicationPerKey(src=src, distance=miscParams['distance'])
y = applyReps(rrMap=repAmountData, applicationMap=applicationMap)
return y
# Final getters
class Distance2RpsGetter(XDistanceMixin, YRpsMixin):
pass
class Distance2RepAmountGetter(XDistanceMixin, YRepAmountMixin):
pass
class Time2RpsGetter(XTimeMixin, YRpsMixin):
pass
class Time2RepAmountGetter(XTimeMixin, YRepAmountMixin):
pass

View File

@@ -19,10 +19,26 @@
from graphs.data.base import FitGraph, XDef, YDef, Input
from service.const import GraphCacheCleanupReason
from .cache import TimeCache
from .getter import Distance2RpsGetter, Distance2RepAmountGetter, Time2RpsGetter, Time2RepAmountGetter
class FitRemoteRepsGraph(FitGraph):
def __init__(self):
super().__init__()
self._timeCache = TimeCache()
def _clearInternalCache(self, reason, extraData):
# Here, we care only about fit changes, graph changes and option switches
# - Input changes are irrelevant as time cache cares only about
# time input, and it regenerates once time goes beyond cached value
if reason in (GraphCacheCleanupReason.fitChanged, GraphCacheCleanupReason.fitRemoved):
self._timeCache.clearForFit(extraData)
elif reason == GraphCacheCleanupReason.graphSwitched:
self._timeCache.clearAll()
# UI stuff
internalName = 'remoteRepsGraph'
name = 'Remote Repairs'
@@ -33,12 +49,16 @@ class FitRemoteRepsGraph(FitGraph):
YDef(handle='rps', unit='HP/s', label='Repair speed'),
YDef(handle='total', unit='HP', label='Total repaired')]
inputs = [
Input(handle='time', unit='s', label='Time', iconID=1392, defaultValue=None, defaultRange=(0, 80), secondaryTooltip='When set, uses repairing ship\'s exact RR stats at a given time\nWhen not set, uses attacker\'s RR stats as shown in stats panel of main window'),
Input(handle='time', unit='s', label='Time', iconID=1392, defaultValue=None, defaultRange=(0, 80), secondaryTooltip='When set, uses repairing ship\'s exact RR stats at a given time\nWhen not set, uses repairing ship\'s RR stats as shown in stats panel of main window'),
Input(handle='distance', unit='km', label='Distance', iconID=1391, defaultValue=None, defaultRange=(0, 100), mainTooltip='Distance between the repairing ship and the target, as seen in overview (surface-to-surface)', secondaryTooltip='Distance between the repairing ship and the target, as seen in overview (surface-to-surface)')]
srcExtraCols = ('ShieldRR', 'ArmorRR', 'HullRR')
# Calculation stuff
_normalizers = {('distance', 'km'): lambda v, src, tgt: None if v is None else v * 1000}
_limiters = {'time': lambda src, tgt: (0, 2500)}
_getters = {}
_getters = {
('distance', 'rps'): Distance2RpsGetter,
('distance', 'total'): Distance2RepAmountGetter,
('time', 'rps'): Time2RpsGetter,
('time', 'total'): Time2RepAmountGetter}
_denormalizers = {('distance', 'km'): lambda v, src, tgt: None if v is None else v / 1000}

View File

@@ -40,8 +40,8 @@ class SubwarpSpeedCache(FitDataCache):
'Cynosural Field Generator',
'Clone Vat Bay',
'Jump Portal Generator')
for mod in src.item.modules:
if mod.item is not None and mod.item.group.name in disallowedGroups and mod.state >= FittingModuleState.ACTIVE:
for mod in src.item.activeModulesIter():
if mod.item is not None and mod.item.group.name in disallowedGroups:
modStates[mod] = mod.state
mod.state = FittingModuleState.ONLINE
projFitStates = {}