Skip to content

Commit 076a08c

Browse files
feat: add run functionality to manual conversion
1 parent 47ce01b commit 076a08c

File tree

1 file changed

+270
-1
lines changed

1 file changed

+270
-1
lines changed

loopstructural/gui/data_conversion/data_conversion_widget.py

Lines changed: 270 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,10 +503,12 @@ def _default_converter_options(self) -> List[ConverterOption]:
503503
options.append(ConverterOption(identifier=name, label=label, description=description))
504504
return options
505505

506-
507506
class ManualConversionWidget(QWidget):
508507
"""Widget that lets the user map table columns to the NTGS configuration."""
509508

509+
OUTPUT_DATA_TYPES: Tuple[str, ...] = AutomaticConversionWidget.OUTPUT_DATA_TYPES
510+
OUTPUT_GROUP_NAME = AutomaticConversionWidget.OUTPUT_GROUP_NAME
511+
510512
def __init__(
511513
self,
512514
parent: Optional[QWidget] = None,
@@ -554,6 +556,21 @@ def __init__(
554556
self.form_layout.setLabelAlignment(Qt.AlignLeft | Qt.AlignTop)
555557
self.scroll_area.setWidget(self.form_widget)
556558

559+
actions_widget = QWidget()
560+
actions_layout = QHBoxLayout(actions_widget)
561+
actions_layout.setContentsMargins(0, 0, 0, 0)
562+
563+
self.run_button = QPushButton("Run Conversion")
564+
self.run_button.clicked.connect(self._handle_run_conversion)
565+
actions_layout.addWidget(self.run_button)
566+
actions_layout.addStretch()
567+
568+
self.status_label = QLabel("")
569+
self.status_label.setObjectName("manualConversionStatus")
570+
actions_layout.addWidget(self.status_label)
571+
572+
layout.addWidget(actions_widget)
573+
557574
self.data_type_combo.currentIndexChanged.connect(self._handle_data_type_changed)
558575
self.layer_combo.layerChanged.connect(self._handle_layer_changed)
559576

@@ -668,6 +685,258 @@ def _persist_current_values(self) -> None:
668685
else:
669686
self.layer_selections[self.current_data_type] = None
670687

688+
def _collect_data_sources(self) -> Dict[Datatype | str, str]:
689+
data_sources: Dict[Datatype | str, str] = {}
690+
for data_type, selection in self.layer_selections.items():
691+
layer = self._layer_from_selection(selection or {})
692+
if layer and isinstance(layer, QgsVectorLayer) and layer.isValid():
693+
path = layer.source()
694+
if path:
695+
data_sources[self._datatype_for_name(data_type)] = path
696+
return data_sources
697+
698+
def _handle_run_conversion(self) -> None:
699+
self._persist_current_values()
700+
sources = self._collect_data_sources()
701+
if not sources:
702+
self._update_status("Select at least one data source layer before running.", error=True)
703+
return
704+
705+
converter: Any = None
706+
result: Any = None
707+
added_layers = 0
708+
try:
709+
survey = self._manual_survey_name()
710+
converter, result = _run_loop_conversion(survey, sources)
711+
layers = self._build_layers_from_converter(converter)
712+
if not layers:
713+
layers = self._materialise_layers_from_result(result)
714+
if layers:
715+
added_layers = self._add_layers_to_project_group(layers)
716+
except Exception as exc: # pragma: no cover - UI feedback
717+
self._update_status(f"Conversion failed: {exc}", error=True)
718+
return
719+
720+
if added_layers:
721+
message = f"Conversion completed: {added_layers} layer(s) added to '{self.OUTPUT_GROUP_NAME}'."
722+
elif result not in (None, True):
723+
message = f"Conversion completed: {result}"
724+
else:
725+
message = "Conversion completed successfully."
726+
self._update_status(message)
727+
728+
def _update_status(self, message: str, *, error: bool = False) -> None:
729+
color = "#c00000" if error else "#006400"
730+
self.status_label.setStyleSheet(f"color: {color};")
731+
self.status_label.setText(message)
732+
733+
def _manual_survey_name(self) -> SurveyName:
734+
options = self._default_converter_options()
735+
identifier: SurveyName | str | None = None
736+
for option in options:
737+
if str(option.identifier).upper() == "NTGS":
738+
identifier = option.identifier
739+
break
740+
if identifier is None and options:
741+
identifier = options[0].identifier
742+
if identifier is None:
743+
members = getattr(SurveyName, "__members__", {}) or {}
744+
if "NTGS" in members:
745+
identifier = members["NTGS"]
746+
elif members:
747+
identifier = next(iter(members.values()))
748+
else:
749+
raise ValueError("No survey definitions available for manual conversion.")
750+
return AutomaticConversionWidget._normalise_survey_name(identifier)
751+
752+
def _default_converter_options(self) -> List[ConverterOption]:
753+
members = getattr(SurveyName, "__members__", None)
754+
if not isinstance(members, Mapping):
755+
return []
756+
options: List[ConverterOption] = []
757+
for name, survey in members.items():
758+
label = getattr(survey, "value", None)
759+
if not isinstance(label, str) or not label.strip():
760+
label = self._format_identifier_label(name)
761+
description = getattr(survey, "description", "")
762+
options.append(ConverterOption(identifier=name, label=label, description=description))
763+
return options
764+
765+
def _build_layers_from_converter(self, converter: Any) -> List[QgsVectorLayer]:
766+
if converter is None:
767+
return []
768+
data_store = getattr(converter, "data", None)
769+
if not data_store:
770+
return []
771+
772+
layers: List[QgsVectorLayer] = []
773+
for identifier in self.OUTPUT_DATA_TYPES:
774+
dtype = self._datatype_for_name(identifier)
775+
dataset = self._extract_dataset(data_store, dtype)
776+
if dataset is None:
777+
continue
778+
layer = self._vector_layer_from_value(dtype, dataset)
779+
if layer is not None and layer.isValid():
780+
layers.append(layer)
781+
return layers
782+
783+
def _datatype_for_name(self, identifier: str | Datatype) -> Datatype | str:
784+
if isinstance(identifier, Datatype):
785+
return identifier
786+
try:
787+
return Datatype[str(identifier)]
788+
except Exception:
789+
return str(identifier)
790+
791+
def _extract_dataset(self, data_store: Any, key: Datatype | str) -> Any:
792+
if data_store is None:
793+
return None
794+
795+
candidates: List[Any] = []
796+
if isinstance(key, Datatype):
797+
candidates.append(key)
798+
candidates.append(key.name)
799+
value = getattr(key, "value", None)
800+
if value is not None:
801+
candidates.append(value)
802+
else:
803+
candidates.append(key)
804+
805+
text_key = str(key)
806+
if "." in text_key:
807+
text_key = text_key.split(".", 1)[-1]
808+
candidates.extend(
809+
[
810+
text_key,
811+
text_key.upper(),
812+
text_key.lower(),
813+
]
814+
)
815+
816+
for candidate in candidates:
817+
if candidate is None:
818+
continue
819+
if isinstance(data_store, Mapping) and candidate in data_store:
820+
return data_store[candidate]
821+
getter = getattr(data_store, "__getitem__", None)
822+
if callable(getter):
823+
try:
824+
return getter(candidate)
825+
except Exception:
826+
pass
827+
attr_name = str(candidate).lower()
828+
if hasattr(data_store, attr_name):
829+
return getattr(data_store, attr_name)
830+
return None
831+
832+
def _materialise_layers_from_result(self, result: Any) -> List[QgsVectorLayer]:
833+
layers: List[QgsVectorLayer] = []
834+
self._collect_layers_from_result(result, layers, prefix="output")
835+
return layers
836+
837+
def _collect_layers_from_result(
838+
self, payload: Any, layers: List[QgsVectorLayer], *, prefix: str
839+
) -> None:
840+
if payload is None:
841+
return
842+
if isinstance(payload, Mapping):
843+
for key, value in payload.items():
844+
label = str(key)
845+
self._collect_layers_from_result(value, layers, prefix=label or prefix)
846+
return
847+
if isinstance(payload, (list, tuple, set)):
848+
for index, value in enumerate(payload, start=1):
849+
self._collect_layers_from_result(value, layers, prefix=f"{prefix}_{index}")
850+
return
851+
layer = self._vector_layer_from_value(prefix, payload)
852+
if layer is not None and layer.isValid():
853+
layers.append(layer)
854+
855+
def _vector_layer_from_value(self, name: Any, value: Any) -> Optional[QgsVectorLayer]:
856+
label = self._format_identifier_label(str(name))
857+
if isinstance(value, QgsVectorLayer):
858+
value.setName(label)
859+
return value
860+
if GeoDataFrame is not None and isinstance(value, GeoDataFrame):
861+
try:
862+
return QgsLayerFromGeoDataFrame(value, layer_name=label)
863+
except ValueError:
864+
return QgsLayerFromDataFrame(value, layer_name=label)
865+
if DataFrame is not None and isinstance(value, DataFrame):
866+
return QgsLayerFromDataFrame(value, layer_name=label)
867+
if (
868+
DataFrame is not None
869+
and isinstance(value, list)
870+
and value
871+
and all(isinstance(item, Mapping) for item in value)
872+
):
873+
try:
874+
dataframe = DataFrame(value)
875+
except Exception:
876+
dataframe = None
877+
if dataframe is not None:
878+
return QgsLayerFromDataFrame(dataframe, layer_name=label)
879+
if isinstance(value, str):
880+
layer = QgsVectorLayer(value, label, "ogr")
881+
return layer if layer.isValid() else None
882+
if isinstance(value, Mapping):
883+
provider = str(value.get("provider") or "ogr")
884+
nested_layer = value.get("layer")
885+
if isinstance(nested_layer, QgsVectorLayer):
886+
nested_layer.setName(str(value.get("name") or label))
887+
return nested_layer if nested_layer.isValid() else None
888+
for key in ("path", "source", "file"):
889+
path = value.get(key)
890+
if isinstance(path, str):
891+
layer_name = str(value.get("name") or label)
892+
layer = QgsVectorLayer(path, layer_name, provider)
893+
return layer if layer.isValid() else None
894+
return None
895+
896+
def _add_layers_to_project_group(self, layers: Iterable[QgsVectorLayer]) -> int:
897+
project = self.project or QgsProject.instance()
898+
if project is None:
899+
return 0
900+
root = project.layerTreeRoot()
901+
if root is None:
902+
return 0
903+
group = root.findGroup(self.OUTPUT_GROUP_NAME)
904+
if group is None:
905+
group = root.insertGroup(0, self.OUTPUT_GROUP_NAME)
906+
added = 0
907+
for layer in layers:
908+
if project.mapLayer(layer.id()) is None:
909+
project.addMapLayer(layer, False)
910+
group.addLayer(layer)
911+
added += 1
912+
return added
913+
914+
def _format_identifier_label(self, identifier: Datatype | str) -> str:
915+
member: Optional[Datatype] = identifier if isinstance(identifier, Datatype) else None
916+
text_value = None if isinstance(identifier, Datatype) else str(identifier).strip()
917+
918+
if member is None and text_value:
919+
candidates = getattr(Datatype, "__members__", {})
920+
key = text_value.split(".", 1)[-1].upper()
921+
member = candidates.get(key)
922+
923+
if member is None and text_value:
924+
lowered = text_value.lower()
925+
for enum_member in getattr(Datatype, "__members__", {}).values():
926+
if str(getattr(enum_member, "value", "")).lower() == lowered:
927+
member = enum_member
928+
break
929+
930+
if member is not None:
931+
name = member.name
932+
else:
933+
name = text_value or "Data"
934+
if "." in name:
935+
name = name.split(".")[-1]
936+
937+
parts = name.replace("_", " ").split()
938+
return " ".join(part.capitalize() for part in parts) or "Data"
939+
671940
def get_configuration(self) -> Dict[str, Dict[str, Any]]:
672941
"""Return the configuration map built from the user selections."""
673942
self._persist_current_values()

0 commit comments

Comments
 (0)