Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make sure we respect local system time #61

Merged
merged 4 commits into from
Aug 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cats/CI_api_interface.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import sys
from collections import namedtuple
from datetime import datetime
from zoneinfo import ZoneInfo

from .forecast import CarbonIntensityPointEstimate

Expand Down Expand Up @@ -63,9 +64,13 @@ def invalid_code(r: dict):
raise InvalidLocationError

datefmt = "%Y-%m-%dT%H:%MZ"
# The "Z" at the end of the format string indicates UTC,
# however, strptime does not know how to parse this, so we
# need to add tzinfo data.
utc = ZoneInfo('UTC')
return [
CarbonIntensityPointEstimate(
datetime=datetime.strptime(d["from"], datefmt),
datetime=datetime.strptime(d["from"], datefmt).replace(tzinfo=utc),
value=d["intensity"]["forecast"],
)
for d in response["data"]["data"]
Expand Down
7 changes: 6 additions & 1 deletion cats/optimise_starttime.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,10 @@ def get_avg_estimates(data, duration=None):
# raise ValueError(
# "Windowed method timespan cannot be greater than the cached timespan"
# )
wf = WindowedForecast(data, duration, start=datetime.now())
# NB: datetime.now().astimezone() gives us a timezone aware datetime object
# in the current system timezone. The resulting start time from the forecast
# works in terms of this timezone (even if the data is in another timezone)
# so we end up with something in system time if data is in utc and system
# time is in bst (for example)
wf = WindowedForecast(data, duration, start=datetime.now().astimezone())
tlestang marked this conversation as resolved.
Show resolved Hide resolved
return wf[0], min(wf)
8 changes: 7 additions & 1 deletion tests/test_api_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

def test_api_call():
"""
This just checks the API call runs and returns a list of tuples
This just checks the API call runs and returns a list of point estimates

Also confirms that datetime objects are timezone aware, as per
https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
"""

api_interface = API_interfaces["carbonintensity.org.uk"]
Expand All @@ -15,6 +18,8 @@ def test_api_call():
assert isinstance(response, list)
for item in response:
assert isinstance(item, CarbonIntensityPointEstimate)
assert ((item.datetime.tzinfo is not None) and
(item.datetime.tzinfo.utcoffset(item.datetime) is not None))
tlestang marked this conversation as resolved.
Show resolved Hide resolved

def test_bad_postcode():
api_interface = API_interfaces["carbonintensity.org.uk"]
Expand All @@ -24,3 +29,4 @@ def test_bad_postcode():

with pytest.raises(InvalidLocationError):
response = cats.CI_api_query.get_CI_forecast('A', api_interface)

77 changes: 59 additions & 18 deletions tests/test_windowed_forecast.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import csv
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import math
from pathlib import Path
from numpy.testing import assert_allclose
Expand Down Expand Up @@ -61,7 +62,7 @@ def test_minimise_average():
next(csvfile) # Skip header line
data = [
CarbonIntensityPointEstimate(
datetime=datetime.fromisoformat(datestr[:-1]),
datetime=datetime.fromisoformat(datestr[:-1]+'+00:00'),
tlestang marked this conversation as resolved.
Show resolved Hide resolved
value=float(intensity_value),
)
for datestr, _, _, intensity_value in csvfile
Expand All @@ -75,22 +76,58 @@ def test_minimise_average():
# Intensity point estimates over best runtime period
v = [10, 8, 7, 7, 5, 8, 8]
expected = CarbonIntensityAverageEstimate(
start=datetime.fromisoformat("2023-05-05T12:00"),
end=datetime.fromisoformat("2023-05-05T15:00"),
start=datetime.fromisoformat("2023-05-05T12:00+00:00"),
end=datetime.fromisoformat("2023-05-05T15:00+00:00"),
value=sum(
[0.5 * (a + b) for a, b in zip(v[:-1], v[1:])]
) / window_size
)
assert (result == expected)


def test_minimise_average_bst():
# We should get a start time in BST if we provide the starting time
# in that timezone, even if the intensity estimate is in UTC. This
# is needed as the `at` command works in local system time (and that's
# what we put in)
with open(TEST_DATA, "r") as f:
csvfile = csv.reader(f, delimiter=",")
next(csvfile) # Skip header line
data = [
CarbonIntensityPointEstimate(
datetime=datetime.fromisoformat(datestr[:-1]+'+00:00'),
value=float(intensity_value),
)
for datestr, _, _, intensity_value in csvfile
]

window_size = 6
# Data points separated by 30 minutes intervals
duration = window_size * 30
start_time_bst = data[0].datetime.replace(tzinfo=timezone(timedelta(seconds=-3600)))
tlestang marked this conversation as resolved.
Show resolved Hide resolved
result = min(WindowedForecast(data, duration, start=start_time_bst))

# Intensity point estimates over best runtime period
v = [10, 8, 7, 7, 5, 8, 8]
expected = CarbonIntensityAverageEstimate(
start=datetime.fromisoformat("2023-05-05T11:00-01:00"),
end=datetime.fromisoformat("2023-05-05T14:00-01:00"),
value=sum(
[0.5 * (a + b) for a, b in zip(v[:-1], v[1:])]
) / window_size
)
assert (result == expected)
assert (result.start.tzinfo == expected.start.tzinfo)
assert (result.end.tzinfo == expected.end.tzinfo)


def test_average_intensity_now():
with open(TEST_DATA, "r") as f:
csvfile = csv.reader(f, delimiter=",")
next(csvfile) # Skip header line
data = [
CarbonIntensityPointEstimate(
datetime=datetime.fromisoformat(datestr[:-1]),
datetime=datetime.fromisoformat(datestr[:-1]+'+00:00'),
value=float(intensity_value),
)
for datestr, _, _, intensity_value in csvfile
Expand Down Expand Up @@ -118,24 +155,25 @@ def test_average_intensity_with_offset():
# carbon intensity data points. In this case cats interpolate the
# intensity value at beginning and end of each potential job
# duration window.
utc = ZoneInfo('UTC')
CI_forecast = [
CarbonIntensityPointEstimate(26, datetime(2023,1,1,8,30)),
CarbonIntensityPointEstimate(40, datetime(2023,1,1,9,0)),
CarbonIntensityPointEstimate(50, datetime(2023,1,1,9,30)),
CarbonIntensityPointEstimate(60, datetime(2023,1,1,10,0)),
CarbonIntensityPointEstimate(25, datetime(2023,1,1,10,30)),
CarbonIntensityPointEstimate(26, datetime(2023,1,1,8,30,tzinfo=utc)),
CarbonIntensityPointEstimate(40, datetime(2023,1,1,9,0,tzinfo=utc)),
CarbonIntensityPointEstimate(50, datetime(2023,1,1,9,30,tzinfo=utc)),
CarbonIntensityPointEstimate(60, datetime(2023,1,1,10,0,tzinfo=utc)),
CarbonIntensityPointEstimate(25, datetime(2023,1,1,10,30,tzinfo=utc)),
]
duration = 70 # in minutes
# First available data point is for 08:00 but the job
# starts 15 minutes later.
job_start = datetime.fromisoformat("2023-01-01T08:45")
job_start = datetime.fromisoformat("2023-01-01T08:45+00:00")
result = WindowedForecast(CI_forecast, duration, start=job_start)[1]

interp1 = 40 + 15 * (50 - 40) / 30
interp2 = 60 + 25 * (25 - 60) / 30
expected = CarbonIntensityAverageEstimate(
start=datetime(2023,1,1,9,15),
end=datetime(2023,1,1,10,25),
start=datetime(2023,1,1,9,15,tzinfo=utc),
end=datetime(2023,1,1,10,25,tzinfo=utc),
value=(
0.5 * (interp1 + 50) * 15 +
0.5 * (50 + 60) * 30 +
Expand All @@ -151,7 +189,7 @@ def test_average_intensity_with_offset():

# When start at 09:15 the WindowedForecast's 'data' attribute
# should discard the first data point at 08:30.
job_start = datetime.fromisoformat("2023-01-01T09:15")
job_start = datetime.fromisoformat("2023-01-01T09:15+00:00")
result = WindowedForecast(CI_forecast, duration, start=job_start)[0]
assert result == expected

Expand All @@ -165,7 +203,7 @@ def test_average_intensity_with_offset_long_job():
next(csvfile) # Skip header line
data = [
CarbonIntensityPointEstimate(
datetime=datetime.fromisoformat(datestr[:-1]),
datetime=datetime.fromisoformat(datestr[:-1]+'+00:00'),
value=float(intensity_value),
)
for datestr, _, _, intensity_value in csvfile
Expand All @@ -174,7 +212,8 @@ def test_average_intensity_with_offset_long_job():
duration = 194 # in minutes
# First available data point is for 12:30 but the job
# starts 18 minutes later.
job_start = datetime.fromisoformat("2023-05-04T12:48")
# Start time in BST
job_start = datetime.fromisoformat("2023-05-04T13:48+01:00")
result = WindowedForecast(data, duration, start=job_start)[2]

# First and last element in v are interpolated intensity value.
Expand All @@ -191,6 +230,8 @@ def test_average_intensity_with_offset_long_job():
) / duration
)
assert (result == expected)
assert (result.start.tzinfo == expected.start.tzinfo)
assert (result.end.tzinfo == expected.end.tzinfo)

def test_average_intensity_with_offset_short_job():
# Case where job is short: start and end time fall between two
Expand All @@ -200,7 +241,7 @@ def test_average_intensity_with_offset_short_job():
next(csvfile) # Skip header line
data = [
CarbonIntensityPointEstimate(
datetime=datetime.fromisoformat(datestr[:-1]),
datetime=datetime.fromisoformat(datestr[:-1]+"+00:00"),
value=float(intensity_value),
)
for datestr, _, _, intensity_value in csvfile
Expand All @@ -209,7 +250,7 @@ def test_average_intensity_with_offset_short_job():
duration = 6 # in minutes
# First available data point is for 12:30 but the job
# starts 6 minutes later.
job_start = datetime.fromisoformat("2023-05-04T12:48")
job_start = datetime.fromisoformat("2023-05-04T12:48+00:00")
result = WindowedForecast(data, duration, start=job_start)[2]

# Job starts at 12:48 and ends at 12:54. For each candidate
Expand Down