Skip to content
This repository has been archived by the owner on Dec 5, 2024. It is now read-only.

feature/analysis-module #2

Merged
merged 10 commits into from
Apr 13, 2024
194 changes: 193 additions & 1 deletion poetry.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "artipy"
version = "0.2.3"
version = "0.3.0-beta.2"
description = ""
authors = [ "Truman Mulholland <[email protected]>" ]

[tool.poetry.dependencies]
python = ">=3.9,<4.0.0"
toml = "^0.10.2"
plotly = "^5.20.0"
pandas = "^2.2.2"

[tool.poetry.group.dev.dependencies]
pytest = "^8.1.1"
Expand Down
15 changes: 15 additions & 0 deletions src/artipy/analysis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from .analyse import (
calculate_artifact_crit_value,
calculate_artifact_maximum_roll_value,
calculate_artifact_roll_value,
calculate_substat_roll_value,
calculate_substat_rolls,
)

__all__ = (
"calculate_artifact_crit_value",
"calculate_artifact_maximum_roll_value",
"calculate_artifact_roll_value",
"calculate_substat_roll_value",
"calculate_substat_rolls",
)
162 changes: 162 additions & 0 deletions src/artipy/analysis/analyse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import itertools
import math
from decimal import Decimal
from enum import StrEnum, auto

from artipy.artifacts import Artifact
from artipy.artifacts.upgrade_strategy import UPGRADE_STEP
from artipy.stats import StatType, SubStat
from artipy.stats.utils import possible_substat_values

ROLL_MULTIPLIERS: dict[int, tuple[float, ...]] = {
1: (0.8, 1.0),
2: (0.7, 0.85, 1.0),
3: (0.7, 0.8, 0.9, 1.0),
4: (0.7, 0.8, 0.9, 1.0),
5: (0.7, 0.8, 0.9, 1.0),
}


class RollMagnitude(StrEnum):
LOW = auto()
MEDIUM = auto()
HIGH = auto()
MAX = auto()

@property
def magnitude(self) -> Decimal:
if self == RollMagnitude.LOW:
return Decimal("0.7")
elif self == RollMagnitude.MEDIUM:
return Decimal("0.8")
elif self == RollMagnitude.HIGH:
return Decimal("0.9")
return Decimal("1.0")

@classmethod
def closest(cls, value: Decimal | float | int) -> "RollMagnitude":
return RollMagnitude(
min(cls, key=lambda x: abs(RollMagnitude(x).magnitude - Decimal(value)))
)


def calculate_substat_roll_value(substat: SubStat) -> Decimal:
"""Get the substat roll value. This is a percentage of the current value over the
highest potential value.

:param substat: The substat to get the roll value for.
:type substat: SubStat
:return: The roll value of the substat.
:rtype: Decimal
"""
stat_value = substat.value
highest_value = max(possible_substat_values(substat.name, substat.rarity))

if substat.name.is_pct:
stat_value *= 100
highest_value *= 100

return stat_value / highest_value


def calculate_substat_rolls(substat: SubStat) -> int:
"""Calculate the number of rolls a substat has gone through. This is the difference
between the current value and the average value divided by the average value.

:param substat: The substat to get the rolls for.
:type substat: SubStat
:return: The number of rolls the substat has gone through.
:rtype: int
"""
possible_rolls = possible_substat_values(substat.name, substat.rarity)
average_roll = Decimal(sum(possible_rolls) / len(possible_rolls))
return math.ceil((substat.value - average_roll) / average_roll)


def calculate_substat_roll_magnitudes(substat: SubStat) -> tuple[RollMagnitude, ...]:
"""Get the roll magnitudes for a substat. This is a tuple of the roll magnitudes
for each roll the substat has gone through.

:param substat: The substat to get the roll magnitudes for.
:type substat: SubStat
:return: The roll magnitudes for the substat.
:rtype: tuple[RollMagnitude]
"""

def get_magnitude(
values: tuple[Decimal, ...], value_to_index: Decimal
) -> RollMagnitude:
index = values.index(value_to_index)
return RollMagnitude.closest(ROLL_MULTIPLIERS[substat.rarity][index])

possible_rolls = possible_substat_values(substat.name, substat.rarity)
rolls_actual = calculate_substat_rolls(substat)

