diff --git a/.github/workflows/installation-from-remote.yaml b/.github/workflows/installation-from-remote.yaml index 1316d7f7..b7cd3654 100644 --- a/.github/workflows/installation-from-remote.yaml +++ b/.github/workflows/installation-from-remote.yaml @@ -1,13 +1,6 @@ name: Install from repository on: - push: - branches: - - main - pull_request: - branches: - - main - types: [opened, reopened, edited, synchronize] schedule: - cron: '42 23 * * 3' # every Wednesday at 23:42 diff --git a/conflowgen/flow_generator/abstract_truck_for_containers_manager.py b/conflowgen/flow_generator/abstract_truck_for_containers_manager.py index 6eef92fa..19224b6a 100644 --- a/conflowgen/flow_generator/abstract_truck_for_containers_manager.py +++ b/conflowgen/flow_generator/abstract_truck_for_containers_manager.py @@ -67,34 +67,38 @@ def _update_truck_arrival_and_container_dwell_time_distributions( self, hour_of_the_week_fraction_pairs: List[Union[Tuple[int, float], Tuple[int, int]]] ) -> None: - for vehicle in ModeOfTransport: + for vehicle_type in ModeOfTransport: for storage_requirement in StorageRequirement: container_dwell_time_distribution = self._get_container_dwell_time_distribution( - vehicle, storage_requirement + vehicle_type, storage_requirement ) - # only work with full hours - if self.is_reversed: - container_dwell_time_distribution.minimum = int(math.floor( - container_dwell_time_distribution.minimum)) - container_dwell_time_distribution.maximum = int(math.ceil( - container_dwell_time_distribution.maximum)) + # Adjust the minimum and maximum to harmonize with the truck slot units. While rounding, always take a + # conservative approach. The minimum and maximum values should not be exceeded! + container_dwell_time_distribution.minimum = int(math.ceil( + container_dwell_time_distribution.minimum)) + container_dwell_time_distribution.maximum = int(math.floor( + container_dwell_time_distribution.maximum)) + + # When we talk about truck deliveries, they can come earliest at the maximum container dwell time + # and latest at the minimum. When, on the other hand, we talk about truck pickups, they can come + # earliest after container arrival and latest at the maximum dwell time. + # In both cases, less time windows are available. + if not self.is_reversed: + considered_time_window_in_hours = ( + container_dwell_time_distribution.maximum + - container_dwell_time_distribution.minimum + ) else: - container_dwell_time_distribution.minimum = int(math.ceil( - container_dwell_time_distribution.minimum)) - container_dwell_time_distribution.maximum = int(math.floor( - container_dwell_time_distribution.maximum)) - - considered_time_window_in_hours = ( - container_dwell_time_distribution.maximum - - 2 # both the first and last time window are not an option - ) + considered_time_window_in_hours = container_dwell_time_distribution.maximum - self.truck_arrival_distributions[vehicle][storage_requirement] = WeeklyDistribution( + self.logger.info(f"For vehicle type {vehicle_type} and storage requirement {storage_requirement}, " + "the container dwell times need to range from " + f"{container_dwell_time_distribution.minimum} to " + f"{container_dwell_time_distribution.maximum}") + self.truck_arrival_distributions[vehicle_type][storage_requirement] = WeeklyDistribution( hour_fraction_pairs=hour_of_the_week_fraction_pairs, - considered_time_window_in_hours=considered_time_window_in_hours, - minimum_dwell_time_in_hours=container_dwell_time_distribution.minimum, - context=f"{self.__class__.__name__} : {vehicle} : {storage_requirement}" + considered_time_window_in_hours=considered_time_window_in_hours ) def _get_distributions( @@ -124,6 +128,8 @@ def _get_time_window_of_truck_arrival( Number of hours after the earliest possible slot """ time_windows_for_truck_arrival = list(truck_arrival_distribution_slice.keys()) + assert max(time_windows_for_truck_arrival) < container_dwell_time_distribution.maximum + truck_arrival_probabilities = list(truck_arrival_distribution_slice.values()) container_dwell_time_probabilities = container_dwell_time_distribution.get_probabilities( time_windows_for_truck_arrival, reversed_distribution=self.is_reversed @@ -133,10 +139,18 @@ def _get_time_window_of_truck_arrival( container_dwell_time_probabilities ) if sum(total_probabilities) == 0: # bad circumstances, no slot available - raise Exception(f"No slots available! {truck_arrival_probabilities} and {total_probabilities} just do not" - f"match, there is no truck available!") + raise Exception(f"No truck slots available! {truck_arrival_probabilities} and {total_probabilities} just " + "do not match.") selected_time_window = random.choices( population=time_windows_for_truck_arrival, weights=total_probabilities )[0] + if not self.is_reversed: # truck delivery of export container + assert container_dwell_time_distribution.minimum <= selected_time_window + assert selected_time_window < container_dwell_time_distribution.maximum + else: # truck pick-up of import container + assert 0 <= selected_time_window + assert selected_time_window < (container_dwell_time_distribution.maximum + - container_dwell_time_distribution.minimum) + return selected_time_window diff --git a/conflowgen/flow_generator/container_flow_generation_service.py b/conflowgen/flow_generator/container_flow_generation_service.py index c0b7717c..bdc84680 100644 --- a/conflowgen/flow_generator/container_flow_generation_service.py +++ b/conflowgen/flow_generator/container_flow_generation_service.py @@ -89,15 +89,6 @@ def generate(self): self.logger.info("Assign containers that are picked up from the terminal by a vehicle adhering a schedule to " "their specific vehicle instance...") self.large_scheduled_vehicle_for_onward_transportation_manager.choose_departing_vehicle_for_containers() - number_assigned_containers = (self.large_scheduled_vehicle_for_onward_transportation_manager - .number_assigned_containers) - number_not_assignable_containers = (self.large_scheduled_vehicle_for_onward_transportation_manager - .number_not_assignable_containers) - assigned_as_fraction = number_assigned_containers / ( - number_assigned_containers + number_not_assignable_containers - ) - self.logger.info( - f"Containers for which no outgoing vehicle could be found: {assigned_as_fraction:.2%}") self.logger.info("Loading status of vehicles adhering to a schedule:") report = ContainerFlowStatisticsReport(transportation_buffer=self.transportation_buffer) diff --git a/conflowgen/flow_generator/large_scheduled_vehicle_for_onward_transportation_manager.py b/conflowgen/flow_generator/large_scheduled_vehicle_for_onward_transportation_manager.py index dd7fc3a5..f9f73063 100644 --- a/conflowgen/flow_generator/large_scheduled_vehicle_for_onward_transportation_manager.py +++ b/conflowgen/flow_generator/large_scheduled_vehicle_for_onward_transportation_manager.py @@ -5,8 +5,8 @@ import random from typing import Tuple, List, Dict, Type, Sequence -# noinspection PyProtectedMember import numpy as np +# noinspection PyProtectedMember from peewee import fn, JOIN, ModelSelect from ..domain_models.data_types.container_length import ContainerLength @@ -34,8 +34,6 @@ def __init__(self): self.large_scheduled_vehicle_repository = self.schedule_repository.large_scheduled_vehicle_repository self.mode_of_transport_distribution_repository = ModeOfTransportDistributionRepository() self.mode_of_transport_distribution = self.mode_of_transport_distribution_repository.get_distribution() - self.number_assigned_containers = 0 - self.number_not_assignable_containers = 0 self.container_dwell_time_distribution_repository = ContainerDwellTimeDistributionRepository() self.container_dwell_time_distributions: \ @@ -65,8 +63,8 @@ def choose_departing_vehicle_for_containers(self) -> None: This method might be quite time-consuming because it repeatedly checks how many containers are already placed on a vehicle to obey the load restriction (maximum capacity of the vehicle available for the terminal). """ - self.number_assigned_containers = 0 - self.number_not_assignable_containers = 0 + number_assigned_containers = 0 + number_not_assignable_containers = 0 self.large_scheduled_vehicle_repository.reset_cache() @@ -74,7 +72,7 @@ def choose_departing_vehicle_for_containers(self) -> None: # Get all containers in a random order which are picked up by a LargeScheduledVehicle # This way no vehicle has an advantage over another by its earlier arrival (getting better slots etc.) - containers: ModelSelect = Container.select( + selected_containers: ModelSelect = Container.select( ).order_by( fn.Random() ).where( @@ -82,7 +80,7 @@ def choose_departing_vehicle_for_containers(self) -> None: ) # all the joins just exist to speed up the process and avoid triggering too many database calls - preloaded_containers: ModelSelect = containers.join( + preloaded_containers: ModelSelect = selected_containers.join( Truck, join_type=JOIN.LEFT_OUTER, on=(Container.delivered_by_truck == Truck.id) @@ -98,20 +96,22 @@ def choose_departing_vehicle_for_containers(self) -> None: on=(Container.delivered_by_large_scheduled_vehicle == LargeScheduledVehicle.id) ) - assert containers.count() == preloaded_containers.count(), \ + selected_containers_count = selected_containers.count() + assert selected_containers_count == preloaded_containers.count(), \ f"No container should be lost due to the join operations but " \ - f"{containers.count()} != {preloaded_containers.count()}" + f"{selected_containers.count()} != {preloaded_containers.count()}" self.logger.info(f"In total, {len(preloaded_containers)} containers continue their journey on a vehicle that " f"adhere to a schedule, assigning these containers to their respective vehicles...") for i, container in enumerate(preloaded_containers): i += 1 if i % 1000 == 0 and i > 0: - self.logger.info(f"Progress: {i} / {len(containers)} ({100 * i / len(containers):.2f}%) " - f"containers have been assigned to a scheduled vehicle to leave the terminal again.") + self.logger.info( + f"Progress: {i} / {len(selected_containers)} ({100 * i / len(selected_containers):.2f}%) " + f"containers have been assigned to a scheduled vehicle to leave the terminal again." + ) container_arrival = self._get_arrival_time_of_container(container) - minimum_dwell_time_in_hours, maximum_dwell_time_in_hours = self._get_dwell_times(container) # This value has been randomly drawn during container generation for the inbound traffic. @@ -130,23 +130,25 @@ def choose_departing_vehicle_for_containers(self) -> None: # this is the case when there is a vehicle available - let's hope everything else works out as well! vehicle = self._pick_vehicle_for_container(available_vehicles, container) if vehicle is not None: - self.number_assigned_containers += 1 - else: - # Well, there was a vehicle available. However, it was not suitable for our container due to - # some constraint. Maybe the container dwell time was unrealistic and thus not permissible? - # This can happen if the distribution is just really, really close to zero, so it is approximated - # as zero. - self.number_not_assignable_containers += 1 - self._find_alternative_mode_of_transportation( - container, container_arrival, minimum_dwell_time_in_hours, maximum_dwell_time_in_hours - ) - else: - # Maybe no permissible vehicles of the required vehicle type are left, then we need to switch the type - # as to somehow move the container out of the container yard. - self.number_not_assignable_containers += 1 - self._find_alternative_mode_of_transportation( - container, container_arrival, minimum_dwell_time_in_hours, maximum_dwell_time_in_hours - ) + # We are lucky and the vehicle has accepted the container for its outbound journey + number_assigned_containers += 1 + continue + # No vehicle is available, either due to operational constraints or we really ran out of vehicles. + number_not_assignable_containers += 1 + self._find_alternative_mode_of_transportation( + container, container_arrival, minimum_dwell_time_in_hours, maximum_dwell_time_in_hours + ) + + number_containers = number_assigned_containers + number_not_assignable_containers + assert number_containers == selected_containers_count, \ + f"All containers should have been treated but {number_containers} != {selected_containers.count()}" + if number_containers == 0: + self.logger.info("No containers are moved from one vehicle adhering to a schedule to another one.") + else: + assigned_as_fraction = number_assigned_containers / number_containers + self.logger.info("Containers for which no outgoing vehicle adhering to a schedule could be found: " + f"{assigned_as_fraction:.2%}. These will be re-assigned to another vehicle type, " + "such as a truck.") self.logger.info("All containers for which a departing vehicle that moves according to a schedule was " "available have been assigned to one.") diff --git a/conflowgen/flow_generator/truck_for_export_containers_manager.py b/conflowgen/flow_generator/truck_for_export_containers_manager.py index 757c95d2..35f33b1f 100644 --- a/conflowgen/flow_generator/truck_for_export_containers_manager.py +++ b/conflowgen/flow_generator/truck_for_export_containers_manager.py @@ -39,18 +39,30 @@ def _get_container_delivery_time( container: Container, container_departure_time: datetime.datetime ) -> datetime.datetime: + """ + When was the container delivered to the terminal, given its departure time? + + Args: + container: The container in question + container_departure_time: The container's departure time (fixed by a vessel or similar) + + Returns: + The time a truck delivered the container to the terminal (some point before the vessel departs). + """ container_dwell_time_distribution, truck_arrival_distribution = self._get_distributions(container) minimum_dwell_time_in_hours = container_dwell_time_distribution.minimum maximum_dwell_time_in_hours = container_dwell_time_distribution.maximum - earliest_slot = ( - container_departure_time.replace(minute=0, second=0, microsecond=0) # reset to previous full hour - + datetime.timedelta(hours=1) # go 1h in the future because previously we lost some minutes - - datetime.timedelta(hours=maximum_dwell_time_in_hours) # go back x hours + truck_arrival_distribution_slice = truck_arrival_distribution.get_distribution_slice( + start_as_datetime=( + container_departure_time + - datetime.timedelta(hours=maximum_dwell_time_in_hours) + ) ) - - truck_arrival_distribution_slice = truck_arrival_distribution.get_distribution_slice(earliest_slot) + assert max(truck_arrival_distribution_slice) < maximum_dwell_time_in_hours, \ + f"{max(truck_arrival_distribution_slice)} < {maximum_dwell_time_in_hours} was harmed for container " \ + f"{container}." delivery_time_window_start = self._get_time_window_of_truck_arrival( container_dwell_time_distribution, truck_arrival_distribution_slice @@ -63,21 +75,26 @@ def _get_container_delivery_time( # go back to the earliest possible day truck_arrival_time = ( - earliest_slot + # go back to the earliest time window + container_departure_time + - datetime.timedelta(hours=(maximum_dwell_time_in_hours - 1)) + + # add the selected time window identifier + datetime.timedelta(hours=delivery_time_window_start) + + # spread the truck arrivals withing the time window. + datetime.timedelta(hours=random_time_component) - - datetime.timedelta(hours=1) # with the random time component a point close to one hour is added + + # With the random time component, a point probably close to one hour is added which might harm the + # required minimum container dwell time. + - datetime.timedelta(hours=1) ) - dwell_time_in_seconds = (container_departure_time - truck_arrival_time).total_seconds() - if maximum_dwell_time_in_hours / 3600 > maximum_dwell_time_in_hours: - self.logger.debug("Maximum dwell time constraint harmed due to a rounding error, move back 1 hour") - truck_arrival_time -= datetime.timedelta(hours=0.5) - dwell_time_in_seconds = (container_departure_time - truck_arrival_time).total_seconds() + dwell_time_in_hours = (container_departure_time - truck_arrival_time).total_seconds() / 3600 - assert dwell_time_in_seconds > 0, "Dwell time must be positive" - assert minimum_dwell_time_in_hours <= dwell_time_in_seconds / 3600 <= maximum_dwell_time_in_hours, \ - f"{minimum_dwell_time_in_hours} <= {dwell_time_in_seconds / 3600} <= {maximum_dwell_time_in_hours} " \ + assert dwell_time_in_hours > 0, "Dwell time must be positive" + assert minimum_dwell_time_in_hours <= dwell_time_in_hours <= maximum_dwell_time_in_hours, \ + f"{minimum_dwell_time_in_hours} <= {dwell_time_in_hours} <= {maximum_dwell_time_in_hours} " \ f"harmed for container {container}." return truck_arrival_time @@ -94,7 +111,7 @@ def generate_trucks_for_delivering(self) -> None: if i % 1000 == 0 and i > 0: self.logger.info( f"Progress: {i} / {len(containers)} ({100 * i / len(containers):.2f}%) trucks generated " - f"for export containers") + f"to deliver containers to the terminal.") picked_up_with: LargeScheduledVehicle = container.picked_up_by_large_scheduled_vehicle # assume that the vessel arrival time changes are not communicated on time so that the trucks which deliver diff --git a/conflowgen/flow_generator/truck_for_import_containers_manager.py b/conflowgen/flow_generator/truck_for_import_containers_manager.py index d3ab378c..9a7070ef 100644 --- a/conflowgen/flow_generator/truck_for_import_containers_manager.py +++ b/conflowgen/flow_generator/truck_for_import_containers_manager.py @@ -35,6 +35,8 @@ def _get_container_pickup_time( ) -> datetime.datetime: container_dwell_time_distribution, truck_arrival_distribution = self._get_distributions(container) + minimum_dwell_time_in_hours = container_dwell_time_distribution.minimum + maximum_dwell_time_in_hours = container_dwell_time_distribution.maximum earliest_slot = container_arrival_time.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) truck_arrival_distribution_slice = truck_arrival_distribution.get_distribution_slice(earliest_slot) @@ -43,12 +45,24 @@ def _get_container_pickup_time( container_dwell_time_distribution, truck_arrival_distribution_slice ) - random_time_component = random.uniform(0, self.time_window_length_in_hours) + # arrival within the last time slot + random_time_component = random.uniform(0, self.time_window_length_in_hours - (1 / 60)) + assert 0 <= random_time_component < self.time_window_length_in_hours, \ + "The random time component must be shorter than the length of the time slot" + truck_arrival_time = ( earliest_slot + datetime.timedelta(hours=pickup_time_window_start) # these are several days, comparable to time slot + datetime.timedelta(hours=random_time_component) # a small random component for the truck arrival time ) + + dwell_time_in_hours = (truck_arrival_time - container_arrival_time).total_seconds() / 3600 + + assert dwell_time_in_hours > 0, "Dwell time must be positive" + assert minimum_dwell_time_in_hours <= dwell_time_in_hours <= maximum_dwell_time_in_hours, \ + f"{minimum_dwell_time_in_hours} <= {dwell_time_in_hours} <= {maximum_dwell_time_in_hours} " \ + f"harmed for container {container}." + return truck_arrival_time def generate_trucks_for_picking_up(self): @@ -60,7 +74,7 @@ def generate_trucks_for_picking_up(self): i += 1 if i % 1000 == 0 and i > 0: self.logger.info(f"Progress: {i} / {len(containers)} ({100 * i / len(containers):.2f}%) trucks " - f"generated for import containers") + f"generated to pick up containers at the terminal.") delivered_by: LargeScheduledVehicle = container.delivered_by_large_scheduled_vehicle # assume that the vessel arrival time changes are communicated early enough so that the trucks which pick diff --git a/conflowgen/tests/flow_generator/test_truck_for_export_containers_manager.py b/conflowgen/tests/flow_generator/test_truck_for_export_containers_manager.py index e10d0fd0..579e4c19 100644 --- a/conflowgen/tests/flow_generator/test_truck_for_export_containers_manager.py +++ b/conflowgen/tests/flow_generator/test_truck_for_export_containers_manager.py @@ -224,11 +224,7 @@ def test_distributions_match(self): StorageRequirement.standard] dwell_time_distribution = self.container_dwell_time_distributions_from_truck_to[ModeOfTransport.feeder][ StorageRequirement.standard] - self.assertEqual( - truck_arrival_distribution.minimum_dwell_time_in_hours, - dwell_time_distribution.minimum - ) self.assertEqual( truck_arrival_distribution.considered_time_window_in_hours, - int(math.floor(dwell_time_distribution.maximum)) - 1 + int(math.floor(dwell_time_distribution.maximum)) ) diff --git a/conflowgen/tests/flow_generator/test_truck_for_import_containers_manager.py b/conflowgen/tests/flow_generator/test_truck_for_import_containers_manager.py index 2e7e5cb9..9921c0ce 100644 --- a/conflowgen/tests/flow_generator/test_truck_for_import_containers_manager.py +++ b/conflowgen/tests/flow_generator/test_truck_for_import_containers_manager.py @@ -109,13 +109,12 @@ def test_container_dwell_time_and_truck_arrival_distributions_match(self): container_dwell_time_distribution, truck_arrival_distribution = self._get_distribution(container) self.assertEqual(3, int(container_dwell_time_distribution.minimum)) - self.assertEqual(3, int(truck_arrival_distribution.minimum_dwell_time_in_hours)) self.assertEqual(216, container_dwell_time_distribution.maximum) possible_hours_for_truck_arrival = truck_arrival_distribution.considered_time_window_in_hours self.assertEqual( - 216 - 2, + 216 - 3, possible_hours_for_truck_arrival, "The truck might arrive 216h after the arrival of the container, but not within the first three hours. " "Furthermore, the last hour is subtracted because up to 59 minutes are later added again and the maximum " @@ -222,12 +221,8 @@ def test_distributions_match(self): StorageRequirement.standard] dwell_time_distribution = self.container_dwell_time_distributions_from_x_to_truck[ModeOfTransport.feeder][ StorageRequirement.standard] - self.assertEqual( - truck_arrival_distribution.minimum_dwell_time_in_hours, - dwell_time_distribution.minimum - ) self.assertEqual( truck_arrival_distribution.considered_time_window_in_hours, - int(math.floor(dwell_time_distribution.maximum)) - 2, + int(math.floor(dwell_time_distribution.maximum) - math.ceil(dwell_time_distribution.minimum)), "Import movement means the truck can come later than minimum but must be earlier than maximum" ) diff --git a/conflowgen/tests/notebooks/compare_container_dwell_time_distribution_with_results.ipynb b/conflowgen/tests/notebooks/compare_container_dwell_time_distribution_with_results.ipynb index d190e9b5..d4e3a27d 100644 --- a/conflowgen/tests/notebooks/compare_container_dwell_time_distribution_with_results.ipynb +++ b/conflowgen/tests/notebooks/compare_container_dwell_time_distribution_with_results.ipynb @@ -45,8 +45,17 @@ "\n", "database_chooser = conflowgen.DatabaseChooser(\n", " sqlite_databases_directory=\"../../data/databases\" # use subdirectory relative to Jupyter Notebook\n", - ")\n", - "file_name = \"demo_deham_cta.sqlite\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3e7dd2e-1966-4a73-bcce-4f2e37a1a494", + "metadata": {}, + "outputs": [], + "source": [ + "file_name = \"demo_continental_gateway.sqlite\"\n", "\n", "database_chooser.load_existing_sqlite_database(file_name)" ] @@ -147,12 +156,42 @@ "metadata": {}, "outputs": [], "source": [ - "distribution = distributions[\n", + "distribution_truck_to_feeder = distributions[\n", " conflowgen.ModeOfTransport.truck][\n", " conflowgen.ModeOfTransport.feeder][\n", " conflowgen.StorageRequirement.standard]\n", "\n", - "distribution" + "distribution_truck_to_feeder" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b5c12b3b-eb64-4408-81e4-5a7ae2ba8c52", + "metadata": {}, + "outputs": [], + "source": [ + "text = container_dwell_time_analysis_report.get_report_as_text(\n", + " container_delivered_by_vehicle_type=conflowgen.ModeOfTransport.feeder,\n", + " container_picked_up_by_vehicle_type=conflowgen.ModeOfTransport.truck,\n", + " storage_requirement=conflowgen.StorageRequirement.standard\n", + ")\n", + "print(text)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5dcc70d2-c519-44eb-96f8-23c895ed3f81", + "metadata": {}, + "outputs": [], + "source": [ + "distribution_feeder_to_truck = distributions[\n", + " conflowgen.ModeOfTransport.feeder][\n", + " conflowgen.ModeOfTransport.truck][\n", + " conflowgen.StorageRequirement.standard]\n", + "\n", + "distribution_feeder_to_truck" ] }, { diff --git a/conflowgen/tests/tools/test_weekly_distribution.py b/conflowgen/tests/tools/test_weekly_distribution.py index 11508ada..957074d9 100644 --- a/conflowgen/tests/tools/test_weekly_distribution.py +++ b/conflowgen/tests/tools/test_weekly_distribution.py @@ -16,8 +16,7 @@ def test_simple_slice(self): (120, 0), (144, 0) ], - considered_time_window_in_hours=48, - minimum_dwell_time_in_hours=3 + considered_time_window_in_hours=48 ) _datetime = datetime.datetime( year=2021, month=8, day=2, hour=3 @@ -38,8 +37,7 @@ def test_simple_with_long_minimum_dwell_time(self): (120, 0), (144, 0) ], - considered_time_window_in_hours=36, - minimum_dwell_time_in_hours=24 + considered_time_window_in_hours=36 ) _datetime = datetime.datetime( year=2021, month=8, day=2 @@ -63,8 +61,7 @@ def test_slice_into_next_two_weeks(self): (120, 0), (144, 0) ], - considered_time_window_in_hours=(7 * 24), - minimum_dwell_time_in_hours=2 + considered_time_window_in_hours=(7 * 24) ) _datetime = datetime.datetime( year=2021, month=8, day=1, hour=0 @@ -78,8 +75,7 @@ def test_slice_into_next_two_weeks(self): 72: .2, 96: .1, 120: 0, - 144: 0, - 168: 0 + 144: 0 } self.assertEqual(assumed_slice.keys(), distribution_slice.keys()) for key in distribution_slice.keys(): @@ -105,7 +101,6 @@ def test_sunday_is_missing_for_delivery(self): weekly_distribution = WeeklyDistribution( list(distribution.items()), considered_time_window_in_hours=72, # 3 days before delivery is allowed - minimum_dwell_time_in_hours=3 # latest 3 hours before vessel arrival ) container_departure_time = datetime.datetime( year=2021, month=8, day=2, hour=11, minute=30 @@ -116,7 +111,7 @@ def test_sunday_is_missing_for_delivery(self): earliest_slot = latest_slot - datetime.timedelta(hours=71) distribution_slice = weekly_distribution.get_distribution_slice(earliest_slot) hours = list(distribution_slice.keys()) - self.assertEqual(73, len(hours)) + self.assertEqual(72, len(hours)) self.assertAlmostEqual(sum(list(distribution_slice.values())), 1, places=1, msg="Severe rounding issues exist.") visited_sunday = False @@ -133,3 +128,77 @@ def test_sunday_is_missing_for_delivery(self): "Assert arrivals on other days") self.assertTrue(visited_sunday) self.assertTrue(visited_working_day) + + # noinspection PyUnusedLocal + def test_correct_starting_and_end_hour_when_looking_forward(self): + distribution_monday_to_saturday = [ + 1 / (24 * 6) + for hour in range(24) + for day in range(6) # Monday to Saturday + ] + distribution_sunday = [0 for hour in range(24)] + # noinspection PyTypeChecker + distribution = dict(list(enumerate(distribution_monday_to_saturday + distribution_sunday))) + + weekly_distribution = WeeklyDistribution( + list(distribution.items()), + considered_time_window_in_hours=72, # 3 days before delivery is allowed + ) + container_departure_time = datetime.datetime( + year=2021, month=8, day=2, hour=11, minute=30 + ) + self.assertEqual(container_departure_time.weekday(), 0) # assert is Monday + + earliest_slot_input = ( + container_departure_time.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) + ) + + distribution_slice = weekly_distribution.get_distribution_slice(earliest_slot_input) + time_slots = list(distribution_slice.keys()) + earliest_time_slot = min(time_slots) + self.assertEqual(earliest_time_slot, 0) + latest_time_slot = max(time_slots) + self.assertEqual(latest_time_slot, 71) + + # noinspection PyUnusedLocal + def test_correct_starting_and_end_hour_when_looking_backward(self): + distribution_monday_to_saturday = [ + 1 / (24 * 6) + for hour in range(24) + for day in range(6) # Monday to Saturday + ] + distribution_sunday = [0 for hour in range(24)] + # noinspection PyTypeChecker + distribution = dict(list(enumerate(distribution_monday_to_saturday + distribution_sunday))) + + weekly_distribution = WeeklyDistribution( + list(distribution.items()), + considered_time_window_in_hours=72, # 3 days before delivery is allowed + ) + container_departure_time = datetime.datetime( + year=2021, month=8, day=2, hour=11, minute=30 + ) + self.assertEqual(container_departure_time.weekday(), 0) # assert is Monday + + earliest_slot_input = ( + container_departure_time.replace(minute=0, second=0, microsecond=0) - datetime.timedelta(hours=72) + ) + + distribution_slice = weekly_distribution.get_distribution_slice(earliest_slot_input) + time_slots = list(distribution_slice.keys()) + earliest_time_slot = min(time_slots) + self.assertEqual(earliest_time_slot, 0) + latest_time_slot = max(time_slots) + self.assertEqual(latest_time_slot, 71) + + def test_get_time_of_the_week_for_full_hour(self): + # pylint: disable=protected-access + hour_of_the_week = WeeklyDistribution._get_hour_of_the_week_from_datetime(datetime.datetime(2022, 8, 9, 11)) + + self.assertEqual(hour_of_the_week, 35) + + def test_get_time_of_the_week_for_started_hour(self): + # pylint: disable=protected-access + hour_of_the_week = WeeklyDistribution._get_hour_of_the_week_from_datetime(datetime.datetime(2022, 8, 9, 11, 30)) + + self.assertEqual(hour_of_the_week, 35) diff --git a/conflowgen/tools/weekly_distribution.py b/conflowgen/tools/weekly_distribution.py index 9e32d9f0..48ca951e 100644 --- a/conflowgen/tools/weekly_distribution.py +++ b/conflowgen/tools/weekly_distribution.py @@ -14,13 +14,9 @@ class WeeklyDistribution: def __init__( self, hour_fraction_pairs: List[Union[Tuple[int, float], Tuple[int, int]]], - considered_time_window_in_hours: int, - minimum_dwell_time_in_hours: int, - context: str = "" + considered_time_window_in_hours: int ): self.considered_time_window_in_hours = considered_time_window_in_hours - self.minimum_dwell_time_in_hours = minimum_dwell_time_in_hours - self.context = context self.hour_of_the_week_fraction_pairs = [] number_of_weeks_to_consider = 2 + int(considered_time_window_in_hours / 24 / 7) @@ -34,10 +30,6 @@ def __init__( ) ) - @property - def maximum_dwell_time_in_hours(self): - return self.minimum_dwell_time_in_hours + self.considered_time_window_in_hours - @classmethod def _get_hour_of_the_week_from_datetime(cls, point_in_time: datetime.datetime) -> int: # Get the monday at midnight before the given point in time @@ -64,18 +56,16 @@ def get_distribution_slice(self, start_as_datetime: datetime.datetime) -> Dict[i assert 0 <= start_hour <= self.HOURS_IN_WEEK, "Start hour must be in first week" assert start_hour < end_hour, "Start hour must be before end hour" - if end_hour - start_hour < self.minimum_dwell_time_in_hours: - raise InvalidDistributionSliceException( - f"start_hour: {start_hour} and end_hour: {end_hour} are too close given " - f"minimum_dwell_time_in_hours: {self.minimum_dwell_time_in_hours}" - ) - # get the distribution slice starting from start_hour and ending with end_hour - not_normalized_distribution_slice = [ - ((hour_of_the_week - start_hour), fraction) - for (hour_of_the_week, fraction) in self.hour_of_the_week_fraction_pairs - if start_hour <= hour_of_the_week <= end_hour - ] + not_normalized_distribution_slice = [] + for (hour_of_the_week, fraction) in self.hour_of_the_week_fraction_pairs: + if start_hour > hour_of_the_week: + continue + if hour_of_the_week >= end_hour: + break + not_normalized_distribution_slice.append( + ((hour_of_the_week - start_hour), fraction) + ) total_fraction_sum = sum((fraction for _, fraction in not_normalized_distribution_slice)) distribution_slice = { @@ -87,8 +77,5 @@ def get_distribution_slice(self, start_as_datetime: datetime.datetime) -> Dict[i def __repr__(self) -> str: return ( f"<{self.__class__.__name__}: " - f"min={self.minimum_dwell_time_in_hours:.1f}h, " - f"max={self.maximum_dwell_time_in_hours:.1f}h={self.maximum_dwell_time_in_hours / 24:.1f}d, " - f"context='{self.context}'" f">" ) diff --git a/examples/Python_Script/demo_DEHAM_CTA.py b/examples/Python_Script/demo_DEHAM_CTA.py index 7822b5c1..e9cbc27d 100644 --- a/examples/Python_Script/demo_DEHAM_CTA.py +++ b/examples/Python_Script/demo_DEHAM_CTA.py @@ -28,7 +28,10 @@ try: import conflowgen - print(f"Importing ConFlowGen version {conflowgen.__version__}") + install_dir = os.path.abspath( + os.path.join(conflowgen.__file__, os.path.pardir) + ) + print(f"Importing ConFlowGen version {conflowgen.__version__} installed at {install_dir}.") except ImportError: print("Please first install conflowgen as a library") sys.exit() @@ -289,12 +292,12 @@ logger.info("Generate all fleets with all vehicles. This is the core of the whole project.") container_flow_generation_manager.generate() -logger.info("The container flow data have been generated, run post-hoc analyses on them") +logger.info("The container flow data have been generated, run analyses on them.") conflowgen.run_all_analyses() logger.info("For a better understanding of the data, it is advised to study the logs and compare the preview with the " - "post-hoc analysis results") + "analysis results.") logger.info("Start data export...") diff --git a/examples/Python_Script/demo_continental_gateway.py b/examples/Python_Script/demo_continental_gateway.py new file mode 100644 index 00000000..1984e4a5 --- /dev/null +++ b/examples/Python_Script/demo_continental_gateway.py @@ -0,0 +1,189 @@ +""" +This is an example of a continental gateway that only receives feeder vessels and uses trucks to connect to the +hinterland. The vessel arrivals are based on the DEHAM CTA example. +""" + +import datetime +import os.path +import random +import sys +import pandas as pd + +try: + import conflowgen + install_dir = os.path.abspath( + os.path.join(conflowgen.__file__, os.path.pardir) + ) + print(f"Importing ConFlowGen version {conflowgen.__version__} installed at {install_dir}.") +except ImportError: + print("Please first install conflowgen as a library") + sys.exit() + +# The seed of x=1 guarantees that the same traffic data is generated as input data in this script. However, it does not +# affect the container generation or the assignment of containers to vehicles. +seeded_random = random.Random(x=1) + +import_deham_dir = os.path.join( + os.path.dirname(sys.modules[__name__].__file__), + "data", + "DEHAM", + "CT Altenwerder" +) +df_feeders = pd.read_csv( + os.path.join( + import_deham_dir, + "feeder_input.csv" + ), + index_col=[0] +) + + +# Start logging +logger = conflowgen.setup_logger() +logger.info(__doc__) + +# Pick database +database_chooser = conflowgen.DatabaseChooser() +demo_file_name = "demo_continental_gateway.sqlite" +database_chooser.create_new_sqlite_database( + demo_file_name, + assume_tas=True, + overwrite=True +) + +# Set settings +container_flow_generation_manager = conflowgen.ContainerFlowGenerationManager() +container_flow_start_date = datetime.date(year=2021, month=7, day=1) +container_flow_end_date = datetime.date(year=2021, month=7, day=31) +container_flow_generation_manager.set_properties( + name="Demo Continental Gateway", + start_date=container_flow_start_date, + end_date=container_flow_end_date +) + +# Set some general assumptions regarding the container properties +container_length_distribution_manager = conflowgen.ContainerLengthDistributionManager() +container_length_distribution_manager.set_container_length_distribution({ + conflowgen.ContainerLength.twenty_feet: 0.33, + conflowgen.ContainerLength.forty_feet: 0.67, + conflowgen.ContainerLength.forty_five_feet: 0, + conflowgen.ContainerLength.other: 0 +}) + +mode_of_transport_distribution_manager = conflowgen.ModeOfTransportDistributionManager() +mode_of_transport_distribution_manager.set_mode_of_transport_distribution( + { + conflowgen.ModeOfTransport.feeder: { + conflowgen.ModeOfTransport.train: 0, + conflowgen.ModeOfTransport.truck: 1, + conflowgen.ModeOfTransport.barge: 0, + conflowgen.ModeOfTransport.feeder: 0, + conflowgen.ModeOfTransport.deep_sea_vessel: 0 + }, + conflowgen.ModeOfTransport.truck: { + conflowgen.ModeOfTransport.train: 0, + conflowgen.ModeOfTransport.truck: 0, + conflowgen.ModeOfTransport.barge: 0, + conflowgen.ModeOfTransport.feeder: 1, + conflowgen.ModeOfTransport.deep_sea_vessel: 0 + }, + + # The following entries cannot be missing but won't be actually used. They do not matter because no vehicles of + # that kind are generated as we do not add any schedules for them. + conflowgen.ModeOfTransport.barge: { + conflowgen.ModeOfTransport.train: 0.2, + conflowgen.ModeOfTransport.truck: 0.15, + conflowgen.ModeOfTransport.barge: 0.25, + conflowgen.ModeOfTransport.feeder: 0.1, + conflowgen.ModeOfTransport.deep_sea_vessel: 0.3 + }, + conflowgen.ModeOfTransport.deep_sea_vessel: { + conflowgen.ModeOfTransport.train: 0.25, + conflowgen.ModeOfTransport.truck: 0.1, + conflowgen.ModeOfTransport.barge: 0.2, + conflowgen.ModeOfTransport.feeder: 0.15, + conflowgen.ModeOfTransport.deep_sea_vessel: 0.3 + }, + conflowgen.ModeOfTransport.train: { + conflowgen.ModeOfTransport.train: 0.3, + conflowgen.ModeOfTransport.truck: 0.1, + conflowgen.ModeOfTransport.barge: 0.15, + conflowgen.ModeOfTransport.feeder: 0.2, + conflowgen.ModeOfTransport.deep_sea_vessel: 0.25 + } + } +) + +# Add vehicles that frequently visit the terminal. +port_call_manager = conflowgen.PortCallManager() + +logger.info("Start importing feeder vessels...") +for i, row in df_feeders.iterrows(): + feeder_vehicle_name = row["vehicle_name"] + "-unique" + capacity = row["capacity"] + vessel_arrives_at_as_pandas_type = row["arrival (planned)"] + vessel_arrives_at_as_datetime_type = pd.to_datetime(vessel_arrives_at_as_pandas_type) + + if vessel_arrives_at_as_datetime_type.date() < container_flow_start_date: + logger.info(f"Skipping feeder '{feeder_vehicle_name}' because it arrives before the start") + continue + + if vessel_arrives_at_as_datetime_type.date() > container_flow_end_date: + logger.info(f"Skipping feeder '{feeder_vehicle_name}' because it arrives after the end") + continue + + if port_call_manager.has_schedule(feeder_vehicle_name, vehicle_type=conflowgen.ModeOfTransport.feeder): + logger.info(f"Skipping feeder '{feeder_vehicle_name}' because it already exists") + continue + + logger.info(f"Add feeder '{feeder_vehicle_name}' to database") + # The estimate is based on the reported lifts per call in "Tendency toward Mega Containerships and the Constraints + # of Container Terminals" of Park and Suh (2019) J. Mar. Sci. Eng, URL: https://www.mdpi.com/2077-1312/7/5/131/htm + # lifts per call refer to both the inbound and outbound journey of the vessel + moved_capacity = int(round(capacity * seeded_random.triangular(0.3, 0.8) / 2)) # this is only the inbound journey + + # The actual name of the port is not important as it is only used to group containers that are destined for the same + # vessel in the yard for faster loading (less reshuffling). The assumption that the same amount of containers is + # destined for each of the following ports is a simplification. + number_ports = int(round(seeded_random.triangular(low=2, high=4))) + next_ports = [ + (port_name, 1/number_ports) + for port_name in [ + f"port {i + 1} of {feeder_vehicle_name}" + for i in range(number_ports) + ] + ] + + port_call_manager.add_large_scheduled_vehicle( + vehicle_type=conflowgen.ModeOfTransport.feeder, + service_name=feeder_vehicle_name, + vehicle_arrives_at=vessel_arrives_at_as_datetime_type.date(), + vehicle_arrives_at_time=vessel_arrives_at_as_datetime_type.time(), + average_vehicle_capacity=capacity, + average_moved_capacity=moved_capacity, + vehicle_arrives_every_k_days=-1, # single arrival, no frequent schedule + next_destinations=next_ports + ) +logger.info("Feeder vessels are imported") + +### +# Now, all schedules and input distributions are set up - no further inputs are required +### + +logger.info("Preview the results with some light-weight approaches.") + +conflowgen.run_all_previews() + +logger.info("Generate all fleets with all vehicles. This is the core of the whole project.") +container_flow_generation_manager.generate() + +logger.info("The container flow data have been generated, run analyses on them.") + +conflowgen.run_all_analyses() + +logger.info("For a better understanding of the data, it is advised to study the logs and compare the preview with the " + "analysis results.") + +# Gracefully close everything +database_chooser.close_current_connection() +logger.info("Script finished successfully.") diff --git a/examples/Python_Script/demo_poc.py b/examples/Python_Script/demo_poc.py index 82efbf4c..a56422d6 100644 --- a/examples/Python_Script/demo_poc.py +++ b/examples/Python_Script/demo_poc.py @@ -13,10 +13,14 @@ import datetime import sys +import os try: import conflowgen - print(f"Importing ConFlowGen version {conflowgen.__version__}") + install_dir = os.path.abspath( + os.path.join(conflowgen.__file__, os.path.pardir) + ) + print(f"Importing ConFlowGen version {conflowgen.__version__} installed at {install_dir}.") except ImportError: print("Please first install conflowgen as a library") sys.exit() diff --git a/setup.py b/setup.py index 838f02d5..1cca86e0 100644 --- a/setup.py +++ b/setup.py @@ -80,6 +80,7 @@ "autopep8", "rope", "yapf", + "pydocstyle" ] }, license=metadata['__license__'],