Skip to content
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
Binary file not shown.
8,228 changes: 8,228 additions & 0 deletions smart_control/configs/resources/sb1/weather_data/2023.csv

Large diffs are not rendered by default.

8,530 changes: 8,530 additions & 0 deletions smart_control/configs/resources/sb1/weather_data/2024.csv

Large diffs are not rendered by default.

5,628 changes: 5,628 additions & 0 deletions smart_control/configs/resources/sb1/weather_data/2025.csv
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contains data up until 8/25. We need to update the data to include the full year.

We have a pipeline for updating this data, so this issue should be handled internally by Google staff.

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions smart_control/configs/resources/sb1/weather_data/station.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "Mountain View Moffett Field Naval Air Station",
"city": "Sunnyvale",
"country": "US",
"state": "California",
"locality": "Mountain View",
"postal": "94043",
"lat": "37 24 35.000",
"lng": "-122 02 56.000",
"timezone": "America/Los_Angeles",
"elevation": 11
}
151 changes: 123 additions & 28 deletions smart_control/simulator/weather_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import abc
import math
import os
from typing import Final, Mapping, Optional, Sequence, Tuple

import gin
Expand All @@ -19,6 +20,15 @@
_MAX_RADIANS: Final[float] = 3.0 * math.pi / 2.0
_EPOCH: Final[pd.Timestamp] = pd.Timestamp('1970-01-01', tz='UTC')

WEATHER_CSV_FILEPATH: Final[str] = os.path.join(
os.path.dirname(__file__),
'..',
'configs',
'resources',
'sb1',
'local_weather_moffett_field_20230701_20231122.csv',
)


@gin.configurable
class BaseWeatherController(metaclass=abc.ABCMeta):
Expand All @@ -28,6 +38,8 @@ class BaseWeatherController(metaclass=abc.ABCMeta):
def get_current_temp(self, timestamp: pd.Timestamp) -> float:
"""Gets outside temp at specified timestamp."""

# SHOULD THIS BASE CLASS IMPLEMENT get_air_convection_coefficient AS WELL?


@gin.configurable
class WeatherController(BaseWeatherController):
Expand Down Expand Up @@ -149,55 +161,138 @@ def get_outside_air_temp(observation_response):


@gin.configurable
class ReplayWeatherController:
class ReplayWeatherController(BaseWeatherController):
"""Weather controller that interplolates real weather from past observations.

Attributes:
local_weather_path: Path to local weather file.
local_weather_path: Path to local weather CSV file.
weather_df: Pandas dataframe of historical weather data.
convection_coefficient: Air convection coefficient (W/m2/K).
humidity_column: Column name of the humidity in the weather CSV file.
"""

def __init__(
self,
local_weather_path: str,
local_weather_path: str = WEATHER_CSV_FILEPATH,
convection_coefficient: float = 12.0,
humidity_column: str = 'Humidity',
):
self._weather_data = pd.read_csv(local_weather_path)
self._weather_data['Time'] = [
pd.Timestamp(t, tz='UTC') for t in self._weather_data['Time']
]
self._weather_data.index = [
(t - _EPOCH).total_seconds() for t in self._weather_data['Time']
]
self.local_weather_path = local_weather_path
self.weather_df = self.read_weather_csv(self.local_weather_path)
self.convection_coefficient = convection_coefficient
self.humidity_column = humidity_column

def get_current_temp(self, timestamp: pd.Timestamp) -> float:
"""Returns current temperature in K.
@property
def csv_filepath(self) -> str:
"""Alias for the local weather CSV file path."""
return self.local_weather_path

def read_weather_csv(self, csv_filepath: str) -> pd.DataFrame:
"""Loads time series weather data from the specified CSV file.

The CSV file is expected to have at least the following columns:

+ `Time`: the time, as a string, in the format: `%Y%m%d-%H%M`
(e.g. `20230701-0000`). Assumed to be in UTC.
+ `TempF`: the temperature in Fahrenheit at the specified time.
+ `Humidity`: the relative humidity in percent at the specified time
(0 to 100).

Coerces the times to UTC. Updates the index to be seconds since epoch.

Args:
csv_filepath: Path to local weather CSV file.

