diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..3a2a8f12d --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[report] +fail_under = 81.0 +show_missing = True diff --git a/.github/workflows/test_linux_with_coverage.yml b/.github/workflows/test_linux_with_coverage.yml index e8b4a2f47..5df7ce9c7 100644 --- a/.github/workflows/test_linux_with_coverage.yml +++ b/.github/workflows/test_linux_with_coverage.yml @@ -1,8 +1,6 @@ name: Code coverage -on: - pull_request: - types: [ready_for_review, merge] +on: [pull_request] jobs: testing: @@ -32,14 +30,4 @@ jobs: - name: Generate coverage report run: | - python3 -m pytest --cov=./ --cov-report=xml - - name: Upload coverage to Codecov - if: ${{ (env.HAS_SECRETS == 'true') }} - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage.xml - flags: unittests - name: codecov-umbrella - yml: ./codecov.yml - fail_ci_if_error: true \ No newline at end of file + python3 -m pytest --cov=aequilibrae tests/ \ No newline at end of file diff --git a/aequilibrae/paths/traffic_assignment.py b/aequilibrae/paths/traffic_assignment.py index 860ccb86a..15e9633dd 100644 --- a/aequilibrae/paths/traffic_assignment.py +++ b/aequilibrae/paths/traffic_assignment.py @@ -1,4 +1,6 @@ +from copy import deepcopy import importlib.util as iutil +import logging import socket import sqlite3 from datetime import datetime @@ -102,6 +104,7 @@ def __init__(self, project=None) -> None: """""" proj = project or get_active_project(must_exist=False) + par = proj.parameters if proj else Parameters().parameters parameters = par["assignment"]["equilibrium"] @@ -127,6 +130,10 @@ def __init__(self, project=None) -> None: self.__dict__["steps_below_needed_to_terminate"] = 1 self.__dict__["project"] = proj + self.__dict__["_TrafficAssignment__config"] = {} + self.__dict__["logger"] = None + self.logger = proj.logger if proj else logging.getLogger("aequilibrae") + def __setattr__(self, instance, value) -> None: check, value, message = self.__check_attributes(instance, value) if check: @@ -248,6 +255,9 @@ def set_algorithm(self, algorithm: str): raise ValueError("Algorithm not listed in the case selection") self.__dict__["algorithm"] = algo + self.__config["Algorithm"] = algo + self.__config["Maximum iterations"] = self.assignment.max_iter + self.__config["Target RGAP"] = self.assignment.rgap_target def set_vdf_parameters(self, par: dict) -> None: """ @@ -265,6 +275,7 @@ def set_vdf_parameters(self, par: dict) -> None: "Before setting vdf parameters, you need to set traffic classes and choose a VDF function" ) self.__dict__["vdf_parameters"] = par + self.__config["VDF parameters"] = par pars = [] if self.vdf.function in ["BPR", "BPR2", "CONICAL", "INRETS"]: for p1 in ["alpha", "beta"]: @@ -291,6 +302,7 @@ def set_vdf_parameters(self, par: dict) -> None: raise ValueError(f"At least one {p1} is smaller than one. Results will make no sense") self.__dict__["vdf_parameters"] = pars + self.__config["VDF function"] = self.vdf.function.lower() def set_cores(self, cores: int) -> None: """Allows one to set the number of cores to be used AFTER traffic classes have been added @@ -365,6 +377,7 @@ def set_time_field(self, time_field: str) -> None: self.__dict__["congested_time"] = np.array(self.free_flow_tt, copy=True) self.__dict__["total_flow"] = np.zeros(self.free_flow_tt.shape[0], np.float64) self.time_field = time_field + self.__config["Time field"] = time_field def set_capacity_field(self, capacity_field: str) -> None: """ @@ -391,6 +404,8 @@ def set_capacity_field(self, capacity_field: str) -> None: self.__dict__["capacity"] = np.zeros(c.graph.graph.shape[0], c.graph.default_types("float")) self.__dict__["capacity"][c.graph.graph.__supernet_id__] = c.graph.graph[capacity_field] self.capacity_field = capacity_field + self.__config["Number of cores"] = c.results.cores + self.__config["Capacity field"] = capacity_field # TODO: This function actually needs to return a human-readable dictionary, and not one with # tons of classes. Feeds into the class above @@ -410,10 +425,20 @@ def __validate_parameters(self, kwargs) -> bool: raise ValueError("List of functions {} for vdf {} has an inadequate set of parameters".format(q, self.vdf)) return True - def execute(self) -> None: + def execute(self, log_specification=True) -> None: """Processes assignment""" + if log_specification: + self.log_specification() self.assignment.execute() + def log_specification(self): + self.logger.info("Traffic Class specification") + for cls in self.classes: + self.logger.info(str(cls.info)) + + self.logger.info("Traffic Assignment specification") + self.logger.info(self.__config) + def save_results(self, table_name: str, keep_zero_flows=True, project=None) -> None: """Saves the assignment results to results_database.sqlite diff --git a/aequilibrae/paths/traffic_class.py b/aequilibrae/paths/traffic_class.py index 68bd15fc5..aaf3451e8 100644 --- a/aequilibrae/paths/traffic_class.py +++ b/aequilibrae/paths/traffic_class.py @@ -1,11 +1,12 @@ +import warnings +from copy import deepcopy from typing import Union, List, Tuple, Dict + import numpy as np -import pandas as pd -from aequilibrae.paths.graph import Graph from aequilibrae.matrix import AequilibraeMatrix +from aequilibrae.paths.graph import Graph from aequilibrae.paths.results import AssignmentResults -import warnings class TrafficClass: @@ -51,7 +52,7 @@ def __init__(self, name: str, graph: Graph, matrix: AequilibraeMatrix) -> None: if matrix.matrix_view.dtype != graph.default_types("float"): raise TypeError("Matrix's computational view need to be of type np.float64") - + self.__config = {} self.graph = graph self.logger = graph.logger self.matrix = matrix @@ -67,6 +68,30 @@ def __init__(self, name: str, graph: Graph, matrix: AequilibraeMatrix) -> None: self._selected_links = {} # maps human name to link_set self.__id__ = name + graph_config = { + "Mode": graph.mode, + "Block through centroids": graph.block_centroid_flows, + "Number of centroids": graph.num_zones, + "Links": graph.num_links, + "Nodes": graph.num_nodes, + } + self.__config["Graph"] = str(graph_config) + + mat_config = { + "Source": matrix.file_path or "", + "Number of centroids": matrix.zones, + "Matrix cores": matrix.view_names, + } + if len(matrix.view_names) == 1: + mat_config["Matrix totals"] = { + nm: np.sum(np.nan_to_num(matrix.matrix_view)[:, :]) for nm in matrix.view_names + } + else: + mat_config["Matrix totals"] = { + nm: np.sum(np.nan_to_num(matrix.matrix_view)[:, :, i]) for i, nm in enumerate(matrix.view_names) + } + self.__config["Matrix"] = str(mat_config) + def set_pce(self, pce: Union[float, int]) -> None: """Sets Passenger Car equivalent @@ -140,6 +165,12 @@ def set_select_links(self, links: Dict[str, List[Tuple[int, int]]]): else: link_ids.append(comp_id) self._selected_links[name] = np.array(link_ids, dtype=self.graph.default_types("int")) + self.__config["select_links"] = str(links) + + @property + def info(self) -> dict: + config = deepcopy(self.__config) + return {self.__id__: config} def __setattr__(self, key, value): if key not in [ @@ -157,6 +188,7 @@ def __setattr__(self, key, value): "fc_multiplier", "fixed_cost_field", "_selected_links", + "_TrafficClass__config", ]: raise KeyError("Traffic Class does not have that element") self.__dict__[key] = value diff --git a/docs/source/examples/other_applications/plot_check_logging.py b/docs/source/examples/other_applications/plot_check_logging.py new file mode 100644 index 000000000..c058534e6 --- /dev/null +++ b/docs/source/examples/other_applications/plot_check_logging.py @@ -0,0 +1,102 @@ +""" +.. _useful-log-tips: + +Checking AequilibraE's log +========================== + +AequilibraE's log is a very useful tool to get more information about +what the software is doing under the hood. + +Information such as Traffic Class and Traffic Assignment stats, and Traffic Assignment +outputs. If you have created your project's network from OSM, you will also find +information on the number of nodes, links, and the query performed to obtain the data. + +In this example, we'll use Sioux Falls data to check the logs, but we strongly encourage +you to go ahead and download a place of your choice and perform a traffic assignment! +""" +# %% +# Imports +from uuid import uuid4 +from tempfile import gettempdir +from os.path import join +from aequilibrae.utils.create_example import create_example +from aequilibrae.paths import TrafficAssignment, TrafficClass + +# %% +# We create an empty project on an arbitrary folder +fldr = join(gettempdir(), uuid4().hex) +project = create_example(fldr) + +# %% +# We build our graphs +project.network.build_graphs() + +graph = project.network.graphs["c"] +graph.set_graph("free_flow_time") +graph.set_skimming(["free_flow_time", "distance"]) +graph.set_blocked_centroid_flows(False) + +# %% +# We get our demand matrix from the project and create a computational view +proj_matrices = project.matrices +demand = proj_matrices.get_matrix("demand_omx") +demand.computational_view(["matrix"]) + +# %% +# Now let's perform our traffic assignment +assig = TrafficAssignment() + +assigclass = TrafficClass(name="car", graph=graph, matrix=demand) + +assig.add_class(assigclass) +assig.set_vdf("BPR") +assig.set_vdf_parameters({"alpha": 0.15, "beta": 4.0}) +assig.set_capacity_field("capacity") +assig.set_time_field("free_flow_time") +assig.set_algorithm("bfw") +assig.max_iter = 50 +assig.rgap_target = 0.001 + +assig.execute() + +# %% +# +with open(join(fldr, "aequilibrae.log")) as file: + for idx, line in enumerate(file): + print(idx + 1, "-", line) + +# %% +# In lines 1-7, we receive some warnings that our fields name and lane have ``NaN`` values. +# As they are not relevant to our example, we can move on. +# +# In lines 8-9 we get the Traffic Class specifications. +# We can see that there is only one traffic class (car). Its **graph** key presents information +# on blocked flow through centroids, number of centroids, links, and nodes. +# In the **matrix** key, we find information on where in the disk the matrix file is located. +# We also have information on the number of centroids and nodes, as well as on the matrix/matrices +# used for computation. In our example, we only have one matrix named matrix, and the total +# sum of this matrix element is equal to 360,600. If you have more than one matrix its data +# will be also displayed in the *matrix_cores* and *matrix_totals* keys. +# +# In lines 10-11 the log shows the Traffic Assignment specifications. +# We can see that the VDF parameters, VDF function, capacity and time fields, algorithm, +# maximum number of iterations, and target gap are just like the ones we set previously. +# The only information that might be new to you is the number of cores used for computation. +# If you haven't set any, AequilibraE is going to use the largest number of CPU threads +# available. +# +# Line 12 displays us a warning to indicate that AequilibraE is converting the data type +# of the cost field. +# +# Lines 13-61 indicate that we'll receive the outputs of a *bfw* algorithm. +# In the log there are also the number of the iteration, its relative gap, and the stepsize. +# The outputs in lines 15-60 are exactly the same as the ones provided by the function +# ``assig.report()``. Finally, the last line shows us that the *bfw* assignment has finished +# after 46 iterations because its gap is smaller than the threshold we configured (0.001). +# +# In case you execute a new traffic assignment using different classes or changing the +# parameters values, these new specification values would be stored in the log file as well +# so you can always keep a record of what you have been doing. One last reminder is that +# if we had created our project from OSM, the lines on top of the log would have been +# different to display information on the queries done to the server to obtain the data. +# diff --git a/tests/aequilibrae/paths/test_traffic_assignment.py b/tests/aequilibrae/paths/test_traffic_assignment.py index f3f473612..1af80a722 100644 --- a/tests/aequilibrae/paths/test_traffic_assignment.py +++ b/tests/aequilibrae/paths/test_traffic_assignment.py @@ -1,15 +1,15 @@ -import os import random import sqlite3 import string +from os.path import join, isfile +from pathlib import Path from random import choice import numpy as np import pandas as pd import pytest -from aequilibrae import TrafficAssignment, TrafficClass, Graph -from aequilibrae.project.project import Project +from aequilibrae import TrafficAssignment, TrafficClass, Graph, Project from aequilibrae.utils.create_example import create_example from ...data import siouxfalls_project @@ -188,9 +188,10 @@ def matrix(self, request, matrix): def test_execute_and_save_results( self, assignment: TrafficAssignment, assigclass: TrafficClass, car_graph: Graph, matrix ): - conn = sqlite3.connect(os.path.join(siouxfalls_project, "project_database.sqlite")) + conn = sqlite3.connect(join(siouxfalls_project, "project_database.sqlite")) results = pd.read_sql("select volume from links order by link_id", conn) + proj = assignment.project assignment.add_class(assigclass) assignment.set_vdf("BPR") assignment.set_vdf_parameters({"alpha": 0.15, "beta": 4.0}) @@ -262,8 +263,52 @@ def test_execute_and_save_results( with pytest.raises(ValueError): assignment.save_results("save_to_database") + num_cores = assignment.cores + # Let's test logging of assignment + log_ = Path(proj.path_to_file).parent / "aequilibrae.log" + assert isfile(log_) + + file_text = "" + with open(log_, "r", encoding="utf-8") as file: + for line in file.readlines(): + file_text += line + + tc_spec = "INFO ; Traffic Class specification" + assert file_text.count(tc_spec) > 1 + + tc_graph = "INFO ; {'car': {'Graph': \"{'Mode': 'c', 'Block through centroids': False, 'Number of centroids': 24, 'Links': 76, 'Nodes': 24}\"," + assert file_text.count(tc_graph) > 1 + + tc_matrix = "'Number of centroids': 24, 'Matrix cores': ['matrix'], 'Matrix totals': {'matrix': 360600.0}}\"}}" + assert file_text.count(tc_matrix) > 1 + + assig_1 = "INFO ; {{'VDF parameters': {{'alpha': 'b', 'beta': 'power'}}, 'VDF function': 'bpr', 'Number of cores': {}, 'Capacity field': 'capacity', 'Time field': 'free_flow_time', 'Algorithm': 'msa', 'Maximum iterations': 10, 'Target RGAP': 0.0001}}".format( + num_cores + ) + assert assig_1 in file_text + + assig_2 = "INFO ; {{'VDF parameters': {{'alpha': 'b', 'beta': 'power'}}, 'VDF function': 'bpr', 'Number of cores': {}, 'Capacity field': 'capacity', 'Time field': 'free_flow_time', 'Algorithm': 'msa', 'Maximum iterations': 500, 'Target RGAP': 0.001}}".format( + num_cores + ) + assert assig_2 in file_text + + assig_3 = "INFO ; {{'VDF parameters': {{'alpha': 'b', 'beta': 'power'}}, 'VDF function': 'bpr', 'Number of cores': {}, 'Capacity field': 'capacity', 'Time field': 'free_flow_time', 'Algorithm': 'frank-wolfe', 'Maximum iterations': 500, 'Target RGAP': 0.001}}".format( + num_cores + ) + assert assig_3 in file_text + + assig_4 = "INFO ; {{'VDF parameters': {{'alpha': 'b', 'beta': 'power'}}, 'VDF function': 'bpr', 'Number of cores': {}, 'Capacity field': 'capacity', 'Time field': 'free_flow_time', 'Algorithm': 'cfw', 'Maximum iterations': 500, 'Target RGAP': 0.001}}".format( + num_cores + ) + assert assig_4 in file_text + + assig_5 = "INFO ; {{'VDF parameters': {{'alpha': 'b', 'beta': 'power'}}, 'VDF function': 'bpr', 'Number of cores': {}, 'Capacity field': 'capacity', 'Time field': 'free_flow_time', 'Algorithm': 'bfw', 'Maximum iterations': 500, 'Target RGAP': 0.001}}".format( + num_cores + ) + assert assig_5 in file_text + def test_execute_no_project(self, project: Project, assignment: TrafficAssignment, assigclass: TrafficClass): - conn = sqlite3.connect(os.path.join(siouxfalls_project, "project_database.sqlite")) + conn = sqlite3.connect(join(siouxfalls_project, "project_database.sqlite")) results = pd.read_sql("select volume from links order by link_id", conn) project.close()