Skip to content

add solar calculation in weather_controller and define fenestration constants#165

Open
ecosang wants to merge 4 commits intogoogle:copybara_pushfrom
ecosang:weather_controller
Open

add solar calculation in weather_controller and define fenestration constants#165
ecosang wants to merge 4 commits intogoogle:copybara_pushfrom
ecosang:weather_controller

Conversation

@ecosang
Copy link
Copy Markdown
Contributor

@ecosang ecosang commented Mar 16, 2026

add solar calculation in weather_controller and define fenestration constants

…onstants, and add relative convergence argument in simulator
@ecosang
Copy link
Copy Markdown
Contributor Author

ecosang commented Mar 16, 2026

@s2t2 I've put only weather controller part in this PR.

@s2t2
Copy link
Copy Markdown
Collaborator

s2t2 commented Mar 19, 2026

OK, thanks! I see the build is failing due to CSV path issues (see below). Would you please fix and then ping me and I will review again when the build is passing.

FAILED smart_control/simulator/weather_controller_test.py::ReplayWeatherControllerPvlibValidationTest::test_replay_controller_irradiance_closure_equation - FileNotFoundError: [Errno 2] No such file or directory: '/home/runner/work/sbsim/sbsim/smart_control/simulator/../configs/resources/sb1/weather_data/local_weather_test_data.csv'

@ecosang
Copy link
Copy Markdown
Contributor Author

ecosang commented Mar 19, 2026

@s2t2 the csv file is automatically ignored. I just added. Sorry for the iteration. Can I also check if the testing is passed here on my end? when I made PR, I didn't see the failure. Maybe I didn't wait enough.

@s2t2
Copy link
Copy Markdown
Collaborator

s2t2 commented Mar 23, 2026

For some reason, I have to click the "Approve workflows and run" button to trigger the tests. Running now...

Copy link
Copy Markdown
Collaborator

@s2t2 s2t2 left a comment

Choose a reason for hiding this comment

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

Hi @ecosang thanks for this PR.

I left some comments below.

One of the major ones is to see if we can use separate test classes to leverage shared set up methods (as applicable), and see if we can use test class inheritance to reduce the amount of test code (if possible).

Also it would be helpful to define some of the expected column names and sensor names as constants.

Also we can see if using some dataclasses would be helpful in specific situations mentioned.

There are some opportunities to refactor shared methods.

Also, let's discuss how to best handle the pvlib dependency, either making it non-optional, or implementing it as optional using the pyproject.toml / poetry mechanism used for this purpose.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There is an existing test data file in "smart_control/simulator/local_weather_test_data.csv". I was wondering how these files differ, and if we could leverage the existing file. Or organize both of these files to be in the same place (perhaps putting them in a "smart_control/simulator/test_data" folder.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Oh I see you moved this file from that location, to be in the resources dir. That could be OK. However I would prefer to keep both test data files in the same location.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I put this in "building_radiation_test_data" folder.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since the local weather test data is used for replay weather controller tests (without radiation), let's prefer to keep it where it is. Or perhaps we can move both CSV files to a generic "simulator/test_data" directory.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It would be nice to know how this data was acquired, what the source is, and perhaps also to have access to the code that pulled this data (as applicable).

solar_azimuth: Solar azimuth angle in degrees (compass direction of sun).
Returns:
POA global irradiance in W/m2.
Example:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Appreciate the example in the docstring.

... surface_azimuth=180.0, solar_zenith=30.0, solar_azimuth=180.0)
"""
# Import here to avoid circular imports and keep pvlib as optional dependency
from pvlib import irradiance as pvlib_irradiance # pylint: disable=import-outside-toplevel
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can you please further explain the circular dependency concerns if this import were placed at the top of the file? And also separately some reasons we would want to keep pvlib as an optional dependency?

FYI there is a way we can do optional dependencies using pyproject.toml, like for example to great a new group called "solar":

[tool.poetry.group.docs.solar]
pvlib = "0.13.1"

Then it can be installed like poetry install --with solar (see Setup docs related to notebook setup, for an example.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's just do a normal import of the pvlib at the top of the file.

It is a small enough dependency, and we are already using scipy, pandas, numpy, etc, so size isn't a big concern.

I know we discussed possibly making it an optional dependency in the poetry / pyproject.toml, but I don't see a strong argument for that, unless there's something I'm missing.

Converts horizontal irradiance components (GHI, DNI, DHI) to the irradiance
incident on a tilted surface. Uses the pvlib library's get_total_irradiance
function.
Args:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: I believe we might need a new line before each of these sections in the docstring: Args, Returns, Example. And I believe Return should go last.

Uses Clark & Allen formula with dry bulb and dew point temperatures.
"""
# Stefan-Boltzmann constant
sigma = 5.6697e-8 # W/(m^2*K^4)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I see this constant is used in building radiation utils file as well. Let's define it once in the simulation/constants.py file, and import / access from there.

