Skip to content

Commit 60011ca

Browse files
logic to handle optional_extra
Signed-off-by: Jerry Guo <Jerry.Jinfeng.Guo@alliander.com>
1 parent c145eef commit 60011ca

3 files changed

Lines changed: 244 additions & 3 deletions

File tree

docs/converters/vision_converter.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,33 @@ Vision introduced UUID based identifier system since version 9.7. It is implemen
5252

5353
An examplery usage can be found in the example notebook as well as in the test cases.
5454

55+
## Optional extra columns
56+
57+
When working with Vision Excel exports, some metadata columns (like `GUID` or `StationID`) may not always be present, especially in partial exports. The `optional_extra` feature allows you to specify columns that should be included in `extra_info` if present, but won't cause conversion failure if missing.
58+
59+
**Syntax:**
60+
```yaml
61+
grid:
62+
Transformers:
63+
transformer:
64+
id:
65+
auto_id:
66+
key: Number
67+
# ... other fields ...
68+
extra:
69+
- ID # Required - fails if missing
70+
- Name # Required - fails if missing
71+
- optional_extra:
72+
- GUID # Optional - skipped if missing
73+
- StationID # Optional - skipped if missing
74+
```
75+
76+
**Behavior:**
77+
- Required columns (listed directly under `extra`) will cause a KeyError if missing
78+
- Optional columns (nested under `optional_extra`) are silently skipped if not found
79+
- If some optional columns are present and others missing, only the present ones are included in `extra_info`
80+
- This feature is particularly useful for handling different Vision export configurations or versions
81+
5582
## Common/Known issues related to Vision
5683
So far we have the following issue known to us related to Vision exported spread sheets. We provide a solution from user perspective to the best of our knowledge.
5784

