From e2b3701bab6153717df333fe8dd31c181b2a6424 Mon Sep 17 00:00:00 2001 From: DivvyC Date: Wed, 15 Apr 2020 14:28:23 +0100 Subject: [PATCH 01/16] Docs analysis_manager.py cleaner.py frame_cleaner.py (improved readability) --- carball/analysis/analysis_manager.py | 180 ++++++++++++++++------ carball/analysis/cleaner/cleaner.py | 19 ++- carball/analysis/cleaner/frame_cleaner.py | 60 ++++++-- 3 files changed, 201 insertions(+), 58 deletions(-) diff --git a/carball/analysis/analysis_manager.py b/carball/analysis/analysis_manager.py index e729bf36..e32f3f33 100644 --- a/carball/analysis/analysis_manager.py +++ b/carball/analysis/analysis_manager.py @@ -31,6 +31,17 @@ class AnalysisManager: + """ + DESCRIPTION + AnalysisManager class takes an initialized Game object and converts the data into a Protobuf and a DataFrame. Then, + that data is used to perform full analysis on the replay. + + COMMON PARAMS + :game: The game object (instance of Game). It contains the replay metadata and processed json data. + :proto_game: The game's protobuf (instance of game_pb2) (refer to the comment in get_protobuf_data() for more info). + :data_frame: The game's pandas.DataFrame object (refer to comment in get_data_frame() for more info). + """ + id_creator = None timer = None @@ -46,22 +57,32 @@ def __init__(self, game: Game): def create_analysis(self, calculate_intensive_events: bool = False): """ - Organizes all the different analysis that can occurs. - :param calculate_intensive_events: Indicates if expensive calculations should run to include additional stats. + Sets basic metadata, and decides whether analysis can be performed and then passes required parameters + to perform_full_analysis(...); After, stores the DataFrame. + + :param calculate_intensive_events: Indicates if expensive calculations should run to include additional stats, + such as: + Hit pressure, which calculates how long it would've taken the nearest opponent to hit the ball, + i.e. did the player have time to make a better play? + 50/50s (not implemented yet) + Bumps (not implemented yet) """ + self.start_time() player_map = self.get_game_metadata(self.game, self.protobuf_game) - self.log_time("creating metadata") + self.log_time("Getting in-game frame-by-frame data...") data_frame = self.get_data_frames(self.game) - self.log_time("getting frames") + self.log_time("Getting important frames (kickoff, first-touch)...") kickoff_frames, first_touch_frames = self.get_kickoff_frames(self.game, self.protobuf_game, data_frame) + self.log_time("Setting game kickoff frames...") self.game.kickoff_frames = kickoff_frames - self.log_time("getting kickoff") + if self.can_do_full_analysis(first_touch_frames): self.perform_full_analysis(self.game, self.protobuf_game, player_map, data_frame, kickoff_frames, first_touch_frames, calculate_intensive_events=calculate_intensive_events) else: + self.log_time("Cannot perform analysis: invalid analysis.") self.protobuf_game.game_metadata.is_invalid_analysis = True # log before we add the dataframes @@ -74,44 +95,64 @@ def perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_ma calculate_intensive_events: bool = False): """ - Performs the more in depth analysis on the game in addition to just metadata. - :param game: Contains all metadata about the game and any json data. - :param proto_game: The protobuf where all the stats are being stored. + Sets some further data and cleans the replay; + Then, performs the analysis, which includes: + creating in-game event data (boostpads, hits, carries, bumps etc.) + getting in-game stats (i.e. player, team, general-game and hit stats) + + :param game: The game object (instance of Game). See top of class for info. + :param proto_game: The game's protobuf (instance of game_pb2). See top of class for info. :param player_map: A map of player name to Player protobuf. - :param data_frame: Contains the entire data from the game. + :param data_frame: The game's pandas.DataFrame. See top of class for info. :param kickoff_frames: Contains data about the kickoffs. :param first_touch_frames: Contains data for frames where touches can actually occur. :param calculate_intensive_events: Indicates if expensive calculations should run to include additional stats. """ + self.get_game_time(proto_game, data_frame) clean_replay(game, data_frame, proto_game, player_map) + self.log_time("Creating events...") self.events_creator.create_events(game, proto_game, player_map, data_frame, kickoff_frames, first_touch_frames, - calculate_intensive_events=calculate_intensive_events) - self.log_time("creating events") + calculate_intensive_events=calculate_intensive_events) + self.log_time("Getting stats...") self.get_stats(game, proto_game, player_map, data_frame) def get_game_metadata(self, game: Game, proto_game: game_pb2.Game) -> Dict[str, Player]: + """ + Processes protobuf data and sets the respective object fields to correct values. + Maps the player's specific online ID (steam unique ID) to the player object. - # create general metadata + :params: See top of class. + :return: A dictionary, with the player's online ID as the key, and the player object (instance of Player) as the value. + """ + # Process the relevant protobuf data and pass it to the Game object (returned data is ignored). ApiGame.create_from_game(proto_game.game_metadata, game, self.id_creator) - # create team metadata + # Process the relevant protobuf data and pass it to the Game object's mutators (returned data is ignored). + ApiMutators.create_from_game(proto_game.mutators, game, self.id_creator) + + # Process the relevant protobuf data and pass it to the Team objects (returned data is ignored). ApiTeam.create_teams_from_game(game, proto_game, self.id_creator) + # Process the relevant protobuf data and add players to their respective parties. ApiGame.create_parties(proto_game.parties, game, self.id_creator) - # create player metadata + player_map = dict() for player in game.players: player_proto = proto_game.players.add() ApiPlayer.create_from_player(player_proto, player, self.id_creator) player_map[str(player.online_id)] = player_proto - # create mutators - ApiMutators.create_from_game(proto_game.mutators, game, self.id_creator) - return player_map def get_game_time(self, protobuf_game: game_pb2.Game, data_frame: pd.DataFrame): + """ + Calculates the game length (total time the game lasted) and sets it to the relevant metadata length field. + Calculates the total time a player has spent in the game and sets it to the relevant player field. + + :params: See top of class. + """ + protobuf_game.game_metadata.length = data_frame.game[data_frame.game.goal_number.notnull()].delta.sum() for player in protobuf_game.players: try: @@ -120,15 +161,21 @@ def get_game_time(self, protobuf_game: game_pb2.Game, data_frame: pd.DataFrame): player.first_frame_in_game = data_frame[player.name].pos_x.first_valid_index() except: player.time_in_game = 0 - logger.info('created all times for players') - def get_data_frames(self, game: Game): - data_frame = SaltieGame.create_data_df(game) - - logger.info("Assigned goal_number in .data_frame") - return data_frame + logger.info("Set each player's in-game times.") def get_kickoff_frames(self, game: Game, proto_game: game_pb2.Game, data_frame: pd.DataFrame): + """ + Firstly, fetches kickoff-related data from SaltieGame. + Secondly, checks for edge-cases and corrects errors. + + NOTE: kickoff_frames is an array of all in-game frames at each kickoff beginning. + NOTE: first_touch_frames is an array of all in-game frames for each 'First Touch' at kickoff. + + :params: See top of class. + :return: See notes above. + """ + kickoff_frames = SaltieGame.get_kickoff_frames(game) first_touch_frames = SaltieGame.get_first_touch_frames(game) @@ -137,9 +184,7 @@ def get_kickoff_frames(self, game: Game, proto_game: game_pb2.Game, data_frame: kickoff_frames = kickoff_frames[:len(first_touch_frames)] for goal_number, goal in enumerate(game.goals): - data_frame.loc[ - kickoff_frames[goal_number]: goal.frame_number, ('game', 'goal_number') - ] = goal_number + data_frame.loc[kickoff_frames[goal_number]: goal.frame_number, ('game', 'goal_number')] = goal_number # Set goal_number of frames that are post last kickoff to -1 (ie non None) if len(kickoff_frames) > len(proto_game.game_metadata.goals): @@ -155,9 +200,13 @@ def get_kickoff_frames(self, game: Game, proto_game: game_pb2.Game, data_frame: def get_stats(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player], data_frame: pd.DataFrame): """ - Calculates all stats that are beyond the basic event creation. - This is only active on valid goal frames. + For each in-game frame after a goal has happened, calculate in-game stats + (i.e. player, team, general-game and hit stats) + + :param player_map: The dictionary with all player IDs matched to the player objects. + :params: See top of class. """ + goal_frames = data_frame.game.goal_number.notnull() self.stats_manager.get_stats(game, proto_game, player_map, data_frame[goal_frames]) @@ -165,6 +214,11 @@ def store_frames(self, data_frame: pd.DataFrame): self.data_frame = data_frame self.df_bytes = PandasManager.safe_write_pandas_to_memory(data_frame) + def write_json_out_to_file(self, file): + printer = _Printer() + js = printer._MessageToJsonObject(self.protobuf_game) + json.dump(js, file, indent=2, cls=CarballJsonEncoder) + def write_proto_out_to_file(self, file): ProtobufManager.write_proto_out_to_file(file, self.protobuf_game) @@ -177,20 +231,38 @@ def write_pandas_out_to_file(self, file): def get_protobuf_data(self) -> game_pb2.Game: """ :return: The protobuf data created by the analysis + + USAGE: A Protocol Buffer contains in-game metadata (e.g. events, stats) + + INFO: The Protocol Buffer is a collection of data organized in a format similar to json. All relevant .proto + files found at https://github.com/SaltieRL/carball/tree/master/api. + + Google's developer guide to protocol buffers may be found at https://developers.google.com/protocol-buffers/docs/overview """ return self.protobuf_game def get_data_frame(self) -> pd.DataFrame: + """ + :return: The pandas.DataFrame object. + + USAGE: A DataFrame contains in-game frame-by-frame data. + + INFO: The DataFrame is a collection of data organized in a format similar to csv. The 'index' column of the + DataFrame is the consecutive in-game frames, and all other column headings (150+) are tuples in the following + format: + (Object, Data), where the Object is either a player, the ball or the game. + + All column information (and keys) may be seen by calling data_frame.info(verbose=True) + + All further documentation about the DataFrame can be found at https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html + """ return self.data_frame - def start_time(self): - self.timer = time.time() - logger.info("starting timer") + def get_data_frames(self, game: Game): + data_frame = SaltieGame.create_data_df(game) - def log_time(self, message=""): - end = time.time() - logger.info("Time taken for %s is %s milliseconds", message, (end - self.timer) * 1000) - self.timer = end + logger.info("Assigned goal_number in .data_frame") + return data_frame def create_player_id_function(self, game: Game) -> Callable: name_map = {player.name: player.online_id for player in game.players} @@ -200,8 +272,21 @@ def create_name(proto_player_id, name): return create_name - def can_do_full_analysis(self, first_touch_frames) -> bool: - # Analyse only if 1v1 or 2v2 or 3v3 + def can_do_full_analysis(self, first_touch_frames, force_equal_teams=False) -> bool: + """ + Check whether or not the replay satisfies the requirements for a full analysis. + This includes checking: + if at least 1 team is present; + if the ball was touched at least once; + if both teams have an equal amount of players. + + In some cases, the check for equal team sizes must be ignored due to spectators joining the match, for example. + Therefore, this check is ignored by default. + + :param first_touch_frames: An array of all in-game frames for each 'First Touch' at kickoff. + :return: Bool - true if criteria above are satisfied. + """ + team_sizes = [] for team in self.game.teams: team_sizes.append(len(team.players)) @@ -209,16 +294,23 @@ def can_do_full_analysis(self, first_touch_frames) -> bool: if len(team_sizes) == 0: logger.warning("Not doing full analysis. No teams found") return False + if len(first_touch_frames) == 0: logger.warning("Not doing full analysis. No one touched the ball") return False - # if any((team_size != team_sizes[0]) for team_size in team_sizes): - # logger.warning("Not doing full analysis. Not all team sizes are equal") - # return False + + if force_equal_teams: + if any((team_size != team_sizes[0]) for team_size in team_sizes): + logger.warning("Not doing full analysis. Not all team sizes are equal") + return False return True - def write_json_out_to_file(self, file): - printer = _Printer() - js = printer._MessageToJsonObject(self.protobuf_game) - json.dump(js, file, indent=2, cls=CarballJsonEncoder) + def start_time(self): + self.timer = time.time() + logger.info("starting timer") + + def log_time(self, message=""): + end = time.time() + logger.info("Time taken for %s is %s milliseconds", message, (end - self.timer) * 1000) + self.timer = end diff --git a/carball/analysis/cleaner/cleaner.py b/carball/analysis/cleaner/cleaner.py index bcc7486f..c4090420 100644 --- a/carball/analysis/cleaner/cleaner.py +++ b/carball/analysis/cleaner/cleaner.py @@ -11,14 +11,31 @@ def clean_replay(game: Game, data_frame: pd.DataFrame, proto_game: game_pb2.Game, player_map): + """ + Cleans the replay's pandas.DataFrame object, by removing unnecessary/useless values. + + :param game: The Game object. + :param data_frame: The pandas.DataFrame object. + :param proto_game: The game's protobuf object. + :param player_map: The map of players' online IDs to the Player objects. + """ + + # Remove empty columns. drop_nans(data_frame) + # Remove players with missing data. remove_invalid_players(game, data_frame, proto_game, player_map) - logger.info('cleaned up the replay') + logger.info("Replay cleaned.") def drop_nans(data_frame: pd.DataFrame): + """ + Removes empty columns. Most commonly the is_overtime column (for games without OT). + + :param data_frame: The pandas.DataFrame object. + """ + data_frame.dropna(axis=1, how='all') post_hit = data_frame[(data_frame.game.time > 5.0)] columns_to_remove = post_hit.columns[post_hit.isna().all()].tolist() diff --git a/carball/analysis/cleaner/frame_cleaner.py b/carball/analysis/cleaner/frame_cleaner.py index 37d81449..f655a174 100644 --- a/carball/analysis/cleaner/frame_cleaner.py +++ b/carball/analysis/cleaner/frame_cleaner.py @@ -10,18 +10,46 @@ def remove_invalid_players(game: Game, data_frame: pd.DataFrame, proto_game: game_pb2.Game, player_map): + """ + Finds and removes invalid players from all relevant fields. + + :param game: The Game object. + :param data_frame: The pandas.DataFrame object. + :param proto_game: The game's protobuf object. + :param player_map: The map of players' online IDs to the Player objects. + """ + + # Get invalid players; if none - return. + invalid_players = get_invalid_players(proto_game, data_frame) + if len(invalid_players) == 0: + return + + # Remove player from Game and game_pb2.Game + game.players = [player for player in game.players if player.name not in invalid_players] + remove_player_from_protobuf(proto_game, player_map, invalid_players) + + logger.warning("Invalid player(s) removed: " + str(invalid_players)) + + +def get_invalid_players(proto_game: game_pb2.Game, data_frame: pd.DataFrame): + """ + :return: An array of invalid players' names. + """ + invalid_players = [] for player in proto_game.players: name = player.name if player.time_in_game < 5 or name not in data_frame or ('pos_x' not in data_frame[name]): invalid_players.append(player.name) - if len(invalid_players) == 0: - return - # remove from game - game.players = [player for player in game.players if player.name not in invalid_players] + return invalid_players + + +def remove_player_from_protobuf(proto_game: game_pb2.Game, player_map, invalid_players): + """ + Removes invalid players from all relevant proto_game fields. + """ - # remove from protobuf proto_players = proto_game.players i = 0 total_length = len(proto_players) @@ -31,15 +59,21 @@ def remove_invalid_players(game: Game, data_frame: pd.DataFrame, proto_game: gam if player.name in invalid_players: del player_map[player.id.id] del proto_players[i] + + remove_player_from_team(proto_game, player) + i -= 1 total_length -= 1 - - # remove player from team - for team in proto_game.teams: - for i in range(len(team.player_ids)): - if team.player_ids[i] == player.id: - del team.player_ids[i] - break i += 1 - logger.warning('removed player: ' + str(invalid_players)) + +def remove_player_from_team(proto_game: game_pb2.Game, player): + """ + Removes given player from proto_game.teams + """ + + for team in proto_game.teams: + for i in range(len(team.player_ids)): + if team.player_ids[i] == player.id: + del team.player_ids[i] + break From 4d936a2c7d3e0d6b77e08b2b702e135a935ae866 Mon Sep 17 00:00:00 2001 From: DivvyC Date: Wed, 15 Apr 2020 14:59:04 +0100 Subject: [PATCH 02/16] Small fix --- carball/analysis/analysis_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carball/analysis/analysis_manager.py b/carball/analysis/analysis_manager.py index e32f3f33..9eb1028b 100644 --- a/carball/analysis/analysis_manager.py +++ b/carball/analysis/analysis_manager.py @@ -272,7 +272,7 @@ def create_name(proto_player_id, name): return create_name - def can_do_full_analysis(self, first_touch_frames, force_equal_teams=False) -> bool: + def can_do_full_analysis(self, first_touch_frames, force_equal_teams: bool = False) -> bool: """ Check whether or not the replay satisfies the requirements for a full analysis. This includes checking: From 33a6d1b31b6c7938935282030ab44a18c6c0278c Mon Sep 17 00:00:00 2001 From: DivvyC Date: Wed, 15 Apr 2020 17:07:08 +0100 Subject: [PATCH 03/16] Edited the docs slightly. --- carball/analysis/analysis_manager.py | 40 ++++++++++++---------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/carball/analysis/analysis_manager.py b/carball/analysis/analysis_manager.py index 9eb1028b..3f72eef4 100644 --- a/carball/analysis/analysis_manager.py +++ b/carball/analysis/analysis_manager.py @@ -32,14 +32,8 @@ class AnalysisManager: """ - DESCRIPTION AnalysisManager class takes an initialized Game object and converts the data into a Protobuf and a DataFrame. Then, that data is used to perform full analysis on the replay. - - COMMON PARAMS - :game: The game object (instance of Game). It contains the replay metadata and processed json data. - :proto_game: The game's protobuf (instance of game_pb2) (refer to the comment in get_protobuf_data() for more info). - :data_frame: The game's pandas.DataFrame object (refer to comment in get_data_frame() for more info). """ id_creator = None @@ -60,12 +54,7 @@ def create_analysis(self, calculate_intensive_events: bool = False): Sets basic metadata, and decides whether analysis can be performed and then passes required parameters to perform_full_analysis(...); After, stores the DataFrame. - :param calculate_intensive_events: Indicates if expensive calculations should run to include additional stats, - such as: - Hit pressure, which calculates how long it would've taken the nearest opponent to hit the ball, - i.e. did the player have time to make a better play? - 50/50s (not implemented yet) - Bumps (not implemented yet) + :param calculate_intensive_events: Indicates if expensive calculations should run to include additional stats. """ self.start_time() @@ -96,14 +85,12 @@ def perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_ma """ Sets some further data and cleans the replay; - Then, performs the analysis, which includes: - creating in-game event data (boostpads, hits, carries, bumps etc.) - getting in-game stats (i.e. player, team, general-game and hit stats) + Then, performs the analysis. - :param game: The game object (instance of Game). See top of class for info. - :param proto_game: The game's protobuf (instance of game_pb2). See top of class for info. + :param game: The game object (instance of Game). It contains the replay metadata and processed json data. + :param proto_game: The game's protobuf (instance of game_pb2) (refer to the comment in get_protobuf_data() for more info). + :param data_frame: The game's pandas.DataFrame object (refer to comment in get_data_frame() for more info). :param player_map: A map of player name to Player protobuf. - :param data_frame: The game's pandas.DataFrame. See top of class for info. :param kickoff_frames: Contains data about the kickoffs. :param first_touch_frames: Contains data for frames where touches can actually occur. :param calculate_intensive_events: Indicates if expensive calculations should run to include additional stats. @@ -122,7 +109,8 @@ def get_game_metadata(self, game: Game, proto_game: game_pb2.Game) -> Dict[str, Processes protobuf data and sets the respective object fields to correct values. Maps the player's specific online ID (steam unique ID) to the player object. - :params: See top of class. + :param game: The game object (instance of Game). It contains the replay metadata and processed json data. + :param proto_game: The game's protobuf (instance of game_pb2) (refer to the comment in get_protobuf_data() for more info). :return: A dictionary, with the player's online ID as the key, and the player object (instance of Player) as the value. """ # Process the relevant protobuf data and pass it to the Game object (returned data is ignored). @@ -150,7 +138,8 @@ def get_game_time(self, protobuf_game: game_pb2.Game, data_frame: pd.DataFrame): Calculates the game length (total time the game lasted) and sets it to the relevant metadata length field. Calculates the total time a player has spent in the game and sets it to the relevant player field. - :params: See top of class. + :param proto_game: The game's protobuf (instance of game_pb2) (refer to the comment in get_protobuf_data() for more info). + :param data_frame: The game's pandas.DataFrame object (refer to comment in get_data_frame() for more info). """ protobuf_game.game_metadata.length = data_frame.game[data_frame.game.goal_number.notnull()].delta.sum() @@ -172,7 +161,9 @@ def get_kickoff_frames(self, game: Game, proto_game: game_pb2.Game, data_frame: NOTE: kickoff_frames is an array of all in-game frames at each kickoff beginning. NOTE: first_touch_frames is an array of all in-game frames for each 'First Touch' at kickoff. - :params: See top of class. + :param game: The game object (instance of Game). It contains the replay metadata and processed json data. + :param proto_game: The game's protobuf (instance of game_pb2) (refer to the comment in get_protobuf_data() for more info). + :param data_frame: The game's pandas.DataFrame object (refer to comment in get_data_frame() for more info). :return: See notes above. """ @@ -203,8 +194,10 @@ def get_stats(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, For each in-game frame after a goal has happened, calculate in-game stats (i.e. player, team, general-game and hit stats) + :param game: The game object (instance of Game). It contains the replay metadata and processed json data. + :param proto_game: The game's protobuf (instance of game_pb2) (refer to the comment in get_protobuf_data() for more info). + :param data_frame: The game's pandas.DataFrame object (refer to comment in get_data_frame() for more info). :param player_map: The dictionary with all player IDs matched to the player objects. - :params: See top of class. """ goal_frames = data_frame.game.goal_number.notnull() @@ -232,7 +225,8 @@ def get_protobuf_data(self) -> game_pb2.Game: """ :return: The protobuf data created by the analysis - USAGE: A Protocol Buffer contains in-game metadata (e.g. events, stats) + USAGE: A Protocol Buffer contains in-game metadata (e.g. events, stats). Treat it as a usual Python object with + fields that match the API. INFO: The Protocol Buffer is a collection of data organized in a format similar to json. All relevant .proto files found at https://github.com/SaltieRL/carball/tree/master/api. From 82707387ce8585b00b7485ffd5ed3ee8cd96560f Mon Sep 17 00:00:00 2001 From: DivvyC Date: Wed, 15 Apr 2020 20:43:20 +0100 Subject: [PATCH 04/16] Removed the if statement for equal team sizes (temp) --- carball/analysis/analysis_manager.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/carball/analysis/analysis_manager.py b/carball/analysis/analysis_manager.py index 3f72eef4..8ddb6956 100644 --- a/carball/analysis/analysis_manager.py +++ b/carball/analysis/analysis_manager.py @@ -266,7 +266,7 @@ def create_name(proto_player_id, name): return create_name - def can_do_full_analysis(self, first_touch_frames, force_equal_teams: bool = False) -> bool: + def can_do_full_analysis(self, first_touch_frames) -> bool: """ Check whether or not the replay satisfies the requirements for a full analysis. This includes checking: @@ -293,10 +293,9 @@ def can_do_full_analysis(self, first_touch_frames, force_equal_teams: bool = Fal logger.warning("Not doing full analysis. No one touched the ball") return False - if force_equal_teams: - if any((team_size != team_sizes[0]) for team_size in team_sizes): - logger.warning("Not doing full analysis. Not all team sizes are equal") - return False + # if any((team_size != team_sizes[0]) for team_size in team_sizes): + # logger.warning("Not doing full analysis. Not all team sizes are equal") + # return False return True From 4b49b6835e28441f606d4bc8c3ee588cbf870109 Mon Sep 17 00:00:00 2001 From: DivvyC Date: Fri, 17 Apr 2020 16:39:03 +0100 Subject: [PATCH 05/16] Cleared up the intitialize() method. Added some docs. --- carball/json_parser/game.py | 94 ++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 22 deletions(-) diff --git a/carball/json_parser/game.py b/carball/json_parser/game.py index 6a5e6ce5..ed908d13 100644 --- a/carball/json_parser/game.py +++ b/carball/json_parser/game.py @@ -50,31 +50,56 @@ def __init__(self): self.dropshot = None def initialize(self, file_path='', loaded_json=None, parse_replay: bool = True, clean_player_names: bool = False): + """ + Initializes the Game object by processing the replay's json file, which finds and copies all relevant data. + + :param file_path: The (string) path to the replay's json file. + :param loaded_json: The replay's json file. + :param parse_replay: Boolean - should the replay be parsed? + :param clean_player_names: Boolean - should the player names be cleared? + """ + self.file_path = file_path + self.load_json(loaded_json) + + self.replay_data = self.replay['content']['body']['frames'] + + self.set_replay_properties() + + if parse_replay: + self.parse_replay(clean_player_names) + + def load_json(self, loaded_json): if loaded_json is None: - with open(file_path, 'r') as f: + with open(self.file_path, 'r') as f: self.replay = json.load(f) else: self.replay = loaded_json - logger.debug('Loaded JSON') - self.replay_data = self.replay['content']['body']['frames'] + logger.debug('Loaded JSON') - # set properties + def set_replay_properties(self): self.properties = self.replay['header']['body']['properties']['value'] + self.replay_id = self.find_actual_value(self.properties['Id']['value']) - if 'MapName' in self.properties: - self.map = self.find_actual_value(self.properties['MapName']['value']) - else: - self.map = 'Unknown' - self.name = self.find_actual_value(self.properties.get('ReplayName', None)) self.match_type = self.find_actual_value(self.properties['MatchType']['value']) self.team_size = self.find_actual_value(self.properties['TeamSize']['value']) + self.set_replay_name() + self.set_replay_date() + self.set_replay_version() + self.set_replay_map() + + self.players: List[Player] = self.create_players() + self.goals: List[Goal] = self.get_goals() + self.primary_player: dict = self.get_primary_player() + + def set_replay_name(self): + self.name = self.find_actual_value(self.properties.get('ReplayName', None)) if self.name is None: logger.warning('Replay name not found') - self.id = self.find_actual_value(self.properties["Id"]['value']) + def set_replay_date(self): date_string = self.properties['Date']['value']['str'] for date_format in DATETIME_FORMATS: try: @@ -85,19 +110,22 @@ def initialize(self, file_path='', loaded_json=None, parse_replay: bool = True, else: logger.error('Cannot parse date: ' + date_string) + def set_replay_version(self): self.replay_version = self.properties.get('ReplayVersion', {}).get('value', {}).get('int', None) logger.info(f"version: {self.replay_version}, date: {self.datetime}") if self.replay_version is None: logger.warning('Replay version not found') - self.players: List[Player] = self.create_players() - self.goals: List[Goal] = self.get_goals() - self.primary_player: dict = self.get_primary_player() + def set_replay_map(self): + if 'MapName' in self.properties: + self.map = self.find_actual_value(self.properties['MapName']['value']) + else: + self.map = 'Unknown' - if parse_replay: - self.all_data = parse_frames(self) - self.parse_all_data(self.all_data, clean_player_names) - logger.info("Finished parsing %s" % self) + def parse_replay(self, clean_player_names): + self.all_data = parse_frames(self) + self.parse_all_data(self.all_data, clean_player_names) + logger.info("Finished parsing %s" % self) def __repr__(self): team_0_name = self.teams[0].name @@ -126,6 +154,12 @@ def get_primary_player(self): return {'name': owner_name, 'id': None} def get_goals(self) -> List[Goal]: + """ + Gets goals from replay_properties and creates respective Goal objects. + + :return: List[Goal] + """ + if "Goals" not in self.properties: return [] @@ -142,11 +176,25 @@ def get_goals(self) -> List[Goal]: @staticmethod def find_actual_value(value_dict: dict) -> dict or int or bool or str: + """ + This method deals with the json file - for every dictionary passed it returns the appropriate value of the + "value" key. + See the json file (beautify it first) to get a better idea of the values being obtained. + + :param value_dict: The dictionary that has needed data/value(s). + :return: Value or another dictionary. + """ + types = ['int', 'boolean', 'string', 'byte', 'str', 'name', ('flagged_int', 'int')] + + # None -> None if value_dict is None: return None + + # Narrows the scope. if 'value' in value_dict: value_dict = value_dict['value'] + for _type in types: if isinstance(_type, str): if _type in value_dict: @@ -224,7 +272,8 @@ def parse_all_data(self, all_data, clean_player_names: bool) -> None: if clean_player_names: cleaned_player_name = re.sub(r'[^\x00-\x7f]', r'', found_player.name).strip() # Support ASCII only if cleaned_player_name != found_player.name: - logger.warning(f"Cleaned player name to ASCII-only. From: {found_player.name} to: {cleaned_player_name}") + logger.warning( + f"Cleaned player name to ASCII-only. From: {found_player.name} to: {cleaned_player_name}") found_player.name = cleaned_player_name # GOAL - add player if not found earlier (ie player just created) @@ -255,13 +304,14 @@ def parse_all_data(self, all_data, clean_player_names: bool) -> None: } # Key created to prevent duplicate demo counts - key = (int(_demo_data["attacker_velocity"]["x"]) + int(_demo_data["attacker_velocity"]["y"]) + int(_demo_data["attacker_velocity"]["z"]) + - int(_demo_data["victim_velocity"]["x"]) + int(_demo_data["victim_velocity"]["y"]) + int(_demo_data["victim_velocity"]["z"])) + key = (int(_demo_data["attacker_velocity"]["x"]) + int(_demo_data["attacker_velocity"]["y"]) + int( + _demo_data["attacker_velocity"]["z"]) + + int(_demo_data["victim_velocity"]["x"]) + int(_demo_data["victim_velocity"]["y"]) + int( + _demo_data["victim_velocity"]["z"])) Game.add_demo_to_map(key, demo, demo_map) self.demos = list(demo_map.values()) - # PARTIES self.parties = all_data['parties'] @@ -313,7 +363,7 @@ def parse_all_data(self, all_data, clean_player_names: bool) -> None: }) damage_frames = set(damage_events.keys()) - self.dropshot['tile_frames'] =\ + self.dropshot['tile_frames'] = \ {k: v for (k, v) in all_data['dropshot']['tile_frames'].items() if k in damage_frames} self.dropshot['ball_events'] = ball_events From f79257c21aece92cbc6d4777270ae12ed2fd24eb Mon Sep 17 00:00:00 2001 From: DivvyC Date: Fri, 17 Apr 2020 20:47:46 +0100 Subject: [PATCH 06/16] Created 'clean' flag - True by default, that indicates whether to clean a replay before analysis. --- carball/analysis/analysis_manager.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/carball/analysis/analysis_manager.py b/carball/analysis/analysis_manager.py index 8ddb6956..6eaf6f5d 100644 --- a/carball/analysis/analysis_manager.py +++ b/carball/analysis/analysis_manager.py @@ -49,12 +49,13 @@ def __init__(self, game: Game): self.should_store_frames = False self.df_bytes = None - def create_analysis(self, calculate_intensive_events: bool = False): + def create_analysis(self, calculate_intensive_events: bool = False, clean: bool = True): """ Sets basic metadata, and decides whether analysis can be performed and then passes required parameters to perform_full_analysis(...); After, stores the DataFrame. :param calculate_intensive_events: Indicates if expensive calculations should run to include additional stats. + :param clean: Indicates if useless/invalid data should be found and removed. """ self.start_time() @@ -69,7 +70,8 @@ def create_analysis(self, calculate_intensive_events: bool = False): if self.can_do_full_analysis(first_touch_frames): self.perform_full_analysis(self.game, self.protobuf_game, player_map, data_frame, kickoff_frames, first_touch_frames, - calculate_intensive_events=calculate_intensive_events) + calculate_intensive_events=calculate_intensive_events, + clean=clean) else: self.log_time("Cannot perform analysis: invalid analysis.") self.protobuf_game.game_metadata.is_invalid_analysis = True @@ -81,7 +83,7 @@ def create_analysis(self, calculate_intensive_events: bool = False): def perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player], data_frame: pd.DataFrame, kickoff_frames: pd.DataFrame, first_touch_frames: pd.Series, - calculate_intensive_events: bool = False): + calculate_intensive_events: bool = False, clean: bool = True): """ Sets some further data and cleans the replay; @@ -94,10 +96,12 @@ def perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_ma :param kickoff_frames: Contains data about the kickoffs. :param first_touch_frames: Contains data for frames where touches can actually occur. :param calculate_intensive_events: Indicates if expensive calculations should run to include additional stats. + :param clean: Indicates if useless/invalid data should be found and removed. """ self.get_game_time(proto_game, data_frame) - clean_replay(game, data_frame, proto_game, player_map) + if clean: + clean_replay(game, data_frame, proto_game, player_map) self.log_time("Creating events...") self.events_creator.create_events(game, proto_game, player_map, data_frame, kickoff_frames, first_touch_frames, calculate_intensive_events=calculate_intensive_events) @@ -191,13 +195,13 @@ def get_kickoff_frames(self, game: Game, proto_game: game_pb2.Game, data_frame: def get_stats(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player], data_frame: pd.DataFrame): """ - For each in-game frame after a goal has happened, calculate in-game stats - (i.e. player, team, general-game and hit stats) + For each in-game frame after a goal has happened, calculate in-game stats, clean_replay: bool = False + (i.e. player, team, gener, clean_replay: bool = Falseal-game and hit stats) :param game: The game object (instance of Game). It contains the replay metadata and processed json data. :param proto_game: The game's protobuf (instance of game_pb2) (refer to the comment in get_protobuf_data() for more info). :param data_frame: The game's pandas.DataFrame object (refer to comment in get_data_frame() for more info). - :param player_map: The dictionary with all player IDs matched to the player objects. + :param player_map: The dictionary with all player IDs matched to the player objects., clean_replay: bool = False """ goal_frames = data_frame.game.goal_number.notnull() From 4ad7fc4cddacfd69694cb18e8ed888ce51d28321 Mon Sep 17 00:00:00 2001 From: DivvyC Date: Sat, 18 Apr 2020 13:27:11 +0100 Subject: [PATCH 07/16] Created docs dir and written auto-gen docs for data frame (used replay added as SHORT_SAMPLE). Flagged analyze_replay_file with clean=True. --- carball/decompile_replays.py | 6 ++- carball/tests/docs/__init__.py | 0 carball/tests/docs/data_frame_docs | 48 ++++++++++++++++++++++ carball/tests/docs/df_methods.txt | 30 ++++++++++++++ carball/tests/docs/df_summary.txt | 11 +++++ carball/tests/replays/SHORT_SAMPLE.replay | Bin 0 -> 61661 bytes 6 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 carball/tests/docs/__init__.py create mode 100644 carball/tests/docs/data_frame_docs create mode 100644 carball/tests/docs/df_methods.txt create mode 100644 carball/tests/docs/df_summary.txt create mode 100644 carball/tests/replays/SHORT_SAMPLE.replay diff --git a/carball/decompile_replays.py b/carball/decompile_replays.py index 7d48f487..91ff7c30 100644 --- a/carball/decompile_replays.py +++ b/carball/decompile_replays.py @@ -27,7 +27,8 @@ def decompile_replay(replay_path, output_path: str = None, overwrite: bool = Tru def analyze_replay_file(replay_path: str, output_path: str = None, overwrite=True, controls: ControlsCreator = None, sanity_check: SanityChecker = None, analysis_per_goal=False, rattletrap_path: str = None, logging_level=logging.NOTSET, - calculate_intensive_events: bool = False): + calculate_intensive_events: bool = False, + clean: bool = True): """ Decompile and analyze a replay file. @@ -41,6 +42,7 @@ def analyze_replay_file(replay_path: str, output_path: str = None, overwrite=Tru :param force_full_analysis: If True full analysis will be performed even if checks say it should not. :param logging_level: Sets the logging level globally across carball :param calculate_intensive_events: Indicates if expensive calculations should run to include additional stats. + :param clean: Indicates if useless/invalid data should be found and removed. :return: AnalysisManager of game with analysis. """ @@ -57,7 +59,7 @@ def analyze_replay_file(replay_path: str, output_path: str = None, overwrite=Tru analysis = PerGoalAnalysis(game) else: analysis = AnalysisManager(game) - analysis.create_analysis(calculate_intensive_events=calculate_intensive_events) + analysis.create_analysis(calculate_intensive_events=calculate_intensive_events, clean=clean) if controls is not None: controls.get_controls(game) diff --git a/carball/tests/docs/__init__.py b/carball/tests/docs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/carball/tests/docs/data_frame_docs b/carball/tests/docs/data_frame_docs new file mode 100644 index 00000000..0b22536f --- /dev/null +++ b/carball/tests/docs/data_frame_docs @@ -0,0 +1,48 @@ +import os + +import carball +from carball.tests.utils import get_replay_path + + +# All relevant files begin with df_ . + +def test_df_docs(): + replay_path = get_replay_path("SHORT_SAMPLE.replay") + + working_dir = os.path.dirname(__file__) + output_dir = os.path.join(working_dir, 'output') + df_summary_path = os.path.join(working_dir, 'df_summary.txt') + df_methods_path = os.path.join(working_dir, 'df.methods.txt') + df_docs_path = os.path.join(output_dir, "DATA_FRAME_INFO.md") + + analysis = carball.analyze_replay_file(replay_path, clean=False) + + data_frame = analysis.get_data_frame() + + df_docs = open(df_docs_path, "w") + df_docs.write("#pandas.DataFrame\n") + + with open(df_summary_path) as summary: + for line in summary: + df_docs.write(line) + + df_docs.write("\n") + + with open(df_methods_path) as methods: + for line in methods: + df_docs.write(line) + + df_docs.write("\n") + + df_docs.write("##Example (list of columns)\n") + df_docs.write("Each subheading is the primary column heading, and each row is the secondary column heading." + "The 'Player' subheading will be unique for each player in the replay (i.e. for a 3v3 game, there " + "will be 6 distinct players)\n") + + current_heading = "" + for c in data_frame.columns: + if current_heading != c[0]: + current_heading = c[0] + df_docs.write("\n####" + c[0]) + + df_docs.write("\n\t" + c[1]) diff --git a/carball/tests/docs/df_methods.txt b/carball/tests/docs/df_methods.txt new file mode 100644 index 00000000..8841f6c4 --- /dev/null +++ b/carball/tests/docs/df_methods.txt @@ -0,0 +1,30 @@ +##Important Methods +To effectively use the DataFrame object, below are listed some of the more valuable methods. Please make sure that you +search the pandas docs for clarifications, *before* asking questions. + +####data_frame.x.y +For example, `data_frame.game.time`. + +This method simply narrows the scope to the selected column(s). The *x* variable refers to the first value of the tuple, +and the *y* variable refers to the second value of the tuple (if only x is provided, all columns belonging to that tuple +will be returned) + +The first (left-most) column still refers to the consecutive in-game frames. + +**Notes:** + * To see all available columns, either refer to the given example, or do data_frame.info(verbose=True) + +####data_frame.loc() + +For example, `data_frame.loc[1, 'Player']` `data_frame.loc[1, ('Player', 'ping')]` + +`data_frame.loc[index, column_key]` where index is the in-game frame number, and column_key is the column that you wish +to access (to access a single, specific column, use the desired tuple in brackets). + +**Notes:** + * The index is an integer, not a string. + * The column keys are always strings. + +####data_frame.at() + +Exactly the same as .loc(), but should preferably be used to access single values, as opposed to many rows/columns. \ No newline at end of file diff --git a/carball/tests/docs/df_summary.txt b/carball/tests/docs/df_summary.txt new file mode 100644 index 00000000..1f7e941a --- /dev/null +++ b/carball/tests/docs/df_summary.txt @@ -0,0 +1,11 @@ +##Summary +The DataFrame is an instance of two-dimensional, size-mutable, potentially heterogeneous tabular data. It comes as part +of the pandas package (often imported as *pd*). + +The index column (no header) represents each in-game frame from the replay (i.e. if the game runs in 60fps, there will +be 60 rows in the DataFrame for every second passed in the game). + +Each column is a tuple, and detailed information about all of them can be seen by using `data_frame.info(verbose=True)`, +and the example below perfectly illustrates this structure. + +**For extensive documentation, refer to [the official pandas website](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html).** \ No newline at end of file diff --git a/carball/tests/replays/SHORT_SAMPLE.replay b/carball/tests/replays/SHORT_SAMPLE.replay new file mode 100644 index 0000000000000000000000000000000000000000..7b1a75fb4d42b5baae3d119dac87ce09b7df1dbd GIT binary patch literal 61661 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 PKuY~2zmhM9N1FcyRS?t1 literal 0 HcmV?d00001 From 5f995a8261b748b73e8a75e3460e95692b493a16 Mon Sep 17 00:00:00 2001 From: DivvyC Date: Sat, 18 Apr 2020 13:35:23 +0100 Subject: [PATCH 08/16] Fixed some random errors.. --- carball/analysis/analysis_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/carball/analysis/analysis_manager.py b/carball/analysis/analysis_manager.py index 6eaf6f5d..dbdece32 100644 --- a/carball/analysis/analysis_manager.py +++ b/carball/analysis/analysis_manager.py @@ -195,13 +195,13 @@ def get_kickoff_frames(self, game: Game, proto_game: game_pb2.Game, data_frame: def get_stats(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player], data_frame: pd.DataFrame): """ - For each in-game frame after a goal has happened, calculate in-game stats, clean_replay: bool = False - (i.e. player, team, gener, clean_replay: bool = Falseal-game and hit stats) + For each in-game frame after a goal has happened, calculate in-game stats. + (i.e. player, team, general-game and hit stats) :param game: The game object (instance of Game). It contains the replay metadata and processed json data. :param proto_game: The game's protobuf (instance of game_pb2) (refer to the comment in get_protobuf_data() for more info). :param data_frame: The game's pandas.DataFrame object (refer to comment in get_data_frame() for more info). - :param player_map: The dictionary with all player IDs matched to the player objects., clean_replay: bool = False + :param player_map: The dictionary with all player IDs matched to the player objects. """ goal_frames = data_frame.game.goal_number.notnull() From 64cc4522848af1560344bcd855278aeda8e9fc2e Mon Sep 17 00:00:00 2001 From: DivvyC Date: Sat, 18 Apr 2020 22:12:36 +0100 Subject: [PATCH 09/16] File formats changed (.txt -> .md) --- carball/tests/docs/data_frame_docs | 4 ++-- carball/tests/docs/{df_methods.txt => df_methods.md} | 0 carball/tests/docs/{df_summary.txt => df_summary.md} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename carball/tests/docs/{df_methods.txt => df_methods.md} (100%) rename carball/tests/docs/{df_summary.txt => df_summary.md} (100%) diff --git a/carball/tests/docs/data_frame_docs b/carball/tests/docs/data_frame_docs index 0b22536f..cdbbf734 100644 --- a/carball/tests/docs/data_frame_docs +++ b/carball/tests/docs/data_frame_docs @@ -11,8 +11,8 @@ def test_df_docs(): working_dir = os.path.dirname(__file__) output_dir = os.path.join(working_dir, 'output') - df_summary_path = os.path.join(working_dir, 'df_summary.txt') - df_methods_path = os.path.join(working_dir, 'df.methods.txt') + df_summary_path = os.path.join(working_dir, 'df_summary.md') + df_methods_path = os.path.join(working_dir, 'df.methods.md') df_docs_path = os.path.join(output_dir, "DATA_FRAME_INFO.md") analysis = carball.analyze_replay_file(replay_path, clean=False) diff --git a/carball/tests/docs/df_methods.txt b/carball/tests/docs/df_methods.md similarity index 100% rename from carball/tests/docs/df_methods.txt rename to carball/tests/docs/df_methods.md diff --git a/carball/tests/docs/df_summary.txt b/carball/tests/docs/df_summary.md similarity index 100% rename from carball/tests/docs/df_summary.txt rename to carball/tests/docs/df_summary.md From 2d9ceecc3dc7d2c639ce5ce6e36e0d5a8865f08c Mon Sep 17 00:00:00 2001 From: dtracers Date: Sat, 18 Apr 2020 22:38:23 -0700 Subject: [PATCH 10/16] Made some methods "private" These methods should not be visible to the external user. --- README.md | 3 + carball/analysis/analysis_manager.py | 168 ++++++++++++++------------- carball/extras/per_goal_analysis.py | 4 +- 3 files changed, 95 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 4a693211..4f335b7a 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ with gzip.open(os.path.join('output.gzip'), 'wb') as fo: # return the proto object in python proto_object = analysis_manager.get_protobuf_data() +# return the proto object as a json object +json_oject = analysis_manager.get_json_data() + # return the pandas data frame in python dataframe = analysis_manager.get_data_frame() ``` diff --git a/carball/analysis/analysis_manager.py b/carball/analysis/analysis_manager.py index dbdece32..de73a79d 100644 --- a/carball/analysis/analysis_manager.py +++ b/carball/analysis/analysis_manager.py @@ -43,7 +43,7 @@ def __init__(self, game: Game): self.game = game self.protobuf_game = game_pb2.Game() self.protobuf_game.version = PROTOBUF_VERSION - self.id_creator = self.create_player_id_function(game) + self.id_creator = self._create_player_id_function(game) self.stats_manager = StatsManager() self.events_creator = EventsCreator(self.id_creator) self.should_store_frames = False @@ -58,32 +58,89 @@ def create_analysis(self, calculate_intensive_events: bool = False, clean: bool :param clean: Indicates if useless/invalid data should be found and removed. """ - self.start_time() - player_map = self.get_game_metadata(self.game, self.protobuf_game) - self.log_time("Getting in-game frame-by-frame data...") - data_frame = self.get_data_frames(self.game) - self.log_time("Getting important frames (kickoff, first-touch)...") - kickoff_frames, first_touch_frames = self.get_kickoff_frames(self.game, self.protobuf_game, data_frame) - self.log_time("Setting game kickoff frames...") + self._start_time() + player_map = self._get_game_metadata(self.game, self.protobuf_game) + self._log_time("Getting in-game frame-by-frame data...") + data_frame = self._initialize_data_frame(self.game) + self._log_time("Getting important frames (kickoff, first-touch)...") + kickoff_frames, first_touch_frames = self._get_kickoff_frames(self.game, self.protobuf_game, data_frame) + self._log_time("Setting game kickoff frames...") self.game.kickoff_frames = kickoff_frames - if self.can_do_full_analysis(first_touch_frames): - self.perform_full_analysis(self.game, self.protobuf_game, player_map, - data_frame, kickoff_frames, first_touch_frames, - calculate_intensive_events=calculate_intensive_events, - clean=clean) + if self._can_do_full_analysis(first_touch_frames): + self._perform_full_analysis(self.game, self.protobuf_game, player_map, + data_frame, kickoff_frames, first_touch_frames, + calculate_intensive_events=calculate_intensive_events, + clean=clean) else: - self.log_time("Cannot perform analysis: invalid analysis.") + self._log_time("Cannot perform analysis: invalid analysis.") self.protobuf_game.game_metadata.is_invalid_analysis = True # log before we add the dataframes # logger.debug(self.protobuf_game) - self.store_frames(data_frame) + self._store_frames(data_frame) - def perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player], - data_frame: pd.DataFrame, kickoff_frames: pd.DataFrame, first_touch_frames: pd.Series, - calculate_intensive_events: bool = False, clean: bool = True): + def write_json_out_to_file(self, file): + printer = _Printer() + js = printer._MessageToJsonObject(self.protobuf_game) + json.dump(js, file, indent=2, cls=CarballJsonEncoder) + + def write_proto_out_to_file(self, file): + ProtobufManager.write_proto_out_to_file(file, self.protobuf_game) + + def write_pandas_out_to_file(self, file): + if self.df_bytes is not None: + file.write(self.df_bytes) + elif not self.should_store_frames: + logger.warning("pd DataFrames are not being stored anywhere") + + def get_protobuf_data(self) -> game_pb2.Game: + """ + :return: The protobuf data created by the analysis + + USAGE: A Protocol Buffer contains in-game metadata (e.g. events, stats). Treat it as a usual Python object with + fields that match the API. + + INFO: The Protocol Buffer is a collection of data organized in a format similar to json. All relevant .proto + files found at https://github.com/SaltieRL/carball/tree/master/api. + + Google's developer guide to protocol buffers may be found at https://developers.google.com/protocol-buffers/docs/overview + """ + return self.protobuf_game + + def get_json_data(self): + """ + :return: The protobuf data created by the analysis as a json object. + + see get_protobuf_data for more details. + The json fields are defined by https://github.com/SaltieRL/carball/tree/master/api + """ + printer = _Printer() + js = printer._MessageToJsonObject(self.protobuf_game) + return js + + def get_data_frame(self) -> pd.DataFrame: + """ + :return: The pandas.DataFrame object. + + USAGE: A DataFrame contains in-game frame-by-frame data. + + INFO: The DataFrame is a collection of data organized in a format similar to csv. The 'index' column of the + DataFrame is the consecutive in-game frames, and all other column headings (150+) are tuples in the following + format: + (Object, Data), where the Object is either a player, the ball or the game. + + All column information (and keys) may be seen by calling data_frame.info(verbose=True) + + All further documentation about the DataFrame can be found at https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html + """ + return self.data_frame + + + def _perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player], + data_frame: pd.DataFrame, kickoff_frames: pd.DataFrame, first_touch_frames: pd.Series, + calculate_intensive_events: bool = False, clean: bool = True): """ Sets some further data and cleans the replay; @@ -99,16 +156,16 @@ def perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_ma :param clean: Indicates if useless/invalid data should be found and removed. """ - self.get_game_time(proto_game, data_frame) + self._get_game_time(proto_game, data_frame) if clean: clean_replay(game, data_frame, proto_game, player_map) - self.log_time("Creating events...") + self._log_time("Creating events...") self.events_creator.create_events(game, proto_game, player_map, data_frame, kickoff_frames, first_touch_frames, calculate_intensive_events=calculate_intensive_events) - self.log_time("Getting stats...") - self.get_stats(game, proto_game, player_map, data_frame) + self._log_time("Getting stats...") + self._get_stats(game, proto_game, player_map, data_frame) - def get_game_metadata(self, game: Game, proto_game: game_pb2.Game) -> Dict[str, Player]: + def _get_game_metadata(self, game: Game, proto_game: game_pb2.Game) -> Dict[str, Player]: """ Processes protobuf data and sets the respective object fields to correct values. Maps the player's specific online ID (steam unique ID) to the player object. @@ -137,7 +194,7 @@ def get_game_metadata(self, game: Game, proto_game: game_pb2.Game) -> Dict[str, return player_map - def get_game_time(self, protobuf_game: game_pb2.Game, data_frame: pd.DataFrame): + def _get_game_time(self, protobuf_game: game_pb2.Game, data_frame: pd.DataFrame): """ Calculates the game length (total time the game lasted) and sets it to the relevant metadata length field. Calculates the total time a player has spent in the game and sets it to the relevant player field. @@ -157,7 +214,7 @@ def get_game_time(self, protobuf_game: game_pb2.Game, data_frame: pd.DataFrame): logger.info("Set each player's in-game times.") - def get_kickoff_frames(self, game: Game, proto_game: game_pb2.Game, data_frame: pd.DataFrame): + def _get_kickoff_frames(self, game: Game, proto_game: game_pb2.Game, data_frame: pd.DataFrame): """ Firstly, fetches kickoff-related data from SaltieGame. Secondly, checks for edge-cases and corrects errors. @@ -192,8 +249,8 @@ def get_kickoff_frames(self, game: Game, proto_game: game_pb2.Game, data_frame: return kickoff_frames, first_touch_frames - def get_stats(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player], - data_frame: pd.DataFrame): + def _get_stats(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player], + data_frame: pd.DataFrame): """ For each in-game frame after a goal has happened, calculate in-game stats. (i.e. player, team, general-game and hit stats) @@ -207,62 +264,17 @@ def get_stats(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, goal_frames = data_frame.game.goal_number.notnull() self.stats_manager.get_stats(game, proto_game, player_map, data_frame[goal_frames]) - def store_frames(self, data_frame: pd.DataFrame): + def _store_frames(self, data_frame: pd.DataFrame): self.data_frame = data_frame self.df_bytes = PandasManager.safe_write_pandas_to_memory(data_frame) - def write_json_out_to_file(self, file): - printer = _Printer() - js = printer._MessageToJsonObject(self.protobuf_game) - json.dump(js, file, indent=2, cls=CarballJsonEncoder) - - def write_proto_out_to_file(self, file): - ProtobufManager.write_proto_out_to_file(file, self.protobuf_game) - - def write_pandas_out_to_file(self, file): - if self.df_bytes is not None: - file.write(self.df_bytes) - elif not self.should_store_frames: - logger.warning("pd DataFrames are not being stored anywhere") - - def get_protobuf_data(self) -> game_pb2.Game: - """ - :return: The protobuf data created by the analysis - - USAGE: A Protocol Buffer contains in-game metadata (e.g. events, stats). Treat it as a usual Python object with - fields that match the API. - - INFO: The Protocol Buffer is a collection of data organized in a format similar to json. All relevant .proto - files found at https://github.com/SaltieRL/carball/tree/master/api. - - Google's developer guide to protocol buffers may be found at https://developers.google.com/protocol-buffers/docs/overview - """ - return self.protobuf_game - - def get_data_frame(self) -> pd.DataFrame: - """ - :return: The pandas.DataFrame object. - - USAGE: A DataFrame contains in-game frame-by-frame data. - - INFO: The DataFrame is a collection of data organized in a format similar to csv. The 'index' column of the - DataFrame is the consecutive in-game frames, and all other column headings (150+) are tuples in the following - format: - (Object, Data), where the Object is either a player, the ball or the game. - - All column information (and keys) may be seen by calling data_frame.info(verbose=True) - - All further documentation about the DataFrame can be found at https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html - """ - return self.data_frame - - def get_data_frames(self, game: Game): + def _initialize_data_frame(self, game: Game): data_frame = SaltieGame.create_data_df(game) logger.info("Assigned goal_number in .data_frame") return data_frame - def create_player_id_function(self, game: Game) -> Callable: + def _create_player_id_function(self, game: Game) -> Callable: name_map = {player.name: player.online_id for player in game.players} def create_name(proto_player_id, name): @@ -270,7 +282,7 @@ def create_name(proto_player_id, name): return create_name - def can_do_full_analysis(self, first_touch_frames) -> bool: + def _can_do_full_analysis(self, first_touch_frames) -> bool: """ Check whether or not the replay satisfies the requirements for a full analysis. This includes checking: @@ -303,11 +315,11 @@ def can_do_full_analysis(self, first_touch_frames) -> bool: return True - def start_time(self): + def _start_time(self): self.timer = time.time() logger.info("starting timer") - def log_time(self, message=""): + def _log_time(self, message=""): end = time.time() logger.info("Time taken for %s is %s milliseconds", message, (end - self.timer) * 1000) self.timer = end diff --git a/carball/extras/per_goal_analysis.py b/carball/extras/per_goal_analysis.py index ef2023df..77256277 100644 --- a/carball/extras/per_goal_analysis.py +++ b/carball/extras/per_goal_analysis.py @@ -12,7 +12,7 @@ def __init__(self, game: Game): super().__init__(game) self.protobuf_games = [] - def perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_map, data_frame, kickoff_frames): + def _perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_map, data_frame, kickoff_frames): self.protobuf_games = [] # split up frames total_score = proto_game.game_metadata.score.team_0_score + proto_game.game_metadata.score.team_1_score @@ -32,7 +32,7 @@ def perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_ma new_game.players = game.players new_game.teams = game.teams new_game.frames = split_pandas - super().perform_full_analysis(new_game, new_proto, player_map, split_pandas, kickoff_frames) + super()._perform_full_analysis(new_game, new_proto, player_map, split_pandas, kickoff_frames) self.protobuf_games.append(new_proto) def get_protobuf_data(self) -> List[game_pb2.Game]: From c2aa2f258b4bd8929cb48bd89bda1ba17d2d77cf Mon Sep 17 00:00:00 2001 From: dtracers Date: Sat, 18 Apr 2020 22:42:09 -0700 Subject: [PATCH 11/16] renamed file to run python correctly --- carball/tests/docs/{data_frame_docs => data_frame_docs.py} | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) rename carball/tests/docs/{data_frame_docs => data_frame_docs.py} (95%) diff --git a/carball/tests/docs/data_frame_docs b/carball/tests/docs/data_frame_docs.py similarity index 95% rename from carball/tests/docs/data_frame_docs rename to carball/tests/docs/data_frame_docs.py index cdbbf734..da746302 100644 --- a/carball/tests/docs/data_frame_docs +++ b/carball/tests/docs/data_frame_docs.py @@ -6,7 +6,7 @@ # All relevant files begin with df_ . -def test_df_docs(): +def create_df_docs(): replay_path = get_replay_path("SHORT_SAMPLE.replay") working_dir = os.path.dirname(__file__) @@ -46,3 +46,7 @@ def test_df_docs(): df_docs.write("\n####" + c[0]) df_docs.write("\n\t" + c[1]) + + +if __name__ == '__main__': + create_df_docs() From 6f2664e3283d46b9645ad7694c6d228014337d0f Mon Sep 17 00:00:00 2001 From: dtracers Date: Sat, 18 Apr 2020 23:02:40 -0700 Subject: [PATCH 12/16] reverted game.py --- carball/analysis/analysis_manager.py | 1 - carball/json_parser/game.py | 46 ++++++++-------------------- requirements-test.txt | 4 +-- 3 files changed, 14 insertions(+), 37 deletions(-) diff --git a/carball/analysis/analysis_manager.py b/carball/analysis/analysis_manager.py index de73a79d..826ae44f 100644 --- a/carball/analysis/analysis_manager.py +++ b/carball/analysis/analysis_manager.py @@ -137,7 +137,6 @@ def get_data_frame(self) -> pd.DataFrame: """ return self.data_frame - def _perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player], data_frame: pd.DataFrame, kickoff_frames: pd.DataFrame, first_touch_frames: pd.Series, calculate_intensive_events: bool = False, clean: bool = True): diff --git a/carball/json_parser/game.py b/carball/json_parser/game.py index ed908d13..a70beaba 100644 --- a/carball/json_parser/game.py +++ b/carball/json_parser/game.py @@ -60,46 +60,27 @@ def initialize(self, file_path='', loaded_json=None, parse_replay: bool = True, """ self.file_path = file_path - self.load_json(loaded_json) - - self.replay_data = self.replay['content']['body']['frames'] - - self.set_replay_properties() - - if parse_replay: - self.parse_replay(clean_player_names) - - def load_json(self, loaded_json): if loaded_json is None: - with open(self.file_path, 'r') as f: + with open(file_path, 'r') as f: self.replay = json.load(f) else: self.replay = loaded_json logger.debug('Loaded JSON') - def set_replay_properties(self): + self.replay_data = self.replay['content']['body']['frames'] + + # set properties self.properties = self.replay['header']['body']['properties']['value'] self.replay_id = self.find_actual_value(self.properties['Id']['value']) self.match_type = self.find_actual_value(self.properties['MatchType']['value']) self.team_size = self.find_actual_value(self.properties['TeamSize']['value']) - self.set_replay_name() - self.set_replay_date() - self.set_replay_version() - self.set_replay_map() - - self.players: List[Player] = self.create_players() - self.goals: List[Goal] = self.get_goals() - self.primary_player: dict = self.get_primary_player() - - def set_replay_name(self): - self.name = self.find_actual_value(self.properties.get('ReplayName', None)) if self.name is None: logger.warning('Replay name not found') + self.id = self.find_actual_value(self.properties["Id"]['value']) - def set_replay_date(self): date_string = self.properties['Date']['value']['str'] for date_format in DATETIME_FORMATS: try: @@ -110,22 +91,19 @@ def set_replay_date(self): else: logger.error('Cannot parse date: ' + date_string) - def set_replay_version(self): self.replay_version = self.properties.get('ReplayVersion', {}).get('value', {}).get('int', None) logger.info(f"version: {self.replay_version}, date: {self.datetime}") if self.replay_version is None: logger.warning('Replay version not found') - def set_replay_map(self): - if 'MapName' in self.properties: - self.map = self.find_actual_value(self.properties['MapName']['value']) - else: - self.map = 'Unknown' + self.players: List[Player] = self.create_players() + self.goals: List[Goal] = self.get_goals() + self.primary_player: dict = self.get_primary_player() - def parse_replay(self, clean_player_names): - self.all_data = parse_frames(self) - self.parse_all_data(self.all_data, clean_player_names) - logger.info("Finished parsing %s" % self) + if parse_replay: + self.all_data = parse_frames(self) + self.parse_all_data(self.all_data, clean_player_names) + logger.info("Finished parsing %s" % self) def __repr__(self): team_0_name = self.teams[0].name diff --git a/requirements-test.txt b/requirements-test.txt index 41a5a5f3..c1374f7a 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,7 +1,7 @@ twine -pytest>=3.7 +pytest>=5.4.1 requests -pytest-cov>=2.6.1 +pytest-cov>=2.8.1 coverage codecov pep8 From bcd7c85cc74105444609938562f991ba892582ae Mon Sep 17 00:00:00 2001 From: dtracers Date: Sat, 18 Apr 2020 23:09:41 -0700 Subject: [PATCH 13/16] Fixed some minor bugs --- carball/json_parser/game.py | 5 +++++ carball/tests/docs/data_frame_docs.py | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/carball/json_parser/game.py b/carball/json_parser/game.py index a70beaba..431489c3 100644 --- a/carball/json_parser/game.py +++ b/carball/json_parser/game.py @@ -74,6 +74,11 @@ def initialize(self, file_path='', loaded_json=None, parse_replay: bool = True, self.properties = self.replay['header']['body']['properties']['value'] self.replay_id = self.find_actual_value(self.properties['Id']['value']) + if 'MapName' in self.properties: + self.map = self.find_actual_value(self.properties['MapName']['value']) + else: + self.map = 'Unknown' + self.name = self.find_actual_value(self.properties.get('ReplayName', None)) self.match_type = self.find_actual_value(self.properties['MatchType']['value']) self.team_size = self.find_actual_value(self.properties['TeamSize']['value']) diff --git a/carball/tests/docs/data_frame_docs.py b/carball/tests/docs/data_frame_docs.py index da746302..da79a4ee 100644 --- a/carball/tests/docs/data_frame_docs.py +++ b/carball/tests/docs/data_frame_docs.py @@ -11,10 +11,14 @@ def create_df_docs(): working_dir = os.path.dirname(__file__) output_dir = os.path.join(working_dir, 'output') + df_summary_path = os.path.join(working_dir, 'df_summary.md') - df_methods_path = os.path.join(working_dir, 'df.methods.md') + df_methods_path = os.path.join(working_dir, 'df_methods.md') df_docs_path = os.path.join(output_dir, "DATA_FRAME_INFO.md") + if not os.path.exists(output_dir): + os.mkdir(output_dir) + analysis = carball.analyze_replay_file(replay_path, clean=False) data_frame = analysis.get_data_frame() From c090fa36fc5154c60edaef93f93182656d390d4e Mon Sep 17 00:00:00 2001 From: dtracers Date: Sat, 18 Apr 2020 23:13:38 -0700 Subject: [PATCH 14/16] fixed lgtm issue --- carball/extras/per_goal_analysis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/carball/extras/per_goal_analysis.py b/carball/extras/per_goal_analysis.py index 77256277..a67795a4 100644 --- a/carball/extras/per_goal_analysis.py +++ b/carball/extras/per_goal_analysis.py @@ -12,7 +12,9 @@ def __init__(self, game: Game): super().__init__(game) self.protobuf_games = [] - def _perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_map, data_frame, kickoff_frames): + def _perform_full_analysis(self, game: Game, proto_game: game_pb2.Game, player_map, + data_frame, kickoff_frames, first_touch_frames, + calculate_intensive_events: bool = False, clean: bool = True): self.protobuf_games = [] # split up frames total_score = proto_game.game_metadata.score.team_0_score + proto_game.game_metadata.score.team_1_score From bf817ec90ee2bcdcc94023b688e984e0cfd9d057 Mon Sep 17 00:00:00 2001 From: dtracers Date: Sun, 19 Apr 2020 09:06:09 -0700 Subject: [PATCH 15/16] Added a check for file mode in the pandas --- carball/analysis/analysis_manager.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/carball/analysis/analysis_manager.py b/carball/analysis/analysis_manager.py index 826ae44f..789a83de 100644 --- a/carball/analysis/analysis_manager.py +++ b/carball/analysis/analysis_manager.py @@ -7,6 +7,8 @@ import os from google.protobuf.json_format import _Printer +from typing.io import IO + from .utils.json_encoder import CarballJsonEncoder script_path = os.path.abspath(__file__) @@ -82,14 +84,20 @@ def create_analysis(self, calculate_intensive_events: bool = False, clean: bool self._store_frames(data_frame) def write_json_out_to_file(self, file): + if 'b' in file.mode: + raise IOError("Json files can not be binary use open(path,\"w\")") printer = _Printer() js = printer._MessageToJsonObject(self.protobuf_game) json.dump(js, file, indent=2, cls=CarballJsonEncoder) - def write_proto_out_to_file(self, file): + def write_proto_out_to_file(self, file: IO): + if 'b' not in file.mode: + raise IOError("Proto files must be binary use open(path,\"wb\")") ProtobufManager.write_proto_out_to_file(file, self.protobuf_game) def write_pandas_out_to_file(self, file): + if 'b' not in file.mode: + raise IOError("Proto files must be binary use open(path,\"wb\")") if self.df_bytes is not None: file.write(self.df_bytes) elif not self.should_store_frames: From 51ce723b7567b2d4827d52428b0ce459fbea21d1 Mon Sep 17 00:00:00 2001 From: dtracers Date: Sun, 19 Apr 2020 09:13:52 -0700 Subject: [PATCH 16/16] Added unit tests for checking file mode --- carball/tests/export_test.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/carball/tests/export_test.py b/carball/tests/export_test.py index 14fe1707..24078540 100644 --- a/carball/tests/export_test.py +++ b/carball/tests/export_test.py @@ -1,9 +1,12 @@ from tempfile import NamedTemporaryFile + +import pytest + from carball.analysis.analysis_manager import AnalysisManager from carball.tests.utils import run_analysis_test_on_replay, get_raw_replays -class Test_Export(): +class TestExport: def test_json_export(self, replay_cache): def test(analysis: AnalysisManager): @@ -12,9 +15,40 @@ def test(analysis: AnalysisManager): run_analysis_test_on_replay(test, get_raw_replays()["DEFAULT_3_ON_3_AROUND_58_HITS"], cache=replay_cache) + def test_proto_export(self, replay_cache): + def test(analysis: AnalysisManager): + with NamedTemporaryFile(mode='wb') as f: + analysis.write_proto_out_to_file(f) + + run_analysis_test_on_replay(test, get_raw_replays()["DEFAULT_3_ON_3_AROUND_58_HITS"], cache=replay_cache) + def test_unicode_names(self, replay_cache): def test(analysis: AnalysisManager): with NamedTemporaryFile(mode='wb') as f: analysis.write_pandas_out_to_file(f) run_analysis_test_on_replay(test, get_raw_replays()["UNICODE_ERROR"], cache=replay_cache) + + def test_json_export_invalid_type(self, replay_cache): + def test(analysis: AnalysisManager): + with NamedTemporaryFile(mode='wb') as f: + with pytest.raises(IOError): + analysis.write_json_out_to_file(f) + + run_analysis_test_on_replay(test, get_raw_replays()["DEFAULT_3_ON_3_AROUND_58_HITS"], cache=replay_cache) + + def test_proto_export_invalid_type(self, replay_cache): + def test(analysis: AnalysisManager): + with NamedTemporaryFile(mode='w') as f: + with pytest.raises(IOError): + analysis.write_proto_out_to_file(f) + + run_analysis_test_on_replay(test, get_raw_replays()["DEFAULT_3_ON_3_AROUND_58_HITS"], cache=replay_cache) + + def test_unicode_names_invalid_type(self, replay_cache): + def test(analysis: AnalysisManager): + with NamedTemporaryFile(mode='w') as f: + with pytest.raises(IOError): + analysis.write_pandas_out_to_file(f) + + run_analysis_test_on_replay(test, get_raw_replays()["UNICODE_ERROR"], cache=replay_cache)