diff --git a/.gitignore b/.gitignore index 8f34001cbd..cc7a113e46 100644 --- a/.gitignore +++ b/.gitignore @@ -126,4 +126,6 @@ gitversion /locale/progress.json # vscode settings -.vscode \ No newline at end of file +.vscode + +eve.db diff --git a/graphs/data/__init__.py b/graphs/data/__init__.py index 6453ee583b..650239edee 100644 --- a/graphs/data/__init__.py +++ b/graphs/data/__init__.py @@ -19,6 +19,7 @@ from . import fitDamageStats +from . import fitApplicationProfile from . import fitEwarStats from . import fitRemoteReps from . import fitShieldRegen diff --git a/graphs/data/fitApplicationProfile/__init__.py b/graphs/data/fitApplicationProfile/__init__.py new file mode 100644 index 0000000000..dcad49039f --- /dev/null +++ b/graphs/data/fitApplicationProfile/__init__.py @@ -0,0 +1,4 @@ +from .graph import FitAmmoOptimalDpsGraph + + +FitAmmoOptimalDpsGraph.register() diff --git a/graphs/data/fitApplicationProfile/calc/__init__.py b/graphs/data/fitApplicationProfile/calc/__init__.py new file mode 100644 index 0000000000..5d0aa1fbda --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/__init__.py @@ -0,0 +1,5 @@ +# 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..31b2d7c159 --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/charges.py @@ -0,0 +1,220 @@ +# ============================================================================= +# Constants +# ============================================================================= + +# Navy faction ammo prefixes (for S/M/L ammo) +NAVY_PREFIXES = ( + 'Imperial Navy ', + 'Republic Fleet ', + 'Caldari Navy ', + 'Federation Navy ' +) + +# 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 ' +) + + +# ============================================================================= +# 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 = [] + for charge in charges: + mg = charge.metaGroup + mgId = mg.ID if mg else None + + # 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) + + return filtered if filtered else 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, # EVE typo + '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..6e476e9dfe --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/launcher.py @@ -0,0 +1,678 @@ +import math +from bisect import bisect_right + +from logbook import Logger + +from .projected import getProjectedParamsAtDistance + + +pyfalog = Logger(__name__) + + +# ============================================================================= +# 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 + pyfalog.debug(f"DEBUG: _extractMultiplier({attr}): base={base}, modified={modified}, mult={modified/base}") + 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 + pyfalog.debug(f"DEBUG: _extractMultiplier({attr}): base is 0, attempting injection") + 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 + pyfalog.debug(f"DEBUG: _extractMultiplier({attr}): injected base 1.0, got modified={multiplier}") + 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) + """ + # pyfalog.debug(f"DEBUG: getAllMultipliers called for {mod.item.name}, charge={mod.charge}") + 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 [] + + pyfalog.debug(f"[MISSILE] Starting transition calculation with {len(chargeData)} charges") + pyfalog.debug(f"[MISSILE] Base params: tgtSpeed={baseTgtSpeed}, tgtSig={baseTgtSigRadius}") + + 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)) + pyfalog.debug(f"[MISSILE] Transition @ {high/1000:.1f}km: {currentCharge} -> {bestName}") + currentCharge = bestName + + # Stop if we're past all missile ranges + if bestVolley < 0.01: + transitions.append((distance, -1, None, 0)) + break + + distance += resolution + + pyfalog.debug(f"[MISSILE] Completed: {len(transitions)} transition points found") + + 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..d7ebb36d7e --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/optimize_ammo.py @@ -0,0 +1,197 @@ +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..d927280925 --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/projected.py @@ -0,0 +1,244 @@ +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 + +from logbook import Logger + +pyfalog = Logger(__name__) + + +# ============================================================================= +# 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: + pyfalog.debug(f"[PROJECTED] Existing cache sufficient: {existingMax/1000:.0f}km >= {maxDistance/1000:.0f}km needed") + return existingCache + + # Otherwise, extend the existing cache + sigStr = 'inf' if baseTgtSigRadius == float('inf') else f"{baseTgtSigRadius:.1f}m" + pyfalog.debug(f"[PROJECTED] Extending cache: {existingMax/1000:.0f}km -> {maxDistance/1000:.0f}km (baseSig={sigStr})") + distances = existingCache['distances'].copy() + cache = existingCache['cache'].copy() + startDistance = existingMax + resolution + else: + sigStr = 'inf' if baseTgtSigRadius == float('inf') else f"{baseTgtSigRadius:.1f}m" + 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', ()) + + # Debug log projected modules + if webMods or webDrones or webFighters: + pyfalog.debug(f"[PROJECTED] Webs: {len(webMods)} mods, {len(webDrones)} drones, {len(webFighters)} fighters") + if tpMods or tpDrones or tpFighters: + pyfalog.debug(f"[PROJECTED] TPs: {len(tpMods)} mods, {len(tpDrones)} drones, {len(tpFighters)} fighters") + + distance = startDistance + entriesAdded = 0 + prevSpeed = None + 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 + ) + + # Log significant speed changes (helps debug grapple/web transitions) + if prevSpeed is not None and abs(tackledSpeed - prevSpeed) > baseTgtSpeed * 0.05: + pyfalog.debug(f"[PROJECTED] Speed change @ {distance/1000:.1f}km: {prevSpeed:.0f} -> {tackledSpeed:.0f} m/s") + prevSpeed = tackledSpeed + + 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..d815cd7aff --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/turret.py @@ -0,0 +1,168 @@ +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..1d10ba8ad7 --- /dev/null +++ b/graphs/data/fitApplicationProfile/calc/valid_charges.py @@ -0,0 +1,60 @@ +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..281829d3f9 --- /dev/null +++ b/graphs/data/fitApplicationProfile/getter.py @@ -0,0 +1,998 @@ +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 + """ + pyfalog.debug(f"[AMMO] buildTurretCacheEntry START for {mod.item.name}") + + # 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) + pyfalog.debug(f"[AMMO] Precomputed {len(chargeData)} charge data entries") + + # 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) + pyfalog.debug(f"[AMMO] Max effective range for this turret: {maxEffectiveRange/1000:.1f}km") + + # 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 + ) + + pyfalog.debug(f"[AMMO] buildTurretCacheEntry END for {mod.item.name}") + + 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 + """ + pyfalog.debug(f"[AMMO] buildLauncherCacheEntry START for {mod.item.name}") + + # 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 + + pyfalog.debug(f"[AMMO] Precomputed {len(chargeData)} missile charge data entries") + + # Calculate max effective range from precomputed data + maxEffectiveRange = getMissileMaxEffectiveRange(chargeData) + pyfalog.debug(f"[AMMO] Max effective range for this launcher: {maxEffectiveRange/1000:.1f}km") + + # Calculate transitions using the pre-built projected cache + transitions = calculateMissileTransitions( + chargeData, baseTgtSpeed, baseTgtSigRadius, + projectedCache, + maxDistance=int(maxEffectiveRange) + ) + + pyfalog.debug(f"[AMMO] buildLauncherCacheEntry END for {mod.item.name}") + + 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 distance == 0: # Log details at distance 0 for debugging + pyfalog.debug(f"[DPS-CALC] weaponType={weaponType}, weaponCache has {len(weaponCache)} groups") + pyfalog.debug(f"[DPS-CALC] trackingParams={trackingParams}") + pyfalog.debug(f"[DPS-CALC] projectedCache has {len(projectedCache)} entries") + + if weaponType == 'turret': + for group_id, groupInfo in weaponCache.items(): + if distance == 0: + pyfalog.debug(f"[DPS-CALC] Turret group {group_id}: {len(groupInfo.get('transitions', []))} transitions, {len(groupInfo.get('charge_data', []))} charges") + volley, _ = getVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + groupInfo['turret_base'], + distance, + trackingParams, + projectedCache + ) + if distance == 0: + pyfalog.debug(f"[DPS-CALC] Turret volley at {distance}m = {volley}") + dps = volleyToDps(volley, groupInfo['cycle_time_ms']) + totalDps += dps * groupInfo['count'] + else: # launcher + for group_id, groupInfo in weaponCache.items(): + if distance == 0: + pyfalog.debug(f"[DPS-CALC] Launcher group {group_id}: {len(groupInfo.get('transitions', []))} transitions, {len(groupInfo.get('charge_data', []))} charges") + volley, _ = getMissileVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + distance, + trackingParams['tgtSpeed'], + trackingParams['tgtSigRadius'], + projectedCache + ) + if distance == 0: + pyfalog.debug(f"[DPS-CALC] Launcher volley at {distance}m = {volley}") + dps = missileVolleyToDps(volley, groupInfo['cycle_time_ms']) + totalDps += dps * groupInfo['count'] + + if distance == 0: + pyfalog.debug(f"[DPS-CALC] Total DPS at {distance}m = {totalDps}") + + 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 + for groupInfo in weaponCache.values(): + volley, name = getMissileVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + distance, + trackingParams['tgtSpeed'], + trackingParams['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 + for groupInfo in weaponCache.values(): + volley, _ = getMissileVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + distance, + trackingParams['tgtSpeed'], + trackingParams['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 + for groupInfo in weaponCache.values(): + volley, name = getMissileVolleyAtDistance( + groupInfo['transitions'], + groupInfo['charge_data'], + distance, + trackingParams['tgtSpeed'], + trackingParams['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(): + # pyfalog.debug(f"DEBUG: Processing module {mod.item.name}, hardpoint={mod.hardpoint}, charge={mod.charge}") + 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: + # pyfalog.debug(f"DEBUG: Temporarily loading {tempCharge.name} into {mod.item.name} for modifier extraction") + mod.charge = tempCharge + # Force fit update (important for effects to apply) + if mod.owner: + # pyfalog.debug("DEBUG: Forcing fit recalculation (1)") + mod.owner.calculated = False + mod.owner.calculateModifiedAttributes() + + # Extract multipliers (optional debug) + # damageMults, flightMults, appMults = getLauncherMultipliers(mod) + # pyfalog.debug(f"DEBUG: Extracted multipliers: Dmg={damageMults}, Flt={flightMults}, App={appMults}") + + # pyfalog.debug("DEBUG: calling getLauncherRangeInfo with temp charge loaded") + ranges = getLauncherRangeInfo(mod, qualityTier, shipRadius, self.graph._ammo_charge_cache) + # p_dmults, p_fmults, p_amults = getLauncherMultipliers(mod) + # pyfalog.debug(f"DEBUG: Multipliers during range calc: Dmg={p_dmults}") + rangeInfo = ranges + + # Unload charge + mod.charge = None + if mod.owner: + # pyfalog.debug("DEBUG: Forcing fit recalculation (Cleanup)") + mod.owner.calculated = False + mod.owner.calculateModifiedAttributes() + # pyfalog.debug("DEBUG: Charge unloaded") + + 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 + + if distance == 0: # Debug logging at distance 0 + sigStr = 'inf' if tgtSigRadius == float('inf') else f"{tgtSigRadius:.1f}" + pyfalog.debug(f"[TRACKING] Building tracking params: tgtSpeed={tgtSpeed:.1f}, tgtSigRadius={sigStr}") + pyfalog.debug(f"[TRACKING] tgt={tgt.name if tgt else None}") + + # Only return None if sig radius is exactly 0 (not infinity - that's valid for Ideal Target) + if tgtSigRadius == 0: + pyfalog.debug(f"[TRACKING] tgtSigRadius is 0, returning None!") + 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 + } + + if distance == 0: + pyfalog.debug(f"[TRACKING] Returning params: {params}") + + return params + + 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: + pyfalog.debug(f"[CALC-POINT] No weaponCache for {src.item.name} at distance {x/1000:.1f}km, returning 0") + 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) + if x % 10000 == 0: # Log every 10km for sampling + pyfalog.debug(f"[CALC-POINT] {src.item.name} at {x/1000:.1f}km: DPS={result:.1f}") + return result + elif hasattr(self, '_getOptimalVolleyAtDistance'): + result = self._getOptimalVolleyAtDistance(x, weaponCache, trackingParams, projectedCache, weaponType) + if x % 10000 == 0: # Log every 10km for sampling + pyfalog.debug(f"[CALC-POINT] {src.item.name} at {x/1000:.1f}km: Volley={result:.1f}") + 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.""" + pyfalog.debug(f"[SEGMENTS] ========== getSegments START for src={src.item.name}, tgt={tgt.name if tgt else None} ==========") + pyfalog.debug(f"[SEGMENTS] xRange={xRange}") + # Validate xRange - can contain None from range limiters + minX, maxX = xRange + if minX is None or maxX is None: + pyfalog.debug(f"[SEGMENTS] Returning empty - xRange contains None: minX={minX}, maxX={maxX}") + return [] + + pyfalog.debug(f"[SEGMENTS] Calling _getCommonData for {src.item.name}...") + commonData = self._getCommonData(miscParams=miscParams, src=src, tgt=tgt) + weaponCache = commonData.get('weapon_cache', {}) + weaponType = commonData.get('weapon_type') + pyfalog.debug(f"[SEGMENTS] After _getCommonData: weaponType={weaponType}, weaponCache has {len(weaponCache)} groups") + pyfalog.debug(f"[SEGMENTS] weaponCache id: {id(weaponCache)}") + + if not weaponCache: + pyfalog.debug(f"[SEGMENTS] Returning empty - no weaponCache") + return [] + + # Get transitions from first weapon group + transitions = None + for groupInfo in weaponCache.values(): + transitions = groupInfo['transitions'] + pyfalog.debug(f"[SEGMENTS] Got {len(transitions) if transitions else 0} transitions from first weapon group") + break + + if not transitions: + pyfalog.debug(f"[SEGMENTS] Returning empty - no transitions") + return [] + + # Filter valid transitions (with ammo name) + validTransitions = [t for t in transitions if t[2] is not None] + pyfalog.debug(f"[SEGMENTS] {len(validTransitions)} valid transitions (with ammo name)") + if not validTransitions: + pyfalog.debug(f"[SEGMENTS] Returning empty - no valid transitions") + 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) + + pyfalog.debug(f"[SEGMENTS] Segment {i} ({ammoName}): {len(xs)} points, y_range=[{min(ys) if ys else 'empty'}, {max(ys) if ys else 'empty'}]") + + segments.append({ + 'xs': xs, + 'ys': ys, + 'ammo': ammoName, + 'ammoIndex': ammoToIndex[ammoName] + }) + + pyfalog.debug(f"[SEGMENTS] ========== Returning {len(segments)} segments for {src.item.name} ==========") + 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..a21a2c2fcc --- /dev/null +++ b/graphs/data/fitApplicationProfile/graph.py @@ -0,0 +1,597 @@ +import colorsys +import math +import re +from logbook import Logger + +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 + +pyfalog = Logger(__name__) + + +# 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() + + flight_mults = getFlightMultipliers(mod) + + # Cleanup + 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): + pyfalog.debug(f"[CLEAR-CACHE] _clearInternalCache called: reason={reason}, extraData={extraData}") + + if reason in (GraphCacheCleanupReason.fitChanged, GraphCacheCleanupReason.fitRemoved): + # extraData is the fit ID (integer), not the fit object + fit_id = extraData + pyfalog.debug(f"[CLEAR-CACHE] Clearing caches for fit ID {fit_id}") + + # 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] + pyfalog.debug(f"[CLEAR-CACHE] Removed {len(keys_to_remove)} weapon cache entries for fit {fit_id}") + + # 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] + pyfalog.debug(f"[CLEAR-CACHE] Removed {len(keys_to_remove)} projected cache entries for fit {fit_id}") + + # 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] + pyfalog.debug(f"[CLEAR-CACHE] Removed {len(keys_to_remove)} range cache entries for fit {fit_id}") + + # Clear charge cache - when fits change, weapon types might change + if hasattr(self, '_ammo_charge_cache'): + count = len(self._ammo_charge_cache) + self._ammo_charge_cache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} charge cache entries for fit change") + + elif reason in (GraphCacheCleanupReason.profileChanged, GraphCacheCleanupReason.profileRemoved): + profile_id = extraData + pyfalog.debug(f"[CLEAR-CACHE] Clearing caches for profile ID {profile_id}") + + if hasattr(self, '_ammo_weapon_cache'): + count = len(self._ammo_weapon_cache) + self._ammo_weapon_cache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} weapon cache entries due to profile change") + + if hasattr(self, '_ammo_projected_cache'): + count = len(self._ammo_projected_cache) + self._ammo_projected_cache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} projected cache entries due to profile change") + + if hasattr(self, '_rangeCache'): + count = len(self._rangeCache) + self._rangeCache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} range cache entries due to profile change") + + elif reason == GraphCacheCleanupReason.graphSwitched: + self._projectedCache.clearAll() + pyfalog.debug(f"[CLEAR-CACHE] Clearing ALL caches for graph switch") + + # Clear all ammo caches globally + if hasattr(self, '_ammo_weapon_cache'): + count = len(self._ammo_weapon_cache) + self._ammo_weapon_cache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} weapon cache entries") + + if hasattr(self, '_ammo_projected_cache'): + count = len(self._ammo_projected_cache) + self._ammo_projected_cache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} projected cache entries") + + if hasattr(self, '_rangeCache'): + count = len(self._rangeCache) + self._rangeCache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} range cache entries") + + if hasattr(self, '_ammo_charge_cache'): + count = len(self._ammo_charge_cache) + self._ammo_charge_cache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} charge cache entries") + + elif reason in (GraphCacheCleanupReason.inputChanged, GraphCacheCleanupReason.optionChanged): + pyfalog.debug(f"[CLEAR-CACHE] Clearing ALL caches for {reason.name}") + + if hasattr(self, '_ammo_weapon_cache'): + count = len(self._ammo_weapon_cache) + self._ammo_weapon_cache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} weapon cache entries due to {reason.name}") + + if hasattr(self, '_ammo_projected_cache'): + count = len(self._ammo_projected_cache) + self._ammo_projected_cache = {} + pyfalog.debug(f"[CLEAR-CACHE] Cleared {count} projected cache entries due to {reason.name}") + + + 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. + """ + pyfalog.debug(f"[GRAPH] getPlotSegments called for src={src.item.name}, mainInput.value={mainInput.value}") + try: + getterClass = self._getters[(xSpec.handle, ySpec.handle)] + except KeyError: + pyfalog.debug(f"[GRAPH] No getter for ({xSpec.handle}, {ySpec.handle})") + 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) + pyfalog.debug(f"[GRAPH] Normalized mainParamRange={mainParamRange}") + + getter = getterClass(graph=self) + + # Check if getter has getSegments method + if not hasattr(getter, 'getSegments'): + pyfalog.debug(f"[GRAPH] Getter has no getSegments method") + return None + + segments = getter.getSegments( + xRange=mainParamRange[1], + miscParams=miscParams, + src=src, + tgt=tgt) + + pyfalog.debug(f"[GRAPH] getter.getSegments returned {len(segments) if segments else segments} segments") + + if not segments: + pyfalog.debug(f"[GRAPH] No segments, returning None") + 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) + + pyfalog.debug(f"[GRAPH] Returning {len(segments)} denormalized segments for {src.item.name}") + 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/gui/canvasPanel.py b/graphs/gui/canvasPanel.py index 4c862f6001..d940465084 100644 --- a/graphs/gui/canvasPanel.py +++ b/graphs/gui/canvasPanel.py @@ -37,6 +37,58 @@ pyfalog = Logger(__name__) +def _filterLowYValues(xs, ys, minY=1, addZeroPoint=False): + """ + Filter out trailing points where Y < minY (default 1). + + For damage graphs, values less than 1 are effectively zero. + Only filters trailing low-Y values - keeps low-Y values in the middle if there are + valid Y>=minY points at further ranges. + + Returns filtered (xs, ys) lists. + If addZeroPoint=True AND filtering actually removed trailing low-Y points, + adds a final point at Y=0 to connect to the axis. + Note: Does NOT add Y=0 if data ends at user-specified bounds (no trailing low-Y values). + """ + if not xs or not ys: + return xs, ys + + # Find the last index where Y >= minY + lastValidIdx = -1 + for i in range(len(ys) - 1, -1, -1): + if ys[i] >= minY: + lastValidIdx = i + break + + # If no valid points, return empty + if lastValidIdx < 0: + return [], [] + + # Keep all points up to and including the last valid point + filteredXs = list(xs[:lastValidIdx + 1]) + filteredYs = list(ys[:lastValidIdx + 1]) + + # Only add Y=0 point if filtering actually removed trailing low-Y points + # (i.e., there's a point after lastValidIdx that was below minY) + # This ensures we don't add Y=0 when data just ends at user bounds + if addZeroPoint and lastValidIdx + 1 < len(xs): + nextX = xs[lastValidIdx + 1] + nextY = ys[lastValidIdx + 1] + prevX = filteredXs[-1] + prevY = filteredYs[-1] + + # Linear interpolation: find X where Y = minY (or close to 0) + if prevY != nextY: + crossX = prevX + (minY - prevY) * (nextX - prevX) / (nextY - prevY) + filteredXs.append(crossX) + filteredYs.append(0) + # Note: Removed the 'elif addZeroPoint and filteredXs' branch + # We should NOT add Y=0 if the data simply ends at the last point + # (no trailing low-Y values were filtered out) + + return filteredXs, filteredYs + + try: import matplotlib as mpl @@ -51,6 +103,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 @@ -100,8 +153,26 @@ def __init__(self, graphFrame, parent): self.xMark = None 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 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 +187,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 +213,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 +221,243 @@ 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 - # 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) + # 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) + # Debug: log segment info + if segments: + pyfalog.debug('Segments for {} vs {}: {} segments'.format( + source.name, target.name if target else 'None', len(segments))) + for i, seg in enumerate(segments): + pyfalog.debug(' Segment {}: ammo={}, x_range=[{:.0f}, {:.0f}], y_range=[{:.0f}, {:.0f}]'.format( + i, seg.get('ammo'), min(seg['xs']), max(seg['xs']), min(seg['ys']), max(seg['ys']))) + 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) + + # Filter out points where Y < 1 (effectively zero damage) + # Add Y=0 point only for the last segment to connect to axis + xs, ys = _filterLowYValues(xs, ys, minY=1, addZeroPoint=isLastSegment) + if not xs or not ys: + continue + + # 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 + # Filter out Y values below 1 (damage can't be less than 1) + # Add Y=0 point to connect line to axis + xs, ys = _filterLowYValues(xs, ys, addZeroPoint=True) + if not xs or not ys: + 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 (always include Y=0 in range) + allYs.add(0) + # 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) + # Clamp Y minimum to 0 - damage values can't be negative + canvasMinY = max(0, canvasMinY) self.subplot.set_ylim(bottom=canvasMinY, top=canvasMaxY) self.subplot.set_xlim(left=canvasMinX, right=canvasMaxX) # Process X marks line @@ -196,29 +469,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() - - def addYMark(val): + 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, extraInfo=None): if val is None: return # Round according to shown Y range - the bigger the range, @@ -230,23 +497,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 +541,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) + + 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 Y values - for yMark in yMarks: + # 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 +666,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 +929,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 +954,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..cb81f098cc 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,77 @@ 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) - 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) + commonOptsSizer.Add(self.showLegendCb, 0, 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) @@ -159,6 +196,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 +288,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 +379,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) @@ -323,8 +391,16 @@ def OnShowLegendChange(self, event): event.Skip() self.graphFrame.draw() - def OnShowY0Change(self, event): + 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): @@ -359,6 +435,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 = [] @@ -398,8 +532,24 @@ def showLegend(self): return self.showLegendCb.GetValue() @property - def showY0(self): - return self.showY0Cb.GetValue() + 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): @@ -425,6 +575,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..388792ea68 100644 --- a/graphs/gui/lists.py +++ b/graphs/gui/lists.py @@ -19,9 +19,12 @@ # noinspection PyPackageRequirements +import logging 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,11 +32,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 +pyfalog = logging.getLogger(__name__) + + +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): @@ -242,23 +295,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): @@ -295,6 +358,116 @@ 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. + """ + from logbook import Logger + pyfalog = Logger(__name__) + + # Check if ctrlPanel is fully initialized (has ammoStyleSelection) + ctrlPanel = getattr(self.graphFrame, 'ctrlPanel', None) + if ctrlPanel is None: + pyfalog.debug("[AMMO STYLE] ctrlPanel is None") + return + if not hasattr(ctrlPanel, 'ammoStyleSelection'): + pyfalog.debug("[AMMO STYLE] ctrlPanel has no ammoStyleSelection") + return + + # Check if this graph supports segments (Application Profile) + try: + view = self.graphFrame.getView() + except Exception: + pyfalog.debug("[AMMO STYLE] Failed to get view") + return + + if not getattr(view, 'hasSegments', False): + pyfalog.debug("[AMMO STYLE] View doesn't have segments") + return + + # Get current ammo style + currentStyle = ctrlPanel.ammoStyle + pyfalog.debug(f"[AMMO STYLE] Current style: {currentStyle}") + + # Only auto-switch if currently on 'color' mode + if currentStyle != 'color': + pyfalog.debug(f"[AMMO STYLE] Not switching - style is {currentStyle}, not '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) + pyfalog.debug(f"[AMMO STYLE] Fit {wrapper.item.name}: weapon class = {wc}") + if wc: + weaponClasses[wc] = weaponClasses.get(wc, 0) + 1 + + pyfalog.debug(f"[AMMO STYLE] Weapon classes: {weaponClasses}") + + # If any weapon class has 2+ fits, switch to pattern mode + for wc, count in weaponClasses.items(): + if count >= 2: + pyfalog.debug(f"[AMMO STYLE] Switching to pattern - {wc} has {count} fits") + ctrlPanel.setAmmoStyle('pattern') + return + + pyfalog.debug("[AMMO STYLE] No conflicts found") + + 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) @@ -329,26 +502,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 - - 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() + 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 + 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)