From 54387a5ee4919cc3072ad2853867d5420c263f98 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 23:28:29 +0200 Subject: [PATCH] fix: send and store real sub-second emission durations The `duration` field was typed `int` in the pydantic schemas while the DB column and ORM were always `Float`, and `ApiClient.add_emission` dropped any measurement shorter than one second. Co-Authored-By: Claude Opus 5 (1M context) --- carbonserver/carbonserver/api/schemas.py | 8 +-- .../tests/api/test_schema_compatibility.py | 24 +++++++++ codecarbon/core/api_client.py | 7 +-- codecarbon/core/schemas.py | 2 +- tests/test_api_call.py | 50 ++++++++++--------- 5 files changed, 57 insertions(+), 34 deletions(-) diff --git a/carbonserver/carbonserver/api/schemas.py b/carbonserver/carbonserver/api/schemas.py index a3343929d..74b267052 100644 --- a/carbonserver/carbonserver/api/schemas.py +++ b/carbonserver/carbonserver/api/schemas.py @@ -56,7 +56,7 @@ def __repr__(self): class EmissionBase(BaseModel): timestamp: datetime run_id: UUID - duration: int = Field( + duration: float = Field( ..., gt=0, description="The duration must be greater than zero" ) emissions_sum: Optional[float] = Field( @@ -283,7 +283,7 @@ class ExperimentReport(ExperimentBase): gpu_energy: float ram_energy: float energy_consumed: float - duration: int + duration: float emissions_rate: float emissions_count: int cpu_utilization_percent: Optional[float] = None @@ -396,7 +396,7 @@ class ProjectReport(ProjectBase): gpu_energy: float ram_energy: float energy_consumed: float - duration: int + duration: float emissions_rate: float emissions_count: int cpu_utilization_percent: Optional[float] = None @@ -443,7 +443,7 @@ class OrganizationReport(OrganizationBase): gpu_energy: float ram_energy: float energy_consumed: float - duration: int + duration: float emissions_rate: float emissions_count: int cpu_utilization_percent: Optional[float] = None diff --git a/carbonserver/tests/api/test_schema_compatibility.py b/carbonserver/tests/api/test_schema_compatibility.py index 94522fc8c..161e5097a 100644 --- a/carbonserver/tests/api/test_schema_compatibility.py +++ b/carbonserver/tests/api/test_schema_compatibility.py @@ -118,3 +118,27 @@ def test_client_create_payloads_validate_against_server_schemas( client_payload, server_schema ): server_schema.model_validate(dataclasses.asdict(client_payload)) + + +def test_millisecond_duration_survives_client_to_server(): + """A single FastAPI request lasts milliseconds and must not be rounded away.""" + payload = client_schemas.EmissionCreate( + timestamp="2021-04-04T08:43:00+02:00", + run_id="40088f1a-d28e-4980-8d80-bf5600056a14", + duration=0.0042, + emissions_sum=1544.54, + emissions_rate=1.548444, + cpu_power=0.3, + gpu_power=0.0, + ram_power=0.15, + cpu_energy=55.21874, + gpu_energy=0.0, + ram_energy=2.0, + energy_consumed=57.21874, + ) + + validated = server_schemas.EmissionCreate.model_validate( + dataclasses.asdict(payload) + ) + + assert validated.duration == 0.0042 diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index eaef94a53..6b6d56660 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -184,15 +184,10 @@ def add_emission(self, carbon_emission: dict): "ApiClient.add_emission still no run_id, aborting for this time !" ) return False - if carbon_emission["duration"] < 1: - logger.warning( - "ApiClient : emissions not sent because of a duration smaller than 1." - ) - return False emission = EmissionCreate( timestamp=get_datetime_with_timezone(), run_id=self.run_id, - duration=int(carbon_emission["duration"]), + duration=carbon_emission["duration"], emissions_sum=carbon_emission["emissions"], emissions_rate=carbon_emission["emissions_rate"], cpu_power=carbon_emission["cpu_power"], diff --git a/codecarbon/core/schemas.py b/codecarbon/core/schemas.py index 84d1e9c77..9cc6b85a7 100644 --- a/codecarbon/core/schemas.py +++ b/codecarbon/core/schemas.py @@ -12,7 +12,7 @@ class EmissionBase: timestamp: str run_id: str - duration: int + duration: float emissions_sum: float emissions_rate: float cpu_power: float diff --git a/tests/test_api_call.py b/tests/test_api_call.py index d3b5bd96f..40881f3c7 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -202,31 +202,35 @@ def test_add_emission_returns_false_when_run_creation_fails(self): ) ) - def test_add_emission_skips_short_duration(self): - api = ApiClient( - endpoint_url="http://test.com", - experiment_id="exp-1", - conf=conf, - create_run_automatically=False, - ) - api.run_id = "run-1" + def test_add_emission_sends_millisecond_duration_unchanged(self): + """A single FastAPI request lasts milliseconds: send it, do not round it.""" + with requests_mock.Mocker() as m: + m.post("http://test.com/emissions", json={"id": "em-1"}, status_code=201) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + conf=conf, + create_run_automatically=False, + ) + api.run_id = "run-1" - self.assertFalse( - api.add_emission( - { - "duration": 0.5, - "emissions": 1.0, - "emissions_rate": 1.0, - "cpu_power": 1.0, - "gpu_power": 0.0, - "ram_power": 0.5, - "cpu_energy": 0.1, - "gpu_energy": 0.0, - "ram_energy": 0.1, - "energy_consumed": 0.2, - } + self.assertTrue( + api.add_emission( + { + "duration": 0.0042, + "emissions": 1.0, + "emissions_rate": 1.0, + "cpu_power": 1.0, + "gpu_power": 0.0, + "ram_power": 0.5, + "cpu_energy": 0.1, + "gpu_energy": 0.0, + "ram_energy": 0.1, + "energy_consumed": 0.2, + } + ) ) - ) + self.assertEqual(m.last_request.json()["duration"], 0.0042) def test_add_emission_raises_on_unsuccessful_post(self): with requests_mock.Mocker() as m: