Coverage for tests/test_timeslices.py: 99%
96 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 23:05 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 23:05 +0000
1"""Tests for solar-script timeslice helpers."""
3import importlib.util
4from pathlib import Path
6import pandas as pd
9def ensure_stage_0_config():
10 """
11 Create the minimal normalized SysSettings file expected by stage_0 imports.
12 """
13 repo_root = Path(__file__).resolve().parents[1]
14 stage_0_config = repo_root / "data_intermediate/stage_0_config"
15 stage_0_config.mkdir(parents=True, exist_ok=True)
16 syssettings_toml = stage_0_config / "SysSettings.toml"
17 syssettings_toml.write_text(
18 """
19[StartYear.Data]
20StartYear = [2023]
22[ActivePDef.Data]
23ActivePDef = ["5Year_increments"]
24""".strip() + "\n",
25 encoding="utf-8",
26 )
29def load_solar_run_hourly_profiles():
30 """
31 Load the script module directly from its path for test usage.
32 """
33 ensure_stage_0_config()
34 module_path = (
35 Path(__file__).resolve().parents[1]
36 / "scripts/stage_3_scenarios/electricity/solar_run_hourly_profiles.py"
37 )
38 spec = importlib.util.spec_from_file_location(
39 "solar_run_hourly_profiles", module_path
40 )
41 module = importlib.util.module_from_spec(spec)
42 assert spec.loader is not None
43 spec.loader.exec_module(module)
44 return module
47# pylint: disable=too-many-arguments,too-many-positional-arguments
48def write_test_epw(
49 path: Path,
50 header_year: int,
51 start_weekday: str = "Sunday",
52 days_in_year: int = 365,
53 calendar_label: str = "TMY3 Year",
54 minute: str = "60",
55 data_period_col_6: str | None = None,
56 data_period_col_7: str | None = None,
57):
58 """
59 Write a minimal synthetic EPW file for calendar and timeslice tests.
60 """
61 header = [
62 [
63 "LOCATION",
64 "Test",
65 "Test",
66 "New Zealand",
67 "TMY3",
68 "0",
69 "0",
70 "0",
71 "0",
72 "0",
73 ],
74 ["DESIGN CONDITIONS", "0"],
75 ["TYPICAL/EXTREME PERIODS", "0"],
76 ["GROUND TEMPERATURES", "0"],
77 ["HOLIDAYS/DAYLIGHT SAVING", "No", "0", "0", "0"],
78 ["COMMENTS 1", "Synthetic test EPW"],
79 ["COMMENTS 2", "Synthetic test EPW"],
80 [
81 "DATA PERIODS",
82 "1",
83 "1",
84 calendar_label,
85 start_weekday,
86 data_period_col_6 if data_period_col_6 is not None else "1",
87 data_period_col_7 if data_period_col_7 is not None else str(days_in_year),
88 ],
89 ]
90 rows = []
91 for day in pd.date_range(f"{header_year}-01-01", f"{header_year}-12-31", freq="D"):
92 for hour in range(1, 25):
93 rows.append(
94 [
95 f"{day.year:04d}",
96 f"{day.month:02d}",
97 f"{day.day:02d}",
98 f"{hour:02d}",
99 minute,
100 ]
101 )
103 with path.open("w", encoding="utf-8", newline="") as handle:
104 for row in header + rows:
105 handle.write(",".join(row) + "\n")
108def test_create_timeslices_uses_project_time_of_day_and_daytype_definitions():
109 """
110 Shared timeslice construction should match the active repo settings.
111 """
112 module = load_solar_run_hourly_profiles()
113 df = pd.DataFrame(
114 {
115 "Trading_Date": pd.to_datetime(
116 ["2023-01-02", "2023-01-07", "2023-07-03", "2023-09-10"]
117 ),
118 "Hour": [18, 18, 7, 19],
119 }
120 )
122 result = module.create_timeslices(df)
124 assert result["TimeSlice"].tolist() == [
125 "SUM-WK-P",
126 "SUM-WE-P",
127 "WIN-WK-D",
128 "SPR-WE-N",
129 ]
132def test_create_timeslices_can_use_wall_clock_hour_column():
133 """
134 Timeslice construction should support solar wall-clock hours.
135 """
136 module = load_solar_run_hourly_profiles()
137 df = pd.DataFrame(
138 {
139 "WallClock_Date": pd.to_datetime(["2023-01-02", "2023-07-03"]),
140 "WallClock_Hour": [19, 18],
141 }
142 )
144 result = module.create_timeslices(
145 df,
146 date_col="WallClock_Date",
147 hour_col="WallClock_Hour",
148 )
150 assert result["TimeSlice"].tolist() == ["SUM-WK-N", "WIN-WK-P"]
153def test_build_time_index_uses_model_base_year_and_ignores_epw_calendar_metadata(
154 tmp_path,
155):
156 """
157 Solar timeslices should use the model base year rather than the EPW calendar.
158 """
159 module = load_solar_run_hourly_profiles()
161 epw_files = {}
162 for zone in module.ZONE_ORDER:
163 path = tmp_path / f"TMY3_NZ_{zone}.epw"
164 write_test_epw(
165 path,
166 1999 if zone == "AK" else 2007,
167 start_weekday="Monday",
168 )
169 epw_files[zone] = path
171 time_index = module.build_time_index(epw_files)
173 assert time_index["Trading_Date"].min() == pd.Timestamp("2023-01-01")
174 assert time_index["Trading_Date"].max() == pd.Timestamp("2023-12-31")
175 assert time_index.iloc[0]["Trading_Date"].day_name() == "Sunday"
176 assert time_index.iloc[0]["WallClock_DateTime"].isoformat() == (
177 "2023-01-01T01:00:00+13:00"
178 )
179 assert time_index.iloc[-1]["WallClock_DateTime"].isoformat() == (
180 "2024-01-01T00:00:00+13:00"
181 )
182 summer_peak = time_index[
183 (time_index["Month"] == 1)
184 & (time_index["Day"] == 2)
185 & (time_index["Hour"] == 18)
186 ].iloc[0]
187 winter_peak = time_index[
188 (time_index["Month"] == 7)
189 & (time_index["Day"] == 2)
190 & (time_index["Hour"] == 18)
191 ].iloc[0]
192 assert summer_peak["WallClock_Hour"] == 19
193 assert summer_peak["WallClock_UtcOffsetHours"] == 13.0
194 assert winter_peak["WallClock_Hour"] == 18
195 assert winter_peak["WallClock_UtcOffsetHours"] == 12.0
198def test_convert_epw_standard_time_to_wallclock_uses_nz_timezone_rules():
199 """
200 Solar wall-clock conversion should rely on Pacific/Auckland timezone rules.
201 """
202 module = load_solar_run_hourly_profiles()
204 summer = module.convert_epw_standard_time_to_wallclock(1, 1, 18)
205 winter = module.convert_epw_standard_time_to_wallclock(7, 1, 18)
206 dst_start = module.convert_epw_standard_time_to_wallclock(9, 24, 2)
208 assert summer.isoformat() == "2023-01-01T19:00:00+13:00"
209 assert winter.isoformat() == "2023-07-01T18:00:00+12:00"
210 assert dst_start.isoformat() == "2023-09-24T03:00:00+13:00"
213def test_build_time_index_rejects_leap_base_year(tmp_path):
214 """
215 Leap-year base calendars should fail until the workflow handles them explicitly.
216 """
217 module = load_solar_run_hourly_profiles()
219 epw_files = {}
220 for zone in module.ZONE_ORDER:
221 path = tmp_path / f"TMY3_NZ_{zone}.epw"
222 write_test_epw(path, 2007)
223 epw_files[zone] = path
225 original_base_year = module.BASE_YEAR
226 module.BASE_YEAR = 2024
227 try:
228 try:
229 module.build_time_index(epw_files)
230 raise AssertionError("Expected leap-year base-year guard to raise")
231 except ValueError as exc:
232 assert "BASE_YEAR 2024 is a leap year" in str(exc)
233 finally:
234 module.BASE_YEAR = original_base_year
237def test_discover_epw_files_accepts_tmy3_names(tmp_path):
238 """
239 Prepared EPW discovery should work for the committed TMY3 filenames.
240 """
241 module = load_solar_run_hourly_profiles()
243 for zone in module.ZONE_ORDER:
244 write_test_epw(tmp_path / f"TMY3_NZ_{zone}.epw", 2024)
246 discovered = module.discover_epw_files(tmp_path)
248 assert set(discovered) == set(module.ZONE_ORDER)
249 assert discovered["AK"].name == "TMY3_NZ_AK.epw"
250 assert discovered["HN"].name == "TMY3_NZ_HN.epw"
253def test_parse_epw_data_period_metadata_accepts_tmy3_header_dates(tmp_path):
254 """
255 MBIE TMY3 headers use start/end dates instead of a numeric day count.
256 """
257 module = load_solar_run_hourly_profiles()
258 path = tmp_path / "TMY3_NZ_AK.epw"
259 write_test_epw(
260 path,
261 2024,
262 minute="0",
263 data_period_col_6="1/ 1",
264 data_period_col_7="12/31",
265 )
267 metadata = module.parse_epw_data_period_metadata(path)
269 assert metadata["calendar_label"] == "TMY3 Year"
270 assert metadata["days_in_year"] == 365
271 assert metadata["start_date"] == "1/ 1"
272 assert metadata["end_date"] == "12/31"