diff --git a/carbonserver/carbonserver/api/infra/database/sql_models.py b/carbonserver/carbonserver/api/infra/database/sql_models.py index 337771697..8872abb21 100644 --- a/carbonserver/carbonserver/api/infra/database/sql_models.py +++ b/carbonserver/carbonserver/api/infra/database/sql_models.py @@ -21,6 +21,9 @@ class Emission(Base): gpu_energy = Column(Float) ram_energy = Column(Float) energy_consumed = Column(Float) + cpu_utilization_percent = Column(Float, nullable=True) + gpu_utilization_percent = Column(Float, nullable=True) + ram_utilization_percent = Column(Float, nullable=True) wue = Column(Float, nullable=False, default=0) run_id = Column(UUID(as_uuid=True), ForeignKey("runs.id", ondelete="CASCADE")) run = relationship("Run", back_populates="emissions") diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_emissions.py b/carbonserver/carbonserver/api/infra/repositories/repository_emissions.py index 70056495d..893ab86e1 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_emissions.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_emissions.py @@ -41,6 +41,9 @@ def add_emission(self, emission: EmissionCreate) -> UUID: gpu_energy=emission.gpu_energy, ram_energy=emission.ram_energy, energy_consumed=emission.energy_consumed, + cpu_utilization_percent=emission.cpu_utilization_percent, + gpu_utilization_percent=emission.gpu_utilization_percent, + ram_utilization_percent=emission.ram_utilization_percent, wue=emission.wue, run_id=emission.run_id, ) @@ -105,6 +108,9 @@ def map_sql_to_schema(emission: sql_models.Emission) -> Emission: gpu_energy=emission.gpu_energy, ram_energy=emission.ram_energy, energy_consumed=emission.energy_consumed, + cpu_utilization_percent=emission.cpu_utilization_percent, + gpu_utilization_percent=emission.gpu_utilization_percent, + ram_utilization_percent=emission.ram_utilization_percent, wue=emission.wue, run_id=emission.run_id, ) diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_experiments.py b/carbonserver/carbonserver/api/infra/repositories/repository_experiments.py index df8a2173c..db729e667 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_experiments.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_experiments.py @@ -135,6 +135,15 @@ def get_project_detailed_sums_by_experiment( func.sum(SqlModelEmission.energy_consumed).label("energy_consumed"), func.sum(SqlModelEmission.duration).label("duration"), func.avg(SqlModelEmission.emissions_rate).label("emissions_rate"), + func.avg(SqlModelEmission.cpu_utilization_percent).label( + "cpu_utilization_percent" + ), + func.avg(SqlModelEmission.gpu_utilization_percent).label( + "gpu_utilization_percent" + ), + func.avg(SqlModelEmission.ram_utilization_percent).label( + "ram_utilization_percent" + ), func.count(SqlModelEmission.emissions_rate).label( "emissions_count" ), diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py b/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py index 4ef9e9963..754655650 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py @@ -116,6 +116,15 @@ def get_organization_detailed_sums( func.sum(SqlModelEmission.energy_consumed).label("energy_consumed"), func.sum(SqlModelEmission.duration).label("duration"), func.avg(SqlModelEmission.emissions_rate).label("emissions_rate"), + func.avg(SqlModelEmission.cpu_utilization_percent).label( + "cpu_utilization_percent" + ), + func.avg(SqlModelEmission.gpu_utilization_percent).label( + "gpu_utilization_percent" + ), + func.avg(SqlModelEmission.ram_utilization_percent).label( + "ram_utilization_percent" + ), func.count(SqlModelEmission.emissions_rate).label( "emissions_count" ), diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_projects.py b/carbonserver/carbonserver/api/infra/repositories/repository_projects.py index 77dc6dbf4..23a7181ca 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_projects.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_projects.py @@ -130,6 +130,15 @@ def get_project_detailed_sums( func.sum(SqlModelEmission.energy_consumed).label("energy_consumed"), func.sum(SqlModelEmission.duration).label("duration"), func.avg(SqlModelEmission.emissions_rate).label("emissions_rate"), + func.avg(SqlModelEmission.cpu_utilization_percent).label( + "cpu_utilization_percent" + ), + func.avg(SqlModelEmission.gpu_utilization_percent).label( + "gpu_utilization_percent" + ), + func.avg(SqlModelEmission.ram_utilization_percent).label( + "ram_utilization_percent" + ), func.count(SqlModelEmission.emissions_rate).label( "emissions_count" ), diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_runs.py b/carbonserver/carbonserver/api/infra/repositories/repository_runs.py index 84d2202b6..bef3fed48 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_runs.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_runs.py @@ -144,6 +144,15 @@ def get_experiment_detailed_sums_by_run( func.sum(SqlModelEmission.energy_consumed).label("energy_consumed"), func.sum(SqlModelEmission.duration).label("duration"), func.avg(SqlModelEmission.emissions_rate).label("emissions_rate"), + func.avg(SqlModelEmission.cpu_utilization_percent).label( + "cpu_utilization_percent" + ), + func.avg(SqlModelEmission.gpu_utilization_percent).label( + "gpu_utilization_percent" + ), + func.avg(SqlModelEmission.ram_utilization_percent).label( + "ram_utilization_percent" + ), func.count(SqlModelEmission.emissions_rate).label( "emissions_count" ), diff --git a/carbonserver/carbonserver/api/schemas.py b/carbonserver/carbonserver/api/schemas.py index e2365f208..21e5bcc91 100644 --- a/carbonserver/carbonserver/api/schemas.py +++ b/carbonserver/carbonserver/api/schemas.py @@ -87,6 +87,15 @@ class EmissionBase(BaseModel): ram_energy: Optional[float] = Field( ..., ge=0, description="The ram_energy must be greater than zero" ) + cpu_utilization_percent: Optional[float] = Field( + None, ge=0, le=100, description="The CPU utilization must be between 0 and 100" + ) + gpu_utilization_percent: Optional[float] = Field( + None, ge=0, le=100, description="The GPU utilization must be between 0 and 100" + ) + ram_utilization_percent: Optional[float] = Field( + None, ge=0, le=100, description="The RAM utilization must be between 0 and 100" + ) wue: Optional[float] = Field( default=0, ge=0, @@ -183,6 +192,9 @@ class RunReport(RunBase): duration: float emissions_rate: float emissions_count: int + cpu_utilization_percent: Optional[float] = None + gpu_utilization_percent: Optional[float] = None + ram_utilization_percent: Optional[float] = None class ExperimentBase(BaseModel): @@ -246,6 +258,9 @@ class ExperimentReport(ExperimentBase): duration: int emissions_rate: float emissions_count: int + cpu_utilization_percent: Optional[float] = None + gpu_utilization_percent: Optional[float] = None + ram_utilization_percent: Optional[float] = None class Config: schema_extra = { @@ -377,6 +392,9 @@ class ProjectReport(ProjectBase): duration: int emissions_rate: float emissions_count: int + cpu_utilization_percent: Optional[float] = None + gpu_utilization_percent: Optional[float] = None + ram_utilization_percent: Optional[float] = None class OrganizationBase(BaseModel): @@ -420,6 +438,9 @@ class OrganizationReport(OrganizationBase): duration: int emissions_rate: float emissions_count: int + cpu_utilization_percent: Optional[float] = None + gpu_utilization_percent: Optional[float] = None + ram_utilization_percent: Optional[float] = None class Membership(BaseModel): diff --git a/carbonserver/carbonserver/database/alembic/versions/20251119_add_utilization_metrics.py b/carbonserver/carbonserver/database/alembic/versions/20251119_add_utilization_metrics.py new file mode 100644 index 000000000..42cca9d84 --- /dev/null +++ b/carbonserver/carbonserver/database/alembic/versions/20251119_add_utilization_metrics.py @@ -0,0 +1,44 @@ +"""add_utilization_metrics_to_emissions + +Revision ID: 20251119_add_utilization +Revises: 3212895acafd +Create Date: 2025-11-19 18:52:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "20251119_add_utilization" +down_revision = "3212895acafd" +branch_labels = None +depends_on = None + + +def upgrade(): + """ + Add CPU, GPU, and RAM utilization percentage fields to emissions table. + These fields track the average utilization of resources during emission tracking. + """ + op.add_column( + "emissions", + sa.Column("cpu_utilization_percent", sa.Float, nullable=True), + ) + op.add_column( + "emissions", + sa.Column("gpu_utilization_percent", sa.Float, nullable=True), + ) + op.add_column( + "emissions", + sa.Column("ram_utilization_percent", sa.Float, nullable=True), + ) + + +def downgrade(): + """ + Remove CPU, GPU, and RAM utilization percentage fields from emissions table. + """ + op.drop_column("emissions", "ram_utilization_percent") + op.drop_column("emissions", "gpu_utilization_percent") + op.drop_column("emissions", "cpu_utilization_percent") diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 2bb73cbd2..05cea21b7 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -13,6 +13,8 @@ from functools import wraps from typing import Any, Callable, Dict, List, Optional, Union +import psutil + from codecarbon._version import __version__ from codecarbon.core.config import get_hierarchical_config from codecarbon.core.emissions import Emissions @@ -335,6 +337,11 @@ def __init__( self._last_measured_time: float = time.perf_counter() self._total_energy: Energy = Energy.from_energy(kWh=0) self._total_water: Water = Water.from_litres(litres=0) + # CPU and RAM utilization tracking + self._cpu_utilization_history: List[float] = [] + self._gpu_utilization_history: List[float] = [] + self._ram_utilization_history: List[float] = [] + self._ram_used_history: List[float] = [] self._total_cpu_energy: Energy = Energy.from_energy(kWh=0) self._total_gpu_energy: Energy = Energy.from_energy(kWh=0) self._total_ram_energy: Energy = Energy.from_energy(kWh=0) @@ -482,6 +489,13 @@ def start(self) -> None: return self._last_measured_time = self._start_time = time.perf_counter() + + # Clear utilization history for fresh measurements + self._cpu_utilization_history.clear() + self._ram_utilization_history.clear() + self._ram_used_history.clear() + self._gpu_utilization_history.clear() + # Read initial energy for hardware for hardware in self._hardware: hardware.start() @@ -525,6 +539,13 @@ def start_task(self, task_name=None) -> None: if task_name in self._tasks.keys(): task_name += "_" + uuid.uuid4().__str__() self._last_measured_time = self._start_time = time.perf_counter() + + # Clear utilization history for fresh measurements + self._cpu_utilization_history.clear() + self._ram_utilization_history.clear() + self._ram_used_history.clear() + self._gpu_utilization_history.clear() + # Read initial energy for hardware for hardware in self._hardware: hardware.start() @@ -782,6 +803,26 @@ def _prepare_emissions_data(self) -> EmissionsData: duration=duration.seconds, emissions=emissions, # kg emissions_rate=emissions / duration.seconds, # kg/s + cpu_utilization_percent=( + sum(self._cpu_utilization_history) / len(self._cpu_utilization_history) + if self._cpu_utilization_history + else 0 + ), + gpu_utilization_percent=( + sum(self._gpu_utilization_history) / len(self._gpu_utilization_history) + if self._gpu_utilization_history + else 0 + ), + ram_utilization_percent=( + sum(self._ram_utilization_history) / len(self._ram_utilization_history) + if self._ram_utilization_history + else 0 + ), + ram_used_gb=( + sum(self._ram_used_history) / len(self._ram_used_history) + if self._ram_used_history + else 0 + ), cpu_power=avg_cpu_power, gpu_power=avg_gpu_power, ram_power=avg_ram_power, @@ -855,6 +896,21 @@ def _monitor_power(self) -> None: if isinstance(hardware, CPU): hardware.monitor_power() + # Collect CPU and RAM utilization metrics + self._cpu_utilization_history.append(psutil.cpu_percent()) + self._ram_utilization_history.append(psutil.virtual_memory().percent) + self._ram_used_history.append(psutil.virtual_memory().used / (1024**3)) + + # Collect GPU utilization metrics + for hardware in self._hardware: + if isinstance(hardware, GPU): + gpu_details = hardware.devices.get_gpu_details() + for gpu_detail in gpu_details: + if "gpu_utilization" in gpu_detail: + self._gpu_utilization_history.append( + gpu_detail["gpu_utilization"] + ) + def _do_measurements(self) -> None: for hardware in self._hardware: h_time = time.perf_counter() diff --git a/codecarbon/output_methods/emissions_data.py b/codecarbon/output_methods/emissions_data.py index 8813a2679..17544aa51 100644 --- a/codecarbon/output_methods/emissions_data.py +++ b/codecarbon/output_methods/emissions_data.py @@ -40,6 +40,10 @@ class EmissionsData: latitude: float ram_total_size: float tracking_mode: str + cpu_utilization_percent: float = 0.0 + gpu_utilization_percent: float = 0.0 + ram_utilization_percent: float = 0.0 + ram_used_gb: float = 0.0 on_cloud: str = "N" pue: float = 1 wue: float = 0 @@ -101,6 +105,10 @@ class TaskEmissionsData: latitude: float ram_total_size: float tracking_mode: str + cpu_utilization_percent: float = 0.0 + gpu_utilization_percent: float = 0.0 + ram_utilization_percent: float = 0.0 + ram_used_gb: float = 0.0 on_cloud: str = "N" @property diff --git a/docs/edit/output.rst b/docs/edit/output.rst index 478a32aca..73dd1e3aa 100644 --- a/docs/edit/output.rst +++ b/docs/edit/output.rst @@ -77,8 +77,16 @@ input parameter (defaults to the current directory), for each experiment tracked | This is done for privacy protection. * - ram_total_size - total RAM available (Go) - * - Tracking_mode: + * - tracking_mode: - ``machine`` or ``process``(default to ``machine``) + * - cpu_utilization_percent + - Average CPU utilization during tracking period (%) + * - gpu_utilization_percent + - Average GPU utilization during tracking period (%) + * - ram_utilization_percent + - Average RAM utilization during tracking period (%) + * - ram_used_gb + - Average RAM used during tracking period (GB) .. note:: diff --git a/tests/test_data/emissions_valid_headers.csv b/tests/test_data/emissions_valid_headers.csv index 7743f6073..b7493c902 100644 --- a/tests/test_data/emissions_valid_headers.csv +++ b/tests/test_data/emissions_valid_headers.csv @@ -1,2 +1,2 @@ -timestamp,project_name,run_id,experiment_id,duration,emissions,emissions_rate,cpu_power,gpu_power,ram_power,cpu_energy,gpu_energy,ram_energy,energy_consumed,water_consumed,country_name,country_iso_code,region,cloud_provider,cloud_region,os,python_version,codecarbon_version,cpu_count,cpu_model,gpu_count,gpu_model,longitude,latitude,ram_total_size,tracking_mode,on_cloud,pue,wue -2021-09-23T15:04:51,codecarbon,0a578547-1d6b-4e2f-be0c-7ad10f2f7c97,test,161.20380687713623,0.0004490989249167,0.0027859076880178,0.269999999999999,0.0,12.884901888000002,0.0,0,0.00057442898176,0.00057442898176,0.1,Morocco,MAR,casablanca-settat,,,macOS-10.15.7-x86_64-i386-64bit,3.8.0,2.1.3,12,Intel(R) Core(TM) i7-8850H CPU @ 2.60GHz,,,-7.9084,33.5932,,machine,N,1.0,0.0 +timestamp,project_name,run_id,experiment_id,duration,emissions,emissions_rate,cpu_power,gpu_power,ram_power,cpu_energy,gpu_energy,ram_energy,energy_consumed,water_consumed,country_name,country_iso_code,region,cloud_provider,cloud_region,os,python_version,codecarbon_version,cpu_count,cpu_model,gpu_count,gpu_model,longitude,latitude,ram_total_size,tracking_mode,cpu_utilization_percent,gpu_utilization_percent,ram_utilization_percent,ram_used_gb,on_cloud,pue,wue +2021-09-23T15:04:51,codecarbon,0a578547-1d6b-4e2f-be0c-7ad10f2f7c97,test,161.20380687713623,0.0004490989249167,0.0027859076880178,0.269999999999999,0.0,12.884901888000002,0.0,0,0.00057442898176,0.00057442898176,0.1,Morocco,MAR,casablanca-settat,,,macOS-10.15.7-x86_64-i386-64bit,3.8.0,2.1.3,12,Intel(R) Core(TM) i7-8850H CPU @ 2.60GHz,,,-7.9084,33.5932,,machine,0.0,0.0,0.0,0.0,N,1.0,0.0 diff --git a/tests/test_utilization_tracking.py b/tests/test_utilization_tracking.py new file mode 100644 index 000000000..f182f8273 --- /dev/null +++ b/tests/test_utilization_tracking.py @@ -0,0 +1,206 @@ +""" +Tests for CPU, RAM, and GPU utilization tracking functionality. +""" + +import time +import unittest +from pathlib import Path +from unittest import mock + +import pandas as pd + +from codecarbon.emissions_tracker import OfflineEmissionsTracker +from tests.testutils import get_custom_mock_open + +empty_conf = "[codecarbon]" + + +class TestUtilizationTracking(unittest.TestCase): + """Test suite for CPU and RAM utilization tracking features.""" + + def setUp(self) -> None: + """Set up test fixtures.""" + import tempfile + + self.temp_dir = tempfile.TemporaryDirectory() + self.temp_path = Path(self.temp_dir.name) + self.emissions_file_path = self.temp_path / "emissions.csv" + + # Patch config file access + patcher = mock.patch( + "builtins.open", new_callable=get_custom_mock_open(empty_conf, empty_conf) + ) + self.addCleanup(patcher.stop) + patcher.start() + + def tearDown(self) -> None: + """Clean up test fixtures.""" + self.temp_dir.cleanup() + + def test_utilization_fields_in_emissions_data(self): + """Test that EmissionsData contains utilization fields.""" + tracker = OfflineEmissionsTracker( + country_iso_code="USA", + output_dir=self.temp_path, + save_to_file=False, + ) + + tracker.start() + time.sleep(2) # Run for 2 seconds to collect measurements + tracker.stop() + + emissions_data = tracker.final_emissions_data + + # Verify utilization fields exist + self.assertTrue(hasattr(emissions_data, "cpu_utilization_percent")) + self.assertTrue(hasattr(emissions_data, "gpu_utilization_percent")) + self.assertTrue(hasattr(emissions_data, "ram_utilization_percent")) + self.assertTrue(hasattr(emissions_data, "ram_used_gb")) + + # Verify values are reasonable + self.assertGreaterEqual(emissions_data.cpu_utilization_percent, 0) + self.assertLessEqual(emissions_data.cpu_utilization_percent, 100) + self.assertGreaterEqual(emissions_data.gpu_utilization_percent, 0) + self.assertLessEqual(emissions_data.gpu_utilization_percent, 100) + self.assertGreaterEqual(emissions_data.ram_utilization_percent, 0) + self.assertLessEqual(emissions_data.ram_utilization_percent, 100) + self.assertGreaterEqual(emissions_data.ram_used_gb, 0) + + def test_utilization_fields_in_csv_output(self): + """Test that utilization metrics are saved to CSV file.""" + tracker = OfflineEmissionsTracker( + country_iso_code="USA", + output_dir=self.temp_path, + ) + + tracker.start() + time.sleep(2) # Run for 2 seconds to collect measurements + tracker.stop() + + # Read CSV and verify columns exist + emissions_df = pd.read_csv(self.emissions_file_path) + + self.assertIn("cpu_utilization_percent", emissions_df.columns) + self.assertIn("gpu_utilization_percent", emissions_df.columns) + self.assertIn("ram_utilization_percent", emissions_df.columns) + self.assertIn("ram_used_gb", emissions_df.columns) + + # Verify values are reasonable + cpu_util = emissions_df["cpu_utilization_percent"].values[0] + gpu_util = emissions_df["gpu_utilization_percent"].values[0] + ram_util = emissions_df["ram_utilization_percent"].values[0] + ram_used = emissions_df["ram_used_gb"].values[0] + + self.assertGreaterEqual(cpu_util, 0) + self.assertLessEqual(cpu_util, 100) + self.assertGreaterEqual(gpu_util, 0) + self.assertLessEqual(gpu_util, 100) + self.assertGreaterEqual(ram_util, 0) + self.assertLessEqual(ram_util, 100) + self.assertGreaterEqual(ram_used, 0) + + def test_utilization_history_cleared_on_start(self): + """Test that utilization history is cleared when tracker starts.""" + tracker = OfflineEmissionsTracker( + country_iso_code="USA", + output_dir=self.temp_path, + save_to_file=False, + ) + + # First run + tracker.start() + time.sleep(1) + tracker.stop() + first_cpu_util = tracker.final_emissions_data.cpu_utilization_percent + + # Second run - history should be cleared + tracker.start() + time.sleep(1) + tracker.stop() + second_cpu_util = tracker.final_emissions_data.cpu_utilization_percent + + # Both should have valid values (not necessarily equal) + self.assertGreaterEqual(first_cpu_util, 0) + self.assertGreaterEqual(second_cpu_util, 0) + + def test_utilization_averaging_over_time(self): + """Test that utilization values are averaged over the tracking period.""" + import psutil + + tracker = OfflineEmissionsTracker( + country_iso_code="USA", + output_dir=self.temp_path, + save_to_file=False, + ) + + # Get instantaneous value before starting + psutil.cpu_percent() + + tracker.start() + time.sleep(3) # Run for 3 seconds to collect multiple measurements + tracker.stop() + + # Get instantaneous value after stopping + psutil.cpu_percent() + + averaged = tracker.final_emissions_data.cpu_utilization_percent + + # Averaged value should be valid + self.assertGreaterEqual(averaged, 0) + self.assertLessEqual(averaged, 100) + + # The averaged value may differ from instantaneous values + # This is expected behavior - we're just verifying it's computed + + def test_task_utilization_tracking(self): + """Test that task-based tracking includes utilization metrics.""" + tracker = OfflineEmissionsTracker( + country_iso_code="USA", + output_dir=self.temp_path, + save_to_file=False, + ) + + tracker.start() + tracker.start_task("test_task") + time.sleep(2) + task_data = tracker.stop_task() + tracker.stop() + + # Verify task data has utilization fields + self.assertTrue(hasattr(task_data, "cpu_utilization_percent")) + self.assertTrue(hasattr(task_data, "ram_utilization_percent")) + self.assertTrue(hasattr(task_data, "ram_used_gb")) + self.assertTrue(hasattr(task_data, "gpu_utilization_percent")) + + # Verify values are reasonable + self.assertGreaterEqual(task_data.cpu_utilization_percent, 0) + self.assertLessEqual(task_data.cpu_utilization_percent, 100) + self.assertGreaterEqual(task_data.ram_utilization_percent, 0) + self.assertLessEqual(task_data.ram_utilization_percent, 100) + self.assertGreaterEqual(task_data.ram_used_gb, 0) + self.assertGreaterEqual(task_data.gpu_utilization_percent, 0) + self.assertLessEqual(task_data.gpu_utilization_percent, 100) + + def test_utilization_with_empty_history(self): + """Test that tracker handles empty history gracefully.""" + tracker = OfflineEmissionsTracker( + country_iso_code="USA", + output_dir=self.temp_path, + save_to_file=False, + ) + + # Start and stop immediately (minimal time for history collection) + tracker.start() + tracker.stop() + + emissions_data = tracker.final_emissions_data + + # Should still have valid values (fallback to instantaneous) + self.assertGreaterEqual(emissions_data.cpu_utilization_percent, 0) + self.assertLessEqual(emissions_data.cpu_utilization_percent, 100) + self.assertGreaterEqual(emissions_data.ram_utilization_percent, 0) + self.assertLessEqual(emissions_data.ram_utilization_percent, 100) + + +if __name__ == "__main__": + unittest.main()