src/power_grid_model_io/converters/tabular_converter.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ def _parse_col_def( # pylint: disable = too-many-arguments,too-many-positional-
396396
col_def: Any,
397397
table_mask: Optional[np.ndarray],
398398
extra_info: Optional[ExtraInfo],
399+
allow_missing: bool = False,
399400
) -> pd.DataFrame:
400401
"""Interpret the column definition and extract/convert/create the data as a pandas DataFrame.
401402
@@ -404,15 +405,27 @@ def _parse_col_def( # pylint: disable = too-many-arguments,too-many-positional-
404405
table: str:
405406
col_def: Any:
406407
extra_info: Optional[ExtraInfo]:
408+
allow_missing: bool: If True, missing columns will return empty DataFrame instead of raising KeyError
407409
408410
Returns:
409411
410412
"""
411413
if isinstance(col_def, (int, float)):
412414
return self._parse_col_def_const(data=data, table=table, col_def=col_def, table_mask=table_mask)
413415
if isinstance(col_def, str):
414-
return self._parse_col_def_column_name(data=data, table=table, col_def=col_def, table_mask=table_mask)
416+
return self._parse_col_def_column_name(
417+
data=data, table=table, col_def=col_def, table_mask=table_mask, allow_missing=allow_missing
418+
)
415419
if isinstance(col_def, dict):
420+
# Check if this is an optional_extra wrapper
421+
if len(col_def) == 1 and "optional_extra" in col_def:
422+
# Extract the list of optional columns and parse as composite with allow_missing=True
423+
optional_cols = col_def["optional_extra"]
424+
if not isinstance(optional_cols, list):
425+
raise TypeError(f"optional_extra value must be a list, got {type(optional_cols).__name__}")
426+
return self._parse_col_def_composite(
427+
data=data, table=table, col_def=optional_cols, table_mask=table_mask, allow_missing=True
428+
)
416429
return self._parse_col_def_filter(
417430
data=data,
418431
table=table,
@@ -421,7 +434,9 @@ def _parse_col_def( # pylint: disable = too-many-arguments,too-many-positional-
421434
extra_info=extra_info,
422435
)
423436
if isinstance(col_def, list):
424-
return self._parse_col_def_composite(data=data, table=table, col_def=col_def, table_mask=table_mask)
437+
return self._parse_col_def_composite(
438+
data=data, table=table, col_def=col_def, table_mask=table_mask, allow_missing=allow_missing
439+
)
425440
raise TypeError(f"Invalid column definition: {col_def}")
426441

427442
@staticmethod
@@ -454,6 +469,7 @@ def _parse_col_def_column_name(
454469
table: str,
455470
col_def: str,
456471
table_mask: Optional[np.ndarray] = None,
472+
allow_missing: bool = False,
457473
) -> pd.DataFrame:
458474
"""Extract a column from the data. If the column doesn't exist, check if the col_def is a special float value,
459475
like 'inf'. If that's the case, create a single column pandas DataFrame containing the const value.
@@ -462,6 +478,7 @@ def _parse_col_def_column_name(
462478
data: TabularData:
463479
table: str:
464480
col_def: str:
481+
allow_missing: bool: If True, return empty DataFrame when column is missing instead of raising KeyError
465482
466483
Returns:
467484
@@ -486,6 +503,15 @@ def _parse_col_def_column_name(
486503
const_value = float(col_def)
487504
except ValueError:
488505
# pylint: disable=raise-missing-from
506+
if allow_missing:
507+
# Return empty DataFrame with correct number of rows when column is optional and missing
508+
self._log.debug(
509+
"Optional column not found",
510+
table=table,
511+
columns=" or ".join(f"'{col_name}'" for col_name in columns),
512+
)
513+
n_rows = len(table_data)
514+
return pd.DataFrame(index=range(n_rows))
489515
columns_str = " and ".join(f"'{col_name}'" for col_name in columns)
490516
raise KeyError(f"Could not find column {columns_str} on table '{table}'")
491517

@@ -778,13 +804,15 @@ def _parse_col_def_composite(
778804
table: str,
779805
col_def: list,
780806
table_mask: Optional[np.ndarray],
807+
allow_missing: bool = False,
781808
) -> pd.DataFrame:
782809
"""Select multiple columns (each is created from a column definition) and return them as a new DataFrame.
783810
784811
Args:
785812
data: TabularData:
786813
table: str:
787814
col_def: list:
815+
allow_missing: bool: If True, skip missing columns instead of raising errors
788816
789817
Returns:
790818
@@ -797,10 +825,20 @@ def _parse_col_def_composite(
797825
col_def=sub_def,
798826
table_mask=table_mask,
799827
extra_info=None,
828+
allow_missing=allow_missing,
800829
)
801830
for sub_def in col_def
802831
]
803-
return pd.concat(columns, axis=1)
832+
# Filter out empty DataFrames (from missing optional columns)
833+
non_empty_columns = [col for col in columns if not col.empty and len(col.columns) > 0]
834+
if not non_empty_columns:
835+
# If all columns are missing, return an empty DataFrame with the correct number of rows
836+
table_data = data[table]
837+
if table_mask is not None:
838+
table_data = table_data[table_mask]
839+
n_rows = len(table_data)
840+
return pd.DataFrame(index=range(n_rows))
841+
return pd.concat(non_empty_columns, axis=1)
804842

