From ff6e2e49687ad34321b8e1688d24606d4d5cfcb6 Mon Sep 17 00:00:00 2001 From: Brian Lee Date: Sun, 26 Jan 2025 08:28:45 -0800 Subject: [PATCH] Add accuracy curve (accgraph) strategy for BeatLeader that filters by a star range --- src/saberlist/make.py | 38 ++++- src/saberlist/playlist_strategies/accuracy.py | 154 ++++++++++++++++++ 2 files changed, 185 insertions(+), 7 deletions(-) diff --git a/src/saberlist/make.py b/src/saberlist/make.py index 8df77e6..329bc13 100644 --- a/src/saberlist/make.py +++ b/src/saberlist/make.py @@ -21,10 +21,25 @@ from helpers.SimpleBeatLeaderAPI import SimpleBeatLeaderAPI from helpers.SimpleBeatSaverAPI import SimpleBeatSaverAPI from saberlist.utils import reset_history -from saberlist.playlist_strategies.oldscores import playlist_strategy_beatleader_oldscores, playlist_strategy_scoresaber_oldscores -from saberlist.playlist_strategies.accuracy import playlist_strategy_beatleader_lowest_acc, playlist_strategy_beatleader_accuracy_gaps, playlist_strategy_scoresaber_accuracy_gaps -from saberlist.playlist_strategies.performance import playlist_strategy_beatleader_lowest_pp, playlist_strategy_scoresaber_lowest_pp -from saberlist.playlist_strategies.beatsaver import playlist_strategy_beatsaver_acc, playlist_strategy_beatsaver_curated, playlist_strategy_beatsaver_mappers +from saberlist.playlist_strategies.oldscores import ( + playlist_strategy_beatleader_oldscores, + playlist_strategy_scoresaber_oldscores, +) +from saberlist.playlist_strategies.accuracy import ( + playlist_strategy_beatleader_lowest_acc, + playlist_strategy_beatleader_accuracy_gaps, + playlist_strategy_scoresaber_accuracy_gaps, + playlist_strategy_beatleader_accuracy_gaps_star_range, +) +from saberlist.playlist_strategies.performance import ( + playlist_strategy_beatleader_lowest_pp, + playlist_strategy_scoresaber_lowest_pp, +) +from saberlist.playlist_strategies.beatsaver import ( + playlist_strategy_beatsaver_acc, + playlist_strategy_beatsaver_curated, + playlist_strategy_beatsaver_mappers, +) def saberlist() -> None: """ @@ -44,7 +59,7 @@ def saberlist() -> None: playlist_builder = PlaylistBuilder(covers_dir='./covers/beatsavers') elif strategy == 'beatleader_lowest_pp': playlist_data, playlist_title = playlist_strategy_beatleader_lowest_pp(BeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS)) - playlist_builder = PlaylistBuilder(covers_dir='./covers/beatleader') + playlist_builder = PlaylistBuilder(covers_dir='./covers/pajamas') elif strategy == 'scoresaber_lowest_pp': playlist_data, playlist_title = playlist_strategy_scoresaber_lowest_pp(ScoreSaberAPI(cache_expiry_days=CACHE_EXPIRY_DAYS)) playlist_builder = PlaylistBuilder(covers_dir='./covers/scoresaber') @@ -54,6 +69,10 @@ def saberlist() -> None: elif strategy == 'beatleader_accuracy_gaps': playlist_data, playlist_title = playlist_strategy_beatleader_accuracy_gaps(SimpleBeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS)) playlist_builder = PlaylistBuilder(covers_dir='./covers/pajamas') + elif strategy == 'beatleader_accuracy_gaps_star_range': + input_star_level = input("Enter star level (Default: 6)") or 6 + playlist_data, playlist_title = playlist_strategy_beatleader_accuracy_gaps_star_range(SimpleBeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS), star_level=float(input_star_level)) + playlist_builder = PlaylistBuilder(covers_dir='./covers/pajamas') elif strategy == 'scoresaber_accuracy_gaps': playlist_data, playlist_title = playlist_strategy_scoresaber_accuracy_gaps(ScoreSaberAPI(cache_expiry_days=CACHE_EXPIRY_DAYS)) playlist_builder = PlaylistBuilder(covers_dir='./covers/scoresaber') @@ -63,11 +82,14 @@ def saberlist() -> None: elif strategy == 'beatsaver_mappers': playlist_data, playlist_title = playlist_strategy_beatsaver_mappers(SimpleBeatSaverAPI()) playlist_builder = PlaylistBuilder(covers_dir='./covers/pajamas') + elif strategy == 'blank_playlist': + playlist_data, playlist_title = [], input("Enter playlist title: ") + playlist_builder = PlaylistBuilder(covers_dir='./covers/pajamas') else: logging.error(f"Unknown strategy '{strategy}'") return - if not playlist_data: + if not playlist_data and strategy != 'blank_playlist': logging.info("No new scores found to add to the playlist.") return @@ -88,9 +110,11 @@ def get_strategy(): # "scoresaber_lowest_pp", # "beatleader_lowest_acc", "beatleader_accuracy_gaps", + "beatleader_accuracy_gaps_star_range", "scoresaber_accuracy_gaps", "beatsaver_curated", - "beatsaver_mappers" + "beatsaver_mappers", + "blank_playlist" ], help="Specify the playlist generation strategy") parser.add_argument("-r", "--reset", diff --git a/src/saberlist/playlist_strategies/accuracy.py b/src/saberlist/playlist_strategies/accuracy.py index a2c5431..2c38340 100644 --- a/src/saberlist/playlist_strategies/accuracy.py +++ b/src/saberlist/playlist_strategies/accuracy.py @@ -391,3 +391,157 @@ def playlist_strategy_beatleader_lowest_acc( save_history(history) return playlist_data, f"beatleader_lowest_acc-{new_count:02d}" + +def playlist_strategy_beatleader_accuracy_gaps_star_range( + api: SimpleBeatLeaderAPI, + star_level: float, + song_count: int = 40, + bin_size: float = 0.25, + bin_sort: bool = False +) -> List[Dict[str, Any]]: + """ + Build a playlist of songs where the player's accuracy is furthest below the median accuracy + for a specified star rating range. Songs are grouped into bins by star rating within the + specified range to ensure fair comparison. + Like beatleader_accuracy_gaps, but only for a specific star rating range. + + :param api: SimpleBeatLeaderAPI instance for making API calls + :param star_level: Specific star level to filter songs (e.g., 6.0 for songs ranging from 6.00 to 6.99) + :param song_count: Number of songs to include in the playlist + :param bin_size: Size of star rating bins for grouping similar difficulty songs + :param bin_sort: Whether to sort the bins by star rating + :return: A tuple containing (list of song dictionaries, playlist title string) + """ + player_id = prompt_for_player_id() + history = load_history() + history.setdefault('beatleader_accuracy_gaps_star_range', {}) + history.setdefault('playlist_counts', {}) + + # Validate star_level + if not isinstance(star_level, (int, float)) or star_level < 0: + logging.error("Invalid star_level provided. It must be a non-negative number.") + return [], "" + + # Define star range based on the specified star_level + star_min = star_level + star_max = star_level + 0.99 # Inclusive of the specified star_level up to the next whole number minus one cent + + # Get the current count and increment it + count_key = f'beatleader_accuracy_gaps_star_{star_level}' + current_count = history['playlist_counts'].get(count_key, 0) + new_count = current_count + 1 + history['playlist_counts'][count_key] = new_count + + # Fetch accuracy graph data + all_scores = api.get_player_accgraph(player_id) + if not all_scores: + logging.warning(f"No accgraph data found for player ID {player_id} on BeatLeader.") + return [], "" + logging.debug(f"Found {len(all_scores)} accgraph entries for player ID {player_id} on BeatLeader.") + + # Filter scores within the specified star range + filtered_scores = [ + entry for entry in all_scores + if entry.get('stars') is not None and star_min <= entry['stars'] <= star_max + ] + + if not filtered_scores: + logging.warning(f"No accgraph entries found within star range {star_min} to {star_max}.") + return [], "" + logging.debug(f"Found {len(filtered_scores)} accgraph entries within star range {star_min} to {star_max}.") + + # Collect all star ratings within the specified range + star_ratings = [entry['stars'] for entry in filtered_scores if entry.get('stars') is not None] + min_stars = min(star_ratings) + max_stars = max(star_ratings) + star_range = max_stars - min_stars + + # Determine number of bins + num_bins = math.ceil(star_range / bin_size) + logging.info(f"Using bin size: {bin_size}, resulting in {num_bins} bins within star range {star_min} to {star_max}.") + + # Group accuracies by bins + bin_to_accuracies = defaultdict(list) + for entry in filtered_scores: + stars = entry.get('stars') + acc = entry.get('acc') + if stars is None or acc is None: + continue + bin_index = int((stars - min_stars) / bin_size) + bin_to_accuracies[bin_index].append(acc) + + # Calculate median accuracy for each bin + bin_to_median = {} + for bin_index, accs in bin_to_accuracies.items(): + bin_to_median[bin_index] = median(accs) + bin_start = min_stars + bin_index * bin_size + bin_end = bin_start + bin_size + logging.debug(f"Median accuracy for bin {bin_index} (stars {bin_start:.2f} to {bin_end:.2f}): {bin_to_median[bin_index]:.4f}") + + # Compute difference from median for each score + for entry in filtered_scores: + stars = entry.get('stars') + acc = entry.get('acc') + if stars is not None and acc is not None: + bin_index = int((stars - min_stars) / bin_size) + median_acc = bin_to_median.get(bin_index) + if median_acc is not None: + entry['diff_from_median'] = acc - median_acc + else: + entry['diff_from_median'] = float('inf') # Place entries with missing data at the end + else: + entry['diff_from_median'] = float('inf') # Place entries with missing data at the end + + # Sort scores by difference from median (ascending: most below median first) + filtered_scores.sort(key=lambda x: x.get('diff_from_median', float('inf'))) + + playlist_data = [] + for score_entry in filtered_scores: + if len(playlist_data) >= song_count: + break + + acc = score_entry.get('acc', 0) + stars = score_entry.get('stars') + song_hash = score_entry.get('hash') + + if not song_hash or stars is None: + logging.debug(f"Skipping entry due to missing hash or stars: {score_entry}") + continue + + difficulty = score_entry.get('diff', '') + difficulty_characteristic = score_entry.get('mode', 'Standard') + + if song_hash in history['beatleader_accuracy_gaps_star_range'] and difficulty in history['beatleader_accuracy_gaps_star_range'][song_hash]: + logging.debug(f"Skipping song {song_hash} with difficulty {difficulty} as it's in history.") + continue + + song_dict = { + 'hash': song_hash, + 'difficulties': [ + { + 'name': difficulty, + 'characteristic': difficulty_characteristic + } + ] + } + + playlist_data.append(song_dict) + logging.debug(f"Selected song for playlist: Hash={song_hash}, Difficulty={difficulty}, " + f"Accuracy={acc*100:.2f}%, Diff from Median={score_entry['diff_from_median']*100:.2f}%") + + # Update history + history['beatleader_accuracy_gaps_star_range'].setdefault(song_hash, []).append(difficulty) + + if not playlist_data: + logging.info(f"No new songs found to add to the playlist based on history for BeatLeader accuracy gaps within star range {star_min} to {star_max}.") + else: + for song in playlist_data: + song_hash = song['hash'] + difficulty = song['difficulties'][0]['name'] + logging.info(f"Song added: Hash={song_hash}, Difficulty={difficulty}") + logging.info(f"Total songs added to playlist from BeatLeader accuracy gaps within star range {star_min} to {star_max}: {len(playlist_data)}") + + save_history(history) + playlist_title = f"beatleader_accgraph_star_{star_level}-{new_count:02d}" + + return playlist_data, playlist_title \ No newline at end of file