From 11ac35c563536f3bc859fd6a369cc24b622059b2 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 22 Jun 2026 16:02:38 -0400 Subject: [PATCH 1/2] Replace Damage Envelope graph with Application Profile graph The new graph plots optimal-ammo DPS/volley versus distance, selecting the best charge at each range and drawing each ammo as its own segment. - graphs/data/fitApplicationProfile: new getter/graph and calc helpers (turret/launcher hit math, ammo optimization, projected effects, charge filtering); removes the old fitDamageEnvelope module - Canvas: segmented plotting with per-segment legend and ammo styling - Control panel: Ammo Style (None/Pattern/Color) and Ammo Meta (T1/Navy/All) dropdowns; dynamic distance range derived from fit weapon ranges - Lists: auto-switch ammo style on weapon-class conflicts; refreshDefaultColumns - Context menus: Apply Projected Effects / Ignore Target Resists for the new graph - service/settings: add ammoOptimalApplyProjected and ammoOptimalIgnoreResists keys --- .gitignore | 3 + graphs/data/__init__.py | 2 +- .../__init__.py | 4 +- .../fitApplicationProfile/calc/__init__.py | 24 + .../fitApplicationProfile/calc/charges.py | 251 +++++ .../fitApplicationProfile/calc/launcher.py | 682 ++++++++++++ .../calc/optimize_ammo.py | 216 ++++ .../fitApplicationProfile/calc/projected.py | 243 +++++ .../data/fitApplicationProfile/calc/turret.py | 187 ++++ .../calc/valid_charges.py | 79 ++ graphs/data/fitApplicationProfile/getter.py | 969 ++++++++++++++++++ graphs/data/fitApplicationProfile/graph.py | 575 +++++++++++ graphs/data/fitDamageEnvelope/getter.py | 317 ------ graphs/data/fitDamageEnvelope/graph.py | 72 -- graphs/gui/canvasPanel.py | 717 +++++++++++-- graphs/gui/ctrlPanel.py | 196 +++- graphs/gui/frame.py | 2 +- graphs/gui/lists.py | 298 +++++- gui/builtinContextMenus/__init__.py | 2 + .../graphAmmoOptimalApplyProjected.py | 33 + .../graphAmmoOptimalIgnoreResists.py | 33 + service/settings.py | 2 + 22 files changed, 4410 insertions(+), 497 deletions(-) rename graphs/data/{fitDamageEnvelope => fitApplicationProfile}/__init__.py (91%) create mode 100644 graphs/data/fitApplicationProfile/calc/__init__.py create mode 100644 graphs/data/fitApplicationProfile/calc/charges.py create mode 100644 graphs/data/fitApplicationProfile/calc/launcher.py create mode 100644 graphs/data/fitApplicationProfile/calc/optimize_ammo.py create mode 100644 graphs/data/fitApplicationProfile/calc/projected.py create mode 100644 graphs/data/fitApplicationProfile/calc/turret.py create mode 100644 graphs/data/fitApplicationProfile/calc/valid_charges.py create mode 100644 graphs/data/fitApplicationProfile/getter.py create mode 100644 graphs/data/fitApplicationProfile/graph.py delete mode 100644 graphs/data/fitDamageEnvelope/getter.py delete mode 100644 graphs/data/fitDamageEnvelope/graph.py create mode 100644 gui/builtinContextMenus/graphAmmoOptimalApplyProjected.py create mode 100644 gui/builtinContextMenus/graphAmmoOptimalIgnoreResists.py diff --git a/.gitignore b/.gitignore index 8f34001cbd..1c46a16598 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ #Pyfa file pyfaFits.html +#Local EVE static data dump +eve.db + #Temporary files *.py__jb_tmp__ diff --git a/graphs/data/__init__.py b/graphs/data/__init__.py index b3a8097a59..650239edee 100644 --- a/graphs/data/__init__.py +++ b/graphs/data/__init__.py @@ -19,7 +19,7 @@ from . import fitDamageStats -from . import fitDamageEnvelope +from . import fitApplicationProfile from . import fitEwarStats from . import fitRemoteReps from . import fitShieldRegen diff --git a/graphs/data/fitDamageEnvelope/__init__.py b/graphs/data/fitApplicationProfile/__init__.py similarity index 91% rename from graphs/data/fitDamageEnvelope/__init__.py rename to graphs/data/fitApplicationProfile/__init__.py index 03378417e7..a5480b0340 100644 --- a/graphs/data/fitDamageEnvelope/__init__.py +++ b/graphs/data/fitApplicationProfile/__init__.py @@ -17,7 +17,7 @@ # along with pyfa. If not, see . # ============================================================================= +from .graph import FitAmmoOptimalDpsGraph -from .graph import FitDamageEnvelopeGraph -FitDamageEnvelopeGraph.register() +FitAmmoOptimalDpsGraph.register() diff --git a/graphs/data/fitApplicationProfile/calc/__init__.py b/graphs/data/fitApplicationProfile/calc/__init__.py new file mode 100644 index 0000000000..e4ed3e84d2 --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/__init__.py @@ -0,0 +1,24 @@ +# ============================================================================= +# 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 key functions for convenient access +from .projected import ( + buildProjectedCache, + getProjectedParamsAtDistance, +) diff --git a/graphs/data/fitApplicationProfile/calc/charges.py b/graphs/data/fitApplicationProfile/calc/charges.py new file mode 100644 index 0000000000..dff3a310b6 --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/charges.py @@ -0,0 +1,251 @@ +# ============================================================================= +# 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 . +# ============================================================================= + +# ============================================================================= +# Constants +# ============================================================================= + +# Navy faction ammo prefixes (for S/M/L ammo) +NAVY_PREFIXES = ( + 'Imperial Navy ', + 'Republic Fleet ', + 'Caldari Navy ', + 'Federation Navy ', + 'Plasma ' +) + +# Capital (XL) "navy-tier" faction ammo prefixes +# There is no empire Navy XL ammo, so pirate faction serves as the "navy" tier for capitals +CAPITAL_NAVY_PREFIXES = ( + 'Sansha ', + 'Arch Angel ', + 'Shadow ', + 'Plasma' +) + + +# ============================================================================= +# Quality Tier Filtering +# ============================================================================= + +def filterChargesByQuality(charges, qualityTier): + """ + Filter charges based on quality tier selection. + + Args: + charges: List of charge items + qualityTier: 't1', 'navy', or 'all' + + Returns: + Filtered list of charges + + Tiers are cumulative: + - 't1': Tech I (metaGroup 1) + Tech II (metaGroup 2) + - 'navy': t1 + Navy faction ammo (Imperial Navy, Republic Fleet, Caldari Navy, Federation Navy) + For XL (capital) ammo: includes pirate faction (Sansha, Arch Angel, Shadow) + - 'all': Everything including high-tier faction (Blood, Dark Blood, True Sansha, etc.) + + Tech II ammo is always included as it's a distinct ammo type, not a "better" variant. + """ + if qualityTier == 'all': + return charges + + filtered = [] + classifiable = False # Did any charge have a meta group we could rank? + for charge in charges: + mg = charge.metaGroup + mgId = mg.ID if mg else None + if mgId is not None: + classifiable = True + + # Tech I (metaGroup 1) - always included + if mgId == 1: + filtered.append(charge) + continue + + # Tech II (metaGroup 2) - always included (distinct ammo type like Conflagration, Void, etc.) + if mgId == 2: + filtered.append(charge) + continue + + # For 'navy' tier, include Navy faction ammo + if qualityTier == 'navy' and mgId == 4: # Faction + # Check if it's XL (capital) ammo by name suffix + isCapital = charge.name.endswith(' XL') + + if isCapital: + # For capital ammo, use pirate faction prefixes as "navy" tier + if any(charge.name.startswith(prefix) for prefix in CAPITAL_NAVY_PREFIXES): + filtered.append(charge) + else: + # For subcap ammo, use empire Navy prefixes + if any(charge.name.startswith(prefix) for prefix in NAVY_PREFIXES): + filtered.append(charge) + + # Honor the user's tier selection even when it excludes every charge (the + # weapon simply has no ammo in this tier). Only fall back to the full list + # when no charge could be classified by meta group at all - in that case + # the tier system does not apply and returning nothing would wrongly hide + # the weapon. + if filtered or classifiable: + return filtered + return charges + + +# ============================================================================= +# Charge Stats Extraction +# ============================================================================= + +def getChargeStats(charge): + """ + Extract charge stats including damage values and multipliers. + + Args: + charge: The charge item + + Returns: + Dict with damage values and range/falloff/tracking multipliers + """ + em = charge.getAttribute('emDamage') or 0 + thermal = charge.getAttribute('thermalDamage') or 0 + kinetic = charge.getAttribute('kineticDamage') or 0 + explosive = charge.getAttribute('explosiveDamage') or 0 + + return { + 'emDamage': em, + 'thermalDamage': thermal, + 'kineticDamage': kinetic, + 'explosiveDamage': explosive, + 'totalDamage': em + thermal + kinetic + explosive, + 'rangeMultiplier': charge.getAttribute('weaponRangeMultiplier') or 1, + 'falloffMultiplier': charge.getAttribute('fallofMultiplier') or 1, + 'trackingMultiplier': charge.getAttribute('trackingSpeedMultiplier') or 1 + } + + +# ============================================================================= +# Resist Application +# ============================================================================= + +def applyResists(chargeStats, tgtResists): + """ + Apply target resists to charge stats. + + Args: + chargeStats: Dict from getChargeStats + tgtResists: Tuple of (em, therm, kin, explo) resist values (0-1) + + Returns: + New dict with resisted damage values + """ + if not tgtResists: + return chargeStats + + emRes, thermRes, kinRes, exploRes = tgtResists + + em = chargeStats['emDamage'] * (1 - emRes) + thermal = chargeStats['thermalDamage'] * (1 - thermRes) + kinetic = chargeStats['kineticDamage'] * (1 - kinRes) + explosive = chargeStats['explosiveDamage'] * (1 - exploRes) + + result = chargeStats.copy() + result.update({ + 'emDamage': em, + 'thermalDamage': thermal, + 'kineticDamage': kinetic, + 'explosiveDamage': explosive, + 'totalDamage': em + thermal + kinetic + explosive + }) + return result + + +# ============================================================================= +# Charge Data Precomputation +# ============================================================================= + +def precomputeChargeData(turretBase, charges, skillMult=1.0, tgtResists=None): + """ + Pre-compute constant values for each charge. + + This computes effective stats (turret base * charge multipliers) and + raw volley for each charge, which can then be used for fast lookups. + + Args: + turretBase: Base turret stats dict from getTurretBaseStats + charges: List of charge items + skillMult: Skill damage multiplier from getSkillMultiplier + tgtResists: Target resists tuple or None + + Returns: + List of dicts with: name, raw_volley, effective_optimal, + effective_falloff, effective_tracking + + Note: We do NOT store raw_dps - it's derived from raw_volley / cycle_time + when needed at the mixin level. + """ + chargeData = [] + + for charge in charges: + stats = getChargeStats(charge) + + # Apply resists early for efficiency + if tgtResists: + stats = applyResists(stats, tgtResists) + + # Compute effective turret stats with charge modifiers + effectiveOptimal = turretBase['optimal'] * stats['rangeMultiplier'] + effectiveFalloff = turretBase['falloff'] * stats['falloffMultiplier'] + effectiveTracking = turretBase['tracking'] * stats['trackingMultiplier'] + + # Compute raw volley (unmodified by range/tracking) + rawVolley = stats['totalDamage'] * skillMult * turretBase['damageMultiplier'] + + chargeData.append({ + 'name': charge.name, + 'raw_volley': rawVolley, + 'effective_optimal': effectiveOptimal, + 'effective_falloff': effectiveFalloff, + 'effective_tracking': effectiveTracking + }) + + return chargeData + + +def getLongestRangeMultiplier(charges): + """ + Get the maximum range multiplier from a list of charges. + + Used to calculate the max effective range of a turret for cache sizing. + + Args: + charges: List of charge items + + Returns: + The highest rangeMultiplier value among all charges + """ + if not charges: + return 1.0 + + maxRangeMult = 1.0 + for charge in charges: + rangeMult = charge.getAttribute('weaponRangeMultiplier') or 1.0 + if rangeMult > maxRangeMult: + maxRangeMult = rangeMult + + return maxRangeMult diff --git a/graphs/data/fitApplicationProfile/calc/launcher.py b/graphs/data/fitApplicationProfile/calc/launcher.py new file mode 100644 index 0000000000..3d0353d590 --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/launcher.py @@ -0,0 +1,682 @@ +# ============================================================================= +# 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 math +from bisect import bisect_right + +from .projected import getProjectedParamsAtDistance + + +# ============================================================================= +# Missile Application Factor +# ============================================================================= + +def calcMissileFactor(atkEr, atkEv, atkDrf, tgtSpeed, tgtSigRadius): + """ + Calculate missile application factor. + + Formula: min(1, tgtSigRadius/eR, ((eV * tgtSigRadius) / (eR * tgtSpeed))^DRF) + + Args: + atkEr: Missile explosion radius (aoeCloudSize) in meters + atkEv: Missile explosion velocity (aoeVelocity) in m/s + atkDrf: Missile damage reduction factor (aoeDamageReductionFactor) + tgtSpeed: Target velocity (m/s) + tgtSigRadius: Target signature radius (m) + + Returns: + Application factor (0-1) + """ + factors = [1] + # "Slow" part - signature vs explosion radius + if atkEr > 0: + factors.append(tgtSigRadius / atkEr) + # "Fast" part - explosion velocity vs target speed (raised to DRF power) + if tgtSpeed > 0 and atkEr > 0: + factors.append(((atkEv * tgtSigRadius) / (atkEr * tgtSpeed)) ** atkDrf) + return min(factors) + + +# ============================================================================= +# Multiplier Extraction +# ============================================================================= + +def _extractMultiplier(mod, attr): + """ + Extract multiplier for a specific attribute. + + If the base value is 0 (e.g. Mjolnir has 0 thermal damage), we cannot + calculate the multiplier by division (x / 0). + + In that case, we temporarily inject a base value of 1.0 into the modifier + dictionary, read the modified value (which will be 1.0 * multiplier), + and use that as the multiplier. + """ + base = mod.getChargeBaseAttrValue(attr) or 0 + + if base > 0: + modified = mod.getModifiedChargeAttr(attr) or 0 + return modified / base + + # Base is 0, we need to trick the eos logic to give us the multiplier + # We use preAssign to set the base value to 1.0 for this calculation + mod.chargeModifiedAttributes.preAssign(attr, 1.0) + try: + # Get the modified value, which should now be 1.0 * multiplier + multiplier = mod.getModifiedChargeAttr(attr) or 1.0 + finally: + # Cleanup: remove the preAssign + # Accessing private members is naughty but eos doesn't give us a clean way to remove preAssigns + # and we must clean up to avoid side effects + if attr in mod.chargeModifiedAttributes._ModifiedAttributeDict__preAssigns: + del mod.chargeModifiedAttributes._ModifiedAttributeDict__preAssigns[attr] + # Force recalculation by removing from cache + if attr in mod.chargeModifiedAttributes._ModifiedAttributeDict__modified: + del mod.chargeModifiedAttributes._ModifiedAttributeDict__modified[attr] + if attr in mod.chargeModifiedAttributes._ModifiedAttributeDict__intermediary: + del mod.chargeModifiedAttributes._ModifiedAttributeDict__intermediary[attr] + + return multiplier + +def getDamageMultipliers(mod): + """ + Extract per-damage-type multipliers by comparing modified to base values. + + This captures all skill bonuses (Warhead Upgrades, etc.) and ship bonuses + that affect missile damage. Different damage types may have different bonuses + (e.g., Gila has kinetic/thermal bonus). + + Args: + mod: Launcher module with a charge loaded + + Returns: + Dict with multipliers for emDamage, thermalDamage, kineticDamage, explosiveDamage + """ + if mod.charge is None: + return { + 'emDamage': 1.0, + 'thermalDamage': 1.0, + 'kineticDamage': 1.0, + 'explosiveDamage': 1.0 + } + + multipliers = {} + for dmgType in ('emDamage', 'thermalDamage', 'kineticDamage', 'explosiveDamage'): + multipliers[dmgType] = _extractMultiplier(mod, dmgType) + + return multipliers + + +def getFlightMultipliers(mod): + """ + Extract flight attribute multipliers by comparing modified to base values. + + This captures skill bonuses from Missile Projection, Missile Bombardment, + and ship bonuses that affect flight time/velocity. + + Args: + mod: Launcher module with a charge loaded + + Returns: + Dict with multipliers for maxVelocity and explosionDelay + """ + if mod.charge is None: + return {'maxVelocity': 1.0, 'explosionDelay': 1.0} + + multipliers = {} + for attr in ('maxVelocity', 'explosionDelay'): + multipliers[attr] = _extractMultiplier(mod, attr) + + return multipliers + + +def getApplicationMultipliers(mod): + """ + Extract application attribute multipliers by comparing modified to base values. + + This captures skills like Guided Missile Precision, Target Navigation Prediction, + and rigging/implant bonuses that affect explosion radius/velocity. + + Args: + mod: Launcher module with a charge loaded + + Returns: + Dict with multipliers for aoeCloudSize, aoeVelocity, aoeDamageReductionFactor + """ + if mod.charge is None: + return {'aoeCloudSize': 1.0, 'aoeVelocity': 1.0, 'aoeDamageReductionFactor': 1.0} + + multipliers = {} + for attr in ('aoeCloudSize', 'aoeVelocity', 'aoeDamageReductionFactor'): + multipliers[attr] = _extractMultiplier(mod, attr) + + return multipliers + + +def getAllMultipliers(mod): + """ + Extract all multipliers (damage, flight, application) from a module. + + Args: + mod: Launcher module with a charge loaded + + Returns: + Tuple of (damageMults, flightMults, appMults) + """ + return ( + getDamageMultipliers(mod), + getFlightMultipliers(mod), + getApplicationMultipliers(mod) + ) + + +# ============================================================================= +# Range Calculation +# ============================================================================= + +def calculateMissileRange(maxVelocity, mass, agility, flightTime): + """ + Calculate missile range for a given flight time. + + Uses EVE formula accounting for acceleration time. + Source: http://www.eveonline.com/ingameboard.asp?a=topic&threadID=1307419&page=1#15 + + D_m = V_m * (T_m + T_0*[exp(- T_m/T_0)-1]) + + Simplified: acceleration time = min(flightTime, mass * agility / 1e6) + + Args: + maxVelocity: Missile max velocity (m/s) + mass: Missile mass (kg) + agility: Missile agility + flightTime: Flight time (seconds) + + Returns: + Range in meters + """ + accelTime = min(flightTime, mass * agility / 1000000) + # Average distance during acceleration (starts at 0, ends at maxVelocity) + duringAcceleration = maxVelocity / 2 * accelTime + # Distance at full speed + fullSpeed = maxVelocity * (flightTime - accelTime) + return duringAcceleration + fullSpeed + + +def getMissileRangeData(charge, shipRadius, damageMults=None, flightMults=None, appMults=None): + """ + Calculate missile range data for a charge with applied multipliers. + + EVE missiles have discrete flight times - if flight time is 1.3s, there's + a 30% chance of flying 2s and 70% chance of flying 1s. + + Args: + charge: Missile charge item + shipRadius: Launching ship's radius (affects flight time) + damageMults: Damage multipliers dict (or None for base values) + flightMults: Flight multipliers dict (or None for base values) + appMults: Application multipliers dict (or None for base values) + + Returns: + Dict with: lowerRange, higherRange, higherChance, maxEffectiveRange, + and all computed stats + """ + if flightMults is None: + flightMults = {'maxVelocity': 1.0, 'explosionDelay': 1.0} + if appMults is None: + appMults = {'aoeCloudSize': 1.0, 'aoeVelocity': 1.0, 'aoeDamageReductionFactor': 1.0} + if damageMults is None: + damageMults = {'emDamage': 1.0, 'thermalDamage': 1.0, 'kineticDamage': 1.0, 'explosiveDamage': 1.0} + + # Get base charge attributes + baseVelocity = charge.getAttribute('maxVelocity') or 0 + baseExplosionDelay = charge.getAttribute('explosionDelay') or 0 + baseMass = charge.getAttribute('mass') or 1 + baseAgility = charge.getAttribute('agility') or 1 + + if baseVelocity <= 0 or baseExplosionDelay <= 0: + return None + + # Apply flight multipliers + maxVelocity = baseVelocity * flightMults['maxVelocity'] + explosionDelay = baseExplosionDelay * flightMults['explosionDelay'] + + # Calculate flight time (includes ship radius bonus) + # Flight time has bonus based on ship radius: https://github.com/pyfa-org/Pyfa/issues/2083 + flightTime = explosionDelay / 1000 + shipRadius / maxVelocity + + # Discrete flight time: floor and ceil + lowerTime = math.floor(flightTime) + higherTime = math.ceil(flightTime) + higherChance = flightTime - lowerTime # Probability of flying the extra second + + # Calculate ranges + lowerRange = calculateMissileRange(maxVelocity, baseMass, baseAgility, lowerTime) + higherRange = calculateMissileRange(maxVelocity, baseMass, baseAgility, higherTime) + + # Make range center-to-surface (missiles spawn at ship center) + lowerRange = max(0, lowerRange - shipRadius) + higherRange = max(0, higherRange - shipRadius) + + # Max effective range uses ceil(flightTime) * velocity for sorting + maxEffectiveRange = higherRange + + # Get application stats with multipliers + baseEr = charge.getAttribute('aoeCloudSize') or 0 + baseEv = charge.getAttribute('aoeVelocity') or 0 + baseDrf = charge.getAttribute('aoeDamageReductionFactor') or 1 + + explosionRadius = baseEr * appMults['aoeCloudSize'] + explosionVelocity = baseEv * appMults['aoeVelocity'] + damageReductionFactor = baseDrf * appMults['aoeDamageReductionFactor'] + + # Get damage with multipliers + baseEm = charge.getAttribute('emDamage') or 0 + baseThermal = charge.getAttribute('thermalDamage') or 0 + baseKinetic = charge.getAttribute('kineticDamage') or 0 + baseExplosive = charge.getAttribute('explosiveDamage') or 0 + + em = baseEm * damageMults['emDamage'] + thermal = baseThermal * damageMults['thermalDamage'] + kinetic = baseKinetic * damageMults['kineticDamage'] + explosive = baseExplosive * damageMults['explosiveDamage'] + totalDamage = em + thermal + kinetic + explosive + + return { + 'lowerRange': lowerRange, + 'higherRange': higherRange, + 'higherChance': higherChance, + 'maxEffectiveRange': maxEffectiveRange, + 'explosionRadius': explosionRadius, + 'explosionVelocity': explosionVelocity, + 'damageReductionFactor': damageReductionFactor, + 'totalDamage': totalDamage, + 'emDamage': em, + 'thermalDamage': thermal, + 'kineticDamage': kinetic, + 'explosiveDamage': explosive + } + + +# ============================================================================= +# Charge Data Precomputation +# ============================================================================= + +# Damage type priority for tie-breaking (EM > Thermal > Kinetic > Explosive) +DAMAGE_TYPE_PRIORITY = { + 'em': 0, + 'thermal': 1, + 'kinetic': 2, + 'explosive': 3 +} + + +def getDominantDamageType(chargeName): + """ + Determine the dominant damage type of a missile based on its name. + + Mjolnir = EM, Inferno = Thermal, Scourge = Kinetic, Nova = Explosive + + Args: + chargeName: Missile name + + Returns: + 'em', 'thermal', 'kinetic', 'explosive', or 'unknown' + """ + nameLower = chargeName.lower() + if 'mjolnir' in nameLower: + return 'em' + elif 'inferno' in nameLower: + return 'thermal' + elif 'scourge' in nameLower: + return 'kinetic' + elif 'nova' in nameLower: + return 'explosive' + return 'unknown' + + +def precomputeMissileChargeData(mod, charges, cycleTimeMs, shipRadius, + damageMults=None, flightMults=None, appMults=None, + tgtResists=None): + """ + Pre-compute constant values for each missile charge. + + Args: + mod: Launcher module + charges: List of valid missile charges + cycleTimeMs: Launcher cycle time in milliseconds + shipRadius: Ship radius for flight calculations + damageMults: Per-damage-type multipliers from skills/ship + flightMults: Flight attribute multipliers + appMults: Application attribute multipliers + tgtResists: Target resist tuple (em, therm, kin, explo) or None + + Returns: + List of charge data dicts, sorted by maxEffectiveRange descending + """ + if damageMults is None: + damageMults = {'emDamage': 1.0, 'thermalDamage': 1.0, 'kineticDamage': 1.0, 'explosiveDamage': 1.0} + if flightMults is None: + flightMults = {'maxVelocity': 1.0, 'explosionDelay': 1.0} + if appMults is None: + appMults = {'aoeCloudSize': 1.0, 'aoeVelocity': 1.0, 'aoeDamageReductionFactor': 1.0} + + # Get launcher damage multiplier + launcherDamageMult = mod.getModifiedItemAttr('damageMultiplier') or 1 + + chargeData = [] + for charge in charges: + rangeData = getMissileRangeData(charge, shipRadius, damageMults, flightMults, appMults) + if rangeData is None: + continue + + # Apply target resists + totalDamage = rangeData['totalDamage'] + if tgtResists: + emRes, thermRes, kinRes, exploRes = tgtResists + totalDamage = ( + rangeData['emDamage'] * (1 - emRes) + + rangeData['thermalDamage'] * (1 - thermRes) + + rangeData['kineticDamage'] * (1 - kinRes) + + rangeData['explosiveDamage'] * (1 - exploRes) + ) + + # Calculate raw volley and DPS + rawVolley = totalDamage * launcherDamageMult + rawDps = rawVolley / (cycleTimeMs / 1000) if cycleTimeMs > 0 else 0 + + # Get damage type priority for tie-breaking + damageType = getDominantDamageType(charge.name) + damagePriority = DAMAGE_TYPE_PRIORITY.get(damageType, 99) + + chargeData.append({ + 'name': charge.name, + 'raw_volley': rawVolley, + 'raw_dps': rawDps, + 'lowerRange': rangeData['lowerRange'], + 'higherRange': rangeData['higherRange'], + 'higherChance': rangeData['higherChance'], + 'maxEffectiveRange': rangeData['maxEffectiveRange'], + 'explosionRadius': rangeData['explosionRadius'], + 'explosionVelocity': rangeData['explosionVelocity'], + 'damageReductionFactor': rangeData['damageReductionFactor'], + 'damage_priority': damagePriority + }) + + # Sort by maxEffectiveRange descending (longest range first for max range calculation) + # Then by raw_dps descending for tie-breaking + chargeData.sort(key=lambda x: (-x['maxEffectiveRange'], -x['raw_dps'])) + + return chargeData + + +def getMaxEffectiveRange(chargeData): + """ + Get the maximum effective range from precomputed charge data. + + Args: + chargeData: List of precomputed charge data dicts + + Returns: + Maximum effective range in meters + """ + if not chargeData: + return 0 + # Charge data is sorted by maxEffectiveRange descending + return chargeData[0]['maxEffectiveRange'] + + +# ============================================================================= +# Applied Volley Calculation +# ============================================================================= + +def calculateRangeFactor(distance, lowerRange, higherRange, higherChance): + """ + Calculate range factor for missile at a distance. + + Args: + distance: Distance to target (m) + lowerRange: Range at floor(flightTime) + higherRange: Range at ceil(flightTime) + higherChance: Probability of flying the extra second + + Returns: + Range factor (0, higherChance, or 1) + """ + if distance <= lowerRange: + return 1.0 + elif distance <= higherRange: + return higherChance + else: + return 0.0 + + +def calculateAppliedVolley(chargeData, distance, tgtSpeed, tgtSigRadius): + """ + Calculate applied volley for a missile charge at a distance. + + Args: + chargeData: Single charge data dict + distance: Distance to target (m) + tgtSpeed: Target velocity (m/s) - can be modified by webs + tgtSigRadius: Target signature radius (m) - can be modified by TPs + + Returns: + Applied volley (damage accounting for range and application) + """ + # Range factor (discrete: 1, higherChance, or 0) + rangeFactor = calculateRangeFactor( + distance, + chargeData['lowerRange'], + chargeData['higherRange'], + chargeData['higherChance'] + ) + + if rangeFactor == 0: + return 0 + + # Application factor + appFactor = calcMissileFactor( + chargeData['explosionRadius'], + chargeData['explosionVelocity'], + chargeData['damageReductionFactor'], + tgtSpeed, + tgtSigRadius + ) + + return chargeData['raw_volley'] * rangeFactor * appFactor + + +def volleyToDps(volley, cycleTimeMs): + """ + Convert volley to DPS. + + Args: + volley: Damage per shot + cycleTimeMs: Cycle time in milliseconds + + Returns: + DPS (damage per second) + """ + if cycleTimeMs <= 0: + return 0 + return volley / (cycleTimeMs / 1000) + + +# ============================================================================= +# Best Charge Finding +# ============================================================================= + +def findBestCharge(chargeData, distance, tgtSpeed, tgtSigRadius): + """ + Find the best missile charge at a distance. + + Uses damage type priority (EM > Thermal > Kinetic > Explosive) as tie-breaker. + + Args: + chargeData: List of charge data dicts + distance: Distance to target (m) + tgtSpeed: Target velocity (m/s) + tgtSigRadius: Target signature radius (m) + + Returns: + Tuple of (best_volley, best_name, best_index) + """ + bestVolley = 0 + bestName = None + bestIndex = 0 + bestPriority = 99 + + for i, cd in enumerate(chargeData): + volley = calculateAppliedVolley(cd, distance, tgtSpeed, tgtSigRadius) + + # Tie-break: higher volley wins; if equal, lower damage_priority wins + if volley > bestVolley or (volley == bestVolley and volley > 0 and cd['damage_priority'] < bestPriority): + bestVolley = volley + bestName = cd['name'] + bestIndex = i + bestPriority = cd['damage_priority'] + + return bestVolley, bestName, bestIndex + + +# ============================================================================= +# Transition Point Calculation +# ============================================================================= + +def _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, distance): + """ + Update target params using projected cache for webs/TPs. + + Args: + baseTgtSpeed: Base target speed (from graph params) + baseTgtSigRadius: Base target sig radius + projectedCache: Pre-built cache from buildProjectedCache() + distance: Distance in meters + + Returns: + Tuple of (tgtSpeed, tgtSigRadius) with projected effects applied + """ + projected = getProjectedParamsAtDistance(projectedCache, distance) + return projected['tgtSpeed'], projected['tgtSigRadius'] + + +def calculateTransitions(chargeData, baseTgtSpeed, baseTgtSigRadius, + projectedCache, maxDistance=300000, resolution=100): + """ + Calculate distances where optimal missile ammo changes. + + Args: + chargeData: List of charge data dicts + baseTgtSpeed: Base target speed (from graph params) + baseTgtSigRadius: Base target sig radius + projectedCache: Pre-built cache for webs/TPs + maxDistance: Maximum distance to scan (m) + resolution: Distance interval (m) + + Returns: + List of tuples: [(distance, charge_index, charge_name, volley), ...] + """ + if not chargeData: + return [] + + transitions = [] + currentCharge = None + + # Start at distance 0 + tgtSpeed, tgtSigRadius = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, 0) + bestVolley, bestName, bestIdx = findBestCharge(chargeData, 0, tgtSpeed, tgtSigRadius) + transitions.append((0, bestIdx, bestName, bestVolley)) + currentCharge = bestName + + # Scan for transitions + distance = resolution + while distance <= maxDistance: + tgtSpeed, tgtSigRadius = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, distance) + bestVolley, bestName, bestIdx = findBestCharge(chargeData, distance, tgtSpeed, tgtSigRadius) + + if bestName != currentCharge: + # Binary search for exact transition point + low, high = distance - resolution, distance + while high - low > 10: + mid = (low + high) // 2 + midSpeed, midSig = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, mid) + _, midName, _ = findBestCharge(chargeData, mid, midSpeed, midSig) + if midName == currentCharge: + low = mid + else: + high = mid + + # Get volley at transition + highSpeed, highSig = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, high) + bestVolley, _, _ = findBestCharge(chargeData, high, highSpeed, highSig) + + transitions.append((high, bestIdx, bestName, bestVolley)) + currentCharge = bestName + + # Stop if we're past all missile ranges + if bestVolley < 0.01: + transitions.append((distance, -1, None, 0)) + break + + distance += resolution + + return transitions + + +# ============================================================================= +# Query Functions +# ============================================================================= + +def getVolleyAtDistance(transitions, chargeData, distance, + baseTgtSpeed, baseTgtSigRadius, projectedCache): + """ + Get applied volley at a specific distance. + + Args: + transitions: List of transition tuples + chargeData: List of charge data dicts + distance: Distance to query (m) + baseTgtSpeed: Base target speed + baseTgtSigRadius: Base target sig radius + projectedCache: Pre-built projected cache + + Returns: + Tuple of (volley, charge_name) + """ + if not transitions or not chargeData: + return 0, None + + # Find which charge is optimal at this distance + distances = [t[0] for t in transitions] + idx = bisect_right(distances, distance) - 1 + if idx < 0: + idx = 0 + + chargeIdx = transitions[idx][1] + if chargeIdx < 0 or chargeIdx >= len(chargeData): + return 0, None + + cd = chargeData[chargeIdx] + + # Calculate exact volley with projected effects + tgtSpeed, tgtSigRadius = _updateParamsWithCache(baseTgtSpeed, baseTgtSigRadius, projectedCache, distance) + volley = calculateAppliedVolley(cd, distance, tgtSpeed, tgtSigRadius) + + return volley, cd['name'] + diff --git a/graphs/data/fitApplicationProfile/calc/optimize_ammo.py b/graphs/data/fitApplicationProfile/calc/optimize_ammo.py new file mode 100644 index 0000000000..b93093b8c3 --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/optimize_ammo.py @@ -0,0 +1,216 @@ +# ============================================================================= +# 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 bisect import bisect_right + +from logbook import Logger + +from .turret import calculateAppliedVolley +from .projected import getProjectedParamsAtDistance + + +pyfalog = Logger(__name__) + + +# ============================================================================= +# Utility Functions +# ============================================================================= + +def volleyToDps(volley, cycleTimeMs): + """ + Convert volley to DPS. + + Args: + volley: Damage per shot + cycleTimeMs: Cycle time in milliseconds + + Returns: + DPS (damage per second) + """ + if cycleTimeMs <= 0: + return 0 + return volley / (cycleTimeMs / 1000) + + +# ============================================================================= +# Best Charge Finding +# ============================================================================= + +def findBestCharge(chargeData, distance, turretBase, trackingParams): + """ + Find the best charge at a distance based on applied volley. + + Args: + chargeData: List of charge data dicts + distance: Surface-to-surface distance (m) + turretBase: Base turret stats dict + trackingParams: Tracking params dict or None for perfect tracking + + Returns: + Tuple of (best_volley, best_name, best_index) + """ + bestVolley = 0 + bestName = None + bestIndex = 0 + + for i, cd in enumerate(chargeData): + volley = calculateAppliedVolley(cd, distance, turretBase, trackingParams) + if volley > bestVolley: + bestVolley = volley + bestName = cd['name'] + bestIndex = i + + return bestVolley, bestName, bestIndex + + +# ============================================================================= +# Transition Point Calculation +# ============================================================================= + +def _updateTrackingWithCache(baseTrackingParams, projectedCache, distance): + """ + Fast update of tracking params using pre-built projected cache. + + This is the performance-critical inner loop optimization - instead of + calling getTackledSpeed/getSigRadiusMult 300+ times, we do a single + cache lookup. + + Args: + baseTrackingParams: Base tracking params dict (or None for perfect tracking) + projectedCache: Cache from buildProjectedCache() + distance: Distance (m) + + Returns: + Updated tracking params dict with cached tgtSpeed/tgtSigRadius + """ + if baseTrackingParams is None: + return None + + params = baseTrackingParams.copy() + projected = getProjectedParamsAtDistance(projectedCache, distance) + params['tgtSpeed'] = projected['tgtSpeed'] + params['tgtSigRadius'] = projected['tgtSigRadius'] + return params + + +def calculateTransitions(chargeData, turretBase, baseTrackingParams, + projectedCache, + maxDistance=300000, resolution=100): + """ + Calculate distances where optimal ammo changes. + + Uses coarse resolution for scanning, then binary search for exact + transition points. This is much faster than fine-grained scanning. + + PERFORMANCE: Uses projectedCache for O(1) lookup of target speed/sig + at each distance, avoiding expensive getTackledSpeed/getSigRadiusMult calls. + + Args: + chargeData: List of charge data dicts + turretBase: Base turret stats dict + baseTrackingParams: Base tracking params dict (with base tgtSpeed/tgtSigRadius) + projectedCache: Pre-built cache from buildProjectedCache() + maxDistance: Maximum distance to scan (m) + resolution: Distance interval (m) + + Returns: + List of tuples: [(distance, charge_index, charge_name, volley), ...] + """ + if not chargeData: + return [] + + transitions = [] + currentCharge = None + + # Start at distance 0 + params0 = _updateTrackingWithCache(baseTrackingParams, projectedCache, 0) + bestVolley, bestName, bestIdx = findBestCharge(chargeData, 0, turretBase, params0) + transitions.append((0, bestIdx, bestName, bestVolley)) + currentCharge = bestName + + # Scan for transitions + distance = resolution + while distance <= maxDistance: + params = _updateTrackingWithCache(baseTrackingParams, projectedCache, distance) + bestVolley, bestName, bestIdx = findBestCharge(chargeData, distance, turretBase, params) + + if bestName != currentCharge: + # Binary search for exact transition point + low, high = distance - resolution, distance + while high - low > 10: + mid = (low + high) // 2 + paramsMid = _updateTrackingWithCache(baseTrackingParams, projectedCache, mid) + _, midName, _ = findBestCharge(chargeData, mid, turretBase, paramsMid) + if midName == currentCharge: + low = mid + else: + high = mid + + # Get volley at transition + paramsHigh = _updateTrackingWithCache(baseTrackingParams, projectedCache, high) + bestVolley, _, _ = findBestCharge(chargeData, high, turretBase, paramsHigh) + + transitions.append((high, bestIdx, bestName, bestVolley)) + currentCharge = bestName + + distance += resolution + + return transitions + + +# ============================================================================= +# Query Functions +# ============================================================================= + +def getVolleyAtDistance(transitions, chargeData, turretBase, distance, + baseTrackingParams, projectedCache): + """ + Get applied volley at a specific distance. + + Uses transitions for O(log n) charge lookup, then calculates exact volley + using the pre-built projected cache for target speed/sig. + + Args: + transitions: List of transition tuples from calculateTransitions + chargeData: List of charge data dicts + turretBase: Base turret stats dict + distance: Distance to query (m) + baseTrackingParams: Base tracking params dict + projectedCache: Pre-built cache from buildProjectedCache() + + Returns: + Tuple of (volley, charge_name) + """ + if not transitions: + return 0, None + + # Find which charge is optimal at this distance + distances = [t[0] for t in transitions] + idx = bisect_right(distances, distance) - 1 + if idx < 0: + idx = 0 + + chargeIdx = transitions[idx][1] + cd = chargeData[chargeIdx] + + # Calculate exact volley with projected effects from cache + params = _updateTrackingWithCache(baseTrackingParams, projectedCache, distance) + volley = calculateAppliedVolley(cd, distance, turretBase, params) + + return volley, cd['name'] diff --git a/graphs/data/fitApplicationProfile/calc/projected.py b/graphs/data/fitApplicationProfile/calc/projected.py new file mode 100644 index 0000000000..8f19d2ae1f --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/projected.py @@ -0,0 +1,243 @@ +# ============================================================================= +# 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 math +from bisect import bisect_right + +from eos.calc import calculateRangeFactor +from eos.utils.float import floatUnerr +from graphs.calc import checkLockRange, checkDroneControlRange +from service.const import GraphDpsDroneMode +from service.settings import GraphSettings + + +# ============================================================================= +# Re-exports from fitDamageStats for convenience +# ============================================================================= + +from graphs.data.fitDamageStats.calc.projected import ( + getScramRange, + getScrammables, + getTackledSpeed, + getSigRadiusMult, +) + + +# ============================================================================= +# Distance-Keyed Projected Cache +# ============================================================================= + +def buildProjectedCache(src, tgt, commonData, baseTgtSpeed, baseTgtSigRadius, + maxDistance=300000, resolution=100, existingCache=None): + """ + Build a distance-keyed cache of target speed and signature radius. + + This pre-computes the expensive getTackledSpeed() and getSigRadiusMult() + calls at regular intervals, allowing O(1) lookup during ammo optimization. + + If an existingCache is provided and the target hasn't changed (same base + speed/sig), we extend it rather than rebuild from scratch. + + Args: + src: Source fit wrapper + tgt: Target wrapper + commonData: Dict with projected effect data (webMods, tpMods, etc.) + baseTgtSpeed: Base (untackled) target speed + baseTgtSigRadius: Base target signature radius + maxDistance: Maximum distance to cache (m) + resolution: Distance interval (m) + existingCache: Optional existing cache to extend (if target unchanged) + + Returns: + Dict with: + 'distances': sorted list of distance keys + 'cache': {distance: {'tgtSpeed': float, 'tgtSigRadius': float}} + 'hasProjected': bool - whether projected effects are applied + 'maxCachedDistance': int - highest distance in cache + """ + applyProjected = commonData.get('applyProjected', False) + + # If no projected effects, return a simple cache with base values + if not applyProjected: + return { + 'distances': [], + 'cache': {}, + 'hasProjected': False, + 'baseTgtSpeed': baseTgtSpeed, + 'baseTgtSigRadius': baseTgtSigRadius, + 'maxCachedDistance': 0 + } + + # Check if we can extend an existing cache + # NOTE: Vector angles are now included in the projectedCacheKey (in getter.py) + # so this cache is already isolated per vector configuration. We only need to + # check if the base target parameters match. + canExtend = ( + existingCache is not None and + existingCache.get('hasProjected', False) and + existingCache.get('baseTgtSpeed') == baseTgtSpeed and + existingCache.get('baseTgtSigRadius') == baseTgtSigRadius + ) + + if canExtend: + existingMax = existingCache.get('maxCachedDistance', 0) + + # If existing cache already covers our needed range, just return it + if existingMax >= maxDistance: + return existingCache + + # Otherwise, extend the existing cache + distances = existingCache['distances'].copy() + cache = existingCache['cache'].copy() + startDistance = existingMax + resolution + else: + distances = [] + cache = {} + startDistance = 0 + + # Extract projected data from commonData + srcScramRange = commonData.get('srcScramRange', 0) + tgtScrammables = commonData.get('tgtScrammables', ()) + webMods = commonData.get('webMods', ()) + webDrones = commonData.get('webDrones', ()) + webFighters = commonData.get('webFighters', ()) + tpMods = commonData.get('tpMods', ()) + tpDrones = commonData.get('tpDrones', ()) + tpFighters = commonData.get('tpFighters', ()) + + distance = startDistance + entriesAdded = 0 + while distance <= maxDistance: + # Calculate tackled speed at this distance + tackledSpeed = getTackledSpeed( + src=src, + tgt=tgt, + currentUntackledSpeed=baseTgtSpeed, + srcScramRange=srcScramRange, + tgtScrammables=tgtScrammables, + webMods=webMods, + webDrones=webDrones, + webFighters=webFighters, + distance=distance + ) + + # Calculate sig radius multiplier at this distance + sigMult = getSigRadiusMult( + src=src, + tgt=tgt, + tgtSpeed=tackledSpeed, + srcScramRange=srcScramRange, + tgtScrammables=tgtScrammables, + tpMods=tpMods, + tpDrones=tpDrones, + tpFighters=tpFighters, + distance=distance + ) + + distances.append(distance) + cache[distance] = { + 'tgtSpeed': tackledSpeed, + 'tgtSigRadius': baseTgtSigRadius * sigMult + } + + distance += resolution + entriesAdded += 1 + + # Ensure distances list is sorted (should already be, but safe to ensure) + distances.sort() + + return { + 'distances': distances, + 'cache': cache, + 'hasProjected': True, + 'baseTgtSpeed': baseTgtSpeed, + 'baseTgtSigRadius': baseTgtSigRadius, + 'maxCachedDistance': distances[-1] if distances else 0 + } + + +def getProjectedParamsAtDistance(projectedCache, distance, interpolate=True): + """ + Get target speed and sig radius at a distance from the pre-built cache. + + Uses linear interpolation between cache entries for smoother curves, + especially important for grapples/webs with falloff mechanics. + + Args: + projectedCache: Cache dict from buildProjectedCache() + distance: Distance to query (m) + interpolate: If True, interpolate between cache entries (default) + + Returns: + Dict with 'tgtSpeed' and 'tgtSigRadius' + """ + if not projectedCache.get('hasProjected', False): + # No projected effects - return base values + return { + 'tgtSpeed': projectedCache.get('baseTgtSpeed', 0), + 'tgtSigRadius': projectedCache.get('baseTgtSigRadius', 0) + } + + distances = projectedCache.get('distances', []) + cache = projectedCache.get('cache', {}) + + if not distances: + return { + 'tgtSpeed': projectedCache.get('baseTgtSpeed', 0), + 'tgtSigRadius': projectedCache.get('baseTgtSigRadius', 0) + } + + # Find position in sorted distances + idx = bisect_right(distances, distance) - 1 + + # Clamp to valid range + if idx < 0: + idx = 0 + if idx >= len(distances) - 1: + # At or beyond the last cached distance + distKey = distances[-1] + return cache[distKey] + + # Get bounding distances + distLow = distances[idx] + distHigh = distances[idx + 1] + + # If not interpolating or exact match, return lower bound + if not interpolate or distance <= distLow: + return cache[distLow] + + # Linear interpolation + cacheLow = cache[distLow] + cacheHigh = cache[distHigh] + + # Calculate interpolation factor (0-1) + t = (distance - distLow) / (distHigh - distLow) if distHigh > distLow else 0 + + # Interpolate both speed and sig radius + # Handle infinity properly - if either value is inf, result should be inf + tgtSpeed = cacheLow['tgtSpeed'] + t * (cacheHigh['tgtSpeed'] - cacheLow['tgtSpeed']) + if cacheLow['tgtSigRadius'] == float('inf') or cacheHigh['tgtSigRadius'] == float('inf'): + tgtSigRadius = float('inf') + else: + tgtSigRadius = cacheLow['tgtSigRadius'] + t * (cacheHigh['tgtSigRadius'] - cacheLow['tgtSigRadius']) + + return { + 'tgtSpeed': tgtSpeed, + 'tgtSigRadius': tgtSigRadius + } diff --git a/graphs/data/fitApplicationProfile/calc/turret.py b/graphs/data/fitApplicationProfile/calc/turret.py new file mode 100644 index 0000000000..6f9014667c --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/turret.py @@ -0,0 +1,187 @@ +# ============================================================================= +# 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 math + +from eos.calc import calculateRangeFactor + + +# ============================================================================= +# Angular Speed +# ============================================================================= + +def calcAngularSpeed(atkSpeed, atkAngle, atkRadius, distance, tgtSpeed, tgtAngle, tgtRadius): + """ + Calculate angular speed (rad/s) between attacker and target. + """ + if distance is None: + return 0 + + atkAngleRad = atkAngle * math.pi / 180 + tgtAngleRad = tgtAngle * math.pi / 180 + + ctcDistance = atkRadius + distance + tgtRadius + + + transSpeed = abs(atkSpeed * math.sin(atkAngleRad) - tgtSpeed * math.sin(tgtAngleRad)) + + if ctcDistance == 0: + return 0 if transSpeed == 0 else math.inf + else: + return transSpeed / ctcDistance + + +def calcTrackingFactor(tracking, optimalSigRadius, angularSpeed, tgtSigRadius): + """ + Calculate the tracking factor component of chance to hit. + """ + if tracking <= 0 or tgtSigRadius <= 0: + return 0 + if angularSpeed <= 0: + return 1.0 + + exponent = (angularSpeed * optimalSigRadius) / (tracking * tgtSigRadius) + return 0.5 ** (exponent ** 2) + + +# def calcTrackingFactor(atkTracking, atkOptimalSigRadius, angularSpeed, tgtSigRadius): +# """Calculate tracking chance to hit component.""" +# return 0.5 ** (((angularSpeed * atkOptimalSigRadius) / (atkTracking * tgtSigRadius)) ** 2) + + +def calcTurretDamageMult(chanceToHit): + """ + Calculate turret damage multiplier from chance to hit. + """ + # https://wiki.eveuniversity.org/Turret_mechanics#Damage + wreckingChance = min(chanceToHit, 0.01) + wreckingPart = wreckingChance * 3 + normalChance = chanceToHit - wreckingChance + if normalChance > 0: + avgDamageMult = (0.01 + chanceToHit) / 2 + 0.49 + normalPart = normalChance * avgDamageMult + else: + normalPart = 0 + + totalMult = normalPart + wreckingPart + return totalMult + + +def getTurretBaseStats(mod): + """ + Get turret stats with ship/skill bonuses but WITHOUT charge modifiers. + """ + # Get the modified values (includes charge effects if charge is loaded) + optimal = mod.getModifiedItemAttr('maxRange') or 0 + falloff = mod.getModifiedItemAttr('falloff') or 0 + tracking = mod.getModifiedItemAttr('trackingSpeed') or 0 + optimalSigRadius = mod.getModifiedItemAttr('optimalSigRadius') or 0 + damageMult = mod.getModifiedItemAttr('damageMultiplier') or 1 + + # If a charge is loaded, undo its range/falloff/tracking multiplier effects + # Charges multiply these stats, so we divide them out to get base stats + if mod.charge: + chargeRangeMult = mod.charge.getAttribute('weaponRangeMultiplier') or 1 + chargeFalloffMult = mod.charge.getAttribute('fallofMultiplier') or 1 # EVE typo + chargeTrackingMult = mod.charge.getAttribute('trackingSpeedMultiplier') or 1 + + if chargeRangeMult != 0: + optimal = optimal / chargeRangeMult + if chargeFalloffMult != 0: + falloff = falloff / chargeFalloffMult + if chargeTrackingMult != 0: + tracking = tracking / chargeTrackingMult + + return { + 'optimal': optimal, + 'falloff': falloff, + 'tracking': tracking, + 'optimalSigRadius': optimalSigRadius, + 'damageMultiplier': damageMult + } + + +def getSkillMultiplier(mod): + """ + Get the skill-based damage multiplier for a turret. + """ + charge = mod.charge + if not charge: + return 1.0 + + baseDamage = ( + (charge.getAttribute('emDamage') or 0) + + (charge.getAttribute('thermalDamage') or 0) + + (charge.getAttribute('kineticDamage') or 0) + + (charge.getAttribute('explosiveDamage') or 0) + ) + + if baseDamage <= 0: + return 1.0 + + modifiedDamage = ( + (mod.getModifiedChargeAttr('emDamage') or 0) + + (mod.getModifiedChargeAttr('thermalDamage') or 0) + + (mod.getModifiedChargeAttr('kineticDamage') or 0) + + (mod.getModifiedChargeAttr('explosiveDamage') or 0) + ) + + return modifiedDamage / baseDamage if baseDamage > 0 else 1.0 + + +def calculateAppliedVolley(chargeData, distance, turretBase, trackingParams): + """ + Calculate applied volley for a charge at a distance. + """ + # Range factor + if distance <= chargeData['effective_optimal']: + rangeFactor = 1.0 + else: + rangeFactor = calculateRangeFactor( + chargeData['effective_optimal'], + chargeData['effective_falloff'], + distance, + restrictedRange=False + ) + + # Tracking factor + if trackingParams is None: + trackingFactor = 1.0 + else: + angularSpeed = calcAngularSpeed( + trackingParams['atkSpeed'], + trackingParams['atkAngle'], + trackingParams['atkRadius'], + distance, + trackingParams['tgtSpeed'], + trackingParams['tgtAngle'], + trackingParams['tgtRadius'] + ) + trackingFactor = calcTrackingFactor( + chargeData['effective_tracking'], + turretBase['optimalSigRadius'], + angularSpeed, + trackingParams['tgtSigRadius'] + ) + + # Chance to hit and damage multiplier + cth = rangeFactor * trackingFactor + damageMult = calcTurretDamageMult(cth) + + return chargeData['raw_volley'] * damageMult diff --git a/graphs/data/fitApplicationProfile/calc/valid_charges.py b/graphs/data/fitApplicationProfile/calc/valid_charges.py new file mode 100644 index 0000000000..015666d6f5 --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/valid_charges.py @@ -0,0 +1,79 @@ +# ============================================================================= +# 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.db +from eos.gamedata import Item + + +# Class-level cache for valid charges: {itemID: set(charges)} +# This prevents repeated DB queries for the same module type +_validChargesCache = {} + + +def getValidChargesForModule(module): + """ + Get all valid charges for a module using optimized database query. + + This is a performance-optimized version for graph calculations that: + 1. Uses class-level caching to prevent repeated queries for the same module type + 2. Uses direct SQLAlchemy queries instead of eager loading full groups + 3. Only validates published items that match the charge groups + + Args: + module: The Module instance to get valid charges for + + Returns: + set: Set of valid Item instances that can be used as charges + """ + # Check class-level cache first + if module.item.ID in _validChargesCache: + return _validChargesCache[module.item.ID].copy() + + # Collect all charge group IDs for this module + chargeGroupIDs = [] + for i in range(5): + itemChargeGroup = module.getModifiedItemAttr('chargeGroup' + str(i), None) + if itemChargeGroup: + chargeGroupIDs.append(int(itemChargeGroup)) + + if not chargeGroupIDs: + _validChargesCache[module.item.ID] = set() + return set() + + # Query only published items from the relevant charge groups + # This is much more efficient than loading entire groups with all attributes + session = eos.db.get_gamedata_session() + + # Query published items in the relevant groups + # Note: We let attributes lazy-load only when needed by isValidCharge() + items = session.query(Item).filter( + Item.groupID.in_(chargeGroupIDs), + Item.published == True + ).all() + + # Validate each item with the module's size/capacity constraints + validCharges = set() + for item in items: + if module.isValidCharge(item): + validCharges.add(item) + + # Store in class-level cache + _validChargesCache[module.item.ID] = validCharges + return validCharges.copy() + diff --git a/graphs/data/fitApplicationProfile/getter.py b/graphs/data/fitApplicationProfile/getter.py new file mode 100644 index 0000000000..54bcb1f80b --- /dev/null +++ b/graphs/data/fitApplicationProfile/getter.py @@ -0,0 +1,969 @@ +# ============================================================================= +# 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 eos.const import FittingHardpoint +from logbook import Logger + +from graphs.data.base.getter import SmoothPointGetter +from graphs.data.fitDamageStats.calc.projected import ( + getScramRange, getScrammables +) +from service.settings import GraphSettings +from .calc.valid_charges import getValidChargesForModule + +from .calc.turret import ( + getTurretBaseStats, + getSkillMultiplier +) +from .calc.charges import ( + filterChargesByQuality, + precomputeChargeData, + getLongestRangeMultiplier +) +from .calc.optimize_ammo import ( + volleyToDps, + calculateTransitions, + getVolleyAtDistance +) +from .calc.projected import ( + buildProjectedCache +) +from .calc.launcher import ( + getAllMultipliers as getLauncherMultipliers, + precomputeMissileChargeData, + getMaxEffectiveRange as getMissileMaxEffectiveRange, + calculateTransitions as calculateMissileTransitions, + getVolleyAtDistance as getMissileVolleyAtDistance, + volleyToDps as missileVolleyToDps +) + + +pyfalog = Logger(__name__) + + +# ============================================================================= +# Max Effective Range Calculation +# ============================================================================= + +def getMaxEffectiveRange(turretBase, charges): + """ + Calculate the max effective range for a turret with its available charges. + + Formula: optimal * longestRangeMult + falloff * 3.1 + + At falloff * 3.1, the range factor is ~0.5% (negligible damage). + + Args: + turretBase: Base turret stats dict from getTurretBaseStats + charges: List of charge items + + Returns: + Max effective range in meters + """ + longestRangeMult = getLongestRangeMultiplier(charges) + effectiveOptimal = turretBase['optimal'] * longestRangeMult + effectiveMaxRange = effectiveOptimal + turretBase['falloff'] * 3.1 + return int(effectiveMaxRange) + + +def getTurretRangeInfo(mod, qualityTier, chargeCache=None): + """ + Get turret base stats and max effective range without computing transitions. + + This is used in the first pass to determine how far the projected cache + needs to extend. + + Args: + mod: The turret module + qualityTier: 't1', 'navy', or 'all' + chargeCache: Optional cache dict for getValidCharges results + + Returns: + Dict with turret_base, charges, max_effective_range, cycle_time_ms + Or None if turret has no valid charges + """ + # Get turret base stats + turretBase = getTurretBaseStats(mod) + + # Get cycle time + cycleParams = mod.getCycleParameters() + if cycleParams is None: + return None + cycleTimeMs = cycleParams.averageTime + + # Get and filter charges - use cache if available + chargeCacheKey = (mod.item.ID, qualityTier) + if chargeCache is not None and chargeCacheKey in chargeCache: + charges = chargeCache[chargeCacheKey] + else: + allCharges = list(getValidChargesForModule(mod)) + charges = filterChargesByQuality(allCharges, qualityTier) + if chargeCache is not None: + chargeCache[chargeCacheKey] = charges + + if not charges: + return None + + # Calculate max effective range + maxEffectiveRange = getMaxEffectiveRange(turretBase, charges) + + return { + 'turret_base': turretBase, + 'charges': charges, + 'max_effective_range': maxEffectiveRange, + 'cycle_time_ms': cycleTimeMs + } + + +# ============================================================================= +# Launcher Max Range Functions +# ============================================================================= + +def getLauncherRangeInfo(mod, qualityTier, shipRadius, chargeCache=None): + """ + Get launcher stats and max effective range without computing transitions. + + This is used in the first pass to determine how far the projected cache + needs to extend. + + Args: + mod: The launcher module + qualityTier: 't1', 'navy', or 'all' + shipRadius: Ship radius for flight time bonus + chargeCache: Optional cache dict for getValidCharges results + + Returns: + Dict with charges, max_effective_range, cycle_time_ms, and multipliers + Or None if launcher has no valid charges + """ + # Get cycle time + cycleParams = mod.getCycleParameters() + if cycleParams is None: + return None + cycleTimeMs = cycleParams.averageTime + + # Get and filter charges - use cache if available + chargeCacheKey = (mod.item.ID, qualityTier) + if chargeCache is not None and chargeCacheKey in chargeCache: + charges = chargeCache[chargeCacheKey] + else: + allCharges = list(getValidChargesForModule(mod)) + charges = filterChargesByQuality(allCharges, qualityTier) + if chargeCache is not None: + chargeCache[chargeCacheKey] = charges + + if not charges: + return None + + # Get multipliers from the currently loaded charge (or first valid charge) + damageMults, flightMults, appMults = getLauncherMultipliers(mod) + + # Get launcher damage multiplier + launcherDamageMult = mod.getModifiedItemAttr('damageMultiplier') or 1 + + # Precompute charge data to determine max effective range + chargeData = precomputeMissileChargeData( + mod, charges, cycleTimeMs, shipRadius, + damageMults, flightMults, appMults, + tgtResists=None # Don't filter by resists for range calculation + ) + + if not chargeData: + return None + + # Max effective range is from the longest-range charge + maxEffectiveRange = getMissileMaxEffectiveRange(chargeData) + + return { + 'charges': charges, + 'charge_data': chargeData, # Cache the precomputed data + 'max_effective_range': maxEffectiveRange, + 'cycle_time_ms': cycleTimeMs, + 'damage_mults': damageMults, + 'flight_mults': flightMults, + 'app_mults': appMults, + 'launcher_damage_mult': launcherDamageMult + } + + +# ============================================================================= +# Dominant Group Detection +# ============================================================================= + +def countWeaponGroups(src): + """ + Count turrets and launchers on the source fit. + + Args: + src: Source fit wrapper + + Returns: + Tuple of (turret_count, launcher_count) + """ + turretCount = 0 + launcherCount = 0 + + for mod in src.item.activeModulesIter(): + # Skip mining lasers + if mod.getModifiedItemAttr('miningAmount'): + continue + + if mod.hardpoint == FittingHardpoint.TURRET: + turretCount += 1 + elif mod.hardpoint == FittingHardpoint.MISSILE: + launcherCount += 1 + + return turretCount, launcherCount + + +def getDominantWeaponType(src): + """ + Determine which weapon type dominates on the fit. + + Args: + src: Source fit wrapper + + Returns: + 'turret', 'launcher', or None (if no weapons) + """ + turretCount, launcherCount = countWeaponGroups(src) + + if turretCount == 0 and launcherCount == 0: + return None + + # Turrets win ties (arbitrary, but consistent) + if turretCount >= launcherCount: + return 'turret' + else: + return 'launcher' + + +# ============================================================================= +# Cache Building +# ============================================================================= + +def buildTurretCacheEntry(mod, qualityTier, tgtResists, baseTrackingParams, + projectedCache, chargeCache=None, rangeInfo=None): + """ + Build a complete cache entry for a single turret type. + + Args: + mod: The turret module + qualityTier: 't1', 'navy', or 'all' + tgtResists: Target resists tuple or None + baseTrackingParams: Base tracking params dict + projectedCache: Pre-built cache from buildProjectedCache() + chargeCache: Optional cache dict for getValidCharges results + rangeInfo: Optional pre-computed range info from getTurretRangeInfo + + Returns: + Dict with charge_data, transitions, turret_base, cycle_time_ms + Or None if turret has no valid charges + """ + # Use pre-computed range info if available, otherwise compute now + if rangeInfo is not None: + turretBase = rangeInfo['turret_base'] + charges = rangeInfo['charges'] + cycleTimeMs = rangeInfo['cycle_time_ms'] + else: + turretBase = getTurretBaseStats(mod) + cycleParams = mod.getCycleParameters() + if cycleParams is None: + return None + cycleTimeMs = cycleParams.averageTime + + # Get and filter charges + chargeCacheKey = (mod.item.ID, qualityTier) + if chargeCache is not None and chargeCacheKey in chargeCache: + charges = chargeCache[chargeCacheKey] + else: + allCharges = list(getValidChargesForModule(mod)) + charges = filterChargesByQuality(allCharges, qualityTier) + if chargeCache is not None: + chargeCache[chargeCacheKey] = charges + + if not charges: + return None + + if not charges: + return None + + # Get skill multiplier + skillMult = getSkillMultiplier(mod) + + # Precompute charge data + chargeData = precomputeChargeData(turretBase, charges, skillMult, tgtResists) + + # Calculate max effective range for this turret (after charge filtering) + # Use the precomputed chargeData to get the longest range + maxEffectiveOptimal = max(cd['effective_optimal'] for cd in chargeData) + maxEffectiveFalloff = max(cd['effective_falloff'] for cd in chargeData) + maxEffectiveRange = int(maxEffectiveOptimal + maxEffectiveFalloff * 3.1) + + # Calculate transitions using the pre-built projected cache + # Only scan up to this turret's max effective range + transitions = calculateTransitions( + chargeData, turretBase, baseTrackingParams, + projectedCache, + maxDistance=maxEffectiveRange + ) + + return { + 'charge_data': chargeData, + 'transitions': transitions, + 'turret_base': turretBase, + 'cycle_time_ms': cycleTimeMs, + 'count': 1 + } + + +def buildLauncherCacheEntry(mod, qualityTier, tgtResists, shipRadius, + baseTgtSpeed, baseTgtSigRadius, + projectedCache, chargeCache=None, rangeInfo=None): + """ + Build a complete cache entry for a single launcher type. + + + Args: + mod: The launcher module + qualityTier: 't1', 'navy', or 'all' + tgtResists: Target resists tuple or None + shipRadius: Ship radius for flight time bonus + baseTgtSpeed: Base target speed (from params) + baseTgtSigRadius: Base target sig radius + projectedCache: Pre-built cache from buildProjectedCache() + chargeCache: Optional cache dict for getValidCharges results + rangeInfo: Optional pre-computed range info from getLauncherRangeInfo + + Returns: + Dict with charge_data, transitions, cycle_time_ms + Or None if launcher has no valid charges + """ + # Use pre-computed range info if available, otherwise compute now + if rangeInfo is not None: + charges = rangeInfo['charges'] + # chargeData = rangeInfo['charge_data'] # Don't use cached data (it ignores resists) + cycleTimeMs = rangeInfo['cycle_time_ms'] + damageMults = rangeInfo['damage_mults'] + flightMults = rangeInfo['flight_mults'] + appMults = rangeInfo['app_mults'] + else: + cycleParams = mod.getCycleParameters() + if cycleParams is None: + return None + cycleTimeMs = cycleParams.averageTime + + # Get and filter charges + chargeCacheKey = (mod.item.ID, qualityTier) + if chargeCache is not None and chargeCacheKey in chargeCache: + charges = chargeCache[chargeCacheKey] + else: + allCharges = list(getValidChargesForModule(mod)) + charges = filterChargesByQuality(allCharges, qualityTier) + if chargeCache is not None: + chargeCache[chargeCacheKey] = charges + + if not charges: + return None + + # Get multipliers from the currently loaded charge + damageMults, flightMults, appMults = getLauncherMultipliers(mod) + + # Precompute charge data with current resists + chargeData = precomputeMissileChargeData( + mod, charges, cycleTimeMs, shipRadius, + damageMults, flightMults, appMults, tgtResists + ) + + if not chargeData: + return None + + # Calculate max effective range from precomputed data + maxEffectiveRange = getMissileMaxEffectiveRange(chargeData) + + # Calculate transitions using the pre-built projected cache + transitions = calculateMissileTransitions( + chargeData, baseTgtSpeed, baseTgtSigRadius, + projectedCache, + maxDistance=int(maxEffectiveRange) + ) + + return { + 'charge_data': chargeData, + 'transitions': transitions, + 'cycle_time_ms': cycleTimeMs, + 'count': 1 + } + + +# ============================================================================= +# Y-Axis Mixins +# ============================================================================= + +class YOptimalAmmoDpsMixin: + """Y-axis mixin: Calculate DPS using optimal ammo selection.""" + + def _getOptimalDpsAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType): + """Get total DPS with optimal ammo at a specific distance.""" + totalDps = 0 + + if weaponType == 'turret': + for group_id, groupInfo in weaponCache.items(): + volley, _ = getVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + groupInfo['turret_base'], + distance, + trackingParams, + projectedCache + ) + dps = volleyToDps(volley, groupInfo['cycle_time_ms']) + totalDps += dps * groupInfo['count'] + else: # launcher + tgtSpeed, tgtSigRadius = self._missileTargetParams(trackingParams) + for group_id, groupInfo in weaponCache.items(): + volley, _ = getMissileVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + distance, + tgtSpeed, + tgtSigRadius, + projectedCache + ) + dps = missileVolleyToDps(volley, groupInfo['cycle_time_ms']) + totalDps += dps * groupInfo['count'] + + return totalDps + + def _getOptimalDpsWithAmmoAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType): + """Get total DPS and ammo name at a specific distance.""" + totalDps = 0 + ammoName = None + + if weaponType == 'turret': + for groupInfo in weaponCache.values(): + volley, name = getVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + groupInfo['turret_base'], + distance, + trackingParams, + projectedCache + ) + dps = volleyToDps(volley, groupInfo['cycle_time_ms']) + totalDps += dps * groupInfo['count'] + if ammoName is None: + ammoName = name + else: # launcher + tgtSpeed, tgtSigRadius = self._missileTargetParams(trackingParams) + for groupInfo in weaponCache.values(): + volley, name = getMissileVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + distance, + tgtSpeed, + tgtSigRadius, + projectedCache + ) + dps = missileVolleyToDps(volley, groupInfo['cycle_time_ms']) + totalDps += dps * groupInfo['count'] + if ammoName is None: + ammoName = name + + return totalDps, ammoName + + +class YOptimalAmmoVolleyMixin: + """Y-axis mixin: Calculate volley using optimal ammo selection.""" + + def _getOptimalVolleyAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType): + """Get total volley with optimal ammo at a specific distance.""" + totalVolley = 0 + + if weaponType == 'turret': + for groupInfo in weaponCache.values(): + volley, _ = getVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + groupInfo['turret_base'], + distance, + trackingParams, + projectedCache + ) + totalVolley += volley * groupInfo['count'] + else: # launcher + tgtSpeed, tgtSigRadius = self._missileTargetParams(trackingParams) + for groupInfo in weaponCache.values(): + volley, _ = getMissileVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + distance, + tgtSpeed, + tgtSigRadius, + projectedCache + ) + totalVolley += volley * groupInfo['count'] + + return totalVolley + + def _getOptimalVolleyWithAmmoAtDistance(self, distance, weaponCache, trackingParams, projectedCache, weaponType): + """Get total volley and ammo name at a specific distance.""" + totalVolley = 0 + ammoName = None + + if weaponType == 'turret': + for groupInfo in weaponCache.values(): + volley, name = getVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + groupInfo['turret_base'], + distance, + trackingParams, + projectedCache + ) + totalVolley += volley * groupInfo['count'] + if ammoName is None: + ammoName = name + else: # launcher + tgtSpeed, tgtSigRadius = self._missileTargetParams(trackingParams) + for groupInfo in weaponCache.values(): + volley, name = getMissileVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + distance, + tgtSpeed, + tgtSigRadius, + projectedCache + ) + totalVolley += volley * groupInfo['count'] + if ammoName is None: + ammoName = name + + return totalVolley, ammoName + + +# ============================================================================= +# X-Axis Mixin +# ============================================================================= + +class XDistanceMixin(SmoothPointGetter): + """X-axis mixin: Distance in meters. Builds weapon cache and handles lookups.""" + + # Coarse resolution for graph display - 100m intervals + # Exact calculations are done on-demand via getPoint/getPointExtended + _baseResolution = 100 # meters + + def _getCommonData(self, miscParams, src, tgt): + """ + Build common data including projected cache and weapon (turret/launcher) cache. + + The projected cache is keyed by target (tgtSpeed, tgtSigRadius) and can be + extended if the attacker's max range increases, without recalculating + existing entries. + """ + # Get settings + qualityTier = getattr(self.graph, '_ammoQuality', 'all') + ignoreResists = GraphSettings.getInstance().get('ammoOptimalIgnoreResists') + applyProjected = GraphSettings.getInstance().get('ammoOptimalApplyProjected') + + tgtResists = None if (ignoreResists or tgt is None) else tgt.getResists() + tgtSpeed = miscParams.get('tgtSpeed', 0) or 0 + tgtSigRadius = tgt.getSigRadius() if tgt else 0 + shipRadius = src.getRadius() + + weaponType = getDominantWeaponType(src) + + fit_id = src.item.ID + + atkSpeed = miscParams.get('atkSpeed', 0) or 0 + atkAngle = miscParams.get('atkAngle', 0) or 0 + tgtAngle = miscParams.get('tgtAngle', 0) or 0 + + weaponCacheKey = (fit_id, weaponType, qualityTier, tgtResists, applyProjected, tgtSpeed, tgtSigRadius, atkSpeed, atkAngle, tgtAngle) + + projectedCacheKey = (fit_id, tgtSpeed, tgtSigRadius, atkSpeed, atkAngle, tgtAngle) + + # Initialize graph caches if needed + if not hasattr(self.graph, '_ammo_weapon_cache'): + self.graph._ammo_weapon_cache = {} + if not hasattr(self.graph, '_ammo_charge_cache'): + self.graph._ammo_charge_cache = {} + if not hasattr(self.graph, '_ammo_projected_cache'): + self.graph._ammo_projected_cache = {} + + # Build base commonData with projected effect info + commonData = { + 'applyProjected': applyProjected, + 'src_radius': shipRadius, + 'weapon_type': weaponType, + } + + # Add projected effect data if enabled + if applyProjected: + commonData['srcScramRange'] = getScramRange(src=src) + commonData['tgtScrammables'] = getScrammables(tgt=tgt) if tgt else () + webMods, tpMods = self.graph._projectedCache.getProjModData(src) + webDrones, tpDrones = self.graph._projectedCache.getProjDroneData(src) + webFighters, tpFighters = self.graph._projectedCache.getProjFighterData(src) + commonData['webMods'] = webMods + commonData['tpMods'] = tpMods + commonData['webDrones'] = webDrones + commonData['tpDrones'] = tpDrones + commonData['webFighters'] = webFighters + commonData['tpFighters'] = tpFighters + + if weaponCacheKey in self.graph._ammo_weapon_cache: + cached_weapon = self.graph._ammo_weapon_cache[weaponCacheKey] + commonData['weapon_cache'] = cached_weapon + commonData['projected_cache'] = self.graph._ammo_projected_cache.get(projectedCacheKey, {}) + return commonData + + if weaponType is None: + commonData['weapon_cache'] = {} + commonData['projected_cache'] = {} + return commonData + + + weaponRangeInfos = {} # {mod.item.ID: rangeInfo} + maxEffectiveRange = 0 + + if weaponType == 'turret': + hardpointType = FittingHardpoint.TURRET + else: + hardpointType = FittingHardpoint.MISSILE + + for mod in src.item.activeModulesIter(): + if mod.hardpoint != hardpointType: + continue + if mod.getModifiedItemAttr('miningAmount'): + continue + + key = mod.item.ID + if key not in weaponRangeInfos: + if weaponType == 'turret': + rangeInfo = getTurretRangeInfo(mod, qualityTier, self.graph._ammo_charge_cache) + else: + # Special handling for empty launchers (Missiles only): + # To apply skill/ship modifiers correctly, eos needs a charge loaded. + # If launcher is empty, temporarily load a charge to extract multipliers. + if mod.charge is None: + # Find a valid charge to simulate load + chargeCacheKey = (mod.item.ID, qualityTier) + validCharges = None + if self.graph._ammo_charge_cache is not None and chargeCacheKey in self.graph._ammo_charge_cache: + validCharges = self.graph._ammo_charge_cache[chargeCacheKey] + + if validCharges is None: + allCharges = list(getValidChargesForModule(mod)) + validCharges = filterChargesByQuality(allCharges, qualityTier) + if self.graph._ammo_charge_cache is not None: + self.graph._ammo_charge_cache[chargeCacheKey] = validCharges + + if validCharges: + # Temporarily load the first valid charge + tempCharge = validCharges[0] + try: + mod.charge = tempCharge + # Force fit update (important for effects to apply) + if mod.owner: + mod.owner.calculated = False + mod.owner.calculateModifiedAttributes() + + ranges = getLauncherRangeInfo(mod, qualityTier, shipRadius, self.graph._ammo_charge_cache) + rangeInfo = ranges + + # Unload charge + mod.charge = None + if mod.owner: + mod.owner.calculated = False + mod.owner.calculateModifiedAttributes() + + except Exception as e: + pyfalog.error(f"Error simulating charge for {mod.item.name}: {e}") + mod.charge = None # Ensure cleanup + if mod.owner: + mod.owner.calculated = False + try: + mod.owner.calculateModifiedAttributes() + except: + pass + rangeInfo = None + else: + rangeInfo = None + else: + rangeInfo = getLauncherRangeInfo(mod, qualityTier, shipRadius, self.graph._ammo_charge_cache) + + if rangeInfo: + weaponRangeInfos[key] = rangeInfo + if rangeInfo['max_effective_range'] > maxEffectiveRange: + maxEffectiveRange = rangeInfo['max_effective_range'] + + if not weaponRangeInfos: + # No weapons found + commonData['weapon_cache'] = {} + commonData['projected_cache'] = {} + return commonData + + # ===================================================================== + # PHASE 2: Build/extend projected cache to max effective range + # ===================================================================== + + # Get existing cache for this target (if any) + existingCache = self.graph._ammo_projected_cache.get(projectedCacheKey) + + # Build base tracking params (used for turrets, also provides tgtSpeed/tgtSig for missiles) + # Vector parameters already extracted above for cache keys + baseTrackingParams = { + 'atkSpeed': atkSpeed, + 'atkAngle': atkAngle, + 'atkRadius': shipRadius, + 'tgtSpeed': tgtSpeed, + 'tgtAngle': tgtAngle, + 'tgtRadius': tgt.getRadius() if tgt else 0, + 'tgtSigRadius': tgtSigRadius + } + + # Build or extend the projected cache + projectedCache = buildProjectedCache( + src=src, + tgt=tgt, + commonData=commonData, + baseTgtSpeed=tgtSpeed, + baseTgtSigRadius=tgtSigRadius, + maxDistance=maxEffectiveRange, + resolution=100, # 100m intervals + existingCache=existingCache + ) + + # Store projected cache - can be reused if target stays the same + self.graph._ammo_projected_cache[projectedCacheKey] = projectedCache + commonData['projected_cache'] = projectedCache + + # ===================================================================== + # PHASE 3: Build weapon cache with transitions + # ===================================================================== + + weaponCache = {} + for mod in src.item.activeModulesIter(): + if mod.hardpoint != hardpointType: + continue + if mod.getModifiedItemAttr('miningAmount'): + continue + + key = mod.item.ID + if key not in weaponCache: + rangeInfo = weaponRangeInfos.get(key) + if rangeInfo: + if weaponType == 'turret': + entry = buildTurretCacheEntry( + mod, qualityTier, tgtResists, baseTrackingParams, + projectedCache, self.graph._ammo_charge_cache, + rangeInfo=rangeInfo + ) + else: + entry = buildLauncherCacheEntry( + mod, qualityTier, tgtResists, shipRadius, + tgtSpeed, tgtSigRadius, + projectedCache, self.graph._ammo_charge_cache, + rangeInfo=rangeInfo + ) + if entry: + weaponCache[key] = entry + else: + weaponCache[key]['count'] += 1 + + # Cache and return + self.graph._ammo_weapon_cache[weaponCacheKey] = weaponCache + commonData['weapon_cache'] = weaponCache + + return commonData + + def _buildTrackingParams(self, distance, miscParams, src, tgt, commonData): + """ + Build base tracking params for a distance query. + + NOTE: This returns BASE params only. The projected effects (web/TP) + are applied via the projected cache in getVolleyAtDistance. + """ + tgtSpeed = miscParams.get('tgtSpeed', 0) or 0 + tgtSigRadius = tgt.getSigRadius() if tgt else 0 + + # Only return None if sig radius is exactly 0 (not infinity - that's valid for Ideal Target) + if tgtSigRadius == 0: + return None + + params = { + 'atkSpeed': miscParams.get('atkSpeed', 0) or 0, + 'atkAngle': miscParams.get('atkAngle', 0) or 0, + 'atkRadius': commonData.get('src_radius', 0), + 'tgtSpeed': tgtSpeed, + 'tgtAngle': miscParams.get('tgtAngle', 0) or 0, + 'tgtRadius': tgt.getRadius() if tgt else 0, + 'tgtSigRadius': tgtSigRadius + } + + return params + + @staticmethod + def _missileTargetParams(trackingParams): + """ + Extract (tgtSpeed, tgtSigRadius) for the missile volley calc from a + possibly-None trackingParams. + + _buildTrackingParams returns None to mean "perfect tracking" (no target + or a target with signature radius 0). The turret path mirrors this as a + tracking factor of 1.0; for missiles we get the same full-application + result by feeding an effectively-infinite signature (and zero speed) + into the application formula. Without this guard the launcher branches + raise TypeError subscripting None. + """ + if trackingParams is None: + return 0, float('inf') + return trackingParams['tgtSpeed'], trackingParams['tgtSigRadius'] + + def _calculatePoint(self, x, miscParams, src, tgt, commonData): + """Calculate value at distance x.""" + weaponCache = commonData.get('weapon_cache', {}) + weaponType = commonData.get('weapon_type') + if not weaponCache: + return 0 + + trackingParams = self._buildTrackingParams(x, miscParams, src, tgt, commonData) + projectedCache = commonData.get('projected_cache', {}) + + if hasattr(self, '_getOptimalDpsAtDistance'): + result = self._getOptimalDpsAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType) + return result + elif hasattr(self, '_getOptimalVolleyAtDistance'): + result = self._getOptimalVolleyAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType) + return result + return 0 + + def _calculatePointExtended(self, x, miscParams, src, tgt, commonData): + """Calculate value and ammo name at distance x.""" + weaponCache = commonData.get('weapon_cache', {}) + weaponType = commonData.get('weapon_type') + if not weaponCache: + return 0, None + + trackingParams = self._buildTrackingParams(x, miscParams, src, tgt, commonData) + projectedCache = commonData.get('projected_cache', {}) + + if hasattr(self, '_getOptimalDpsWithAmmoAtDistance'): + return self._getOptimalDpsWithAmmoAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType) + elif hasattr(self, '_getOptimalVolleyWithAmmoAtDistance'): + return self._getOptimalVolleyWithAmmoAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType) + return 0, None + + def getSegments(self, xRange, miscParams, src, tgt): + """Get plot segments with ammo transition information.""" + # Validate xRange - can contain None from range limiters + minX, maxX = xRange + if minX is None or maxX is None: + return [] + + commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt) + weaponCache = commonData.get('weapon_cache', {}) + weaponType = commonData.get('weapon_type') + + if not weaponCache: + return [] + + # Get transitions from first weapon group + transitions = None + for groupInfo in weaponCache.values(): + transitions = groupInfo['transitions'] + break + + if not transitions: + return [] + + # Filter valid transitions (with ammo name) + validTransitions = [t for t in transitions if t[2] is not None] + if not validTransitions: + return [] + + # Build ammo index mapping + ammoToIndex = {} + for t in validTransitions: + if t[2] not in ammoToIndex: + ammoToIndex[t[2]] = len(ammoToIndex) + + # Generate segments + segments = [] + + for i, transition in enumerate(validTransitions): + transDist, _, ammoName, _ = transition + segStart = max(transDist, minX) + + # Find segment end + if i + 1 < len(validTransitions): + segEnd = min(validTransitions[i + 1][0], maxX) + else: + segEnd = maxX + + if segStart >= segEnd: + continue + + # Generate points at fixed 100m resolution for performance + step = 100 + xs, ys = [], [] + x = segStart + while x <= segEnd: + y = self._calculatePoint(x, miscParams, src, tgt, commonData) + xs.append(x) + ys.append(y) + x += step + + # Always include the segment end point for smooth transitions + if xs[-1] < segEnd: + y = self._calculatePoint(segEnd, miscParams, src, tgt, commonData) + xs.append(segEnd) + ys.append(y) + + segments.append({ + 'xs': xs, + 'ys': ys, + 'ammo': ammoName, + 'ammoIndex': ammoToIndex[ammoName] + }) + + return segments + + +# ============================================================================= +# Getter Classes +# ============================================================================= + +class Distance2OptimalAmmoDpsGetter(XDistanceMixin, YOptimalAmmoDpsMixin): + """Distance vs Optimal Ammo DPS graph getter.""" + + def getPointExtended(self, x, miscParams, src, tgt): + commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt) + value, ammo = self._calculatePointExtended(x, miscParams, src, tgt, commonData) + return value, {'ammo': ammo} + + +class Distance2OptimalAmmoVolleyGetter(XDistanceMixin, YOptimalAmmoVolleyMixin): + """Distance vs Optimal Ammo Volley graph getter.""" + + def getPointExtended(self, x, miscParams, src, tgt): + commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt) + value, ammo = self._calculatePointExtended(x, miscParams, src, tgt, commonData) + return value, {'ammo': ammo} diff --git a/graphs/data/fitApplicationProfile/graph.py b/graphs/data/fitApplicationProfile/graph.py new file mode 100644 index 0000000000..20963c2312 --- /dev/null +++ b/graphs/data/fitApplicationProfile/graph.py @@ -0,0 +1,575 @@ +# ============================================================================= +# 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 colorsys +import math +import re + +from eos.const import FittingHardpoint +from eos.saveddata.fit import Fit +from graphs.data.base import FitGraph, XDef, YDef, Input, VectorDef +from graphs.data.fitApplicationProfile.getter import ( + Distance2OptimalAmmoDpsGetter, + Distance2OptimalAmmoVolleyGetter, +) +from graphs.data.fitApplicationProfile.calc.turret import getTurretBaseStats +from graphs.data.fitApplicationProfile.calc.charges import getChargeStats +from graphs.data.fitApplicationProfile.calc.valid_charges import getValidChargesForModule +from graphs.data.fitApplicationProfile.calc.launcher import getFlightMultipliers +from graphs.data.fitDamageStats.cache import ProjectedDataCache +from service.const import GraphCacheCleanupReason +from service.settings import GraphSettings + + +# Ammo color definitions (RGB tuples, 0-255 range) +AMMO_COLORS = { + # Hybrid - Short Range + "Null": (179, 179, 166), + "Void": (128, 26, 51), + # Hybrid - Long Range + "Spike": (194, 255, 43), + "Javelin": (112, 251, 0), + # Hybrid - Standard + "Antimatter": (15, 0, 0), + "Iridium": (26, 179, 179), + "Lead": (114, 120, 125), + "Plutonium": (0, 150, 68), + "Thorium": (148, 127, 115), + "Uranium": (94, 230, 73), + "Tungsten": (8, 0, 38), + "Iron": (153, 77, 77), + + # Energy - Short Range + "Scorch": (235, 79, 255), + "Conflagration": (0, 184, 64), + # Energy - Long Range + "Gleam": (181, 145, 94), + "Aurora": (166, 18, 55), + # Energy - Standard + "Multifrequency": (204, 204, 204), + "Gamma": (5, 102, 242), + "Xray": (0, 189, 134), + "Ultraviolet": (107, 0, 189), + "Standard": (230, 179, 0), + "Infrared": (242, 64, 5), + "Microwave": (242, 142, 5), + "Radio": (227, 10, 10), + + # Projectile - Short Range + "Quake": (199, 154, 82), + "Hail": (255, 153, 0), + # Projectile - Long Range + "Tremor": (74, 64, 47), + "Barrage": (196, 83, 2), + # Projectile - Standard + "Carbonized Lead": (192, 81, 214), + "Depleted Uranium": (103, 0, 207), + "EMP": (25, 194, 194), + "Fusion": (222, 140, 33), + "Nuclear": (122, 184, 15), + "Phased Plasma": (184, 15, 54), + "Proton": (55, 116, 117), + "Titanium Sabot": (54, 75, 94), + + # Exotic Plasma - Advanced + "Occult": (189,0,38), + "Mystic": (252,174,145), + # Exotic Plasma - Standard + "Tetryon": (240,59,32), + "Baryon": (253,141,60), + "Meson": (254,204,92), + + # Vorton Charges - Advanced + "ElectroPunch Ultra": (37,52,148), + "StrikeSnipe Ultra": (103,169,207), + # Vorton Charges - Standard + "BlastShot Condenser Pack": (49,163,84), + "GalvaSurge Condenser Pack": (44,127,184), + "MesmerFlux Condenser Pack": (65,182,196), + "SlamBolt Condenser Pack": (194,230,153), +} + +# Missile damage type hues (0-360 degrees) +MISSILE_DAMAGE_HUES = { + 'Mjolnir': 210, # Blue (EM) + 'Inferno': 0, # Red (Thermal) + 'Scourge': 180, # Cyan/Teal (Kinetic) + 'Nova': 30, # Orange (Explosive) +} + +# Charge type saturation and value/brightness (0-100 scale) +MISSILE_CHARGE_SV = { + 'Rage': (90, 55), + 'Fury': (90, 55), + 'Faction': (55, 90), + 'Precision': (50, 85), + 'Javelin': (50, 45), + 'T1': (25, 90), +} + + +def _hsv_to_rgb_255(h, s, v): + """Convert HSV (h: 0-360, s: 0-100, v: 0-100) to RGB (0-255).""" + r, g, b = colorsys.hsv_to_rgb(h / 360, s / 100, v / 100) + return (int(r * 255), int(g * 255), int(b * 255)) + + +def _generate_missile_colors(): + """Generate missile ammo colors based on damage type hue and charge type sat/brightness.""" + colors = {} + + for damage_type, hue in MISSILE_DAMAGE_HUES.items(): + # Rage variant + s, v = MISSILE_CHARGE_SV['Rage'] + colors[f"{damage_type} Rage"] = _hsv_to_rgb_255(hue, s, v) + + # Fury variant + s, v = MISSILE_CHARGE_SV['Fury'] + colors[f"{damage_type} Fury"] = _hsv_to_rgb_255(hue, s, v) + + # Faction variant + s, v = MISSILE_CHARGE_SV['Faction'] + colors[f"Faction {damage_type}"] = _hsv_to_rgb_255(hue, s, v) + + # Precision variant + s, v = MISSILE_CHARGE_SV['Precision'] + colors[f"{damage_type} Precision"] = _hsv_to_rgb_255(hue, s, v) + + # Javelin variant + s, v = MISSILE_CHARGE_SV['Javelin'] + colors[f"{damage_type} Javelin"] = _hsv_to_rgb_255(hue, s, v) + + # T1 Standard (just damage type name) + s, v = MISSILE_CHARGE_SV['T1'] + colors[damage_type] = _hsv_to_rgb_255(hue, s, v) + + return colors + +# Add generated missile colors to AMMO_COLORS +AMMO_COLORS.update(_generate_missile_colors()) + + +def get_ammo_base_name(ammo_name): + """ + Extract base ammo name by removing size suffix (S/M/L/XL), missile type suffixes, and other common suffixes. + """ + if not ammo_name: + return None + + cleaned = ammo_name + + # Remove missile type suffixes (e.g., "Light Missile", "Heavy Assault Missile", "Torpedo", "Cruise Missile") + missile_suffixes = [ + ' XL Torpedo', ' XL Cruise Missile', # XL variants first (longest match) + ' Light Missile', ' Heavy Missile', ' Heavy Assault Missile', + ' Cruise Missile', ' Torpedo', ' Auto-Targeting Missile', + ' Defender Missile', + ] + is_missile = False + for suffix in missile_suffixes: + if cleaned.endswith(suffix): + cleaned = cleaned[:-len(suffix)] + is_missile = True + break + + # For turret ammo, remove faction prefixes (e.g., "Republic Fleet ", "Imperial Navy ", "Caldari Navy ") + # For missiles, keep faction prefix as it indicates ammo quality + if not is_missile: + faction_prefixes = [ + 'Republic Fleet ', 'Imperial Navy ', 'Caldari Navy ', 'Federation Navy ', + 'Dread Guristas ', 'True Sansha ', 'Shadow Serpentis ', 'Domination ', + 'Dark Blood ', "Arch Angel ", 'Guristas ', 'Sansha ', 'Serpentis ', + 'Blood ', 'Angel ' + ] + for prefix in faction_prefixes: + if cleaned.startswith(prefix): + cleaned = cleaned[len(prefix):] + break + + cleaned = re.sub(r'\s+(S|M|L|XL)$', '', cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r'\s+Charge$', '', cleaned, flags=re.IGNORECASE) + + return cleaned + + +# Missile damage type base names for faction lookup +MISSILE_DAMAGE_TYPES = {'Mjolnir', 'Inferno', 'Scourge', 'Nova'} + +# Faction prefixes to normalize for missile color lookup +FACTION_PREFIXES = [ + 'Caldari Navy ', 'Dread Guristas ', 'True Sansha ', 'Shadow Serpentis ', + 'Domination ', 'Dark Blood ', "Arch Angel ", 'Guristas ', 'Sansha ', + 'Serpentis ', 'Blood ', 'Angel ', 'Republic Fleet ', 'Imperial Navy ', + 'Federation Navy ' +] + + +def get_ammo_color(ammo_name): + """ + Get RGB color tuple for an ammo type. + Returns color in 0-1 range for matplotlib, or None if no color defined. + """ + base_name = get_ammo_base_name(ammo_name) + if not base_name: + return None + + color = None + + # Direct lookup first + if base_name in AMMO_COLORS: + color = AMMO_COLORS[base_name] + else: + # For faction missiles, normalize to "Faction " lookup + # e.g., "Caldari Navy Mjolnir" -> try "Faction Mjolnir" + for prefix in FACTION_PREFIXES: + if base_name.startswith(prefix): + faction_normalized = 'Faction ' + base_name[len(prefix):] + if faction_normalized in AMMO_COLORS: + color = AMMO_COLORS[faction_normalized] + break + + # If still not found, try partial match for turret ammo names + if color is None: + for key in AMMO_COLORS: + if key in base_name or base_name in key: + color = AMMO_COLORS[key] + break + + # Convert from 0-255 to 0-1 range for matplotlib + if color: + return (color[0] / 255, color[1] / 255, color[2] / 255) + return None + + +class FitAmmoOptimalDpsGraph(FitGraph): + + # Graph definition + internalName = 'ammoOptimalDpsGraph' + name = 'Application Profile' + xDefs = [ + XDef(handle='distance', unit='km', label='Distance', mainInput=('distance', 'km'))] + inputs = [ + Input(handle='distance', unit='km', label='Distance', iconID=None, defaultValue=None, defaultRange=(0, 100), mainTooltip='Distance to target')] + + # Vector controls for attacker and target velocity/angle (same as DPS graph) + srcVectorDef = VectorDef(lengthHandle='atkSpeed', lengthUnit='%', angleHandle='atkAngle', angleUnit='degrees', label='Attacker') + tgtVectorDef = VectorDef(lengthHandle='tgtSpeed', lengthUnit='%', angleHandle='tgtAngle', angleUnit='degrees', label='Target') + + sources = {Fit} + _limitToOutgoingProjected = True + hasTargets = True + srcExtraCols = ('Dps', 'Volley', 'Speed', 'SigRadius', 'Radius') + + @property + def tgtExtraCols(self): + """Define target extra columns similar to Damage Stats graph""" + cols = ['Target Resists', 'Speed', 'SigRadius', 'Radius'] + return cols + + @property + def yDefs(self): + ignoreResists = GraphSettings.getInstance().get('ammoOptimalIgnoreResists') + return [ + YDef(handle='dps', unit=None, label='DPS' if ignoreResists else 'Effective DPS'), + YDef(handle='volley', unit=None, label='Volley' if ignoreResists else 'Effective Volley')] + + # Normalizers convert input values to internal units + _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()} + + # Denormalizers convert internal units back to display units + _denormalizers = { + ('distance', 'km'): lambda v, src, tgt: None if v is None else v / 1000, + ('tgtSpeed', '%'): lambda v, src, tgt: v * 100 / tgt.getMaxVelocity()} + + # No limiters - allow user to specify any range they want + _limiters = {} + + # Getter mapping + _getters = { + ('distance', 'dps'): Distance2OptimalAmmoDpsGetter, + ('distance', 'volley'): Distance2OptimalAmmoVolleyGetter} + + # Enable segmented plotting for this graph + hasSegments = True + + # Ammo color mode: True = use ammo-specific colors, False = use line patterns + useAmmoColors = True + + def __init__(self): + super().__init__() + self._projectedCache = ProjectedDataCache() + self._rangeCache = {} # Cache for getDefaultInputRange: {frozenset(fitIDs): (min, max)} + + def getAmmoColor(self, ammoName): + """Get RGB color tuple for an ammo type.""" + return get_ammo_color(ammoName) + + def getDefaultInputRange(self, inputDef, sources): + """ + Calculate dynamic default range based on the turrets/missiles max effective range. + + Returns (min, max) tuple in the input's units (km for distance). + For turrets: the longest range ammo's optimal+falloff*2 + 10%, capped at 300km. + For missiles: the longest range missile's max range + 10%, capped at 300km. + """ + if inputDef.handle != 'distance' or not sources: + return inputDef.defaultRange + + # Build cache key from fit IDs + fitIDs = frozenset(src.item.ID for src in sources if src.item is not None) + if not fitIDs: + return inputDef.defaultRange + + # Check cache + if fitIDs in self._rangeCache: + return self._rangeCache[fitIDs] + + max_range_m = 0 + + for src in sources: + fit = src.item + if fit is None: + continue + + # Check all turrets and missiles + for mod in fit.activeModulesIter(): + if mod.hardpoint == FittingHardpoint.TURRET: + if mod.getModifiedItemAttr('miningAmount'): + continue + + # Get turret base stats + turret_base = getTurretBaseStats(mod) + + # Check all compatible charges for this turret + for charge in getValidChargesForModule(mod): + charge_stats = getChargeStats(charge) + + # Calculate effective optimal + 2*falloff (where DPS drops to ~6%) + effective_optimal = turret_base['optimal'] * charge_stats['rangeMultiplier'] + effective_falloff = turret_base['falloff'] * charge_stats['falloffMultiplier'] + effective_max = effective_optimal + effective_falloff * 2.5 + + if effective_max > max_range_m: + max_range_m = effective_max + + elif mod.hardpoint == FittingHardpoint.MISSILE: + # For missiles, check ALL compatible charges to find longest range + # We need the max range across all ammo types, not just the loaded one + + valid_charges = list(getValidChargesForModule(mod)) + if not valid_charges: + continue + + # Get flight multipliers from skills/ship (handling empty launcher case) + if mod.charge is None: + # Temp load first valid charge to extract multipliers + temp_charge = valid_charges[0] + mod.charge = temp_charge + if mod.owner: + mod.owner.calculated = False + mod.owner.calculateModifiedAttributes() + try: + flight_mults = getFlightMultipliers(mod) + finally: + # Always restore the empty launcher, even if the + # multiplier extraction raises - otherwise the fit + # is left with a phantom charge and stale attributes + mod.charge = None + if mod.owner: + mod.owner.calculated = False + mod.owner.calculateModifiedAttributes() + else: + flight_mults = getFlightMultipliers(mod) + + for charge in valid_charges: + base_velocity = charge.getAttribute('maxVelocity') or 0 + base_explosion_delay = charge.getAttribute('explosionDelay') or 0 + if base_velocity > 0 and base_explosion_delay > 0: + # Apply skill/ship bonuses to flight attributes + maxVelocity = base_velocity * flight_mults['maxVelocity'] + explosionDelay = base_explosion_delay * flight_mults['explosionDelay'] + # Estimate range: velocity * flight_time + flightTime = explosionDelay / 1000 + estimated_range = maxVelocity * flightTime * 1.1 + if estimated_range > max_range_m: + max_range_m = estimated_range + + if max_range_m <= 0: + return inputDef.defaultRange + + # Add 10% buffer and convert to km + max_range_km = (max_range_m * 1.1) / 1000 + + # Cap at 300km (EVE's max lock range) + max_range_km = min(max_range_km, 300) + + # Round to nice number + max_range_km = int(max_range_km + 0.5) + + result = (0, max_range_km) + self._rangeCache[fitIDs] = result + return result + + def _clearInternalCache(self, reason, extraData): + if reason in (GraphCacheCleanupReason.fitChanged, GraphCacheCleanupReason.fitRemoved): + # extraData is the fit ID (integer), not the fit object + fit_id = extraData + + # Clear base projected cache for this fit + self._projectedCache.clearForFit(fit_id) + + # Clear weapon cache entries for this specific fit only + # Cache key format: (fitID, weaponType, qualityTier, tgtResists, applyProjected, tgtSpeed, tgtSigRadius) + if hasattr(self, '_ammo_weapon_cache'): + keys_to_remove = [k for k in self._ammo_weapon_cache.keys() if k[0] == fit_id] + for key in keys_to_remove: + del self._ammo_weapon_cache[key] + + # Clear projected cache entries for this specific fit (all target combinations) + # Projected cache key format: (fitID, tgtSpeed, tgtSigRadius) + if hasattr(self, '_ammo_projected_cache'): + keys_to_remove = [k for k in self._ammo_projected_cache.keys() if k[0] == fit_id] + for key in keys_to_remove: + del self._ammo_projected_cache[key] + + # Clear range cache entries that include this fit ID + if hasattr(self, '_rangeCache'): + keys_to_remove = [k for k in self._rangeCache.keys() if fit_id in k] + for key in keys_to_remove: + del self._rangeCache[key] + + # Clear charge cache - when fits change, weapon types might change + if hasattr(self, '_ammo_charge_cache'): + self._ammo_charge_cache = {} + + elif reason in (GraphCacheCleanupReason.profileChanged, GraphCacheCleanupReason.profileRemoved): + if hasattr(self, '_ammo_weapon_cache'): + self._ammo_weapon_cache = {} + + if hasattr(self, '_ammo_projected_cache'): + self._ammo_projected_cache = {} + + if hasattr(self, '_rangeCache'): + self._rangeCache = {} + + elif reason == GraphCacheCleanupReason.graphSwitched: + self._projectedCache.clearAll() + + # Clear all ammo caches globally + if hasattr(self, '_ammo_weapon_cache'): + self._ammo_weapon_cache = {} + + if hasattr(self, '_ammo_projected_cache'): + self._ammo_projected_cache = {} + + if hasattr(self, '_rangeCache'): + self._rangeCache = {} + + if hasattr(self, '_ammo_charge_cache'): + self._ammo_charge_cache = {} + + elif reason in (GraphCacheCleanupReason.inputChanged, GraphCacheCleanupReason.optionChanged): + if hasattr(self, '_ammo_weapon_cache'): + self._ammo_weapon_cache = {} + + if hasattr(self, '_ammo_projected_cache'): + self._ammo_projected_cache = {} + + + def getPlotSegments(self, mainInput, miscInputs, xSpec, ySpec, src, tgt=None): + """ + Get segmented plot data with ammo information for color coding. + + Returns list of segments, each with xs, ys, ammo name, and ammo index. + Returns None if this graph doesn't support segments or getter doesn't have getSegments. + """ + try: + getterClass = self._getters[(xSpec.handle, ySpec.handle)] + except KeyError: + return None + + # Normalize the input range + mainParamRange = self._normalizeMain(mainInput=mainInput, src=src, tgt=tgt) + miscParams = self._normalizeMisc(miscInputs=miscInputs, src=src, tgt=tgt) + mainParamRange = self._limitMain(mainParamRange=mainParamRange, src=src, tgt=tgt) + miscParams = self._limitMisc(miscParams=miscParams, src=src, tgt=tgt) + + getter = getterClass(graph=self) + + # Check if getter has getSegments method + if not hasattr(getter, 'getSegments'): + return None + + segments = getter.getSegments( + xRange=mainParamRange[1], + miscParams=miscParams, + src=src, + tgt=tgt) + + if not segments: + return None + + # Denormalize the values back to display units + for segment in segments: + segment['xs'] = self._denormalizeValues(values=segment['xs'], axisSpec=xSpec, src=src, tgt=tgt) + segment['ys'] = self._denormalizeValues(values=segment['ys'], axisSpec=ySpec, src=src, tgt=tgt) + + return segments + + def getPointExtended(self, x, miscInputs, xSpec, ySpec, src, tgt=None): + """ + Get point value with extended info (like ammo name) at x. + + Returns (y_value, extra_info_dict) tuple. + extra_info_dict may contain 'ammo' key with the ammo name. + """ + try: + getterClass = self._getters[(xSpec.handle, ySpec.handle)] + except KeyError: + return None, {} + + x = self._normalizeValue(value=x, axisSpec=xSpec, src=src, tgt=tgt) + miscParams = self._normalizeMisc(miscInputs=miscInputs, src=src, tgt=tgt) + miscParams = self._limitMisc(miscParams=miscParams, src=src, tgt=tgt) + + getter = getterClass(graph=self) + + # Check if getter has getPointExtended method + if hasattr(getter, 'getPointExtended'): + y, extraInfo = getter.getPointExtended(x=x, miscParams=miscParams, src=src, tgt=tgt) + y = self._denormalizeValue(value=y, axisSpec=ySpec, src=src, tgt=tgt) + return y, extraInfo + else: + # Fall back to regular getPoint + y = self._getPoint(x=x, miscParams=miscParams, xSpec=xSpec, ySpec=ySpec, src=src, tgt=tgt) + y = self._denormalizeValue(value=y, axisSpec=ySpec, src=src, tgt=tgt) + return y, {} + + def _updateMiscParams(self, **kwargs): + miscParams = super()._updateMiscParams(**kwargs) + # Set defaults from target profile + miscParams['tgtSigRadius'] = miscParams['tgt'].getSigRadius() + miscParams['tgtSpeed'] = miscParams['tgt'].getMaxVelocity() + miscParams.setdefault('atkSpeed', 0) + miscParams.setdefault('atkAngle', 0) + miscParams.setdefault('tgtAngle', 0) + return miscParams diff --git a/graphs/data/fitDamageEnvelope/getter.py b/graphs/data/fitDamageEnvelope/getter.py deleted file mode 100644 index a11a0f4252..0000000000 --- a/graphs/data/fitDamageEnvelope/getter.py +++ /dev/null @@ -1,317 +0,0 @@ -# ============================================================================= -# 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 deleted file mode 100644 index a531422fc9..0000000000 --- a/graphs/data/fitDamageEnvelope/graph.py +++ /dev/null @@ -1,72 +0,0 @@ -# ============================================================================= -# 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()} diff --git a/graphs/gui/canvasPanel.py b/graphs/gui/canvasPanel.py index 4c862f6001..e792e0bf70 100644 --- a/graphs/gui/canvasPanel.py +++ b/graphs/gui/canvasPanel.py @@ -51,6 +51,7 @@ from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as Canvas from matplotlib.figure import Figure from matplotlib.colors import hsv_to_rgb + import matplotlib.patheffects as PathEffects except ImportError as e: pyfalog.warning('Matplotlib failed to import. Likely missing or incompatible version.') graphFrame_enabled = False @@ -101,7 +102,37 @@ def __init__(self, graphFrame, parent): self.mplOnDragHandler = None self.mplOnReleaseHandler = None + # Blitting state for fast X marker updates during drag + self._blitBackground = None # Saved background (without X marker) + self._xMarkerArtists = [] # Artists for X marker (line + labels) + self._blitPlotData = {} # Cached plot data for interpolation during drag + self._blitView = None # Cached view + self._blitIterList = None # Cached source/target pairs + self._blitCanvasLimits = None # Cached (canvasMinX, canvasMaxX, canvasMinY, canvasMaxY) + self._blitChosenX = None # Cached X axis spec + self._blitChosenY = None # Cached Y axis spec + self._blitYDiff = None # Cached Y range for rounding + self._blitHasSegments = False # Cached segment flag + + # Track if user has manually overridden the input range (to prevent dynamic bounds from re-triggering) + self._defaultInputRange = None # Stores the default (minX, maxX) from graph definition + self._userModifiedInput = False # Flag: has user manually changed input field? + + def resetDynamicBoundsTracking(self): + """ + Forget any manual input-range override. + + Called when the graph is switched: the panel is a single long-lived + instance shared across all graph types, so without this reset a manual + edit on one graph would permanently suppress dynamic auto-ranging for + every other graph for the rest of the session. + """ + self._defaultInputRange = None + self._userModifiedInput = False + def draw(self, accurateMarks=True): + # Invalidate blit cache at the start of every draw + self._blitBackground = None self.subplot.clear() self.subplot.grid(True) allXs = set() @@ -116,12 +147,24 @@ def draw(self, accurateMarks=True): mainInput, miscInputs = self.graphFrame.ctrlPanel.getValues() view = self.graphFrame.getView() + + # Track the effective max X where data ends (where Y drops to minY threshold) + # This is used to limit X bounds for missile-like data that doesn't span full range + effectiveMaxX = None + + # Set ammo quality on view for segmented graphs + if hasattr(view, 'hasSegments') and view.hasSegments: + view._ammoQuality = self.graphFrame.ctrlPanel.ammoQuality + sources = self.graphFrame.ctrlPanel.sources if view.hasTargets: iterList = tuple(itertools.product(sources, self.graphFrame.ctrlPanel.targets)) else: iterList = tuple((f, None) for f in sources) + # Check if this view supports segmented plotting + hasSegments = getattr(view, 'hasSegments', False) + # Draw plot lines and get data for legend for source, target in iterList: # Get line style data @@ -130,7 +173,7 @@ def draw(self, accurateMarks=True): except KeyError: pyfalog.warning('Invalid color "{}" for "{}"'.format(source.colorID, source.name)) continue - color = colorData.hsl + baseColor = colorData.hsl lineStyle = 'solid' if target is not None: try: @@ -138,53 +181,223 @@ def draw(self, accurateMarks=True): except KeyError: pyfalog.warning('Invalid lightness "{}" for "{}"'.format(target.lightnessID, target.name)) continue - color = lightnessData.func(color) + baseColor = lightnessData.func(baseColor) try: lineStyleData = STYLES[target.lineStyleID] except KeyError: pyfalog.warning('Invalid line style "{}" for "{}"'.format(target.lightnessID, target.name)) continue lineStyle = lineStyleData.mplSpec - color = hsv_to_rgb(hsl_to_hsv(color)) - # Get point data - try: - xs, ys = view.getPlotPoints( - mainInput=mainInput, - miscInputs=miscInputs, - xSpec=chosenX, - ySpec=chosenY, - src=source, - tgt=target) - if not self.__checkNumbers(xs, ys): - pyfalog.warning('Failed to plot "{}" vs "{}" due to inf or NaN in values'.format(source.name, '' if target is None else target.name)) - continue - plotData[(source, target)] = (xs, ys) - allXs.update(xs) - allYs.update(ys) - # If we have single data point, show marker - otherwise line won't be shown - if len(xs) == 1 and len(ys) == 1: - self.subplot.plot(xs, ys, color=color, linestyle=lineStyle, marker='.') - else: - self.subplot.plot(xs, ys, color=color, linestyle=lineStyle) - # Fill data for legend - if target is None: - legendData.append((color, lineStyle, source.shortName)) - else: - legendData.append((color, lineStyle, '{} vs {}'.format(source.shortName, target.shortName))) - except (KeyboardInterrupt, SystemExit): - raise - except Exception: - pyfalog.warning('Failed to plot "{}" vs "{}"'.format(source.name, '' if target is None else target.name)) - self.canvas.draw() - self.Refresh() - return + # Try segmented plotting first if supported + segmentsPlotted = False + if hasSegments: + try: + segments = view.getPlotSegments( + mainInput=mainInput, + miscInputs=miscInputs, + xSpec=chosenX, + ySpec=chosenY, + src=source, + tgt=target) + if segments: + segmentsPlotted = True + # Base color from source/target selection + baseRgbColor = hsv_to_rgb(hsl_to_hsv(baseColor)) + styleKeys = list(STYLES.keys()) + + # Get ammo style from control panel ('none', 'pattern', 'color') + ammoStyle = self.graphFrame.ctrlPanel.ammoStyle + getAmmoColorFunc = getattr(view, 'getAmmoColor', None) + + segmentXs = [] + segmentYs = [] + legendSegments = [] # Track segments for legend + lastSegmentColor = None + lastSegmentStyle = None + lastSegmentMaxX = None + + for segIdx, segment in enumerate(segments): + xs = segment['xs'] + ys = segment['ys'] + ammoName = segment.get('ammo', 'Unknown') + ammoIndex = segment.get('ammoIndex', 0) + + if not self.__checkNumbers(xs, ys): + continue + + # Check if this is the last segment + isLastSegment = (segIdx == len(segments) - 1) + + # Track effective max X (where data actually ends) + if xs: + segMaxX = max(xs) + if effectiveMaxX is None or segMaxX > effectiveMaxX: + effectiveMaxX = segMaxX + + # Determine color and line style based on ammo style mode + if ammoStyle == 'color' and getAmmoColorFunc: + # Color mode: use ammo-specific colors, use target's line style + ammoColor = getAmmoColorFunc(ammoName) + if ammoColor: + segColor = ammoColor + else: + # Fallback to base color if no ammo color defined + segColor = baseRgbColor + # Use the target's line style selection + segLineStyle = lineStyle + elif ammoStyle == 'pattern': + # Pattern mode: use base color, vary line patterns + segColor = baseRgbColor + segStyleKey = styleKeys[ammoIndex % len(styleKeys)] + segStyleData = STYLES[segStyleKey] + segLineStyle = segStyleData.mplSpec + else: + # None mode: solid single color line + segColor = baseRgbColor + segLineStyle = 'solid' + + # Track last segment info for potential Y=0 connection + lastSegmentColor = segColor + lastSegmentStyle = segLineStyle + lastSegmentMaxX = max(xs) if xs else None + + # Plot this segment + if len(xs) == 1 and len(ys) == 1: + self.subplot.plot(xs, ys, color=segColor, linestyle=segLineStyle, marker='.', linewidth=2) + else: + self.subplot.plot(xs, ys, color=segColor, linestyle=segLineStyle, linewidth=2) + + segmentXs.extend(xs) + segmentYs.extend(ys) + + # Track for legend (color mode only) - always use solid lines in legend + if ammoStyle == 'color' and ammoName not in [ls[2] for ls in legendSegments]: + legendSegments.append((segColor, 'solid', ammoName)) + + # Store combined data for X mark lookup + if segmentXs and segmentYs: + # Store segment boundaries for fast ammo name lookup during drag + segmentData = [] + for seg in segments: + if seg['xs']: + segmentData.append((min(seg['xs']), max(seg['xs']), seg.get('ammo', 'Unknown'))) + plotData[(source, target)] = (segmentXs, segmentYs, segmentData) + allXs.update(segmentXs) + allYs.update(segmentYs) + + # Add legend entries + if ammoStyle == 'color': + # Add legend entry for each ammo type (avoid duplicates across targets) + existingLabels = [ld[2] for ld in legendData] + for segColor, segLineStyle, ammoName in legendSegments: + if ammoName not in existingLabels: + legendData.append((segColor, 'solid', ammoName)) + existingLabels.append(ammoName) + else: + # Single legend entry for this source (none or pattern mode) + if target is None: + legendData.append((baseRgbColor, 'solid', source.shortName)) + else: + legendData.append((baseRgbColor, 'solid', '{} vs {}'.format(source.shortName, target.shortName))) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + pyfalog.warning('Failed to get segments for "{}" vs "{}": {}'.format( + source.name, '' if target is None else target.name, e)) + + # Fall back to regular plotting if segments not available or failed + if not segmentsPlotted: + color = hsv_to_rgb(hsl_to_hsv(baseColor)) + try: + xs, ys = view.getPlotPoints( + mainInput=mainInput, + miscInputs=miscInputs, + xSpec=chosenX, + ySpec=chosenY, + src=source, + tgt=target) + if not self.__checkNumbers(xs, ys): + pyfalog.warning('Failed to plot "{}" vs "{}" due to inf or NaN in values'.format(source.name, '' if target is None else target.name)) + continue + # Track effective max X (where data actually ends) + if xs: + dataMaxX = max(xs) + if effectiveMaxX is None or dataMaxX > effectiveMaxX: + effectiveMaxX = dataMaxX + + plotData[(source, target)] = (xs, ys, None) + allXs.update(xs) + allYs.update(ys) + # If we have single data point, show marker - otherwise line won't be shown + if len(xs) == 1 and len(ys) == 1: + self.subplot.plot(xs, ys, color=color, linestyle=lineStyle, marker='.') + else: + self.subplot.plot(xs, ys, color=color, linestyle=lineStyle) + # Fill data for legend + if target is None: + legendData.append((color, lineStyle, source.shortName)) + else: + legendData.append((color, lineStyle, '{} vs {}'.format(source.shortName, target.shortName))) + except (KeyboardInterrupt, SystemExit): + raise + except Exception: + pyfalog.warning('Failed to plot "{}" vs "{}"'.format(source.name, '' if target is None else target.name)) + self.canvas.draw() + self.Refresh() + return # Setting Y limits for canvas if self.graphFrame.ctrlPanel.showY0: allYs.add(0) - canvasMinY, canvasMaxY = self._getLimits(allYs, minExtra=0.05, maxExtra=0.1) - canvasMinX, canvasMaxX = self._getLimits(allXs, minExtra=0.02, maxExtra=0.02) + # Include the user's input range in X limits so axis extends to full range + if mainInput and mainInput.value: + inputMin = min(mainInput.value) + inputMax = max(mainInput.value) + allXs.add(inputMin) + + # Initialize default input range on first draw (before any dynamic bounds are applied) + if self._defaultInputRange is None: + # Get the default range directly from the graph's Input definition + # This is the "true" default before any dynamic adjustments + try: + graphView = self.graphFrame.getView() + for inputDef in graphView.inputs: + if inputDef == mainInput: + self._defaultInputRange = (min(inputDef.defaultValue), max(inputDef.defaultValue)) + break + except (KeyboardInterrupt, SystemExit): + raise + except: + # Fallback: use current input as default + self._defaultInputRange = (inputMin, inputMax) + + # Check if user has manually modified the input field + # Compare current input to the original default range from graph definition + if not self._userModifiedInput and self._defaultInputRange is not None: + defaultMin, defaultMax = self._defaultInputRange + # If input range differs from the graph's default, user has manually modified it + if inputMin != defaultMin or inputMax != defaultMax: + self._userModifiedInput = True + + # Application Profile graph: use dynamic bounds ONLY on initial load + # Once user modifies input OR once dynamic bounds have been applied once, lock it + # Damage Stats graph: always uses static bounds (full input range) + useDynamicBounds = ( + effectiveMaxX is not None and + view.internalName == 'ammoOptimalDpsGraph' and + not self._userModifiedInput and + self._defaultInputRange is not None and + inputMax == self._defaultInputRange[1] # Only if input is still at default + ) + + if useDynamicBounds: + effectiveMaxXWithMargin = effectiveMaxX * 1 + allXs.add(effectiveMaxXWithMargin) + else: + allXs.add(inputMax) + canvasMinY, canvasMaxY = self._getLimits(allYs, minExtra=0.05, maxExtra=0.03, roundNice=True) + canvasMinX, canvasMaxX = self._getLimits(allXs, minExtra=0.02, maxExtra=0.02, roundNice=False) self.subplot.set_ylim(bottom=canvasMinY, top=canvasMaxY) self.subplot.set_xlim(left=canvasMinX, right=canvasMaxX) # Process X marks line @@ -196,29 +409,23 @@ def draw(self, accurateMarks=True): maxY = max(allYs, default=None) yDiff = (maxY or 0) - (minY or 0) xMark = max(min(self.xMark, maxX), minX) - # If in top 10% of X coordinates, align labels differently - if xMark > canvasMinX + 0.9 * (canvasMaxX - canvasMinX): - labelAlignment = 'right' - labelPrefix = '' - labelSuffix = ' ' - else: - labelAlignment = 'left' - labelPrefix = ' ' - labelSuffix = '' - # Draw line + + # Draw line first self.subplot.axvline(x=xMark, linestyle='dotted', linewidth=1, color=(0, 0, 0)) - # Draw its X position + + # Prepare X label text (without prefix/suffix yet) if chosenX.unit is None: - xLabel = '{}{}{}'.format(labelPrefix, roundToPrec(xMark, 4), labelSuffix) + xLabelCore = '{}'.format(roundToPrec(xMark, 4)) else: - xLabel = '{}{} {}{}'.format(labelPrefix, roundToPrec(xMark, 4), chosenX.unit, labelSuffix) - self.subplot.annotate( - xLabel, xy=(xMark, canvasMaxY - 0.01 * (canvasMaxY - canvasMinY)), xytext=(0, 0), annotation_clip=False, - textcoords='offset pixels', ha=labelAlignment, va='top', fontsize='small') - # Get Y values - yMarks = set() + xLabelCore = '{} {}'.format(roundToPrec(xMark, 4), chosenX.unit) + + # Text outline effect for better visibility + textOutline = [PathEffects.withStroke(linewidth=3, foreground='white')] + + # Get Y values with optional extra info (like ammo name) + yMarks = {} # {rounded_value: extra_info_str} - def addYMark(val): + def addYMark(val, extraInfo=None): if val is None: return # Round according to shown Y range - the bigger the range, @@ -230,23 +437,42 @@ def addYMark(val): # If due to some bug or insufficient plot density we're # out of bounds, do not add anything if minY <= val <= maxY or minY <= rounded <= maxY: - yMarks.add(rounded) + yMarks[rounded] = extraInfo for source, target in iterList: - xs, ys = plotData[(source, target)] + if (source, target) not in plotData: + continue + plotEntry = plotData[(source, target)] + xs, ys = plotEntry[0], plotEntry[1] + segmentData = plotEntry[2] if len(plotEntry) > 2 else None if not xs or xMark < min(xs) or xMark > max(xs): continue # Fetch values from graphs when we're asked to provide accurate data if accurateMarks: try: - y = view.getPoint( - x=xMark, - miscInputs=miscInputs, - xSpec=chosenX, - ySpec=chosenY, - src=source, - tgt=target) - addYMark(y) + # Try extended point info first (for ammo name etc.) + if hasattr(view, 'getPointExtended'): + y, extraInfo = view.getPointExtended( + x=xMark, + miscInputs=miscInputs, + xSpec=chosenX, + ySpec=chosenY, + src=source, + tgt=target) + # Build extra info string + extraStr = None + if extraInfo and extraInfo.get('ammo'): + extraStr = extraInfo['ammo'] + addYMark(y, extraStr) + else: + y = view.getPoint( + x=xMark, + miscInputs=miscInputs, + xSpec=chosenX, + ySpec=chosenY, + src=source, + tgt=target) + addYMark(y) except (KeyboardInterrupt, SystemExit): raise except Exception: @@ -255,20 +481,116 @@ def addYMark(val): continue # Otherwise just do linear interpolation between two points else: + # Get ammo name from segment data (fast and accurate) + extraStr = None + if segmentData: + for min_x, max_x, ammo_name in segmentData: + if min_x <= xMark <= max_x: + extraStr = ammo_name + break + # If xMark is beyond all segments, use last segment's ammo + if extraStr is None and segmentData: + extraStr = segmentData[-1][2] + if xMark in xs: # We might have multiples of the same value in our sequence, pick value for the last one idx = len(xs) - xs[::-1].index(xMark) - 1 - addYMark(ys[idx]) + addYMark(ys[idx], extraStr) continue idx = bisect(xs, xMark) yMark = self._interpolateX(x=xMark, x1=xs[idx - 1], y1=ys[idx - 1], x2=xs[idx], y2=ys[idx]) - addYMark(yMark) + addYMark(yMark, extraStr) + + # Draw Y values with optional extra info + # First, collect all labels to determine the widest one + labelData = [] # List of (yMark, labelText) + + # For DPS graphs (Damage Stats and Application Profile), show integers + isDpsGraph = view.internalName in ('dmgStatsGraph', 'ammoOptimalDpsGraph') + + for yMark, extraInfo in yMarks.items(): + # Format yMark as integer for DPS graphs + if isDpsGraph: + yMarkStr = '{:.0f}'.format(yMark) + else: + yMarkStr = '{}'.format(yMark) - # Draw Y values - for yMark in yMarks: + if extraInfo: + labelText = '{} ({})'.format(yMarkStr, extraInfo) + else: + labelText = yMarkStr + labelData.append((yMark, labelText)) + + # Determine alignment based on position in data range + # Use a simple percentage-based approach but factor in text length + # by using a smaller threshold for longer text + xRange = canvasMaxX - canvasMinX + xPosRatio = (xMark - canvasMinX) / xRange if xRange > 0 else 0 + + # Find the longest label to estimate how early we need to flip + maxLabelLen = len(xLabelCore) + for yMark, labelText in labelData: + maxLabelLen = max(maxLabelLen, len(labelText)) + + # Adjust threshold based on label length + # Short labels (< 15 chars): flip at 80% + # Medium labels (15-30 chars): flip at 65% + # Long labels (> 30 chars): flip at 50% + if maxLabelLen < 15: + flipThreshold = 0.80 + elif maxLabelLen < 30: + flipThreshold = 0.65 + else: + flipThreshold = 0.50 + + if xPosRatio > flipThreshold: + labelAlignment = 'right' + labelPrefix = '' + labelSuffix = ' ' + else: + labelAlignment = 'left' + labelPrefix = ' ' + labelSuffix = '' + + # Unify Y label offsetting logic with blit path + textOutline = [PathEffects.withStroke(linewidth=3, foreground='white')] + + # Draw X label + xLabel = '{}{}{}'.format(labelPrefix, xLabelCore, labelSuffix) + self.subplot.annotate( + xLabel, xy=(xMark, canvasMaxY - 0.01 * (canvasMaxY - canvasMinY)), xytext=(0, 0), annotation_clip=False, + textcoords='offset pixels', ha=labelAlignment, va='top', fontsize='small', + path_effects=textOutline) + + # Draw Y labels with fixed pixel offset for anti-overlap + labelData.sort(key=lambda x: x[0]) + pixel_pad = 8 # 8 pixels padding top/bottom + pixel_spacing = 16 # 16 pixels minimum spacing between labels + adjusted_y = [] + # Convert pixel spacing to data units using the axis transform + trans = self.subplot.transData.inverted() + # Get the pixel height of the graph area + bbox = self.subplot.get_window_extent() + y0_pix = bbox.y0 + y1_pix = bbox.y1 + # Calculate data units per pixel + data_per_pix = (canvasMaxY - canvasMinY) / (y1_pix - y0_pix) + min_pad = pixel_pad * data_per_pix + min_spacing = pixel_spacing * data_per_pix + for i, (yMark, labelText) in enumerate(labelData): + # Clamp to graph area with padding + yMark = max(min(yMark, canvasMaxY - min_pad), canvasMinY + min_pad) + if i > 0: + prev_y = adjusted_y[-1] + if yMark - prev_y < min_spacing: + yMark = prev_y + min_spacing + yMark = min(yMark, canvasMaxY - min_pad) + adjusted_y.append(yMark) + label = '{}{}{}'.format(labelPrefix, labelText, labelSuffix) self.subplot.annotate( - '{}{}{}'.format(labelPrefix, yMark, labelSuffix), xy=(xMark, yMark), xytext=(0, 0), - textcoords='offset pixels', ha=labelAlignment, va='center', fontsize='small') + label, xy=(xMark, yMark), xytext=(0, 0), + textcoords='offset pixels', ha=labelAlignment, va='center', fontsize='small', + path_effects=textOutline) legendLines = [] for i, iData in enumerate(legendData): @@ -284,18 +606,255 @@ def addYMark(val): self.canvas.draw() self.Refresh() + # Always save the background for blitting after drawing the graph, before drawing the X marker + self._blitBackground = self.canvas.copy_from_bbox(self.subplot.bbox) + # Cache data needed for fast X marker interpolation during drag + self._blitPlotData = plotData + self._blitView = view + self._blitIterList = iterList + self._blitCanvasLimits = (canvasMinX, canvasMaxX, canvasMinY, canvasMaxY) + self._blitChosenX = chosenX + self._blitChosenY = chosenY + minY = min(allYs, default=0) + maxY = max(allYs, default=0) + self._blitYDiff = maxY - minY + self._blitHasSegments = hasSegments + + def _drawXMarkerBlit(self, xMark): + """Fast X marker update using matplotlib blitting. + + Only redraws the X marker line and labels, not the entire plot. + Returns True if blit was successful, False if full redraw needed. + """ + # Check if we have cached data for blitting + if (self._blitBackground is None or + self._blitPlotData is None or + self._blitCanvasLimits is None): + return False + + canvasMinX, canvasMaxX, canvasMinY, canvasMaxY = self._blitCanvasLimits + + # Clamp xMark to canvas bounds + if xMark is None or xMark < canvasMinX or xMark > canvasMaxX: + return False + + # Restore the clean background (without X marker) + self.canvas.restore_region(self._blitBackground) + + # Remove old X marker artists + for artist in self._xMarkerArtists: + try: + artist.remove() + except (KeyboardInterrupt, SystemExit): + raise + except: + pass + self._xMarkerArtists = [] + + # Draw new X marker line + line = self.subplot.axvline(x=xMark, linestyle='dotted', linewidth=1, color=(0, 0, 0), animated=True) + self._xMarkerArtists.append(line) + + # Prepare X label + chosenX = self._blitChosenX + if chosenX.unit is None: + xLabelCore = '{}'.format(roundToPrec(xMark, 4)) + else: + xLabelCore = '{} {}'.format(roundToPrec(xMark, 4), chosenX.unit) + + # Calculate Y marks via interpolation + yMarks = {} + yDiff = self._blitYDiff + minY = canvasMinY + maxY = canvasMaxY + + def addYMark(val, extraInfo=None): + if val is None: + return + if yDiff != 0: + rounded = roundToPrec(val, 4, nsValue=yDiff) + else: + rounded = val + if minY <= val <= maxY or minY <= rounded <= maxY: + yMarks[rounded] = extraInfo + + view = self._blitView + plotData = self._blitPlotData + iterList = self._blitIterList + + for source, target in iterList: + if (source, target) not in plotData: + continue + plotEntry = plotData[(source, target)] + xs, ys = plotEntry[0], plotEntry[1] + segmentData = plotEntry[2] if len(plotEntry) > 2 else None + if not xs or xMark < min(xs) or xMark > max(xs): + continue + + # Get ammo name from segment data (fast and accurate) + extraStr = None + if segmentData: + for min_x, max_x, ammo_name in segmentData: + if min_x <= xMark <= max_x: + extraStr = ammo_name + break + # If xMark is beyond all segments, use last segment's ammo + if extraStr is None and segmentData: + extraStr = segmentData[-1][2] + + # Interpolate Y value + if xMark in xs: + idx = len(xs) - xs[::-1].index(xMark) - 1 + addYMark(ys[idx], extraStr) + else: + idx = bisect(xs, xMark) + if idx > 0 and idx < len(xs): + yMark = self._interpolateX(x=xMark, x1=xs[idx - 1], y1=ys[idx - 1], x2=xs[idx], y2=ys[idx]) + addYMark(yMark, extraStr) + + # Build label data + labelData = [] + isDpsGraph = view.internalName in ('dmgStatsGraph', 'ammoOptimalDpsGraph') + + for yMark, extraInfo in yMarks.items(): + if isDpsGraph: + yMarkStr = '{:.0f}'.format(yMark) + else: + yMarkStr = '{}'.format(yMark) + + if extraInfo: + labelText = '{} ({})'.format(yMarkStr, extraInfo) + else: + labelText = yMarkStr + labelData.append((yMark, labelText)) + + # Determine alignment + xRange = canvasMaxX - canvasMinX + xPosRatio = (xMark - canvasMinX) / xRange if xRange > 0 else 0 + + maxLabelLen = len(xLabelCore) + for yMark, labelText in labelData: + maxLabelLen = max(maxLabelLen, len(labelText)) + + if maxLabelLen < 15: + flipThreshold = 0.80 + elif maxLabelLen < 30: + flipThreshold = 0.65 + else: + flipThreshold = 0.50 + + if xPosRatio > flipThreshold: + labelAlignment = 'right' + labelPrefix = '' + labelSuffix = ' ' + else: + labelAlignment = 'left' + labelPrefix = ' ' + labelSuffix = '' + + textOutline = [PathEffects.withStroke(linewidth=3, foreground='white')] + + # Draw X label + xLabel = '{}{}{}'.format(labelPrefix, xLabelCore, labelSuffix) + ann = self.subplot.annotate( + xLabel, xy=(xMark, canvasMaxY - 0.01 * (canvasMaxY - canvasMinY)), xytext=(0, 0), + annotation_clip=False, textcoords='offset pixels', ha=labelAlignment, va='top', + fontsize='small', path_effects=textOutline, animated=True) + self._xMarkerArtists.append(ann) + + # Draw Y labels with fixed pixel offset for anti-overlap (same as non-drag) + labelData.sort(key=lambda x: x[0]) + pixel_pad = 8 # 8 pixels padding top/bottom + pixel_spacing = 16 # 16 pixels minimum spacing between labels + adjusted_y = [] + trans = self.subplot.transData.inverted() + bbox = self.subplot.get_window_extent() + y0_pix = bbox.y0 + y1_pix = bbox.y1 + data_per_pix = (canvasMaxY - canvasMinY) / (y1_pix - y0_pix) + min_pad = pixel_pad * data_per_pix + min_spacing = pixel_spacing * data_per_pix + for i, (yMark, labelText) in enumerate(labelData): + # Clamp to graph area with padding + yMark = max(min(yMark, canvasMaxY - min_pad), canvasMinY + min_pad) + if i > 0: + prev_y = adjusted_y[-1] + if yMark - prev_y < min_spacing: + yMark = prev_y + min_spacing + yMark = min(yMark, canvasMaxY - min_pad) + adjusted_y.append(yMark) + label = '{}{}{}'.format(labelPrefix, labelText, labelSuffix) + ann = self.subplot.annotate( + label, xy=(xMark, yMark), xytext=(0, 0), + textcoords='offset pixels', ha=labelAlignment, va='center', + fontsize='small', path_effects=textOutline, animated=True) + self._xMarkerArtists.append(ann) + + # Draw the animated artists + for artist in self._xMarkerArtists: + self.subplot.draw_artist(artist) + + # Blit the updated region + self.canvas.blit(self.subplot.bbox) + + return True def markXApproximate(self, x): if x is not None: self.xMark = x - self.draw(accurateMarks=False) + # Try fast blit path first, fall back to full redraw + if not self._drawXMarkerBlit(x): + self.draw(accurateMarks=False) def unmarkX(self): self.xMark = None + # Clear blit state so next draw() saves fresh background + self._blitBackground = None + self._xMarkerArtists = [] self.draw() @staticmethod - def _getLimits(vals, minExtra=0, maxExtra=0): + def _roundToNice(val, direction='up', maxIncrease=0.15): + """ + Round a value to a 'nice' number (1, 2, 5, or 10 multiplied by power of 10). + This helps stabilize Y-axis limits and reduce flickering. + + Args: + val: Value to round + direction: 'up' to round up (for max), 'down' to round down (for min) + maxIncrease: Maximum allowed increase as a fraction (default 15%) + """ + if val == 0: + return 0 + + sign = 1 if val >= 0 else -1 + absVal = abs(val) + + # Find the order of magnitude + magnitude = 10 ** math.floor(math.log10(absVal)) + normalized = absVal / magnitude + + # Nice numbers: 1, 2, 5, 10 + nice_numbers = [1, 2, 5, 10] + + if direction == 'up': + # Round up to next nice number, but cap the increase + maxAllowed = absVal * (1 + maxIncrease) + for nice in nice_numbers: + candidate = nice * magnitude + if normalized <= nice and candidate <= maxAllowed: + return sign * candidate + # If all nice numbers exceed maxIncrease, just return with small buffer + return sign * absVal * 1.05 + else: + # Round down to previous nice number + for nice in reversed(nice_numbers): + if normalized >= nice: + return sign * nice * magnitude + return sign * magnitude + + @staticmethod + def _getLimits(vals, minExtra=0, maxExtra=0, roundNice=False): minVal = min(vals, default=0) maxVal = max(vals, default=0) # Extend range a little for some visual space @@ -310,6 +869,9 @@ def _getLimits(vals, minExtra=0, maxExtra=0): if minVal == maxVal: minVal -= 5 maxVal += 5 + # Round to nice values to reduce Y-axis flickering (only for Y-axis) + if roundNice and maxVal > 0: + maxVal = GraphCanvasPanel._roundToNice(maxVal, 'up') return minVal, maxVal @staticmethod @@ -332,6 +894,13 @@ def OnMplCanvasClick(self, event): self.mplOnDragHandler = self.canvas.mpl_connect('motion_notify_event', self.OnMplCanvasDrag) if not self.mplOnReleaseHandler: self.mplOnReleaseHandler = self.canvas.mpl_connect('button_release_event', self.OnMplCanvasRelease) + # On drag start, always cache background with no X marker + prevXMark = self.xMark + self.xMark = None + self.draw(accurateMarks=False) + self._blitBackground = self.canvas.copy_from_bbox(self.subplot.bbox) + # Set X marker to drag position and start moving + self.xMark = event.xdata self.markXApproximate(event.xdata) elif event.button == 3: self.unmarkX() diff --git a/graphs/gui/ctrlPanel.py b/graphs/gui/ctrlPanel.py index 418bbe468d..7dc07594c8 100644 --- a/graphs/gui/ctrlPanel.py +++ b/graphs/gui/ctrlPanel.py @@ -25,7 +25,7 @@ from gui.bitmap_loader import BitmapLoader from gui.contextMenu import ContextMenu -from gui.utils.inputs import FloatBox, FloatRangeBox +from gui.utils.inputs import FloatBox, FloatRangeBox, valToStr from service.const import GraphCacheCleanupReason from service.fit import Fit from .lists import SourceWrapperList, TargetWrapperList @@ -47,40 +47,81 @@ def __init__(self, graphFrame, parent): self._inputCheckboxes = [] self._storedRanges = {} self._storedConsts = {} + self._lastDynamicRange = None # Track last applied dynamic range + self._userModifiedMainInput = False # Flag: has user manually changed main input? mainSizer = wx.BoxSizer(wx.VERTICAL) optsSizer = wx.BoxSizer(wx.HORIZONTAL) commonOptsSizer = wx.BoxSizer(wx.VERTICAL) + + # Row 1: Y axis ySubSelectionSizer = wx.BoxSizer(wx.HORIZONTAL) yText = wx.StaticText(self, wx.ID_ANY, _t('Axis Y:')) ySubSelectionSizer.Add(yText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) self.ySubSelection = wx.Choice(self, wx.ID_ANY) self.ySubSelection.Bind(wx.EVT_CHOICE, self.OnYTypeUpdate) - ySubSelectionSizer.Add(self.ySubSelection, 1, wx.EXPAND | wx.ALL, 0) - commonOptsSizer.Add(ySubSelectionSizer, 0, wx.EXPAND | wx.ALL, 0) + ySubSelectionSizer.Add(self.ySubSelection, 1, wx.EXPAND, 0) + commonOptsSizer.Add(ySubSelectionSizer, 0, wx.EXPAND, 0) - xSubSelectionSizer = wx.BoxSizer(wx.HORIZONTAL) - xText = wx.StaticText(self, wx.ID_ANY, _t('Axis X:')) - xSubSelectionSizer.Add(xText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) + # Row 2: X axis (hidden for segment graphs) + self.xSubSelectionSizer = wx.BoxSizer(wx.HORIZONTAL) + self.xText = wx.StaticText(self, wx.ID_ANY, _t('Axis X:')) + self.xSubSelectionSizer.Add(self.xText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) self.xSubSelection = wx.Choice(self, wx.ID_ANY) self.xSubSelection.Bind(wx.EVT_CHOICE, self.OnXTypeUpdate) - xSubSelectionSizer.Add(self.xSubSelection, 1, wx.EXPAND | wx.ALL, 0) - commonOptsSizer.Add(xSubSelectionSizer, 0, wx.EXPAND | wx.TOP, 5) - + self.xSubSelectionSizer.Add(self.xSubSelection, 1, wx.EXPAND, 0) + commonOptsSizer.Add(self.xSubSelectionSizer, 0, wx.EXPAND | wx.TOP, 5) + + # Row 3: Color dropdown (only shown for graphs with segments) - Quality is in right column + self.ammoStyleSizer = wx.BoxSizer(wx.HORIZONTAL) + self.ammoStyleText = wx.StaticText(self, wx.ID_ANY, _t('Style:')) + self.ammoStyleSizer.Add(self.ammoStyleText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) + self.ammoStyleSelection = wx.Choice(self, wx.ID_ANY) + self.ammoStyleSelection.Append(_t('None'), 'none') + self.ammoStyleSelection.Append(_t('Pattern'), 'pattern') + self.ammoStyleSelection.Append(_t('Color'), 'color') + self.ammoStyleSelection.SetSelection(2) # Default to Color + self.ammoStyleSelection.Bind(wx.EVT_CHOICE, self.OnAmmoStyleChange) + self.ammoStyleSizer.Add(self.ammoStyleSelection, 1, wx.EXPAND, 0) + commonOptsSizer.Add(self.ammoStyleSizer, 0, wx.EXPAND | wx.TOP, 5) + + # Row 4: Ammo Meta dropdown (moved from right column) + self.ammoQualitySizer = wx.BoxSizer(wx.HORIZONTAL) + self.ammoQualityText = wx.StaticText(self, wx.ID_ANY, _t('Ammo Meta:')) + self.ammoQualitySizer.Add(self.ammoQualityText, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) + self.ammoQualitySelection = wx.Choice(self, wx.ID_ANY) + self.ammoQualitySelection.Append(_t('T1'), 't1') + self.ammoQualitySelection.Append(_t('Navy'), 'navy') + self.ammoQualitySelection.Append(_t('All'), 'all') + self.ammoQualitySelection.SetSelection(1) # Default to Navy + self.ammoQualitySelection.Bind(wx.EVT_CHOICE, self.OnAmmoQualityChange) + self.ammoQualitySizer.Add(self.ammoQualitySelection, 1, wx.EXPAND, 0) + commonOptsSizer.Add(self.ammoQualitySizer, 0, wx.EXPAND | wx.TOP, 5) + + # Row 5: Show legend checkbox self.showLegendCb = wx.CheckBox(self, wx.ID_ANY, _t('Show legend'), wx.DefaultPosition, wx.DefaultSize, 0) self.showLegendCb.SetValue(True) self.showLegendCb.Bind(wx.EVT_CHECKBOX, self.OnShowLegendChange) - commonOptsSizer.Add(self.showLegendCb, 0, wx.EXPAND | wx.TOP, 5) + commonOptsSizer.Add(self.showLegendCb, 0, wx.TOP, 5) self.showY0Cb = wx.CheckBox(self, wx.ID_ANY, _t('Always show Y = 0'), wx.DefaultPosition, wx.DefaultSize, 0) self.showY0Cb.SetValue(True) self.showY0Cb.Bind(wx.EVT_CHECKBOX, self.OnShowY0Change) commonOptsSizer.Add(self.showY0Cb, 0, wx.EXPAND | wx.TOP, 5) + optsSizer.Add(commonOptsSizer, 0, wx.EXPAND | wx.RIGHT, 10) + # Right column: inputs graphOptsSizer = wx.BoxSizer(wx.HORIZONTAL) + + # Container for inputs (normal graphs) + self.rightColumnSizer = wx.BoxSizer(wx.VERTICAL) + + # Input fields sizer (shown for normal graphs) - at the top self.inputsSizer = wx.BoxSizer(wx.VERTICAL) - graphOptsSizer.Add(self.inputsSizer, 1, wx.EXPAND | wx.ALL, 0) + self.rightColumnSizer.Add(self.inputsSizer, 0, wx.EXPAND, 0) + + graphOptsSizer.Add(self.rightColumnSizer, 1, wx.EXPAND | wx.ALL, 0) vectorSize = 90 if 'wxGTK' in wx.PlatformInfo else 75 self.srcVectorSizer = wx.BoxSizer(wx.VERTICAL) @@ -134,6 +175,14 @@ def updateControls(self, layout=True): if layout: self.Freeze() self._clearStoredValues() + # Forget any manual input-range override from the previous graph. The + # control and canvas panels are single instances shared across every + # graph, so these flags must be reset on switch or dynamic auto-ranging + # stays suppressed for the rest of the session once the user edits the + # distance field on any one graph. + self._lastDynamicRange = None + self._userModifiedMainInput = False + self.graphFrame.canvasPanel.resetDynamicBoundsTracking() view = self.graphFrame.getView() self.refreshAxeLabels() @@ -159,6 +208,28 @@ def updateControls(self, layout=True): self.refreshColumns(layout=False) self.targetList.Show(view.hasTargets) + # Ammo options and X axis visibility (only for graphs with segments) + hasSegments = getattr(view, 'hasSegments', False) + # Hide X axis dropdown for segment graphs (Application Profile) + self.xText.Show(not hasSegments) + self.xSubSelection.Show(not hasSegments) + self.xSubSelectionSizer.ShowItems(not hasSegments) + # Show ammo style (Color) dropdown for segment graphs (left column) + self.ammoStyleText.Show(hasSegments) + self.ammoStyleSelection.Show(hasSegments) + self.ammoStyleSizer.ShowItems(hasSegments) + # Show ammo quality dropdown for segment graphs (right column) + self.ammoQualityText.Show(hasSegments) + self.ammoQualitySelection.Show(hasSegments) + self.ammoQualitySizer.ShowItems(hasSegments) + + # Check if we need to auto-switch ammo style when switching to/from segmented graphs + if hasSegments: + # First check if we should switch back to color (no conflicts) + self.sourceList._checkAutoSwitchBackToColor() + # Then check if we need to switch to pattern (conflicts exist) + self.sourceList._checkAutoSwitchAmmoStyle() + # Inputs self._updateInputs(storeInputs=False) @@ -229,7 +300,14 @@ def __addInputField(self, inputDef, handledHandles, mainInput=False): fieldSizer = wx.BoxSizer(wx.HORIZONTAL) tooltipText = (inputDef.mainTooltip if mainInput else inputDef.secondaryTooltip) or '' if mainInput: - fieldTextBox = FloatRangeBox(self, self._storedRanges.get((inputDef.handle, inputDef.unit), inputDef.defaultRange)) + # Check if view has a dynamic default range method + view = self.graphFrame.getView() + defaultRange = inputDef.defaultRange + if hasattr(view, 'getDefaultInputRange'): + dynamicRange = view.getDefaultInputRange(inputDef, self.sources) + if dynamicRange is not None: + defaultRange = dynamicRange + fieldTextBox = FloatRangeBox(self, self._storedRanges.get((inputDef.handle, inputDef.unit), defaultRange)) fieldTextBox.Bind(wx.EVT_TEXT, self.OnMainInputChanged) else: fieldTextBox = FloatBox(self, self._storedConsts.get((inputDef.handle, inputDef.unit), inputDef.defaultValue)) @@ -313,6 +391,8 @@ def refreshColumns(self, layout=True): view = self.graphFrame.getView() self.sourceList.refreshExtraColumns(view.srcExtraCols) self.targetList.refreshExtraColumns(view.tgtExtraCols) + # Also refresh default columns for target list based on ammo style + self.targetList.refreshDefaultColumns() self.srcTgtSizer.Detach(self.sourceList) self.srcTgtSizer.Detach(self.targetList) self.srcTgtSizer.Add(self.sourceList, self.sourceList.getWidthProportion(), wx.EXPAND | wx.ALL, 0) @@ -327,6 +407,18 @@ def OnShowY0Change(self, event): event.Skip() self.graphFrame.draw() + def OnAmmoStyleChange(self, event): + event.Skip() + # Refresh target list columns to show/hide lightness/line style based on ammo style + self.targetList.refreshDefaultColumns() + self.graphFrame.draw() + + def OnAmmoQualityChange(self, event): + event.Skip() + # Clear cache when quality changes since we need to recalculate with different ammo + self.graphFrame.clearCache(reason=GraphCacheCleanupReason.inputChanged) + self.graphFrame.draw() + def OnYTypeUpdate(self, event): event.Skip() self._updateInputs() @@ -359,6 +451,64 @@ def OnInputTimer(self, event): self.graphFrame.clearCache(reason=GraphCacheCleanupReason.inputChanged) self.graphFrame.draw() + def _refreshMainInputRange(self): + """ + Refresh the main input field's range based on current fit data. + + Called when fits change to update the distance range dynamically + for graphs that support getDefaultInputRange (like Application Profile). + """ + # If user has manually modified the main input, never override it + if self._userModifiedMainInput: + return + + if self._mainInputBox is None: + return + + view = self.graphFrame.getView() + if not hasattr(view, 'getDefaultInputRange'): + return + + # Get the input definition for the main input + mainInputKey = self.xType.mainInput + if mainInputKey not in view.inputMap: + return + + inputDef = view.inputMap[mainInputKey] + + # Check if user has manually modified the input field since last dynamic update + currentRange = self._mainInputBox.textBox.GetValueRange() + if currentRange: + currentMin, currentMax = currentRange + # Get the baseline to compare against + if self._lastDynamicRange is not None: + baselineMin, baselineMax = self._lastDynamicRange + else: + baselineMin, baselineMax = inputDef.defaultRange + + # If current range differs from the baseline, user has manually changed it + # Set the flag permanently to prevent future overrides + if currentMin != baselineMin or currentMax != baselineMax: + self._userModifiedMainInput = True + return + + # Calculate the new dynamic range + dynamicRange = view.getDefaultInputRange(inputDef, self.sources) + if dynamicRange is None: + dynamicRange = inputDef.defaultRange + + # Store this as the last dynamic range we applied + self._lastDynamicRange = dynamicRange + + # Clear the stored range so the new default is used + storedKey = (inputDef.handle, inputDef.unit) + if storedKey in self._storedRanges: + del self._storedRanges[storedKey] + + # Update the text box with the new range + self._mainInputBox.textBox.ChangeValue('{}-{}'.format( + valToStr(dynamicRange[0]), valToStr(dynamicRange[1]))) + def getValues(self): view = self.graphFrame.getView() misc = [] @@ -401,6 +551,26 @@ def showLegend(self): def showY0(self): return self.showY0Cb.GetValue() + @property + def ammoStyle(self): + """Returns ammo style: 'none', 'pattern', or 'color'""" + return self.ammoStyleSelection.GetClientData(self.ammoStyleSelection.GetSelection()) + + def setAmmoStyle(self, style): + """Set ammo style programmatically: 'none', 'pattern', or 'color'""" + for i in range(self.ammoStyleSelection.GetCount()): + if self.ammoStyleSelection.GetClientData(i) == style: + self.ammoStyleSelection.SetSelection(i) + # Trigger the same updates as OnAmmoStyleChange + self.targetList.refreshDefaultColumns() + self.graphFrame.draw() + return + + @property + def ammoQuality(self): + """Returns ammo quality tier: 't1', 'navy', or 'all'""" + return self.ammoQualitySelection.GetClientData(self.ammoQualitySelection.GetSelection()) + @property def yType(self): return self.ySubSelection.GetClientData(self.ySubSelection.GetSelection()) @@ -425,6 +595,8 @@ def OnFitRenamed(self, event): def OnFitChanged(self, event): self.sourceList.OnFitChanged(event) self.targetList.OnFitChanged(event) + # Refresh the main input's default range when fit changes + self._refreshMainInputRange() def OnFitRemoved(self, event): self.sourceList.OnFitRemoved(event) diff --git a/graphs/gui/frame.py b/graphs/gui/frame.py index 4313b81d70..c8c6c0b7b6 100644 --- a/graphs/gui/frame.py +++ b/graphs/gui/frame.py @@ -38,7 +38,7 @@ _t = wx.GetTranslation -REDRAW_DELAY = 500 +REDRAW_DELAY = 200 class GraphFrame(AuxiliaryFrame): diff --git a/graphs/gui/lists.py b/graphs/gui/lists.py index a63efebcd8..de4b103da9 100644 --- a/graphs/gui/lists.py +++ b/graphs/gui/lists.py @@ -22,6 +22,8 @@ import wx import gui.display +import gui.globalEvents as GE +from eos.const import FittingHardpoint from eos.saveddata.targetProfile import TargetProfile from graphs.style import BASE_COLORS, LIGHTNESSES, STYLES from graphs.wrapper import SourceWrapper, TargetWrapper @@ -29,12 +31,61 @@ from gui.builtinViewColumns.graphLightness import GraphLightness from gui.builtinViewColumns.graphLineStyle import GraphLineStyle from gui.contextMenu import ContextMenu -from service.const import GraphCacheCleanupReason +from service.const import GraphCacheCleanupReason, GraphLightness as GraphLightnessEnum, GraphLineStyle as GraphLineStyleEnum from service.fit import Fit from .stylePickers import ColorPickerPopup, LightnessPickerPopup, LineStylePickerPopup _t = wx.GetTranslation + +def getFitWeaponClass(fit): + """ + Determine the weapon class of a fit based on its turret or missile type. + + Returns: 'energy', 'projectile', 'hybrid', 'exotic', 'vorton', 'missile', or None if no weapons. + + Uses module group names instead of loading charges for performance. + """ + if fit is None: + return None + + # Try activeModulesIter first (more reliable), fall back to modules + modules = list(fit.activeModulesIter()) if hasattr(fit, 'activeModulesIter') else fit.modules + + for mod in modules: + if mod.isEmpty or mod.item is None: + continue + + # Check turret hardpoints - use module group name to determine type + if mod.hardpoint == FittingHardpoint.TURRET: + # Skip mining turrets + if mod.getModifiedItemAttr('miningAmount'): + continue + + # Get module group name to determine weapon class + if mod.item.group is None: + continue + + groupName = mod.item.group.name + + # Determine weapon class from module group + if 'Energy' in groupName or 'Laser' in groupName or 'Beam' in groupName or 'Pulse' in groupName: + return 'energy' + elif 'Projectile' in groupName or 'Autocannon' in groupName or 'Artillery' in groupName: + return 'projectile' + elif 'Hybrid' in groupName or 'Blaster' in groupName or 'Railgun' in groupName: + return 'hybrid' + elif 'Entropic' in groupName or 'Disintegrator' in groupName: + return 'exotic' + elif 'Vorton' in groupName or 'Arcing' in groupName: + return 'vorton' + + # Check missile hardpoints + elif mod.hardpoint == FittingHardpoint.MISSILE: + return 'missile' + + return None + class BaseWrapperList(gui.display.Display): def __init__(self, graphFrame, parent): @@ -242,23 +293,33 @@ def addFit(self, fit): return if self.containsFitID(fit.ID): return + # Ensure fit is fully recalculated before adding to graph + sFit = Fit.getInstance() + sFit.recalc(fit) self.appendItem(fit) self.updateView() - self.graphFrame.draw() + # Trigger FIT_CHANGED event to refresh all caches and views + wx.PostEvent(self.graphFrame.mainFrame, GE.FitChanged(fitIDs=(fit.ID,))) def getExistingFitIDs(self): return [w.item.ID for w in self._wrappers if w.isFit] def addFitsByIDs(self, fitIDs): sFit = Fit.getInstance() + addedFitIDs = [] for fitID in fitIDs: if self.containsFitID(fitID): continue fit = sFit.getFit(fitID) if fit is not None: + # Ensure fit is fully recalculated before adding to graph + sFit.recalc(fit) self.appendItem(fit) + addedFitIDs.append(fitID) self.updateView() - self.graphFrame.draw() + # Trigger FIT_CHANGED event to refresh all caches and views + if addedFitIDs: + wx.PostEvent(self.graphFrame.mainFrame, GE.FitChanged(fitIDs=tuple(addedFitIDs))) class SourceWrapperList(BaseWrapperList): @@ -296,6 +357,101 @@ def getDefaultParams(): colorID = getDefaultParams() self._wrappers.append(SourceWrapper(item=item, colorID=colorID)) + # Check if we should switch to Pattern mode (for Application Profile graph) + self._checkAutoSwitchAmmoStyle() + + def _checkAutoSwitchAmmoStyle(self): + """ + Auto-switch ammo style to Pattern when multiple fits with same weapon class are added. + + This helps differentiate between attackers when they use the same ammo types. + """ + # Check if ctrlPanel is fully initialized (has ammoStyleSelection) + ctrlPanel = getattr(self.graphFrame, 'ctrlPanel', None) + if ctrlPanel is None: + return + if not hasattr(ctrlPanel, 'ammoStyleSelection'): + return + + # Check if this graph supports segments (Application Profile) + try: + view = self.graphFrame.getView() + except Exception: + return + + if not getattr(view, 'hasSegments', False): + return + + # Get current ammo style + currentStyle = ctrlPanel.ammoStyle + + # Only auto-switch if currently on 'color' mode + if currentStyle != 'color': + return + + # Check if we have 2+ fits with the same weapon class + weaponClasses = {} + for wrapper in self._wrappers: + if not wrapper.isFit: + continue + wc = getFitWeaponClass(wrapper.item) + if wc: + weaponClasses[wc] = weaponClasses.get(wc, 0) + 1 + + # If any weapon class has 2+ fits, switch to pattern mode + for wc, count in weaponClasses.items(): + if count >= 2: + ctrlPanel.setAmmoStyle('pattern') + return + + def _checkAutoSwitchBackToColor(self): + """ + Auto-switch ammo style back to Color when no more weapon class conflicts exist. + + Called after removing a fit to see if we can switch back to color mode. + """ + # Check if ctrlPanel is fully initialized (has ammoStyleSelection) + ctrlPanel = getattr(self.graphFrame, 'ctrlPanel', None) + if ctrlPanel is None: + return + if not hasattr(ctrlPanel, 'ammoStyleSelection'): + return + + # Check if this graph supports segments (Application Profile) + try: + view = self.graphFrame.getView() + except Exception: + return + + if not getattr(view, 'hasSegments', False): + return + + # Get current ammo style + currentStyle = ctrlPanel.ammoStyle + + # Only auto-switch if currently on 'pattern' mode + if currentStyle != 'pattern': + return + + # Check if we still have 2+ fits with the same weapon class + weaponClasses = {} + for wrapper in self._wrappers: + if not wrapper.isFit: + continue + wc = getFitWeaponClass(wrapper.item) + if wc: + weaponClasses[wc] = weaponClasses.get(wc, 0) + 1 + + # If no weapon class has 2+ fits anymore, switch back to color mode + hasConflict = any(count >= 2 for count in weaponClasses.values()) + if not hasConflict: + ctrlPanel.setAmmoStyle('color') + + def removeWrappers(self, wrappers): + """Override to check if we should switch back to color mode after removal.""" + super().removeWrappers(wrappers) + self._checkAutoSwitchBackToColor() + def spawnMenu(self, event): clickedPos = self.getRowByAbs(event.Position) self.ensureSelection(clickedPos) @@ -329,26 +485,132 @@ def __init__(self, graphFrame, parent): self.appendItem(TargetProfile.getIdeal()) self.updateView() + def getFilteredDefaultCols(self): + """Return default columns filtered based on current ammo style. + + For the Application Profile graph (hasSegments=True): + - 'color' mode: Ammo determines line color, so hide Lightness (show Line Style only) + - 'pattern' mode: Ammo determines line pattern, so hide Line Style (show Lightness only) + - 'none' mode: Show both columns + + For other graphs, always show both columns. + """ + view = self.graphFrame.getView() + hasSegments = getattr(view, 'hasSegments', False) + + if not hasSegments: + return self.DEFAULT_COLS + + ammoStyle = self.graphFrame.ctrlPanel.ammoStyle + + if ammoStyle == 'color': + # Color mode: ammo color differentiates, use line style for targets + return tuple(c for c in self.DEFAULT_COLS if c != 'Graph Lightness') + elif ammoStyle == 'pattern': + # Pattern mode: ammo pattern differentiates, use lightness for targets + return tuple(c for c in self.DEFAULT_COLS if c != 'Graph Line Style') + else: + # None mode: show both + return self.DEFAULT_COLS + + def refreshDefaultColumns(self): + """Refresh the default columns based on current ammo style. + + Rebuilds all columns in correct order to maintain proper column positions. + """ + filteredCols = self.getFilteredDefaultCols() + + # Get base names of columns that should be shown + colNamesToShow = set() + for colName in filteredCols: + if ":" in colName: + colName = colName.split(":", 1)[0] + colNamesToShow.add(colName) + + # Check if we need to make any changes + currentStyleCols = [col.name for col in self.activeColumns + if col.name in ('Graph Lightness', 'Graph Line Style')] + targetStyleCols = [c for c in ('Graph Lightness', 'Graph Line Style') if c in colNamesToShow] + + if currentStyleCols == targetStyleCols: + # No changes needed + return + + # Save any extra columns (non-default columns added by the view) + extraCols = [col.name for col in self.activeColumns + if col.name not in ('Graph Lightness', 'Graph Line Style', 'Base Icon', 'Base Name')] + + # Remove ALL columns + while self.activeColumns: + self.removeColumn(self.activeColumns[0]) + + # Re-add columns in correct order using filtered defaults + for colName in filteredCols: + self.appendColumnBySpec(colName) + + # Re-add any extra columns + for colName in extraCols: + self.appendColumnBySpec(colName) + + self.refreshView() + def appendItem(self, item): - # Find out least used lightness - lightnessUseMap = {(l, s): 0 for l in LIGHTNESSES for s in STYLES} + # Find least used line style and least used lightness independently + # This ensures both properties iterate even when only one is visible + + # Count line style usage + lineStyleUseMap = {s: 0 for s in STYLES} for wrapper in self._wrappers: - key = (wrapper.lightnessID, wrapper.lineStyleID) - if key not in lightnessUseMap: - continue - lightnessUseMap[key] += 1 + if wrapper.lineStyleID in lineStyleUseMap: + lineStyleUseMap[wrapper.lineStyleID] += 1 + + # Count lightness usage + lightnessUseMap = {l: 0 for l in LIGHTNESSES} + for wrapper in self._wrappers: + if wrapper.lightnessID in lightnessUseMap: + lightnessUseMap[wrapper.lightnessID] += 1 + + # Find least used line style + leastLineStyleUses = min(lineStyleUseMap.values(), default=0) + lineStyleID = None + for sid in STYLES: + if lineStyleUseMap.get(sid, 0) == leastLineStyleUses: + lineStyleID = sid + break + + # Find least used lightness + leastLightnessUses = min(lightnessUseMap.values(), default=0) + lightnessID = None + for lid in LIGHTNESSES: + if lightnessUseMap.get(lid, 0) == leastLightnessUses: + lightnessID = lid + break - def getDefaultParams(): - leastUses = min(lightnessUseMap.values(), default=0) - for lineStyleID in STYLES: - for lightnessID in LIGHTNESSES: - if leastUses == lightnessUseMap.get((lightnessID, lineStyleID), 0): - return lightnessID, lineStyleID - return None, None - - lightnessID, lineStyleID = getDefaultParams() self._wrappers.append(TargetWrapper(item=item, lightnessID=lightnessID, lineStyleID=lineStyleID)) + def removeWrappers(self, wrappers): + """Override to reset remaining target to default style when only one remains.""" + # Call parent implementation + wrappers = set(wrappers).intersection(self._wrappers) + if not wrappers: + return + for wrapper in wrappers: + self._wrappers.remove(wrapper) + + # If only one target remains, reset it to default styles + if len(self._wrappers) == 1: + remaining = self._wrappers[0] + remaining.lightnessID = GraphLightnessEnum.normal + remaining.lineStyleID = GraphLineStyleEnum.solid + + self.updateView() + for wrapper in wrappers: + if wrapper.isFit: + self.graphFrame.clearCache(reason=GraphCacheCleanupReason.fitRemoved, extraData=wrapper.item.ID) + elif wrapper.isProfile: + self.graphFrame.clearCache(reason=GraphCacheCleanupReason.profileRemoved, extraData=wrapper.item.ID) + self.graphFrame.draw() + def spawnMenu(self, event): clickedPos = self.getRowByAbs(event.Position) self.ensureSelection(clickedPos) diff --git a/gui/builtinContextMenus/__init__.py b/gui/builtinContextMenus/__init__.py index a1a26e591c..5740565f35 100644 --- a/gui/builtinContextMenus/__init__.py +++ b/gui/builtinContextMenus/__init__.py @@ -52,6 +52,8 @@ # Graph extra options from gui.builtinContextMenus import graphDmgApplyProjected from gui.builtinContextMenus import graphDmgIgnoreResists +from gui.builtinContextMenus import graphAmmoOptimalIgnoreResists +from gui.builtinContextMenus import graphAmmoOptimalApplyProjected from gui.builtinContextMenus import graphLockRange from gui.builtinContextMenus import graphDroneControlRange from gui.builtinContextMenus import graphDmgDroneMode diff --git a/gui/builtinContextMenus/graphAmmoOptimalApplyProjected.py b/gui/builtinContextMenus/graphAmmoOptimalApplyProjected.py new file mode 100644 index 0000000000..32c0859d40 --- /dev/null +++ b/gui/builtinContextMenus/graphAmmoOptimalApplyProjected.py @@ -0,0 +1,33 @@ +# noinspection PyPackageRequirements + +import wx + +import gui.globalEvents as GE +import gui.mainFrame +from gui.contextMenu import ContextMenuUnconditional +from service.settings import GraphSettings + +_t = wx.GetTranslation + + +class GraphAmmoOptimalApplyProjectedMenu(ContextMenuUnconditional): + + def __init__(self): + self.mainFrame = gui.mainFrame.MainFrame.getInstance() + self.settings = GraphSettings.getInstance() + + def display(self, callingWindow, srcContext): + return srcContext == 'ammoOptimalDpsGraph' + + def getText(self, callingWindow, itmContext): + return _t('Apply Projected Effects') + + def activate(self, callingWindow, fullContext, i): + self.settings.set('ammoOptimalApplyProjected', not self.settings.get('ammoOptimalApplyProjected')) + wx.PostEvent(self.mainFrame, GE.GraphOptionChanged()) + + def isChecked(self, i): + return self.settings.get('ammoOptimalApplyProjected') + + +GraphAmmoOptimalApplyProjectedMenu.register() diff --git a/gui/builtinContextMenus/graphAmmoOptimalIgnoreResists.py b/gui/builtinContextMenus/graphAmmoOptimalIgnoreResists.py new file mode 100644 index 0000000000..b721452985 --- /dev/null +++ b/gui/builtinContextMenus/graphAmmoOptimalIgnoreResists.py @@ -0,0 +1,33 @@ +# noinspection PyPackageRequirements + +import wx + +import gui.globalEvents as GE +import gui.mainFrame +from gui.contextMenu import ContextMenuUnconditional +from service.settings import GraphSettings + +_t = wx.GetTranslation + + +class GraphAmmoOptimalIgnoreResistsMenu(ContextMenuUnconditional): + + def __init__(self): + self.mainFrame = gui.mainFrame.MainFrame.getInstance() + self.settings = GraphSettings.getInstance() + + def display(self, callingWindow, srcContext): + return srcContext == 'ammoOptimalDpsGraph' + + def getText(self, callingWindow, itmContext): + return _t('Ignore Target Resists') + + def activate(self, callingWindow, fullContext, i): + self.settings.set('ammoOptimalIgnoreResists', not self.settings.get('ammoOptimalIgnoreResists')) + wx.PostEvent(self.mainFrame, GE.GraphOptionChanged(refreshAxeLabels=True, refreshColumns=True)) + + def isChecked(self, i): + return self.settings.get('ammoOptimalIgnoreResists') + + +GraphAmmoOptimalIgnoreResistsMenu.register() diff --git a/service/settings.py b/service/settings.py index e80b2f6ff6..9d24469033 100644 --- a/service/settings.py +++ b/service/settings.py @@ -537,6 +537,8 @@ def __init__(self): 'mobileDroneMode': GraphDpsDroneMode.auto, 'ignoreDCR': False, 'ignoreResists': True, + 'ammoOptimalIgnoreResists': True, + 'ammoOptimalApplyProjected': True, 'ignoreLockRange': True, 'applyProjected': True} self.settings = SettingsProvider.getInstance().getSettings('graphSettings', defaults) From c6f20892e60f5ae4c66190f047473447c425bb55 Mon Sep 17 00:00:00 2001 From: Sven Date: Wed, 24 Jun 2026 11:42:49 -0400 Subject: [PATCH 2/2] Updated Navy Ammo meta filter to be cumulative instead of exclusive buckets to fix bug related to Exotic plasma not having navy variants. --- .../fitApplicationProfile/calc/charges.py | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/graphs/data/fitApplicationProfile/calc/charges.py b/graphs/data/fitApplicationProfile/calc/charges.py index dff3a310b6..2eda421c6e 100644 --- a/graphs/data/fitApplicationProfile/calc/charges.py +++ b/graphs/data/fitApplicationProfile/calc/charges.py @@ -27,7 +27,6 @@ 'Republic Fleet ', 'Caldari Navy ', 'Federation Navy ', - 'Plasma ' ) # Capital (XL) "navy-tier" faction ammo prefixes @@ -36,7 +35,6 @@ 'Sansha ', 'Arch Angel ', 'Shadow ', - 'Plasma' ) @@ -55,13 +53,16 @@ def filterChargesByQuality(charges, qualityTier): Returns: Filtered list of charges - Tiers are cumulative: - - 't1': Tech I (metaGroup 1) + Tech II (metaGroup 2) - - 'navy': t1 + Navy faction ammo (Imperial Navy, Republic Fleet, Caldari Navy, Federation Navy) + Tiers are cumulative (each tier includes everything below it): + - 't1': Tech I only (metaGroup 1) + - 'navy': t1 + Tech II (metaGroup 2) + Navy faction ammo (Imperial Navy, + Republic Fleet, Caldari Navy, Federation Navy) For XL (capital) ammo: includes pirate faction (Sansha, Arch Angel, Shadow) - 'all': Everything including high-tier faction (Blood, Dark Blood, True Sansha, etc.) - Tech II ammo is always included as it's a distinct ammo type, not a "better" variant. + Charges with no meta group in the game data (metaGroupID is NULL - e.g. all + Baryon Exotic Plasma and every XL Triglavian charge) are treated as Tech I. + Otherwise they would be filtered out of every tier despite being basic ammo. """ if qualityTier == 'all': return charges @@ -74,29 +75,32 @@ def filterChargesByQuality(charges, qualityTier): if mgId is not None: classifiable = True - # Tech I (metaGroup 1) - always included - if mgId == 1: + # Tech I (metaGroup 1), or unclassified ammo (NULL metaGroup) treated as + # Tech I - always included in every tier. + if mgId == 1 or mgId is None: filtered.append(charge) continue - # Tech II (metaGroup 2) - always included (distinct ammo type like Conflagration, Void, etc.) - if mgId == 2: - filtered.append(charge) - continue - - # For 'navy' tier, include Navy faction ammo - if qualityTier == 'navy' and mgId == 4: # Faction - # Check if it's XL (capital) ammo by name suffix - isCapital = charge.name.endswith(' XL') - - if isCapital: - # For capital ammo, use pirate faction prefixes as "navy" tier - if any(charge.name.startswith(prefix) for prefix in CAPITAL_NAVY_PREFIXES): - filtered.append(charge) - else: - # For subcap ammo, use empire Navy prefixes - if any(charge.name.startswith(prefix) for prefix in NAVY_PREFIXES): - filtered.append(charge) + # 'navy' tier additionally includes Tech II and Navy faction ammo. + if qualityTier == 'navy': + # Tech II (metaGroup 2) - distinct ammo type like Conflagration, Void, etc. + if mgId == 2: + filtered.append(charge) + continue + + # Navy faction ammo (metaGroup 4) + if mgId == 4: + # Check if it's XL (capital) ammo by name suffix + isCapital = charge.name.endswith(' XL') + + if isCapital: + # For capital ammo, use pirate faction prefixes as "navy" tier + if any(charge.name.startswith(prefix) for prefix in CAPITAL_NAVY_PREFIXES): + filtered.append(charge) + else: + # For subcap ammo, use empire Navy prefixes + if any(charge.name.startswith(prefix) for prefix in NAVY_PREFIXES): + filtered.append(charge) # Honor the user's tier selection even when it excludes every charge (the # weapon simply has no ammo in this tier). Only fall back to the full list