diff --git a/graphs/data/__init__.py b/graphs/data/__init__.py
index 6453ee583..b3a8097a5 100644
--- a/graphs/data/__init__.py
+++ b/graphs/data/__init__.py
@@ -19,6 +19,7 @@
from . import fitDamageStats
+from . import fitDamageEnvelope
from . import fitEwarStats
from . import fitRemoteReps
from . import fitShieldRegen
diff --git a/graphs/data/fitDamageEnvelope/__init__.py b/graphs/data/fitDamageEnvelope/__init__.py
new file mode 100644
index 000000000..03378417e
--- /dev/null
+++ b/graphs/data/fitDamageEnvelope/__init__.py
@@ -0,0 +1,23 @@
+# =============================================================================
+# 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 .
+# =============================================================================
+
+
+from .graph import FitDamageEnvelopeGraph
+
+FitDamageEnvelopeGraph.register()
diff --git a/graphs/data/fitDamageEnvelope/getter.py b/graphs/data/fitDamageEnvelope/getter.py
new file mode 100644
index 000000000..a11a0f425
--- /dev/null
+++ b/graphs/data/fitDamageEnvelope/getter.py
@@ -0,0 +1,317 @@
+# =============================================================================
+# 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 .
+# =============================================================================
+
+
+import eos.config
+from eos.const import FittingHardpoint
+from eos.saveddata.targetProfile import TargetProfile
+from eos.utils.spoolSupport import SpoolOptions, SpoolType
+from graphs.calc import checkLockRange
+from graphs.data.base import SmoothPointGetter
+from graphs.data.fitDamageStats.calc.application import (_calcMissileFactor, _calcTurretChanceToHit, _calcTurretMult,
+ getApplicationPerKey, )
+from service.settings import GraphSettings
+
+
+def _buildResistProfile(tgtResists, tgtFullHp):
+ if not GraphSettings.getInstance().get('ignoreResists'):
+ emRes, thermRes, kinRes, exploRes = tgtResists
+ else:
+ emRes = thermRes = kinRes = exploRes = 0
+ return TargetProfile(emAmount=emRes, thermalAmount=thermRes, kineticAmount=kinRes, explosiveAmount=exploRes,
+ hp=tgtFullHp)
+
+
+def _typedDmgScalar(dmgTyped, applicationMult, profile):
+ """Apply application multiplier and resist profile, return scalar EHP/s."""
+ if applicationMult == 0:
+ return 0
+ scaled = dmgTyped * applicationMult
+ scaled.profile = profile
+ return scaled.total
+
+
+def _turretApplication(snapshot, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius):
+ cth = _calcTurretChanceToHit(atkSpeed=atkSpeed, atkAngle=atkAngle, atkRadius=src.getRadius(),
+ atkOptimalRange=snapshot['maxRange'] or 0, atkFalloffRange=snapshot['falloff'] or 0,
+ atkTracking=snapshot['tracking'], atkOptimalSigRadius=snapshot['optimalSigRadius'], distance=distance,
+ tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtRadius=tgt.getRadius(), tgtSigRadius=tgtSigRadius)
+ return _calcTurretMult(cth)
+
+
+def _missileApplication(snapshot, distance, tgtSpeed, tgtSigRadius):
+ rangeData = snapshot['missileMaxRangeData']
+ if rangeData is None:
+ return 0
+ lowerRange, higherRange, higherChance = rangeData
+ if distance is None or distance <= lowerRange:
+ distanceFactor = 1
+ elif lowerRange < distance <= higherRange:
+ distanceFactor = higherChance
+ else:
+ distanceFactor = 0
+ if distanceFactor == 0:
+ return 0
+ applicationFactor = _calcMissileFactor(atkEr=snapshot['aoeCloudSize'], atkEv=snapshot['aoeVelocity'],
+ atkDrf=snapshot['aoeDamageReductionFactor'], tgtSpeed=tgtSpeed, tgtSigRadius=tgtSigRadius)
+ return distanceFactor * applicationFactor
+
+
+def _snapshotTurret(mod, dmgTyped, charge):
+ return {'kind': 'turret', 'charge': charge, 'dmg': dmgTyped, 'maxRange': mod.maxRange, 'falloff': mod.falloff,
+ 'tracking': mod.getModifiedItemAttr('trackingSpeed'),
+ 'optimalSigRadius': mod.getModifiedItemAttr('optimalSigRadius')}
+
+
+def _snapshotMissile(mod, dmgTyped, charge):
+ return {'kind': 'missile', 'charge': charge, 'dmg': dmgTyped, 'missileMaxRangeData': mod.missileMaxRangeData,
+ 'aoeCloudSize': mod.getModifiedChargeAttr('aoeCloudSize'),
+ 'aoeVelocity': mod.getModifiedChargeAttr('aoeVelocity'),
+ 'aoeDamageReductionFactor': mod.getModifiedChargeAttr('aoeDamageReductionFactor'),
+ 'isFoF': 'fofMissileLaunching' in (charge.effects if charge else {})}
+
+
+def _isAmmoEnvelopeWeapon(mod):
+ """Turret or standard missile launcher with valid charges."""
+ if mod.hardpoint not in (FittingHardpoint.TURRET, FittingHardpoint.MISSILE):
+ return False
+ # Skip exotic weapon groups handled separately by stock app logic
+ if mod.item.group.name in ('Missile Launcher Bomb', 'Structure Guided Bomb Launcher'):
+ return False
+ if 'ChainLightning' in mod.item.effects:
+ return False
+ if mod.isBreacher:
+ return False
+ return bool(mod.getValidCharges())
+
+
+def _snapshotForCurrentCharge(mod):
+ """Build a snapshot dict for whatever charge is currently loaded on mod."""
+ spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, eos.config.settings['globalDefaultSpoolupPercentage'], False)
+ dmgTyped = mod.getDps(spoolOptions=spoolOptions)
+ if mod.hardpoint == FittingHardpoint.TURRET:
+ return _snapshotTurret(mod, dmgTyped, mod.charge)
+ return _snapshotMissile(mod, dmgTyped, mod.charge)
+
+
+def _collectWeaponCandidates(src):
+ """For each ammo-bearing weapon, return list of per-charge snapshots.
+
+ Charge-dependent attributes (optimal/falloff/tracking/missile range/AoE) are
+ only applied to the module's modified attributes by a full fit recalc.
+ Since ammo effects are gun-local in EVE (a crystal in laser-1 does not
+ affect laser-2's attributes), we load up to N different ammos onto N
+ different weapons of the same group, recalc the fit once, and snapshot
+ all N (weapon, ammo) pairs from that single recalc. For a group of size
+ K weapons and M ammos this needs ceil(M / K) recalcs instead of M.
+ Originals are always restored via try/finally even if a calc raises.
+ """
+ fit = src.item
+ weapon_mods = [mod for mod in fit.activeModulesIter() if _isAmmoEnvelopeWeapon(mod)]
+ if not weapon_mods:
+ return []
+
+ # Group by (item ID, state) — within such a group, snapshots can be shared
+ # across mods, and DPS reads need consistent per-mod state.
+ groups = {}
+ for mod in weapon_mods:
+ groups.setdefault((mod.item.ID, mod.state), []).append(mod)
+
+ originals = {id(mod): mod.charge for mod in weapon_mods}
+ snapshots_by_mod = {id(mod): [] for mod in weapon_mods}
+ spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, eos.config.settings['globalDefaultSpoolupPercentage'], False)
+
+ try:
+ for group_mods in groups.values():
+ valid_charges = sorted(group_mods[0].getValidCharges(), key=lambda c: c.name)
+ if not valid_charges:
+ continue
+ chunk_size = len(group_mods)
+ for chunk_start in range(0, len(valid_charges), chunk_size):
+ chunk = valid_charges[chunk_start:chunk_start + chunk_size]
+ # Assign one chunk-ammo per group mod (extras stay on their previous charge)
+ for i, charge in enumerate(chunk):
+ group_mods[i].charge = charge
+ fit.clear()
+ fit.calculateModifiedAttributes()
+ # Snapshot per (assignee mod, charge); copy to all group mods since
+ # within an (item ID, state) group attributes for a given ammo match.
+ for i, charge in enumerate(chunk):
+ assignee = group_mods[i]
+ dmgTyped = assignee.getDps(spoolOptions=spoolOptions)
+ if dmgTyped.total <= 0:
+ continue
+ if assignee.hardpoint == FittingHardpoint.TURRET:
+ snap = _snapshotTurret(assignee, dmgTyped, charge)
+ else:
+ snap = _snapshotMissile(assignee, dmgTyped, charge)
+ for target_mod in group_mods:
+ snapshots_by_mod[id(target_mod)].append(snap)
+ finally:
+ for mod in weapon_mods:
+ mod.charge = originals[id(mod)]
+ fit.clear()
+ fit.calculateModifiedAttributes()
+
+ weapons = [{'mod': mod, 'candidates': snapshots_by_mod[id(mod)]} for mod in weapon_mods if
+ snapshots_by_mod[id(mod)]]
+ for weapon in weapons:
+ weapon['candidates'] = _pruneDominated(weapon['candidates'], src)
+ return weapons
+
+
+def _pruneDominated(candidates, src):
+ """Drop candidates whose effective-DPS curve is dominated everywhere.
+
+ Sample each candidate's application-only multiplier (ignoring resists,
+ which are mod-independent and uniformly scale all candidates) over a
+ coarse distance grid. A candidate X is dominated if there exists Y such
+ that Y's raw_damage * multiplier(distance) >= X's at every sample.
+ """
+ if len(candidates) <= 1:
+ return candidates
+ # Sample multipliers under a neutral mid-range scenario; this captures
+ # the shape of each ammo's range envelope without depending on misc inputs.
+ sampleDistances = [0, 1000, 5000, 10000, 20000, 40000, 80000, 160000, 320000]
+ tgtSpeed = 0
+ atkSpeed = 0
+ tgtSigRadius = 125
+ sigRefMod = src.getSigRadius() # not directly used, kept for clarity
+ del sigRefMod
+ # For each candidate, build a scalar score vector across samples.
+ scores = []
+ for snap in candidates:
+ rawTotal = snap['dmg'].total
+ vec = []
+ for d in sampleDistances:
+ if snap['kind'] == 'turret':
+ # Use only the range factor (drop tracking — angular speed is 0 here)
+ # by passing 0 atkSpeed/tgtSpeed/tgtAngle.
+ mult = _turretApplication(snap, src, src, atkSpeed, 0, d, tgtSpeed, 0, tgtSigRadius)
+ else:
+ mult = _missileApplication(snap, d, tgtSpeed, tgtSigRadius)
+ vec.append(rawTotal * mult)
+ scores.append(vec)
+ # Mark dominated
+ n = len(candidates)
+ eps = 1e-9
+ keep = [True] * n
+ for i in range(n):
+ if not keep[i]:
+ continue
+ for j in range(n):
+ if i == j or not keep[j]:
+ continue
+ # j dominates i if scores[j][k] >= scores[i][k] - eps for all k
+ # and scores[j][k] > scores[i][k] + eps for at least one k
+ dominates = True
+ strict = False
+ for k in range(len(sampleDistances)):
+ if scores[j][k] + eps < scores[i][k]:
+ dominates = False
+ break
+ if scores[j][k] > scores[i][k] + eps:
+ strict = True
+ if dominates and strict:
+ keep[i] = False
+ break
+ return [c for c, k in zip(candidates, keep) if k]
+
+
+def _bestWeaponDpsAtDistance(weapon, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius, profile,
+ inLockRange):
+ if not inLockRange:
+ # Special case: FoF missiles ignore lock range
+ candidates = [c for c in weapon['candidates'] if c.get('isFoF')]
+ if not candidates:
+ return 0
+ else:
+ candidates = weapon['candidates']
+ best = 0
+ for snap in candidates:
+ if snap['kind'] == 'turret':
+ mult = _turretApplication(snap, src, tgt, atkSpeed, atkAngle, distance, tgtSpeed, tgtAngle, tgtSigRadius)
+ else:
+ mult = _missileApplication(snap, distance, tgtSpeed, tgtSigRadius)
+ scalar = _typedDmgScalar(snap['dmg'], mult, profile)
+ if scalar > best:
+ best = scalar
+ return best
+
+
+class Distance2EnvelopeDpsGetter(SmoothPointGetter):
+ _baseResolution = 50
+ _extraDepth = 2
+
+ def _getCommonData(self, miscParams, src, tgt):
+ # Snapshot per-weapon ammo candidates once. _calculatePoint reuses these
+ # for every distance step so we avoid repeated charge swaps.
+ weapons = _collectWeaponCandidates(src)
+ # Track ammo-envelope weapon IDs so we can subtract their stock contribution
+ # from the common application map below.
+ envelopeMods = {id(w['mod']) for w in weapons}
+ # Standard application path covers everything else (drones, fighters,
+ # smartbombs, doomsdays, modules without valid charges, etc.).
+ defaultSpool = eos.config.settings['globalDefaultSpoolupPercentage']
+ spoolOptions = SpoolOptions(SpoolType.SPOOL_SCALE, defaultSpool, False)
+ nonEnvelopeDmg = {}
+ for mod in src.item.activeModulesIter():
+ if id(mod) in envelopeMods:
+ continue
+ if not mod.isDealingDamage():
+ continue
+ nonEnvelopeDmg[mod] = mod.getDps(spoolOptions=spoolOptions)
+ for drone in src.item.activeDronesIter():
+ if not drone.isDealingDamage():
+ continue
+ nonEnvelopeDmg[drone] = drone.getDps()
+ for fighter in src.item.activeFightersIter():
+ if not fighter.isDealingDamage():
+ continue
+ for effectID, effectDps in fighter.getDpsPerEffect().items():
+ nonEnvelopeDmg[(fighter, effectID)] = effectDps
+ return {'weapons': weapons, 'nonEnvelopeDmg': nonEnvelopeDmg, 'tgtResists': tgt.getResists(),
+ 'tgtFullHp': tgt.getFullHp()}
+
+ def _calculatePoint(self, x, miscParams, src, tgt, commonData):
+ distance = x
+ tgtSpeed = miscParams['tgtSpeed']
+ tgtSigRadius = miscParams.get('tgtSigRad', tgt.getSigRadius())
+ atkSpeed = miscParams.get('atkSpeed', 0) or 0
+ atkAngle = miscParams.get('atkAngle', 0) or 0
+ tgtAngle = miscParams.get('tgtAngle', 0) or 0
+ profile = _buildResistProfile(commonData['tgtResists'], commonData['tgtFullHp'])
+ inLockRange = checkLockRange(src=src, distance=distance)
+
+ total = 0
+ # Sum optimum-ammo contribution for each ammo-bearing weapon
+ for weapon in commonData['weapons']:
+ total += _bestWeaponDpsAtDistance(weapon=weapon, src=src, tgt=tgt, atkSpeed=atkSpeed, atkAngle=atkAngle,
+ distance=distance, tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtSigRadius=tgtSigRadius, profile=profile,
+ inLockRange=inLockRange)
+
+ # Add fixed-ammo contributors (drones, fighters, smartbombs, etc.) using
+ # the standard application math from fitDamageStats.
+ if commonData['nonEnvelopeDmg']:
+ applicationMap = getApplicationPerKey(src=src, tgt=tgt, atkSpeed=atkSpeed, atkAngle=atkAngle,
+ distance=distance, tgtSpeed=tgtSpeed, tgtAngle=tgtAngle, tgtSigRadius=tgtSigRadius)
+ for key, dmgTyped in commonData['nonEnvelopeDmg'].items():
+ mult = applicationMap.get(key, 0)
+ total += _typedDmgScalar(dmgTyped, mult, profile)
+ return total
diff --git a/graphs/data/fitDamageEnvelope/graph.py b/graphs/data/fitDamageEnvelope/graph.py
new file mode 100644
index 000000000..a531422fc
--- /dev/null
+++ b/graphs/data/fitDamageEnvelope/graph.py
@@ -0,0 +1,72 @@
+# =============================================================================
+# 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 .
+# =============================================================================
+
+
+import wx
+
+from graphs.data.base import FitGraph, Input, VectorDef, XDef, YDef
+from service.settings import GraphSettings
+from .getter import Distance2EnvelopeDpsGetter
+
+_t = wx.GetTranslation
+
+
+class FitDamageEnvelopeGraph(FitGraph):
+ # UI stuff
+ internalName = 'dmgEnvelopeGraph'
+ name = _t('Damage Projection')
+ xDefs = [XDef(handle='distance', unit='km', label=_t('Distance'), mainInput=('distance', 'km'))]
+ inputs = [
+ Input(handle='distance', unit='km', label=_t('Distance'), iconID=1391, defaultValue=None, defaultRange=(0, 100),
+ mainTooltip=_t('Distance between the attacker and the target, as seen in overview (surface-to-surface)'),
+ secondaryTooltip=_t(
+ 'Distance between the attacker and the target, as seen in overview (surface-to-surface)')),
+ Input(handle='tgtSpeed', unit='%', label=_t('Target speed'), iconID=1389, defaultValue=100,
+ defaultRange=(0, 100)),
+ Input(handle='tgtSigRad', unit='%', label=_t('Target signature'), iconID=1390, defaultValue=100,
+ defaultRange=(100, 200), conditions=[(('tgtSigRad', 'm'), None), (('tgtSigRad', '%'), None)])]
+ srcVectorDef = VectorDef(lengthHandle='atkSpeed', lengthUnit='%', angleHandle='atkAngle', angleUnit='degrees',
+ label=_t('Attacker'))
+ tgtVectorDef = VectorDef(lengthHandle='tgtSpeed', lengthUnit='%', angleHandle='tgtAngle', angleUnit='degrees',
+ label=_t('Target'))
+ hasTargets = True
+ srcExtraCols = ('Dps', 'Speed', 'Radius')
+
+ @property
+ def yDefs(self):
+ ignoreResists = GraphSettings.getInstance().get('ignoreResists')
+ return [YDef(handle='dps', unit=None, label=_t('Best DPS') if ignoreResists else _t('Best effective DPS'))]
+
+ @property
+ def tgtExtraCols(self):
+ cols = []
+ if not GraphSettings.getInstance().get('ignoreResists'):
+ cols.append('Target Resists')
+ cols.extend(('Speed', 'SigRadius', 'Radius', 'FullHP'))
+ return cols
+
+ # Calculation stuff
+ _normalizers = {('distance', 'km'): lambda v, src, tgt: None if v is None else v * 1000,
+ ('atkSpeed', '%'): lambda v, src, tgt: v / 100 * src.getMaxVelocity(),
+ ('tgtSpeed', '%'): lambda v, src, tgt: v / 100 * tgt.getMaxVelocity(),
+ ('tgtSigRad', '%'): lambda v, src, tgt: v / 100 * tgt.getSigRadius()}
+ _getters = {('distance', 'dps'): Distance2EnvelopeDpsGetter}
+ _denormalizers = {('distance', 'km'): lambda v, src, tgt: None if v is None else v / 1000,
+ ('tgtSpeed', '%'): lambda v, src, tgt: v * 100 / tgt.getMaxVelocity(),
+ ('tgtSigRad', '%'): lambda v, src, tgt: v * 100 / tgt.getSigRadius()}