combinations = list(
itertools.combinations_with_replacement(possible_rolls, rolls_actual)
)
combination = min(combinations, key=lambda x: abs(sum(x) - substat.value))
return tuple(get_magnitude(possible_rolls, value) for value in combination)


def calculate_artifact_roll_value(artifact: Artifact) -> Decimal:
"""Get the current roll value of a given artifact. This is the sum of the roll
values for all substats.

:param artifact: The artifact to get the roll value for.
:type artifact: Artifact
:return: The roll value of the artifact roll.
:rtype: Decimal
"""
return Decimal(
sum(
calculate_substat_roll_value(substat) for substat in artifact.get_substats()
)
)


def calculate_artifact_maximum_roll_value(artifact: Artifact) -> Decimal:
"""Get the maximum roll value of a given artifact. This differs from the regular
roll value by assuming remaining rolls are the highest possible value (i.e 100%
roll value).

:param artifact: The artifact to get the maximum roll value for.
:type artifact: Artifact
:return: The maximum roll value of the artifact roll.
:rtype: Decimal
"""
artifact_max_level = artifact.get_rarity() * 4
remaining_rolls = (artifact_max_level - artifact.get_level()) // UPGRADE_STEP
return Decimal(calculate_artifact_roll_value(artifact) + remaining_rolls)


def calculate_artifact_crit_value(artifact: Artifact) -> Decimal:
"""Get the crit value of a given artifact. This is the crit damage value plus
the two times the crit rate.

:param artifact: The artifact to get the crit value for.
:type artifact: Artifact
:return: The crit value of the artifact.
:rtype: Decimal
"""
crit_dmg = (
sum(
[
substat.value
for substat in artifact.get_substats()
if substat.name == StatType.CRIT_DMG
]
)
* 100
)
crit_rate = (
sum(
[
substat.value
for substat in artifact.get_substats()
if substat.name == StatType.CRIT_RATE
]
)
* 100
)
return Decimal(crit_dmg + crit_rate * 2)
200 changes: 200 additions & 0 deletions src/artipy/analysis/plots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
from decimal import Decimal
from typing import Callable

import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots

from artipy.artifacts import Artifact
from artipy.stats import STAT_NAMES, VALID_MAINSTATS, StatType

from .analyse import (
RollMagnitude,
calculate_artifact_crit_value,
calculate_artifact_roll_value,
calculate_substat_roll_magnitudes,
calculate_substat_rolls,
)
from .simulate import create_multiple_random_artifacts, upgrade_artifact_to_max

ROUND_TO = Decimal("1E-2")

ATTRIBUTES: dict[str, Callable] = {
"rolls": calculate_substat_rolls,
"roll_value": calculate_artifact_roll_value,
"crit_value": calculate_artifact_crit_value,
}


def plot_artifact_substat_rolls(artifact: Artifact) -> None:
substat_rolls = {
STAT_NAMES[substat.name]: calculate_substat_rolls(substat)
for substat in artifact.get_substats()
}
df = pd.DataFrame(substat_rolls.items(), columns=["stat", "rolls"])
pie_figure = px.pie(df, values="rolls", names="stat")

magnitudes_flat = [
tuple(i.value for i in calculate_substat_roll_magnitudes(substat))
for substat in artifact.get_substats()
]
magnitudes_to_dict = {
STAT_NAMES[substat.name]: {
i.value: magnitudes_flat[idx].count(i.value) for i in RollMagnitude
}
for idx, substat in enumerate(artifact.get_substats())
}
magnitudes_to_long_form = [
{"stat_name": stat_name, "magnitude": magnitude, "count": count}
for stat_name, magnitudes in magnitudes_to_dict.items()
for magnitude, count in magnitudes.items()
]
df_long = pd.DataFrame(magnitudes_to_long_form)
bar_traces = []
for stat_name in df_long["stat_name"].unique():
df_filtered = df_long[df_long["stat_name"] == stat_name]
bar_traces.append(
go.Bar(
x=df_filtered["magnitude"],
y=df_filtered["count"],
name=stat_name,
text=df_filtered["count"],
textposition="auto",
)
)

