1+ """State-machine style helpers for navigating the RateAcuity portal.
2+
3+ The module exposes a small set of classes that wrap Selenium interactions and
4+ provide a linear flow for logging in, picking reporting parameters, and
5+ downloading prepared benchmark reports as Polars dataframes.
6+
7+ Usage
8+ -----
9+ from tariff_fetch.rateacuity.base import create_context
10+ from tariff_fetch.rateacuity.state import LoginState
11+
12+ with create_context() as context:
13+ report = (
14+ LoginState(context)
15+ .login(username, password)
16+ .electric()
17+ .benchmark()
18+ .select_state("NY")
19+ .select_utility("Consolidated Edison Company of New York")
20+ .select_schedule("Residential Service")
21+ )
22+ df = report.as_dataframe()
23+
24+ The resulting ``df`` contains the cleaned benchmark data for the selected
25+ schedule.
26+ """
27+
128from __future__ import annotations
229
330import argparse
1946
2047
2148class State :
49+ """Shared Selenium helpers as the base for the strongly-typed state objects."""
50+
2251 def __init__ (self , context : ScrapingContext ):
52+ """Store the scraping context that contains the shared webdriver."""
2353 self ._context = context
2454
2555 @property
2656 def driver (self ) -> Chrome :
57+ """Expose the underlying Selenium driver used for navigation."""
2758 return self ._context .driver
2859
2960 def _logged_in (self ) -> bool :
61+ """Return True if the login link is absent, indicating an authenticated session."""
3062 return not self .driver .find_elements (By .ID , "loginLink" )
3163
3264 def _wait (self ) -> WebDriverWait :
65+ """Create a wait helper bound to the current driver instance."""
3366 return WebDriverWait (self .driver , 10 )
3467
3568
3669class LoginState (State ):
3770 def login (self , username : str , password : str ) -> PortalState :
71+ """Authenticate with RateAcuity and transition into the portal state."""
3872 login (self ._context .driver , username , password )
3973 return PortalState (self ._context )
4074
4175
4276class PortalState (State ):
4377 def electric (self ) -> ElectricState :
78+ """Navigate to the Electric entry point of the portal."""
4479 logger .info ("Going to electric" )
4580 self .driver .get ("https://secure.rateacuity.com/RateAcuity/ElecEntry/IndexViews" )
4681 return ElectricState (self ._context )
4782
4883
4984class ElectricState (State ):
5085 def benchmark (self ) -> ElectricBenchmarkStateDropdown :
86+ """Switch the Electric report type to Benchmark and expose the state dropdown."""
5187 self ._select_report ("benchmark" )
5288 return ElectricBenchmarkStateDropdown (self ._context )
5389
5490 def _select_report (self , report : str ):
91+ """Click the given radio report selector if it is not already active."""
5592 radio = self ._wait ().until (
5693 EC .presence_of_element_located ((By .XPATH , f'//input[@id="report" and @value="{ report } "]' ))
5794 )
@@ -64,11 +101,13 @@ def _wait_for_element(self):
64101 return self ._wait ().until (EC .presence_of_element_located ((By .ID , "StateSelect" )))
65102
66103 def get_states (self ) -> list [str ]:
104+ """Return all available states visible in the State dropdown."""
67105 dropdown = self ._wait_for_element ()
68106 options = dropdown .find_elements (By .TAG_NAME , "option" )
69107 return [_ .text for _ in options ]
70108
71109 def select_state (self , state : str ) -> ElectricBenchmarkUtilityDropdown :
110+ """Select the provided state and transition to the utility dropdown."""
72111 dropdown = self ._wait_for_element ()
73112 options = [_ .text .strip () for _ in dropdown .find_elements (By .TAG_NAME , "option" )]
74113 if state not in options :
@@ -86,11 +125,13 @@ def _wait_for_element(self):
86125 return self ._wait ().until (EC .presence_of_element_located ((By .ID , "UtilitySelect" )))
87126
88127 def get_utilities (self ) -> list [str ]:
128+ """Return all available utilities for the previously chosen state."""
89129 dropdown = self ._wait_for_element ()
90130 options = dropdown .find_elements (By .TAG_NAME , "option" )
91131 return [_ .text for _ in options ]
92132
93133 def select_utility (self , utility : str ):
134+ """Select the provided utility and expose the schedule dropdown."""
94135 dropdown = self ._wait_for_element ()
95136 options = [_ .text .strip () for _ in dropdown .find_elements (By .TAG_NAME , "option" )]
96137 if utility not in options :
@@ -108,11 +149,13 @@ def _wait_for_element(self):
108149 return self ._wait ().until (EC .presence_of_element_located ((By .ID , "ScheduleSelect" )))
109150
110151 def get_schedules (self ) -> list [str ]:
152+ """Return all schedules associated with the selected utility."""
111153 dropdown = self ._wait_for_element ()
112154 options = dropdown .find_elements (By .TAG_NAME , "option" )
113155 return [_ .text for _ in options ]
114156
115157 def select_schedule (self , schedule : str ):
158+ """Select a schedule and produce a report interface that can fetch data."""
116159 dropdown = self ._wait_for_element ()
117160 options = [_ .text .strip () for _ in dropdown .find_elements (By .TAG_NAME , "option" )]
118161 if schedule not in options :
@@ -127,10 +170,12 @@ def select_schedule(self, schedule: str):
127170
128171class ElectricBenchmarkReport (State ):
129172 def back_to_selections (self ) -> ElectricBenchmarkScheduleDropdown :
173+ """Return to the selections page so additional schedules can be fetched."""
130174 self ._wait ().until (EC .presence_of_element_located ((By .LINK_TEXT , "Back To Selections" ))).click ()
131175 return ElectricBenchmarkScheduleDropdown (self ._context )
132176
133177 def download_excel (self ) -> Path :
178+ """Trigger the report download and return the path once it appears."""
134179 self ._wait ().until (EC .presence_of_element_located ((By .XPATH , '//a[text()="Create Excel Spreadsheet"]' ))).click ()
135180 download_path = self ._context .download_path
136181 initial_state = _get_xlsx (download_path )
@@ -145,6 +190,7 @@ def download_excel(self) -> Path:
145190 return Path (download_path , filename )
146191
147192 def as_dataframe (self ) -> pl .DataFrame :
193+ """Convert a freshly downloaded Excel report into a cleaned Polars dataframe."""
148194 filepath = self .download_excel ()
149195 logger .info (f"Reading excel file { filepath } " )
150196 raw_data = pl .read_excel (filepath , engine = "calamine" , has_header = False )
@@ -162,10 +208,12 @@ def as_dataframe(self) -> pl.DataFrame:
162208
163209
164210def _get_xlsx (folder ) -> set [str ]:
211+ """Return the set of .xlsx filenames currently present in the provided folder."""
165212 return {_ for _ in os .listdir (folder ) if _ .endswith (".xlsx" )}
166213
167214
168215def main (argv : Sequence [str ] | None = None ):
216+ """Fetch residential benchmark schedules for a hard-coded utility and state."""
169217 logging .basicConfig (level = logging .INFO )
170218
171219 parser = argparse .ArgumentParser (description = "Fetch RateAcuity utility rates" )
0 commit comments