diff --git a/data/examples/configs/extensive.cfg b/data/examples/configs/extensive.cfg index 5d95a2a..ebae3bf 100644 --- a/data/examples/configs/extensive.cfg +++ b/data/examples/configs/extensive.cfg @@ -56,6 +56,8 @@ skip_inconsistent_rotations = false show_plots = false # generate special plots in report mode (default: false) extended_output_plots = false +# set language of output plots (en / de, default: en) +plot_language = en # Rotation filter variable, options: # "include": include only the rotations from file 'rotation_filter' # "exclude": exclude the rotations from file 'rotation_filter' from the schedule diff --git a/simba/report.py b/simba/report.py index 0fa64f8..435d7d3 100644 --- a/simba/report.py +++ b/simba/report.py @@ -15,6 +15,7 @@ from spice_ev.util import sanitize, datetime_within_time_window from simba import util +from simba.translate import translate as _ DPI = 300 @@ -199,14 +200,14 @@ def generate_plots(schedule, scenario, args): extended_plots_path = args.results_directory.joinpath("extended_plots") extended_plots_path.mkdir(parents=True, exist_ok=True) - plot_blocks_dense(schedule, extended_plots_path) - plot_vehicle_services(schedule, extended_plots_path) + plot_blocks_dense(schedule, extended_plots_path, args) + plot_vehicle_services(schedule, extended_plots_path, args) plot_consumption_per_rotation_distribution(extended_plots_path, schedule) plot_distance_per_rotation_distribution(extended_plots_path, schedule) - plot_charge_type_distribution(extended_plots_path, scenario, schedule) + plot_charge_type_distribution(extended_plots_path, scenario, schedule, args) plot_gc_power_timeseries(extended_plots_path, scenario, schedule, args) - plot_active_rotations(extended_plots_path, scenario, schedule) + plot_active_rotations(extended_plots_path, scenario, schedule, args) # revert logging override logging.disable(logging.NOTSET) @@ -469,15 +470,16 @@ def plot_distance_per_rotation_distribution(extended_plots_path, schedule): ax.bar(labels, bins[v_type], width=0.9, label=v_type, bottom=bar_bottom) for i in range(len(labels)): bar_bottom[i] += bins[v_type][i] - ax.set_xlabel('Distance [km]') + ax.set_xlabel(_("Distance [km]")) plt.xticks(rotation=30) # slant labels for better readability - ax.set_ylabel('Number of rotations') + ax.set_ylabel(_("Number of rotations")) ax.yaxis.get_major_locator().set_params(integer=True) ax.yaxis.grid(True) - ax.set_title('Distribution of rotation length per vehicle type') + ax.set_title(_("Distribution of rotation length per vehicle type")) ax.legend() plt.tight_layout() plt.savefig(extended_plots_path / "distribution_distance.png", dpi=DPI) + plt.savefig(extended_plots_path / "distribution_distance.pdf") plt.close() @@ -499,19 +501,20 @@ def plot_consumption_per_rotation_distribution(extended_plots_path, schedule): ax.bar(labels, bins[v_type], width=0.9, label=v_type, bottom=bar_bottom) for i in range(len(labels)): bar_bottom[i] += bins[v_type][i] - ax.set_xlabel('Energy consumption [kWh]') + ax.set_xlabel(_("Energy consumption [kWh]")) plt.xticks(rotation=30) - ax.set_ylabel('Number of rotations') + ax.set_ylabel(_("Number of rotations")) ax.yaxis.get_major_locator().set_params(integer=True) ax.yaxis.grid(True) - ax.set_title('Distribution of energy consumption of rotations per vehicle type') + ax.set_title(_("Distribution of energy consumption of rotations per vehicle type")) ax.legend() plt.tight_layout() - plt.savefig(extended_plots_path / "distribution_consumption", dpi=DPI) + plt.savefig(extended_plots_path / "distribution_consumption.png", dpi=DPI) + plt.savefig(extended_plots_path / "distribution_consumption.pdf") plt.close() -def plot_charge_type_distribution(extended_plots_path, scenario, schedule): +def plot_charge_type_distribution(extended_plots_path, scenario, schedule, args): """Plots the number of rotations of each charging type in a bar chart. :param extended_plots_path: directory to save plot to @@ -520,14 +523,16 @@ def plot_charge_type_distribution(extended_plots_path, scenario, schedule): :type scenario: spice_ev.Scenario :param schedule: Driving schedule for the simulation. schedule.rotations are used :type schedule: simba.schedule.Schedule + :param args: Configuration arguments + :type args: argparse.Namespace """ # count charging types (also with regard to negative rotations) - charging_types = {'oppb': 0, 'oppb_neg': 0, 'depb': 0, 'depb_neg': 0} + charging_types = {"oppb": 0, "oppb_neg": 0, "depb": 0, "depb_neg": 0} negative_rotations = schedule.get_negative_rotations(scenario) for rot in schedule.rotations: ct = schedule.rotations[rot].charging_type if rot in negative_rotations: - ct += '_neg' + ct += "_neg" try: charging_types[ct] += 1 except KeyError: @@ -536,33 +541,44 @@ def plot_charge_type_distribution(extended_plots_path, scenario, schedule): # plot fig, ax = plt.subplots() bars1 = ax.bar( - ["Opportunity", "Depot"], + [_("Opportunity"), _("Depot")], [charging_types["oppb"], charging_types["depb"]], ) bars2 = ax.bar( - ["Opportunity", "Depot"], + [_("Opportunity"), _("Depot")], [charging_types["oppb_neg"], charging_types["depb_neg"]], bottom=[charging_types["oppb"], charging_types["depb"]], ) # create labels with counts # create empty labels for empty bins - labels = [ - [ - f"{ct}{suffix}: {charging_types[f'{ct}{suffix}']}" - if charging_types[f'{ct}{suffix}'] > 0 else "" - for ct in ['oppb', 'depb'] - ] for suffix in ['', '_neg'] - ] - ax.bar_label(bars1, labels=labels[0], label_type='center') # oppb, depb - ax.bar_label(bars2, labels=labels[1], label_type='center') # oppb_neg, depb_neg + labels = list() + for suffix in ["", "_neg"]: + labels2 = list() + for ct in ["oppb", "depb"]: + number = charging_types[f"{ct}{suffix}"] + if number > 0: + if args.plot_language == "de": + # german translation: only numbers + label = number + else: + label = f"{ct}{suffix}: {number}" + else: + # number is zero: don't show label + label = "" + labels2.append(label) + labels.append(labels2) - ax.set_xlabel("Charging type") - ax.set_ylabel("Number of rotations") + ax.bar_label(bars1, labels=labels[0], label_type="center") # oppb, depb + ax.bar_label(bars2, labels=labels[1], label_type="center") # oppb_neg, depb_neg + + ax.set_xlabel(_("Charging type")) + ax.set_ylabel(_("Number of rotations")) ax.yaxis.grid(True) ax.yaxis.get_major_locator().set_params(integer=True) - ax.legend(["successful rotations", "negative rotations"]) - ax.set_title("Feasibility of rotations per charging type") - plt.savefig(extended_plots_path / "charge_types", dpi=DPI) + ax.legend([_("successful rotations"), _("negative rotations")]) + ax.set_title(_("Feasibility of rotations per charging type")) + plt.savefig(extended_plots_path / "charge_types.png", dpi=DPI) + plt.savefig(extended_plots_path / "charge_types.pdf") plt.close() @@ -579,8 +595,8 @@ def plot_gc_power_timeseries(extended_plots_path, scenario, schedule, args): :type args: argparse.Namespace """ for gcID, gc in scenario.components.grid_connectors.items(): - fig, ax = plt.subplots() - # fig, ax = plt.subplots(figsize=(9, 4.8)) # For legends outside plot + # fig, ax = plt.subplots() + fig, ax = plt.subplots(figsize=(9, 4.8)) # For legends outside plot agg_ts = aggregate_timeseries(scenario, gcID) headers = [ @@ -617,29 +633,29 @@ def plot_gc_power_timeseries(extended_plots_path, scenario, schedule, args): has_prices = True twin_price = ax.twinx() # get next color from color cycle (just plotting would start with first color) - next_color = plt.rcParams['axes.prop_cycle'].by_key()["color"][header_index] + next_color = plt.rcParams["axes.prop_cycle"].by_key()["color"][header_index] twin_price.plot( - time_values, header_values, label=header, c=next_color, linestyle="dashdot") + time_values, header_values, label=_(header), c=next_color, linestyle="dashdot") twin_price.yaxis.label.set_color(next_color) - twin_price.tick_params(axis='y', colors=next_color) - twin_price.set_ylabel("price [ct/kWh]") + twin_price.tick_params(axis="y", colors=next_color) + twin_price.set_ylabel(_("price [ct/kWh]")) # add dummy values to primary axis for legend - ax.plot([], [], next_color, linestyle="dashdot", label=header) + ax.plot([], [], next_color, linestyle="dashdot", label=_(header)) elif header == "bat. stored energy [kWh]": has_battery_column = True twin_bat = ax.twinx() # get next color from color cycle (just plotting would start with first color) - next_color = plt.rcParams['axes.prop_cycle'].by_key()["color"][header_index] + next_color = plt.rcParams["axes.prop_cycle"].by_key()["color"][header_index] twin_bat.plot( - time_values, header_values, label=header, c=next_color, linestyle="dashdot") + time_values, header_values, label=_(header), c=next_color, linestyle="dashdot") twin_bat.yaxis.label.set_color(next_color) - twin_bat.tick_params(axis='y', colors=next_color) - twin_bat.set_ylabel("stored battery energy [kWh]") + twin_bat.tick_params(axis="y", colors=next_color) + twin_bat.set_ylabel(_("stored battery energy [kWh]")) # add dummy values to primary axis for legend - ax.plot([], [], next_color, linestyle="dashdot", label=header) + ax.plot([], [], next_color, linestyle="dashdot", label=_(header)) else: # normal plot (no price or battery) - ax.plot(time_values, header_values, label=header) + ax.plot(time_values, header_values, label=_(header)) if plot_price and not has_prices: logging.error(f"Plot GC power: {gcID} simulated with balanced_market, but has no price") @@ -690,11 +706,11 @@ def plot_gc_power_timeseries(extended_plots_path, scenario, schedule, args): # window value changed or end of scenario: plot new interval window = time_windows[start_idx] if window is not None: - color = 'red' if window else 'lightgreen' - label = 'Inside window' if window else 'Outside window' + color = "red" if window else "lightgreen" + label = _("Inside window") if window else _("Outside window") if label_shown[window]: # labels starting with underscores are ignored - label = '_' + label + label = "_" + label else: # show label once, then set flag label_shown[window] = True @@ -704,8 +720,8 @@ def plot_gc_power_timeseries(extended_plots_path, scenario, schedule, args): label=label, facecolor=color, alpha=0.2) start_idx = i - ax.legend() - # ax.legend(loc='center left', bbox_to_anchor=(1.2, 0.5)) # legend outside of plot + # ax.legend() + ax.legend(loc='center left', bbox_to_anchor=(1.2, 0.5)) # legend outside of plot # legend might be behind twin plots, so adjust z-order # since this affects visibility, hide original axis frame if has_battery_column: @@ -718,21 +734,24 @@ def plot_gc_power_timeseries(extended_plots_path, scenario, schedule, args): twin_price.set_frame_on(True) # plt.xticks(rotation=30) - ax.set_ylabel("Power [kW]") - ax.set_title(f"Power: {gcID}") - ax.grid(color='gray', linestyle='-') + ax.set_ylabel(_("Power [kW]")) + ax.set_title(_("Power") + f": {gcID}") + ax.grid(color="gray", linestyle="-") # xaxis are datetime strings ax.set_xlim(time_values[0], time_values[-1]) - ax.tick_params(axis='x', rotation=30) + if args.plot_language == "de": + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m.%Y")) + ax.tick_params(axis="x", rotation=30) plt.tight_layout() plt.savefig(extended_plots_path / f"{sanitize(gcID)}_power_overview.png", dpi=DPI) + plt.savefig(extended_plots_path / f"{sanitize(gcID)}_power_overview.pdf") plt.close(fig) def ColorGenerator(vehicle_types): # generates color according to vehicle_type and charging type of rotation - colors = util.cycling_generator(plt.rcParams['axes.prop_cycle'].by_key()["color"]) + colors = util.cycling_generator(plt.rcParams["axes.prop_cycle"].by_key()["color"]) color_per_vt = {vt: next(colors) for vt in vehicle_types} color = None while True: @@ -753,13 +772,15 @@ def ColorGenerator(vehicle_types): color = matplotlib.colors.hsv_to_rgb(hsv) -def plot_vehicle_services(schedule, output_path): +def plot_vehicle_services(schedule, output_path, args): """Plots the rotations serviced by the same vehicle :param schedule: Provides the schedule data. :type schedule: simba.schedule.Schedule :param output_path: Path to the output folder :type output_path: pathlib.Path + :param args: Configuration arguments + :type args: argparse.Namespace """ # find depots for every rotation @@ -787,9 +808,11 @@ def VehicleIdRowGenerator(): sorted_rotations = list( sorted(rotations, key=lambda x: (x.vehicle_type, x.charging_type, x.departure_time))) fig, ax = create_plot_blocks(sorted_rotations, color_generator, row_generator) - ax.set_ylabel("Vehicle ID") + ax.set_ylabel(_("Vehicle ID")) # Vehicle ids need small fontsize to fit - ax.tick_params(axis='y', labelsize=6) + ax.tick_params(axis="y", labelsize=6) + if args.plot_language == "de": + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m.%Y %H:%M")) # add legend handles = [] vts = {f"{rotation.vehicle_type}_{rotation.charging_type}": rotation @@ -797,22 +820,24 @@ def VehicleIdRowGenerator(): for key, rot in vts.items(): handles.append(Patch(color=color_generator.send(rot), label=key)) # Position legend at the top outside of the plot - ax.legend(handles=handles, loc='lower center', bbox_to_anchor=(0.5, 1), + ax.legend(handles=handles, loc="lower center", bbox_to_anchor=(0.5, 1), ncol=len(handles)//2+1, prop={"size": 7}) fig.tight_layout() # PDF so Block names stay readable - fig.savefig(output_path_folder / f"{sanitize(depot)}_vehicle_services.pdf") fig.savefig(output_path_folder / f"{sanitize(depot)}_vehicle_services.png", dpi=DPI) + fig.savefig(output_path_folder / f"{sanitize(depot)}_vehicle_services.pdf") plt.close(fig) -def plot_blocks_dense(schedule, output_path): +def plot_blocks_dense(schedule, output_path, args): """Plots the different loads (total, feedin, external) of all grid connectors. :param schedule: Provides the schedule data. :type schedule: simba.schedule.Schedule :param output_path: Path to the output folder :type output_path: pathlib.Path + :param args: Configuration arguments + :type args: argparse.Namespace """ # find depots for every rotation all_rotations = schedule.rotations @@ -848,8 +873,10 @@ def DenseRowGenerator(): sorted(rotations, key=lambda r: (r.departure_time, -(r.departure_time - r.arrival_time)))) fig, ax = create_plot_blocks(sorted_rotations, color_generator, row_generator) - ax.set_ylabel("Block") + ax.set_ylabel(_("Block")) ax.yaxis.get_major_locator().set_params(integer=True) + if args.plot_language == "de": + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m.%Y %H:%M")) # add legend handles = [] vts = {f"{rotation.vehicle_type}_{rotation.charging_type}": rotation @@ -857,13 +884,13 @@ def DenseRowGenerator(): for key, rot in vts.items(): handles.append(Patch(color=color_generator.send(rot), label=key)) # Position legend at the top outside of the plot - ax.legend(handles=handles, loc='lower center', bbox_to_anchor=(0.5, 1), + ax.legend(handles=handles, loc="lower center", bbox_to_anchor=(0.5, 1), ncol=len(handles)//2+1, prop={"size": 7}) fig.tight_layout() # PDF so Block names stay readable - fig.savefig(output_path_folder / f"{sanitize(depot)}_block_distribution.pdf") fig.savefig(output_path_folder / f"{sanitize(depot)}_block_distribution.png", dpi=DPI) + fig.savefig(output_path_folder / f"{sanitize(depot)}_block_distribution.pdf") plt.close(fig) @@ -878,16 +905,16 @@ def create_plot_blocks(sorted_rotations, color_generator, row_generator): width = rotation.arrival_time - rotation.departure_time artist = ax.barh([row_nr], width=[width], height=0.8, left=rotation.departure_time, label=rotation.id, color=color_generator.send(rotation)) - ax.bar_label(artist, labels=[rotation.id], label_type='center', fontsize=2) + ax.bar_label(artist, labels=[rotation.id], label_type="center", fontsize=2) # Large heights create too much margin with default value of margins: Reduce value to 0.01 ax.axes.margins(y=0.01) - ax.grid(axis='x') + ax.grid(axis="x") ax.xaxis.set_major_locator(mdates.AutoDateLocator()) - ax.xaxis.set_major_formatter(mdates.DateFormatter('%D %H:%M')) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%D %H:%M")) ax.set_xlim(min(r.departure_time for r in sorted_rotations) - datetime.timedelta(minutes=30), max(r.arrival_time for r in sorted_rotations) + datetime.timedelta(minutes=30)) - ax.tick_params(axis='x', rotation=30) + ax.tick_params(axis="x", rotation=30) return fig, ax @@ -903,7 +930,7 @@ def count_active_rotations(scenario, schedule): return num_active_rotations -def plot_active_rotations(extended_plots_path, scenario, schedule): +def plot_active_rotations(extended_plots_path, scenario, schedule, args): """Generate a plot with number of active rotations over time. :param extended_plots_path: directory to save plot to @@ -912,6 +939,8 @@ def plot_active_rotations(extended_plots_path, scenario, schedule): :type scenario: spice_ev.Scenario :param schedule: Driving schedule for the simulation. schedule.rotations are used :type schedule: simba.schedule.Schedule + :param args: Configuration arguments + :type args: argparse.Namespace """ ts = [scenario.start_time + scenario.interval * i for i in range(scenario.n_intervals)] num_active_rotations = count_active_rotations(scenario, schedule) @@ -921,11 +950,14 @@ def plot_active_rotations(extended_plots_path, scenario, schedule): ax = plt.gca() ax.xaxis_date() ax.set_xlim(ts[0], ts[-1]) + if args.plot_language == "de": + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m.%Y %H:%M")) plt.xticks(rotation=30) - plt.ylabel("Number of active rotations") + plt.ylabel(_("Number of active rotations")) ax.yaxis.get_major_locator().set_params(integer=True) plt.grid(axis="y") - plt.title("Active Rotations") + plt.title(_("Active Rotations")) plt.tight_layout() - plt.savefig(extended_plots_path / "active_rotations", dpi=DPI) + plt.savefig(extended_plots_path / "active_rotations.png", dpi=DPI) + plt.savefig(extended_plots_path / "active_rotations.pdf") plt.close() diff --git a/simba/translate.py b/simba/translate.py new file mode 100644 index 0000000..00e7ee1 --- /dev/null +++ b/simba/translate.py @@ -0,0 +1,49 @@ +language = None +translation_tables = { + "de": { + "Active Rotations": "Aktive Umläufe", + "bat. stored energy [kWh]": "stat. Speicher Energieinhalt in kWh", + "battery power [kW]": "stat. Speicher Leistung in kW", + "Block": "Umlauf", + "Charging type": "Ladetyp", + "Depot": "Depot", + "Distance [km]": "Distanz in km", + "Distribution of energy consumption of rotations per vehicle type": + "Verteilung des Energiebedarfs über Umläufe und Fahrzeugtypen", + "Distribution of rotation length per vehicle type": + "Verteilung der Umlauflänge je Fahrzeugtyp", + "Energy consumption [kWh]": "Energiebedarf in kWh", + "Feasibility of rotations per charging type": "Machbarkeit der Umläufe je Ladetyp", + "fixed load [kW]": "Feste Last in kW", + "grid supply [kW]": "Netzanschlusspunkt in kW", + "Inside window": "Innerhalb des HLZ", + "local generation [kW]": "lokale Erzeugung in kW", + "negative rotations": "Negative Umläufe", + "Number of active rotations": "Anzahl aktiver Umläufe", + "Number of rotations": "Anzahl Umläufe", + "Opportunity": "Gelegenheit", + "Outside window": "Außerhalb des HLZ", + "Power": "Leistung", + "Power [kW]": "Leistung in kW", + "price [ct/kWh]": "Strompreis in ct/kWh", + "stored battery energy [kWh]": "Batterie Energieinhalt in kWh", + "successful rotations": "Erfolgreiche Umläufe", + "sum CS power [kW]": "Ladestationen in kW", + "Vehicle ID": "Fahrzeug ID", + }, +} + + +def set_language(new_language): + global language + language = new_language + + +def translate(text): + if language in translation_tables: + # language set and known + table = translation_tables[language] + # find text in translation table, otherwise return text unchanged + return table.get(text, text) + # language not set or not known + return text diff --git a/simba/util.py b/simba/util.py index c3fc89e..ef0ec60 100644 --- a/simba/util.py +++ b/simba/util.py @@ -11,6 +11,8 @@ from spice_ev.strategy import STRATEGIES from spice_ev.util import set_options_from_config +from simba.translate import set_language + def get_git_revision_hash() -> str: return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip() @@ -454,6 +456,9 @@ def get_args(): if not isinstance(args.mode, list): args.mode = [args.mode] + # set translation language + set_language(args.plot_language) + # check mandatory arguments if not vars(args).get("load_pickle_path"): mandatory_arguments = ["schedule_path", "electrified_stations_path"] @@ -522,6 +527,8 @@ def get_parser(): help='show plots for users to view in "report" mode') parser.add_argument('--extended-output-plots', action='store_true', help='show extended plots') + parser.add_argument('--plot-language', choices=['en', 'de'], default='en', + help='set extended plot output language') parser.add_argument('--propagate-mode-errors', action='store_true', help='Re-raise errors instead of continuing during simulation modes') parser.add_argument('--create-scenario-file', help='Write scenario.json to file')