fig = make_subplots(
rows=1,
cols=len(bar_traces) + 1,
specs=[[{"type": "pie"}] + [{"type": "bar"}] * len(bar_traces)],
column_widths=[0.4] + [0.6 / len(bar_traces)] * len(bar_traces),
subplot_titles=[
f"Substat rolls on Artifact with {sum(substat_rolls.values())} total rolls",
*(
f"{stat} ({substat_rolls[STAT_NAMES[stat.name]]} rolls)"
for stat in artifact.get_substats()
),
],
)

fig.add_trace(pie_figure.data[0], row=1, col=1)
for i, trace in enumerate(bar_traces, start=2):
fig.add_trace(trace, row=1, col=i)

fig.update_layout(showlegend=False)

fig.show()


def plot_crit_value_distribution(iterations: int = 1000) -> None:
for a in (artifacts := create_multiple_random_artifacts(iterations)):
upgrade_artifact_to_max(a)

crit_values = [
calculate_artifact_crit_value(a).quantize(ROUND_TO) for a in artifacts
]
df = pd.DataFrame(crit_values, columns=["crit_value"])

bins = [0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0]
labels = [f"{bins[i]}-{bins[i+1]}" for i in range(len(bins) - 1)]
df["crit_value_range"] = pd.cut(df["crit_value"], bins=bins, labels=labels)

fig = px.histogram(
df,
x="crit_value",
color="crit_value_range",
title=f"Crit Rate Distribution of {iterations:,} Artifacts",
)

fig.show()


def plot_roll_value_distribution(iterations: int = 1000) -> None:
for a in (artifacts := create_multiple_random_artifacts(iterations)):
upgrade_artifact_to_max(a)
roll_values = [calculate_artifact_roll_value(a) for a in artifacts]
df = pd.DataFrame(roll_values, columns=["roll_value"])
fig = px.histogram(
df, x="roll_value", title=f"Roll Value Distribution of {iterations:,} Artifacts"
)
fig.show()


def plot_expected_against_actual_mainstats(iterations: int = 1000) -> None:
for a in (artifacts := create_multiple_random_artifacts(iterations)):
upgrade_artifact_to_max(a)

expected_mainstats = {
k: v for k, v in VALID_MAINSTATS.items() if k not in ("flower", "plume")
}
actual_mainstats: dict[str, list[StatType]] = {k: [] for k in expected_mainstats}

for a in artifacts:
if (slot := a.get_artifact_slot()) in expected_mainstats:
actual_mainstats[slot].append(a.get_mainstat().name)

actual_mainstats_pct: dict[str, dict[StatType, float]] = {
k: {
stat: (actual_mainstats[k].count(stat) / len(actual_mainstats[k])) * 100
for stat in v
}
for k, v in actual_mainstats.items()
}

fig = make_subplots(
rows=1, cols=len(expected_mainstats), subplot_titles=list(expected_mainstats)
)

for i, slot in enumerate(expected_mainstats, start=1):
col = (i - 1) % len(expected_mainstats) + 1
fig.add_trace(
go.Bar(
x=list(expected_mainstats[slot]),
y=list(expected_mainstats[slot].values()),
name="Expected",
marker=dict(color="#FF6961"),
),
row=1,
col=col,
)
fig.add_trace(
go.Bar(
x=list(actual_mainstats_pct[slot]),
y=list(actual_mainstats_pct[slot].values()),
name="Actual",
marker=dict(color="#B4D8E7"),
),
row=1,
col=col,
)

fig.update_layout(barmode="overlay", showlegend=False)
fig.show()


def plot_multi_value_distribution(
iterations: int = 1000, *, attributes: tuple[str]
) -> None:
for attr in attributes:
if attr not in ATTRIBUTES:
raise ValueError(
f"Invalid attribute: {attr}\nValid attributes: {ATTRIBUTES}"
)

for a in (artifacts := create_multiple_random_artifacts(iterations)):
upgrade_artifact_to_max(a)

concatted_df = pd.DataFrame()
for attr in attributes:
values = [ATTRIBUTES[attr](a) for a in artifacts if ATTRIBUTES[attr](a) > 0]
df = pd.DataFrame(values, columns=[attr])
concatted_df = pd.concat([concatted_df, df])

fig = px.histogram(
concatted_df,
x=concatted_df.columns,
title=f"Value Distribution of {iterations:,} Artifacts",
)

fig.show()
Loading