Skip to content

Commit 7d88d07

Browse files
committed
feat: Add power_trade.py
1 parent 712aa54 commit 7d88d07

File tree

2 files changed

+345
-0
lines changed

2 files changed

+345
-0
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""power_trade.py: PowerTradeParams dataclass for electricity system trading"""
2+
3+
import dataclasses
4+
import math
5+
6+
import pandas as pd
7+
8+
9+
@dataclasses.dataclass()
10+
class PowerTradeParams:
11+
"""
12+
Data class to hold static parameters for electricity trading among power systems.
13+
14+
Attributes:
15+
intertie_points (list[tuple[str, str]]): List of tuples representing intertie points between balancing areas (Node-Node).
16+
intertie_capacities (dict[tuple[str, str], float]): Dictionary mapping intertie points to their capacities in MW.
17+
connected_ba (list[tuple[str, str]]): List of tuples representing connected balancing authorities (BA-BA).
18+
total_transfer_limits (dict[tuple[str, str], float]): Dictionary mapping trading corridors (BA-BA) to their total transfer limits in MW.
19+
"""
20+
21+
intertie_points: list[tuple[str, str]] = dataclasses.field(default_factory=list)
22+
intertie_capacities: dict[tuple[str, str], float] = dataclasses.field(default_factory=dict)
23+
24+
connected_ba: list[tuple[str, str]] = dataclasses.field(default_factory=list)
25+
total_transfer_limits: dict[tuple[str, str], float] = dataclasses.field(default_factory=dict)
26+
27+
def __post_init__(self):
28+
# Ensure capacities are positive
29+
for intertie, capacity in self.intertie_capacities.items():
30+
if capacity < 0:
31+
raise ValueError(f"Intertie capacity for {intertie} cannot be negative: {capacity} MW")
32+
33+
# Ensure there are no self-loops in intertie_points
34+
for intertie in self.intertie_points:
35+
if intertie[0] == intertie[1]:
36+
raise ValueError(f"Self-loop intertie found: {intertie[0]} to {intertie[1]} in intertie points: {self.intertie_points}")
37+
38+
# Ensure there is no reverse intertie_points
39+
for intertie in self.intertie_points:
40+
if (intertie[1], intertie[0]) in self.intertie_points:
41+
raise ValueError(f"Reverse intertie {intertie[1]}, {intertie[0]} found in intertie points: {self.intertie_points}")
42+
43+
# Ensure connected_ba are unique pairs
44+
unique_ba_pairs = set()
45+
for ba_pair in self.connected_ba:
46+
if (ba_pair[1], ba_pair[0]) in unique_ba_pairs:
47+
raise ValueError(f"Reverse BA pair {ba_pair[1]}, {ba_pair[0]} found in connected BA: {self.connected_ba}")
48+
unique_ba_pairs.add(ba_pair)
49+
50+
@classmethod
51+
def from_csv(cls, csv_file: str):
52+
"""
53+
Factory method to create a PowerTradeParams instance from a CSV file.
54+
The CSV should have columns: intertie_1_ba, intertie_1_node, intertie_2_ba, intertie_2_node, capacity_mw.
55+
56+
Args:
57+
csv_file (str): Path to the CSV file containing trading parameters.
58+
59+
Returns:
60+
PowerTradeParams: A fully populated PowerTradeParams instance.
61+
62+
Raises:
63+
ValueError: If the CSV file does not contain the required columns or invalid data.
64+
"""
65+
df = pd.read_csv(csv_file, header=0)
66+
67+
# --- Check for required columns ---
68+
required_columns = [
69+
"intertie_1_ba",
70+
"intertie_1_node",
71+
"intertie_2_ba",
72+
"intertie_2_node",
73+
"capacity_mw",
74+
]
75+
if not all(col in df.columns for col in required_columns):
76+
raise ValueError(
77+
f"CSV file must contain the following columns: {', '.join(required_columns)}"
78+
)
79+
80+
# --- Collect attributes from the DataFrame ---
81+
# Initialize temporary lists/dicts to build the data
82+
temp_intertie_points = []
83+
temp_intertie_capacities = {}
84+
temp_connected_ba = []
85+
temp_total_transfer_limits = {}
86+
87+
for _, row in df.iterrows():
88+
intertie_1_node = row["intertie_1_node"]
89+
intertie_1_ba = row["intertie_1_ba"]
90+
intertie_2_node = row["intertie_2_node"]
91+
intertie_2_ba = row["intertie_2_ba"]
92+
capacity = float(row["capacity_mw"])
93+
94+
# Intertie Points and Capacities
95+
intertie_tuple = (intertie_1_node, intertie_2_node)
96+
if intertie_tuple in temp_intertie_capacities:
97+
raise ValueError(
98+
f"Duplicate intertie point found: {intertie_tuple} with capacity {capacity} MW. "
99+
"Each intertie point must be unique."
100+
)
101+
else:
102+
temp_intertie_points.append(intertie_tuple)
103+
temp_intertie_capacities[intertie_tuple] = capacity
104+
105+
# Connected BAs and Total Transfer Limits
106+
ba_corridor = (intertie_1_ba, intertie_2_ba)
107+
if ba_corridor not in temp_connected_ba:
108+
temp_connected_ba.append(ba_corridor)
109+
110+
temp_total_transfer_limits[ba_corridor] = temp_total_transfer_limits.get(ba_corridor, 0.0) + capacity
111+
112+
# Instantiate the dataclass with the collected data
113+
# __post_init__ will be called automatically after this
114+
return cls(
115+
intertie_points=temp_intertie_points,
116+
intertie_capacities=temp_intertie_capacities,
117+
connected_ba=temp_connected_ba,
118+
total_transfer_limits=temp_total_transfer_limits,
119+
)
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import unittest
2+
import os
3+
import tempfile
4+
import dataclasses
5+
6+
7+
from pownet.data_model.power_trade import PowerTradeParams
8+
9+
# --- Test Data ---
10+
VALID_INTERTIE_POINTS = [("N1", "N2"), ("N3", "N4")]
11+
VALID_INTERTIE_CAPACITIES = {("N1", "N2"): 100.0, ("N3", "N4"): 200.0}
12+
VALID_CONNECTED_BA = [("BA1", "BA2"), ("BA3", "BA4")]
13+
VALID_TOTAL_TRANSFER_LIMITS = {("BA1", "BA2"): 150.0, ("BA3", "BA4"): 250.0}
14+
15+
CSV_HEADER = "intertie_1_ba,intertie_1_node,intertie_2_ba,intertie_2_node,capacity_mw\n"
16+
VALID_CSV_DATA_ROW1 = "BA1,N1,BA2,N2,100.0\n"
17+
VALID_CSV_DATA_ROW2 = "BA1,N3,BA2,N4,50.5\n"
18+
VALID_CSV_DATA_ROW3 = "BA3,N5,BA4,N6,200.0\n"
19+
20+
21+
class TestPowerTradeParams(unittest.TestCase):
22+
"""Test suite for the PowerTradeParams dataclass and its methods."""
23+
24+
def _create_temp_csv_file(self, content: str) -> str:
25+
"""
26+
Helper method to create a temporary CSV file with given content.
27+
The file is registered for cleanup after the test.
28+
Returns the path to the temporary file.
29+
"""
30+
# Use mkstemp for a unique file name and then open it in text mode
31+
fd, path = tempfile.mkstemp(suffix=".csv", text=True)
32+
with os.fdopen(fd, "w") as tmp_file:
33+
tmp_file.write(content)
34+
self.addCleanup(
35+
os.remove, path
36+
) # Ensures the file is deleted after the test method
37+
return path
38+
39+
# --- __init__ and __post_init__ Tests ---
40+
41+
def test_successful_initialization(self):
42+
"""Test successful instantiation with valid parameters."""
43+
params = PowerTradeParams(
44+
intertie_points=VALID_INTERTIE_POINTS,
45+
intertie_capacities=VALID_INTERTIE_CAPACITIES,
46+
connected_ba=VALID_CONNECTED_BA,
47+
total_transfer_limits=VALID_TOTAL_TRANSFER_LIMITS,
48+
)
49+
self.assertEqual(params.intertie_points, VALID_INTERTIE_POINTS)
50+
self.assertEqual(params.intertie_capacities, VALID_INTERTIE_CAPACITIES)
51+
self.assertEqual(params.connected_ba, VALID_CONNECTED_BA)
52+
self.assertEqual(params.total_transfer_limits, VALID_TOTAL_TRANSFER_LIMITS)
53+
54+
def test_default_factory_initialization(self):
55+
"""Test instantiation with default factory values (empty lists/dicts)."""
56+
params = PowerTradeParams()
57+
self.assertEqual(params.intertie_points, [])
58+
self.assertEqual(params.intertie_capacities, {})
59+
self.assertEqual(params.connected_ba, [])
60+
self.assertEqual(params.total_transfer_limits, {})
61+
62+
def test_post_init_negative_intertie_capacity(self):
63+
"""Test __post_init__ raises ValueError for negative intertie capacity."""
64+
with self.assertRaisesRegex(ValueError, "cannot be negative"):
65+
PowerTradeParams(intertie_capacities={("N1", "N2"): -100.0})
66+
67+
def test_post_init_self_loop_intertie(self):
68+
"""Test __post_init__ raises ValueError for self-loop in intertie_points."""
69+
with self.assertRaisesRegex(ValueError, "Self-loop intertie found"):
70+
PowerTradeParams(intertie_points=[("N1", "N1")])
71+
72+
def test_post_init_reverse_intertie(self):
73+
"""Test __post_init__ raises ValueError for reverse intertie in intertie_points."""
74+
with self.assertRaisesRegex(
75+
ValueError, "Reverse intertie .* found in intertie points"
76+
):
77+
PowerTradeParams(intertie_points=[("N1", "N2"), ("N2", "N1")])
78+
79+
def test_post_init_reverse_ba_pair(self):
80+
"""Test __post_init__ raises ValueError for reverse BA pair in connected_ba."""
81+
with self.assertRaisesRegex(
82+
ValueError, "Reverse BA pair .* found in connected BA"
83+
):
84+
PowerTradeParams(connected_ba=[("BA1", "BA2"), ("BA2", "BA1")])
85+
86+
# --- from_csv Tests ---
87+
88+
def test_from_csv_successful_creation(self):
89+
"""Test successful creation of PowerTradeParams from a valid CSV file."""
90+
csv_content = CSV_HEADER + VALID_CSV_DATA_ROW1 + VALID_CSV_DATA_ROW2
91+
# BA1,N1,BA2,N2,100.0
92+
# BA1,N3,BA2,N4,50.5
93+
temp_csv_path = self._create_temp_csv_file(csv_content)
94+
95+
params = PowerTradeParams.from_csv(temp_csv_path)
96+
97+
expected_intertie_points = [("N1", "N2"), ("N3", "N4")]
98+
expected_intertie_capacities = {("N1", "N2"): 100.0, ("N3", "N4"): 50.5}
99+
expected_connected_ba = [("BA1", "BA2")] # Only one BA-BA pair
100+
expected_total_transfer_limits = {("BA1", "BA2"): 150.5} # Sum of capacities
101+
102+
self.assertCountEqual(
103+
params.intertie_points, expected_intertie_points
104+
) # Order might not be guaranteed from set
105+
self.assertEqual(params.intertie_capacities, expected_intertie_capacities)
106+
self.assertCountEqual(params.connected_ba, expected_connected_ba)
107+
self.assertEqual(params.total_transfer_limits, expected_total_transfer_limits)
108+
109+
def test_from_csv_missing_required_column(self):
110+
"""Test from_csv raises ValueError if a required column is missing."""
111+
csv_content = "intertie_1_ba,intertie_1_node,intertie_2_ba,capacity_mw\nBA1,N1,BA2,100\n" # Missing intertie_2_node
112+
temp_csv_path = self._create_temp_csv_file(csv_content)
113+
with self.assertRaisesRegex(
114+
ValueError, "CSV file must contain the following columns"
115+
):
116+
PowerTradeParams.from_csv(temp_csv_path)
117+
118+
def test_from_csv_duplicate_intertie_in_file(self):
119+
"""Test from_csv raises ValueError for duplicate intertie points in the CSV."""
120+
csv_content = (
121+
CSV_HEADER + VALID_CSV_DATA_ROW1 + "BA_X,N1,BA_Y,N2,200.0\n"
122+
) # N1-N2 defined twice
123+
temp_csv_path = self._create_temp_csv_file(csv_content)
124+
with self.assertRaisesRegex(ValueError, "Duplicate intertie point found"):
125+
PowerTradeParams.from_csv(temp_csv_path)
126+
127+
def test_from_csv_invalid_capacity_value(self):
128+
"""Test from_csv raises ValueError if capacity_mw is not a valid float."""
129+
csv_content = CSV_HEADER + "BA1,N1,BA2,N2,not_a_number\n"
130+
temp_csv_path = self._create_temp_csv_file(csv_content)
131+
with self.assertRaises(ValueError) as cm: # Specific message comes from float()
132+
PowerTradeParams.from_csv(temp_csv_path)
133+
self.assertIn("could not convert string to float", str(cm.exception).lower())
134+
135+
def test_from_csv_headers_only_file(self):
136+
"""Test from_csv with a file containing only headers (no data rows)."""
137+
temp_csv_path = self._create_temp_csv_file(CSV_HEADER)
138+
params = PowerTradeParams.from_csv(temp_csv_path)
139+
self.assertEqual(params.intertie_points, [])
140+
self.assertEqual(params.intertie_capacities, {})
141+
self.assertEqual(params.connected_ba, [])
142+
self.assertEqual(params.total_transfer_limits, {})
143+
144+
def test_from_csv_truly_empty_file(self):
145+
"""Test from_csv with a completely empty file (0 bytes)."""
146+
# This should raise an error from pandas.read_csv
147+
# The typical error is pandas.errors.EmptyDataError
148+
temp_csv_path = self._create_temp_csv_file("") # Creates an empty file
149+
150+
# Import pandas errors for a more specific check, if desired
151+
# from pandas.errors import EmptyDataError
152+
with self.assertRaises(Exception) as cm:
153+
PowerTradeParams.from_csv(temp_csv_path)
154+
155+
self.assertIn("no columns to parse", str(cm.exception).lower())
156+
157+
def test_from_csv_correct_total_transfer_limits_aggregation(self):
158+
"""Test from_csv correctly aggregates capacities for total_transfer_limits."""
159+
csv_content = (
160+
CSV_HEADER
161+
+ "BA1,N1,BA2,N2,100.0\n" # BA1-BA2
162+
+ "BA1,N3,BA2,N4,50.0\n" # BA1-BA2
163+
+ "BA3,N5,BA4,N6,75.0\n" # BA3-BA4
164+
)
165+
temp_csv_path = self._create_temp_csv_file(csv_content)
166+
params = PowerTradeParams.from_csv(temp_csv_path)
167+
expected_limits = {("BA1", "BA2"): 150.0, ("BA3", "BA4"): 75.0}
168+
self.assertEqual(params.total_transfer_limits, expected_limits)
169+
170+
# --- from_csv triggering __post_init__ validations ---
171+
172+
def test_from_csv_validates_negative_capacity(self):
173+
"""Test from_csv leads to __post_init__ validation for negative capacity."""
174+
csv_content = CSV_HEADER + "BA1,N1,BA2,N2,-100.0\n"
175+
temp_csv_path = self._create_temp_csv_file(csv_content)
176+
with self.assertRaisesRegex(ValueError, "cannot be negative"):
177+
PowerTradeParams.from_csv(temp_csv_path)
178+
179+
def test_from_csv_validates_self_loop_intertie(self):
180+
"""Test from_csv leads to __post_init__ validation for self-loop interties."""
181+
csv_content = CSV_HEADER + "BA1,N1,BA2,N1,100.0\n" # N1-N1 intertie
182+
temp_csv_path = self._create_temp_csv_file(csv_content)
183+
with self.assertRaisesRegex(ValueError, "Self-loop intertie found"):
184+
PowerTradeParams.from_csv(temp_csv_path)
185+
186+
def test_from_csv_validates_reverse_intertie(self):
187+
"""Test from_csv leads to __post_init__ validation for reverse interties."""
188+
csv_content = (
189+
CSV_HEADER
190+
+ "BA1,N1,BA2,N2,100.0\n"
191+
+ "BA3,N2,BA4,N1,50.0\n" # N2-N1 intertie, distinct from N1-N2
192+
)
193+
temp_csv_path = self._create_temp_csv_file(csv_content)
194+
# This should create intertie_points [('N1','N2'), ('N2','N1')] which __post_init__ catches
195+
with self.assertRaisesRegex(
196+
ValueError, "Reverse intertie .* found in intertie points"
197+
):
198+
PowerTradeParams.from_csv(temp_csv_path)
199+
200+
def test_from_csv_validates_reverse_ba_pair(self):
201+
"""Test from_csv leads to __post_init__ validation for reverse BA pairs."""
202+
csv_content = (
203+
CSV_HEADER
204+
+ "BA1,N1,BA2,N2,100.0\n" # Corridor BA1-BA2
205+
+ "BA2,N3,BA1,N4,50.0\n" # Corridor BA2-BA1
206+
)
207+
temp_csv_path = self._create_temp_csv_file(csv_content)
208+
# This creates connected_ba [('BA1','BA2'), ('BA2','BA1')] which __post_init__ catches
209+
with self.assertRaisesRegex(
210+
ValueError, "Reverse BA pair .* found in connected BA"
211+
):
212+
PowerTradeParams.from_csv(temp_csv_path)
213+
214+
def test_from_csv_allows_self_connected_ba_if_not_reversed(self):
215+
"""Test that a BA connected to itself is allowed if not explicitly reversed."""
216+
csv_content = CSV_HEADER + "BA1,N1,BA1,N2,100.0\n" # Intertie within BA1
217+
temp_csv_path = self._create_temp_csv_file(csv_content)
218+
params = PowerTradeParams.from_csv(temp_csv_path)
219+
220+
self.assertCountEqual(params.connected_ba, [("BA1", "BA1")])
221+
self.assertEqual(params.total_transfer_limits, {("BA1", "BA1"): 100.0})
222+
# No error should be raised by __post_init__ for connected_ba
223+
224+
225+
if __name__ == "__main__":
226+
unittest.main(argv=["first-arg-is-ignored"], exit=False)

0 commit comments

Comments
 (0)