sigma = 5.67 * 1e-8 # [W/m^2K^4] Stefan-Boltzmann constant

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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


# Get dry bulb temperature in Kelvin (use numpy operations for arrays)
if 'TempC' in self._weather_data:
temp_k = self._weather_data['TempC'].values + 273.15
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

if possible, let's use / apply temp conversion functions from conversion utils (c to k, f to k)

applies for temp and dewpoint below

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

# Stefan-Boltzmann constant
sigma = 5.6697e-8 # W/(m^2*K^4)

def get_value(observation_response, measurement_name):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I see these nested get_value methods are repeated. Let's prefer to move them to a single function at the top of the file (outside the class), and invoke from that location.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

continue

# Try to get dew point temperature, otherwise estimate from depression
dp_k = get_value(r, 'dew_point_temperature_sensor')
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's define these sensor names as constants at the top of the file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

)
return float(cloud_cover)

def get_current_irradiance(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If the specific structure of a dictionary containing these specific keys is used in many places, let's consider using a dataclass to define those expected properties. That would also allow us to better document the meaning behind each of these properties.

@dataclasses.dataclass
class Irradiance
  """ Docstring with ...
   Args:
     ...
  """
  ghi: float
  dni: float
  dhi: float
   # etc. 

This function can then return an instance of that dataclass.

Later, when we need to reference the values, we can use dot notation (like irr.ghi) or convert the dataclass to a dict using dataclasses.asdict() and access the values using dictionary notation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Collaborator

@s2t2 s2t2 left a comment

Choose a reason for hiding this comment

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

Hi @ecosang thanks for the updates. I provided another round of comments.

FYI - in a few comments I am just testing to see if Gemini Code Assist is installed correctly.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since the local weather test data is used for replay weather controller tests (without radiation), let's prefer to keep it where it is. Or perhaps we can move both CSV files to a generic "simulator/test_data" directory.

@@ -0,0 +1,12 @@
{
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we use the JSON file from the configs/resources directory, instead of creating a copy of it here? That way we have one source of truth.

https://github.com/google/sbsim/blob/copybara_push/smart_control/configs/resources/sb1/weather_data/station.json

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The main reason is station depends on the weather file. So, csv file should be with the station.json.

from smart_control.simulator import constants

if TYPE_CHECKING:
from smart_control.simulator import weather_controller
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's do a normal import here.



def calculate_poa_irradiance(
irradiance_components: 'weather_controller.IrradianceComponents',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's use the actual class here for type hint

... surface_azimuth=180.0, solar_zenith=30.0, solar_azimuth=180.0)
"""
# Import here to avoid circular imports and keep pvlib as optional dependency
from pvlib import irradiance as pvlib_irradiance # pylint: disable=import-outside-toplevel
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's just do a normal import of the pvlib at the top of the file.

It is a small enough dependency, and we are already using scipy, pandas, numpy, etc, so size isn't a big concern.

I know we discussed possibly making it an optional dependency in the poetry / pyproject.toml, but I don't see a strong argument for that, unless there's something I'm missing.

iteration_warning: int,
start_timestamp: pd.Timestamp,
relative_convergence_threshold: float | None = 1e-6,
relative_convergence_streak: int = 20,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gemini-code-assist how are these new attributes used in this PR?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These new attributes, relative_convergence_threshold and relative_convergence_streak, are introduced to enhance the simulator's convergence criteria. They enable an early stopping mechanism for the finite difference method (FDM) iterations.

Specifically:

  • relative_convergence_threshold: If set (not None), the simulator will consider itself converged and stop iterating if the absolute change in max_delta (which likely represents the maximum temperature change between iterations) falls below this threshold for a certain number of consecutive iterations.
  • relative_convergence_streak: This defines how many consecutive iterations the max_delta change must remain below the relative_convergence_threshold before the simulator stops early.

This allows for more efficient simulations by preventing unnecessary iterations once a stable state is reached, even if the absolute iteration_limit hasn't been met.

# 2. Circular-import risk: building_radiation_utils imports weather_controller
# (guarded by TYPE_CHECKING) to type-hint IrradianceComponents. If pvlib
# were imported at module load time here, and pvlib itself (or a future
# transitive dependency) ever imported anything from smart_control, that
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

at this time let's assume pvlib won't be importing from smart_control

# any user who has not installed the "solar" extras group, even if they never
# use the irradiance / solar-position features.
#
# 2. Circular-import risk: building_radiation_utils imports weather_controller
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

if we need to move the IrradianceComponents to avoid a circular import, let's consider moving it to its own file in the simulator directory, perhaps named "building_radiation_models.py". We can have this weather controller and the radiation utils import from there. I think that should avoid a circular import, right?


def get_current_irradiance(
self, timestamp: pd.Timestamp
) -> 'IrradianceComponents':
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's use the real class name here as a type hint

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

don't understand what it means.

)


class WeatherControllerIrradianceTest(IrradianceTestBase):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nice inheritance of the test classes.

Since these three Irradiance test classes have so many lines of code, let's move them to their own test files, like "weather_controller_irradiance_test.py" or something like that. This will make them easier to review and maintain.

@ecosang
Copy link
Copy Markdown
Contributor Author

ecosang commented Mar 31, 2026

I am kind of lost and can't figure it out. Let's clarify where to put which codes and what to create for a new testing file. Then, I will make a new PR, and I will drop this one.

  • Put weather_test file to where?
  • Make the sbsim working without pvlib?
  • pvlib optional dependency?
  • create a new class for the irrdiance test.
  • Split relative_threshold in another PR.
  • make irrdiance data and station as new data class.
Maybe we can pass an instance of the WeatherStation class I suggested, and use that here.

This would allow us to remove the following params from the initialization:

station_json_path: str | None = None,
timezone: str | None = None,
latitude: float | None = None,
longitude: float | None = None,
In favor of:

self.latitude = self.station.lat
self.longitude = self.station.lon
self.time_zone = self.station.time_zone
s2t2 replied 

s2t2
Collaborator
If the station_info is now a required parameter for the weather controller, we can pass it as none, and create one using default values if one is not passed:

# Args:

station: WeatherStation | None = None

# Init
self.station = station or WeatherStation() # use default values for building SB1

  • remove _ensure_timestamp_tz

def _validate_timestamp(self, timestamp):
    timestamp = self._ensure_timestamp_tz(timestamp)
    min_time = min(self._weather_data['Time'])
    if timestamp < min_time:
      raise ValueError(
          f'Attempting to get weather data at {timestamp}, before the earliest'
          f' timestamp {min_time}.'
      )
    max_time = max(self._weather_data['Time'])
    if timestamp > max_time:
      raise ValueError(
          f'Attempting to get weather data at {timestamp}, after the latest'
          f' timestamp {max_time}.'
      )
   return timestamp
  • remove self.weather_data
  • define constatns at the top

_OUTSIDE_AIR_TEMP_SENSOR: Final[str] = 'outside_air_temperature_sensor'
_DEW_POINT_TEMP_SENSOR: Final[str] = 'dew_point_temperature_sensor'
_CLOUD_COVER_SENSOR: Final[str] = 'cloud_cover_sensor'
_GHI_SENSOR: Final[str] = 'ghi_sensor'
_DNI_SENSOR: Final[str] = 'dni_sensor'
_DHI_SENSOR: Final[str] = 'dhi_sensor'
Let's consider making all the specific important column names into constants, like SKY_TEMP_COL="'TempSkyC'" or SKY_TEMPERATURE_COLUMN_NAME="TempSkyC"

  • Put irradiance stuff in weather_controller by default? or all optional? then how to deal with pvlib?

Anything I missed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants