from logging import Formatter
from copy import copy
from enum import Enum, IntFlag
from itertools import product, chain, combinations
import numpy as np
from circleguard.mod import Mod
[docs]class RatelimitWeight(Enum):
"""
How much it 'costs' to load a replay from the api.
:data:`~.RatelimitWeight.NONE` if the load method of a replay makes no api
calls.
:data:`~.RatelimitWeight.LIGHT` if the load method of a replay makes only
light api calls (anything but ``get_replay``).
:data:`~.RatelimitWeight.HEAVY` if the load method of a replay makes any
heavy api calls (``get_replay``).
Notes
-----
This value currently has no effect in circlecore and is reserved for future
functionality.
"""
NONE = "None"
LIGHT = "Light"
HEAVY = "Heavy"
[docs]class Key(IntFlag):
M1 = 1 << 0
M2 = 1 << 1
K1 = 1 << 2
K2 = 1 << 3
SMOKE = 1 << 4
KEY_MASK = int(Key.M1) | int(Key.M2)
[docs]def convert_statistic(stat, mods, *, to):
"""
Converts a game statistic to either its unconverted or converted form,
depending on ``to``.
Parameters
----------
stat: float
The statistic to convert.
mods: Mod
The mods the replay was played with. Only ``Mod.DT`` and ``Mod.HT``
will affect the statistic conversion.
to: {"cv", "ucv"}
``cv`` if the statistic should be converted to its converted form, and
``ucv`` if the statistic should be converted to its unconverted form.
Notes
-----
This method is intended for any statistic that is modified from what we
expect by ``Mod.DT`` or ``Mod.HT`` being applied (ie changing the game
clock speed). This includes ur (unstable rate) and median frametime
(time between frames).
"""
check_param(to, ["cv", "ucv"])
conversion_factor = 1
if Mod.DT in mods:
conversion_factor = (1 / 1.5)
elif Mod.HT in mods:
conversion_factor = (1 / 0.75)
if to == "cv":
return stat * conversion_factor
return stat / conversion_factor
[docs]def order(replay1, replay2):
"""
An ordered tuple of the given replays. The first element is the earlier
replay, and the second element is the later replay.
Parameters
----------
replay1: Replay
The first replay to order.
replay2: Replay
The second replay to order.
Returns
-------
(Replay, Replay)
The first element is the earlier replay, and the second element is the
later replay.
"""
if not replay1.timestamp or not replay2.timestamp:
raise ValueError("Both replay1 and replay2 must provide a timestamp. "
"Replays without a timestamp cannot be ordered.")
# assume they're passed in order (earliest first); if not, switch them
order = (replay1, replay2)
if replay2.timestamp < replay1.timestamp:
order = tuple(reversed(order))
return order
[docs]def replay_pairs(replays, replays2=None):
"""
A list of pairs of replays which can be compared against each other to cover
all cases of replay stealing in ``replays`` and/or ``replays2``.
If ``replays2`` is not passed (the default), this is a list of 2-tuples
which are pairs of replays in ``replays``, where each replay will be paired
with every other replay exactly once.
If ``replays2`` is passed, this is a list of 2-tuples which are pairs of
replays in where one replay is from ``replays``, the other is from
``replays2``, and every replay in ``replays`` is paired against every replay
in ``replays2`` (but not against other replays in ``replays``).
Returns
-------
Iterable[(Replay, Replay)]
The first element is the earlier replay, and the second element is the
later replay.
Notes
-----
This is equivalent to ``itertools.combinations(replays, 2)`` if ``replays2``
is ``None`` or the empty list, and ``itertools.product(replays, replays2)``
otherwise.
"""
if not replays2:
return combinations(replays, 2)
return product(replays, replays2)
def check_param(param, options):
if param not in options:
raise ValueError(f"Expected one of {','.join(options)}. Got {param}")
[docs]def fuzzy_mods(required_mod, optional_mods):
"""
All mod combinations where each mod in ``optional_mods`` is allowed to be
present or absent.
If you don't want any mods to be required, pass ``Mod.NM`` as your
``required_mod``.
Parameters
----------
required_mod: class:`~circleguard.mod.ModCombination`
What mod to require be present for all mods.
optional_mods = [class:`~circleguard.mod.ModCombination`]
What mods are allowed, but not required, to be present.
Examples
--------
>>> fuzzy_mods(Mod.HD, [Mod.DT])
[HD, HDDT]
>>> fuzzy_mods(Mod.HD, [Mod.EZ, Mod.DT])
[HD, HDDT, HDEZ, HDDTEZ]
>>> fuzzy_mods(Mod.NM, [Mod.EZ, Mod.DT])
[NM, DT, EZ, DTEZ]
"""
all_mods = []
for mods in powerset(optional_mods):
final_mod = required_mod
for mod in mods:
final_mod = final_mod + mod
all_mods.append(final_mod)
return all_mods
[docs]def powerset(iterable):
"""
The powerset of an iterable.
Examples
--------
>>> powerset([1,2,3])
[(), (1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)]
Notes
-----
https://stackoverflow.com/a/1482316
"""
s = list(iterable)
return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1))
[docs]def hitwindow(OD):
"""
The number of milliseconds before and after a hitobject's time where that
hitobject can be hit.
"""
# stable converts OD (and CS), which are originally a float32, to a
# double and this causes some hitwindows to be messed up when casted to
# an int so we replicate this
return int(150 + 50 * (5 - float(np.float32(OD))) / 5)
def hitwindows(OD):
hitwindow_50 = hitwindow(OD)
hitwindow_100 = (280 - 16 * OD) / 2
hitwindow_300 = (160 - 12 * OD) / 2
return (hitwindow_50, hitwindow_100, hitwindow_300)
[docs]def hitradius(CS):
"""
The radius, in osu!pixels (?) of where a hitobject can be hit.
"""
# attempting to match stable hitradius
return np.float32(64 * ((1.0 - np.float32(0.7) * (float(np.float32(CS)) - 5) / 5)) / 2) * np.float32(1.00041)
[docs]def filter_outliers(arr, bias=1.5):
"""
Returns ``arr` with outliers removed.
Parameters
----------
arr: list
List of numbers to filter outliers from.
bias: int
Points in ``arr`` which are more than ``IQR * bias`` away from the first
or third quartile of ``arr`` will be removed.
"""
q3, q1 = np.percentile(arr, [75 ,25])
iqr = q3 - q1
lower_limit = q1 - (bias * iqr)
upper_limit = q3 + (bias * iqr)
return [x for x in arr if lower_limit < x < upper_limit]
TRACE = 5