vee-speedrun-ratings/chart.py
2024-07-07 04:30:31 +00:00

1004 lines
27 KiB
Python
Executable File

import math
import random
import statistics
import time
import json
import os.path
import argparse
import sys
import urllib.request
import re
from html.parser import HTMLParser
import matplotlib as matplotlib
import matplotlib.pyplot as Plot
import matplotlib.patches as patches
# consts
TITLE = "SGDQ 2024"
USE_LEGEND = True # display the legend with markers
TITLE_OFFSET = 0 # 2.5 # to-do: dynamically set this to how many columns are set with legend
# les constant consts
AUX_MODE = None # total | markers | None
SORT_BY = None # sort runs by the values
CUTOFF_SECONDS = 0
MODE = "scatter"
# more constant consts
TIMESTAMP = str(int(time.time_ns()/1000/1000))
IN_QUEUE_FILE = "./data/queue.json"
IN_RATINGS_FILE = "./data/ratings.json"
OUT_FILE_TIMESTAMP = f'./images/{TIMESTAMP}.png'
OUT_FILE = f'./images/ratings[{SORT_BY or AUX_MODE or "chronological"}].png'
MIN_COLUMNS = 2 # looks better if there's more than one column with the legend
CULL_SINGLETON_MARKERS = True # remove any marker that only has 1 entry
COLOR_BY = "mean" # color by this stat's value
FADE_BY_STDEV = True # fade outliers
LINES = ["mean_smart", "median"] # show mean and median lines (or stdev too)
USE_LATEX = False # never got this to work
DROP_Z_S = False
RE_RATING = re.compile(r"(\b[A-HJ-Zz]+(?:[+-]|\b))")
RE_RATING_NEWLINE = re.compile(r"(\b[A-HJ-Zz]+(?:[+-]|\b))\n") # just the above, but for newlines
COL_WIDTH = 4.0
ROW_HEIGHT = 0.5
# gimmicks
REVERSE = False # classic reverse tier lists
ZOOMER = False # sets scale to zoomer-friendly ratings
# theme colors
DARK = True
COLORS = {
"BAND0": "#101010",
"BAND1": "#080808",
"LINE": "#181818",
"MEAN": "#404040",
"STDEV": "#404040",
"TEXT": "#e0e0e0",
"STATS": "#606060",
"MEDIAN": "#AA00AA",
"RATINGS": [
( 0.0, (0.2, 0.2, 0.2)),
( 3.5, (0.6, 0.2, 0.2)),
( 4.5, (1.0, 0.2, 0.2)),
( 6.5, (1.0, 1.0, 0.2)),
( 7.5, (0.2, 1.0, 0.2)),
( 8.5, (0.2, 1.0, 1.0)),
(10.0, (1.0, 1.0, 1.0)),
],
"SPECIAL": (0.6, 0.2, 1.0),
"BARBIE": (244.0/255.0, 33.0/255.0, 138.0/255.0),
} if DARK else {
"BAND0": "#f0f0f0",
"BAND1": "#f8f8f8",
"LINE": "#e8e8e8",
"MEAN": "#404040",
"STDEV": "#404040",
"TEXT": "#404040",
"STATS": "#a0a0a0",
"MEDIAN": "#AA00AA",
"RATINGS": [
( 0.0, (0.0, 0.0, 0.0)),
( 3.5, (0.4, 0.0, 0.0)),
( 4.5, (0.9, 0.0, 0.0)),
( 6.5, (0.8, 0.8, 0.0)),
( 7.5, (0.0, 0.9, 0.0)),
( 8.5, (0.0, 0.8, 0.8)),
(10.0, (0.7, 0.7, 0.7)),
],
"SPECIAL": (0.4, 0.0, 0.8),
"BARBIE": (244.0/255.0, 33.0/255.0, 138.0/255.0),
}
if DARK:
Plot.style.use("dark_background")
SCORES = {
"K": -1,
"ZZZZ": 2.9,
"ZZZ-": 3.0,
"ZZZ": 3.1, "ZZ": 3.15, "Z-": 3.2, "Z": 3.25,
"L": 3.25, "T": 3.25, "N": 3.25,
"FFF": 3.5, "FF": 3.7,
"F-": 3.7, "F": 4.0, "F+": 4.3, "E": 4.5,
"D-": 4.7, "D": 5.0, "D+": 5.3,
"C-": 5.7, "C": 6.0, "C+": 6.3,
"B-": 6.7, "B": 7.0, "B+": 7.3,
"A-": 7.7, "A": 8.0, "A+": 8.3,
"S-": 8.7, "S": 9.0, "Plot": 9.0, "S+": 9.3,
"SS": 9.3, "SSS": 9.5, "W": 9.7,
"SSS+": 9.8,
"SSSS": 9.9,
"HH": 10
}
TOTAL_SCORES = { name: 0 for name in SCORES.keys() }
if SORT_BY is not None:
COLOR_BY = SORT_BY
# runtime globals
THREADS = {}
MARKERS = {}
REVERSE_MARKERS = {}
def add_marker( name, tag, color=COLORS["TEXT"], reverse=None ):
MARKERS[name] = {
"tag": tag,
"color": color,
"count": 0,
"reverse": reverse,
}
add_marker("girl", tag="!", reverse="femcel")
add_marker("foid", tag="...", reverse="sex worker")
add_marker("tranny", tag="*", reverse="real woman")
add_marker("biohazard", tag="#")
add_marker("male", tag="")
add_marker("female", tag="")
add_marker("BOOBS", tag="( Y )")
add_marker("vt", tag="^")
add_marker("race", tag="@")
add_marker("trainwreck/cringekino", tag="%", reverse="flawless")
add_marker("DNF/invalid", tag="$", reverse="WR")
add_marker("overestimate", tag=">", reverse="underestimate")
add_marker("amogus", tag=" sus")
add_marker("savestated", tag="\\")
add_marker("ad", tag="", reverse="organic")
# Ratings fetch related
# yuck
class MyHTMLParser(HTMLParser):
def __init__(self):
self.str = ""
super().__init__()
def handle_starttag(self, tag, attrs):
if tag == "br":
self.str += "\n"
def handle_data(self, data):
self.str += data
def curl(url):
if url in THREADS:
return THREADS[url]
try:
conn = urllib.request.urlopen(url)
data = conn.read()
data = data.decode()
data = json.loads(data)
conn.close()
THREADS[url] = data
return data
except:
return None
# ick
def parse_rating(comment):
matches = RE_RATING_NEWLINE.findall(comment)
binned = re.sub(r'>>\d+\n', "", comment)
if len(matches) == 1:
match = matches[0]
if match[0] in "SABCDEFNTZKsabcdefntzk":
return match, None
matches = RE_RATING.findall(comment)
if len(matches) <= 0:
return None, binned
if len(matches) >= 2:
return None, binned
match = matches[0]
if match[0] not in "SABCDEFNTZKsabcdefntzk":
return None, binned
return match, None
def fetch_ratings( queue=[] ):
try:
ratings = json.load(open(IN_RATINGS_FILE, "r", encoding='utf-8'))
except Exception as e:
ratings = {}
raise e
if not queue:
try:
queue = json.load(open(IN_QUEUE_FILE, "r", encoding='utf-8'))
except Exception as e:
print(str(e))
pass
for entry in queue:
name = entry["name"]
post = entry["post"]
if post == "" or post is None:
continue
url, _, post_no = post.partition("#p")
url = url.replace("boards.4chan.org", "a.4cdn.org") + ".json"
post_no = int(post_no)
if name not in ratings:
ratings[name] = {}
# coerse marker => markers
if "marker" in entry and "markers" not in entry:
entry["markers"] = [ entry["marker"] ]
del entry["marker"]
# in case an entry is already defined without these
if "markers" in entry:
ratings[name]["markers"] = entry["markers"]
if "posts" not in ratings[name]:
ratings[name]["posts"] = []
if post not in ratings[name]["posts"]:
ratings[name]["posts"].append(post)
if "ratings" not in ratings[name]:
ratings[name]["ratings"] = {}
if "binned" not in ratings[name]:
ratings[name]["binned"] = {}
if "times" not in ratings[name]:
ratings[name]["times"] = {}
print(f"Fetching {name}: {post}")
data = curl(url)
if data is None:
print(f"404: {name}: {post}")
continue
# Matches ">>123" quote tags in a HTML-encoded post comment
re_link = re.compile(f">&gt;&gt;{post_no}<")
for post in data["posts"]:
if "com" not in post:
continue
if "no" not in post:
continue
com = post["com"]
no = str(post["no"])
time = post["time"]
match = re_link.search(com)
if match is None and f'{no}' != f'{post_no}':
continue
if no not in ratings[name]["times"]:
ratings[name]["times"][no] = time
if no in ratings[name]["ratings"]:
continue
parser = MyHTMLParser()
parser.feed(com)
com = parser.str
rating, binned = parse_rating(com)
if rating is not None:
ratings[name]["ratings"][no] = rating
else:
ratings[name]["binned"][no] = binned
for no in ratings[name]["ratings"]:
if no not in ratings[name]["times"]:
print("MISSING:", name, no)
json.dump(ratings, open(IN_RATINGS_FILE, "w"), indent='\t')
json.dump(ratings, open(IN_RATINGS_FILE, "w"), indent='\t')
# Plot related
def rating_to_point(rating, count, RAND_PT_DX=1/5, RAND_PT_DY=1/10):
dx = random.gauss(0, RAND_PT_DX)
dy = random.gauss(0, RAND_PT_DY)
if count < 6:
dx = dx * (math.sqrt(count) / 6)
px = abs(rating + dx)
if rating >= 3.5:
if px < 3.5 or px > 9.5:
px = abs(rating + dx / 2) # Clamp a bit at edges
else:
px = abs(rating - dx / 2)
# clamp
if px <= 3.1:
px = 3.1 + abs(dx) / 2
dy = dy * 1.125
if px > 9.9:
px = 9.9 - abs(dx) / 2
dy = dy * 1.125
py = dy
return px, py
def clamp(x, lo=0, hi=1):
if x < lo:
return lo
if x > hi:
return hi
return x
def li(v0, v1, a):
return v0 * (1- clamp(a)) + v1 * a
def lic(c0, c1, a):
return tuple(li(v0,v1,a) for v0,v1 in zip(c0,c1))
def lerp( a, b, t ):
return a + (b - a) * clamp(t)
# interpolate between the closest defined points
def rating_from_color_table( rating, table, distance=0 ):
for i in range(len(table)-1):
t0, t1 = table[i], table[i+1]
if t0[0] <= rating <= t1[0]:
alpha = (rating-t0[0]) / (t1[0]-t0[0])
c = lic(t0[1], t1[1], alpha)
r = c[0] / (1 + distance)
g = c[1] / (1 + distance)
b = c[2] / (1 + distance)
return (r, g, b)
return (1.0, 0.0, 1.0)
def rating_to_color(rating, stat):
target = stat[COLOR_BY]
mean = stat['mean']
stdev = stat['stdev']
markers = stat['markers']
table = COLORS["RATINGS"]
if "kino" in markers:
return COLORS["SPECIAL"]
if "french" in markers:
RED = (1.0, 0.0, 0.0)
WHITE = (1.0, 1.0, 1.0)
BLUE = (0.0, 0.0, 1.0)
distance = rating - mean / stdev
if distance < -1.0:
return BLUE
if distance > 0.5:
return RED
return WHITE
distance = 0
if stdev > 0:
distance = (abs(rating - mean) / stdev)
if distance < 2:
distance = 0
distance = distance * 0.75
return rating_from_color_table( target, table, distance )
def title_format(s):
if USE_LATEX:
return s.replace('&', r'\&').replace("^", r'\^')
return s
def plot_sub_scatter(sub, stats):
if DROP_Z_S:
xticks = [("W", 8), ("Mid", 6), ("L", 4)] if ZOOMER else [("S", 9), ("A", 8), ("B", 7), ("C", 6), ("D", 5), ("F", 4)]
# set Range
lo = xticks[-1][1] - 1
hi = xticks[0][1] + 1
else:
xticks = [("W", 8), ("Mid", 6), ("L", 4)] if ZOOMER else [("SSS", 10), ("S", 9), ("A", 8), ("B", 7), ("C", 6), ("D", 5), ("F", 4), ("Z", 3)]
# set Range
lo = xticks[-1][1]
hi = xticks[0][1]
sub.set_xlim([lo, hi])
sub.set_ylim([0, len(stats)])
# Draw horizontal bands
for y in range(len(stats)):
col = COLORS["BAND0"] if y % 2 == 0 else COLORS["BAND1"]
y = len(stats) - y - 1 # Flip
sub.axhspan(y, y+1, facecolor=col, zorder=-20)
# Draw vertical lines
for _, x in xticks:
sub.axvline(x, color=COLORS["LINE"], zorder=-10)
# Set axis labels
sub.tick_params(axis="y", left=False, labelleft=False)
sub.tick_params(axis="x", bottom=True, top=True, labelbottom=True, labeltop=True)
sub.set_xticks([t[1] for t in xticks])
sub.set_xticklabels([f"{t[0]} " for t in xticks], ha="center")
stats.reverse()
# Draw stat points
for y, stat in enumerate(stats):
if stat is None:
continue
xs = [ p[0] for p in stat['points'] ]
ys = [ p[1] + y + 0.35 for p in stat['points'] ]
name = stat['name']
event = stat['event'] if "event" in stat else ""
markers = stat["markers"]
color_points = [ rating_to_color(r, stat) for r in stat['ratings'] ]
color_name = COLORS["TEXT"]
color_stats = COLORS["STATS"]
# for barbie
if name.upper() in COLORS:
color_points = [ COLORS[name.upper()] ]
show_lines = True
for marker in markers:
if marker not in MARKERS:
continue
tag = MARKERS[marker]["tag"]
count = MARKERS[marker]["count"]
if CULL_SINGLETON_MARKERS and count <= 1:
continue
name = f'{name}{tag}'
NAME_SIZE = 8 if len(name) < 58 else 7.5
sub.scatter(xs, ys, marker=".", s=lerp(80, 20, stat['count']/60.0), color=color_points)
sub.annotate(name, (3.1, y+0.8), ha="left", va="center", size=NAME_SIZE, color=color_name, alpha=1) # name
sub.annotate(f"{stat['count']}", (9.9, y+0.8), ha="right", va="center", size=8, color=color_stats) # count
#sub.annotate(f"{stat['mean']}", (9.9, y+1.6), ha="right", va="center", size=8, color=color_stats) # mean
# Show stat visual lines
if "stdev" in LINES:
sub.vlines([stat["mean"] - stat["stdev"], stat["mean"] + stat["stdev"]], y+0.2, y+0.8, color=COLORS["STDEV"], linewidths=1.0, zorder=-9)
if "mean" in LINES:
sub.vlines(stat['mean'], y+0.1, y+0.9, color=COLORS["MEAN"], linewidth=1.0, zorder=-9)
if "mean_smart" in LINES:
sub.vlines(stat['mean_smart'], y+0.1, y+0.9, color=COLORS["MEAN"], linewidth=1.0, zorder=-9)
if "median" in LINES:
sub.vlines(stat['median'], y+0.1, y+0.9, color=COLORS["MEDIAN"], linewidth=1.0, zorder=-9)
def plot_sub_boxplot(sub, stats):
if DROP_Z_S:
xticks = [("W", 8), ("Mid", 6), ("L", 4)] if ZOOMER else [("S", 9), ("A", 8), ("B", 7), ("C", 6), ("D", 5), ("F", 4)]
# set Range
lo = xticks[-1][1] - 1
hi = xticks[0][1] + 1
else:
xticks = [("W", 8), ("Mid", 6), ("L", 4)] if ZOOMER else [("SSS", 10), ("S", 9), ("A", 8), ("B", 7), ("C", 6), ("D", 5), ("F", 4), ("Z", 3)]
# set Range
lo = xticks[-1][1]
hi = xticks[0][1]
sub.set_xlim([lo, hi])
sub.set_ylim([0, len(stats)])
# Draw horizontal bands
for y in range(len(stats)):
col = COLORS["BAND0"] if y % 2 == 0 else COLORS["BAND1"]
y = len(stats) - y - 1 # Flip
sub.axhspan(y, y+1, facecolor=col, zorder=-20)
# Draw vertical lines
for _, x in xticks:
sub.axvline(x, color=COLORS["LINE"], zorder=-10)
# Set axis labels
sub.tick_params(axis="y", left=False, labelleft=False)
sub.tick_params(axis="x", bottom=True, top=True, labelbottom=True, labeltop=True)
sub.set_xticks([t[1] for t in xticks])
sub.set_xticklabels([f"{t[0]} " for t in xticks], ha="center")
boxes = []
positions = []
colors = []
stats.reverse()
for y, stat in enumerate(stats):
# Reverse y position so the first stat ends on top
if stat is None:
continue
boxes.append( [ r for r in stat['ratings'] ] )
positions.append( y + 0.4 )
colors.append( rating_from_color_table( stat['mean_smart'], COLORS["RATINGS"] ) )
name = stat['name']
event = stat['event'] if "event" in stat else ""
markers = stat["markers"]
color_name = COLORS["TEXT"]
color_stats = COLORS["STATS"]
for marker in markers:
if marker not in MARKERS:
continue
tag = MARKERS[marker]["tag"]
count = MARKERS[marker]["count"]
if CULL_SINGLETON_MARKERS and count <= 1:
continue
name = f'{name}{tag}'
NAME_SIZE = 8 if len(name) < 58 else 7.5
sub.annotate(name, (3.1, y+0.8), ha="left", va="center", size=NAME_SIZE, color=color_name, alpha=1) # name
sub.annotate(f"{stat['count']}", (9.9, y+0.8), ha="right", va="center", size=8, color=color_stats) # count
#sub.annotate(f"{stat['mean']}", (9.9, y+1.6), ha="right", va="center", size=8, color=color_stats) # mean
# Draw stat boxes
bplot = sub.boxplot( boxes, positions=positions, showmeans=True, patch_artist=True, meanline=True, vert=False )
# set color to rating color
for patch, color in zip(bplot['boxes'], colors):
patch.set_facecolor(color)
# dict_keys(['whiskers', 'caps', 'boxes', 'medians', 'fliers', 'means'])
# set median to median color
for median in bplot['medians']:
median.set_color(COLORS["MEDIAN"])
for median in bplot['means']:
median.set_color(COLORS["MEAN"])
def plot_stat_bar(sub, stats, stat, y):
if stat is None:
return
xs = [ p[0] for p in stat['points'] ]
ys = [ p[1] + y + 0.35 for p in stat['points'] ]
name = stat['name']
event = stat['event'] if "event" in stat else ""
markers = stat["markers"]
color_points = [ rating_to_color(r, stat) for r in stat['ratings'] ]
color_name = COLORS["TEXT"]
color_stats = COLORS["STATS"]
# for barbie
if name.upper() in COLORS:
color_points = [ COLORS[name.upper()] ]
show_lines = True
for marker in markers:
if marker not in MARKERS:
continue
tag = MARKERS[marker]["tag"]
count = MARKERS[marker]["count"]
if CULL_SINGLETON_MARKERS and count <= 1:
continue
name = f'{name}{tag}'
NAME_SIZE = 8 if len(name) < 58 else 7.5
sub.annotate(name, (3.1, y+0.8), ha="left", va="center", size=NAME_SIZE, color=color_name, alpha=1) # name
sub.annotate(f"{stat['count']}", (9.9, y+0.8), ha="right", va="center", size=8, color=color_stats) # count
#sub.annotate(f"{stat['mean']}", (9.9, y+1.6), ha="right", va="center", size=8, color=color_stats) # mean
def plot_sub_bars(sub, stats):
lo = 0
hi = 1
sub.set_ylim([0, len(stats)])
stats.reverse()
# Draw horizontal bands
for y in range(len(stats)):
col = COLORS["BAND0"] if y % 2 == 0 else COLORS["BAND1"]
y = len(stats) - y - 1 # Flip
sub.axhspan(y, y+1, facecolor=col, zorder=-20)
ys = [0.5 + y for y in range(len(stats))]
# Place bars
bottoms = [0] * len(stats)
buckets = [
(4, "F", "#73172d"),
(5, "D", "#df3e23"),
(6, "C", "#f9a31b"),
(7, "B", "#fffc40"),
(8, "A", "#9cdb43"),
(9, "S", "#20d6c7"),
]
for i,r,c in buckets:
counts = [(0 if s is None else s['buckets'][i]) for s in stats]
sub.barh(ys, counts, 0.5, left=bottoms, color=c, label=r)
bottoms = [b + n for b,n in zip(bottoms, counts)]
# Text
for y, stat in enumerate(stats):
if stat is None:
continue
name = stat['name']
event = stat['event'] if "event" in stat else ""
markers = stat["markers"]
color_name = COLORS["TEXT"]
color_stats = COLORS["STATS"]
show_lines = True
for marker in markers:
if marker not in MARKERS:
continue
tag = MARKERS[marker]["tag"]
count = MARKERS[marker]["count"]
if CULL_SINGLETON_MARKERS and count <= 1:
continue
name = f'{name}{tag}'
NAME_SIZE = 8 if len(name) < 58 else 7.5
sub.annotate(name, (0.0, y+0.85), ha="left", va="center", size=NAME_SIZE, color=color_name, alpha=1) # name
#sub.annotate(f"{stat['count']}", (0, y+0.9), ha="right", va="center", size=8, color=color_stats) # count
# Axis labels
sub.tick_params(axis="y", left=False, labelleft=False)
sub.tick_params(axis="x", bottom=True, top=True, labelbottom=True, labeltop=True)
def create_plot( stats ):
# determine dimensions
cols = max(MIN_COLUMNS, min(5, round(math.sqrt(len(stats) / 6))))
rows = math.ceil(len(stats) / cols)
# subplots might return an array or single subplot
fig, subs = Plot.subplots(1, cols)
if cols == 1:
subs = [subs]
# set main figure stuff
figSize = (cols * COL_WIDTH + 1, rows * ROW_HEIGHT + 1.5)
fig.set_size_inches(figSize)
fig.set_tight_layout(True)
# set title and subtitle
figCenterX, figTop = figSize[0] / 2 - TITLE_OFFSET, figSize[1]
fig.text(figCenterX, figTop-0.2, TITLE, size=20, ha="center", va="top", transform=fig.dpi_scale_trans) # Title
fig.text(figCenterX, figTop-0.45, "as rated by /v/", size=10, ha="center", va="top", transform=fig.dpi_scale_trans) # Subtitle
# legend
if USE_LEGEND:
handles = []
# add lines to legend
if "mean" in LINES or "mean_smart" in LINES:
handles.append(patches.Patch(label=title_format('Mean'), color=COLORS["MEAN"]))
if "median" in LINES:
handles.append(patches.Patch(label=title_format('Median'), color=COLORS["MEDIAN"]))
if "stdev" in LINES:
handles.append(patches.Patch(label=title_format('Std. Dev.'), color=COLORS["STDEV"]))
# add markers to legend
for marker, entry in MARKERS.items():
tag = entry["tag"]
count = entry["count"]
reverse = entry["reverse"] if "reverse" in entry else None
if count <= (1 if CULL_SINGLETON_MARKERS else 0):
continue
if tag == "":
continue
if REVERSE and reverse:
marker = reverse
title = f'({count}) {marker}{tag}'
handles.append(patches.Patch(label=title_format(title), color="#000000"))
fig.legend(handles=handles, ncol=2 if len(handles) <= 4 else 3)
# padding
fig.suptitle(" ", size=40)
# sort by specified statistic
if SORT_BY is not None:
stats = sorted(stats, key=lambda s: s[SORT_BY])
# add empty ratings ensure each column has the same number of entries
stats += [None] * (len(stats) - len(stats) // rows)
# create subplot column plot
for i, sub in enumerate(subs):
stats_sub = stats[i*rows:(i+1)*rows]
if MODE == "scatter":
plot_sub_scatter(sub, stats_sub)
elif MODE == "bars":
plot_sub_bars(sub, stats_sub)
elif MODE == "boxplot":
plot_sub_boxplot(sub, stats_sub)
# Stats related
def compute_mean_smart(ratings):
ratings = sorted( ratings )
cutoff = len(ratings) // 8
if cutoff > 0:
ratings = ratings[cutoff:-cutoff]
return statistics.mean(ratings)
def stat_new(name, ratings, entry={}):
# Fallback in case of near-empty ratings
if len(ratings) == 0:
ratings.append(0.0)
if len(ratings) == 1:
ratings.append(ratings[0])
# Count frequency of each (rounded) rating
buckets = [0] * 10
for r in ratings:
r = min(9, max(4, round(r)))
buckets[r] += 1
# Compute stats
stat = {}
stat['name'] = name
stat['ratings'] = ratings
stat['points'] = [rating_to_point(r, len(ratings)) for r in ratings]
stat['buckets'] = buckets
stat['count'] = len(ratings)
stat['mean'] = statistics.mean(ratings)
stat['median'] = statistics.median(ratings)
stat['mode'] = statistics.mode(ratings)
stat['median_grouped'] = statistics.median_grouped(round(r) for r in ratings)
stat['stdev'] = statistics.pstdev(ratings)
stat['mean_smart'] = compute_mean_smart(ratings)
# attach extra data
stat["markers"] = []
if "marker" in entry:
stat["markers"] = [ entry["marker"] ]
elif "markers" in entry:
stat["markers"] = entry["markers"]
# fix up previously split markers
for i, marker in enumerate(stat["markers"]):
if marker == "DNF":
stat["markers"][i] = "DNF/invalid"
elif marker == "trainwreck":
stat["markers"][i] = "trainwreck/cringekino"
elif marker == "invalid":
stat["markers"][i] = "DNF/invalid"
elif marker == "cringekino":
stat["markers"][i] = "trainwreck/cringekino"
if "event" in entry:
stat["event"] = entry["event"]
return stat
# read from json
def read_stats(filename):
stats = []
aux = {
"total": [],
"markers": {},
}
data = json.load(open(filename, "r", encoding='utf-8'))
for name, entry in data.items():
ratings = []
if "ignore" in entry and entry["ignore"]:
continue
op = entry["posts"][0].split("#p")[-1]
start_time = entry["times"][op] if "times" in entry and op in entry["times"] else 0
# parse ratings in each run
for no, score in entry["ratings"].items():
#cur_time = entry["times"][no] if "times" in entry and no in entry["times"] else 0
if start_time > 0 and CUTOFF_SECONDS > 0:
if no not in entry["times"]:
continue
cur_time = entry["times"][no]
if cur_time - start_time > CUTOFF_SECONDS:
print("Culled", name, no, score, cur_time - start_time)
continue
score = score.upper()
# trim extraneous characters (for example: ZZZZZZZ) if not already a valid score
if score not in SCORES:
score = score[:3]
if DROP_Z_S:
if score[0] == "Z":
continue
if score[:2] == "SS":
continue
# invalid score
if score not in SCORES:
continue
# increment totals (I don't remember what I originally used this for)
TOTAL_SCORES[score] += 1
# score modifiers
rating = SCORES[score]
# randomize downward
if "randomize" in entry and CUTOFF_SECONDS == 0 and False:
lo = -3.0 # SCORES[list(SCORES.keys())[0]]
hi = 6.0 # SCORES[list(SCORES.keys())[-1]]
if random.random() < entry["randomize"] and rating > hi:
rating = random.uniform(lo, hi)
ratings.append( rating )
aux["total"].append( rating )
stat = stat_new(name, ratings, entry)
stats.append(stat)
# increment marker totals
for marker in stat["markers"]:
if marker not in MARKERS:
continue
MARKERS[marker]["count"] += 1
for rating in ratings:
if marker not in aux["markers"]:
aux["markers"][marker] = []
aux["markers"][marker].append( rating )
if not stat["markers"]:
marker = "none"
for rating in ratings:
if marker not in aux["markers"]:
aux["markers"][marker] = []
aux["markers"][marker].append( rating )
# show aggregate total
if AUX_MODE == "total":
stats = [ stat_new("total", aux["total"]) ]
# aggregate by markers
elif AUX_MODE == "markers":
stats = [ stat_new(name, ratings) for name, ratings in aux["markers"].items() ]
return stats
def main():
# yuck
global REVERSE
global ZOOMER
global DARK
global USE_LEGEND
global MIN_COLUMNS
global CUTOFF_SECONDS
global SORT_BY
global MODE
global AUX_MODE
global OUT_FILE
parser = argparse.ArgumentParser("Charter")
# actions
parser.add_argument("--fetch", action="store_true", help="Fetch ratings from queue")
parser.add_argument("--plot", action="store_true", help="Plot ratings from file")
# queue-less args
parser.add_argument("--url", type=str, default=None, help="Post link to fetch ratings from its replies")
parser.add_argument("--name", type=str, default=None, help="Name of run for rating")
parser.add_argument("--markers", type=str, default=None, help="Markers to attach to run rating")
# modifiers
parser.add_argument("--sort-by", type=str, default=None, help="Sort plotted ratings by this value")
parser.add_argument("--mode", type=str, default=None, help="Additional modes (scatter | bars | boxplot)")
parser.add_argument("--aux-mode", type=str, default=None, help="Additional modes (total | markers)")
parser.add_argument("--cutoff-seconds", type=int, default=None, help="Ignore ratings made X seconds after the rating post")
parser.add_argument("--cutoff-minutes", type=int, default=None, help="Ignore ratings made X minutes after the rating post")
# lesser used modifiers
parser.add_argument("--reverse", action="store_true", help="Reverses the ratings")
parser.add_argument("--zoomer", action="store_true", help="Zoomer friendly scales")
parser.add_argument("--light", action="store_true", help="Light mode")
parser.add_argument("--no-legend", action="store_true", help="Hides the legend")
parser.add_argument("--copy", action="store_true", help="Saves a copy by the timestamp")
args = parser.parse_args()
if args.sort_by:
SORT_BY = args.sort_by
if args.aux_mode:
AUX_MODE = args.aux_mode
if AUX_MODE == "total":
args.no_legend = True
MIN_COLUMNS = 1
modifiers = [ f'[{SORT_BY or AUX_MODE or "chronological"}]' ]
if args.mode:
MODE = args.mode
modifiers.append(f'[type={MODE}]')
if args.cutoff_seconds:
CUTOFF_SECONDS = args.cutoff_seconds
elif args.cutoff_minutes:
CUTOFF_SECONDS = args.cutoff_minutes * 60
if CUTOFF_SECONDS > 0:
modifiers.append(f'[cutoff={CUTOFF_SECONDS//60}]')
OUT_FILE = f'./images/ratings{"".join(modifiers)}.png'
REVERSE = args.reverse
ZOOMER = args.zoomer
DARK = not args.light
USE_LEGEND = not args.no_legend
if args.fetch:
queue = []
# inject to queue
if args.url and args.name:
queue = [{ "post": args.url, "name": args.name, "markers": args.markers.split(",") if args.markers else None }]
fetch_ratings(queue=queue)
if args.plot:
stats = read_stats(IN_RATINGS_FILE)
create_plot(stats)
if args.copy:
print(f'Saving chart: {OUT_FILE_TIMESTAMP}')
Plot.savefig(OUT_FILE_TIMESTAMP)
print(f'Saving chart: {OUT_FILE}')
Plot.savefig(OUT_FILE)
Plot.close()
if not args.fetch and not args.plot:
raise Exception("Specify --fetch or --plot")
if __name__ == "__main__":
main()