diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..e92fe16 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,16 @@ +name: deploy-docs +on: + push: + branches: + - main + - docs +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.x + - run: pip install mkdocs mkdocstrings[python] + - run: mkdocs gh-deploy --force --clean --verbose diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 04bdc2a..d5a1c79 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, '3.10'] + python-version: [3.7, 3.8, 3.9, '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v3 @@ -23,7 +23,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install .\[test,docs\] - name: Run tests run: python -m unittest discover @@ -45,7 +45,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install .\[test,docs\] pip install flake8 black - name: Lint with flake8 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2cb2f73..6ddad72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,11 +11,11 @@ on: - dev jobs: - build: + test: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, '3.10'] + python-version: ['3.7', '3.8','3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v3 @@ -27,9 +27,11 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 black - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install .[test] - name: Lint with flake8 run: | flake8 carbontracker --count --select=E9,F63,F7,F82 --show-source --statistics - name: Formatting with Black run: black --line-length 120 carbontracker + - name: Run tests + run: python -m unittest discover -v diff --git a/carbontracker/cli.py b/carbontracker/cli.py index 40e03e8..f0fc69a 100644 --- a/carbontracker/cli.py +++ b/carbontracker/cli.py @@ -1,22 +1,64 @@ import argparse import subprocess from carbontracker.tracker import CarbonTracker +from carbontracker import parser import ast + +def parse_logs(log_dir): + parser.print_aggregate(log_dir=log_dir) + + def main(): + """ + The **carbontracker** CLI allows the user to track the energy consumption and carbon intensity of any program. + [Make sure that you have relevant permissions before running this.](/#permissions) + + Args: + --log_dir (path, optional): Log directory. Defaults to `./logs`. + --api_keys (str, optional): API keys in a dictionary-like format, e.g. `\'{"electricitymaps": "YOUR_KEY"}\'` + --parse (path, optional): Directory containing the log files to parse. + + Example: + Tracking the carbon intensity of `script.py`. + + $ carbontracker python script.py + + With example options + + $ carbontracker --log_dir='./logs' --api_keys='{"electricitymaps": "API_KEY_EXAMPLE"}' python script.py + + Parsing logs: + + $ carbontracker --parse ./internal_logs + """ + # Create a parser for the known arguments - parser = argparse.ArgumentParser(description="CarbonTracker CLI", add_help=True) - parser.add_argument("--log_dir", type=str, default="./logs", help="Log directory") - parser.add_argument("--api_keys", type=str, help="API keys in a dictionary-like format, e.g., " - "'{\"electricitymaps\": \"YOUR_KEY\"}'", default=None) + cli_parser = argparse.ArgumentParser(description="CarbonTracker CLI", add_help=True) + cli_parser.add_argument("--log_dir", type=str, default="./logs", help="Log directory") + cli_parser.add_argument( + "--api_keys", + type=str, + help="API keys in a dictionary-like format, e.g., " + '\'{"electricitymaps": "YOUR_KEY"}\'', + default=None, + ) + cli_parser.add_argument("--parse", type=str, help="Directory containing the log files to parse.") # Parse known arguments only - known_args, remaining_args = parser.parse_known_args() + known_args, remaining_args = cli_parser.parse_known_args() + + # Check if the --parse argument is provided + if known_args.parse: + parse_logs(known_args.parse) + return # Parse the API keys string into a dictionary api_keys = ast.literal_eval(known_args.api_keys) if known_args.api_keys else None - tracker = CarbonTracker(epochs=1, log_dir=known_args.log_dir, epochs_before_pred=0, api_keys=api_keys) + tracker = CarbonTracker( + epochs=1, log_dir=known_args.log_dir, epochs_before_pred=0, api_keys=api_keys + ) tracker.epoch_start() # The remaining_args are considered as the command to execute @@ -33,4 +75,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/carbontracker/components/apple_silicon/powermetrics.py b/carbontracker/components/apple_silicon/powermetrics.py index b627fb2..c77f90b 100644 --- a/carbontracker/components/apple_silicon/powermetrics.py +++ b/carbontracker/components/apple_silicon/powermetrics.py @@ -3,43 +3,49 @@ import re import time from carbontracker.components.handler import Handler +from typing import Union, List, Pattern class PowerMetricsUnified: - _output = None - _last_updated = None + _output: Union[None, str] = None + _last_updated: Union[None, float] = None @staticmethod def get_output(): - if PowerMetricsUnified._output is None or time.time() - PowerMetricsUnified._last_updated > 1: + if ( + PowerMetricsUnified._output is None + or PowerMetricsUnified._last_updated is None + or time.time() - PowerMetricsUnified._last_updated > 1 + ): PowerMetricsUnified._output = subprocess.check_output( - ["sudo", "powermetrics", "-n", "1", "-i", "1000", "--samplers", "all"], universal_newlines=True + ["sudo", "powermetrics", "-n", "1", "-i", "100", "--samplers", "all"], + universal_newlines=True, ) PowerMetricsUnified._last_updated = time.time() return PowerMetricsUnified._output class AppleSiliconCPU(Handler): - def init(self, pids=None, devices_by_pid=None): + def init(self, pids=None, devices_by_pid=False): self.devices_list = ["CPU"] self.cpu_pattern = re.compile(r"CPU Power: (\d+) mW") def shutdown(self): pass - def devices(self): + def devices(self) -> List[str]: """Returns a list of devices (str) associated with the component.""" return self.devices_list - def available(self): + def available(self) -> bool: return platform.system() == "Darwin" - def power_usage(self): + def power_usage(self) -> List[float]: output = PowerMetricsUnified.get_output() cpu_power = self.parse_power(output, self.cpu_pattern) - return cpu_power + return [cpu_power] - def parse_power(self, output, pattern): + def parse_power(self, output: str, pattern: Pattern[str]) -> float: match = pattern.search(output) if match: power = float(match.group(1)) / 1000 # Convert mW to W @@ -49,28 +55,31 @@ def parse_power(self, output, pattern): class AppleSiliconGPU(Handler): - def init(self, pids=None, devices_by_pid=None): + def init(self, pids=None, devices_by_pid=False): self.devices_list = ["GPU", "ANE"] self.gpu_pattern = re.compile(r"GPU Power: (\d+) mW") self.ane_pattern = re.compile(r"ANE Power: (\d+) mW") - def devices(self): + def devices(self) -> List[str]: """Returns a list of devices (str) associated with the component.""" return self.devices_list - def available(self): + def available(self) -> bool: return platform.system() == "Darwin" def power_usage(self): output = PowerMetricsUnified.get_output() gpu_power = self.parse_power(output, self.gpu_pattern) ane_power = self.parse_power(output, self.ane_pattern) - return gpu_power + ane_power + return [gpu_power + ane_power] - def parse_power(self, output, pattern): + def parse_power(self, output: str, pattern: Pattern[str]) -> float: match = pattern.search(output) if match: power = float(match.group(1)) / 1000 # Convert mW to W (J/s) return power else: return 0.0 + + def shutdown(self): + pass diff --git a/carbontracker/components/component.py b/carbontracker/components/component.py index 338edb5..94a19a0 100644 --- a/carbontracker/components/component.py +++ b/carbontracker/components/component.py @@ -3,7 +3,12 @@ from carbontracker import exceptions from carbontracker.components.gpu import nvidia from carbontracker.components.cpu import intel -from carbontracker.components.apple_silicon.powermetrics import AppleSiliconCPU, AppleSiliconGPU +from carbontracker.components.apple_silicon.powermetrics import ( + AppleSiliconCPU, + AppleSiliconGPU, +) +from carbontracker.components.handler import Handler +from typing import Iterable, List, Union, Type, Sized COMPONENTS = [ { @@ -19,38 +24,46 @@ ] -def component_names(): +def component_names() -> List[str]: return [comp["name"] for comp in COMPONENTS] -def error_by_name(name): +def error_by_name(name) -> Exception: for comp in COMPONENTS: if comp["name"] == name: return comp["error"] + raise exceptions.ComponentNameError() -def handlers_by_name(name): +def handlers_by_name(name) -> List[Type[Handler]]: for comp in COMPONENTS: if comp["name"] == name: return comp["handlers"] + raise exceptions.ComponentNameError() class Component: - def __init__(self, name, pids, devices_by_pid): + def __init__(self, name: str, pids: Iterable[int], devices_by_pid: bool): self.name = name if name not in component_names(): - raise exceptions.ComponentNameError(f"No component found with name '{self.name}'.") - self._handler = self._determine_handler(pids=pids, devices_by_pid=devices_by_pid) - self.power_usages = [] - self.cur_epoch = -1 # Sentry + raise exceptions.ComponentNameError( + f"No component found with name '{self.name}'." + ) + self._handler = self._determine_handler( + pids=pids, devices_by_pid=devices_by_pid + ) + self.power_usages: List[List[float]] = [] + self.cur_epoch: int = -1 # Sentry @property - def handler(self): + def handler(self) -> Handler: if self._handler is None: raise error_by_name(self.name) return self._handler - def _determine_handler(self, pids, devices_by_pid): + def _determine_handler( + self, pids: Iterable[int], devices_by_pid: bool + ) -> Union[Handler, None]: handlers = handlers_by_name(self.name) for h in handlers: handler = h(pids=pids, devices_by_pid=devices_by_pid) @@ -58,13 +71,13 @@ def _determine_handler(self, pids, devices_by_pid): return handler return None - def devices(self): + def devices(self) -> List[str]: return self.handler.devices() - def available(self): + def available(self) -> bool: return self._handler is not None - def collect_power_usage(self, epoch): + def collect_power_usage(self, epoch: int): if epoch < 1: return @@ -77,11 +90,13 @@ def collect_power_usage(self, epoch): if diff != 0: for _ in range(diff): # Copy previous measurement lists. - latest_measurements = self.power_usages[-1] if self.power_usages else [] + latest_measurements = ( + self.power_usages[-1] if self.power_usages else [] + ) self.power_usages.append(latest_measurements) self.power_usages.append([]) try: - self.power_usages[-1].append(self.handler.power_usage()) + self.power_usages[-1] += self.handler.power_usage() except exceptions.IntelRaplPermissionError: # Only raise error if no measurements have been collected. if not self.power_usages[-1]: @@ -100,7 +115,7 @@ def collect_power_usage(self, epoch): # Append zero measurement to avoid further errors. self.power_usages.append([0]) - def energy_usage(self, epoch_times): + def energy_usage(self, epoch_times: List[int]) -> List[int]: """Returns energy (mWh) used by component per epoch.""" energy_usages = [] # We have to compute each epoch in a for loop since numpy cannot @@ -138,11 +153,17 @@ def shutdown(self): self.handler.shutdown() -def create_components(components, pids, devices_by_pid): +def create_components( + components: str, pids: Iterable[int], devices_by_pid: bool +) -> List[Component]: components = components.strip().replace(" ", "").lower() if components == "all": - return [Component(name=comp_name, pids=pids, devices_by_pid=devices_by_pid) for comp_name in component_names()] + return [ + Component(name=comp_name, pids=pids, devices_by_pid=devices_by_pid) + for comp_name in component_names() + ] else: return [ - Component(name=comp_name, pids=pids, devices_by_pid=devices_by_pid) for comp_name in components.split(",") + Component(name=comp_name, pids=pids, devices_by_pid=devices_by_pid) + for comp_name in components.split(",") ] diff --git a/carbontracker/components/cpu/intel.py b/carbontracker/components/cpu/intel.py index e55de8a..c234b22 100644 --- a/carbontracker/components/cpu/intel.py +++ b/carbontracker/components/cpu/intel.py @@ -4,6 +4,7 @@ from carbontracker import exceptions from carbontracker.components.handler import Handler +from typing import List, Union # RAPL Literature: # https://www.researchgate.net/publication/322308215_RAPL_in_Action_Experiences_in_Using_RAPL_for_Power_Measurements @@ -15,18 +16,18 @@ class IntelCPU(Handler): - def __init__(self, pids, devices_by_pid): + def __init__(self, pids: List, devices_by_pid: bool): super().__init__(pids, devices_by_pid) self._handler = None - def devices(self): + def devices(self) -> List[str]: """Returns the name of all RAPL Domains""" return self._devices - def available(self): + def available(self) -> bool: return os.path.exists(RAPL_DIR) and bool(os.listdir(RAPL_DIR)) - def power_usage(self): + def power_usage(self) -> List[float]: before_measures = self._get_measurements() time.sleep(MEASURE_DELAY) after_measures = self._get_measurements() @@ -35,20 +36,21 @@ def power_usage(self): while attempts > 0: attempts -= 1 power_usages = [ - self._compute_power(before, after) for before, after in zip(before_measures, after_measures) + self._compute_power(before, after) + for before, after in zip(before_measures, after_measures) ] if all(power >= 0 for power in power_usages): return power_usages default = [0.0 for device in range(len(self._devices))] return default - def _compute_power(self, before, after): + def _compute_power(self, before: int, after: int) -> float: """Compute avg. power usage from two samples in microjoules.""" joules = (after - before) / 1000000 watt = joules / MEASURE_DELAY return watt - def _read_energy(self, path): + def _read_energy(self, path: str) -> int: with open(os.path.join(path, "energy_uj"), "r") as f: return int(f.read()) @@ -65,16 +67,22 @@ def _get_measurements(self): except FileNotFoundError: # check cpu/gpu/dram - parts = [f for f in os.listdir(os.path.join(RAPL_DIR, package)) if re.match(self.parts_pattern, f)] + parts = [ + f + for f in os.listdir(os.path.join(RAPL_DIR, package)) + if re.match(self.parts_pattern, f) + ] total_power_usage = 0 for part in parts: - total_power_usage += self._read_energy(os.path.join(RAPL_DIR, package, part)) + total_power_usage += self._read_energy( + os.path.join(RAPL_DIR, package, part) + ) measurements.append(total_power_usage) return measurements - def _convert_rapl_name(self, name, pattern): + def _convert_rapl_name(self, name, pattern) -> Union[None, str]: if re.match(pattern, name): return "cpu:" + name[-1] @@ -82,8 +90,8 @@ def init(self): # Get amount of intel-rapl folders packages = list(filter(lambda x: ":" in x, os.listdir(RAPL_DIR))) self.device_count = len(packages) - self._devices = [] - self._rapl_devices = [] + self._devices: List[str] = [] + self._rapl_devices: List[str] = [] self.parts_pattern = re.compile(r"intel-rapl:(\d):(\d)") devices_pattern = re.compile("intel-rapl:.") @@ -93,7 +101,9 @@ def init(self): name = f.read().strip() if name != "psys": self._rapl_devices.append(package) - self._devices.append(self._convert_rapl_name(package, devices_pattern)) + rapl_name = self._convert_rapl_name(package, devices_pattern) + if rapl_name is not None: + self._devices.append(rapl_name) def shutdown(self): pass diff --git a/carbontracker/components/gpu/nvidia.py b/carbontracker/components/gpu/nvidia.py index 91996ef..b45c852 100644 --- a/carbontracker/components/gpu/nvidia.py +++ b/carbontracker/components/gpu/nvidia.py @@ -7,6 +7,7 @@ by running queries in batches (initializing and shutdown after each query can result in more than a 10x slowdown). """ + import sys import pynvml @@ -14,14 +15,15 @@ from carbontracker import exceptions from carbontracker.components.handler import Handler +from typing import List, Union class NvidiaGPU(Handler): - def __init__(self, pids, devices_by_pid): + def __init__(self, pids: List[int], devices_by_pid: bool): super().__init__(pids, devices_by_pid) - self._handles = None + self._handles = [] - def devices(self): + def devices(self) -> List[str]: """ Note: Requires NVML to be initialized. @@ -29,12 +31,12 @@ def devices(self): names = [pynvml.nvmlDeviceGetName(handle) for handle in self._handles] # Decode names if Python version is less than 3.9 - if sys.version_info < (3, 9): - names = [name.decode("utf-8") for name in names] + if sys.version_info < (3, 10): + names = [name.decode() for name in names] return names - def available(self): + def available(self) -> bool: """Checks if NVML and any GPUs are available.""" try: self.init() @@ -47,7 +49,7 @@ def available(self): available = False return available - def power_usage(self): + def power_usage(self) -> List[float]: """Retrieves instantaneous power usages (W) of all GPUs in a list. Note: @@ -73,9 +75,9 @@ def init(self): def shutdown(self): pynvml.nvmlShutdown() - self._handles = None + self._handles = [] - def _get_handles(self): + def _get_handles(self) -> List: """Returns handles of GPUs in slurm job if existent otherwise all available GPUs.""" device_indices = self._slurm_gpu_indices() @@ -87,7 +89,7 @@ def _get_handles(self): return [pynvml.nvmlDeviceGetHandleByIndex(i) for i in device_indices] - def _slurm_gpu_indices(self): + def _slurm_gpu_indices(self) -> Union[List[int], None]: """Returns indices of GPUs for the current slurm job if existent. Note: @@ -97,12 +99,16 @@ def _slurm_gpu_indices(self): """ index_str = os.environ.get("CUDA_VISIBLE_DEVICES") try: - indices = [int(i) for i in index_str.split(",")] + indices = ( + [int(i) for i in index_str.split(",")] + if index_str is not None + else None + ) except: indices = None return indices - def _get_handles_by_pid(self): + def _get_handles_by_pid(self) -> List: """Returns handles of GPU running at least one process from PIDS. Note: @@ -119,7 +125,7 @@ def _get_handles_by_pid(self): gpu_pids = [ p.pid for p in pynvml.nvmlDeviceGetComputeRunningProcesses(handle) - + pynvml.nvmlDeviceGetGraphicsRunningProcesses(handle) + + pynvml.nvmlDeviceGetGraphicsRunningProcesses(handle) ] if set(gpu_pids).intersection(self.pids): diff --git a/carbontracker/components/handler.py b/carbontracker/components/handler.py index 5527348..66fee51 100644 --- a/carbontracker/components/handler.py +++ b/carbontracker/components/handler.py @@ -1,25 +1,26 @@ from abc import ABCMeta, abstractmethod +from typing import List, Iterable class Handler: __metaclass__ = ABCMeta - def __init__(self, pids, devices_by_pid): + def __init__(self, pids: Iterable[int], devices_by_pid: bool): self.pids = pids self.devices_by_pid = devices_by_pid @abstractmethod - def devices(self): + def devices(self) -> List[str]: """Returns a list of devices (str) associated with the component.""" raise NotImplementedError @abstractmethod - def available(self): + def available(self) -> bool: """Returns True if the handler is available.""" raise NotImplementedError @abstractmethod - def power_usage(self): + def power_usage(self) -> List[float]: """Returns the current power usage (W) in a list.""" raise NotImplementedError diff --git a/carbontracker/constants.py b/carbontracker/constants.py index 01caafa..04f0791 100644 --- a/carbontracker/constants.py +++ b/carbontracker/constants.py @@ -9,6 +9,10 @@ # https://uptimeinstitute.com/uptime_assets/6768eca6a75d792c8eeede827d76de0d0380dee6b5ced20fde45787dd3688bfe-2022-data-center-industry-survey-en.pdf PUE_2022 = 1.55 +# https://journal.uptimeinstitute.com/global-pues-are-they-going-anywhere/ +PUE_2023 = 1.58 + # World-wide average carbon intensity of electricity production in 2019. # https://www.iea.org/reports/global-energy-co2-status-report-2019/emissions -WORLD_2019_CARBON_INTENSITY = 475 +# 2024 update: Now uses number from https://www.statista.com/statistics/943137/global-emissions-intensity-power-sector-by-country/ +WORLD_2019_CARBON_INTENSITY = 481 diff --git a/carbontracker/data/carbon-intensities.csv b/carbontracker/data/carbon-intensities.csv index a6ebe2f..9dedb1c 100644 --- a/carbontracker/data/carbon-intensities.csv +++ b/carbontracker/data/carbon-intensities.csv @@ -1,188 +1,190 @@ alpha-2,Entity,Code,Year,Carbon intensity of electricity (gCO2/kWh) -AE,United Arab Emirates,ARE,2020,427.86282 -AF,Afghanistan,AFG,2020,115.38463 -AG,Antigua and Barbuda,ATG,2020,687.5 -AL,Albania,ALB,2020,24.482107 -AM,Armenia,ARM,2021,206.94258 -AO,Angola,AGO,2020,168.86728 -AR,Argentina,ARG,2021,347.29196 -AS,American Samoa,ASM,2020,733.3333 -AT,Austria,AUT,2021,81.25418 -AU,Australia,AUS,2021,486.25497 -AW,Aruba,ABW,2020,579.5454 -AZ,Azerbaijan,AZE,2021,481.51907 -BA,Bosnia and Herzegovina,BIH,2021,478.45804 -BB,Barbados,BRB,2020,670.103 -BD,Bangladesh,BGD,2021,446.66843 -BE,Belgium,BEL,2021,139.7274 -BF,Burkina Faso,BFA,2020,631.25 -BG,Bulgaria,BGR,2021,419.39273 -BH,Bahrain,BHR,2020,489.95312 -BI,Burundi,BDI,2021,312.5 -BJ,Benin,BEN,2020,652.17395 -BR,Brazil,BRA,2021,141.77426 -BS,Bahamas,BHS,2020,698.49243 -BT,Bhutan,BTN,2020,23.463686 -BW,Botswana,BWA,2020,800.0 -BY,Belarus,BLR,2021,443.62558 -BZ,Belize,BLZ,2020,474.57626 -CA,Canada,CAN,2021,118.99668 -CF,Central African Republic,CAF,2020,0.0 -CG,Congo,COG,2020,364.14563 -CH,Switzerland,CHE,2021,57.772644 -CK,Cook Islands,COK,2020,500.0 -CL,Chile,CHL,2021,374.46323 -CM,Cameroon,CMR,2020,243.7071 -CN,China,CHN,2021,541.3317 -CO,Colombia,COL,2020,192.62947 -CR,Costa Rica,CRI,2021,30.903326 -CU,Cuba,CUB,2020,575.9049 -CY,Cyprus,CYP,2021,587.49805 -CZ,Czechia,CZE,2021,412.24548 -DE,Germany,DEU,2021,352.42252 -DJ,Djibouti,DJI,2020,800.0 -DK,Denmark,DNK,2021,149.74605 -DM,Dominica,DMA,2020,500.0 -DO,Dominican Republic,DOM,2020,605.7243 -DZ,Algeria,DZA,2020,449.74878 -EC,Ecuador,ECU,2021,137.54825 -EE,Estonia,EST,2021,739.8695 -EG,Egypt,EGY,2021,389.0191 +AE,United Arab Emirates,ARE,2022,561.1348 +AF,Afghanistan,AFG,2022,132.53012 +AG,Antigua and Barbuda,ATG,2022,611.1111 +AL,Albania,ALB,2022,24.285715 +AM,Armenia,ARM,2022,264.53815 +AO,Angola,AGO,2022,174.73436 +AR,Argentina,ARG,2023,354.10287 +AS,American Samoa,ASM,2022,611.1111 +AT,Austria,AUT,2023,110.81243 +AU,Australia,AUS,2023,548.69226 +AW,Aruba,ABW,2022,561.2245 +AZ,Azerbaijan,AZE,2022,671.38904 +BA,Bosnia and Herzegovina,BIH,2023,600.00006 +BB,Barbados,BRB,2022,605.5046 +BD,Bangladesh,BGD,2023,691.4112 +BE,Belgium,BEL,2023,138.1068 +BF,Burkina Faso,BFA,2022,467.5325 +BG,Bulgaria,BGR,2023,335.3338 +BH,Bahrain,BHR,2022,904.6145 +BI,Burundi,BDI,2022,250.00002 +BJ,Benin,BEN,2022,584.0708 +BM,Bermuda,BMU,2022,650.79364 +BR,Brazil,BRA,2023,98.34824 +BS,Bahamas,BHS,2022,660.0986 +BT,Bhutan,BTN,2022,23.333334 +BW,Botswana,BWA,2022,847.9087 +BY,Belarus,BLR,2022,441.74 +BZ,Belize,BLZ,2022,225.80646 +CA,Canada,CAN,2023,170.04251 +CF,Central African Republic,CAF,2022,0.0 +CG,Congo,COG,2022,700.0 +CH,Switzerland,CHE,2023,34.842716 +CK,Cook Islands,COK,2022,250.0 +CL,Chile,CHL,2023,291.1135 +CM,Cameroon,CMR,2022,305.4187 +CN,China,CHN,2023,582.31696 +CO,Colombia,COL,2023,259.51117 +CR,Costa Rica,CRI,2023,53.377815 +CU,Cuba,CUB,2022,637.6096 +CY,Cyprus,CYP,2023,534.3229 +CZ,Czechia,CZE,2023,449.72433 +DE,Germany,DEU,2023,380.95047 +DJ,Djibouti,DJI,2022,692.3078 +DK,Denmark,DNK,2023,151.6503 +DM,Dominica,DMA,2022,529.4118 +DO,Dominican Republic,DOM,2022,580.7799 +DZ,Algeria,DZA,2022,634.611 +EC,Ecuador,ECU,2023,150.22354 +EE,Estonia,EST,2023,416.6667 +EG,Egypt,EGY,2023,570.30554 EH,Western Sahara,ESH,2009,666.6666 -ER,Eritrea,ERI,2020,659.0909 -ES,Spain,ESP,2021,169.03445 -ET,Ethiopia,ETH,2020,25.441698 -FI,Finland,FIN,2021,68.833595 -FJ,Fiji,FJI,2020,292.92926 -FR,France,FRA,2021,58.4792 -GA,Gabon,GAB,2020,294.91525 -GB,United Kingdom,GBR,2021,264.5091 -GD,Grenada,GRD,2020,700.0 -GE,Georgia,GEO,2021,111.374405 -GF,French Guiana,GUF,2020,350.51547 -GH,Ghana,GHA,2020,355.04724 -GL,Greenland,GRL,2020,118.644066 -GM,Gambia,GMB,2020,689.65515 -GN,Guinea,GIN,2020,175.0 -GP,Guadeloupe,GLP,2020,588.6076 -GQ,Equatorial Guinea,GNQ,2020,628.31854 -GR,Greece,GRC,2021,430.25867 -GT,Guatemala,GTM,2020,332.0158 -GU,Guam,GUM,2020,670.58826 -GW,Guinea-Bissau,GNB,2020,750.0 -GY,Guyana,GUY,2020,636.3636 -HK,Hong Kong,HKG,2020,644.89954 -HN,Honduras,HND,2020,358.64594 -HR,Croatia,HRV,2021,131.26709 -HT,Haiti,HTI,2020,606.383 -HU,Hungary,HUN,2021,195.50255 -ID,Indonesia,IDN,2020,624.6789 -IE,Ireland,IRL,2021,381.08688 -IL,Israel,ISR,2020,527.77277 -IN,India,IND,2021,626.0071 -IQ,Iraq,IRQ,2020,419.7325 -IS,Iceland,ISL,2020,28.75471 -IT,Italy,ITA,2021,222.8387 -JM,Jamaica,JAM,2020,532.3383 -JO,Jordan,JOR,2020,432.20337 -JP,Japan,JPN,2021,416.4962 -KE,Kenya,KEN,2021,112.37928 -KG,Kyrgyzstan,KGZ,2020,91.52752 -KH,Cambodia,KHM,2020,423.52942 -KI,Kiribati,KIR,2020,666.6667 -KM,Comoros,COM,2020,692.3078 -KN,Saint Kitts and Nevis,KNA,2020,666.6667 -KW,Kuwait,KWT,2020,438.1219 -KY,Cayman Islands,CYM,2020,681.1594 -KZ,Kazakhstan,KAZ,2021,654.9424 -LB,Lebanon,LBN,2020,544.89166 -LC,Saint Lucia,LCA,2020,696.9697 -LK,Sri Lanka,LKA,2020,439.22418 -LR,Liberia,LBR,2020,292.13483 -LS,Lesotho,LSO,2020,20.0 -LT,Lithuania,LTU,2021,209.03082 -LU,Luxembourg,LUX,2021,0.0 -LV,Latvia,LVA,2021,171.61119 -LY,Libya,LBY,2020,496.51044 -MA,Morocco,MAR,2020,571.06445 -ME,Montenegro,MNE,2021,350.6849 -MG,Madagascar,MDG,2020,452.8302 -MK,North Macedonia,MKD,2021,349.11407 -ML,Mali,MLI,2020,465.625 -MM,Myanmar,MMR,2020,311.10156 -MN,Mongolia,MNG,2021,725.97406 -MO,Macao,MAC,2020,482.14288 -MQ,Martinique,MTQ,2020,653.5948 -MR,Mauritania,MRT,2020,522.7273 -MS,Montserrat,MSR,2020,1000.0 -MT,Malta,MLT,2021,406.50406 -MU,Mauritius,MUS,2020,613.1387 -MV,Maldives,MDV,2020,701.7544 -MW,Malawi,MWI,2020,113.20756 -MX,Mexico,MEX,2021,373.80746 -MY,Malaysia,MYS,2021,541.00055 -MZ,Mozambique,MOZ,2020,129.77527 -NA,Namibia,NAM,2020,56.603775 -NC,New Caledonia,NCL,2020,640.0 -NE,Niger,NER,2020,675.00006 -NG,Nigeria,NGA,2020,395.2415 -NI,Nicaragua,NIC,2020,343.34766 -NL,Netherlands,NLD,2021,328.90408 -NO,Norway,NOR,2021,25.080723 -NP,Nepal,NPL,2020,22.653723 -NR,Nauru,NRU,2020,750.0 -NZ,New Zealand,NZL,2021,135.98497 -OM,Oman,OMN,2020,440.53098 -PA,Panama,PAN,2020,183.39417 -PE,Peru,PER,2021,233.97925 -PF,French Polynesia,PYF,2020,469.69696 -PG,Papua New Guinea,PNG,2020,563.6793 -PH,Philippines,PHL,2021,544.3023 -PK,Pakistan,PAK,2021,295.77448 -PL,Poland,POL,2021,727.7765 -PM,Saint Pierre and Miquelon,SPM,2020,800.0 -PR,Puerto Rico,PRI,2020,664.7727 -PT,Portugal,PRT,2021,181.10257 -PY,Paraguay,PRY,2020,23.915686 -QA,Qatar,QAT,2020,442.76172 -RO,Romania,ROU,2021,252.84236 -RS,Serbia,SRB,2021,545.552 -RW,Rwanda,RWA,2020,289.15662 -SA,Saudi Arabia,SAU,2021,568.50006 -SB,Solomon Islands,SLB,2020,700.0 -SC,Seychelles,SYC,2020,698.1133 -SD,Sudan,SDN,2020,259.31232 -SE,Sweden,SWE,2021,11.770537 -SG,Singapore,SGP,2021,463.89664 -SI,Slovenia,SVN,2021,250.87906 -SK,Slovakia,SVK,2021,100.85782 -SL,Sierra Leone,SLE,2020,47.61905 -SN,Senegal,SEN,2021,534.4203 -SO,Somalia,SOM,2020,648.6486 -SR,Suriname,SUR,2020,298.7013 -SS,South Sudan,SSD,2020,698.1133 -ST,Sao Tome and Principe,STP,2020,600.0 -SV,El Salvador,SLV,2021,245.827 -SZ,Eswatini,SWZ,2020,203.12498 -TC,Turks and Caicos Islands,TCA,2020,720.00006 -TD,Chad,TCD,2020,678.5714 -TG,Togo,TGO,2020,576.92316 -TH,Thailand,THA,2021,503.15494 -TJ,Tajikistan,TJK,2021,83.28969 -TM,Turkmenistan,TKM,2020,412.32855 -TN,Tunisia,TUN,2021,470.38147 -TO,Tonga,TON,2020,666.6667 -TR,Turkey,TUR,2021,429.6388 -TT,Trinidad and Tobago,TTO,2020,497.9688 -UA,Ukraine,UKR,2021,240.50241 -UG,Uganda,UGA,2020,77.0878 -US,United States,USA,2021,357.2021 -UY,Uruguay,URY,2021,152.38716 -UZ,Uzbekistan,UZB,2020,426.88644 -VC,Saint Vincent and the Grenadines,VCT,2020,533.3333 -VU,Vanuatu,VUT,2020,571.4286 -WS,Samoa,WSM,2020,500.0 -YE,Yemen,YEM,2020,538.46155 -ZA,South Africa,ZAF,2021,664.73755 -ZM,Zambia,ZMB,2020,120.77597 -ZW,Zimbabwe,ZWE,2020,279.0279 +ER,Eritrea,ERI,2022,631.5789 +ES,Spain,ESP,2023,174.05005 +ET,Ethiopia,ETH,2022,24.643318 +FI,Finland,FIN,2023,79.158325 +FJ,Fiji,FJI,2022,288.46158 +FO,Faroe Islands,FRO,2022,404.76193 +FR,France,FRA,2023,56.03859 +GA,Gabon,GAB,2022,491.59662 +GB,United Kingdom,GBR,2023,237.58902 +GD,Grenada,GRD,2022,640.0 +GE,Georgia,GEO,2023,167.59389 +GF,French Guiana,GUF,2021,217.82178 +GH,Ghana,GHA,2022,484.00003 +GI,Gibraltar,GIB,2022,600.00006 +GL,Greenland,GRL,2022,178.57143 +GM,Gambia,GMB,2022,666.6667 +GN,Guinea,GIN,2022,236.84212 +GP,Guadeloupe,GLP,2021,500.0 +GQ,Equatorial Guinea,GNQ,2022,591.83673 +GR,Greece,GRC,2023,336.57352 +GT,Guatemala,GTM,2022,328.26752 +GU,Guam,GUM,2022,622.8572 +GW,Guinea-Bissau,GNB,2022,625.0 +GY,Guyana,GUY,2022,640.3509 +HK,Hong Kong,HKG,2022,699.49915 +HN,Honduras,HND,2022,282.26477 +HR,Croatia,HRV,2023,204.96161 +HT,Haiti,HTI,2022,567.3077 +HU,Hungary,HUN,2023,204.18994 +ID,Indonesia,IDN,2022,675.9309 +IE,Ireland,IRL,2023,290.805 +IL,Israel,ISR,2022,582.9271 +IN,India,IND,2023,713.4407 +IQ,Iraq,IRQ,2022,688.81396 +IS,Iceland,ISL,2022,27.679918 +IT,Italy,ITA,2023,330.71823 +JM,Jamaica,JAM,2022,555.55554 +JO,Jordan,JOR,2022,540.92365 +JP,Japan,JPN,2023,485.39236 +KE,Kenya,KEN,2023,70.491806 +KG,Kyrgyzstan,KGZ,2022,147.2924 +KH,Cambodia,KHM,2022,417.70712 +KI,Kiribati,KIR,2022,666.6667 +KM,Comoros,COM,2022,642.8572 +KN,Saint Kitts and Nevis,KNA,2022,636.36365 +KW,Kuwait,KWT,2023,649.1634 +KY,Cayman Islands,CYM,2022,642.8571 +KZ,Kazakhstan,KAZ,2023,821.3909 +LB,Lebanon,LBN,2022,599.005 +LC,Saint Lucia,LCA,2022,666.6666 +LK,Sri Lanka,LKA,2022,509.78134 +LR,Liberia,LBR,2022,227.84811 +LS,Lesotho,LSO,2022,20.0 +LT,Lithuania,LTU,2023,160.07195 +LU,Luxembourg,LUX,2023,105.26315 +LV,Latvia,LVA,2023,123.2 +LY,Libya,LBY,2022,818.6922 +MA,Morocco,MAR,2023,630.01416 +ME,Montenegro,MNE,2023,417.07318 +MG,Madagascar,MDG,2022,436.44064 +MK,North Macedonia,MKD,2023,565.3451 +ML,Mali,MLI,2022,407.99997 +MM,Myanmar,MMR,2023,398.89804 +MN,Mongolia,MNG,2023,775.30865 +MO,Macao,MAC,2022,448.97958 +MQ,Martinique,MTQ,2021,523.17883 +MR,Mauritania,MRT,2022,464.7059 +MS,Montserrat,MSR,2022,1000.0 +MT,Malta,MLT,2023,459.14395 +MU,Mauritius,MUS,2022,632.47864 +MV,Maldives,MDV,2022,611.7647 +MW,Malawi,MWI,2022,66.66667 +MX,Mexico,MEX,2023,507.24512 +MY,Malaysia,MYS,2022,605.83136 +MZ,Mozambique,MOZ,2022,135.64668 +NA,Namibia,NAM,2022,59.25926 +NC,New Caledonia,NCL,2022,660.5839 +NE,Niger,NER,2022,670.88605 +NG,Nigeria,NGA,2023,523.24725 +NI,Nicaragua,NIC,2022,265.11627 +NL,Netherlands,NLD,2023,267.62177 +NO,Norway,NOR,2023,30.080084 +NP,Nepal,NPL,2022,24.43992 +NR,Nauru,NRU,2022,750.0 +NZ,New Zealand,NZL,2023,112.75831 +OM,Oman,OMN,2023,564.63495 +PA,Panama,PAN,2022,161.67665 +PE,Peru,PER,2023,266.47754 +PF,French Polynesia,PYF,2022,442.85715 +PG,Papua New Guinea,PNG,2022,507.24634 +PH,Philippines,PHL,2023,610.68835 +PK,Pakistan,PAK,2023,440.6085 +PL,Poland,POL,2023,661.92584 +PM,Saint Pierre and Miquelon,SPM,2022,600.0 +PR,Puerto Rico,PRI,2022,678.7377 +PT,Portugal,PRT,2023,165.55257 +PY,Paraguay,PRY,2023,23.75506 +QA,Qatar,QAT,2023,602.5005 +RO,Romania,ROU,2023,240.58281 +RS,Serbia,SRB,2023,636.06226 +RW,Rwanda,RWA,2022,316.32654 +SA,Saudi Arabia,SAU,2022,706.7905 +SB,Solomon Islands,SLB,2022,700.0 +SC,Seychelles,SYC,2022,564.5161 +SD,Sudan,SDN,2022,263.15787 +SE,Sweden,SWE,2023,40.694878 +SG,Singapore,SGP,2023,470.7832 +SI,Slovenia,SVN,2023,231.27463 +SK,Slovakia,SVK,2023,116.773544 +SL,Sierra Leone,SLE,2022,50.0 +SN,Senegal,SEN,2022,511.59793 +SO,Somalia,SOM,2022,578.9473 +SR,Suriname,SUR,2022,349.28232 +SS,South Sudan,SSD,2022,629.0322 +ST,Sao Tome and Principe,STP,2022,642.8572 +SV,El Salvador,SLV,2023,271.46817 +SZ,Eswatini,SWZ,2022,172.41379 +TC,Turks and Caicos Islands,TCA,2022,653.8462 +TD,Chad,TCD,2022,628.5714 +TG,Togo,TGO,2022,443.1818 +TH,Thailand,THA,2023,549.5827 +TJ,Tajikistan,TJK,2022,116.85825 +TM,Turkmenistan,TKM,2022,1306.0251 +TN,Tunisia,TUN,2023,563.95624 +TO,Tonga,TON,2022,625.0 +TT,Trinidad and Tobago,TTO,2022,681.5286 +UA,Ukraine,UKR,2022,259.69302 +UG,Uganda,UGA,2022,44.526905 +US,United States,USA,2023,369.47318 +UY,Uruguay,URY,2023,128.78789 +UZ,Uzbekistan,UZB,2022,1167.6029 +VC,Saint Vincent and the Grenadines,VCT,2022,529.4118 +VU,Vanuatu,VUT,2022,571.4286 +WS,Samoa,WSM,2022,473.68423 +YE,Yemen,YEM,2022,566.1016 +ZA,South Africa,ZAF,2023,707.6856 +ZM,Zambia,ZMB,2022,111.96713 +ZW,Zimbabwe,ZWE,2022,297.87234 diff --git a/carbontracker/emissions/intensity/fetchers/carbonintensitygb.py b/carbontracker/emissions/intensity/fetchers/carbonintensitygb.py index b527c2a..8afdb04 100644 --- a/carbontracker/emissions/intensity/fetchers/carbonintensitygb.py +++ b/carbontracker/emissions/intensity/fetchers/carbonintensitygb.py @@ -74,7 +74,7 @@ def _time_from_to_str(self, time_dur): """Returns the current date in UTC (from) and time_dur seconds ahead (to) in ISO8601 format YYYY-MM-DDThh:mmZ.""" date_format = "%Y-%m-%dT%H:%MZ" - time_from = datetime.datetime.utcnow() + time_from = datetime.datetime.now(datetime.timezone.utc) time_to = time_from + datetime.timedelta(seconds=time_dur) from_str = time_from.strftime(date_format) to_str = time_to.strftime(date_format) diff --git a/carbontracker/emissions/intensity/fetchers/electricitymaps.py b/carbontracker/emissions/intensity/fetchers/electricitymaps.py index a654afb..f258601 100644 --- a/carbontracker/emissions/intensity/fetchers/electricitymaps.py +++ b/carbontracker/emissions/intensity/fetchers/electricitymaps.py @@ -3,6 +3,7 @@ from carbontracker import exceptions from carbontracker.emissions.intensity.fetcher import IntensityFetcher from carbontracker.emissions.intensity import intensity +from carbontracker.loggerutil import Logger API_URL = "https://api-access.electricitymaps.com/free-tier/carbon-intensity/latest" @@ -10,12 +11,18 @@ class ElectricityMap(IntensityFetcher): _api_key = None + def __init__(self, logger: Logger): + self.logger = logger + @classmethod def set_api_key(cls, key): cls._api_key = key def suitable(self, g_location): - return self._api_key is not None + has_key = self._api_key is not None + if not has_key: + self.logger.err_warn("ElectricityMaps API key not set. Will default to average carbon intensity.") + return has_key def carbon_intensity(self, g_location, time_dur=None): carbon_intensity = intensity.CarbonIntensity(g_location=g_location) diff --git a/carbontracker/emissions/intensity/fetchers/energidataservice.py b/carbontracker/emissions/intensity/fetchers/energidataservice.py index 5518616..fc04d12 100644 --- a/carbontracker/emissions/intensity/fetchers/energidataservice.py +++ b/carbontracker/emissions/intensity/fetchers/energidataservice.py @@ -26,7 +26,11 @@ def carbon_intensity(self, g_location, time_dur=None): def _emission_current(self): def url_creator(area): - return 'https://api.energidataservice.dk/dataset/CO2emis?filter={"PriceArea":"' + area + '"}' + return ( + 'https://api.energidataservice.dk/dataset/CO2emis?filter={"PriceArea":"' + + area + + '"}' + ) areas = ["DK1", "DK2"] carbon_intensities = [] @@ -41,7 +45,13 @@ def url_creator(area): def _emission_prognosis(self, time_dur): from_str, to_str = self._interval(time_dur=time_dur) - url = "https://api.energidataservice.dk/dataset/CO2Emis?start={" + from_str + "&end={" + to_str + "}&limit=4" + url = ( + "https://api.energidataservice.dk/dataset/CO2Emis?start=" + + from_str + + "&end=" + + to_str + + "&limit=4" + ) response = requests.get(url) if not response.ok: raise exceptions.CarbonIntensityFetcherError(response.json()) @@ -50,14 +60,14 @@ def _emission_prognosis(self, time_dur): return np.mean(carbon_intensities) def _interval(self, time_dur): - from_time = datetime.datetime.utcnow() + from_time = datetime.datetime.now(datetime.timezone.utc) to_time = from_time + datetime.timedelta(seconds=time_dur) from_str = self._nearest_5_min(from_time) to_str = self._nearest_5_min(to_time) return from_str, to_str def _nearest_5_min(self, time): - date_format = "%Y-%m-%d %H:%M" + date_format = "%Y-%m-%dT%H:%M" nearest_5_min = time - datetime.timedelta( minutes=time.minute % 5, seconds=time.second, microseconds=time.microsecond ) diff --git a/carbontracker/emissions/intensity/intensity.py b/carbontracker/emissions/intensity/intensity.py index 3a62883..4ecfe2f 100644 --- a/carbontracker/emissions/intensity/intensity.py +++ b/carbontracker/emissions/intensity/intensity.py @@ -2,9 +2,10 @@ import traceback import geocoder -import importlib.resources import numpy as np import pandas as pd +import sys +from typing import Union from carbontracker import loggerutil from carbontracker import exceptions @@ -12,11 +13,13 @@ from carbontracker.emissions.intensity.fetchers import carbonintensitygb from carbontracker.emissions.intensity.fetchers import energidataservice from carbontracker.emissions.intensity.fetchers import electricitymaps +from carbontracker.emissions.intensity.location import Location + def get_default_intensity(): """Retrieve static default carbon intensity value based on location.""" try: - g_location = geocoder.ip("me") + g_location: Location = geocoder.ip("me") if not g_location.ok: raise exceptions.IPLocationError("Failed to retrieve location based on IP.") address = g_location.address @@ -26,17 +29,34 @@ def get_default_intensity(): country = "Unknown" try: - carbon_intensities_df = pd.read_csv( - str(importlib.resources.files("carbontracker").joinpath("data", "carbon-intensities.csv"))) - intensity_row = carbon_intensities_df[carbon_intensities_df["alpha-2"] == country].iloc[0] - intensity = intensity_row["Carbon intensity of electricity (gCO2/kWh)"] - year = intensity_row["Year"] + # importlib.resources.files was introduced in Python 3.9 + if sys.version_info < (3, 9): + import pkg_resources + + path = pkg_resources.resource_filename( + "carbontracker", "data/carbon-intensities.csv" + ) + else: + import importlib.resources + + path = importlib.resources.files("carbontracker").joinpath( + "data", "carbon-intensities.csv" + ) + carbon_intensities_df = pd.read_csv(str(path)) + intensity_row = carbon_intensities_df[ + carbon_intensities_df["alpha-2"] == country + ].iloc[0] + intensity: float = intensity_row["Carbon intensity of electricity (gCO2/kWh)"] + year: int = intensity_row["Year"] description = f"Defaulted to average carbon intensity for {country} in {year} of {intensity:.2f} gCO2/kWh." except Exception as err: intensity = constants.WORLD_2019_CARBON_INTENSITY description = f"Defaulted to average carbon intensity for world in 2019 of {intensity:.2f} gCO2/kWh." - description = f"Live carbon intensity could not be fetched at detected location: {address}. " + description + description = ( + f"Live carbon intensity could not be fetched at detected location: {address}. " + + description + ) default_intensity = { "carbon_intensity": intensity, "description": description, @@ -50,10 +70,10 @@ def get_default_intensity(): class CarbonIntensity: def __init__( self, - carbon_intensity=None, + carbon_intensity: Union[float, None] = None, g_location=None, address="UNDETECTED", - message=None, + message: Union[str, None] = None, success=False, is_prediction=False, default=False, @@ -78,12 +98,13 @@ def set_default_message(self): self.message = default_intensity["description"] -def carbon_intensity(logger, time_dur=None): - fetchers = [ - electricitymaps.ElectricityMap(), - energidataservice.EnergiDataService(), - carbonintensitygb.CarbonIntensityGB(), - ] +def carbon_intensity(logger, time_dur=None, fetchers=None): + if fetchers is None: + fetchers = [ + electricitymaps.ElectricityMap(logger=logger), + #energidataservice.EnergiDataService(), # UPDATE 2024: EnergiDataService/CarbonIntensityGB has been deprecated + #carbonintensitygb.CarbonIntensityGB(), + ] carbon_intensity = CarbonIntensity(default=True) @@ -119,7 +140,7 @@ def carbon_intensity(logger, time_dur=None): return carbon_intensity -def set_carbon_intensity_message(ci, time_dur): +def set_carbon_intensity_message(ci: CarbonIntensity, time_dur): if ci.is_prediction: if ci.success: ci.message = ( @@ -135,7 +156,10 @@ def set_carbon_intensity_message(ci, time_dur): ) else: if ci.success: - ci.message = f"Current carbon intensity is {ci.carbon_intensity:.2f} gCO2/kWh" + ci.message = ( + f"Current carbon intensity is {ci.carbon_intensity:.2f} gCO2/kWh" + ) else: ci.set_default_message() - ci.message += f" at detected location: {ci.address}." + if ci.message is not None: + ci.message += f" at detected location: {ci.address}." diff --git a/carbontracker/emissions/intensity/location.py b/carbontracker/emissions/intensity/location.py new file mode 100644 index 0000000..21d0319 --- /dev/null +++ b/carbontracker/emissions/intensity/location.py @@ -0,0 +1,6 @@ +class Location: + # geocoder has no type hints, so this class represents the "location" object + def __init__(self, ok: bool, address: str, country: str): + self.ok = ok + self.address = address + self.country = country diff --git a/carbontracker/exceptions.py b/carbontracker/exceptions.py index b822100..4b28893 100644 --- a/carbontracker/exceptions.py +++ b/carbontracker/exceptions.py @@ -58,3 +58,7 @@ class FetcherNameError(Exception): class MismatchedLogFilesError(Exception): pass + + +class MismatchedEpochsError(Exception): + pass diff --git a/carbontracker/loggerutil.py b/carbontracker/loggerutil.py index b8d159d..fd06d38 100644 --- a/carbontracker/loggerutil.py +++ b/carbontracker/loggerutil.py @@ -1,13 +1,15 @@ import logging +from logging import LogRecord import os import sys import pathlib import datetime import importlib_metadata as metadata from carbontracker import constants +from typing import Union -def convert_to_timestring(seconds, add_milliseconds=False): +def convert_to_timestring(seconds: int, add_milliseconds=False) -> str: negative = False if seconds < 0: negative = True @@ -35,14 +37,15 @@ def convert_to_timestring(seconds, add_milliseconds=False): class TrackerFormatter(logging.Formatter): converter = datetime.datetime.fromtimestamp - def formatTime(self, record, datefmt=None): - ct = self.converter(record.created) - if datefmt: - s = ct.strftime(datefmt) - else: - t = ct.strftime("%Y-%m-%d %H:%M:%S") - s = "%s" % t - return s + def formatTime(self, record: LogRecord, datefmt: Union[str, None] = None) -> str: + if record.created: + ct = self.converter(record.created) + if datefmt: + s = ct.strftime(datefmt) + else: + t = ct.strftime("%Y-%m-%d %H:%M:%S") + s = "%s" % t + return s class VerboseFilter(logging.Filter): @@ -55,21 +58,23 @@ def filter(self, record): class Logger: - def __init__(self, log_dir=None, verbose=0, log_prefix=""): + def __init__(self, log_dir=None, verbose=0, log_prefix="", logger_id="root"): self.verbose = verbose - self.logger, self.logger_output, self.logger_err = self._setup(log_dir=log_dir, log_prefix=log_prefix) + self.logger, self.logger_output, self.logger_err = self._setup( + log_dir=log_dir, log_prefix=log_prefix, logger_id=logger_id + ) self._log_initial_info() self.msg_prepend = "CarbonTracker: " - def _setup(self, log_dir=None, log_prefix=""): + def _setup(self, log_dir=None, log_prefix="", logger_id="root"): if log_prefix: log_prefix += "_" - logger_name = f"{log_prefix}{os.getpid()}" + logger_name = f"{log_prefix}{os.getpid()}.{logger_id}" logger = logging.getLogger(logger_name) - logger_err = logging.getLogger("carbontracker.err") - logger_output = logging.getLogger("carbontracker.output") + logger_err = logging.getLogger(f"carbontracker.{logger_id}.err") + logger_output = logging.getLogger(f"carbontracker.{logger_id}.output") logger.propagate = False logger.setLevel(logging.DEBUG) logger_output.propagate = False @@ -86,7 +91,9 @@ def _setup(self, log_dir=None, log_prefix=""): # Add error logging to console. ce = logging.StreamHandler(stream=sys.stdout) - ce_formatter = logging.Formatter("CarbonTracker: {levelname} - {message}", style="{") + ce_formatter = logging.Formatter( + "CarbonTracker: {levelname} - {message}", style="{" + ) ce.setLevel(logging.INFO) ce.setFormatter(ce_formatter) logger_err.addHandler(ce) @@ -103,7 +110,9 @@ def _setup(self, log_dir=None, log_prefix=""): f_formatter = TrackerFormatter(fmt="%(asctime)s - %(message)s") # Add output logging to file. - fh = logging.FileHandler(f"{log_dir}/{logger_name}_{date}_carbontracker_output.log") + fh = logging.FileHandler( + f"{log_dir}/{logger_name}_{date}_carbontracker_output.log" + ) fh.setLevel(logging.INFO) fh.setFormatter(f_formatter) logger_output.addHandler(fh) @@ -115,8 +124,12 @@ def _setup(self, log_dir=None, log_prefix=""): logger.addHandler(f) # Add error logging to file. - err_formatter = logging.Formatter("{asctime} - {threadName} - {levelname} - {message}", style="{") - f_err = logging.FileHandler(f"{log_dir}/{logger_name}_{date}_carbontracker_err.log", delay=True) + err_formatter = logging.Formatter( + "{asctime} - {threadName} - {levelname} - {message}", style="{" + ) + f_err = logging.FileHandler( + f"{log_dir}/{logger_name}_{date}_carbontracker_err.log", delay=True + ) f_err.setLevel(logging.DEBUG) f_err.setFormatter(err_formatter) logger_err.addHandler(f_err) @@ -127,7 +140,7 @@ def _log_initial_info(self): self.info(f"{__package__} version {metadata.version(__package__)}") self.info( "Only predicted and actual consumptions are multiplied by a PUE " - f"coefficient of {constants.PUE_2022} (Rhonda Ascierto, 2022, Uptime " + f"coefficient of {constants.PUE_2023} (Daniel Bizo, 2023, Uptime " "Institute Global Data Center Survey)." ) diff --git a/carbontracker/parser.py b/carbontracker/parser.py index 632c36d..7db1dcd 100644 --- a/carbontracker/parser.py +++ b/carbontracker/parser.py @@ -4,9 +4,28 @@ import numpy as np from carbontracker import exceptions +from typing import Dict, Union, List def parse_all_logs(log_dir): + """ + Parse all logs in directory. + + Args: + log_dir (str): Directory of logs + + Returns: + (dict[]): List of log entries of shape + + { + "output_filename": str, + "standard_filename": str, + "components": dict, # See parse_logs + "early_stop": bool, + "actual": dict | None, # See get_consumption + "pred": dict | None, # See get_consumption + } + """ logs = [] output_logs, std_logs = get_all_logs(log_dir) @@ -33,7 +52,29 @@ def parse_all_logs(log_dir): def parse_logs(log_dir, std_log_file=None, output_log_file=None): - """Parse logs in log_dir (defaults to most recent logs).""" + """ + Parse logs in log_dir (defaults to most recent logs). + + Args: + log_dir (str): Directory of logs + std_log_file (str, optional): Log file to read. Defaults to most recent logs. + output_log_file (str, optional): Deprecated + + Returns: + (dict): Dictionary of shape + + { + [component name]: { + "avg_power_usages (W)": NDArray | None, + "avg_energy_usages (J)": NDArray | None, + "epoch_durations (s)": NDArray | None, + "devices": str[], + } + } + + where `[component name]` is either `"gpu"` or `"cpu"`. + Return value can contain both `"gpu"` and `"cpu"` field. + """ if std_log_file is None or output_log_file is None: std_log_file, output_log_file = get_most_recent_logs(log_dir) @@ -46,11 +87,18 @@ def parse_logs(log_dir, std_log_file=None, output_log_file=None): components = {} for comp, devices in devices.items(): - power_usages = np.array(avg_power_usages[comp]) if len(avg_power_usages) != 0 else None + power_usages = ( + np.array(avg_power_usages[comp]) if len(avg_power_usages) != 0 else None + ) durations = np.array(epoch_durations) if len(epoch_durations) != 0 else None if power_usages is None or durations is None: energy_usages = None else: + if power_usages.size != durations.size: + raise exceptions.MismatchedEpochsError( + f"Found {power_usages.size} power measurements and {durations.size} duration measurements. " + "Expected equal number of measurements." + ) energy_usages = (power_usages.T * durations).T measurements = { "avg_power_usages (W)": power_usages, @@ -63,9 +111,31 @@ def parse_logs(log_dir, std_log_file=None, output_log_file=None): return components -def get_consumption(output_log_data): +def get_consumption(output_log_data: str): + """ + Gets actual and predicted energy consumption, CO2eq and equivalence statements from output_log_data using regular expressions. + + Args: + output_log_data (str): Log data to search through. + + Returns: + actual (dict | None): Actual consumption + + pred (dict | None): Predicted consumption + + Both `actual` and `pred` has the shape: + + { + "epochs": int, + "duration (s)": int, + "energy (kWh)": float | None, + "co2eq (g)": float | None, + "equivalents": equivalents, + } + """ actual_re = re.compile( - r"(?i)Actual consumption for (\d*) epoch\(s\):" + r"(?i)Actual consumption" + r"(?:\s*for\s+\d+\s+epochs)?" r"[\s\S]*?Time:\s*(.*)\n\s*Energy:\s*(.*)\s+kWh" r"[\s\S]*?CO2eq:\s*(.*)\s+g" r"(?:\s*This is equivalent to:\s*([\s\S]*?))?(?=\d{4}-\d{2}-\d{2}|\Z)" @@ -83,16 +153,17 @@ def get_consumption(output_log_data): return actual, pred -def get_early_stop(std_log_data): +def get_early_stop(std_log_data: str) -> bool: early_stop_re = re.compile(r"(?i)Training was interrupted") early_stop = re.findall(early_stop_re, std_log_data) return bool(early_stop) - def extract_measurements(match): if not match: return None match = match.groups() + if len(match) == 4: + match = [1] + list(match) epochs = int(match[0]) duration = get_time(match[1]) energy, co2eq, equivalents = get_stats(match) @@ -106,7 +177,7 @@ def extract_measurements(match): return measurements -def get_time(time_str): +def get_time(time_str: str) -> Union[float, None]: duration_re = re.compile(r"(\d+):(\d{2}):(\d\d?(?:.\d{2})?)") match = re.search(duration_re, time_str) if not match: @@ -117,12 +188,17 @@ def get_time(time_str): def print_aggregate(log_dir): - """Prints the aggregate consumption in all log files in log_dir.""" + """ + Prints the aggregate consumption in all log files in log_dir to stdout. See `get_aggregate`. + + Args: + log_dir (str): Directory of logs + """ energy, co2eq, equivalents = aggregate_consumption(log_dir) - equivalents_p = " or ".join([f"{v:.3f} {k}" for k, v in equivalents.items()]) + equivalents_p = " or ".join([f"{v:.16f} {k}" for k, v in equivalents.items()]) - printable = f"The training of models in this work is estimated to use {energy:.3f} kWh of electricity contributing to {co2eq / 1000:.3f} kg of CO2eq. " + printable = f"The training of models in this work is estimated to use {energy:.16f} kWh of electricity contributing to {co2eq / 1000:.16f} kg of CO2eq. " if equivalents_p: printable += f"This is equivalent to {equivalents_p}. " @@ -132,7 +208,17 @@ def print_aggregate(log_dir): def aggregate_consumption(log_dir): - """Aggregate consumption in all log files in specified log_dir.""" + """ + Aggregate consumption in all log files in specified log_dir. + + Args: + log_dir (str): Directory of logs + + Returns: + total_energy (float): Total energy (kWh) of all logs + total_co2 (float): Total CO2eq (gCO2eq) of all logs + total_equivalents (float): Total energy of all logs + """ output_logs, std_logs = get_all_logs(log_dir=log_dir) total_energy = 0 @@ -150,16 +236,16 @@ def aggregate_consumption(log_dir): if actual is None and pred is None: continue - elif actual is None: + elif actual is None and pred is not None: energy = pred["energy (kWh)"] co2eq = pred["co2eq (g)"] equivalents = pred["equivalents"] - elif pred is None: + elif pred is None and actual is not None: energy = actual["energy (kWh)"] co2eq = actual["co2eq (g)"] equivalents = actual["equivalents"] # Both actual and pred is available - else: + elif pred is not None and actual is not None: actual_epochs = actual["epochs"] pred_epochs = pred["epochs"] if early_stop or actual_epochs == pred_epochs: @@ -170,6 +256,8 @@ def aggregate_consumption(log_dir): energy = pred["energy (kWh)"] co2eq = pred["co2eq (g)"] equivalents = pred["equivalents"] + else: + continue # unreachable case total_energy += energy if not np.isnan(co2eq): @@ -200,17 +288,32 @@ def parse_equivalents(lines): try: equivalents[tup[1].strip()] = float(tup[0].strip()) except ValueError as e: - print(f"Warning: Unable to convert '{tup[0]}' to float. Skipping this equivalent.") + print( + f"Warning: Unable to convert '{tup[0]}' to float. Skipping this equivalent." + ) continue return equivalents def get_all_logs(log_dir): - """Get all output and standard logs in log_dir.""" + """ + Get all output and standard logs in log_dir. + + Args: + log_dir (str): Directory of logs + + Returns: + std_logs (list[str]): List of file names of standard logs + output_logs (list[str]): List of file names of output logs + + Raises: + MismatchedLogFilesError: Thrown if there exists standard log files that cannot be matched with an output log file or vice versa. + """ files = [ os.path.join(log_dir, f) for f in os.listdir(log_dir) - if os.path.isfile(os.path.join(log_dir, f)) and os.path.getsize(os.path.join(log_dir, f)) > 0 + if os.path.isfile(os.path.join(log_dir, f)) + and os.path.getsize(os.path.join(log_dir, f)) > 0 ] output_re = re.compile(r".*carbontracker_output.log") std_re = re.compile(r".*carbontracker.log") @@ -218,15 +321,15 @@ def get_all_logs(log_dir): std_logs = sorted(list(filter(std_re.match, files))) if len(output_logs) != len(std_logs): # Try to remove the files with no matching output/std logs - op_fn = [f.split("_")[0] for f in output_logs] - std_fn = [f.split("_")[0] for f in std_logs] + op_fn = [f.split("_carbontracker")[0] for f in output_logs] + std_fn = [f.split("_carbontracker")[0] for f in std_logs] if len(std_logs) > len(output_logs): missing_logs = list(set(std_fn) - set(op_fn)) [std_logs.remove(f + "_carbontracker.log") for f in missing_logs] else: missing_logs = list(set(op_fn) - set(std_fn)) - [output_logs.remove(f + "carbontracker_output.log") for f in missing_logs] - ### Even after removel if then there is a mismatch, then throw the error + [output_logs.remove(f + "_carbontracker_output.log") for f in missing_logs] + ### Even after removal if then there is a mismatch, then throw the error if len(output_logs) != len(std_logs): raise exceptions.MismatchedLogFilesError( f"Found {len(output_logs)} output logs and {len(std_logs)} " @@ -235,8 +338,22 @@ def get_all_logs(log_dir): return output_logs, std_logs -def get_devices(std_log_data): - """Retrieve dictionary of components with their device(s).""" +def get_devices(std_log_data: str) -> Dict[str, List[str]]: + """ + Retrieve dictionary of components with their device(s). + + Args: + std_log_data (str): Log data to parse + + Returns: + (dict): Dictionary with devices per component of shape + + { + [component]: ["device1", "device2"] + } + + Where `[component]` is the component name and `"device1"`, `"device2"` are device names. + """ comp_re = re.compile(r"The following components were found:(.*)\n") device_re = re.compile(r" (.*?) with device\(s\) (.*?)\.") # Take first match as we only expect one. @@ -254,22 +371,43 @@ def get_devices(std_log_data): def get_epoch_durations(std_log_data): - """Retrieve epoch durations (s).""" + """ + Retrieve epoch durations (s). + + Args: + std_log_data (str): Log to parse + + Returns: + (list[float]): List of epoch durations (s) + """ duration_re = re.compile(r"Duration: (\d+):(\d{2}):(\d\d?(?:.\d{2})?)") matches = re.findall(duration_re, std_log_data) - epoch_durations = [float(h) * 60 * 60 + float(m) * 60 + float(s) for h, m, s in matches] + epoch_durations = [ + float(h) * 60 * 60 + float(m) * 60 + float(s) for h, m, s in matches + ] return epoch_durations def get_avg_power_usages(std_log_data): - """Retrieve average power usages for each epoch (W).""" - power_re = re.compile(r"Average power usage \(W\) for (.+): (\[.+\]|None)") + """ + Retrieve average power usages for each epoch (W). + + Args: + std_log_data (str): Log to parse + + Returns: + (dict): Dictionary containing list of average power usages for each epoch per component. Has shape: + { + [component name]: list[list[float]] + } + """ + power_re = re.compile(r"Average power usage \(W\) for (.+): (\[?[0-9\.]+\]?|None)") matches = re.findall(power_re, std_log_data) components = list(set([comp for comp, _ in matches])) avg_power_usages = {} for component in components: - powers = [] + powers: list[list[float]] = [] for comp, power in matches: if power == "None": powers.append([0.0]) @@ -284,9 +422,22 @@ def get_avg_power_usages(std_log_data): def get_most_recent_logs(log_dir): - """Retrieve the file names of the most recent standard and output logs.""" + """ + Retrieve the file names of the most recent standard and output logs. + + Args: + log_dir (str): Directory of logs + + Returns: + std_log (str): File name of latest standard log + output_log (str): File name of latest output log + """ # Get all files in log_dir. - files = [os.path.join(log_dir, f) for f in os.listdir(log_dir) if os.path.isfile(os.path.join(log_dir, f))] + files = [ + os.path.join(log_dir, f) + for f in os.listdir(log_dir) + if os.path.isfile(os.path.join(log_dir, f)) + ] # Find output and standard logs and sort by modified date. output_re = re.compile(r".*carbontracker_output.log") std_re = re.compile(r".*carbontracker.log") diff --git a/carbontracker/tracker.py b/carbontracker/tracker.py index 717d999..a6b1d2d 100644 --- a/carbontracker/tracker.py +++ b/carbontracker/tracker.py @@ -5,14 +5,17 @@ import psutil import math from threading import Thread, Event +from typing import List, Union import numpy as np +from random import randint from carbontracker import constants from carbontracker import loggerutil from carbontracker import predictor from carbontracker import exceptions from carbontracker.components import component +from carbontracker.components.component import Component from carbontracker.emissions.intensity import intensity from carbontracker.emissions.conversion import co2eq from carbontracker.emissions.intensity.fetchers import electricitymaps @@ -21,11 +24,11 @@ class CarbonIntensityThread(Thread): """Sleeper thread to update Carbon Intensity every 15 minutes.""" - def __init__(self, logger, stop_event, update_interval=900): + def __init__(self, logger, stop_event, update_interval: Union[float, int] = 900): super(CarbonIntensityThread, self).__init__() self.name = "CarbonIntensityThread" self.logger = logger - self.update_interval = update_interval + self.update_interval: Union[float, int] = update_interval self.daemon = True self.stop_event = stop_event self.carbon_intensities = [] @@ -43,12 +46,18 @@ def run(self): def _fetch_carbon_intensity(self): ci = intensity.carbon_intensity(self.logger) - if ci.success and isinstance(ci.carbon_intensity, (int, float)) and not np.isnan(ci.carbon_intensity): + if ( + ci.success + and isinstance(ci.carbon_intensity, (int, float)) + and not np.isnan(ci.carbon_intensity) + ): self.carbon_intensities.append(ci) def predict_carbon_intensity(self, pred_time_dur): ci = intensity.carbon_intensity(self.logger, time_dur=pred_time_dur) - weighted_intensities = [ci.carbon_intensity for ci in self.carbon_intensities] + [ci.carbon_intensity] + weighted_intensities = [ + ci.carbon_intensity for ci in self.carbon_intensities + ] + [ci.carbon_intensity] # Account for measured intensities by taking weighted average. weight = math.floor(pred_time_dur / self.update_interval) @@ -79,7 +88,9 @@ def average_carbon_intensity(self): f"Average carbon intensity during training was {avg_intensity:.2f}" f" gCO2/kWh at detected location: {location}." ) - avg_ci = intensity.CarbonIntensity(carbon_intensity=avg_intensity, message=msg, success=True) + avg_ci = intensity.CarbonIntensity( + carbon_intensity=avg_intensity, message=msg, success=True + ) self.logger.info( "Carbon intensities (gCO2/kWh) fetched every " @@ -95,7 +106,14 @@ def average_carbon_intensity(self): class CarbonTrackerThread(Thread): """Thread to fetch consumptions""" - def __init__(self, components, logger, ignore_errors, delete, update_interval=10): + def __init__( + self, + components: List[Component], + logger, + ignore_errors, + delete, + update_interval: Union[int, float] = 1, + ): super(CarbonTrackerThread, self).__init__() self.cur_epoch_time = time.time() self.name = "CarbonTrackerThread" @@ -142,7 +160,6 @@ def stop(self): self.logger.info("Monitoring thread ended.") self.logger.output("Finished monitoring.", verbose_level=1) - def epoch_start(self): self.epoch_counter += 1 self.cur_epoch_time = time.time() @@ -166,7 +183,9 @@ def _log_components_info(self): def _log_epoch_measurements(self): self.logger.info(f"Epoch {self.epoch_counter}:") duration = self.epoch_times[-1] - self.logger.info(f"Duration: {loggerutil.convert_to_timestring(duration, True)}") + self.logger.info( + f"Duration: {loggerutil.convert_to_timestring(duration, True)}" + ) for comp in self.components: if comp.power_usages and comp.power_usages[-1]: power_avg = np.mean(comp.power_usages[-1], axis=0) @@ -175,9 +194,15 @@ def _log_epoch_measurements(self): # previous measurement. # TODO: Use semaphores to wait for measurement to finish. if np.isnan(power_avg).all(): - power_avg = np.mean(comp.power_usages[-2], axis=0) if len(comp.power_usages) >= 2 else None + power_avg = ( + np.mean(comp.power_usages[-2], axis=0) + if len(comp.power_usages) >= 2 + else None + ) else: - self.logger.err_warn("Epoch duration is too short for a measurement to be " "collected.") + self.logger.err_warn( + "Epoch duration is too short for a measurement to be " "collected." + ) power_avg = None self.logger.info(f"Average power usage (W) for {comp.name}: {power_avg}") @@ -207,12 +232,14 @@ def total_energy_per_epoch(self): for comp in self.components: energy_usage = comp.energy_usage(self.epoch_times) total_energy += energy_usage - return total_energy * constants.PUE_2022 + return total_energy * constants.PUE_2023 def _handle_error(self, error): err_str = traceback.format_exc() if self.ignore_errors: - err_str = f"Ignored error: {err_str}Continued training without " "monitoring..." + err_str = ( + f"Ignored error: {err_str}Continued training without " "monitoring..." + ) self.logger.err_critical(err_str) self.logger.output(err_str) @@ -225,12 +252,52 @@ def _handle_error(self, error): class CarbonTracker: + """ + + The CarbonTracker class is the main interface for starting, stopping and reporting through **carbontracker**. + + Args: + epochs (int): Total epochs of your training loop. + api_keys (dict, optional): Dictionary of Carbon Intensity API keys following the {name:key} format. Can also be set using `CarbonTracker.set_api_keys` + + Example: `{ \\"electricitymaps\\": \\"abcdefg\\" }` + epochs_before_pred (int, optional): Epochs to monitor before outputting predicted consumption. Set to -1 for all epochs. Set to 0 for no prediction. + monitor_epochs (int, optional): Total number of epochs to monitor. Outputs actual consumption when reached. Set to -1 for all epochs. Cannot be less than `epochs_before_pred` or equal to 0. + update_interval (int, optional): Interval in seconds between power usage measurements are taken by sleeper thread. + interpretable (bool, optional): If set to `True` then the CO2eq are also converted to interpretable numbers such as the equivalent distance travelled in a car, etc. Otherwise, no conversions are done. + stop_and_confirm (bool, optional): If set to `True` then the main thread (with your training loop) is paused after epochs_before_pred epochs to output the prediction and the user will need to confirm to continue training. Otherwise, prediction is output and training is continued instantly. + ignore_errors (bool, optional): If set to `True` then all errors will cause energy monitoring to be stopped and training will continue. Otherwise, training will be interrupted as with regular errors. + components (str, optional): Comma-separated string of which components to monitor. Options are: `"all"`, `"gpu"`, `"cpu"`, or `"gpu,cpu"`. + devices_by_pid (bool, optional): If `True`, only devices (under the chosen components) running processes associated with the main process are measured. If False, all available devices are measured. Note that this requires your devices to have active processes before instantiating the CarbonTracker class. + log_dir (str, optional): Path to the desired directory to write log files. If `None`, then no logging will be done. + log_file_prefix (str, optional): Prefix to add to the log file name. + verbose (int, optional): Sets the level of verbosity. + decimal_precision (int, optional): Desired decimal precision of reported values. + + Example: + Tracking the carbon intensity of PyTorch model training: + + from carbontracker.tracker import CarbonTracker + + tracker = CarbonTracker(epochs=max_epochs) + # Training loop. + for epoch in range(max_epochs): + tracker.epoch_start() + # Your model training. + tracker.epoch_end() + + # Optional: Add a stop in case of early termination before all monitor_epochs has + # been monitored to ensure that actual consumption is reported. + tracker.stop() + + """ + def __init__( self, epochs, epochs_before_pred=1, - monitor_epochs=1, - update_interval=10, + monitor_epochs=-1, + update_interval=1, interpretable=True, stop_and_confirm=False, ignore_errors=False, @@ -246,7 +313,9 @@ def __init__( self.set_api_keys(api_keys) self.epochs = epochs - self.epochs_before_pred = epochs if epochs_before_pred < 0 else epochs_before_pred + self.epochs_before_pred = ( + epochs if epochs_before_pred < 0 else epochs_before_pred + ) self.monitor_epochs = epochs if monitor_epochs < 0 else monitor_epochs if self.monitor_epochs == 0 or self.monitor_epochs < self.epochs_before_pred: raise ValueError( @@ -262,20 +331,32 @@ def __init__( try: pids = self._get_pids() - self.logger = loggerutil.Logger(log_dir=log_dir, verbose=verbose, log_prefix=log_file_prefix) + self.logger = loggerutil.Logger( + log_dir=log_dir, + verbose=verbose, + log_prefix=log_file_prefix, + logger_id=str(randint(1, 999999)), + ) self.tracker = CarbonTrackerThread( delete=self._delete, - components=component.create_components(components=components, pids=pids, devices_by_pid=devices_by_pid), + components=component.create_components( + components=components, pids=pids, devices_by_pid=devices_by_pid + ), logger=self.logger, ignore_errors=ignore_errors, update_interval=update_interval, ) self.intensity_stopper = Event() - self.intensity_updater = CarbonIntensityThread(self.logger, self.intensity_stopper) + self.intensity_updater = CarbonIntensityThread( + self.logger, self.intensity_stopper + ) except Exception as e: self._handle_error(e) def epoch_start(self): + """ + Starts tracking energy consumption for current epoch. Call in the beginning of training loop. + """ if self.deleted: return @@ -286,20 +367,23 @@ def epoch_start(self): self._handle_error(e) def epoch_end(self): + """ + Ends tracking energy consumption for current epoch. Call in the end of training loop. + """ if self.deleted: return try: self.tracker.epoch_end() - if self.epoch_counter == self.monitor_epochs: - self._output_actual() - if self.epoch_counter == self.epochs_before_pred: self._output_pred() if self.stop_and_confirm: self._user_query() + if self.epoch_counter == self.monitor_epochs: + self._output_actual() + if self.epoch_counter == self.monitor_epochs: self._delete() except Exception as e: @@ -310,7 +394,10 @@ def stop(self): stopping, where not all monitor_epochs have been run.""" if self.deleted: return - self.logger.info(f"Training was interrupted before all {self.monitor_epochs} epochs" " were monitored.") + self.logger.info( + f"Training was interrupted before all {self.monitor_epochs} epochs" + " were monitored." + ) # Decrement epoch_counter with 1 since measurements for ultimate epoch # was interrupted and is not accounted for. self.epoch_counter -= 1 @@ -324,14 +411,18 @@ def set_api_keys(self, api_dict): if name.lower() == "electricitymaps": electricitymaps.ElectricityMap.set_api_key(key) else: - raise exceptions.FetcherNameError(f"Invalid API name '{name}' given.") + raise exceptions.FetcherNameError( + f"Invalid API name '{name}' given." + ) except Exception as e: self._handle_error(e) def _handle_error(self, error): err_str = traceback.format_exc() if self.ignore_errors: - err_str = f"Ignored error: {err_str}Continued training without " "monitoring..." + err_str = ( + f"Ignored error: {err_str}Continued training without " "monitoring..." + ) self.logger.err_critical(err_str) self.logger.output(err_str) @@ -368,9 +459,17 @@ def _output_actual(self): _co2eq = self._co2eq(energy) conversions = co2eq.convert(_co2eq) if self.interpretable else None if self.epochs_before_pred == 0: - self._output_energy("Actual consumption:", time, energy, _co2eq, conversions) + self._output_energy( + "Actual consumption:", time, energy, _co2eq, conversions + ) else: - self._output_energy(f"Actual consumption for {self.epoch_counter} epoch(s):", time, energy, _co2eq, conversions) + self._output_energy( + f"Actual consumption for {self.epoch_counter} epoch(s):", + time, + energy, + _co2eq, + conversions, + ) def _output_pred(self): """Output predicted usage for full training epochs.""" @@ -382,7 +481,11 @@ def _output_pred(self): conversions = co2eq.convert(pred_co2eq) if self.interpretable else None self._output_energy( - f"Predicted consumption for {self.epochs} epoch(s):", pred_time, pred_energy, pred_co2eq, conversions + f"Predicted consumption for {self.epochs} epoch(s):", + pred_time, + pred_energy, + pred_co2eq, + conversions, ) def _co2eq(self, energy_usage, pred_time_dur=None): @@ -399,7 +502,7 @@ def _user_query(self): user_input = input().lower() self._check_input(user_input) - def _check_input(self, user_input): + def _check_input(self, user_input: str): if user_input == "y": self.logger.output("Continuing...") return @@ -421,7 +524,7 @@ def _delete(self): del self.intensity_stopper self.deleted = True - def _get_pids(self): + def _get_pids(self) -> List[int]: """Get current process id and all children process ids.""" process = psutil.Process() pids = [process.pid] + [child.pid for child in process.children(recursive=True)] diff --git a/docs/documentation/CLI.md b/docs/documentation/CLI.md new file mode 100644 index 0000000..a9e125e --- /dev/null +++ b/docs/documentation/CLI.md @@ -0,0 +1,32 @@ +# CLI +::: carbontracker.cli.main + +### Usage + +To start tracking, simply run the following command: + +```bash +carbontracker --log_dir --api_keys +``` +For example: +```bash +carbontracker python train_resnet.py --epochs 100 --step_size 1 --log_dir ./logs --api_keys '{"electricitymaps": "YOUR_KEY_HERE"}' +``` + +### Arguments + +- `--log_dir`: Specifies the directory where CarbonTracker will save the logs. This is useful for keeping a record of your runs and for later analysis. +- `--api_keys`: API key(s) for external services used by CarbonTracker to retrieve real-time carbon intensity data. Currently, [Electricity Maps](https://www.electricitymaps.com/) is supported + +### Additional Options +Log Parsing: If you've previously run CarbonTracker and saved the logs, you can parse and aggregate the data for analysis. Use the following command to aggregate logs from a specific directory: +```bash +carbontracker --parse +``` +For example: + +```bash +carbontracker --parse ./logs +``` + + diff --git a/docs/documentation/CarbonTracker.md b/docs/documentation/CarbonTracker.md new file mode 100644 index 0000000..dd55f04 --- /dev/null +++ b/docs/documentation/CarbonTracker.md @@ -0,0 +1,2 @@ +# CarbonTracker +::: carbontracker.tracker.CarbonTracker diff --git a/docs/documentation/Log Parsing.md b/docs/documentation/Log Parsing.md new file mode 100644 index 0000000..53681fd --- /dev/null +++ b/docs/documentation/Log Parsing.md @@ -0,0 +1,4 @@ +# Log Parsing + +Carbontracker contains utilities for parsing and interacting with the generated log files for futher analysis and reporting. +::: carbontracker.parser diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..70db57f --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,66 @@ +# Getting started +## Installation +[**carbontracker** is available on PyPI](https://pypi.org/project/carbontracker/) and can be installed using pip: +~~~bash +pip install carbontracker +~~~ + +To get accurate measurements for applications using GPUs, please make sure [NVML](https://developer.nvidia.com/nvidia-management-library-nvml) is installed. + +## Example usage (CLI) +[See documentation for list of CLI options](documentation/CLI.md). + +~~~bash +$ carbontracker python script.py +~~~ + +Example output: +``` +CarbonTracker: The following components were found: CPU with device(s) cpu:0. +CarbonTracker: Average carbon intensity during training was 151.50 gCO2/kWh at detected location: Copenhagen, Capital Region, DK. +CarbonTracker: +Actual consumption: + Time: 0:00:24 + Energy: 0.000286936393 kWh + CO2eq: 0.043470863590 g + This is equivalent to: + 0.000404380126 km travelled by car +CarbonTracker: Finished monitoring. +``` + +## Example usage (Python) +[See documentation for CarbonTracker class](documentation/CarbonTracker.md). + +~~~python +from carbontracker.tracker import CarbonTracker + +tracker = CarbonTracker(epochs=max_epochs) +# Training loop. +for epoch in range(max_epochs): + tracker.epoch_start() + # Your model training. + tracker.epoch_end() + +# Optional: Add a stop in case of early termination before all monitor_epochs has +# been monitored to ensure that actual consumption is reported. +tracker.stop() +~~~ + +Example output: +~~~ +CarbonTracker: +Actual consumption for 1 epoch(s): + Time: 0:00:10 + Energy: 0.000038 kWh + CO2eq: 0.003130 g + This is equivalent to: + 0.000026 km travelled by car +CarbonTracker: +Predicted consumption for 1000 epoch(s): + Time: 2:52:22 + Energy: 0.038168 kWh + CO2eq: 4.096665 g + This is equivalent to: + 0.034025 km travelled by car +CarbonTracker: Finished monitoring. +~~~ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..41e2896 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,57 @@ +# About + +**carbontracker** is a tool for tracking and predicting the energy consumption and carbon footprint of training deep learning models as described in [Anthony et al. (2020)](https://arxiv.org/abs/2007.03051). +It is available both as a CLI and as a Python module for easy implementation into existing code. + +See [Getting started](/getting-started) for how to get started. + +See [CLI](documentation/CLI.md) for CLI options. + +## Compatible components +**carbontracker** supports the following components: + +- Intel CPUs that support [Intel RAPL](http://web.eece.maine.edu/~vweaver/projects/rapl/rapl_support.html) on Linux. [Note on how to enable permissions](/#permissions) +- NVIDIA GPUs that support [NVIDIA Management Library (NVML)](Intel CPUs that support Intel RAPL) on Linux +- Apple Silicon on MacOS + +## Permissions +To be able to read the power consumption from Intel CPUs, **carbontracker** needs read access to the `/sys/class/powercap/intel-rapl:0/energy_uj` file. This can be done like so using `chmod`: +~~~bash +sudo chmod +r /sys/class/powercap/intel-rapl:0/energy_uj +~~~ +Note that these changes are not persistent. To make persistent changes, one can add a `udev` rule like so: +~~~bash +# /etc/udev/rules.d/powercap.rules +ACTION=="add|change", SUBSYSTEM=="powercap", KERNEL=="intel-rapl:*", RUN+="/bin/chmod og+r %S%p/energy_uj" +~~~ +Then one can immediately apply the permission changes: +~~~bash +sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=powercap +~~~ +### Disabling CPU monitoring +If you do not have such access and only wish to monitor GPU power consumption, one can disable CPU access using the `components` parameter: +~~~python +tracker = CarbonTracker( + epochs=args.num_epochs, + components="gpu", # Exclude CPU from components to monitor + log_dir='carbontracker/', + monitor_epochs=-1 + ) +~~~ + +## Running **carbontracker** on HPC clusters and in containers + +- Available GPU devices are determined by first checking the environment variable `CUDA_VISIBLE_DEVICES` (only if `devices_by_pid=False`, otherwise devices are found by PID). +This ensures that for Slurm we only fetch GPU devices associated with the current job and not the entire cluster. +If this fails we measure all available GPUs. + +- NVML cannot find processes for containers spawned without `--pid=host`. This affects the `device_by_pid` parameter and means that it will never find any active processes for GPUs in affected containers. + +## Running **carbontracker** on Apple Silicon + +- **carbontracker** is compatible with Apple Silicon on MacOS using `powermetrics` to get power consumption data. +- `powermetrics` requires root access to run. This can be done by adding `your_username ALL=(ALL) NOPASSWD: /usr/bin/powermetrics` to `/etc/sudoers` (replace `your_username` with your actual username): +``` +echo "your_username ALL=(ALL) NOPASSWD: /usr/bin/powermetrics" | sudo tee -a /etc/sudoers +``` +- Alternatively, one can run **carbontracker** with root privileges. \ No newline at end of file diff --git a/docs/parsing.md b/docs/parsing.md new file mode 100644 index 0000000..caa3754 --- /dev/null +++ b/docs/parsing.md @@ -0,0 +1,40 @@ +# Aggregating log files +**carbontracker** supports aggregating all log files in a specified directory to a single estimate of the carbon footprint. + + +#### Example usage +```python +from carbontracker import parser + +parser.print_aggregate(log_dir="./my_log_directory/") +``` +#### Example output +``` +The training of models in this work is estimated to use 4.494 kWh of electricity contributing to 0.423 kg of CO2eq. This is equivalent to 3.515 km travelled by car. Measured by carbontracker (https://github.com/lfwa/carbontracker). +``` + +### Convert logs to dictionary objects +Log files can be parsed into dictionaries using `parser.parse_all_logs()` or `parser.parse_logs()`. +#### Example usage +```python +from carbontracker import parser + +logs = parser.parse_all_logs(log_dir="./logs/") +first_log = logs[0] + +print(f"Output file name: {first_log['output_filename']}") +print(f"Standard file name: {first_log['standard_filename']}") +print(f"Stopped early: {first_log['early_stop']}") +print(f"Measured consumption: {first_log['actual']}") +print(f"Predicted consumption: {first_log['pred']}") +print(f"Measured GPU devices: {first_log['components']['gpu']['devices']}") +``` +#### Example output +``` +Output file name: ./logs/2020-05-17T19:02Z_carbontracker_output.log +Standard file name: ./logs/2020-05-17T19:02Z_carbontracker.log +Stopped early: False +Measured consumption: {'epochs': 1, 'duration (s)': 8.0, 'energy (kWh)': 6.5e-05, 'co2eq (g)': 0.019201, 'equivalents': {'km travelled by car': 0.000159}} +Predicted consumption: {'epochs': 3, 'duration (s)': 25.0, 'energy (kWh)': 1000.000196, 'co2eq (g)': 10000.057604, 'equivalents': {'km travelled by car': 10000.000478}} +Measured GPU devices: ['Tesla T4'] +``` diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..19bc957 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,5 @@ +site_name: Carbontracker +theme: readthedocs +plugins: + - mkdocstrings + diff --git a/pyproject.toml b/pyproject.toml index 74edc25..2e07fa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,15 @@ dynamic = ["version"] homepage = "https://github.com/lfwa/carbontracker" repository = "https://github.com/lfwa/carbontracker" +[tool.setuptools] +packages = ['carbontracker'] + [tool.setuptools_scm] +[project.optional-dependencies] +test = ["pyfakefs"] +docs = ["mkdocs", "mkdocstrings[python]"] + [project.scripts] carbontracker = "carbontracker.cli:main" diff --git a/tests/components/test_apple_silicon.py b/tests/components/test_apple_silicon.py index 7b18420..ef889fe 100644 --- a/tests/components/test_apple_silicon.py +++ b/tests/components/test_apple_silicon.py @@ -1,75 +1,90 @@ import unittest from unittest.mock import patch -from carbontracker.components.apple_silicon.powermetrics import AppleSiliconCPU, AppleSiliconGPU, PowerMetricsUnified +from carbontracker.components.apple_silicon.powermetrics import ( + AppleSiliconCPU, + AppleSiliconGPU, + PowerMetricsUnified, +) class TestAppleSiliconCPU(unittest.TestCase): def setUp(self): - self.cpu_handler = AppleSiliconCPU(pids=[], devices_by_pid={}) + self.cpu_handler = AppleSiliconCPU(pids=[], devices_by_pid=False) self.cpu_handler.init() def test_shutdown(self): self.cpu_handler.shutdown() - @patch('platform.system', return_value="Darwin") + @patch("platform.system", return_value="Darwin") def test_available_darwin(self, mock_platform): self.assertTrue(self.cpu_handler.available()) - @patch('platform.system', return_value="AlienOS") + @patch("platform.system", return_value="AlienOS") def test_available_not_darwin(self, mock_platform): self.assertFalse(self.cpu_handler.available()) def test_devices(self): self.assertEqual(self.cpu_handler.devices(), ["CPU"]) - @patch('carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output', - return_value="CPU Power: 1000 mW") + @patch( + "carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output", + return_value="CPU Power: 1000 mW", + ) def test_power_usage_with_match(self, mock_get_output): - self.assertEqual(self.cpu_handler.power_usage(), 1.0) + self.assertEqual(self.cpu_handler.power_usage(), [1.0]) - @patch('carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output', - return_value="No CPU Power data") + @patch( + "carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output", + return_value="No CPU Power data", + ) def test_power_usage_no_match(self, mock_get_output): - self.assertEqual(self.cpu_handler.power_usage(), 0.0) + self.assertEqual(self.cpu_handler.power_usage(), [0.0]) class TestAppleSiliconGPU(unittest.TestCase): def setUp(self): - self.gpu_handler = AppleSiliconGPU(pids=[], devices_by_pid={}) + self.gpu_handler = AppleSiliconGPU(pids=[], devices_by_pid=False) self.gpu_handler.init() - @patch('platform.system', return_value="Darwin") + @patch("platform.system", return_value="Darwin") def test_available_darwin(self, mock_platform): self.assertTrue(self.gpu_handler.available()) - @patch('platform.system', return_value="Windows") + @patch("platform.system", return_value="Windows") def test_available_not_darwin(self, mock_platform): self.assertFalse(self.gpu_handler.available()) def test_devices(self): self.assertEqual(self.gpu_handler.devices(), ["GPU", "ANE"]) - @patch('carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output', - return_value="GPU Power: 500 mW\nANE Power: 300 mW") + @patch( + "carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output", + return_value="GPU Power: 500 mW\nANE Power: 300 mW", + ) def test_power_usage_with_match(self, mock_get_output): - self.assertAlmostEqual(self.gpu_handler.power_usage(), 0.8, places=2) + self.assertEqual(len(self.gpu_handler.power_usage()), 1) + self.assertAlmostEqual(self.gpu_handler.power_usage()[0], 0.8, places=2) - @patch('carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output', - return_value="No GPU Power data") + @patch( + "carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output", + return_value="No GPU Power data", + ) def test_power_usage_no_match(self, mock_get_output): - self.assertEqual(self.gpu_handler.power_usage(), 0.0) + self.assertEqual(self.gpu_handler.power_usage(), [0.0]) class TestPowerMetricsUnified(unittest.TestCase): - @patch('subprocess.check_output', return_value="Sample Output") - @patch('time.time', side_effect=[100, 101, 102, 200, 202]) + @patch("subprocess.check_output", return_value="Sample Output") + @patch("time.time", side_effect=[100, 101, 102, 200, 202]) def test_get_output_with_actual_call(self, mock_time, mock_check_output): # First call - should call subprocess output1 = PowerMetricsUnified.get_output() # Second call - should use cached output output2 = PowerMetricsUnified.get_output() - + self.assertIsNotNone(PowerMetricsUnified._last_updated) + if PowerMetricsUnified._last_updated is None: + self.fail() # Advance time to invalidate cache PowerMetricsUnified._last_updated -= 2 @@ -84,19 +99,24 @@ def test_get_output_with_actual_call(self, mock_time, mock_check_output): class TestAppleSiliconGPUPowerUsage(unittest.TestCase): def setUp(self): - self.gpu_handler = AppleSiliconGPU(pids=[], devices_by_pid={}) + self.gpu_handler = AppleSiliconGPU(pids=[], devices_by_pid=False) self.gpu_handler.init() - @patch('carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output', - return_value="GPU Power: 500 mW\nANE Power: 300 mW") + @patch( + "carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output", + return_value="GPU Power: 500 mW\nANE Power: 300 mW", + ) def test_power_usage_with_match(self, mock_get_output): - self.assertAlmostEqual(self.gpu_handler.power_usage(), 0.8, places=2) + self.assertEqual(len(self.gpu_handler.power_usage()), 1) + self.assertAlmostEqual(self.gpu_handler.power_usage()[0], 0.8, places=2) - @patch('carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output', - return_value="No GPU Power data") + @patch( + "carbontracker.components.apple_silicon.powermetrics.PowerMetricsUnified.get_output", + return_value="No GPU Power data", + ) def test_power_usage_no_match(self, mock_get_output): - self.assertEqual(self.gpu_handler.power_usage(), 0.0) + self.assertEqual(self.gpu_handler.power_usage(), [0.0]) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/components/test_nvidia.py b/tests/components/test_nvidia.py index 4138a03..d592234 100644 --- a/tests/components/test_nvidia.py +++ b/tests/components/test_nvidia.py @@ -5,6 +5,7 @@ from carbontracker import exceptions from carbontracker.components.gpu.nvidia import NvidiaGPU + class PynvmlStub: @staticmethod def nvmlInit(): @@ -12,7 +13,8 @@ def nvmlInit(): @staticmethod def nvmlShutdown(): - NvidiaGPU._handles = None + # NvidiaGPU._handles = None + pass @staticmethod def nvmlDeviceGetHandleByIndex(index): @@ -49,39 +51,39 @@ def nvmlDeviceGetGraphicsRunningProcesses(handle): class TestNvidiaGPU(unittest.TestCase): @patch("carbontracker.components.gpu.nvidia.pynvml", new=PynvmlStub) def test_devices(self): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + gpu = NvidiaGPU(pids=[], devices_by_pid=False) gpu._handles = [0] self.assertEqual(gpu.devices(), ["GPU"]) @patch("carbontracker.components.gpu.nvidia.pynvml", new=PynvmlStub) def test_available(self): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + gpu = NvidiaGPU(pids=[], devices_by_pid=False) self.assertTrue(gpu.available()) @patch("carbontracker.components.gpu.nvidia.pynvml", new=PynvmlStub) def test_power_usage(self): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + gpu = NvidiaGPU(pids=[], devices_by_pid=False) gpu._handles = [0] self.assertEqual(gpu.power_usage(), [1]) @patch("carbontracker.components.gpu.nvidia.pynvml", new=PynvmlStub) def test_init_shutdown(self): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + gpu = NvidiaGPU(pids=[], devices_by_pid=False) gpu.init() - self.assertIsNotNone(gpu._handles) + self.assertNotEqual(gpu._handles, []) gpu.shutdown() - self.assertIsNone(gpu._handles) + self.assertEqual(gpu._handles, []) @patch("carbontracker.components.gpu.nvidia.pynvml", new=PynvmlStub) def test_init(self): - gpu = NvidiaGPU(pids=[1234], devices_by_pid={1234: [0]}) + gpu = NvidiaGPU(pids=[1234], devices_by_pid=True) self.assertEqual(gpu.pids, [1234]) - self.assertEqual(gpu.devices_by_pid, {1234: [0]}) - self.assertIsNone(gpu._handles) + self.assertEqual(gpu.devices_by_pid, True) + self.assertEqual(gpu._handles, []) @patch("carbontracker.components.gpu.nvidia.pynvml", new=PynvmlStub) def test_get_handles(self): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + gpu = NvidiaGPU(pids=[], devices_by_pid=False) gpu.init() self.assertEqual(gpu._handles, [0]) gpu.shutdown() @@ -89,12 +91,12 @@ def test_get_handles(self): @patch("carbontracker.components.gpu.nvidia.pynvml", new=PynvmlStub) @patch("carbontracker.components.gpu.nvidia.os.environ.get", return_value="0") def test_slurm_gpu_indices(self, mock_get): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + gpu = NvidiaGPU(pids=[], devices_by_pid=False) self.assertEqual(gpu._slurm_gpu_indices(), [0]) @patch("carbontracker.components.gpu.nvidia.pynvml", new=PynvmlStub) def test_get_handles_by_pid(self): - gpu = NvidiaGPU(pids=[1234], devices_by_pid={1234: [0]}) + gpu = NvidiaGPU(pids=[1234], devices_by_pid=True) gpu.init() self.assertEqual(gpu._handles, [0]) gpu.shutdown() @@ -102,35 +104,54 @@ def test_get_handles_by_pid(self): @patch("sys.version_info", new=(3, 8)) @patch("carbontracker.components.gpu.nvidia.pynvml", new=PynvmlStub) def test_devices_python_version_less_than_3_10(self): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + gpu = NvidiaGPU(pids=[], devices_by_pid=False) gpu._handles = [0] self.assertEqual(gpu.devices(), ["GPU"]) - @patch("carbontracker.components.gpu.nvidia.pynvml.nvmlDeviceGetPowerUsage", - side_effect=pynvml.NVMLError(pynvml.NVML_ERROR_UNKNOWN)) - def test_power_usage_error_retrieving_power_usage(self, mock_nvmlDeviceGetPowerUsage): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + @patch( + "carbontracker.components.gpu.nvidia.pynvml.nvmlDeviceGetPowerUsage", + side_effect=pynvml.NVMLError(pynvml.NVML_ERROR_UNKNOWN), + ) + def test_power_usage_error_retrieving_power_usage( + self, mock_nvmlDeviceGetPowerUsage + ): + gpu = NvidiaGPU(pids=[], devices_by_pid=False) gpu._handles = [0] with self.assertRaises(exceptions.GPUPowerUsageRetrievalError): gpu.power_usage() - @patch("carbontracker.components.gpu.nvidia.pynvml.nvmlDeviceGetComputeRunningProcesses", return_value=[]) - @patch("carbontracker.components.gpu.nvidia.pynvml.nvmlDeviceGetGraphicsRunningProcesses", return_value=[]) - def test_get_handles_by_pid_no_gpus_running_processes(self, mock_nvmlDeviceGetComputeRunningProcesses, mock_nvmlDeviceGetGraphicsRunningProcesses): - gpu = NvidiaGPU(pids=[1234], devices_by_pid={1234: [0]}) - self.assertEqual(gpu._handles, None) + @patch( + "carbontracker.components.gpu.nvidia.pynvml.nvmlDeviceGetComputeRunningProcesses", + return_value=[], + ) + @patch( + "carbontracker.components.gpu.nvidia.pynvml.nvmlDeviceGetGraphicsRunningProcesses", + return_value=[], + ) + def test_get_handles_by_pid_no_gpus_running_processes( + self, + mock_nvmlDeviceGetComputeRunningProcesses, + mock_nvmlDeviceGetGraphicsRunningProcesses, + ): + gpu = NvidiaGPU(pids=[1234], devices_by_pid=True) + self.assertEqual(gpu._handles, []) @patch("carbontracker.components.gpu.nvidia.pynvml.nvmlInit") - @patch("carbontracker.components.gpu.nvidia.pynvml.nvmlDeviceGetCount", return_value=0) + @patch( + "carbontracker.components.gpu.nvidia.pynvml.nvmlDeviceGetCount", return_value=0 + ) def test_available_no_gpus(self, mock_nvmlDeviceGetCount, mock_nvmlInit): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + gpu = NvidiaGPU(pids=[], devices_by_pid=False) self.assertFalse(gpu.available()) - @patch("carbontracker.components.gpu.nvidia.pynvml.nvmlInit", side_effect=pynvml.NVMLError(pynvml.NVML_ERROR_UNKNOWN)) + @patch( + "carbontracker.components.gpu.nvidia.pynvml.nvmlInit", + side_effect=pynvml.NVMLError(pynvml.NVML_ERROR_UNKNOWN), + ) def test_available_nvml_error(self, mock_nvmlInit): - gpu = NvidiaGPU(pids=[], devices_by_pid={}) + gpu = NvidiaGPU(pids=[], devices_by_pid=False) self.assertFalse(gpu.available()) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/intensity/test_carbonintensitygb.py b/tests/intensity/test_carbonintensitygb.py index 38e1bc4..a681feb 100644 --- a/tests/intensity/test_carbonintensitygb.py +++ b/tests/intensity/test_carbonintensitygb.py @@ -19,7 +19,9 @@ def test_suitable_with_non_gb_location(self): self.assertFalse(result) @mock.patch("carbontracker.emissions.intensity.fetchers.carbonintensitygb.datetime") - @mock.patch("carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get") + @mock.patch( + "carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get" + ) def test_carbon_intensity_gb_regional(self, mock_get, mock_datetime): mock_response = mock.MagicMock() mock_response.ok = True @@ -43,8 +45,8 @@ def test_carbon_intensity_gb_regional(self, mock_get, mock_datetime): g_location = mock.MagicMock(postal="AB12 3CD") time_dur = 3600 - # Patch datetime.utcnow to return a fixed value - mock_datetime.datetime.utcnow.return_value = datetime.datetime(2023, 5, 20, 0, 0) + # Patch datetime.now to return a fixed value + mock_datetime.datetime.now.return_value = datetime.datetime(2023, 5, 20, 0, 0) # Patch datetime.timedelta to return a fixed value mock_datetime.timedelta().__radd__().strftime.return_value = "2023-05-20T01:00Z" @@ -59,7 +61,9 @@ def test_carbon_intensity_gb_regional(self, mock_get, mock_datetime): ) self.assertEqual(result, 250) - @mock.patch("carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get") + @mock.patch( + "carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get" + ) def test_carbon_intensity_gb_regional_with_error_response(self, mock_get): mock_response = mock.MagicMock() mock_response.ok = False @@ -72,7 +76,9 @@ def test_carbon_intensity_gb_regional_with_error_response(self, mock_get): self.fetcher._carbon_intensity_gb_regional(g_location.postal, time_dur) @mock.patch("carbontracker.emissions.intensity.fetchers.carbonintensitygb.datetime") - @mock.patch("carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get") + @mock.patch( + "carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get" + ) def test_carbon_intensity_gb_national(self, mock_get, mock_datetime): mock_response = mock.MagicMock() mock_response.ok = True @@ -90,8 +96,8 @@ def test_carbon_intensity_gb_national(self, mock_get, mock_datetime): from_str = "2023-05-20T00:00Z" to_str = "2023-05-20T01:00Z" - # Patch datetime.utcnow to return a fixed value - mock_datetime.datetime.utcnow.return_value = datetime.datetime(2023, 5, 20, 0, 0) + # Patch datetime.now to return a fixed value + mock_datetime.datetime.now.return_value = datetime.datetime(2023, 5, 20, 0, 0) # Patch datetime.timedelta to return a fixed value mock_datetime.timedelta().__radd__().strftime.return_value = to_str @@ -103,7 +109,9 @@ def test_carbon_intensity_gb_national(self, mock_get, mock_datetime): ) self.assertEqual(result, 250) - @mock.patch("carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get") + @mock.patch( + "carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get" + ) def test_carbon_intensity_gb_national_with_error_response(self, mock_get): mock_response = mock.MagicMock() mock_response.ok = False @@ -116,7 +124,7 @@ def test_carbon_intensity_gb_national_with_error_response(self, mock_get): def test_time_from_to_str(self): time_dur = 3600 - time_from = datetime.datetime.utcnow() + time_from = datetime.datetime.now(datetime.timezone.utc) time_to = time_from + datetime.timedelta(seconds=time_dur) from_str = time_from.strftime("%Y-%m-%dT%H:%MZ") to_str = time_to.strftime("%Y-%m-%dT%H:%MZ") @@ -125,7 +133,9 @@ def test_time_from_to_str(self): self.assertEqual(result, (from_str, to_str)) - @mock.patch("carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get") + @mock.patch( + "carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get" + ) def test_carbon_intensity_with_postal(self, mock_get): mock_response = mock.MagicMock() mock_response.ok = True @@ -140,7 +150,9 @@ def test_carbon_intensity_with_postal(self, mock_get): self.assertEqual(carbon_intensity_obj.carbon_intensity, 250) self.assertEqual(carbon_intensity_obj.is_prediction, True) - @mock.patch("carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get") + @mock.patch( + "carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get" + ) def test_carbon_intensity_without_postal(self, mock_get): mock_response = mock.MagicMock() mock_response.ok = True @@ -154,7 +166,9 @@ def test_carbon_intensity_without_postal(self, mock_get): self.assertEqual(carbon_intensity_obj.carbon_intensity, 250) self.assertEqual(carbon_intensity_obj.is_prediction, True) - @mock.patch("carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get") + @mock.patch( + "carbontracker.emissions.intensity.fetchers.carbonintensitygb.requests.get" + ) def test_carbon_intensity_gb_regional_without_time_dur(self, mock_get): mock_response = mock.MagicMock() mock_response.ok = True diff --git a/tests/intensity/test_electricitymaps.py b/tests/intensity/test_electricitymaps.py index 7aa5021..72cc007 100644 --- a/tests/intensity/test_electricitymaps.py +++ b/tests/intensity/test_electricitymaps.py @@ -5,7 +5,8 @@ class TestElectricityMap(unittest.TestCase): def setUp(self): - self.electricity_map = ElectricityMap() + self.logger = MagicMock() + self.electricity_map = ElectricityMap(logger=self.logger) self.g_location = MagicMock() self.g_location.lng = 0.0 self.g_location.lat = 0.0 diff --git a/tests/intensity/test_energidataservice.py b/tests/intensity/test_energidataservice.py index e65e835..3b83b93 100644 --- a/tests/intensity/test_energidataservice.py +++ b/tests/intensity/test_energidataservice.py @@ -21,10 +21,7 @@ def test_carbon_intensity_no_time_dur(self, mock_get): mock_response = mock.MagicMock() mock_response.ok = True mock_response.json.return_value = { - "records": [ - {"CO2Emission": 1.0}, - {"CO2Emission": 2.0} - ] + "records": [{"CO2Emission": 1.0}, {"CO2Emission": 2.0}] } mock_get.return_value = mock_response result = self.fetcher.carbon_intensity(self.geocoder) @@ -41,7 +38,7 @@ def test_carbon_intensity_with_time_dur(self, mock_get): {"CO2Emission": 1.0}, {"CO2Emission": 2.0}, {"CO2Emission": 3.0}, - {"CO2Emission": 4.0} + {"CO2Emission": 4.0}, ] } mock_get.return_value = mock_response @@ -60,14 +57,14 @@ def test_nearest_5_min(self, mock_get): {"CO2Emission": 1.0}, {"CO2Emission": 2.0}, {"CO2Emission": 3.0}, - {"CO2Emission": 4.0} + {"CO2Emission": 4.0}, ] } mock_get.return_value = mock_response _result = self.fetcher.carbon_intensity(self.geocoder, time_dur=1800) - now = datetime.datetime.utcnow() + now = datetime.datetime.now(datetime.timezone.utc) from_time = now - datetime.timedelta( minutes=now.minute % 5, seconds=now.second, microseconds=now.microsecond @@ -75,12 +72,12 @@ def test_nearest_5_min(self, mock_get): to_time = from_time + datetime.timedelta(seconds=1800) # Format the from_time and to_time to strings - date_format = "%Y-%m-%d %H:%M" + date_format = "%Y-%m-%dT%H:%M" expected_from_time = from_time.strftime(date_format) expected_to_time = to_time.strftime(date_format) # Check that the mocked requests.get was called with the expected URL - expected_url = f"https://api.energidataservice.dk/dataset/CO2Emis?start={{{expected_from_time}&end={{{expected_to_time}}}&limit=4" + expected_url = f"https://api.energidataservice.dk/dataset/CO2Emis?start={expected_from_time}&end={expected_to_time}&limit=4" mock_get.assert_called_once_with(expected_url) @mock.patch("requests.get") diff --git a/tests/intensity/test_intensity.py b/tests/intensity/test_intensity.py index b1e85c2..76ef709 100644 --- a/tests/intensity/test_intensity.py +++ b/tests/intensity/test_intensity.py @@ -1,13 +1,14 @@ +import geocoder import unittest from unittest.mock import patch, MagicMock import numpy as np import pandas as pd -import pkg_resources +import sys from carbontracker import constants from carbontracker.emissions.intensity import intensity -from carbontracker.emissions.intensity.intensity import carbon_intensity, default_intensity +from carbontracker.emissions.intensity.intensity import carbon_intensity class TestIntensity(unittest.TestCase): @@ -21,8 +22,15 @@ def test_get_default_intensity_success(self, mock_geocoder_ip): result = intensity.get_default_intensity() - carbon_intensities_df = pd.read_csv( - pkg_resources.resource_filename("carbontracker", "data/carbon-intensities.csv")) + # importlib.resources.files was introduced in Python 3.9 and replaces deprecated pkg_resource.resources + if sys.version_info < (3,9): + import pkg_resources + carbon_intensities_df = pd.read_csv(pkg_resources.resource_filename("carbontracker", "data/carbon-intensities.csv")) + else: + import importlib.resources + ref = importlib.resources.files("carbontracker") / "data/carbon-intensities.csv" + with importlib.resources.as_file(ref) as path: + carbon_intensities_df = pd.read_csv(path) intensity_row = carbon_intensities_df[carbon_intensities_df["alpha-2"] == mock_location.country].iloc[0] expected_intensity = intensity_row["Carbon intensity of electricity (gCO2/kWh)"] @@ -106,27 +114,26 @@ def test_carbon_intensity_location_failure(self, mock_geocoder_ip): logger = MagicMock() - result = intensity.carbon_intensity(logger) + with patch('carbontracker.emissions.intensity.intensity.default_intensity', intensity.get_default_intensity()) as C: + result = intensity.carbon_intensity(logger) + default_intensity = intensity.get_default_intensity() - self.assertEqual(result.carbon_intensity, default_intensity["carbon_intensity"]) - self.assertEqual(result.address, "UNDETECTED") - self.assertEqual(result.success, False) - self.assertIn("Live carbon intensity could not be fetched at detected location", result.message) + self.assertEqual(result.carbon_intensity, default_intensity["carbon_intensity"]) + self.assertEqual(result.address, "UNDETECTED") + self.assertEqual(result.success, False) + self.assertIn("Live carbon intensity could not be fetched at detected location", result.message) @patch("carbontracker.emissions.intensity.intensity.geocoder.ip") - def test_set_carbon_intensity_message(self, mock_geocoder): - ci = intensity.CarbonIntensity() + def test_set_carbon_intensity_message(self, mock_geocoder_ip): time_dur = 3600 - mock_location = MagicMock() # Assuming the actual function logic uses a specific fallback or detected location - detected_address = "Zürich, Zurich, CH" # The detected location that appears in the error + detected_address = "Aarhus, Capital Region, DK" # The detected location that appears in the error fallback_address = "Generic Location, Country" # The fallback or generic location mock_location.address = detected_address - mock_geocoder.ip.return_value = mock_location - - ci.address = fallback_address # Set to the fallback or generic address for the test case - + mock_location.country = 'DK' + mock_geocoder_ip.ok = True + mock_geocoder_ip.return_value = mock_location # Adjust the set_expected_message function to match the error details def set_expected_message(is_prediction, success, carbon_intensity): if is_prediction: @@ -139,10 +146,9 @@ def set_expected_message(is_prediction, success, carbon_intensity): message = f"Current carbon intensity is {carbon_intensity:.2f} gCO2/kWh at detected location: {fallback_address}." else: message = (f"Live carbon intensity could not be fetched at detected location: {detected_address}. " - f"Defaulted to average carbon intensity for CH in 2021 of 57.77 gCO2/kWh. " + f"Defaulted to average carbon intensity for DK in 2023 of 151.65 gCO2/kWh. " f"at detected location: {fallback_address}.") return message - # Test scenarios scenarios = [ (True, True, 100.0), @@ -151,13 +157,16 @@ def set_expected_message(is_prediction, success, carbon_intensity): (False, False, None) # The scenario corresponding to the failure message ] - for is_prediction, success, carbon_intensity in scenarios: - ci.is_prediction = is_prediction - ci.success = success - ci.carbon_intensity = carbon_intensity if carbon_intensity is not None else 0.0 - intensity.set_carbon_intensity_message(ci, time_dur) - expected_message = set_expected_message(is_prediction, success, carbon_intensity) - self.assertEqual(ci.message, expected_message) + with patch('carbontracker.emissions.intensity.intensity.default_intensity', intensity.get_default_intensity()) as C: + ci = intensity.CarbonIntensity() + ci.address = fallback_address # Set to the fallback or generic address for the test case + for is_prediction, success, carbon_intensity in scenarios: + ci.is_prediction = is_prediction + ci.success = success + ci.carbon_intensity = carbon_intensity if carbon_intensity is not None else 0.0 + intensity.set_carbon_intensity_message(ci, time_dur) + expected_message = set_expected_message(is_prediction, success, carbon_intensity) + self.assertEqual(ci.message, expected_message) @patch("geocoder.ip") @patch("carbontracker.emissions.intensity.fetchers.electricitymaps.ElectricityMap.suitable") @@ -206,7 +215,7 @@ def test_carbon_intensity_exception_carbonintensitygb(self, mock_geocoder, mock_ logger = MagicMock() - result = carbon_intensity(logger) + result = carbon_intensity(logger, fetchers=[mock_carbonintensitygb()]) self.assertEqual(result.carbon_intensity, 23.0) self.assertTrue(result.success) @@ -221,7 +230,7 @@ def test_carbon_intensity_energidataservice(self, mock_energidataservice): mock_energidataservice.return_value.carbon_intensity.return_value = mock_result logger = MagicMock() - result = carbon_intensity(logger) + result = carbon_intensity(logger, fetchers=[mock_energidataservice()]) self.assertEqual(result.carbon_intensity, 23.0) self.assertTrue(result.success) @@ -248,4 +257,4 @@ def test_carbon_intensity_nan(self, mock_electricity_map, mock_geocoder): self.assertFalse(result.success) self.assertTrue(np.isnan(result.carbon_intensity)) - self.assertEqual(mock_location.address, "Sample Address") \ No newline at end of file + self.assertEqual(mock_location.address, "Sample Address") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3d06d7d..a9d4391 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,12 @@ import subprocess import unittest +from unittest import skipIf from time import sleep from unittest.mock import patch from io import StringIO import sys from carbontracker import cli +import os def mock_password_input(prompt): # Simulate password entry based on the prompt @@ -14,6 +16,7 @@ def mock_password_input(prompt): # Handle other prompts or return None for unexpected prompts return None +@skipIf(os.environ.get('CI') == 'true', 'Skipped due to CI') class TestCLI(unittest.TestCase): @patch("builtins.input", side_effect=mock_password_input) diff --git a/tests/test_component.py b/tests/test_component.py index ac208fe..92a1cb1 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -4,111 +4,135 @@ from carbontracker import exceptions from carbontracker.components.gpu import nvidia -from carbontracker.components.component import Component, create_components, error_by_name +from carbontracker.components.component import ( + Component, + create_components, + error_by_name, +) class TestComponent(unittest.TestCase): - @patch('carbontracker.components.component.component_names', return_value=["gpu"]) - @patch('carbontracker.components.component.error_by_name', return_value=exceptions.GPUError("No GPU(s) available.")) - @patch('carbontracker.components.component.handlers_by_name', return_value=[MagicMock(spec=nvidia.NvidiaGPU)]) - def test_init_valid_component(self, mock_handlers_by_name, mock_error_by_name, mock_component_names): - component = Component(name="gpu", pids=[], devices_by_pid={}) + @patch("carbontracker.components.component.component_names", return_value=["gpu"]) + @patch( + "carbontracker.components.component.error_by_name", + return_value=exceptions.GPUError("No GPU(s) available."), + ) + @patch( + "carbontracker.components.component.handlers_by_name", + return_value=[MagicMock(spec=nvidia.NvidiaGPU)], + ) + def test_init_valid_component( + self, mock_handlers_by_name, mock_error_by_name, mock_component_names + ): + component = Component(name="gpu", pids=[], devices_by_pid=False) self.assertEqual(component.name, "gpu") self.assertEqual(component._handler, mock_handlers_by_name()[0]()) def test_init_invalid_component(self): with self.assertRaises(exceptions.ComponentNameError): - Component(name="unknown", pids=[], devices_by_pid={}) + Component(name="unknown", pids=[], devices_by_pid=False) def test_devices(self): handler_mock = MagicMock(devices=MagicMock(return_value=["Test GPU"])) - component = Component(name="gpu", pids=[], devices_by_pid={}) + component = Component(name="gpu", pids=[], devices_by_pid=False) component._handler = handler_mock self.assertEqual(component.devices(), ["Test GPU"]) def test_available_true(self): handler_mock = MagicMock(available=MagicMock(return_value=True)) - component = Component(name="gpu", pids=[], devices_by_pid={}) + component = Component(name="gpu", pids=[], devices_by_pid=False) component._handler = handler_mock self.assertTrue(component.available()) - @patch('carbontracker.components.gpu.nvidia.NvidiaGPU.available', return_value=False) - @patch('carbontracker.components.apple_silicon.powermetrics.AppleSiliconGPU.available', return_value=False) + @patch( + "carbontracker.components.gpu.nvidia.NvidiaGPU.available", return_value=False + ) + @patch( + "carbontracker.components.apple_silicon.powermetrics.AppleSiliconGPU.available", + return_value=False, + ) def test_available_false(self, mock_apple_gpu_available, mock_nvidia_gpu_available): - component = Component(name="gpu", pids=[], devices_by_pid={}) + component = Component(name="gpu", pids=[], devices_by_pid=False) self.assertFalse(component.available()) def test_collect_power_usage_no_measurement(self): - handler_mock = MagicMock(power_usage=MagicMock(side_effect=exceptions.IntelRaplPermissionError)) - component = Component(name="cpu", pids=[], devices_by_pid={}) + handler_mock = MagicMock( + power_usage=MagicMock(side_effect=exceptions.IntelRaplPermissionError) + ) + component = Component(name="cpu", pids=[], devices_by_pid=False) component._handler = handler_mock component.collect_power_usage(epoch=1) self.assertEqual(component.power_usages, [[], [0]]) def test_collect_power_usage_with_measurement(self): - handler_mock = MagicMock(power_usage=MagicMock(return_value=1000)) - component = Component(name="cpu", pids=[], devices_by_pid={}) + handler_mock = MagicMock(power_usage=MagicMock(return_value=[1000])) + component = Component(name="cpu", pids=[], devices_by_pid=False) component._handler = handler_mock component.collect_power_usage(epoch=1) self.assertEqual(component.power_usages, [[1000]]) - def test_collect_power_usage_with_measurement_but_no_epoch(self): - power_collector = Component(name="cpu", pids=[], devices_by_pid={}) - power_collector._handler = MagicMock(power_usage=MagicMock(return_value=1000)) + power_collector = Component(name="cpu", pids=[], devices_by_pid=False) + power_collector._handler = MagicMock(power_usage=MagicMock(return_value=[1000])) power_collector.collect_power_usage(epoch=0) assert len(power_collector.power_usages) == 0 def test_collect_power_usage_with_previous_measurement(self): - power_collector = Component(name="cpu", pids=[], devices_by_pid={}) - power_collector._handler = MagicMock(power_usage=MagicMock(return_value=1000)) + power_collector = Component(name="cpu", pids=[], devices_by_pid=False) + power_collector._handler = MagicMock(power_usage=MagicMock(return_value=[1000])) power_collector.collect_power_usage(epoch=1) power_collector.collect_power_usage(epoch=3) assert len(power_collector.power_usages) == 3 - def test_collect_power_usage_GPUPowerUsageRetrievalError(self): - handler_mock = MagicMock(power_usage=MagicMock(side_effect=exceptions.GPUPowerUsageRetrievalError)) - component = Component(name="gpu", pids=[], devices_by_pid={}) + handler_mock = MagicMock( + power_usage=MagicMock(side_effect=exceptions.GPUPowerUsageRetrievalError) + ) + component = Component(name="gpu", pids=[], devices_by_pid=False) component._handler = handler_mock component.collect_power_usage(epoch=1) self.assertEqual(component.power_usages, [[], [0]]) def test_energy_usage(self): - component = Component(name="cpu", pids=[], devices_by_pid={}) + component = Component(name="cpu", pids=[], devices_by_pid=False) component.power_usages = [[1000], [2000], [3000]] epoch_times = [1, 2, 3] energy_usages = component.energy_usage(epoch_times) - self.assertEqual(energy_usages, [0.0002777777777777778, 0.0011111111111111111, 0.0025]) + self.assertEqual( + energy_usages, [0.0002777777777777778, 0.0011111111111111111, 0.0025] + ) self.assertTrue(np.all(np.array(energy_usages) > 0)) def test_energy_usage_no_measurements(self): - component = Component(name="cpu", pids=[], devices_by_pid={}) + component = Component(name="cpu", pids=[], devices_by_pid=False) component.power_usages = [[]] epoch_times = [1] energy_usages = component.energy_usage(epoch_times) self.assertEqual(energy_usages, [0]) - def test_energy_usage_with_power_from_later_epoch(self): - component = Component(name="cpu", pids=[], devices_by_pid={}) + component = Component(name="cpu", pids=[], devices_by_pid=False) component.power_usages = [[1000], [2000], [3000]] epoch_times = [1, 2, 3, 4] energy_usages = component.energy_usage(epoch_times) - self.assertEqual(energy_usages, [0.0002777777777777778, 0.0011111111111111111, 0.0025, 0.0025]) + self.assertEqual( + energy_usages, + [0.0002777777777777778, 0.0011111111111111111, 0.0025, 0.0025], + ) def test_energy_usage_no_power(self): - component = Component(name="cpu", pids=[], devices_by_pid={}) + component = Component(name="cpu", pids=[], devices_by_pid=False) component.power_usages = [[], [], [], [], []] epoch_times = [1, 2, 3, 4, 5] energy_usages = component.energy_usage(epoch_times) expected_energy_usages = [0, 0, 0, 0, 0] - assert np.allclose(energy_usages, expected_energy_usages, atol=1e-8), \ - f"Expected {expected_energy_usages}, but got {energy_usages}" + assert np.allclose( + energy_usages, expected_energy_usages, atol=1e-8 + ), f"Expected {expected_energy_usages}, but got {energy_usages}" def test_init(self): handler_mock = MagicMock() - component = Component(name="gpu", pids=[], devices_by_pid={}) + component = Component(name="gpu", pids=[], devices_by_pid=False) component._handler = handler_mock component.init() handler_mock.init.assert_called_once() @@ -120,34 +144,38 @@ def test_init(self): def test_shutdown(self): handler_mock = MagicMock() - component = Component(name="gpu", pids=[], devices_by_pid={}) + component = Component(name="gpu", pids=[], devices_by_pid=False) component._handler = handler_mock component.shutdown() handler_mock.shutdown.assert_called_once() def test_create_components(self): - gpu = create_components("gpu", pids=[], devices_by_pid={}) - cpu = create_components("cpu", pids=[], devices_by_pid={}) - all_components = create_components("all", pids=[], devices_by_pid={}) + gpu = create_components("gpu", pids=[], devices_by_pid=False) + cpu = create_components("cpu", pids=[], devices_by_pid=False) + all_components = create_components("all", pids=[], devices_by_pid=False) self.assertEqual(len(gpu), 1) self.assertEqual(len(cpu), 1) self.assertEqual(len(all_components), 2) def test_error_by_name(self): - self.assertEqual(str(error_by_name('gpu')), str(exceptions.GPUError('No GPU(s) available.'))) - self.assertEqual(str(error_by_name('cpu')), str(exceptions.CPUError('No CPU(s) available.'))) + self.assertEqual( + str(error_by_name("gpu")), str(exceptions.GPUError("No GPU(s) available.")) + ) + self.assertEqual( + str(error_by_name("cpu")), str(exceptions.CPUError("No CPU(s) available.")) + ) def test_handler_property_with_handler_set(self): - component = Component(name="gpu", pids=[], devices_by_pid={}) + component = Component(name="gpu", pids=[], devices_by_pid=False) component._handler = "test" self.assertEqual(component.handler, "test") def test_handler_property_without_handler(self): - component = Component(name="gpu", pids=[], devices_by_pid={}) + component = Component(name="gpu", pids=[], devices_by_pid=False) component._handler = None with self.assertRaises(exceptions.GPUError): component.handler() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_loggerutil.py b/tests/test_loggerutil.py index d66daa3..4a658ff 100644 --- a/tests/test_loggerutil.py +++ b/tests/test_loggerutil.py @@ -1,11 +1,14 @@ import unittest +from unittest import skipIf from carbontracker import loggerutil from carbontracker.loggerutil import Logger, convert_to_timestring import unittest.mock -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import tempfile import os import logging +from datetime import datetime +import time class TestLoggerUtil(unittest.TestCase): @@ -31,12 +34,17 @@ def test_convert_to_timestring_rounding_seconds(self): def test_convert_to_timestring_rounding_float_seconds(self): time_s = 3659.9955 # Very close to 3660, and should round off to it - self.assertEqual(convert_to_timestring(time_s, add_milliseconds=True), "1:01:00.00") + self.assertEqual( + convert_to_timestring(time_s, add_milliseconds=True), "1:01:00.00" + ) + @skipIf(os.environ.get("CI") == "true", "Skipped due to CI") def test_formatTime_with_datefmt(self): formatter = loggerutil.TrackerFormatter() record = MagicMock() - record.created = 1678886400.0 # This is a sample timestamp for "2023-03-15 12:00:00" + record.created = time.mktime( + datetime(2023, 3, 15, 14, 20, 0).timetuple() + ) # This is a sample timestamp for "2023-03-15 14:20:00" at UTC time # Specify a custom date format datefmt = "%Y-%m-%d %H-%M-%S" @@ -44,10 +52,11 @@ def test_formatTime_with_datefmt(self): self.assertEqual(formatted_time, "2023-03-15 14-20-00") + @skipIf(os.environ.get("CI") == "true", "Skipped due to CI") def test_formatTime_without_datefmt(self): formatter = loggerutil.TrackerFormatter() record = MagicMock() - record.created = 1678886400.0 # This is a sample timestamp for "2023-03-15 12:00:00" + record.created = time.mktime(datetime(2023, 3, 15, 14, 20, 0).timetuple()) formatted_time = formatter.formatTime(record) @@ -83,7 +92,9 @@ def test_VerboseFilter_without_verbose(self): def test_logger_setup(self): logger = Logger() self.assertIsInstance(logger, Logger) - self.assertEqual(logger.logger_output.level, logging.DEBUG, "Logging level is not DEBUG.") + self.assertEqual( + logger.logger_output.level, logging.DEBUG, "Logging level is not DEBUG." + ) def test_info_logging(self): logger = Logger() @@ -124,7 +135,9 @@ def test_log_initial_info(self): logger = Logger() with unittest.mock.patch.object(logger.logger, "info") as mock_info: logger._log_initial_info() # Call it again for testing purposes - self.assertEqual(mock_info.call_count, 2) # Called twice: one during initialization and one during our test + self.assertEqual( + mock_info.call_count, 2 + ) # Called twice: one during initialization and one during our test def test_logger_with_log_dir(self): with tempfile.TemporaryDirectory() as tmp_dir: @@ -151,6 +164,13 @@ def test_output(self, mock_info): mock_info.assert_called_once_with(f"CarbonTracker: {test_message}") + def test_multiple_loggers(self): + logger1 = loggerutil.Logger(logger_id="1") + logger2 = loggerutil.Logger(logger_id="2") + self.assertNotEqual(logger1.logger, logger2.logger) + self.assertNotEqual(logger1.logger_output, logger2.logger_output) + self.assertNotEqual(logger1.logger_err, logger2.logger_err) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_parser.py b/tests/test_parser.py index 7e4c5ac..e7cc29d 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -5,8 +5,13 @@ from pyfakefs import fake_filesystem_unittest from carbontracker import parser -from carbontracker.parser import extract_measurements, parse_logs, print_aggregate, get_stats, \ - parse_equivalents +from carbontracker.parser import ( + extract_measurements, + parse_logs, + print_aggregate, + get_stats, + parse_equivalents, +) class TestParser(fake_filesystem_unittest.TestCase): @@ -19,16 +24,28 @@ def setUp(self): def test_get_all_logs(self, mock_getsize, mock_isfile, mock_listdir): log_dir = "/path/to/logs" - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log1.log"), contents="output_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log2.log"), contents="output_log2 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log1.log"), contents="std_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log2.log"), contents="std_log2 content") + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + contents="output_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker_output.log"), + contents="output_log2 content", + ) + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + contents="std_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker.log"), + contents="std_log2 content", + ) mock_listdir.return_value = [ - "carbontracker_output_log1.log", - "carbontracker_output_log2.log", - "carbontracker_log1.log", - "carbontracker_log2.log", + "10151_2024-03-26T105926Z_carbontracker_output.log", + "32487_2024-06-26T141608Z_carbontracker_output.log", + "10151_2024-03-26T105926Z_carbontracker.log", + "32487_2024-06-26T141608Z_carbontracker.log", ] mock_isfile.side_effect = lambda path: path.endswith(".log") @@ -37,12 +54,12 @@ def test_get_all_logs(self, mock_getsize, mock_isfile, mock_listdir): output_logs, std_logs = parser.get_all_logs(log_dir) expected_output_logs = [ - os.path.join(log_dir, "carbontracker_output_log1.log"), - os.path.join(log_dir, "carbontracker_output_log2.log"), + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker_output.log"), ] expected_std_logs = [ - os.path.join(log_dir, "carbontracker_log1.log"), - os.path.join(log_dir, "carbontracker_log2.log"), + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker.log"), ] self.assertCountEqual(output_logs, expected_output_logs) @@ -102,25 +119,46 @@ def test_get_avg_power_usages(self): def test_get_most_recent_logs(self, mock_getmtime, mock_isfile, mock_listdir): log_dir = "/path/to/logs" - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log1.log"), contents="output_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log2.log"), contents="output_log2 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log1.log"), contents="std_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log2.log"), contents="std_log2 content") + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + contents="output_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker_output.log"), + contents="output_log2 content", + ) + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + contents="std_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker.log"), + contents="std_log2 content", + ) mock_listdir.return_value = [ - "carbontracker_output_log1.log", - "carbontracker_output_log2.log", - "carbontracker_log1.log", - "carbontracker_log2.log", + "10151_2024-03-26T105926Z_carbontracker_output.log", + "32487_2024-06-26T141608Z_carbontracker_output.log", + "10151_2024-03-26T105926Z_carbontracker.log", + "32487_2024-06-26T141608Z_carbontracker.log", ] mock_isfile.side_effect = lambda path: path.endswith(".log") - mock_getmtime.side_effect = [100, 200, 300, 400] # Mock the modification timestamps + mock_getmtime.side_effect = [ + 100, + 200, + 300, + 400, + ] # Mock the modification timestamps std_log, output_log = parser.get_most_recent_logs(log_dir) - expected_std_log = os.path.join(log_dir, "carbontracker_log2.log") - expected_output_log = os.path.join(log_dir, "carbontracker_output_log2.log") + expected_std_log = os.path.join( + log_dir, "32487_2024-06-26T141608Z_carbontracker.log" + ) + expected_output_log = os.path.join( + log_dir, "32487_2024-06-26T141608Z_carbontracker_output.log" + ) self.assertEqual(std_log, expected_std_log) self.assertEqual(output_log, expected_output_log) @@ -174,18 +212,33 @@ def test_get_consumption(self, mock_open): def test_parse_all_logs(self, mock_isfile, mock_listdir, mock_open): log_dir = "/path/to/logs" - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log1.log"), contents="output_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log1.log"), contents="std_log1 content") + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + contents="output_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + contents="std_log1 content", + ) - mock_listdir.return_value = ["carbontracker_output_log1.log", "carbontracker_log1.log"] + mock_listdir.return_value = [ + "10151_2024-03-26T105926Z_carbontracker_output.log", + "10151_2024-03-26T105926Z_carbontracker.log", + ] mock_isfile.side_effect = lambda path: path.endswith(".log") mock_open.return_value.read.return_value = "content" logs = parser.parse_all_logs(log_dir) self.assertEqual(len(logs), 1) - self.assertEqual(logs[0]["output_filename"], os.path.join(log_dir, "carbontracker_output_log1.log")) - self.assertEqual(logs[0]["standard_filename"], os.path.join(log_dir, "carbontracker_log1.log")) + self.assertEqual( + logs[0]["output_filename"], + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + ) + self.assertEqual( + logs[0]["standard_filename"], + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + ) @mock.patch("builtins.open", new_callable=mock.mock_open) @mock.patch("os.listdir") @@ -200,20 +253,31 @@ def test_parse_logs(self, mock_get_devices, mock_isfile, mock_listdir, mock_open "2022-11-14 15:44:48 - Epoch 1:\nDuration: 0:02:21.90" ) - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log1.log"), contents="output_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log1.log"), contents=std_log_data) + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + contents="output_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + contents=std_log_data, + ) mock_isfile.side_effect = lambda path: path.endswith(".log") mock_open.return_value.read.return_value = std_log_data - mock_get_devices.return_value = {"gpu": ["NVIDIA GeForce RTX 3060"], "cpu": ["cpu:0"]} + mock_get_devices.return_value = { + "gpu": ["NVIDIA GeForce RTX 3060"], + "cpu": ["cpu:0"], + } - components = parser.parse_logs(log_dir, os.path.join(log_dir, "carbontracker_log1.log"), - os.path.join(log_dir, "carbontracker_output_log1.log")) + components = parser.parse_logs( + log_dir, + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + ) self.assertIn("gpu", components) self.assertIn("cpu", components) - def test_get_avg_power_usages_none_power(self): std_log_data = "2022-11-14 15:44:48 - Average power usage (W) for gpu: None" @@ -226,68 +290,119 @@ def test_get_avg_power_usages_none_power(self): @mock.patch("os.listdir") @mock.patch("os.path.isfile") @mock.patch("os.path.getsize") - def test_get_all_logs_mismatched_files(self, mock_getsize, mock_isfile, mock_listdir): + def test_get_all_logs_mismatched_files( + self, mock_getsize, mock_isfile, mock_listdir + ): log_dir = "/path/to/logs" # Create three matching pairs of log files - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log1.log"), contents="output_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log1.log"), contents="std_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log2.log"), contents="output_log2 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log2.log"), contents="std_log2 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log3.log"), contents="output_log3 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log3.log"), contents="std_log3 content") + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + contents="output_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + contents="std_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker_output.log"), + contents="output_log2 content", + ) + self.fs.create_file( + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker.log"), + contents="std_log2 content", + ) + self.fs.create_file( + os.path.join(log_dir, "40793_2024-03-26T131535Z_carbontracker_output.log"), + contents="output_log3 content", + ) + self.fs.create_file( + os.path.join(log_dir, "40793_2024-03-26T131535Z_carbontracker.log"), + contents="std_log3 content", + ) # Add extra unmatched output log file - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log4.log"), contents="output_log4 content") + self.fs.create_file( + os.path.join(log_dir, "9803_2024-03-26T105836Z_carbontracker_output.log"), + contents="output_log4 content", + ) mock_listdir.return_value = [ - "carbontracker_output_log1.log", - "carbontracker_output_log2.log", - "carbontracker_output_log3.log", - "carbontracker_output_log4.log", - "carbontracker_log1.log", - "carbontracker_log2.log", - "carbontracker_log3.log", + "10151_2024-03-26T105926Z_carbontracker_output.log", + "32487_2024-06-26T141608Z_carbontracker_output.log", + "40793_2024-03-26T131535Z_carbontracker_output.log", + "9803_2024-03-26T105836Z_carbontracker_output.log", + "10151_2024-03-26T105926Z_carbontracker.log", + "32487_2024-06-26T141608Z_carbontracker.log", + "40793_2024-03-26T131535Z_carbontracker.log", ] mock_isfile.side_effect = lambda path: path.endswith(".log") mock_getsize.return_value = 100 - with self.assertRaises(exceptions.MismatchedLogFilesError): - parser.get_all_logs(log_dir) + expected = [os.path.join(log_dir, f) for f in mock_listdir.return_value] + self.assertTupleEqual( + parser.get_all_logs(log_dir), + (expected[:3], expected[4:]), + ) @mock.patch("os.listdir") @mock.patch("os.path.isfile") @mock.patch("os.path.getsize") - def test_get_all_logs_mismatched_files_extra_std_log(self, mock_getsize, mock_isfile, mock_listdir): + def test_get_all_logs_mismatched_files_extra_std_log( + self, mock_getsize, mock_isfile, mock_listdir + ): log_dir = "/path/to/logs" # Create three matching pairs of log files - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log1.log"), contents="output_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log1.log"), contents="std_log1 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log2.log"), contents="output_log2 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log2.log"), contents="std_log2 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log3.log"), contents="output_log3 content") - self.fs.create_file(os.path.join(log_dir, "carbontracker_log3.log"), contents="std_log3 content") + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + contents="output_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + contents="std_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker_output.log"), + contents="output_log2 content", + ) + self.fs.create_file( + os.path.join(log_dir, "32487_2024-06-26T141608Z_carbontracker.log"), + contents="std_log2 content", + ) + self.fs.create_file( + os.path.join(log_dir, "40793_2024-03-26T131535Z_carbontracker_output.log"), + contents="output_log3 content", + ) + self.fs.create_file( + os.path.join(log_dir, "40793_2024-03-26T131535Z_carbontracker.log"), + contents="std_log3 content", + ) # Add extra unmatched std log file - self.fs.create_file(os.path.join(log_dir, "carbontracker_log4.log"), contents="std_log4 content") + self.fs.create_file( + os.path.join(log_dir, "9803_2024-03-26T105836Z_carbontracker.log"), + contents="std_log4 content", + ) mock_listdir.return_value = [ - "carbontracker_output_log1.log", - "carbontracker_output_log2.log", - "carbontracker_output_log3.log", - "carbontracker_log1.log", - "carbontracker_log2.log", - "carbontracker_log3.log", - "carbontracker_log4.log", + "10151_2024-03-26T105926Z_carbontracker_output.log", + "32487_2024-06-26T141608Z_carbontracker_output.log", + "40793_2024-03-26T131535Z_carbontracker_output.log", + "10151_2024-03-26T105926Z_carbontracker.log", + "32487_2024-06-26T141608Z_carbontracker.log", + "40793_2024-03-26T131535Z_carbontracker.log", + "9803_2024-03-26T105836Z_carbontracker.log", ] mock_isfile.side_effect = lambda path: path.endswith(".log") mock_getsize.return_value = 100 - with self.assertRaises(exceptions.MismatchedLogFilesError): - parser.get_all_logs(log_dir) + expected = [os.path.join(log_dir, f) for f in mock_listdir.return_value] + self.assertTupleEqual( + parser.get_all_logs(log_dir), (expected[:3], expected[3:6]) + ) @mock.patch("builtins.open", new_callable=mock.mock_open) def test_get_consumption_no_equivalents(self, mock_open): @@ -327,7 +442,9 @@ def test_parse_logs_no_files(self): @mock.patch("builtins.open", new_callable=mock.mock_open) @mock.patch("carbontracker.parser.get_avg_power_usages", return_value={}) @mock.patch("carbontracker.parser.get_devices") - def test_parse_logs_consumption_no_power_usages(self, mock_get_devices, mock_power_usages, mock_open): + def test_parse_logs_consumption_no_power_usages( + self, mock_get_devices, mock_power_usages, mock_open + ): log_dir = "/logs" std_log_file = log_dir + "/test_carbontracker.log" output_log_file = log_dir + "/test_carbontracker_output.log" @@ -402,7 +519,9 @@ def test_aggregate_consumption_all_logs_none(self, mock_get_all_logs): log_dir = "/path/to/logs" mock_get_all_logs.return_value = ([], []) - total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption(log_dir) + total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption( + log_dir + ) self.assertEqual(total_energy, 0) self.assertEqual(total_co2eq, 0) @@ -411,7 +530,9 @@ def test_aggregate_consumption_all_logs_none(self, mock_get_all_logs): @mock.patch("carbontracker.parser.get_all_logs") @mock.patch("carbontracker.parser.get_consumption") @mock.patch("carbontracker.parser.get_early_stop") - def test_aggregate_consumption(self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs): + def test_aggregate_consumption( + self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs + ): log_dir = "/path/to/logs" output_log_path = "/path/to/logs/output_log1" std_log_path = "/path/to/logs/std_log1" @@ -423,8 +544,12 @@ def test_aggregate_consumption(self, mock_get_early_stop, mock_get_consumption, mock_get_consumption.return_value = (None, None) mock_get_early_stop.return_value = False - with mock.patch("builtins.open", mock.mock_open(read_data="mock_data")) as mock_open: - total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption(log_dir) + with mock.patch( + "builtins.open", mock.mock_open(read_data="mock_data") + ) as mock_open: + total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption( + log_dir + ) expected_total_energy = 0 expected_total_co2eq = 0 @@ -449,14 +574,25 @@ def test_aggregate_consumption_actual(self, mock_isfile, mock_listdir, mock_open " 0.007977 km travelled by car\n" ) - self.fs.create_file(os.path.join(log_dir, "carbontracker_output_log1.log"), contents=output_log_content) - self.fs.create_file(os.path.join(log_dir, "carbontracker_log1.log"), contents="std_log1 content") + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + contents=output_log_content, + ) + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + contents="std_log1 content", + ) - mock_listdir.return_value = ["carbontracker_output_log1.log", "carbontracker_log1.log"] + mock_listdir.return_value = [ + "10151_2024-03-26T105926Z_carbontracker_output.log", + "10151_2024-03-26T105926Z_carbontracker.log", + ] mock_isfile.side_effect = lambda path: path.endswith(".log") mock_open.return_value.read.return_value = output_log_content - total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption(log_dir) + total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption( + log_dir + ) self.assertEqual(total_energy, 0.009417) self.assertEqual(total_co2eq, 0.96049) @@ -465,7 +601,9 @@ def test_aggregate_consumption_actual(self, mock_isfile, mock_listdir, mock_open @mock.patch("carbontracker.parser.get_all_logs") @mock.patch("carbontracker.parser.get_consumption") @mock.patch("carbontracker.parser.get_early_stop") - def test_aggregate_consumption_both_none(self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs): + def test_aggregate_consumption_both_none( + self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs + ): log_dir = "/path/to/logs" output_log_path = "/path/to/logs/output_log1" std_log_path = "/path/to/logs/std_log1" @@ -477,7 +615,9 @@ def test_aggregate_consumption_both_none(self, mock_get_early_stop, mock_get_con mock_get_consumption.return_value = (None, None) mock_get_early_stop.return_value = False - total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption(log_dir) + total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption( + log_dir + ) expected_total_energy = 0 expected_total_co2eq = 0 @@ -490,7 +630,9 @@ def test_aggregate_consumption_both_none(self, mock_get_early_stop, mock_get_con @mock.patch("carbontracker.parser.get_all_logs") @mock.patch("carbontracker.parser.get_consumption") @mock.patch("carbontracker.parser.get_early_stop") - def test_aggregate_consumption_actual_none(self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs): + def test_aggregate_consumption_actual_none( + self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs + ): log_dir = "/path/to/logs" output_log_path = "/path/to/logs/output_log1" std_log_path = "/path/to/logs/std_log1" @@ -499,10 +641,15 @@ def test_aggregate_consumption_actual_none(self, mock_get_early_stop, mock_get_c self.fs.create_file(std_log_path, contents="std_log_content") mock_get_all_logs.return_value = ([output_log_path], [std_log_path]) - mock_get_consumption.return_value = (None, {"energy (kWh)": 1, "co2eq (g)": 2, "equivalents": {"km": 3}}) + mock_get_consumption.return_value = ( + None, + {"energy (kWh)": 1, "co2eq (g)": 2, "equivalents": {"km": 3}}, + ) mock_get_early_stop.return_value = False - total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption(log_dir) + total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption( + log_dir + ) expected_total_energy = 1 expected_total_co2eq = 2 @@ -515,7 +662,9 @@ def test_aggregate_consumption_actual_none(self, mock_get_early_stop, mock_get_c @mock.patch("carbontracker.parser.get_all_logs") @mock.patch("carbontracker.parser.get_consumption") @mock.patch("carbontracker.parser.get_early_stop") - def test_aggregate_consumption_pred_none(self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs): + def test_aggregate_consumption_pred_none( + self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs + ): log_dir = "/path/to/logs" output_log_path = "/path/to/logs/output_log1" std_log_path = "/path/to/logs/std_log1" @@ -524,10 +673,15 @@ def test_aggregate_consumption_pred_none(self, mock_get_early_stop, mock_get_con self.fs.create_file(std_log_path, contents="std_log_content") mock_get_all_logs.return_value = ([output_log_path], [std_log_path]) - mock_get_consumption.return_value = ({"energy (kWh)": 1, "co2eq (g)": 2, "equivalents": {"km": 3}}, None) + mock_get_consumption.return_value = ( + {"energy (kWh)": 1, "co2eq (g)": 2, "equivalents": {"km": 3}}, + None, + ) mock_get_early_stop.return_value = False - total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption(log_dir) + total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption( + log_dir + ) expected_total_energy = 1 expected_total_co2eq = 2 @@ -540,7 +694,9 @@ def test_aggregate_consumption_pred_none(self, mock_get_early_stop, mock_get_con @mock.patch("carbontracker.parser.get_all_logs") @mock.patch("carbontracker.parser.get_consumption") @mock.patch("carbontracker.parser.get_early_stop") - def test_aggregate_consumption_both_available(self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs): + def test_aggregate_consumption_both_available( + self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs + ): log_dir = "/path/to/logs" output_log_path = "/path/to/logs/output_log1" std_log_path = "/path/to/logs/std_log1" @@ -587,7 +743,9 @@ def test_aggregate_consumption_both_available(self, mock_get_early_stop, mock_ge ) mock_get_early_stop.return_value = False - total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption(log_dir) + total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption( + log_dir + ) expected_total_energy = 0.014018 expected_total_co2eq = 1.429803 @@ -600,7 +758,9 @@ def test_aggregate_consumption_both_available(self, mock_get_early_stop, mock_ge @mock.patch("carbontracker.parser.get_all_logs") @mock.patch("carbontracker.parser.get_consumption") @mock.patch("carbontracker.parser.get_early_stop") - def test_aggregate_consumption_multiple_files(self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs): + def test_aggregate_consumption_multiple_files( + self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs + ): log_dir = "/path/to/logs" output_log_path1 = "/path/to/logs/output_log1" std_log_path1 = "/path/to/logs/std_log1" @@ -635,7 +795,10 @@ def test_aggregate_consumption_multiple_files(self, mock_get_early_stop, mock_ge self.fs.create_file(output_log_path2, contents=output_log_content2) self.fs.create_file(std_log_path2, contents=std_log_content2) - mock_get_all_logs.return_value = ([output_log_path1, output_log_path2], [std_log_path1, std_log_path2]) + mock_get_all_logs.return_value = ( + [output_log_path1, output_log_path2], + [std_log_path1, std_log_path2], + ) mock_get_consumption.side_effect = [ ( { @@ -660,7 +823,9 @@ def test_aggregate_consumption_multiple_files(self, mock_get_early_stop, mock_ge ] mock_get_early_stop.return_value = False - total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption(log_dir) + total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption( + log_dir + ) expected_total_energy = 0.010512 # Sum of energy from both logs expected_total_co2eq = 1.072353 @@ -668,13 +833,18 @@ def test_aggregate_consumption_multiple_files(self, mock_get_early_stop, mock_ge self.assertAlmostEqual(total_energy, expected_total_energy, places=6) self.assertAlmostEqual(total_co2eq, expected_total_co2eq, places=6) - self.assertAlmostEqual(total_equivalents['km travelled by car'], - expected_total_equivalents['km travelled by car'], places=6) + self.assertAlmostEqual( + total_equivalents["km travelled by car"], + expected_total_equivalents["km travelled by car"], + places=6, + ) @mock.patch("carbontracker.parser.get_all_logs") @mock.patch("carbontracker.parser.get_consumption") @mock.patch("carbontracker.parser.get_early_stop") - def test_aggregate_consumption_early_stop(self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs): + def test_aggregate_consumption_early_stop( + self, mock_get_early_stop, mock_get_consumption, mock_get_all_logs + ): log_dir = "/path/to/logs" output_log_path = "/path/to/logs/output_log1" std_log_path = "/path/to/logs/std_log1" @@ -719,7 +889,9 @@ def test_aggregate_consumption_early_stop(self, mock_get_early_stop, mock_get_co ) mock_get_early_stop.return_value = True - total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption(log_dir) + total_energy, total_co2eq, total_equivalents = parser.aggregate_consumption( + log_dir + ) expected_total_energy = 0.003504 # Energy from actual expected_total_co2eq = 0.357451 @@ -727,8 +899,11 @@ def test_aggregate_consumption_early_stop(self, mock_get_early_stop, mock_get_co self.assertAlmostEqual(total_energy, expected_total_energy, places=6) self.assertAlmostEqual(total_co2eq, expected_total_co2eq, places=6) - self.assertAlmostEqual(total_equivalents["km travelled by car"], - expected_total_equivalents["km travelled by car"], places=6) + self.assertAlmostEqual( + total_equivalents["km travelled by car"], + expected_total_equivalents["km travelled by car"], + places=6, + ) def test_get_time_no_match(self): time_str = "Invalid time string" @@ -736,22 +911,31 @@ def test_get_time_no_match(self): assert result is None @mock.patch("builtins.print") - @mock.patch("carbontracker.parser.aggregate_consumption", return_value=(100.0, 50000.0, {})) - def test_print_aggregate_empty_equivalents(self, mock_aggregate_consumption, mock_print): + @mock.patch( + "carbontracker.parser.aggregate_consumption", return_value=(100.0, 50000.0, {}) + ) + def test_print_aggregate_empty_equivalents( + self, mock_aggregate_consumption, mock_print + ): log_dir = "/logs" print_aggregate(log_dir) mock_print.assert_called_once_with( - "The training of models in this work is estimated to use 100.000 kWh of electricity contributing to 50.000 kg of CO2eq. Measured by carbontracker (https://github.com/lfwa/carbontracker)." + "The training of models in this work is estimated to use 100.0000000000000000 kWh of electricity contributing to 50.0000000000000000 kg of CO2eq. Measured by carbontracker (https://github.com/lfwa/carbontracker)." ) @mock.patch("builtins.print") - @mock.patch("carbontracker.parser.aggregate_consumption", return_value=(100.0, 50000.0, {"km travelled": 200.0})) - def test_print_aggregate_non_empty_equivalents(self, mock_aggregate_consumption, mock_print): + @mock.patch( + "carbontracker.parser.aggregate_consumption", + return_value=(100.0, 5000.0, {"km travelled": 200.0}), + ) + def test_print_aggregate_non_empty_equivalents( + self, mock_aggregate_consumption, mock_print + ): log_dir = "/logs" print_aggregate(log_dir) mock_print.assert_called_once_with( - "The training of models in this work is estimated to use 100.000 kWh of electricity contributing to 50.000 kg of CO2eq. " - "This is equivalent to 200.000 km travelled. " + "The training of models in this work is estimated to use 100.0000000000000000 kWh of electricity contributing to 5.0000000000000000 kg of CO2eq. " + "This is equivalent to 200.0000000000000000 km travelled. " "Measured by carbontracker (https://github.com/lfwa/carbontracker)." ) @@ -766,3 +950,45 @@ def test_parse_equivalents_value_error(self): lines = "not_a_float equivalent1\n10.5 equivalent2" equivalents = parse_equivalents(lines) self.assertEqual({"equivalent2": 10.5}, equivalents) + + @mock.patch("builtins.open", new_callable=mock.mock_open) + @mock.patch("os.listdir") + @mock.patch("os.path.isfile") + @mock.patch("carbontracker.parser.get_devices") + def test_parse_epoch_mismatch( + self, mock_get_devices, mock_isfile, mock_listdir, mock_open + ): + log_dir = "/path/to/logs" + + std_log_data = ( + "2022-11-14 15:44:48 - Average power usage (W) for gpu: [136.86084615]\n" + "2022-11-14 15:44:48 - Average power usage (W) for cpu: [13.389104]\n" + "2022-11-14 15:44:48 - Average power usage (W) for gpu: [136.86084615]\n" + "2022-11-14 15:44:48 - Average power usage (W) for cpu: [13.389104]\n" + "2022-11-14 15:44:48 - Epoch 1:\nDuration: 0:02:21.90" + ) + + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log"), + contents="output_log1 content", + ) + self.fs.create_file( + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + contents=std_log_data, + ) + + mock_isfile.side_effect = lambda path: path.endswith(".log") + mock_open.return_value.read.return_value = std_log_data + mock_get_devices.return_value = { + "gpu": ["NVIDIA GeForce RTX 3060"], + "cpu": ["cpu:0"], + } + + with self.assertRaises(exceptions.MismatchedEpochsError): + components = parser.parse_logs( + log_dir, + os.path.join(log_dir, "10151_2024-03-26T105926Z_carbontracker.log"), + os.path.join( + log_dir, "10151_2024-03-26T105926Z_carbontracker_output.log" + ), + ) diff --git a/tests/test_tracker.py b/tests/test_tracker.py index 7bfb9d3..a3db21b 100644 --- a/tests/test_tracker.py +++ b/tests/test_tracker.py @@ -3,13 +3,19 @@ import time import traceback import unittest -from unittest import mock +from unittest import mock, skipIf from unittest.mock import Mock, patch, MagicMock from threading import Event +from typing import List, Any import numpy as np from carbontracker import exceptions, constants -from carbontracker.tracker import CarbonIntensityThread, CarbonTrackerThread, CarbonTracker +from carbontracker.tracker import ( + CarbonIntensityThread, + CarbonTrackerThread, + CarbonTracker, +) +from carbontracker.components.component import Component from carbontracker.components.gpu import nvidia from carbontracker.components.cpu import intel @@ -61,7 +67,9 @@ def test_predict_carbon_intensity(self, mock_intensity, mock_carbon_intensity): ci = thread.predict_carbon_intensity(pred_time_dur) self.assertEqual(ci.carbon_intensity, 10.5) - mock_intensity.set_carbon_intensity_message.assert_called_with(ci, pred_time_dur) + mock_intensity.set_carbon_intensity_message.assert_called_with( + ci, pred_time_dur + ) self.logger.info.assert_called() self.logger.output.assert_called() @@ -121,9 +129,9 @@ def test_average_carbon_intensity_empty_intensities(self, mock_carbon_intensity) class TestCarbonTrackerThread(unittest.TestCase): def setUp(self): - self.mock_components = [ + self.mock_components: List[Any] = [ MagicMock(name="Component1"), - MagicMock(name="Component2") + MagicMock(name="Component2"), ] for component in self.mock_components: @@ -133,12 +141,15 @@ def setUp(self): self.mock_delete = MagicMock(name="Delete") self.thread = CarbonTrackerThread( - self.mock_components, self.mock_logger, False, self.mock_delete, update_interval=0.1 + self.mock_components, + self.mock_logger, + False, + self.mock_delete, + update_interval=0.1, ) def tearDown(self): self.thread.running = False - self.thread.measuring = False self.thread.epoch_counter = 0 self.thread.epoch_times = [] @@ -147,8 +158,12 @@ def test_stop_tracker(self): self.thread.stop() self.assertFalse(self.thread.running) - self.mock_logger.info.assert_called_with("Monitoring thread ended.") - self.mock_logger.output.assert_called_with("Finished monitoring.", verbose_level=1) + + # assert_any_call because different log statements races in Python 3.11 in Github Actions + self.mock_logger.info.assert_any_call("Monitoring thread ended.") + self.mock_logger.output.assert_called_with( + "Finished monitoring.", verbose_level=1 + ) def test_stop_tracker_not_running(self): self.thread.running = False @@ -156,8 +171,14 @@ def test_stop_tracker_not_running(self): assert result is None - @patch('carbontracker.components.component.component_names', return_value=["gpu", "cpu"]) - @patch('carbontracker.components.component.handlers_by_name', return_value=[nvidia.NvidiaGPU, intel.IntelCPU]) + @patch( + "carbontracker.components.component.component_names", + return_value=["gpu", "cpu"], + ) + @patch( + "carbontracker.components.component.handlers_by_name", + return_value=[nvidia.NvidiaGPU, intel.IntelCPU], + ) def test_run_and_measure(self, mock_component_names, mock_handlers_by_name): self.thread.epoch_start() @@ -168,7 +189,10 @@ def test_run_and_measure(self, mock_component_names, mock_handlers_by_name): component.collect_power_usage.assert_called_with(self.thread.epoch_counter) def test_init(self): - mock_components = [MagicMock(name="Component1"), MagicMock(name="Component2")] + mock_components: List[Component] = [ + MagicMock(name="Component1"), + MagicMock(name="Component2"), + ] mock_logger = MagicMock(name="Logger") mock_delete = MagicMock(name="Delete") @@ -191,7 +215,9 @@ def test_run_with_exception_ignore_errors(self): self.thread._components_shutdown = MagicMock() self.thread.ignore_errors = True - self.thread._collect_measurements = MagicMock(side_effect=Exception("Mocked exception")) + self.thread._collect_measurements = MagicMock( + side_effect=Exception("Mocked exception") + ) self.thread.logger.err_critical = MagicMock() self.thread.logger.output = MagicMock() @@ -199,16 +225,13 @@ def test_run_with_exception_ignore_errors(self): os._exit = MagicMock() self.thread.running = True - self.thread.measuring = True time.sleep(0.2) - self.thread.measuring = False self.assertFalse(os._exit.called) def test_epoch_start(self): self.thread.epoch_counter = 0 - self.thread.measuring = False self.thread.epoch_start() @@ -216,8 +239,9 @@ def test_epoch_start(self): self.assertIsNotNone(self.thread.cur_epoch_time) def test_epoch_end(self): - self.thread.measuring = True - self.thread.cur_epoch_time = time.time() - 1 # Set a non-zero value for cur_epoch_time + self.thread.cur_epoch_time = ( + time.time() - 1 + ) # Set a non-zero value for cur_epoch_time self.thread.epoch_end() time.sleep(0.2) @@ -226,19 +250,20 @@ def test_epoch_end(self): self.assertAlmostEqual(self.thread.epoch_times[-1], 1, delta=0.1) def test_epoch_end_too_short(self): - mock_component = MagicMock(name="Component") + mock_component: Any = MagicMock(name="Component") mock_component.power_usages = [] self.thread.components = [mock_component] - self.thread.measuring = True self.thread.cur_epoch_time = time.time() self.thread.epoch_end() self.assertTrue(self.thread.epoch_times) self.assertIsNotNone(self.thread.epoch_times[-1]) - self.mock_logger.err_warn.assert_called_with("Epoch duration is too short for a measurement to be collected.") + self.mock_logger.err_warn.assert_called_with( + "Epoch duration is too short for a measurement to be collected." + ) def test_no_components_available(self): self.thread.components = [] @@ -247,9 +272,9 @@ def test_no_components_available(self): self.thread.begin() def test_total_energy_per_epoch(self): - mock_component1 = MagicMock(name="Component1") + mock_component1: Any = MagicMock(name="Component1") mock_component1.energy_usage.return_value = np.array([1.0, 2.0, 3.0]) - mock_component2 = MagicMock(name="Component2") + mock_component2: Any = MagicMock(name="Component2") mock_component2.energy_usage.return_value = np.array([2.0, 3.0, 4.0]) self.thread.components = [mock_component1, mock_component2] @@ -258,14 +283,13 @@ def test_total_energy_per_epoch(self): total_energy = self.thread.total_energy_per_epoch() - expected_total_energy = np.array([3.0, 5.0, 7.0]) * constants.PUE_2022 + expected_total_energy = np.array([3.0, 5.0, 7.0]) * constants.PUE_2023 np.testing.assert_array_equal(total_energy, expected_total_energy) - - @mock.patch('os._exit') + @mock.patch("os._exit") def test_handle_error_ignore(self, mock_os_exit): self.thread.ignore_errors = True - error = Exception('Test error') + error = Exception("Test error") expected_err_str = f"Ignored error: {traceback.format_exc()}Continued training without monitoring..." self.thread._handle_error(error) @@ -275,22 +299,21 @@ def test_handle_error_ignore(self, mock_os_exit): self.thread.delete.assert_called() mock_os_exit.assert_not_called() - - @mock.patch('os._exit') + @mock.patch("os._exit") def test_handle_error_no_ignore_errors(self, mock_os_exit): self.thread.ignore_errors = False self.thread.logger = self.mock_logger - self.thread._handle_error(Exception('Test exception')) + self.thread._handle_error(Exception("Test exception")) self.mock_logger.err_critical.assert_called() self.mock_logger.output.assert_called() mock_os_exit.assert_called_with(70) - @mock.patch('carbontracker.tracker.CarbonTrackerThread._handle_error') + @mock.patch("carbontracker.tracker.CarbonTrackerThread._handle_error") def test_run_exception_handling(self, mock_handle_error): mock_wait = mock.MagicMock() - mock_wait.side_effect = Exception('Test exception') + mock_wait.side_effect = Exception("Test exception") self.thread.measuring_event.wait = mock_wait self.thread.run() @@ -304,11 +327,19 @@ def setUp(self): self.mock_tracker_thread = MagicMock() self.mock_intensity_thread = MagicMock() - with patch('carbontracker.tracker.CarbonTrackerThread', return_value=self.mock_tracker_thread), \ - patch('carbontracker.tracker.CarbonIntensityThread', return_value=self.mock_intensity_thread), \ - patch('carbontracker.tracker.loggerutil.Logger', return_value=self.mock_logger), \ - patch('carbontracker.tracker.CarbonTracker._output_actual') as self.mock_output_actual, \ - patch('carbontracker.tracker.CarbonTracker._delete') as self.mock_delete: + with patch( + "carbontracker.tracker.CarbonTrackerThread", + return_value=self.mock_tracker_thread, + ), patch( + "carbontracker.tracker.CarbonIntensityThread", + return_value=self.mock_intensity_thread, + ), patch( + "carbontracker.tracker.loggerutil.Logger", return_value=self.mock_logger + ), patch( + "carbontracker.tracker.CarbonTracker._output_actual" + ) as self.mock_output_actual, patch( + "carbontracker.tracker.CarbonTracker._delete" + ) as self.mock_delete: self.tracker = CarbonTracker( epochs=5, epochs_before_pred=1, @@ -332,44 +363,58 @@ def tearDown(self): self.tracker = None def test_epoch_start_increments_epoch_counter_and_starts_measurement(self): + assert self.tracker is not None + assert self.mock_tracker_thread is not None initial_epoch_counter = self.tracker.epoch_counter self.tracker.epoch_start() self.assertEqual(self.tracker.epoch_counter, initial_epoch_counter + 1) self.assertTrue(self.mock_tracker_thread.measuring_event.is_set()) def test_check_input_yes(self): - with patch('builtins.input', return_value='y'): - self.tracker._check_input('y') + with patch("builtins.input", return_value="y"): + assert self.tracker is not None + assert self.mock_logger is not None + self.tracker._check_input("y") self.mock_logger.output.assert_called_with("Continuing...") def test_check_input_no(self): - with patch('builtins.input', return_value='n'): + assert self.tracker is not None + with patch("builtins.input", return_value="n"): with self.assertRaises(SystemExit): - self.tracker._check_input('n') + self.tracker._check_input("n") - @patch('carbontracker.tracker.CarbonTracker._check_input') + @patch("carbontracker.tracker.CarbonTracker._check_input") def test_user_query(self, mock_check_input): - with patch('builtins.input', return_value='y'), \ - patch.object(self.tracker.logger, 'output') as mock_logger_output: + assert self.tracker is not None + with patch("builtins.input", return_value="y"), patch.object( + self.tracker.logger, "output" + ) as mock_logger_output: self.tracker._user_query() mock_logger_output.assert_called_once_with("Continue training (y/n)?") mock_check_input.assert_called_once() def test_check_input_invalid(self): - with patch('builtins.input', side_effect=['a', 'y']): - self.tracker._check_input('a') - self.mock_logger.output.assert_any_call("Input not recognized. Try again (y/n):") - self.tracker._check_input('y') + assert self.tracker is not None + assert self.mock_logger is not None + with patch("builtins.input", side_effect=["a", "y"]): + self.tracker._check_input("a") + self.mock_logger.output.assert_any_call( + "Input not recognized. Try again (y/n):" + ) + self.tracker._check_input("y") self.mock_logger.output.assert_any_call("Continuing...") def test_delete(self): + assert self.tracker is not None + assert self.mock_tracker_thread is not None self.tracker._delete() self.mock_tracker_thread.stop.assert_called_once() self.assertTrue(self.tracker.deleted) - @patch('carbontracker.tracker.psutil.Process') + @patch("carbontracker.tracker.psutil.Process") def test_get_pids(self, mock_process): + assert self.tracker is not None mock_process.return_value.pid = 1234 mock_process.return_value.children.return_value = [MagicMock(pid=5678)] pids = self.tracker._get_pids() @@ -377,6 +422,8 @@ def test_get_pids(self, mock_process): def test_stop_when_already_deleted(self): """Test the stop method when the tracker has already been marked as deleted.""" + assert self.tracker is not None + assert self.mock_logger is not None self.tracker.deleted = True self.tracker.stop() @@ -385,8 +432,9 @@ def test_stop_when_already_deleted(self): self.mock_output_actual.assert_not_called() self.mock_delete.assert_not_called() - @patch('carbontracker.tracker.CarbonTracker._output_actual') + @patch("carbontracker.tracker.CarbonTracker._output_actual") def test_stop_behavior(self, mock_output_actual): + assert self.tracker is not None self.assertFalse(self.tracker.deleted) initial_epoch_counter = 2 @@ -394,38 +442,52 @@ def test_stop_behavior(self, mock_output_actual): self.tracker.stop() expected_epoch_counter = initial_epoch_counter - 1 - self.assertEqual(self.tracker.epoch_counter, expected_epoch_counter, - "Epoch counter should be decremented by 1.") + self.assertEqual( + self.tracker.epoch_counter, + expected_epoch_counter, + "Epoch counter should be decremented by 1.", + ) mock_output_actual.assert_called_once() - self.assertTrue(self.tracker.deleted, "Tracker should be marked as deleted after stop is called.") + self.assertTrue( + self.tracker.deleted, + "Tracker should be marked as deleted after stop is called.", + ) def test_epoch_end_when_deleted(self): + assert self.tracker is not None + assert self.mock_tracker_thread is not None self.tracker.deleted = True self.tracker.epoch_end() self.mock_tracker_thread.epoch_end.assert_not_called() - @patch('carbontracker.tracker.CarbonTracker._output_actual', autospec=True) - @patch('carbontracker.tracker.CarbonTracker._delete', autospec=True) + @patch("carbontracker.tracker.CarbonTracker._output_actual", autospec=True) + @patch("carbontracker.tracker.CarbonTracker._delete", autospec=True) def test_epoch_end_output_actual_and_delete(self, mock_delete, mock_output_actual): + assert self.tracker is not None self.tracker.epoch_counter = self.tracker.monitor_epochs self.tracker.epoch_end() mock_output_actual.assert_called_once() mock_delete.assert_called_once() - @patch('carbontracker.tracker.CarbonTracker._output_pred', autospec=True) - @patch('carbontracker.tracker.CarbonTracker._user_query', autospec=True) - def test_epoch_end_output_pred_and_user_query(self, mock_user_query, mock_output_pred): + @patch("carbontracker.tracker.CarbonTracker._output_pred", autospec=True) + @patch("carbontracker.tracker.CarbonTracker._user_query", autospec=True) + def test_epoch_end_output_pred_and_user_query( + self, mock_user_query, mock_output_pred + ): + assert self.tracker is not None self.tracker.epoch_counter = self.tracker.epochs_before_pred self.tracker.epoch_end() mock_output_pred.assert_called_once() mock_user_query.assert_called_once() - @patch('carbontracker.tracker.CarbonTracker._handle_error', autospec=True) + @patch("carbontracker.tracker.CarbonTracker._handle_error", autospec=True) def test_epoch_end_exception_handling(self, mock_handle_error): + assert self.tracker is not None + assert self.mock_tracker_thread is not None self.mock_tracker_thread.epoch_end.side_effect = Exception("Test Exception") self.tracker.epoch_end() @@ -467,8 +529,9 @@ def test_invalid_monitor_epochs_less_than_epochs_before_pred(self): decimal_precision=6, ) - @patch('carbontracker.tracker.CarbonTracker._handle_error') + @patch("carbontracker.tracker.CarbonTracker._handle_error") def test_epoch_start_deleted(self, mock_handle_error): + assert self.tracker is not None self.tracker.deleted = True self.tracker.epoch_start() @@ -476,9 +539,12 @@ def test_epoch_start_deleted(self, mock_handle_error): mock_handle_error.assert_not_called() - @patch('carbontracker.tracker.CarbonTrackerThread.epoch_start') - @patch('carbontracker.tracker.CarbonTracker._handle_error') - def test_epoch_start_exception(self, mock_handle_error, mock_tracker_thread_epoch_start): + @skipIf(os.environ.get("CI") == "true", "Skipped due to CI") + @patch("carbontracker.tracker.CarbonTrackerThread.epoch_start") + @patch("carbontracker.tracker.CarbonTracker._handle_error") + def test_epoch_start_exception( + self, mock_handle_error, mock_tracker_thread_epoch_start + ): tracker = CarbonTracker( epochs=5, epochs_before_pred=1, @@ -504,16 +570,22 @@ def test_epoch_start_exception(self, mock_handle_error, mock_tracker_thread_epoc mock_handle_error.assert_called_once() def test_handle_error_ignore_errors(self): + assert self.tracker is not None + assert self.mock_logger is not None self.tracker.ignore_errors = True - self.tracker._handle_error(Exception('Test exception')) + self.tracker._handle_error(Exception("Test exception")) self.mock_logger.err_critical.assert_called_once() def test_handle_error_no_ignore_errors(self): + assert self.tracker is not None self.tracker.ignore_errors = False with self.assertRaises(SystemExit): - self.tracker._handle_error(Exception('Test exception')) + self.tracker._handle_error(Exception("Test exception")) - @patch('carbontracker.emissions.intensity.fetchers.electricitymaps.ElectricityMap.set_api_key') + @skipIf(os.environ.get("CI") == "true", "Skipped due to CI") + @patch( + "carbontracker.emissions.intensity.fetchers.electricitymaps.ElectricityMap.set_api_key" + ) def test_set_api_keys_electricitymaps(self, mock_set_api_key): tracker = CarbonTracker(epochs=1) api_dict = {"ElectricityMaps": "mock_api_key"} @@ -521,7 +593,8 @@ def test_set_api_keys_electricitymaps(self, mock_set_api_key): mock_set_api_key.assert_called_once_with("mock_api_key") - @patch('carbontracker.tracker.CarbonTracker.set_api_keys') + @skipIf(os.environ.get("CI") == "true", "Skipped due to CI") + @patch("carbontracker.tracker.CarbonTracker.set_api_keys") def test_carbontracker_api_key(self, mock_set_api_keys): api_dict = {"ElectricityMaps": "mock_api_key"} _tracker = CarbonTracker(epochs=1, api_keys=api_dict) @@ -529,6 +602,9 @@ def test_carbontracker_api_key(self, mock_set_api_keys): mock_set_api_keys.assert_called_once_with(api_dict) def test_output_energy(self): + assert self.tracker is not None + assert self.mock_logger is not None + description = "Test description" time = 1000 energy = 50.123 @@ -546,11 +622,18 @@ def test_output_energy(self): "\n\t100.000000 km" "\n\t200.000000 kg" ) - self.mock_logger.output.assert_called_once_with(expected_output, verbose_level=1) + self.mock_logger.output.assert_called_once_with( + expected_output, verbose_level=1 + ) def test_output_actual_zero_epochs(self): + assert self.tracker is not None + assert self.mock_logger is not None + self.tracker.epochs_before_pred = 0 - self.tracker.tracker.total_energy_per_epoch = MagicMock(return_value=np.array([10, 20, 30])) + self.tracker.tracker.total_energy_per_epoch = MagicMock( + return_value=np.array([10, 20, 30]) + ) self.tracker.tracker.epoch_times = [100, 200, 300] self.tracker._co2eq = MagicMock(return_value=150) self.tracker.interpretable = True @@ -566,12 +649,19 @@ def test_output_actual_zero_epochs(self): "\t1.395349 km travelled by car" ) - self.mock_logger.output.assert_called_once_with(expected_output, verbose_level=1) + self.mock_logger.output.assert_called_once_with( + expected_output, verbose_level=1 + ) def test_output_actual_nonzero_epochs(self): + assert self.tracker is not None + assert self.mock_logger is not None + self.tracker.epochs_before_pred = 1 self.tracker.epoch_counter = 2 - self.tracker.tracker.total_energy_per_epoch = MagicMock(return_value=np.array([10, 20, 30])) + self.tracker.tracker.total_energy_per_epoch = MagicMock( + return_value=np.array([10, 20, 30]) + ) self.tracker.tracker.epoch_times = [100, 200, 300] self.tracker._co2eq = MagicMock(return_value=150) self.tracker.interpretable = True @@ -589,15 +679,22 @@ def test_output_actual_nonzero_epochs(self): "\t1.395349 km travelled by car" ) - self.mock_logger.output.assert_called_once_with(expected_output, verbose_level=1) + self.mock_logger.output.assert_called_once_with( + expected_output, verbose_level=1 + ) def test_output_pred(self): + assert self.tracker is not None + assert self.mock_logger is not None + predictor = MagicMock() predictor.predict_energy = MagicMock(return_value=100) predictor.predict_time = MagicMock(return_value=1000) self.tracker.epochs = 5 - self.tracker.tracker.total_energy_per_epoch = MagicMock(return_value=[10, 20, 30]) + self.tracker.tracker.total_energy_per_epoch = MagicMock( + return_value=[10, 20, 30] + ) self.tracker.tracker.epoch_times = [100, 200, 300] self.tracker._co2eq = MagicMock(return_value=150) self.tracker.interpretable = True @@ -615,11 +712,16 @@ def test_output_pred(self): "\t1.395349 km travelled by car" ) - self.mock_logger.output.assert_called_once_with(expected_output, verbose_level=1) + self.mock_logger.output.assert_called_once_with( + expected_output, verbose_level=1 + ) def test_co2eq_with_pred_time_dur(self): + assert self.tracker is not None intensity_updater = MagicMock() - intensity_updater.predict_carbon_intensity = MagicMock(return_value=MagicMock(carbon_intensity=0.5)) + intensity_updater.predict_carbon_intensity = MagicMock( + return_value=MagicMock(carbon_intensity=0.5) + ) energy_usage = 100 pred_time_dur = 1000 @@ -632,8 +734,11 @@ def test_co2eq_with_pred_time_dur(self): self.assertEqual(co2eq, expected_co2eq) def test_co2eq_without_pred_time_dur(self): + assert self.tracker is not None intensity_updater = MagicMock() - intensity_updater.average_carbon_intensity = MagicMock(return_value=MagicMock(carbon_intensity=0.5)) + intensity_updater.average_carbon_intensity = MagicMock( + return_value=MagicMock(carbon_intensity=0.5) + ) energy_usage = 100 @@ -644,26 +749,86 @@ def test_co2eq_without_pred_time_dur(self): expected_co2eq = 50 self.assertEqual(co2eq, expected_co2eq) - @patch('sys.exit') + @patch("sys.exit") def test_set_api_keys_with_invalid_name_exits(self, mock_exit): - self.tracker.set_api_keys({'invalid_name': 'test_key'}) + assert self.tracker is not None + self.tracker.set_api_keys({"invalid_name": "test_key"}) mock_exit.assert_called_once_with(70) - @mock.patch('carbontracker.tracker.CarbonTracker._get_pids') - @mock.patch('carbontracker.tracker.loggerutil.Logger') - @mock.patch('carbontracker.tracker.CarbonTrackerThread') - @mock.patch('carbontracker.tracker.CarbonIntensityThread') - def test_exception_handling(self, mock_intensity_thread, mock_tracker_thread, mock_logger, mock_get_pids): - mock_get_pids.side_effect = Exception('Test exception in _get_pids') - mock_logger.side_effect = Exception('Test exception in Logger initialization') - mock_tracker_thread.side_effect = Exception('Test exception in CarbonTrackerThread initialization') - mock_intensity_thread.side_effect = Exception('Test exception in CarbonIntensityThread initialization') + @mock.patch("carbontracker.tracker.CarbonTracker._get_pids") + @mock.patch("carbontracker.tracker.loggerutil.Logger") + @mock.patch("carbontracker.tracker.CarbonTrackerThread") + @mock.patch("carbontracker.tracker.CarbonIntensityThread") + def test_exception_handling( + self, mock_intensity_thread, mock_tracker_thread, mock_logger, mock_get_pids + ): + mock_get_pids.side_effect = Exception("Test exception in _get_pids") + mock_logger.side_effect = Exception("Test exception in Logger initialization") + mock_tracker_thread.side_effect = Exception( + "Test exception in CarbonTrackerThread initialization" + ) + mock_intensity_thread.side_effect = Exception( + "Test exception in CarbonIntensityThread initialization" + ) with self.assertRaises(Exception) as context: - CarbonTracker(log_dir=None, verbose=False, log_file_prefix='', epochs=1) - - self.assertEqual(str(context.exception), "'CarbonTracker' object has no attribute 'logger'") + CarbonTracker(log_dir=None, verbose=False, log_file_prefix="", epochs=1) + self.assertEqual( + str(context.exception), "'CarbonTracker' object has no attribute 'logger'" + ) -if __name__ == '__main__': + # # Instantiating a second instance should not make this instance log twice + # @mock.patch("carbontracker.tracker.CarbonIntensityThread") + # def test_multiple_instances(self, mock_intensity_thread): + # assert self.mock_logger is not None + # assert self.tracker is not None + + # tracker2 = CarbonTracker( + # epochs=5, + # epochs_before_pred=1, + # monitor_epochs=3, + # update_interval=10, + # interpretable=True, + # stop_and_confirm=True, + # ignore_errors=False, + # components="all", + # devices_by_pid=False, + # log_dir=None, + # log_file_prefix="", + # verbose=1, + # decimal_precision=6, + # ) + + # predictor = MagicMock() + # predictor.predict_energy = MagicMock(return_value=100) + # predictor.predict_time = MagicMock(return_value=1000) + + # self.tracker.epochs = 5 + # self.tracker.tracker.total_energy_per_epoch = MagicMock( + # return_value=[10, 20, 30] + # ) + # self.tracker.tracker.epoch_times = [100, 200, 300] + # self.tracker._co2eq = MagicMock(return_value=150) + # self.tracker.interpretable = True + + # self.tracker._output_pred() + + # expected_description = "Predicted consumption for 5 epoch(s):" + + # expected_output = ( + # f"\n{expected_description}\n" + # "\tTime:\t0:16:40\n" + # "\tEnergy:\t100.000000 kWh\n" + # "\tCO2eq:\t150.000000 g" + # "\n\tThis is equivalent to:\n" + # "\t1.395349 km travelled by car" + # ) + + # self.mock_logger.output.assert_called_once_with( + # expected_output, verbose_level=1 + # ) + + +if __name__ == "__main__": unittest.main() diff --git a/tox.ini b/tox.ini index 273c5b1..9056375 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,8 @@ [tox] isolated_build = True -envlist = py38, py39, py310 - +envlist = py37, py38, py39, py310, py311, py312 + [testenv] +deps=pyfakefs commands = - python -m unittest discover + python -m unittest {posargs}