805843
def _get_id(self, table: str, key: Mapping[str, int], name: Optional[str]) -> int:
806844
"""

tests/unit/converters/test_tabular_converter.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ def test_parse_col_def(converter: TabularConverter, tabular_data_no_units_no_sub
462462
table="nodes",
463463
col_def="col_name",
464464
table_mask=None,
465+
allow_missing=False,
465466
)
466467

467468
# type(col_def) == dict
@@ -499,6 +500,7 @@ def test_parse_col_def(converter: TabularConverter, tabular_data_no_units_no_sub
499500
table="nodes",
500501
col_def=[],
501502
table_mask=None,
503+
allow_missing=False,
502504
)
503505

504506

@@ -1356,3 +1358,177 @@ def bool_fn_filter(row: pd.Series, **kwargs):
13561358
def test_parse_table_filters__ndarray_data(converter: TabularConverter):
13571359
numpy_tabular_data = TabularData(numpy_table=np.ones((4, 3)))
13581360
assert converter._parse_table_filters(data=numpy_tabular_data, table="numpy_table", filtering_functions=[]) is None
1361+
1362+
1363+
def test_optional_extra__all_columns_present(converter: TabularConverter):
1364+
"""Test optional_extra when all optional columns are present in the data"""
1365+
# Arrange
1366+
data = TabularData(
1367+
test_table=pd.DataFrame(
1368+
{"id": [1, 2], "name": ["node1", "node2"], "guid": ["guid1", "guid2"], "station": ["st1", "st2"]}
1369+
)
1370+
)
1371+
col_def = {"optional_extra": ["guid", "station"]}
1372+
1373+
# Act
1374+
result = converter._parse_col_def(
1375+
data=data, table="test_table", col_def=col_def, table_mask=None, extra_info=None, allow_missing=False
1376+
)
1377+
1378+
# Assert
1379+
assert list(result.columns) == ["guid", "station"]
1380+
assert list(result["guid"]) == ["guid1", "guid2"]
1381+
assert list(result["station"]) == ["st1", "st2"]
1382+
1383+
1384+
def test_optional_extra__some_columns_missing(converter: TabularConverter):
1385+
"""Test optional_extra when some optional columns are missing from the data"""
1386+
# Arrange
1387+
data = TabularData(test_table=pd.DataFrame({"id": [1, 2], "name": ["node1", "node2"], "guid": ["guid1", "guid2"]}))
1388+
col_def = {"optional_extra": ["guid", "station"]} # 'station' is missing
1389+
1390+
# Act
1391+
result = converter._parse_col_def(
1392+
data=data, table="test_table", col_def=col_def, table_mask=None, extra_info=None, allow_missing=False
1393+
)
1394+
1395+
# Assert - only 'guid' should be present
1396+
assert list(result.columns) == ["guid"]
1397+
assert list(result["guid"]) == ["guid1", "guid2"]
1398+
1399+
1400+
def test_optional_extra__all_columns_missing(converter: TabularConverter):
1401+
"""Test optional_extra when all optional columns are missing from the data"""
1402+
# Arrange
1403+
data = TabularData(test_table=pd.DataFrame({"id": [1, 2], "name": ["node1", "node2"]}))
1404+
col_def = {"optional_extra": ["guid", "station"]} # Both are missing
1405+
1406+
# Act
1407+
result = converter._parse_col_def(
1408+
data=data, table="test_table", col_def=col_def, table_mask=None, extra_info=None, allow_missing=False
1409+
)
1410+
1411+
# Assert - should return empty DataFrame with correct number of rows
1412+
assert len(result) == 2
1413+
assert len(result.columns) == 0
1414+
1415+
1416+
def test_optional_extra__mixed_with_required(converter: TabularConverter):
1417+
"""Test mixing required and optional extra columns"""
1418+
# Arrange
1419+
data = TabularData(test_table=pd.DataFrame({"id": [1, 2], "name": ["node1", "node2"], "guid": ["guid1", "guid2"]}))
1420+
# Mix required columns with optional_extra
1421+
col_def = ["name", {"optional_extra": ["guid", "station"]}]
1422+
1423+
# Act
1424+
result = converter._parse_col_def(
1425+
data=data, table="test_table", col_def=col_def, table_mask=None, extra_info=None, allow_missing=False
1426+
)
1427+
1428+
# Assert - should have 'name' and 'guid', but not 'station'
1429+
assert list(result.columns) == ["name", "guid"]
1430+
assert list(result["name"]) == ["node1", "node2"]
1431+
assert list(result["guid"]) == ["guid1", "guid2"]
1432+
1433+
1434+
def test_optional_extra__in_extra_info(converter: TabularConverter):
1435+
"""Test that optional_extra works correctly with _handle_extra_info"""
1436+
# Arrange
1437+
data = TabularData(
1438+
test_table=pd.DataFrame(
1439+
{"id": [1, 2], "name": ["node1", "node2"], "guid": ["guid1", "guid2"]} # 'station' is missing
1440+
)
1441+
)
1442+
uuids = np.array([100, 200])
1443+
extra_info: ExtraInfo = {}
1444+
col_def = {"optional_extra": ["guid", "station"]}
1445+
1446+
# Act
1447+
converter._handle_extra_info(
1448+
data=data, table="test_table", col_def=col_def, uuids=uuids, table_mask=None, extra_info=extra_info
1449+
)
1450+
1451+
# Assert - only 'guid' should be in extra_info, not 'station'
1452+
assert 100 in extra_info
1453+
assert 200 in extra_info
1454+
assert "guid" in extra_info[100]
1455+
assert "guid" in extra_info[200]
1456+
assert extra_info[100]["guid"] == "guid1"
1457+
assert extra_info[200]["guid"] == "guid2"
1458+
assert "station" not in extra_info[100]
1459+
assert "station" not in extra_info[200]
1460+
1461+
1462+
def test_optional_extra__all_missing_no_extra_info(converter: TabularConverter):
1463+
"""Test that when all optional columns are missing, no extra_info entries are created"""
1464+
# Arrange
1465+
data = TabularData(test_table=pd.DataFrame({"id": [1, 2], "name": ["node1", "node2"]})) # Both optional missing
1466+
uuids = np.array([100, 200])
1467+
extra_info: ExtraInfo = {}
1468+
col_def = {"optional_extra": ["guid", "station"]}
1469+
1470+
# Act
1471+
converter._handle_extra_info(
1472+
data=data, table="test_table", col_def=col_def, uuids=uuids, table_mask=None, extra_info=extra_info
1473+
)
1474+
1475+
# Assert - no entries should be added to extra_info
1476+
assert len(extra_info) == 0
1477+
1478+
1479+
def test_optional_extra__invalid_type():
1480+
"""Test that optional_extra raises TypeError if value is not a list"""
1481+
# Arrange
1482+
converter = TabularConverter(mapping_file=MAPPING_FILE)
1483+
data = TabularData(test_table=pd.DataFrame({"id": [1, 2]}))
1484+
col_def = {"optional_extra": "not_a_list"} # Invalid: should be a list
1485+
1486+
# Act & Assert
1487+
with pytest.raises(TypeError, match="optional_extra value must be a list"):
1488+
converter._parse_col_def(
1489+
data=data, table="test_table", col_def=col_def, table_mask=None, extra_info=None, allow_missing=False
1490+
)
1491+
1492+
1493+
def test_optional_extra__integration():
1494+
"""Integration test for optional_extra feature using a complete mapping file"""
1495+
# Arrange
1496+
mapping_file = Path(__file__).parents[2] / "data" / "config" / "test_optional_extra_mapping.yaml"
1497+
converter = TabularConverter(mapping_file=mapping_file)
1498+
1499+
# Create test data with some optional columns present and some missing
1500+
data = TabularData(
1501+
nodes=pd.DataFrame(
1502+
{
1503+
"node_id": [1, 2, 3],
1504+
"voltage": [10.5, 10.5, 0.4],
1505+
"ID": ["N1", "N2", "N3"],
1506+
"Name": ["Node 1", "Node 2", "Node 3"],
1507+
"GUID": ["guid-1", "guid-2", "guid-3"],
1508+
# Note: StationID column is missing (optional)
1509+
}
1510+
)
1511+
)
1512+
1513+
extra_info: ExtraInfo = {}
1514+
1515+
# Act
1516+
result = converter._parse_data(data=data, data_type=DatasetType.input, extra_info=extra_info)
1517+
1518+
# Assert
1519+
assert ComponentType.node in result
1520+
assert len(result[ComponentType.node]) == 3
1521+
1522+
# Check that extra_info contains the required and present optional fields
1523+
for node_id in result[ComponentType.node]["id"]:
1524+
assert node_id in extra_info
1525+
assert "ID" in extra_info[node_id]
1526+
assert "Name" in extra_info[node_id]
1527+
assert "GUID" in extra_info[node_id] # Optional but present
1528+
assert "StationID" not in extra_info[node_id] # Optional and missing
1529+
1530+
# Verify values
1531+
node_0_id = result[ComponentType.node]["id"][0]
1532+
assert extra_info[node_0_id]["ID"] == "N1"
1533+
assert extra_info[node_0_id]["Name"] == "Node 1"
1534+
assert extra_info[node_0_id]["GUID"] == "guid-1"

0 commit comments

Comments
 (0)