From 19e22a73ca0575fab33953c65418132e0faccb48 Mon Sep 17 00:00:00 2001 From: nick-gorman Date: Mon, 24 Aug 2026 10:01:08 +1000 Subject: [PATCH 1/2] Rename the timeslices table's timeslice_id column to timeslice The tables that reference timeslice windows (network_transmission_path_limits, custom_constraints_rhs) have always called the column timeslice, so any consumer joining limits onto windows had to rename one side first -- the apply-per-timeslice-link-limits branch adds the first such join and with it the first rename shim. Using one name end-to-end removes that seam; the divergence from the path_id/constraint_id suffix convention is accepted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Eip6Q6HyAwbqVkoKthGA8Z --- src/ispypsa/pypsa_build/links.py | 13 ++--- src/ispypsa/templater/timeslices.py | 52 +++++++++---------- .../schemas/custom_constraints_rhs.yaml | 2 +- .../network_transmission_path_limits.yaml | 6 +-- .../validation/schemas/timeslices.yaml | 8 +-- .../test_add_links_with_timeslice_limits.py | 20 +++---- tests/test_templater/test_timeslices.py | 42 +++++++-------- 7 files changed, 72 insertions(+), 71 deletions(-) diff --git a/src/ispypsa/pypsa_build/links.py b/src/ispypsa/pypsa_build/links.py index 7d24dab9..ee35aa54 100644 --- a/src/ispypsa/pypsa_build/links.py +++ b/src/ispypsa/pypsa_build/links.py @@ -42,7 +42,7 @@ def _add_links_to_network( CQ-NQ_existing p_min_pu , -0.714 # fallback only timeslice_snapshots: - timeslice_id investment_periods snapshots + timeslice investment_periods snapshots qld_peak_demand 2025 2025-01-13 12:00 network.snapshots: @@ -115,7 +115,7 @@ def _build_link_pu_overrides( CQ-NQ_existing p_min_pu , -0.714 # fallback only timeslice_snapshots: - timeslice_id investment_periods snapshots + timeslice investment_periods snapshots qld_peak_demand 2025 2025-01-13 12:00 snapshots: @@ -161,7 +161,7 @@ def _expand_limits_to_snapshots( CQ-NQ_existing p_min_pu qld_peak_demand -0.9 # no fallback timeslice_snapshots: - timeslice_id investment_periods snapshots + timeslice investment_periods snapshots qld_peak_demand 2025 2025-01-13 12:00 snapshots: @@ -234,15 +234,16 @@ def _place_named_limits_at_snapshots( CQ-NQ_existing p_max_pu qld_peak_demand 0.857 timeslice_snapshots: - timeslice_id investment_periods snapshots + timeslice investment_periods snapshots qld_peak_demand 2025 2025-01-13 12:00 returns: name attribute investment_periods snapshots value CQ-NQ_existing p_max_pu 2025 2025-01-13 12:00 0.857 """ - active_at = timeslice_snapshots.rename(columns={"timeslice_id": "timeslice"}) - active_at["snapshots"] = pd.to_datetime(active_at["snapshots"]) + active_at = timeslice_snapshots.assign( + snapshots=pd.to_datetime(timeslice_snapshots["snapshots"]) + ) placed = named.merge(active_at, on="timeslice") return placed.loc[:, _LIMIT_PER_SNAPSHOT_COLUMNS] diff --git a/src/ispypsa/templater/timeslices.py b/src/ispypsa/templater/timeslices.py index b7275568..70d04eba 100644 --- a/src/ispypsa/templater/timeslices.py +++ b/src/ispypsa/templater/timeslices.py @@ -21,7 +21,7 @@ from ispypsa.templater.custom_constraints_from_plexos import _tag_to_timeslice _TIMESLICE_COLUMNS = [ - "timeslice_id", + "timeslice", "reference_year", "start_month_day", "end_month_day", @@ -90,7 +90,7 @@ def _template_timeslices( 2026 2015 returns: - timeslice_id reference_year start_month_day end_month_day + timeslice reference_year start_month_day end_month_day nsw_peak_demand 2015 11-18 11-20 # end exclusive """ events = _parse_calendar_events(timeslice_calendar) @@ -115,12 +115,12 @@ def _parse_calendar_events(calendar: pd.DataFrame) -> pd.DataFrame: I/O Example: DATETIME=18/11/2021, NAME="NSW Hot Day", TIMESLICE=-1 - -> DATETIME=2021-11-18, timeslice_id="nsw_peak_demand", TIMESLICE=-1 + -> DATETIME=2021-11-18, timeslice="nsw_peak_demand", TIMESLICE=-1 """ events = calendar.copy() events["DATETIME"] = pd.to_datetime(events["DATETIME"], dayfirst=True) - events["timeslice_id"] = events["NAME"].map(_tag_to_timeslice) - return events.sort_values(["timeslice_id", "DATETIME"]) + events["timeslice"] = events["NAME"].map(_tag_to_timeslice) + return events.sort_values(["timeslice", "DATETIME"]) def _add_next_event_columns(events: pd.DataFrame) -> pd.DataFrame: @@ -128,16 +128,16 @@ def _add_next_event_columns(events: pd.DataFrame) -> pd.DataFrame: same timeslice (NaN/NaT on each timeslice's last event). I/O Example: - timeslice_id DATETIME TIMESLICE + timeslice DATETIME TIMESLICE nsw_peak_demand 2021-11-18 -1 nsw_peak_demand 2021-11-20 0 -> - timeslice_id DATETIME TIMESLICE next_date next_state + timeslice DATETIME TIMESLICE next_date next_state nsw_peak_demand 2021-11-18 -1 2021-11-20 0 nsw_peak_demand 2021-11-20 0 NaT NaN """ - grouped = events.groupby("timeslice_id") + grouped = events.groupby("timeslice") events["next_date"] = grouped["DATETIME"].shift(-1) events["next_state"] = grouped["TIMESLICE"].shift(-1) return events @@ -163,19 +163,19 @@ def _extract_windows(events: pd.DataFrame) -> pd.DataFrame: """Turns each on event into a window row ending at the paired off event. I/O Example: - timeslice_id DATETIME TIMESLICE next_date next_state + timeslice DATETIME TIMESLICE next_date next_state nsw_peak_demand 2021-11-18 -1 2021-11-20 0 nsw_peak_demand 2021-11-20 0 NaT NaN returns: - timeslice_id start_date end_date + timeslice start_date end_date nsw_peak_demand 2021-11-18 2021-11-20 """ windows = events[events["TIMESLICE"] == -1] windows = windows.rename( columns={"DATETIME": "start_date", "next_date": "end_date"} ) - return windows[["timeslice_id", "start_date", "end_date"]].reset_index(drop=True) + return windows[["timeslice", "start_date", "end_date"]].reset_index(drop=True) def _drop_horizon_truncated_planning_years(windows: pd.DataFrame) -> pd.DataFrame: @@ -187,7 +187,7 @@ def _drop_horizon_truncated_planning_years(windows: pd.DataFrame) -> pd.DataFram nothing is lost by dropping the year entirely. I/O Example: - timeslice_id start_date end_date planning_year + timeslice start_date end_date planning_year nsw_peak_demand 2057-11-18 2057-11-20 2058 # dropped: shares nsw_winter_reference 2058-04-01 NaT 2058 # the truncated year nsw_peak_demand 2056-11-18 2056-11-20 2057 # kept @@ -228,7 +228,7 @@ def _extend_sequence_to_horizon( 2027 2011 windows (only planning_year is read): - timeslice_id start_date end_date planning_year + timeslice start_date end_date planning_year nsw_peak_demand 2028-11-18 2028-11-20 2029 returns: @@ -256,17 +256,17 @@ def _convert_windows_to_month_days(windows: pd.DataFrame) -> pd.DataFrame: next calendar year, and winter's 04-01 -> 10-01 extends past 30 June). I/O Example: - timeslice_id start_date end_date planning_year reference_year + timeslice start_date end_date planning_year reference_year nsw_peak_demand 2025-11-18 2025-11-20 2026 2015 -> - timeslice_id reference_year planning_year start_month_day end_month_day + timeslice reference_year planning_year start_month_day end_month_day nsw_peak_demand 2015 2026 11-18 11-20 """ windows["start_month_day"] = windows["start_date"].dt.strftime("%m-%d") windows["end_month_day"] = windows["end_date"].dt.strftime("%m-%d") return windows[ - ["timeslice_id", "reference_year", "planning_year"] + ["timeslice", "reference_year", "planning_year"] + ["start_month_day", "end_month_day"] ] @@ -278,7 +278,7 @@ def _raise_on_inconsistent_reference_year_patterns(patterns: pd.DataFrame) -> No decoding one pattern per reference year would silently lose windows.""" occurrences = patterns.groupby(["reference_year", "planning_year"]).apply( lambda x: frozenset( - zip(x["timeslice_id"], x["start_month_day"], x["end_month_day"]) + zip(x["timeslice"], x["start_month_day"], x["end_month_day"]) ), include_groups=False, ) @@ -296,12 +296,12 @@ def _keep_first_occurrence_per_reference_year(patterns: pd.DataFrame) -> pd.Data occurrences are identical — validated before this is called). I/O Example: - timeslice_id reference_year planning_year start_month_day end_month_day + timeslice reference_year planning_year start_month_day end_month_day nsw_peak_demand 2015 2026 11-18 11-20 nsw_peak_demand 2015 2031 11-18 11-20 returns: - timeslice_id reference_year start_month_day end_month_day + timeslice reference_year start_month_day end_month_day nsw_peak_demand 2015 11-18 11-20 """ first_occurrence = patterns.groupby("reference_year")["planning_year"].transform( @@ -316,12 +316,12 @@ def _raise_unless_windows_tile_the_year(timeslices: pd.DataFrame) -> None: """Raise unless, within every region and reference year, the windows tile the year exactly — no day left uncovered and none covered twice. A gap there would let a snapshot fall in no timeslice (silently taking a base limit); an - overlap would let it fall in two. The region is the timeslice_id prefix + overlap would let it fall in two. The region is the timeslice prefix before the first underscore. """ - # add a region column from the timeslice_id prefix (nsw_peak_demand -> nsw) + # add a region column from the timeslice prefix (nsw_peak_demand -> nsw) # to group by, so each region's windows are checked for tiling independently - tagged = timeslices.assign(region=timeslices["timeslice_id"].str.split("_").str[0]) + tagged = timeslices.assign(region=timeslices["timeslice"].str.split("_").str[0]) not_tiling = sorted( (region, int(year)) for (region, year), windows in tagged.groupby(["region", "reference_year"]) @@ -371,8 +371,8 @@ def _raise_unless_only_winter_crosses_financial_year(timeslices: pd.DataFrame) - ), axis=1, ) - is_winter = timeslices["timeslice_id"].str.endswith("_winter_reference") - crossing = sorted(timeslices.loc[spans_july & ~is_winter, "timeslice_id"].unique()) + is_winter = timeslices["timeslice"].str.endswith("_winter_reference") + crossing = sorted(timeslices.loc[spans_july & ~is_winter, "timeslice"].unique()) if crossing: raise ValueError( f"Only winter_reference windows may cross the 1 July financial-year " @@ -403,8 +403,8 @@ def _raise_unless_winter_is_constant_per_region(timeslices: pd.DataFrame) -> Non reference years, so which reference year owns its end never changes its value. This is the assumption documented on _template_timeslices. """ - winter = timeslices[timeslices["timeslice_id"].str.endswith("_winter_reference")] - winter = winter.assign(region=winter["timeslice_id"].str.split("_").str[0]) + winter = timeslices[timeslices["timeslice"].str.endswith("_winter_reference")] + winter = winter.assign(region=winter["timeslice"].str.split("_").str[0]) varying = sorted( region for region, group in winter.groupby("region") diff --git a/src/ispypsa/validation/schemas/custom_constraints_rhs.yaml b/src/ispypsa/validation/schemas/custom_constraints_rhs.yaml index d08aa20d..08f5c977 100644 --- a/src/ispypsa/validation/schemas/custom_constraints_rhs.yaml +++ b/src/ispypsa/validation/schemas/custom_constraints_rhs.yaml @@ -22,7 +22,7 @@ columns: type: string required: false allowed_values_from: - - timeslices: timeslice_id + - timeslices: timeslice description: > Demand condition the limit applies to: the constraint binds only at snapshots inside the timeslice's active windows. diff --git a/src/ispypsa/validation/schemas/network_transmission_path_limits.yaml b/src/ispypsa/validation/schemas/network_transmission_path_limits.yaml index 8c41bae4..992db29b 100644 --- a/src/ispypsa/validation/schemas/network_transmission_path_limits.yaml +++ b/src/ispypsa/validation/schemas/network_transmission_path_limits.yaml @@ -33,7 +33,7 @@ custom_validation: A path that names timeslices must name them all for its region, or add an empty-timeslice fallback, in both directions; a single all-empty row covers the whole space. Because the timeslices table's windows partition the year, covering every - region timeslice_id covers every snapshot downstream. + region timeslice covers every snapshot downstream. columns: path_id: type: string @@ -60,10 +60,10 @@ columns: type: string required: false allowed_values_from: - - timeslices: timeslice_id + - timeslices: timeslice description: > Demand condition the limit applies to: the limit binds only at snapshots - inside the timeslice's active windows. A timeslice_id's region is the prefix + inside the timeslice's active windows. A timeslice's region is the prefix before its first underscore (e.g. qld in qld_peak_demand). If absent (or empty): diff --git a/src/ispypsa/validation/schemas/timeslices.yaml b/src/ispypsa/validation/schemas/timeslices.yaml index e0c1ab96..303d0041 100644 --- a/src/ispypsa/validation/schemas/timeslices.yaml +++ b/src/ispypsa/validation/schemas/timeslices.yaml @@ -1,11 +1,11 @@ table: timeslices required: false unique: - - [timeslice_id, reference_year, start_month_day] + - [timeslice, reference_year, start_month_day] custom_validation: - name: no_overlapping_windows_per_region_and_reference_year description: > - Within a region (the timeslice_id prefix before the first underscore) + Within a region (the timeslice prefix before the first underscore) and reference_year, no two windows may cover the same day. Windows are compared as expanded [start, end) day ranges, not raw month-day strings: an end_month_day at or before its start wraps into the following calendar @@ -13,7 +13,7 @@ custom_validation: next financial year (04-01 to 10-01). - name: windows_cover_full_year_per_region_and_reference_year description: > - Within a region (the timeslice_id prefix before the first underscore) + Within a region (the timeslice prefix before the first underscore) and reference_year, the windows must cover every day of the year, leaving no day with no active timeslice. Together with no_overlapping_windows_per_region_and_reference_year this makes the @@ -52,7 +52,7 @@ description: > No timeslice is ever active: timeslice-tagged transmission path limits and custom-constraint RHS values never apply to any snapshot. columns: - timeslice_id: + timeslice: type: string required: true allowed_values: [ diff --git a/tests/test_model/test_add_links_with_timeslice_limits.py b/tests/test_model/test_add_links_with_timeslice_limits.py index 8de262cf..e109bfc3 100644 --- a/tests/test_model/test_add_links_with_timeslice_limits.py +++ b/tests/test_model/test_add_links_with_timeslice_limits.py @@ -48,7 +48,7 @@ def test_named_timeslices_overlay_the_fallback(csv_str_to_df): CQ-NQ_existing, p_min_pu, , -0.714 """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots qld_peak_demand, 2025, 2025-01-01 01:00:00 qld_peak_demand, 2025, 2025-01-01 02:00:00 """) @@ -79,7 +79,7 @@ def test_fallback_only_attribute_gets_the_fallback_at_every_snapshot(csv_str_to_ CQ-NQ_existing, p_min_pu, , -0.714 """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots qld_peak_demand, 2025, 2025-01-01 01:00:00 """) @@ -114,7 +114,7 @@ def test_fallback_only_limits_with_no_timeslices_apply_at_every_snapshot(csv_str CQ-NQ_existing, p_min_pu, , 0.0 """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots """) _add_links_to_network( @@ -141,7 +141,7 @@ def test_named_timeslices_that_tile_the_snapshots_need_no_fallback(csv_str_to_df CQ-NQ_existing, p_min_pu, , -0.714 """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots qld_winter_reference, 2025, 2025-01-01 00:00:00 qld_peak_demand, 2025, 2025-01-01 01:00:00 qld_peak_demand, 2025, 2025-01-01 02:00:00 @@ -172,7 +172,7 @@ def test_named_timeslice_with_no_snapshots_leaves_the_fallback(csv_str_to_df): CQ-NQ_existing, p_min_pu, , -0.714 """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots """) _add_links_to_network( @@ -198,7 +198,7 @@ def test_snapshot_covered_by_neither_named_timeslice_nor_fallback_raises(csv_str CQ-NQ_existing, p_min_pu, , -0.714 """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots qld_peak_demand, 2025, 2025-01-01 01:00:00 """) @@ -226,7 +226,7 @@ def test_existing_link_with_no_timeslice_limits_raises(csv_str_to_df): name, attribute, timeslice, value """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots """) with pytest.raises(ValueError) as excinfo: @@ -252,7 +252,7 @@ def test_existing_link_missing_one_attribute_raises(csv_str_to_df): CQ-NQ_existing, p_max_pu, , 1.0 """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots qld_peak_demand, 2025, 2025-01-01 01:00:00 """) @@ -286,7 +286,7 @@ def test_expansion_links_keep_their_static_values(csv_str_to_df): CQ-NQ_existing, p_min_pu, , -0.714 """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots qld_peak_demand, 2025, 2025-01-01 01:00:00 """) @@ -341,7 +341,7 @@ def test_expand_limits_to_snapshots(csv_str_to_df): NQ-CQ_other, p_max_pu, , 0.8 """) timeslice_snapshots = csv_str_to_df(""" - timeslice_id, investment_periods, snapshots + timeslice, investment_periods, snapshots qld_peak_demand, 2025, 2025-01-01 01:00:00 """) snapshots = pd.MultiIndex.from_arrays( diff --git a/tests/test_templater/test_timeslices.py b/tests/test_templater/test_timeslices.py index 76386757..5619897e 100644 --- a/tests/test_templater/test_timeslices.py +++ b/tests/test_templater/test_timeslices.py @@ -51,7 +51,7 @@ def test_template_timeslices_decodes_one_pattern_per_reference_year(csv_str_to_d # belongs to FY2026's reference year. The typical summer end wraps past # the new year. expected = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_peak_demand, 2011, 12-10, 12-12 nsw_winter_reference, 2011, 12-12, 12-10 nsw_peak_demand, 2015, 11-18, 11-20 @@ -61,8 +61,8 @@ def test_template_timeslices_decodes_one_pattern_per_reference_year(csv_str_to_d qld_winter_reference, 2015, 04-01, 10-01 """) # expected rows are grouped by reference year for readability; the templater - # returns them in timeslice_id order, so compare on a shared sort key - sort_key = ["reference_year", "timeslice_id", "start_month_day"] + # returns them in timeslice order, so compare on a shared sort key + sort_key = ["reference_year", "timeslice", "start_month_day"] pd.testing.assert_frame_equal( result.sort_values(sort_key).reset_index(drop=True), expected.sort_values(sort_key).reset_index(drop=True), @@ -94,7 +94,7 @@ def test_template_timeslices_deduplicates_repeated_reference_year(csv_str_to_df) result = _template_timeslices(timeslice_calendar, reference_year_sequence) expected = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_peak_demand, 2015, 11-18, 11-20 nsw_winter_reference, 2015, 11-20, 11-18 """) @@ -128,7 +128,7 @@ def test_template_timeslices_extends_sequence_cyclically(csv_str_to_df): result = _template_timeslices(timeslice_calendar, reference_year_sequence) expected = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_peak_demand, 2011, 12-10, 12-12 nsw_peak_demand, 2015, 11-18, 11-20 nsw_winter_reference, 2011, 12-12, 12-10 @@ -206,7 +206,7 @@ def test_template_timeslices_drops_horizon_truncated_planning_year(csv_str_to_df result = _template_timeslices(timeslice_calendar, reference_year_sequence) expected = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_peak_demand, 2015, 11-18, 11-20 nsw_summer_typical, 2015, 10-01, 11-18 nsw_summer_typical, 2015, 11-20, 04-01 @@ -235,7 +235,7 @@ def test_template_timeslices_drops_windows_before_sequence_start(csv_str_to_df): result = _template_timeslices(timeslice_calendar, reference_year_sequence) expected = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_peak_demand, 2015, 12-10, 12-12 nsw_winter_reference, 2015, 12-12, 12-10 """) @@ -252,7 +252,7 @@ def test_template_timeslices_empty_calendar(csv_str_to_df): result = _template_timeslices(timeslice_calendar, reference_year_sequence) expected = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day """) pd.testing.assert_frame_equal(result, expected, check_dtype=False) @@ -272,8 +272,8 @@ def test_shipped_calendar_decodes(): assert sorted(result["reference_year"].unique()) == list(range(2011, 2026)) # tas_peak_demand never activates in the Draft 2026 ISP calendar; the other # 14 region-prefixed timeslices all do. - assert "tas_peak_demand" not in set(result["timeslice_id"]) - assert result["timeslice_id"].nunique() == 14 + assert "tas_peak_demand" not in set(result["timeslice"]) + assert result["timeslice"].nunique() == 14 # Reaching here means the inline partition guard passed: the shipped # calendar's windows tile each reference year exactly. The reference-year # attribution also relies on only winter crossing 1 July and winter being @@ -293,7 +293,7 @@ def test_template_timeslices_empty_sequence(csv_str_to_df): result = _template_timeslices(timeslice_calendar, reference_year_sequence) expected = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day """) pd.testing.assert_frame_equal(result, expected, check_dtype=False) @@ -301,7 +301,7 @@ def test_template_timeslices_empty_sequence(csv_str_to_df): def test_coverage_guard_passes_on_full_partition(csv_str_to_df): # peak carved out of summer, winter filling the cool half: tiles the year. timeslices = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_peak_demand, 2015, 01-25, 01-26 nsw_summer_typical, 2015, 01-26, 04-01 nsw_winter_reference, 2015, 04-01, 11-01 @@ -314,7 +314,7 @@ def test_coverage_guard_passes_on_full_partition(csv_str_to_df): def test_coverage_guard_passes_without_peak_timeslice(csv_str_to_df): # tas has no peak_demand; summer + winter alone still tile the year. timeslices = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day tas_summer_typical, 2015, 01-01, 07-01 tas_winter_reference, 2015, 07-01, 01-01 """) @@ -325,7 +325,7 @@ def test_coverage_guard_passes_without_peak_timeslice(csv_str_to_df): def test_coverage_guard_raises_on_gap(csv_str_to_df): # June (06-01 to 07-01) is covered by no window. timeslices = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_summer_typical, 2015, 01-01, 06-01 nsw_winter_reference, 2015, 07-01, 01-01 """) @@ -339,7 +339,7 @@ def test_coverage_guard_raises_on_gap(csv_str_to_df): def test_coverage_guard_raises_on_overlap(csv_str_to_df): # July (07-01 to 08-01) is covered by both summer and winter. timeslices = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_summer_typical, 2015, 01-01, 08-01 nsw_winter_reference, 2015, 07-01, 01-01 """) @@ -354,7 +354,7 @@ def test_only_winter_may_cross_financial_year_boundary_raises(csv_str_to_df): # A summer window spanning 1 July is weather-varying AND boundary-crossing: # exactly the case that could blur reference years. timeslices = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_summer_typical, 2015, 06-01, 08-01 """) @@ -365,7 +365,7 @@ def test_only_winter_may_cross_financial_year_boundary_raises(csv_str_to_df): def test_only_winter_may_cross_financial_year_boundary_passes(csv_str_to_df): # Winter spans 1 July (allowed); the wrapping summer covers Nov-Apr, not July. timeslices = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_winter_reference, 2015, 04-01, 11-01 nsw_summer_typical, 2015, 11-01, 04-01 """) @@ -376,7 +376,7 @@ def test_only_winter_may_cross_financial_year_boundary_passes(csv_str_to_df): def test_winter_must_be_constant_per_region_raises(csv_str_to_df): # NSW winter differs between its two reference years. timeslices = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_winter_reference, 2015, 04-01, 11-01 nsw_winter_reference, 2011, 04-01, 10-15 """) @@ -388,7 +388,7 @@ def test_winter_must_be_constant_per_region_raises(csv_str_to_df): def test_winter_constant_per_region_passes(csv_str_to_df): # NSW winter is identical across reference years; TAS may differ from NSW. timeslices = csv_str_to_df(""" - timeslice_id, reference_year, start_month_day, end_month_day + timeslice, reference_year, start_month_day, end_month_day nsw_winter_reference, 2015, 04-01, 11-01 nsw_winter_reference, 2011, 04-01, 11-01 tas_winter_reference, 2015, 03-01, 12-01 @@ -402,7 +402,7 @@ def test_convert_windows_to_month_days_preserves_leap_day(csv_str_to_df): # shipped calendar. The templater must keep "02-29" — clamping it to 02-28 # in non-leap model years is the translator's job, not the templater's. windows = csv_str_to_df(""" - timeslice_id, reference_year, planning_year, start_date, end_date + timeslice, reference_year, planning_year, start_date, end_date nsw_peak_demand, 2024, 2040, 2040-02-29, 2040-03-01 """) # _convert_windows_to_month_days strftimes these, so they must be datetimes @@ -412,7 +412,7 @@ def test_convert_windows_to_month_days_preserves_leap_day(csv_str_to_df): result = _convert_windows_to_month_days(windows) expected = csv_str_to_df(""" - timeslice_id, reference_year, planning_year, start_month_day, end_month_day + timeslice, reference_year, planning_year, start_month_day, end_month_day nsw_peak_demand, 2024, 2040, 02-29, 03-01 """) pd.testing.assert_frame_equal(result, expected) From b24a95b3cf7ed41a155e82865f53ef164bf46690 Mon Sep 17 00:00:00 2001 From: nick-gorman Date: Mon, 24 Aug 2026 10:14:39 +1000 Subject: [PATCH 2/2] Prefer an explicit copy over assign when localising the date parse The point of the line is that the caller's timeslice_snapshots frame is not mutated; .copy() states that directly instead of leaning on assign returning a new frame. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Eip6Q6HyAwbqVkoKthGA8Z --- src/ispypsa/pypsa_build/links.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ispypsa/pypsa_build/links.py b/src/ispypsa/pypsa_build/links.py index ee35aa54..18a9d735 100644 --- a/src/ispypsa/pypsa_build/links.py +++ b/src/ispypsa/pypsa_build/links.py @@ -241,9 +241,8 @@ def _place_named_limits_at_snapshots( name attribute investment_periods snapshots value CQ-NQ_existing p_max_pu 2025 2025-01-13 12:00 0.857 """ - active_at = timeslice_snapshots.assign( - snapshots=pd.to_datetime(timeslice_snapshots["snapshots"]) - ) + active_at = timeslice_snapshots.copy() + active_at["snapshots"] = pd.to_datetime(active_at["snapshots"]) placed = named.merge(active_at, on="timeslice") return placed.loc[:, _LIMIT_PER_SNAPSHOT_COLUMNS]