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.

@@ -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()}