Returns:
Pandas dataframe of weather data.
"""
df = pd.read_csv(csv_filepath)
df = df.drop(columns=['Unnamed: 0'], errors='ignore')

df['Time'] = pd.to_datetime(df['Time'], utc=True)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider cleaning the data here. See #144


df.index = (df['Time'] - _EPOCH).dt.total_seconds()
df.index.name = 'SecondsSinceEpoch'

return df

@property
def min_time(self) -> pd.Timestamp:
"""Earliest timestamp in the weather data."""
return min(self.weather_df['Time'])

@property
def max_time(self) -> pd.Timestamp:
"""Latest timestamp in the weather data."""
return max(self.weather_df['Time'])

@property
def times_in_seconds(self) -> pd.Index:
"""Returns the timestamps of the weather data, as seconds since epoch."""
return self.weather_df.index

@property
def temps_f(self) -> pd.Series:
"""Returns the temperatures in Fahrenheit of the weather data."""
return self.weather_df['TempF']

@property
def humidities(self) -> pd.Series:
"""Returns the humidities of the weather data."""
return self.weather_df[self.humidity_column]

def _get_interpolated_value(
self, timestamp: pd.Timestamp, values: pd.Series
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are currently passing the list of values to be interpolated, but this requires a separate property for each column of values (e.g. humidities, etc.).

If possible, let's implement a more flexible interface where we can just pass the column name (instead of the values) into the _get_interpolated_value function.

) -> float:
"""Helper to get interpolated value from a given series.

The timestamp need not exactly appear in the weather data, but should be
within the range of the data.
If there is no exact match, linear interpolation is used to estimate the
temperature between the nearest timestamps.

Args:
timestamp: Pandas timestamp to get temperature for interpolation.
timestamp: Pandas timestamp to get temperature for interpolation. If the
timestamp is timezone aware, it will be converted to UTC. If the
timestamp is timezone naive, it will be localized to UTC. This allows
for accurate comparisons against the min and max timestamps, as well as
the epoch, which are always timezone aware (in UTC).
values: Pandas series to interpolate from.

