pyconvexity 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pyconvexity might be problematic. Click here for more details.
- pyconvexity/__init__.py +120 -0
- pyconvexity/_version.py +2 -0
- pyconvexity/core/__init__.py +64 -0
- pyconvexity/core/database.py +314 -0
- pyconvexity/core/errors.py +100 -0
- pyconvexity/core/types.py +306 -0
- pyconvexity/models/__init__.py +36 -0
- pyconvexity/models/attributes.py +383 -0
- pyconvexity/models/components.py +464 -0
- pyconvexity/models/network.py +426 -0
- pyconvexity/validation/__init__.py +17 -0
- pyconvexity/validation/rules.py +301 -0
- pyconvexity-0.1.0.dist-info/METADATA +135 -0
- pyconvexity-0.1.0.dist-info/RECORD +16 -0
- pyconvexity-0.1.0.dist-info/WHEEL +5 -0
- pyconvexity-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Component management operations for PyConvexity.
|
|
3
|
+
|
|
4
|
+
Provides CRUD operations for energy system components (buses, generators, loads, etc.)
|
|
5
|
+
with proper validation and error handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
from typing import Dict, Any, Optional, List
|
|
10
|
+
|
|
11
|
+
from pyconvexity.core.types import (
|
|
12
|
+
Component, CreateComponentRequest, StaticValue
|
|
13
|
+
)
|
|
14
|
+
from pyconvexity.core.errors import (
|
|
15
|
+
ComponentNotFound, ValidationError, DatabaseError
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_component_type(conn: sqlite3.Connection, component_id: int) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Get component type for a component ID.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
conn: Database connection
|
|
25
|
+
component_id: ID of the component
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Component type string (e.g., "BUS", "GENERATOR")
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ComponentNotFound: If component doesn't exist
|
|
32
|
+
"""
|
|
33
|
+
cursor = conn.execute("SELECT component_type FROM components WHERE id = ?", (component_id,))
|
|
34
|
+
row = cursor.fetchone()
|
|
35
|
+
if not row:
|
|
36
|
+
raise ComponentNotFound(component_id)
|
|
37
|
+
return row[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_component(conn: sqlite3.Connection, component_id: int) -> Component:
|
|
41
|
+
"""
|
|
42
|
+
Get component by ID.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
conn: Database connection
|
|
46
|
+
component_id: ID of the component
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Component object with all fields populated
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ComponentNotFound: If component doesn't exist
|
|
53
|
+
"""
|
|
54
|
+
cursor = conn.execute("""
|
|
55
|
+
SELECT id, network_id, component_type, name, longitude, latitude,
|
|
56
|
+
carrier_id, bus_id, bus0_id, bus1_id
|
|
57
|
+
FROM components WHERE id = ?
|
|
58
|
+
""", (component_id,))
|
|
59
|
+
|
|
60
|
+
row = cursor.fetchone()
|
|
61
|
+
if not row:
|
|
62
|
+
raise ComponentNotFound(component_id)
|
|
63
|
+
|
|
64
|
+
return Component(
|
|
65
|
+
id=row[0],
|
|
66
|
+
network_id=row[1],
|
|
67
|
+
component_type=row[2],
|
|
68
|
+
name=row[3],
|
|
69
|
+
longitude=row[4],
|
|
70
|
+
latitude=row[5],
|
|
71
|
+
carrier_id=row[6],
|
|
72
|
+
bus_id=row[7],
|
|
73
|
+
bus0_id=row[8],
|
|
74
|
+
bus1_id=row[9]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def list_components_by_type(
|
|
79
|
+
conn: sqlite3.Connection,
|
|
80
|
+
network_id: int,
|
|
81
|
+
component_type: Optional[str] = None
|
|
82
|
+
) -> List[Component]:
|
|
83
|
+
"""
|
|
84
|
+
List components by type.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
conn: Database connection
|
|
88
|
+
network_id: Network ID to filter by
|
|
89
|
+
component_type: Optional component type filter (e.g., "BUS", "GENERATOR")
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of Component objects
|
|
93
|
+
"""
|
|
94
|
+
if component_type:
|
|
95
|
+
cursor = conn.execute("""
|
|
96
|
+
SELECT id, network_id, component_type, name, longitude, latitude,
|
|
97
|
+
carrier_id, bus_id, bus0_id, bus1_id
|
|
98
|
+
FROM components
|
|
99
|
+
WHERE network_id = ? AND component_type = ?
|
|
100
|
+
ORDER BY name
|
|
101
|
+
""", (network_id, component_type.upper()))
|
|
102
|
+
else:
|
|
103
|
+
cursor = conn.execute("""
|
|
104
|
+
SELECT id, network_id, component_type, name, longitude, latitude,
|
|
105
|
+
carrier_id, bus_id, bus0_id, bus1_id
|
|
106
|
+
FROM components
|
|
107
|
+
WHERE network_id = ?
|
|
108
|
+
ORDER BY component_type, name
|
|
109
|
+
""", (network_id,))
|
|
110
|
+
|
|
111
|
+
components = []
|
|
112
|
+
for row in cursor.fetchall():
|
|
113
|
+
components.append(Component(
|
|
114
|
+
id=row[0],
|
|
115
|
+
network_id=row[1],
|
|
116
|
+
component_type=row[2],
|
|
117
|
+
name=row[3],
|
|
118
|
+
longitude=row[4],
|
|
119
|
+
latitude=row[5],
|
|
120
|
+
carrier_id=row[6],
|
|
121
|
+
bus_id=row[7],
|
|
122
|
+
bus0_id=row[8],
|
|
123
|
+
bus1_id=row[9]
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
return components
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def insert_component(conn: sqlite3.Connection, request: CreateComponentRequest) -> int:
|
|
130
|
+
"""
|
|
131
|
+
Insert a new component.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
conn: Database connection
|
|
135
|
+
request: Component creation request with all necessary fields
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
ID of the newly created component
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
DatabaseError: If insertion fails
|
|
142
|
+
ValidationError: If required fields are missing
|
|
143
|
+
"""
|
|
144
|
+
# Determine carrier_id - use provided value or auto-assign default
|
|
145
|
+
# CONSTRAINT components must have carrier_id=None per database schema
|
|
146
|
+
carrier_id = request.carrier_id
|
|
147
|
+
if carrier_id is None and request.component_type.upper() != 'CONSTRAINT':
|
|
148
|
+
carrier_id = get_default_carrier_id(conn, request.network_id, request.component_type)
|
|
149
|
+
elif request.component_type.upper() == 'CONSTRAINT':
|
|
150
|
+
carrier_id = None # Explicitly keep None for constraints
|
|
151
|
+
|
|
152
|
+
# Insert the component
|
|
153
|
+
cursor = conn.execute("""
|
|
154
|
+
INSERT INTO components (
|
|
155
|
+
network_id, component_type, name, longitude, latitude,
|
|
156
|
+
carrier_id, bus_id, bus0_id, bus1_id,
|
|
157
|
+
created_at, updated_at
|
|
158
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
159
|
+
""", (
|
|
160
|
+
request.network_id,
|
|
161
|
+
request.component_type,
|
|
162
|
+
request.name,
|
|
163
|
+
request.longitude,
|
|
164
|
+
request.latitude,
|
|
165
|
+
carrier_id,
|
|
166
|
+
request.bus_id,
|
|
167
|
+
request.bus0_id,
|
|
168
|
+
request.bus1_id
|
|
169
|
+
))
|
|
170
|
+
|
|
171
|
+
component_id = cursor.lastrowid
|
|
172
|
+
if not component_id:
|
|
173
|
+
raise DatabaseError("Failed to create component")
|
|
174
|
+
|
|
175
|
+
# If description is provided, store it as an attribute
|
|
176
|
+
if request.description:
|
|
177
|
+
from pyconvexity.models.attributes import set_static_attribute
|
|
178
|
+
set_static_attribute(conn, component_id, "description", StaticValue(request.description))
|
|
179
|
+
|
|
180
|
+
# If this is a BUS, ensure unmet load exists
|
|
181
|
+
if request.component_type.upper() == "BUS":
|
|
182
|
+
# Get unmet_load_active flag from network
|
|
183
|
+
cursor = conn.execute("""
|
|
184
|
+
SELECT COALESCE(unmet_load_active, 1) FROM networks WHERE id = ?
|
|
185
|
+
""", (request.network_id,))
|
|
186
|
+
|
|
187
|
+
row = cursor.fetchone()
|
|
188
|
+
# Explicitly convert to boolean to avoid int/bool type confusion
|
|
189
|
+
unmet_load_active = bool(row[0]) if row else True
|
|
190
|
+
|
|
191
|
+
ensure_unmet_load_for_bus(conn, request.network_id, component_id, request.name, unmet_load_active)
|
|
192
|
+
|
|
193
|
+
return component_id
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def create_component(
|
|
197
|
+
conn: sqlite3.Connection,
|
|
198
|
+
network_id: int,
|
|
199
|
+
component_type: str,
|
|
200
|
+
name: str,
|
|
201
|
+
description: Optional[str] = None,
|
|
202
|
+
longitude: Optional[float] = None,
|
|
203
|
+
latitude: Optional[float] = None,
|
|
204
|
+
carrier_id: Optional[int] = None,
|
|
205
|
+
bus_id: Optional[int] = None,
|
|
206
|
+
bus0_id: Optional[int] = None,
|
|
207
|
+
bus1_id: Optional[int] = None
|
|
208
|
+
) -> int:
|
|
209
|
+
"""
|
|
210
|
+
Create a component and return its ID - convenience function.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
conn: Database connection
|
|
214
|
+
network_id: Network ID
|
|
215
|
+
component_type: Type of component (e.g., "BUS", "GENERATOR")
|
|
216
|
+
name: Component name
|
|
217
|
+
description: Optional description
|
|
218
|
+
longitude: Optional longitude coordinate
|
|
219
|
+
latitude: Optional latitude coordinate
|
|
220
|
+
carrier_id: Optional carrier ID
|
|
221
|
+
bus_id: Optional bus ID (for single-bus components)
|
|
222
|
+
bus0_id: Optional first bus ID (for two-bus components)
|
|
223
|
+
bus1_id: Optional second bus ID (for two-bus components)
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
ID of the newly created component
|
|
227
|
+
"""
|
|
228
|
+
request = CreateComponentRequest(
|
|
229
|
+
network_id=network_id,
|
|
230
|
+
component_type=component_type,
|
|
231
|
+
name=name,
|
|
232
|
+
description=description,
|
|
233
|
+
longitude=longitude,
|
|
234
|
+
latitude=latitude,
|
|
235
|
+
carrier_id=carrier_id,
|
|
236
|
+
bus_id=bus_id,
|
|
237
|
+
bus0_id=bus0_id,
|
|
238
|
+
bus1_id=bus1_id
|
|
239
|
+
)
|
|
240
|
+
return insert_component(conn, request)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def update_component(
|
|
244
|
+
conn: sqlite3.Connection,
|
|
245
|
+
component_id: int,
|
|
246
|
+
name: Optional[str] = None,
|
|
247
|
+
description: Optional[str] = None,
|
|
248
|
+
longitude: Optional[float] = None,
|
|
249
|
+
latitude: Optional[float] = None,
|
|
250
|
+
carrier_id: Optional[int] = None,
|
|
251
|
+
bus_id: Optional[int] = None,
|
|
252
|
+
bus0_id: Optional[int] = None,
|
|
253
|
+
bus1_id: Optional[int] = None
|
|
254
|
+
) -> None:
|
|
255
|
+
"""
|
|
256
|
+
Update a component.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
conn: Database connection
|
|
260
|
+
component_id: ID of component to update
|
|
261
|
+
name: New name (optional)
|
|
262
|
+
description: New description (optional)
|
|
263
|
+
longitude: New longitude (optional)
|
|
264
|
+
latitude: New latitude (optional)
|
|
265
|
+
carrier_id: New carrier ID (optional)
|
|
266
|
+
bus_id: New bus ID (optional)
|
|
267
|
+
bus0_id: New first bus ID (optional)
|
|
268
|
+
bus1_id: New second bus ID (optional)
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
ComponentNotFound: If component doesn't exist
|
|
272
|
+
"""
|
|
273
|
+
# Build dynamic SQL based on what fields are being updated
|
|
274
|
+
set_clauses = []
|
|
275
|
+
params = []
|
|
276
|
+
|
|
277
|
+
if name is not None:
|
|
278
|
+
set_clauses.append("name = ?")
|
|
279
|
+
params.append(name)
|
|
280
|
+
if longitude is not None:
|
|
281
|
+
set_clauses.append("longitude = ?")
|
|
282
|
+
params.append(longitude)
|
|
283
|
+
if latitude is not None:
|
|
284
|
+
set_clauses.append("latitude = ?")
|
|
285
|
+
params.append(latitude)
|
|
286
|
+
if carrier_id is not None:
|
|
287
|
+
set_clauses.append("carrier_id = ?")
|
|
288
|
+
params.append(carrier_id)
|
|
289
|
+
if bus_id is not None:
|
|
290
|
+
set_clauses.append("bus_id = ?")
|
|
291
|
+
params.append(bus_id)
|
|
292
|
+
if bus0_id is not None:
|
|
293
|
+
set_clauses.append("bus0_id = ?")
|
|
294
|
+
params.append(bus0_id)
|
|
295
|
+
if bus1_id is not None:
|
|
296
|
+
set_clauses.append("bus1_id = ?")
|
|
297
|
+
params.append(bus1_id)
|
|
298
|
+
|
|
299
|
+
# Update component table fields if any changes
|
|
300
|
+
if set_clauses:
|
|
301
|
+
set_clauses.append("updated_at = datetime('now')")
|
|
302
|
+
params.append(component_id)
|
|
303
|
+
|
|
304
|
+
sql = f"UPDATE components SET {', '.join(set_clauses)} WHERE id = ?"
|
|
305
|
+
cursor = conn.execute(sql, params)
|
|
306
|
+
|
|
307
|
+
if cursor.rowcount == 0:
|
|
308
|
+
raise ComponentNotFound(component_id)
|
|
309
|
+
|
|
310
|
+
# Handle description as an attribute
|
|
311
|
+
if description is not None:
|
|
312
|
+
from pyconvexity.models.attributes import set_static_attribute, delete_attribute
|
|
313
|
+
if description == "":
|
|
314
|
+
# Remove description attribute if empty
|
|
315
|
+
try:
|
|
316
|
+
delete_attribute(conn, component_id, "description")
|
|
317
|
+
except: # AttributeNotFound
|
|
318
|
+
pass # Already doesn't exist
|
|
319
|
+
else:
|
|
320
|
+
# Set description as attribute
|
|
321
|
+
set_static_attribute(conn, component_id, "description", StaticValue(description))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def delete_component(conn: sqlite3.Connection, component_id: int) -> None:
|
|
325
|
+
"""
|
|
326
|
+
Delete a component and all its attributes.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
conn: Database connection
|
|
330
|
+
component_id: ID of component to delete
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
ComponentNotFound: If component doesn't exist
|
|
334
|
+
"""
|
|
335
|
+
# First delete all component attributes
|
|
336
|
+
conn.execute("DELETE FROM component_attributes WHERE component_id = ?", (component_id,))
|
|
337
|
+
|
|
338
|
+
# Then delete the component itself
|
|
339
|
+
cursor = conn.execute("DELETE FROM components WHERE id = ?", (component_id,))
|
|
340
|
+
|
|
341
|
+
if cursor.rowcount == 0:
|
|
342
|
+
raise ComponentNotFound(component_id)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def list_component_attributes(
|
|
346
|
+
conn: sqlite3.Connection,
|
|
347
|
+
component_id: int
|
|
348
|
+
) -> List[str]:
|
|
349
|
+
"""
|
|
350
|
+
List all attribute names for a component.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
conn: Database connection
|
|
354
|
+
component_id: Component ID
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
List of attribute names
|
|
358
|
+
"""
|
|
359
|
+
cursor = conn.execute("""
|
|
360
|
+
SELECT attribute_name FROM component_attributes
|
|
361
|
+
WHERE component_id = ? ORDER BY attribute_name
|
|
362
|
+
""", (component_id,))
|
|
363
|
+
|
|
364
|
+
return [row[0] for row in cursor.fetchall()]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# Helper functions
|
|
368
|
+
|
|
369
|
+
def get_default_carrier_id(
|
|
370
|
+
conn: sqlite3.Connection,
|
|
371
|
+
network_id: int,
|
|
372
|
+
component_type: str
|
|
373
|
+
) -> int:
|
|
374
|
+
"""Get default carrier ID for a component type."""
|
|
375
|
+
# Default carrier names based on PyPSA conventions
|
|
376
|
+
default_carrier_name = {
|
|
377
|
+
'BUS': 'AC',
|
|
378
|
+
'GENERATOR': 'electricity',
|
|
379
|
+
'LOAD': 'electricity',
|
|
380
|
+
'STORAGE_UNIT': 'electricity',
|
|
381
|
+
'STORE': 'electricity',
|
|
382
|
+
'LINE': 'AC',
|
|
383
|
+
'LINK': 'AC'
|
|
384
|
+
}.get(component_type.upper(), 'AC')
|
|
385
|
+
|
|
386
|
+
# Try to find the default carrier
|
|
387
|
+
cursor = conn.execute("""
|
|
388
|
+
SELECT id FROM carriers WHERE network_id = ? AND name = ? LIMIT 1
|
|
389
|
+
""", (network_id, default_carrier_name))
|
|
390
|
+
|
|
391
|
+
row = cursor.fetchone()
|
|
392
|
+
if row:
|
|
393
|
+
return row[0]
|
|
394
|
+
|
|
395
|
+
# If not found, try AC
|
|
396
|
+
cursor = conn.execute("""
|
|
397
|
+
SELECT id FROM carriers WHERE network_id = ? AND name = 'AC' LIMIT 1
|
|
398
|
+
""", (network_id,))
|
|
399
|
+
|
|
400
|
+
row = cursor.fetchone()
|
|
401
|
+
if row:
|
|
402
|
+
return row[0]
|
|
403
|
+
|
|
404
|
+
# If still not found, get any carrier
|
|
405
|
+
cursor = conn.execute("""
|
|
406
|
+
SELECT id FROM carriers WHERE network_id = ? LIMIT 1
|
|
407
|
+
""", (network_id,))
|
|
408
|
+
|
|
409
|
+
row = cursor.fetchone()
|
|
410
|
+
if row:
|
|
411
|
+
return row[0]
|
|
412
|
+
|
|
413
|
+
raise DatabaseError(f"No carriers found in network {network_id}")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def ensure_unmet_load_for_bus(
|
|
417
|
+
conn: sqlite3.Connection,
|
|
418
|
+
network_id: int,
|
|
419
|
+
bus_id: int,
|
|
420
|
+
bus_name: str,
|
|
421
|
+
unmet_load_active: bool
|
|
422
|
+
) -> None:
|
|
423
|
+
"""Ensure there is exactly one UNMET_LOAD per bus."""
|
|
424
|
+
# Check if unmet load already exists for this bus
|
|
425
|
+
cursor = conn.execute("""
|
|
426
|
+
SELECT id FROM components
|
|
427
|
+
WHERE network_id = ? AND bus_id = ? AND component_type = 'UNMET_LOAD'
|
|
428
|
+
LIMIT 1
|
|
429
|
+
""", (network_id, bus_id))
|
|
430
|
+
|
|
431
|
+
if cursor.fetchone():
|
|
432
|
+
return # Already exists
|
|
433
|
+
|
|
434
|
+
# Get default carrier for generators (unmet loads are treated as generators)
|
|
435
|
+
carrier_id = get_default_carrier_id(conn, network_id, "GENERATOR")
|
|
436
|
+
|
|
437
|
+
# Insert unmet load component - sanitize bus name for PyPSA compatibility
|
|
438
|
+
# Remove spaces, periods, and other problematic characters
|
|
439
|
+
sanitized_bus_name = bus_name.replace(" ", "_").replace(".", "_").replace("-", "_")
|
|
440
|
+
name = f"unmet_load_{sanitized_bus_name}"
|
|
441
|
+
cursor = conn.execute("""
|
|
442
|
+
INSERT INTO components (
|
|
443
|
+
network_id, component_type, name, carrier_id, bus_id,
|
|
444
|
+
created_at, updated_at
|
|
445
|
+
) VALUES (?, 'UNMET_LOAD', ?, ?, ?, datetime('now'), datetime('now'))
|
|
446
|
+
""", (network_id, name, carrier_id, bus_id))
|
|
447
|
+
|
|
448
|
+
unmet_load_id = cursor.lastrowid
|
|
449
|
+
|
|
450
|
+
# Set fixed attributes for unmet load
|
|
451
|
+
from pyconvexity.models.attributes import set_static_attribute
|
|
452
|
+
set_static_attribute(conn, unmet_load_id, "marginal_cost", StaticValue(1e6))
|
|
453
|
+
set_static_attribute(conn, unmet_load_id, "p_nom", StaticValue(1e6))
|
|
454
|
+
set_static_attribute(conn, unmet_load_id, "active", StaticValue(unmet_load_active))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def get_bus_name_to_id_map(conn: sqlite3.Connection, network_id: int) -> Dict[str, int]:
|
|
458
|
+
"""Get mapping from bus names to component IDs."""
|
|
459
|
+
cursor = conn.execute("""
|
|
460
|
+
SELECT name, id FROM components
|
|
461
|
+
WHERE network_id = ? AND component_type = 'BUS'
|
|
462
|
+
""", (network_id,))
|
|
463
|
+
|
|
464
|
+
return {row[0]: row[1] for row in cursor.fetchall()}
|