#!/usr/bin/python3 -tt # Author: Toshio Kuratomi # Copyright: 2024 # License: AGPL-3.0 # # Version: 1.0 """ Calculate the efficiency of various convoys in simutrans. This is a quick hack to calculate the efficiency of transporting (primarily passengers) on various convoys in simutrans standard. The data for this has to be gathered manually (if someone knows how to get this information out of the compiled *.pak files I would probably spend some time in the future making the information gathering automatic. For now, you can get a sample data file used with this script from: https://toshio.fedorapeople.org/simutrans/nyc-finish/simutrans-convoy.yml The data is from about in-game year ~2015. (Vehicle data doesn't change with the year, although the vehicles available will. The speed bonuses do need to be updated periodically as you play since those vary as fsater vehicles become available. You will need three third party libraries to run this script:: python -m pip install pyyaml appeal beautifultable """ # Global todos: # * Rename eff/efficiency to price # * Rename pax/passenger to cargo # * Break the main function into functions for each step (setup, calculation, report) # * Start using twiggy? import bisect import contextvars import os.path import shutil import typing as t import appeal import beautifultable import yaml DATAFILE = "~/bin/simutrans-convoy.yml" context = contextvars.ContextVar("context", default = {}) context.get()["app"] = appeal.Appeal() def prepare_speed_bonus_table(speed_bonuses): """ Reformat the speed_bonus_table into sorted lists so that we can use bisect on them. """ new_speed_bonus_table = {} for vehicle_type, records in speed_bonuses.items(): list_of_cutoffs = [] for speed, value in records.items(): list_of_cutoffs.append((speed, value)) new_speed_bonus_table[vehicle_type] = tuple(sorted(list_of_cutoffs)) return new_speed_bonus_table def find_speed_bonus(vehicle_type, speed): speed_bonus_table = context.get()["speed_bonus"] bonuses = speed_bonus_table[vehicle_type] # Find the speed that is just below or equal to the given speed bonus_idx = bisect.bisect_left(bonuses, speed, key=lambda entry: entry[0]) # bisect.bisect_(left|right) always return the idx for the number higher # than the value. This is because the bisect functions are for # calculating the index to insert a value in the list. Our interest is for # the record in the number lower than the value. if bonus_idx > 0: bonus_idx = bonus_idx -1 return bonuses[bonus_idx][1] def calculate_efficiency_from_pax(vehicle_data, vehicle_type, pax): """ Calculate the amount per passenger per km that this vehicle receives. :arg vehicle_data: The vehicle data record. :arg pax: Number of people in the vehicle """ pax = min(vehicle_data["capacity"], pax) speed_bonus = find_speed_bonus(vehicle_type, vehicle_data["speed"]) return (pax * speed_bonus - vehicle_data["upkeep"]) / pax def calculate_efficiency_from_percent(vehicle_data, vehicle_type, fullness_percent): """ :arg vehicle_data: The vehicle data record. :arg fullness_percent: The percent fullness of the vehicle to calculate at. """ pax = vehicle_data["capacity"] * fullness_percent return calculate_efficiency_from_pax(vehicle_data, vehicle_type, pax) def print_report(convoy_data, custom_percent=None, custom_pax=None, sort_by: t.Optional[str] = None): for vehicle_type, records in convoy_data: print(f"\n=== {vehicle_type} ===\n") if not records: print("No convoy information for this type") continue headers = ["Name", "Capacity", "Speed", "100% full", "75% full", "50% full", "25% full"] custom_percent_header = f"{custom_percent}% full" if custom_percent is not None: headers.append(custom_percent_header) custom_pax_header = f"@{custom_pax} pax" if custom_pax is not None: headers.append(custom_pax_header) # Validate and normalize the sort_by value if sort_by and sort_by not in headers: # Try to match, ignoring case sort_by = sort_by.casefold() for header in (h for h in headers if h.casefold() == sort_by): sort_by = header break else: # sort_by matches special values "custom_percent" or "custom_pax" if "percent" in sort_by: if custom_percent is not None: sort_by = custom_percent_header else: sort_by = None elif "pax" in sort_by: if custom_pax is not None: sort_by = custom_pax_header else: sort_by = None else: try: int(sort_by) except ValueError: # No other ways we can try to understand sort_by sort_by = None else: if custom_pax is not None and custom_pax_header.startswith(f"@{sort_by}"): sort_by = custom_pax_header else: for header in (h for h in headers if h.startswith(f"{sort_by}%")): # sort_by is a number which matches one of the percentages sort_by = header break else: # We've run out of things to try sort_by = None if not sort_by: sort_by = headers[0] table = beautifultable.BeautifulTable(maxwidth=shutil.get_terminal_size().columns, default_alignment=beautifultable.ALIGN_LEFT, sign=beautifultable.SM_SPACE, precision=5) table.set_style(beautifultable.STYLE_RST) table.columns.header = headers table.columns.alignment["Capacity"] = beautifultable.ALIGN_RIGHT table.columns.alignment["Speed"] = beautifultable.ALIGN_RIGHT for vehicle in records: if "modifier" in vehicle and vehicle["modifier"]: name = f'{vehicle["name"]} - {vehicle["modifier"]}' else: name = vehicle["name"] row = [ name, vehicle["capacity"], vehicle["speed"], vehicle["eff100"], vehicle["eff75"], vehicle["eff50"], vehicle["eff25"], ] if "eff_custom" in vehicle: row.append(vehicle["eff_custom"]) if "eff_pax" in vehicle: row.append(vehicle["eff_pax"]) table.rows.append(row) table.rows.sort(sort_by) print(table) def limit_truck_speeds(convoy_data, speed_limit): for vehicle_type, vehicle_records in convoy_data.items(): if not vehicle_records: continue if vehicle_type.startswith("truck"): for vehicle in vehicle_records: vehicle["speed"] = min(vehicle["speed"], speed_limit) @context.get()["app"].global_command() def report_revenue_per_km_per_person(*, filename=DATAFILE, categories=None, custom_pax: t.Annotated[t.Optional[int], int] = None, custom_percent: t.Annotated[t.Optional[int], int] = None, sort_by="Name", truck_speed_limit: t.Annotated[t.Optional[int], int] = None, ): with open(os.path.expanduser(filename), encoding='utf-8') as f: convoy_data = f.read() convoy_data = yaml.safe_load(convoy_data) speed_bonus = convoy_data.pop("speed_bonus") context.get()["speed_bonus"] = speed_bonus = prepare_speed_bonus_table(speed_bonus) if truck_speed_limit is not None: limit_truck_speeds(convoy_data, truck_speed_limit) all_vehicle_types = frozenset(speed_bonus.keys()) if broken_categories := frozenset(convoy_data).difference(all_vehicle_types): print(f"Warning, the following vehicle types had no bonus information and will be skipped: {', '.join(broken_categories)}") if not categories: categories = all_vehicle_types else: categories = frozenset(c.strip() for c in categories.split(',')) if broken_categories := categories.difference(all_vehicle_types): print(f"Warning, the following categories from the command line are not valid: {', '.join(broken_categories)}") for vehicle_type in list(convoy_data): if vehicle_type not in categories: del convoy_data[vehicle_type] for vehicle_type, vehicle_records in convoy_data.items(): if not vehicle_records: continue for vehicle_data in vehicle_records: pax_efficiency = { "eff100": calculate_efficiency_from_percent(vehicle_data, vehicle_type, 1.0), "eff75": calculate_efficiency_from_percent(vehicle_data, vehicle_type, .75), "eff50": calculate_efficiency_from_percent(vehicle_data, vehicle_type, .5), "eff25": calculate_efficiency_from_percent(vehicle_data, vehicle_type, .25), } if custom_percent is not None: pax_efficiency["eff_custom"] = calculate_efficiency_from_percent(vehicle_data, vehicle_type, custom_percent / 100) if custom_pax is not None: pax_efficiency["eff_pax"] = calculate_efficiency_from_pax(vehicle_data, vehicle_type, custom_pax) vehicle_data.update(pax_efficiency) print_report(convoy_data.items(), custom_percent=custom_percent, custom_pax=custom_pax, sort_by=sort_by) if __name__ == "__main__": context.get()["app"].main()