Returns:
The interpolated value from the series at the given timestamp.
"""
timestamp = timestamp.tz_convert('UTC')
min_time = min(self._weather_data['Time'])
if timestamp < min_time:
# convert timestamp to UTC to enable proper comparisons:
if timestamp.tzname() is not None:
# timestamp is timezone aware, unable to localize, so convert to UTC:
timestamp = timestamp.tz_convert('UTC')
else:
# timestamp is timezone naive, unable to convert, so localize to UTC:
timestamp = timestamp.tz_localize('UTC')

if timestamp < self.min_time:
raise ValueError(
f'Attempting to get weather data at {timestamp}, before the latest'
f' timestamp {min_time}.'
f'Timestamp not in range. Timestamp {timestamp} is before the'
f' earliest timestamp {self.min_time}.'
)
max_time = max(self._weather_data['Time'])
if timestamp > max_time:

if timestamp > self.max_time:
raise ValueError(
f'Attempting to get weather data at {timestamp}, after the latest'
f' timestamp {max_time}.'
f'Timestamp not in range. Timestamp {timestamp} is after the'
f' latest timestamp {self.max_time}.'
)

times = np.array(self._weather_data.index)
target_timestamp = (timestamp - _EPOCH).total_seconds()
temps = self._weather_data['TempF']
temp_f = np.interp(target_timestamp, times, temps)
return utils.fahrenheit_to_kelvin(temp_f)
time_in_seconds = (timestamp - _EPOCH).total_seconds()
return np.interp(time_in_seconds, self.times_in_seconds, values)

def get_current_temp(self, timestamp: pd.Timestamp) -> float:
"""For a given timestamp, returns the current temperature in Kelvin."""
return utils.fahrenheit_to_kelvin(
self._get_interpolated_value(timestamp, self.temps_f)
)

def get_current_humidity(self, timestamp: pd.Timestamp) -> float:
"""For a given timestamp, returns the current humidity level in percent."""
return self._get_interpolated_value(timestamp, self.humidities)

# pylint: disable=unused-argument
def get_air_convection_coefficient(self, timestamp: pd.Timestamp) -> float:
Expand Down
101 changes: 84 additions & 17 deletions smart_control/simulator/weather_controller_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,42 +121,109 @@ def test_get_air_convection_coefficient(self):

self.assertEqual(convection_coefficient, expected_convection_coefficient)

def test_replay_weather_controller(self):

class ReplayWeatherControllerTest(parameterized.TestCase):

def setUp(self):
super().setUp()
data_path = os.path.join(
os.path.dirname(__file__), 'local_weather_test_data.csv'
)
controller = weather_controller.ReplayWeatherController(data_path, 10.0)
self.controller = weather_controller.ReplayWeatherController(
local_weather_path=data_path, convection_coefficient=10.0
)

temp = controller.get_current_temp(
def test_replay_weather_controller(self):
temp = self.controller.get_current_temp(
pd.Timestamp('2023-07-01 03:00:01+00:00')
)

self.assertAlmostEqual(temp, 298.1500, places=5)

def test_replay_weather_controller_raises_error_before_range(self):
data_path = os.path.join(
os.path.dirname(__file__), 'local_weather_test_data.csv'
)
controller = weather_controller.ReplayWeatherController(data_path, 10.0)

weather_fn = lambda: controller.get_current_temp(
weather_fn = lambda: self.controller.get_current_temp(
pd.Timestamp('2023-05-01 03:00:01+00:00')
)

self.assertRaises(ValueError, weather_fn)

def test_replay_weather_controller_raises_error_after_range(self):
data_path = os.path.join(
os.path.dirname(__file__), 'local_weather_test_data.csv'
weather_fn = lambda: self.controller.get_current_temp(
pd.Timestamp('2023-12-01 03:00:01+00:00')
)
controller = weather_controller.ReplayWeatherController(data_path, 10.0)
self.assertRaises(ValueError, weather_fn)

weather_fn = lambda: controller.get_current_temp(
pd.Timestamp('2023-12-01 03:00:01+00:00')

class MoffettReplayWeatherControllerTest(parameterized.TestCase):
"""Tests for ReplayWeatherController using real weather data."""

def setUp(self):
super().setUp()
self.controller = weather_controller.ReplayWeatherController()

def test_weather_df(self):
self.assertIsInstance(self.controller.weather_df, pd.DataFrame)
self.assertEqual(self.controller.weather_df.shape, (3462, 15))

expected_columns = [
'Time',
'StationName',
'StationId',
'Location',
'TempC',
'DewPointC',
'BarometerMbar',
'Rain',
'RainTotal',
'WindspeedKmph',
'WindDirection',
'SkyCoverage',
'VisibilityKm',
'Humidity',
'TempF',
]
self.assertCountEqual(
self.controller.weather_df.columns.tolist(),
expected_columns,
)

self.assertRaises(ValueError, weather_fn)
def test_time_range(self):
min_time = pd.Timestamp('2023-06-30 17:00:00+00:00')
max_time = pd.Timestamp('2023-11-22 16:00:00+00:00')

self.assertEqual(self.controller.min_time, min_time)
self.assertEqual(self.controller.max_time, max_time)

def test_times_in_seconds(self):
self.assertIsInstance(self.controller.times_in_seconds, pd.Index)
self.assertEqual(self.controller.times_in_seconds.shape, (3462,))

self.assertEqual(min(self.controller.times_in_seconds), 1688144400.0)
self.assertEqual(max(self.controller.times_in_seconds), 1700668800.0)

def test_get_temp_timezones(self):
with self.subTest('when timestamp is timezone aware'):
timestamp = pd.Timestamp('2023-07-01 10:00:00+00:00')
self.assertEqual(timestamp.tzname(), 'UTC')

temp = self.controller.get_current_temp(timestamp)
self.assertEqual(temp, 289.15)

with self.subTest('when timestamp is timezone naive'):
timestamp = pd.Timestamp('2023-07-01 10:00:00')
self.assertIsNone(timestamp.tzname())

temp = self.controller.get_current_temp(timestamp)
self.assertEqual(temp, 289.15)

def test_interpolation(self):
timestamp = pd.Timestamp('2023-07-01 03:00:01+00:00')

with self.subTest('current_temp'):
temp_k = self.controller.get_current_temp(timestamp)
self.assertAlmostEqual(temp_k, 294.1497, places=4)

with self.subTest('current_humidity'):
humidity = self.controller.get_current_humidity(timestamp)
self.assertAlmostEqual(humidity, 65.0, places=5)


if __name__ == '__main__':
Expand Down
Loading