From 6af2c0333f7b1afb8cf00eb3b4786b16ad761772 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 18 Jan 2026 12:00:39 +0000 Subject: [PATCH] Initial commit: Trimble Geodesy Tool mit PyQt5 GUI Features: - JXL-Datei Analyse und Bearbeitung - COR-Datei Generierung - Koordinatentransformation (Rotation/Translation) - Georeferenzierung mit Passpunkten - Netzausgleichung nach kleinsten Quadraten --- .abacus.donotdelete | 1 + README.md | 128 ++ main.py | 1056 +++++++++++++++++ modules/__init__.py | 6 + modules/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 501 bytes .../__pycache__/cor_generator.cpython-311.pyc | Bin 0 -> 18161 bytes .../georeferencing.cpython-311.pyc | Bin 0 -> 21599 bytes .../__pycache__/jxl_parser.cpython-311.pyc | Bin 0 -> 32226 bytes .../network_adjustment.cpython-311.pyc | Bin 0 -> 33660 bytes .../transformation.cpython-311.pyc | Bin 0 -> 16491 bytes modules/cor_generator.py | 406 +++++++ modules/georeferencing.py | 369 ++++++ modules/jxl_parser.py | 616 ++++++++++ modules/network_adjustment.py | 633 ++++++++++ modules/transformation.py | 321 +++++ 15 files changed, 3536 insertions(+) create mode 100644 .abacus.donotdelete create mode 100644 README.md create mode 100644 main.py create mode 100644 modules/__init__.py create mode 100644 modules/__pycache__/__init__.cpython-311.pyc create mode 100644 modules/__pycache__/cor_generator.cpython-311.pyc create mode 100644 modules/__pycache__/georeferencing.cpython-311.pyc create mode 100644 modules/__pycache__/jxl_parser.cpython-311.pyc create mode 100644 modules/__pycache__/network_adjustment.cpython-311.pyc create mode 100644 modules/__pycache__/transformation.cpython-311.pyc create mode 100644 modules/cor_generator.py create mode 100644 modules/georeferencing.py create mode 100644 modules/jxl_parser.py create mode 100644 modules/network_adjustment.py create mode 100644 modules/transformation.py diff --git a/.abacus.donotdelete b/.abacus.donotdelete new file mode 100644 index 0000000..3c36da4 --- /dev/null +++ b/.abacus.donotdelete @@ -0,0 +1 @@ +gAAAAABpbMkVj3c_kR7OnAA7zo0Sfzw05-3MyOtYVW_kwm6pXuL5RNaqVAIlgDozOuzqrE7RPfvpzW2IRwTvNCMn1AQOXx-EsHqqzRj1WS153NEFBXvkT8g= \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..edf4aec --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Trimble Geodesy Tool + +Ein vollständiges Python-Programm mit grafischer Benutzeroberfläche (GUI) für geodätische Vermessungsarbeiten mit Trimble JXL-Dateien. + +## Funktionen + +### 1. JXL-Datei Analyse und Bearbeitung +- Trimble JXL-Dateien einlesen und analysieren +- Übersicht über Punkte, Stationen und Messungen +- Passpunkte entfernen +- Prismenkonstanten ändern +- Parameter bearbeiten + +### 2. COR-Datei Generierung +- Aus JXL-Dateien Punktdateien im COR-Format berechnen +- Unterstützt zwei Stationierungsarten: + - Erste Stationierung über eine Referenzlinie + - Alle anderen als freie Stationierungen +- Export in verschiedene Formate (COR, CSV, TXT, DXF) + +### 3. Koordinatensystem-Transformation +- Rotation um einen wählbaren Punkt +- Verschiebung in XY-Richtung +- Verschiebung in Z-Richtung +- Zwei Eingabemodi: + - Manuelle Eingabe von Transformationsparametern + - Definition über 2 Punkte (Ursprung und Y-Richtung) +- **Keine Maßstabsänderung** (wie gefordert) + +### 4. Georeferenzierung +- Mindestens 3 Passpunkte für Transformation +- Rotation und Translation des Koordinatensystems +- **Keine Maßstabsänderung** (keine Helmert-Transformation) +- Restfehler ausgleichen und anzeigen +- Detaillierte Qualitätsparameter (RMSE, Residuen) + +### 5. Netzausgleichung +- Methode der kleinsten Quadrate +- Basierend auf Beobachtungen der JXL-Datei +- Automatische Festpunkterkennung +- Ausgabe von: + - Ausgeglichenen Koordinaten + - Standardabweichungen + - Residuen + - Qualitätsparametern (Sigma-0, Chi-Quadrat, RMSE) + +## Installation + +### Voraussetzungen +- Python 3.8 oder höher +- PyQt5 +- NumPy +- SciPy +- lxml + +### Installation der Abhängigkeiten +```bash +pip install PyQt5 numpy scipy lxml +``` + +## Verwendung + +### Programm starten +```bash +cd /home/ubuntu/trimble_geodesy +python3 main.py +``` + +### Workflow +1. **JXL-Analyse Tab**: JXL-Datei laden und analysieren +2. **COR-Generator Tab**: Koordinaten generieren und exportieren +3. **Transformation Tab**: Koordinatensystem rotieren/verschieben +4. **Georeferenzierung Tab**: Mit Passpunkten transformieren +5. **Netzausgleichung Tab**: Netzausgleichung durchführen + +## Dateistruktur + +``` +trimble_geodesy/ +├── main.py # Hauptprogramm mit GUI +├── modules/ +│ ├── __init__.py +│ ├── jxl_parser.py # JXL-Datei Parser +│ ├── cor_generator.py # COR-Datei Generator +│ ├── transformation.py # Koordinatentransformation +│ ├── georeferencing.py # Georeferenzierung +│ └── network_adjustment.py # Netzausgleichung +├── output/ # Ausgabeverzeichnis +└── README.md +``` + +## Technische Details + +### JXL-Format +Das JXL-Format (Trimble JobXML) ist ein XML-basiertes Format für Vermessungsdaten: +- ``: Punktdaten mit Koordinaten und Messungen +- ``: Stationsinformationen +- ``: Prismeneinstellungen +- ``: Orientierungsdaten + +### COR-Format +Das COR-Format ist ein einfaches Textformat für Koordinaten: +``` +|Punkt |X |Y |Z | +|5001 |0.000 |0.000 |0.000 | +|5002 |0 | 11.407 | -0.035 | +``` + +### Transformation +Die Transformation verwendet eine 4-Parameter-Transformation: +- Translation X, Y, Z +- Rotation um die Z-Achse +- **Kein Maßstabsfaktor** (Maßstab = 1.0) + +### Netzausgleichung +Die Netzausgleichung verwendet: +- Gauß-Markov-Modell +- Beobachtungsgleichungen für Richtungen und Strecken +- Iterative Lösung nach Gauß-Newton +- Varianzfortpflanzung für Genauigkeitsmaße + +## Lizenz + +Dieses Programm wurde für geodätische Vermessungsarbeiten entwickelt. + +## Autor + +Entwickelt für geodätische Vermessungsarbeiten mit Trimble-Instrumenten. diff --git a/main.py b/main.py new file mode 100644 index 0000000..60e7a0f --- /dev/null +++ b/main.py @@ -0,0 +1,1056 @@ +#!/usr/bin/env python3 +""" +Trimble Geodesy Tool - Hauptprogramm mit GUI +Geodätische Vermessungsarbeiten mit JXL-Dateien +""" + +import sys +import os +from pathlib import Path + +# Module-Pfad hinzufügen +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout, + QGridLayout, QLabel, QPushButton, QLineEdit, QTextEdit, QFileDialog, + QTableWidget, QTableWidgetItem, QMessageBox, QGroupBox, QComboBox, + QSpinBox, QDoubleSpinBox, QCheckBox, QSplitter, QFrame, QScrollArea, + QHeaderView, QListWidget, QListWidgetItem, QDialog, QDialogButtonBox, + QFormLayout, QProgressBar, QStatusBar, QMenuBar, QMenu, QAction, + QToolBar, QStyle +) +from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtGui import QFont, QIcon, QPalette, QColor + +from modules.jxl_parser import JXLParser +from modules.cor_generator import CORGenerator, CORPoint +from modules.transformation import CoordinateTransformer, LocalSystemTransformer +from modules.georeferencing import Georeferencer, ControlPoint +from modules.network_adjustment import NetworkAdjustment + + +class JXLAnalysisTab(QWidget): + """Tab für JXL-Datei Analyse und Bearbeitung""" + + def __init__(self, parent=None): + super().__init__(parent) + self.main_window = parent + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Datei laden + file_group = QGroupBox("JXL-Datei") + file_layout = QHBoxLayout(file_group) + + self.file_path_edit = QLineEdit() + self.file_path_edit.setPlaceholderText("JXL-Datei auswählen...") + file_layout.addWidget(self.file_path_edit) + + browse_btn = QPushButton("Durchsuchen") + browse_btn.clicked.connect(self.browse_file) + file_layout.addWidget(browse_btn) + + load_btn = QPushButton("Laden") + load_btn.clicked.connect(self.load_file) + file_layout.addWidget(load_btn) + + layout.addWidget(file_group) + + # Splitter für Info und Tabellen + splitter = QSplitter(Qt.Vertical) + + # Zusammenfassung + summary_group = QGroupBox("Zusammenfassung") + summary_layout = QVBoxLayout(summary_group) + self.summary_text = QTextEdit() + self.summary_text.setReadOnly(True) + self.summary_text.setMaximumHeight(200) + summary_layout.addWidget(self.summary_text) + splitter.addWidget(summary_group) + + # Punkte-Tabelle + points_group = QGroupBox("Punkte") + points_layout = QVBoxLayout(points_group) + + self.points_table = QTableWidget() + self.points_table.setColumnCount(7) + self.points_table.setHorizontalHeaderLabels( + ["Name", "Code", "East (X)", "North (Y)", "Elevation (Z)", "Methode", "Aktiv"]) + self.points_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + points_layout.addWidget(self.points_table) + + # Punkt-Aktionen + point_actions = QHBoxLayout() + + remove_point_btn = QPushButton("Ausgewählten Punkt entfernen") + remove_point_btn.clicked.connect(self.remove_selected_point) + point_actions.addWidget(remove_point_btn) + + point_actions.addStretch() + points_layout.addLayout(point_actions) + + splitter.addWidget(points_group) + + # Prismenkonstanten + prism_group = QGroupBox("Prismenkonstanten") + prism_layout = QVBoxLayout(prism_group) + + self.prism_table = QTableWidget() + self.prism_table.setColumnCount(4) + self.prism_table.setHorizontalHeaderLabels( + ["Target ID", "Prismentyp", "Konstante [mm]", "Neue Konstante [mm]"]) + self.prism_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + prism_layout.addWidget(self.prism_table) + + prism_actions = QHBoxLayout() + apply_prism_btn = QPushButton("Prismenkonstanten übernehmen") + apply_prism_btn.clicked.connect(self.apply_prism_changes) + prism_actions.addWidget(apply_prism_btn) + prism_actions.addStretch() + prism_layout.addLayout(prism_actions) + + splitter.addWidget(prism_group) + + layout.addWidget(splitter) + + def browse_file(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "JXL-Datei öffnen", "", "JXL Files (*.jxl);;All Files (*)") + if file_path: + self.file_path_edit.setText(file_path) + + def load_file(self): + file_path = self.file_path_edit.text() + if not file_path or not os.path.exists(file_path): + QMessageBox.warning(self, "Fehler", "Bitte eine gültige JXL-Datei auswählen!") + return + + parser = JXLParser() + if parser.parse(file_path): + self.main_window.parser = parser + self.update_display() + self.main_window.statusBar().showMessage(f"JXL-Datei geladen: {file_path}") + else: + QMessageBox.critical(self, "Fehler", "Datei konnte nicht geladen werden!") + + def update_display(self): + if not self.main_window.parser: + return + + parser = self.main_window.parser + + # Zusammenfassung + self.summary_text.setText(parser.get_summary()) + + # Punkte-Tabelle + points = parser.get_active_points() + self.points_table.setRowCount(len(points)) + + for row, (name, point) in enumerate(sorted(points.items())): + self.points_table.setItem(row, 0, QTableWidgetItem(name)) + self.points_table.setItem(row, 1, QTableWidgetItem(point.code or "")) + self.points_table.setItem(row, 2, QTableWidgetItem(f"{point.east:.4f}" if point.east else "")) + self.points_table.setItem(row, 3, QTableWidgetItem(f"{point.north:.4f}" if point.north else "")) + self.points_table.setItem(row, 4, QTableWidgetItem(f"{point.elevation:.4f}" if point.elevation else "")) + self.points_table.setItem(row, 5, QTableWidgetItem(point.method)) + self.points_table.setItem(row, 6, QTableWidgetItem("Ja" if not point.deleted else "Nein")) + + # Prismen-Tabelle + targets = parser.targets + self.prism_table.setRowCount(len(targets)) + + for row, (tid, target) in enumerate(targets.items()): + self.prism_table.setItem(row, 0, QTableWidgetItem(tid)) + self.prism_table.setItem(row, 1, QTableWidgetItem(target.prism_type)) + self.prism_table.setItem(row, 2, QTableWidgetItem(f"{target.prism_constant * 1000:.1f}")) + + spin = QDoubleSpinBox() + spin.setRange(-100, 100) + spin.setDecimals(1) + spin.setValue(target.prism_constant * 1000) + self.prism_table.setCellWidget(row, 3, spin) + + def remove_selected_point(self): + row = self.points_table.currentRow() + if row >= 0: + name = self.points_table.item(row, 0).text() + reply = QMessageBox.question( + self, "Bestätigung", + f"Punkt '{name}' wirklich entfernen?", + QMessageBox.Yes | QMessageBox.No) + + if reply == QMessageBox.Yes: + self.main_window.parser.remove_point(name) + self.update_display() + + def apply_prism_changes(self): + parser = self.main_window.parser + if not parser: + return + + for row in range(self.prism_table.rowCount()): + tid = self.prism_table.item(row, 0).text() + spin = self.prism_table.cellWidget(row, 3) + new_const = spin.value() / 1000.0 # mm to m + parser.modify_prism_constant(tid, new_const) + + QMessageBox.information(self, "Info", "Prismenkonstanten wurden aktualisiert!") + + +class CORGeneratorTab(QWidget): + """Tab für COR-Datei Generierung""" + + def __init__(self, parent=None): + super().__init__(parent) + self.main_window = parent + self.cor_generator = None + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Optionen + options_group = QGroupBox("Generierungsoptionen") + options_layout = QGridLayout(options_group) + + options_layout.addWidget(QLabel("Methode:"), 0, 0) + self.method_combo = QComboBox() + self.method_combo.addItems([ + "Aus berechneten Koordinaten (ComputedGrid)", + "Aus Rohbeobachtungen berechnen" + ]) + options_layout.addWidget(self.method_combo, 0, 1) + + self.include_header_check = QCheckBox("Stations-Header einfügen") + self.include_header_check.setChecked(True) + options_layout.addWidget(self.include_header_check, 1, 0, 1, 2) + + layout.addWidget(options_group) + + # Generieren Button + generate_btn = QPushButton("COR-Datei generieren") + generate_btn.clicked.connect(self.generate_cor) + layout.addWidget(generate_btn) + + # Vorschau + preview_group = QGroupBox("Vorschau") + preview_layout = QVBoxLayout(preview_group) + + self.preview_table = QTableWidget() + self.preview_table.setColumnCount(4) + self.preview_table.setHorizontalHeaderLabels(["Punkt", "X (East)", "Y (North)", "Z (Elev)"]) + self.preview_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + preview_layout.addWidget(self.preview_table) + + # Statistiken + self.stats_text = QTextEdit() + self.stats_text.setReadOnly(True) + self.stats_text.setMaximumHeight(100) + preview_layout.addWidget(self.stats_text) + + layout.addWidget(preview_group) + + # Export + export_group = QGroupBox("Export") + export_layout = QHBoxLayout(export_group) + + export_cor_btn = QPushButton("Als COR speichern") + export_cor_btn.clicked.connect(lambda: self.export_file('cor')) + export_layout.addWidget(export_cor_btn) + + export_csv_btn = QPushButton("Als CSV speichern") + export_csv_btn.clicked.connect(lambda: self.export_file('csv')) + export_layout.addWidget(export_csv_btn) + + export_txt_btn = QPushButton("Als TXT speichern") + export_txt_btn.clicked.connect(lambda: self.export_file('txt')) + export_layout.addWidget(export_txt_btn) + + export_dxf_btn = QPushButton("Als DXF speichern") + export_dxf_btn.clicked.connect(lambda: self.export_file('dxf')) + export_layout.addWidget(export_dxf_btn) + + layout.addWidget(export_group) + + def generate_cor(self): + if not self.main_window.parser: + QMessageBox.warning(self, "Fehler", "Bitte zuerst eine JXL-Datei laden!") + return + + self.cor_generator = CORGenerator(self.main_window.parser) + + if self.method_combo.currentIndex() == 0: + points = self.cor_generator.generate_from_computed_grid() + else: + points = self.cor_generator.compute_from_observations() + + # Tabelle aktualisieren + self.preview_table.setRowCount(len(points)) + for row, p in enumerate(points): + self.preview_table.setItem(row, 0, QTableWidgetItem(p.name)) + self.preview_table.setItem(row, 1, QTableWidgetItem(f"{p.x:.4f}")) + self.preview_table.setItem(row, 2, QTableWidgetItem(f"{p.y:.4f}")) + self.preview_table.setItem(row, 3, QTableWidgetItem(f"{p.z:.4f}")) + + # Statistiken + self.stats_text.setText(self.cor_generator.get_statistics()) + + self.main_window.statusBar().showMessage(f"{len(points)} Punkte generiert") + + def export_file(self, format_type): + if not self.cor_generator or not self.cor_generator.cor_points: + QMessageBox.warning(self, "Fehler", "Bitte zuerst Punkte generieren!") + return + + filters = { + 'cor': "COR Files (*.cor)", + 'csv': "CSV Files (*.csv)", + 'txt': "Text Files (*.txt)", + 'dxf': "DXF Files (*.dxf)" + } + + file_path, _ = QFileDialog.getSaveFileName( + self, "Speichern unter", "", filters.get(format_type, "All Files (*)")) + + if file_path: + if format_type == 'cor': + self.cor_generator.write_cor_file(file_path, self.include_header_check.isChecked()) + elif format_type == 'csv': + self.cor_generator.export_csv(file_path) + elif format_type == 'txt': + self.cor_generator.export_txt(file_path) + elif format_type == 'dxf': + self.cor_generator.export_dxf(file_path) + + QMessageBox.information(self, "Erfolg", f"Datei gespeichert: {file_path}") + + +class TransformationTab(QWidget): + """Tab für Koordinatentransformation""" + + def __init__(self, parent=None): + super().__init__(parent) + self.main_window = parent + self.transformer = CoordinateTransformer() + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Methode auswählen + method_group = QGroupBox("Transformationsmethode") + method_layout = QVBoxLayout(method_group) + + self.manual_radio = QCheckBox("Manuelle Parameter") + self.manual_radio.setChecked(True) + self.manual_radio.stateChanged.connect(self.toggle_method) + method_layout.addWidget(self.manual_radio) + + self.twopoint_radio = QCheckBox("Über 2 Punkte definieren") + self.twopoint_radio.stateChanged.connect(self.toggle_method) + method_layout.addWidget(self.twopoint_radio) + + layout.addWidget(method_group) + + # Manuelle Parameter + self.manual_group = QGroupBox("Manuelle Parameter") + manual_layout = QGridLayout(self.manual_group) + + manual_layout.addWidget(QLabel("Verschiebung X (East):"), 0, 0) + self.dx_spin = QDoubleSpinBox() + self.dx_spin.setRange(-1000000, 1000000) + self.dx_spin.setDecimals(4) + manual_layout.addWidget(self.dx_spin, 0, 1) + manual_layout.addWidget(QLabel("m"), 0, 2) + + manual_layout.addWidget(QLabel("Verschiebung Y (North):"), 1, 0) + self.dy_spin = QDoubleSpinBox() + self.dy_spin.setRange(-1000000, 1000000) + self.dy_spin.setDecimals(4) + manual_layout.addWidget(self.dy_spin, 1, 1) + manual_layout.addWidget(QLabel("m"), 1, 2) + + manual_layout.addWidget(QLabel("Verschiebung Z (Höhe):"), 2, 0) + self.dz_spin = QDoubleSpinBox() + self.dz_spin.setRange(-1000000, 1000000) + self.dz_spin.setDecimals(4) + manual_layout.addWidget(self.dz_spin, 2, 1) + manual_layout.addWidget(QLabel("m"), 2, 2) + + manual_layout.addWidget(QLabel("Rotation:"), 3, 0) + self.rotation_spin = QDoubleSpinBox() + self.rotation_spin.setRange(-400, 400) + self.rotation_spin.setDecimals(6) + manual_layout.addWidget(self.rotation_spin, 3, 1) + manual_layout.addWidget(QLabel("gon"), 3, 2) + + manual_layout.addWidget(QLabel("Drehpunkt X:"), 4, 0) + self.pivot_x_spin = QDoubleSpinBox() + self.pivot_x_spin.setRange(-1000000, 1000000) + self.pivot_x_spin.setDecimals(4) + manual_layout.addWidget(self.pivot_x_spin, 4, 1) + + manual_layout.addWidget(QLabel("Drehpunkt Y:"), 5, 0) + self.pivot_y_spin = QDoubleSpinBox() + self.pivot_y_spin.setRange(-1000000, 1000000) + self.pivot_y_spin.setDecimals(4) + manual_layout.addWidget(self.pivot_y_spin, 5, 1) + + layout.addWidget(self.manual_group) + + # 2-Punkte-Definition + self.twopoint_group = QGroupBox("2-Punkte-Definition") + twopoint_layout = QGridLayout(self.twopoint_group) + + twopoint_layout.addWidget(QLabel("Ursprung (0,0):"), 0, 0) + self.origin_combo = QComboBox() + twopoint_layout.addWidget(self.origin_combo, 0, 1) + + twopoint_layout.addWidget(QLabel("Y-Richtung:"), 1, 0) + self.direction_combo = QComboBox() + twopoint_layout.addWidget(self.direction_combo, 1, 1) + + twopoint_layout.addWidget(QLabel("Z-Referenz (0):"), 2, 0) + self.zref_combo = QComboBox() + twopoint_layout.addWidget(self.zref_combo, 2, 1) + + refresh_btn = QPushButton("Punktliste aktualisieren") + refresh_btn.clicked.connect(self.refresh_point_lists) + twopoint_layout.addWidget(refresh_btn, 3, 0, 1, 2) + + self.twopoint_group.setVisible(False) + layout.addWidget(self.twopoint_group) + + # Transformation durchführen + transform_btn = QPushButton("Transformation durchführen") + transform_btn.clicked.connect(self.execute_transformation) + layout.addWidget(transform_btn) + + # Ergebnisse + results_group = QGroupBox("Ergebnisse") + results_layout = QVBoxLayout(results_group) + + self.results_text = QTextEdit() + self.results_text.setReadOnly(True) + results_layout.addWidget(self.results_text) + + # Export + export_layout = QHBoxLayout() + export_report_btn = QPushButton("Bericht exportieren") + export_report_btn.clicked.connect(self.export_report) + export_layout.addWidget(export_report_btn) + + export_points_btn = QPushButton("Punkte exportieren") + export_points_btn.clicked.connect(self.export_points) + export_layout.addWidget(export_points_btn) + + results_layout.addLayout(export_layout) + + layout.addWidget(results_group) + + def toggle_method(self): + self.manual_group.setVisible(self.manual_radio.isChecked()) + self.twopoint_group.setVisible(self.twopoint_radio.isChecked()) + + if self.twopoint_radio.isChecked(): + self.refresh_point_lists() + + def refresh_point_lists(self): + if not self.main_window.parser: + return + + points = list(self.main_window.parser.get_active_points().keys()) + + self.origin_combo.clear() + self.direction_combo.clear() + self.zref_combo.clear() + + self.zref_combo.addItem("(wie Ursprung)") + + for name in sorted(points): + self.origin_combo.addItem(name) + self.direction_combo.addItem(name) + self.zref_combo.addItem(name) + + def execute_transformation(self): + if not self.main_window.parser: + QMessageBox.warning(self, "Fehler", "Bitte zuerst eine JXL-Datei laden!") + return + + # Punkte aus Parser holen + points = [] + for name, p in self.main_window.parser.get_active_points().items(): + if p.east is not None and p.north is not None: + points.append(CORPoint( + name=name, + x=p.east, + y=p.north, + z=p.elevation or 0.0 + )) + + self.transformer.set_points(points) + + if self.manual_radio.isChecked(): + self.transformer.set_manual_parameters( + dx=self.dx_spin.value(), + dy=self.dy_spin.value(), + dz=self.dz_spin.value(), + rotation_gon=self.rotation_spin.value(), + pivot_x=self.pivot_x_spin.value(), + pivot_y=self.pivot_y_spin.value() + ) + elif self.twopoint_radio.isChecked(): + origin = self.origin_combo.currentText() + direction = self.direction_combo.currentText() + zref = self.zref_combo.currentText() + + if zref == "(wie Ursprung)": + zref = None + + if not self.transformer.compute_from_two_points(origin, direction, zref): + QMessageBox.warning(self, "Fehler", "Punkte nicht gefunden!") + return + + self.transformer.transform() + + # Ergebnisse anzeigen + report = self.transformer.get_parameters_report() + report += "\n\n" + report += self.transformer.get_comparison_table() + + self.results_text.setText(report) + self.main_window.statusBar().showMessage("Transformation durchgeführt") + + def export_report(self): + file_path, _ = QFileDialog.getSaveFileName( + self, "Bericht speichern", "", "Text Files (*.txt)") + if file_path: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(self.results_text.toPlainText()) + QMessageBox.information(self, "Erfolg", f"Bericht gespeichert: {file_path}") + + def export_points(self): + if not self.transformer.transformed_points: + QMessageBox.warning(self, "Fehler", "Keine transformierten Punkte vorhanden!") + return + + file_path, _ = QFileDialog.getSaveFileName( + self, "Punkte speichern", "", "CSV Files (*.csv)") + if file_path: + lines = ["Punkt;X;Y;Z"] + for p in self.transformer.transformed_points: + lines.append(f"{p.name};{p.x:.4f};{p.y:.4f};{p.z:.4f}") + + with open(file_path, 'w', encoding='utf-8') as f: + f.write("\n".join(lines)) + QMessageBox.information(self, "Erfolg", f"Punkte gespeichert: {file_path}") + + +class GeoreferencingTab(QWidget): + """Tab für Georeferenzierung""" + + def __init__(self, parent=None): + super().__init__(parent) + self.main_window = parent + self.georeferencer = Georeferencer() + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Passpunkte + cp_group = QGroupBox("Passpunkte (mind. 3)") + cp_layout = QVBoxLayout(cp_group) + + self.cp_table = QTableWidget() + self.cp_table.setColumnCount(7) + self.cp_table.setHorizontalHeaderLabels([ + "Punkt", "X_lokal", "Y_lokal", "Z_lokal", "X_Ziel", "Y_Ziel", "Z_Ziel"]) + self.cp_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + cp_layout.addWidget(self.cp_table) + + # Passpunkt-Buttons + cp_buttons = QHBoxLayout() + + add_cp_btn = QPushButton("Passpunkt hinzufügen") + add_cp_btn.clicked.connect(self.add_control_point) + cp_buttons.addWidget(add_cp_btn) + + remove_cp_btn = QPushButton("Entfernen") + remove_cp_btn.clicked.connect(self.remove_control_point) + cp_buttons.addWidget(remove_cp_btn) + + load_local_btn = QPushButton("Lokale Koordinaten aus JXL") + load_local_btn.clicked.connect(self.load_local_from_jxl) + cp_buttons.addWidget(load_local_btn) + + load_target_btn = QPushButton("Zielkoordinaten importieren") + load_target_btn.clicked.connect(self.load_target_coords) + cp_buttons.addWidget(load_target_btn) + + cp_buttons.addStretch() + cp_layout.addLayout(cp_buttons) + + layout.addWidget(cp_group) + + # Transformation berechnen + calc_btn = QPushButton("Transformation berechnen") + calc_btn.clicked.connect(self.calculate_transformation) + layout.addWidget(calc_btn) + + # Ergebnisse + results_group = QGroupBox("Ergebnisse") + results_layout = QVBoxLayout(results_group) + + self.results_text = QTextEdit() + self.results_text.setReadOnly(True) + self.results_text.setFont(QFont("Courier")) + results_layout.addWidget(self.results_text) + + # Export + export_layout = QHBoxLayout() + + export_report_btn = QPushButton("Bericht exportieren") + export_report_btn.clicked.connect(self.export_report) + export_layout.addWidget(export_report_btn) + + transform_all_btn = QPushButton("Alle Punkte transformieren") + transform_all_btn.clicked.connect(self.transform_all_points) + export_layout.addWidget(transform_all_btn) + + results_layout.addLayout(export_layout) + + layout.addWidget(results_group) + + def add_control_point(self): + row = self.cp_table.rowCount() + self.cp_table.insertRow(row) + + # Punkt-Auswahl + combo = QComboBox() + if self.main_window.parser: + for name in sorted(self.main_window.parser.get_active_points().keys()): + combo.addItem(name) + self.cp_table.setCellWidget(row, 0, combo) + + # Editierbare Felder für Koordinaten + for col in range(1, 7): + spin = QDoubleSpinBox() + spin.setRange(-10000000, 10000000) + spin.setDecimals(4) + self.cp_table.setCellWidget(row, col, spin) + + def remove_control_point(self): + row = self.cp_table.currentRow() + if row >= 0: + self.cp_table.removeRow(row) + + def load_local_from_jxl(self): + if not self.main_window.parser: + QMessageBox.warning(self, "Fehler", "Bitte zuerst eine JXL-Datei laden!") + return + + for row in range(self.cp_table.rowCount()): + combo = self.cp_table.cellWidget(row, 0) + if combo: + name = combo.currentText() + if name in self.main_window.parser.points: + p = self.main_window.parser.points[name] + + self.cp_table.cellWidget(row, 1).setValue(p.east or 0) + self.cp_table.cellWidget(row, 2).setValue(p.north or 0) + self.cp_table.cellWidget(row, 3).setValue(p.elevation or 0) + + def load_target_coords(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "Zielkoordinaten laden", "", + "CSV Files (*.csv);;Text Files (*.txt);;All Files (*)") + + if not file_path: + return + + try: + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # Format: Punkt;X;Y;Z oder Punkt,X,Y,Z + coord_dict = {} + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + + parts = line.replace(',', ';').split(';') + if len(parts) >= 4: + name = parts[0].strip() + x = float(parts[1]) + y = float(parts[2]) + z = float(parts[3]) + coord_dict[name] = (x, y, z) + + # In Tabelle eintragen + for row in range(self.cp_table.rowCount()): + combo = self.cp_table.cellWidget(row, 0) + if combo: + name = combo.currentText() + if name in coord_dict: + x, y, z = coord_dict[name] + self.cp_table.cellWidget(row, 4).setValue(x) + self.cp_table.cellWidget(row, 5).setValue(y) + self.cp_table.cellWidget(row, 6).setValue(z) + + QMessageBox.information(self, "Erfolg", f"{len(coord_dict)} Koordinaten geladen!") + + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Fehler beim Laden: {e}") + + def calculate_transformation(self): + self.georeferencer.clear_control_points() + + for row in range(self.cp_table.rowCount()): + combo = self.cp_table.cellWidget(row, 0) + if not combo: + continue + + name = combo.currentText() + local_x = self.cp_table.cellWidget(row, 1).value() + local_y = self.cp_table.cellWidget(row, 2).value() + local_z = self.cp_table.cellWidget(row, 3).value() + target_x = self.cp_table.cellWidget(row, 4).value() + target_y = self.cp_table.cellWidget(row, 5).value() + target_z = self.cp_table.cellWidget(row, 6).value() + + self.georeferencer.add_control_point( + name, local_x, local_y, local_z, target_x, target_y, target_z) + + if len(self.georeferencer.control_points) < 3: + QMessageBox.warning(self, "Fehler", "Mindestens 3 Passpunkte erforderlich!") + return + + try: + self.georeferencer.compute_transformation() + report = self.georeferencer.get_transformation_report() + self.results_text.setText(report) + self.main_window.statusBar().showMessage("Georeferenzierung berechnet") + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Berechnung fehlgeschlagen: {e}") + + def export_report(self): + file_path, _ = QFileDialog.getSaveFileName( + self, "Bericht speichern", "", "Text Files (*.txt)") + if file_path: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(self.results_text.toPlainText()) + QMessageBox.information(self, "Erfolg", f"Bericht gespeichert: {file_path}") + + def transform_all_points(self): + if self.georeferencer.result is None: + QMessageBox.warning(self, "Fehler", "Bitte zuerst Transformation berechnen!") + return + + if not self.main_window.parser: + return + + # Punkte transformieren + points = [] + for name, p in self.main_window.parser.get_active_points().items(): + if p.east is not None and p.north is not None: + points.append(CORPoint( + name=name, x=p.east, y=p.north, z=p.elevation or 0)) + + self.georeferencer.set_points_to_transform(points) + transformed = self.georeferencer.transform_points() + + # Export-Dialog + file_path, _ = QFileDialog.getSaveFileName( + self, "Transformierte Punkte speichern", "", "CSV Files (*.csv)") + + if file_path: + lines = ["Punkt;X;Y;Z"] + for p in transformed: + lines.append(f"{p.name};{p.x:.4f};{p.y:.4f};{p.z:.4f}") + + with open(file_path, 'w', encoding='utf-8') as f: + f.write("\n".join(lines)) + + QMessageBox.information(self, "Erfolg", + f"{len(transformed)} Punkte transformiert und gespeichert!") + + +class NetworkAdjustmentTab(QWidget): + """Tab für Netzausgleichung""" + + def __init__(self, parent=None): + super().__init__(parent) + self.main_window = parent + self.adjustment = None + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Konfiguration + config_group = QGroupBox("Konfiguration") + config_layout = QGridLayout(config_group) + + config_layout.addWidget(QLabel("Max. Iterationen:"), 0, 0) + self.max_iter_spin = QSpinBox() + self.max_iter_spin.setRange(1, 100) + self.max_iter_spin.setValue(20) + config_layout.addWidget(self.max_iter_spin, 0, 1) + + config_layout.addWidget(QLabel("Konvergenzlimit [mm]:"), 1, 0) + self.convergence_spin = QDoubleSpinBox() + self.convergence_spin.setRange(0.001, 10) + self.convergence_spin.setDecimals(3) + self.convergence_spin.setValue(0.01) + config_layout.addWidget(self.convergence_spin, 1, 1) + + config_layout.addWidget(QLabel("Sigma-0 a-priori:"), 2, 0) + self.sigma0_spin = QDoubleSpinBox() + self.sigma0_spin.setRange(0.1, 10) + self.sigma0_spin.setDecimals(2) + self.sigma0_spin.setValue(1.0) + config_layout.addWidget(self.sigma0_spin, 2, 1) + + layout.addWidget(config_group) + + # Festpunkte + fixed_group = QGroupBox("Festpunkte") + fixed_layout = QVBoxLayout(fixed_group) + + self.fixed_list = QListWidget() + self.fixed_list.setSelectionMode(QListWidget.MultiSelection) + fixed_layout.addWidget(self.fixed_list) + + fixed_buttons = QHBoxLayout() + refresh_btn = QPushButton("Liste aktualisieren") + refresh_btn.clicked.connect(self.refresh_point_list) + fixed_buttons.addWidget(refresh_btn) + + auto_btn = QPushButton("Auto (Referenzpunkte)") + auto_btn.clicked.connect(self.auto_select_fixed) + fixed_buttons.addWidget(auto_btn) + + fixed_layout.addLayout(fixed_buttons) + layout.addWidget(fixed_group) + + # Ausgleichung durchführen + adjust_btn = QPushButton("Netzausgleichung durchführen") + adjust_btn.clicked.connect(self.run_adjustment) + layout.addWidget(adjust_btn) + + # Ergebnisse + results_group = QGroupBox("Ergebnisse") + results_layout = QVBoxLayout(results_group) + + self.results_text = QTextEdit() + self.results_text.setReadOnly(True) + self.results_text.setFont(QFont("Courier", 9)) + results_layout.addWidget(self.results_text) + + # Export + export_layout = QHBoxLayout() + + export_report_btn = QPushButton("Bericht exportieren") + export_report_btn.clicked.connect(self.export_report) + export_layout.addWidget(export_report_btn) + + export_points_btn = QPushButton("Koordinaten exportieren") + export_points_btn.clicked.connect(self.export_points) + export_layout.addWidget(export_points_btn) + + results_layout.addLayout(export_layout) + layout.addWidget(results_group) + + def refresh_point_list(self): + self.fixed_list.clear() + + if not self.main_window.parser: + return + + for name in sorted(self.main_window.parser.get_active_points().keys()): + item = QListWidgetItem(name) + self.fixed_list.addItem(item) + + def auto_select_fixed(self): + if not self.main_window.parser: + return + + self.fixed_list.clearSelection() + + ref_line = self.main_window.parser.get_reference_line() + if ref_line: + for i in range(self.fixed_list.count()): + item = self.fixed_list.item(i) + if item.text() in [ref_line.start_point, ref_line.end_point]: + item.setSelected(True) + + def run_adjustment(self): + if not self.main_window.parser: + QMessageBox.warning(self, "Fehler", "Bitte zuerst eine JXL-Datei laden!") + return + + # Adjustment erstellen + self.adjustment = NetworkAdjustment(self.main_window.parser) + self.adjustment.max_iterations = self.max_iter_spin.value() + self.adjustment.convergence_limit = self.convergence_spin.value() / 1000.0 + self.adjustment.sigma_0_priori = self.sigma0_spin.value() + + # Beobachtungen extrahieren + self.adjustment.extract_observations() + self.adjustment.initialize_points() + + # Festpunkte setzen + for item in self.fixed_list.selectedItems(): + self.adjustment.set_fixed_point(item.text()) + + if not self.adjustment.fixed_points: + self.adjustment.set_fixed_points_auto() + + try: + result = self.adjustment.adjust() + report = self.adjustment.get_adjustment_report() + self.results_text.setText(report) + + status = "konvergiert" if result.converged else "nicht konvergiert" + self.main_window.statusBar().showMessage( + f"Ausgleichung abgeschlossen ({status}, {result.iterations} Iterationen)") + + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Ausgleichung fehlgeschlagen: {e}") + import traceback + traceback.print_exc() + + def export_report(self): + if not self.adjustment or not self.adjustment.result: + QMessageBox.warning(self, "Fehler", "Keine Ergebnisse vorhanden!") + return + + file_path, _ = QFileDialog.getSaveFileName( + self, "Bericht speichern", "", "Text Files (*.txt)") + if file_path: + self.adjustment.export_report(file_path) + QMessageBox.information(self, "Erfolg", f"Bericht gespeichert: {file_path}") + + def export_points(self): + if not self.adjustment or not self.adjustment.result: + QMessageBox.warning(self, "Fehler", "Keine Ergebnisse vorhanden!") + return + + file_path, _ = QFileDialog.getSaveFileName( + self, "Koordinaten speichern", "", "CSV Files (*.csv)") + if file_path: + self.adjustment.export_adjusted_points(file_path, 'csv') + QMessageBox.information(self, "Erfolg", f"Koordinaten gespeichert: {file_path}") + + +class MainWindow(QMainWindow): + """Hauptfenster der Anwendung""" + + def __init__(self): + super().__init__() + self.parser: JXLParser = None + self.setup_ui() + + def setup_ui(self): + self.setWindowTitle("Trimble Geodesy Tool - Geodätische Vermessungsarbeiten") + self.setMinimumSize(1200, 800) + + # Menüleiste + self.setup_menu() + + # Statusleiste + self.statusBar().showMessage("Bereit") + + # Zentrale Widget mit Tabs + central_widget = QWidget() + self.setCentralWidget(central_widget) + + layout = QVBoxLayout(central_widget) + + # Tab-Widget + self.tabs = QTabWidget() + + self.jxl_tab = JXLAnalysisTab(self) + self.tabs.addTab(self.jxl_tab, "📁 JXL-Analyse") + + self.cor_tab = CORGeneratorTab(self) + self.tabs.addTab(self.cor_tab, "📄 COR-Generator") + + self.transform_tab = TransformationTab(self) + self.tabs.addTab(self.transform_tab, "🔄 Transformation") + + self.georef_tab = GeoreferencingTab(self) + self.tabs.addTab(self.georef_tab, "🌍 Georeferenzierung") + + self.adjust_tab = NetworkAdjustmentTab(self) + self.tabs.addTab(self.adjust_tab, "📐 Netzausgleichung") + + layout.addWidget(self.tabs) + + def setup_menu(self): + menubar = self.menuBar() + + # Datei-Menü + file_menu = menubar.addMenu("&Datei") + + open_action = QAction("&Öffnen...", self) + open_action.setShortcut("Ctrl+O") + open_action.triggered.connect(self.open_file) + file_menu.addAction(open_action) + + file_menu.addSeparator() + + exit_action = QAction("&Beenden", self) + exit_action.setShortcut("Ctrl+Q") + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Hilfe-Menü + help_menu = menubar.addMenu("&Hilfe") + + about_action = QAction("&Über", self) + about_action.triggered.connect(self.show_about) + help_menu.addAction(about_action) + + def open_file(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "JXL-Datei öffnen", "", "JXL Files (*.jxl);;All Files (*)") + if file_path: + self.jxl_tab.file_path_edit.setText(file_path) + self.jxl_tab.load_file() + + def show_about(self): + QMessageBox.about(self, "Über Trimble Geodesy Tool", + "

Trimble Geodesy Tool

" + "

Geodätische Vermessungsarbeiten mit JXL-Dateien

" + "

Funktionen:

" + "
    " + "
  • JXL-Datei Analyse und Bearbeitung
  • " + "
  • COR-Datei Generierung
  • " + "
  • Koordinatentransformation (Rotation/Translation)
  • " + "
  • Georeferenzierung mit Passpunkten
  • " + "
  • Netzausgleichung nach kleinsten Quadraten
  • " + "
" + "

Version 1.0

") + + +def main(): + app = QApplication(sys.argv) + + # Stil setzen + app.setStyle('Fusion') + + # Fenster erstellen und anzeigen + window = MainWindow() + window.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..5ba9474 --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,6 @@ +# Trimble Geodesy Modules +from .jxl_parser import JXLParser +from .cor_generator import CORGenerator +from .transformation import CoordinateTransformer +from .georeferencing import Georeferencer +from .network_adjustment import NetworkAdjustment diff --git a/modules/__pycache__/__init__.cpython-311.pyc b/modules/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae56889ed4548972b094ec7bbce6d3f4c0432094 GIT binary patch literal 501 zcmZWlO-sW-5Z!H>rY)wZ;1BR5xfs2Rpgj~ss;CDELP(owP2G<&*--mS{0ZVu@s?xh zN$})N?5!s^TdM?z$LyQ8v+uz^yKa-{x_f2GmpqsS1pl!tAUqB*;SS!SBu?h*J-xPH8#S2;JPL>D$z2x9$y-cXg zSD5e~105%0NT)uRamFC^!!Qwv3d8;JbXh-IsmHjf4T>*GLAOtfQu;=A^D<^+FE3+8 T4)Zc*#LLV0zq5LREuHiYbwrfb literal 0 HcmV?d00001 diff --git a/modules/__pycache__/cor_generator.cpython-311.pyc b/modules/__pycache__/cor_generator.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a47439739a73343524003e7dc450ea72a96924de GIT binary patch literal 18161 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 Jq?A$n{{rvIz|jBz literal 0 HcmV?d00001 diff --git a/modules/__pycache__/georeferencing.cpython-311.pyc b/modules/__pycache__/georeferencing.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4bf4843db12ab55829e3bde3de88ccca3979ea06 GIT binary patch literal 21599 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 L+?RC{$n^X#1RN8v literal 0 HcmV?d00001 diff --git a/modules/cor_generator.py b/modules/cor_generator.py new file mode 100644 index 0000000..002a939 --- /dev/null +++ b/modules/cor_generator.py @@ -0,0 +1,406 @@ +""" +COR Generator Module - Generiert COR-Punktdateien aus JXL-Daten +Unterstützt Referenzlinien-Stationierung und freie Stationierung +""" + +import math +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +from .jxl_parser import JXLParser, Point, Station + + +@dataclass +class CORPoint: + """Repräsentiert einen Punkt im COR-Format""" + name: str + x: float # East + y: float # North + z: float # Elevation + + def to_cor_line(self) -> str: + """Formatiert als COR-Zeile""" + # Format: |Name |X |Y |Z | + x_str = f"{self.x:.3f}" if abs(self.x) >= 0.001 else "0" + y_str = f"{self.y:.3f}" if abs(self.y) >= 0.001 else "0" + z_str = f"{self.z:.3f}" if abs(self.z) >= 0.001 else "0" + + return f"|{self.name} |{x_str} | {y_str} | {z_str} |" + + +class CORGenerator: + """Generator für COR-Dateien aus JXL-Daten""" + + def __init__(self, parser: JXLParser): + self.parser = parser + self.cor_points: List[CORPoint] = [] + + # Transformationsparameter (für lokales System) + self.origin_east: float = 0.0 + self.origin_north: float = 0.0 + self.origin_elev: float = 0.0 + self.rotation: float = 0.0 # in Radiant + + def compute_from_observations(self) -> List[CORPoint]: + """ + Berechnet Koordinaten aus den Rohbeobachtungen + Erste Station: Referenzlinie + Weitere Stationen: Freie Stationierung + """ + self.cor_points = [] + computed_coords: Dict[str, Tuple[float, float, float]] = {} + + # Referenzlinie finden + ref_line = self.parser.get_reference_line() + + # Stationen sortieren nach Aufnahmezeitpunkt + stations_sorted = sorted(self.parser.stations.items(), + key=lambda x: x[1].timestamp) + + first_station = True + + for station_id, station in stations_sorted: + if station.station_type == 'ReflineStationSetup' and first_station: + # Erste Station mit Referenzlinie + self._compute_refline_station(station_id, station, ref_line, computed_coords) + first_station = False + else: + # Freie Stationierung + self._compute_free_station(station_id, station, computed_coords) + + # COR-Punkte erstellen + for name, (e, n, elev) in computed_coords.items(): + self.cor_points.append(CORPoint( + name=name, + x=e, + y=n, + z=elev + )) + + return self.cor_points + + def _compute_refline_station(self, station_id: str, station: Station, + ref_line, computed_coords: Dict): + """Berechnet Punkte für eine Referenzlinien-Station""" + + # Referenzpunkte definieren das lokale System + if ref_line: + start_name = ref_line.start_point + end_name = ref_line.end_point + + # Startpunkt ist Ursprung (0,0) + if start_name in self.parser.points: + computed_coords[start_name] = (0.0, 0.0, 0.0) + + # Endpunkt liegt auf der Y-Achse (Nord-Richtung) + # Die Distanz muss aus den Messungen berechnet werden + + # Alle Messungen von dieser Station + measurements = self.parser.get_measurements_from_station(station_id) + + # Finde Backbearing für Orientierung + backbearing = None + for bb_id, bb in self.parser.backbearings.items(): + if bb.station_record_id == station_id: + backbearing = bb + break + + # Stationskoordinaten + if station.name in self.parser.points: + st_point = self.parser.points[station.name] + st_e = st_point.east if st_point.east is not None else 0.0 + st_n = st_point.north if st_point.north is not None else 0.0 + st_elev = st_point.elevation if st_point.elevation is not None else 0.0 + + computed_coords[station.name] = (st_e, st_n, st_elev) + + # Punkte aus Messungen berechnen + for meas in measurements: + if meas.name and meas.horizontal_circle is not None and meas.edm_distance is not None: + # Prismenkonstante holen + prism_const = 0.0 + if meas.target_id in self.parser.targets: + prism_const = self.parser.targets[meas.target_id].prism_constant + + # Korrigierte Distanz + dist = meas.edm_distance + prism_const + + # Vertikalwinkel + vz = meas.vertical_circle if meas.vertical_circle is not None else 100.0 + vz_rad = vz * math.pi / 200.0 # Gon zu Radiant + + # Horizontaldistanz + h_dist = dist * math.sin(vz_rad) + + # Höhendifferenz (von Zenitwinkel) + dh = dist * math.cos(vz_rad) + + # Richtung berechnen + hz = meas.horizontal_circle + + # Orientierung anwenden + if backbearing and backbearing.orientation_correction is not None: + ori = backbearing.orientation_correction + else: + ori = 0.0 + + # Azimut berechnen + # Bei Referenzlinie: Der Hz-Kreis zum Backsight definiert die Nordrichtung + azimut_gon = hz + ori + azimut_rad = azimut_gon * math.pi / 200.0 + + # Koordinatenberechnung + de = h_dist * math.sin(azimut_rad) + dn = h_dist * math.cos(azimut_rad) + + # Endkoordinaten + if meas.north is not None and meas.east is not None: + # Verwende berechnete Koordinaten aus JXL + e = meas.east + n = meas.north + elev = meas.elevation if meas.elevation is not None else 0.0 + else: + # Berechne aus Messungen + st_point = self.parser.points.get(station.name) + if st_point: + e = (st_point.east or 0.0) + de + n = (st_point.north or 0.0) + dn + elev = (st_point.elevation or 0.0) + dh + else: + e = de + n = dn + elev = dh + + # Nur hinzufügen wenn noch nicht vorhanden oder neuere Messung + if meas.name not in computed_coords: + computed_coords[meas.name] = (e, n, elev) + + def _compute_free_station(self, station_id: str, station: Station, + computed_coords: Dict): + """Berechnet Punkte für eine freie Stationierung""" + + # Bei freier Stationierung: Station wurde bereits berechnet + # Wir verwenden die Koordinaten aus dem Parser + + measurements = self.parser.get_measurements_from_station(station_id) + + # Stationskoordinaten hinzufügen falls vorhanden + if station.name in self.parser.points: + st_point = self.parser.points[station.name] + if st_point.east is not None and st_point.north is not None: + computed_coords[station.name] = ( + st_point.east, + st_point.north, + st_point.elevation or 0.0 + ) + + # Punkte aus Messungen + for meas in measurements: + if meas.name and meas.north is not None and meas.east is not None: + if meas.name not in computed_coords: + computed_coords[meas.name] = ( + meas.east, + meas.north, + meas.elevation or 0.0 + ) + + def generate_from_computed_grid(self) -> List[CORPoint]: + """ + Generiert COR-Punkte direkt aus den ComputedGrid-Koordinaten der JXL-Datei + """ + self.cor_points = [] + seen_names = set() + + # Referenzlinie finden für Header + ref_line = self.parser.get_reference_line() + + # Sortiere Stationen nach Zeitstempel + stations_sorted = sorted(self.parser.stations.items(), + key=lambda x: x[1].timestamp) + + current_station_header = None + + for station_id, station in stations_sorted: + # Neuer Stationsheader wenn sich die Station ändert + if station.station_type == 'ReflineStationSetup': + if ref_line: + # Header für Referenzlinie-Station + header_point = CORPoint( + name=ref_line.start_point, + x=0.0, y=0.0, z=0.0 + ) + if ref_line.start_point not in seen_names: + self.cor_points.append(header_point) + seen_names.add(ref_line.start_point) + + # Punkte von dieser Station + measurements = self.parser.get_measurements_from_station(station_id) + + for meas in measurements: + if meas.name and meas.name not in seen_names: + if meas.north is not None and meas.east is not None: + self.cor_points.append(CORPoint( + name=meas.name, + x=meas.east, + y=meas.north, + z=meas.elevation or 0.0 + )) + seen_names.add(meas.name) + + # Alle verbleibenden Punkte hinzufügen + for name, point in self.parser.get_active_points().items(): + if name not in seen_names and point.east is not None and point.north is not None: + self.cor_points.append(CORPoint( + name=name, + x=point.east, + y=point.north, + z=point.elevation or 0.0 + )) + seen_names.add(name) + + return self.cor_points + + def write_cor_file(self, output_path: str, include_header: bool = True) -> str: + """Schreibt die COR-Datei""" + lines = [] + + # Sammle eindeutige Stationsstarts für Header + ref_line = self.parser.get_reference_line() + stations_sorted = sorted(self.parser.stations.items(), + key=lambda x: x[1].timestamp) + + current_station_idx = 0 + written_points = set() + + for station_id, station in stations_sorted: + # Header für neue Station (Referenzlinie) + if station.station_type == 'ReflineStationSetup' and ref_line: + if include_header: + # Markdown-Style Header + lines.append(f"|{ref_line.start_point} |0.000 |0.000.1 |0.000.2 |") + lines.append("|----:|----:|----:|----:|") + + # Punkte von dieser Station + measurements = self.parser.get_measurements_from_station(station_id) + for meas in measurements: + if meas.name and meas.name not in written_points: + # Finde den COR-Punkt + for cp in self.cor_points: + if cp.name == meas.name: + lines.append(cp.to_cor_line()) + written_points.add(meas.name) + break + + # Verbleibende Punkte + for cp in self.cor_points: + if cp.name not in written_points: + lines.append(cp.to_cor_line()) + written_points.add(cp.name) + + content = "\n".join(lines) + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) + + return content + + def export_csv(self, output_path: str) -> str: + """Exportiert als CSV-Datei""" + lines = ["Punktname;East;North;Elevation"] + + for cp in self.cor_points: + lines.append(f"{cp.name};{cp.x:.4f};{cp.y:.4f};{cp.z:.4f}") + + content = "\n".join(lines) + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) + + return content + + def export_txt(self, output_path: str, delimiter: str = "\t") -> str: + """Exportiert als TXT-Datei""" + lines = [] + + for cp in self.cor_points: + lines.append(f"{cp.name}{delimiter}{cp.x:.4f}{delimiter}{cp.y:.4f}{delimiter}{cp.z:.4f}") + + content = "\n".join(lines) + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) + + return content + + def export_dxf(self, output_path: str) -> str: + """Exportiert als DXF-Datei (einfaches Format)""" + lines = [] + + # DXF Header + lines.extend([ + "0", "SECTION", + "2", "ENTITIES" + ]) + + # Punkte als POINT und TEXT + for cp in self.cor_points: + # POINT entity + lines.extend([ + "0", "POINT", + "8", "POINTS", # Layer + "10", f"{cp.x:.4f}", # X + "20", f"{cp.y:.4f}", # Y + "30", f"{cp.z:.4f}" # Z + ]) + + # TEXT entity für Punktname + lines.extend([ + "0", "TEXT", + "8", "NAMES", # Layer + "10", f"{cp.x + 0.5:.4f}", # X offset + "20", f"{cp.y + 0.5:.4f}", # Y offset + "30", f"{cp.z:.4f}", # Z + "40", "0.5", # Texthöhe + "1", cp.name # Text + ]) + + # DXF Footer + lines.extend([ + "0", "ENDSEC", + "0", "EOF" + ]) + + content = "\n".join(lines) + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) + + return content + + def get_statistics(self) -> str: + """Gibt Statistiken über die generierten Punkte zurück""" + if not self.cor_points: + return "Keine Punkte generiert." + + x_vals = [p.x for p in self.cor_points] + y_vals = [p.y for p in self.cor_points] + z_vals = [p.z for p in self.cor_points] + + stats = [] + stats.append(f"Anzahl Punkte: {len(self.cor_points)}") + stats.append(f"") + stats.append(f"X (East):") + stats.append(f" Min: {min(x_vals):.3f} m") + stats.append(f" Max: {max(x_vals):.3f} m") + stats.append(f" Spanne: {max(x_vals) - min(x_vals):.3f} m") + stats.append(f"") + stats.append(f"Y (North):") + stats.append(f" Min: {min(y_vals):.3f} m") + stats.append(f" Max: {max(y_vals):.3f} m") + stats.append(f" Spanne: {max(y_vals) - min(y_vals):.3f} m") + stats.append(f"") + stats.append(f"Z (Elevation):") + stats.append(f" Min: {min(z_vals):.3f} m") + stats.append(f" Max: {max(z_vals):.3f} m") + stats.append(f" Spanne: {max(z_vals) - min(z_vals):.3f} m") + + return "\n".join(stats) diff --git a/modules/georeferencing.py b/modules/georeferencing.py new file mode 100644 index 0000000..f05789f --- /dev/null +++ b/modules/georeferencing.py @@ -0,0 +1,369 @@ +""" +Georeferenzierungs-Modul +Transformation mit Passpunkten (mind. 3 Punkte) +NUR Rotation und Translation - KEINE Maßstabsänderung +""" + +import math +import numpy as np +from typing import List, Tuple, Optional, Dict +from dataclasses import dataclass +from .cor_generator import CORPoint + + +@dataclass +class ControlPoint: + """Passpunkt mit Soll- und Ist-Koordinaten""" + name: str + # Ist-Koordinaten (lokales System) + local_x: float + local_y: float + local_z: float + # Soll-Koordinaten (Zielsystem, z.B. UTM) + target_x: float + target_y: float + target_z: float + # Residuen nach Transformation + residual_x: float = 0.0 + residual_y: float = 0.0 + residual_z: float = 0.0 + + @property + def residual_2d(self) -> float: + """2D-Residuum""" + return math.sqrt(self.residual_x**2 + self.residual_y**2) + + @property + def residual_3d(self) -> float: + """3D-Residuum""" + return math.sqrt(self.residual_x**2 + self.residual_y**2 + self.residual_z**2) + + +@dataclass +class GeoreferenceResult: + """Ergebnis der Georeferenzierung""" + # Transformationsparameter + translation_x: float = 0.0 + translation_y: float = 0.0 + translation_z: float = 0.0 + rotation_rad: float = 0.0 + + # Qualitätsparameter + rmse_x: float = 0.0 + rmse_y: float = 0.0 + rmse_z: float = 0.0 + rmse_2d: float = 0.0 + rmse_3d: float = 0.0 + max_residual_2d: float = 0.0 + max_residual_3d: float = 0.0 + + # Passpunkte mit Residuen + control_points: List[ControlPoint] = None + + def __post_init__(self): + if self.control_points is None: + self.control_points = [] + + @property + def rotation_gon(self) -> float: + """Rotation in Gon""" + return self.rotation_rad * 200.0 / math.pi + + @property + def rotation_deg(self) -> float: + """Rotation in Grad""" + return math.degrees(self.rotation_rad) + + +class Georeferencer: + """ + Georeferenzierung mit mindestens 3 Passpunkten + Verwendet Methode der kleinsten Quadrate für Überbestimmung + NUR Rotation und Translation (4 Parameter) + """ + + def __init__(self): + self.control_points: List[ControlPoint] = [] + self.result: Optional[GeoreferenceResult] = None + self.points_to_transform: List[CORPoint] = [] + self.transformed_points: List[CORPoint] = [] + + def add_control_point(self, name: str, + local_x: float, local_y: float, local_z: float, + target_x: float, target_y: float, target_z: float): + """Fügt einen Passpunkt hinzu""" + self.control_points.append(ControlPoint( + name=name, + local_x=local_x, local_y=local_y, local_z=local_z, + target_x=target_x, target_y=target_y, target_z=target_z + )) + + def clear_control_points(self): + """Löscht alle Passpunkte""" + self.control_points = [] + self.result = None + + def set_points_to_transform(self, points: List[CORPoint]): + """Setzt die zu transformierenden Punkte""" + self.points_to_transform = points.copy() + + def compute_transformation(self) -> GeoreferenceResult: + """ + Berechnet die Transformationsparameter + 4-Parameter-Transformation: Rotation + Translation (kein Maßstab) + Methode der kleinsten Quadrate + """ + if len(self.control_points) < 3: + raise ValueError("Mindestens 3 Passpunkte erforderlich!") + + n = len(self.control_points) + + # Koordinaten extrahieren + local_coords = np.array([[cp.local_x, cp.local_y] for cp in self.control_points]) + target_coords = np.array([[cp.target_x, cp.target_y] for cp in self.control_points]) + + # Schwerpunkte berechnen + local_centroid = np.mean(local_coords, axis=0) + target_centroid = np.mean(target_coords, axis=0) + + # Zum Schwerpunkt verschieben + local_centered = local_coords - local_centroid + target_centered = target_coords - target_centroid + + # Rotation berechnen mit SVD (Procrustes ohne Skalierung) + H = local_centered.T @ target_centered + U, S, Vt = np.linalg.svd(H) + + # Rotationsmatrix + R = Vt.T @ U.T + + # Reflexion korrigieren falls nötig + if np.linalg.det(R) < 0: + Vt[-1, :] *= -1 + R = Vt.T @ U.T + + # Rotationswinkel aus Matrix + rotation_rad = math.atan2(R[1, 0], R[0, 0]) + + # Translation berechnen + translation = target_centroid - R @ local_centroid + + # Ergebnis speichern + self.result = GeoreferenceResult( + translation_x=translation[0], + translation_y=translation[1], + rotation_rad=rotation_rad + ) + + # Z-Translation (Mittelwert der Höhendifferenzen) + z_diffs = [cp.target_z - cp.local_z for cp in self.control_points] + self.result.translation_z = np.mean(z_diffs) + + # Residuen berechnen + self._compute_residuals() + + return self.result + + def _compute_residuals(self): + """Berechnet Residuen für alle Passpunkte""" + if self.result is None: + return + + cos_r = math.cos(self.result.rotation_rad) + sin_r = math.sin(self.result.rotation_rad) + + sum_res_x2 = 0.0 + sum_res_y2 = 0.0 + sum_res_z2 = 0.0 + max_res_2d = 0.0 + max_res_3d = 0.0 + + for cp in self.control_points: + # Transformation anwenden + x_trans = cos_r * cp.local_x - sin_r * cp.local_y + self.result.translation_x + y_trans = sin_r * cp.local_x + cos_r * cp.local_y + self.result.translation_y + z_trans = cp.local_z + self.result.translation_z + + # Residuen + cp.residual_x = cp.target_x - x_trans + cp.residual_y = cp.target_y - y_trans + cp.residual_z = cp.target_z - z_trans + + sum_res_x2 += cp.residual_x**2 + sum_res_y2 += cp.residual_y**2 + sum_res_z2 += cp.residual_z**2 + + max_res_2d = max(max_res_2d, cp.residual_2d) + max_res_3d = max(max_res_3d, cp.residual_3d) + + n = len(self.control_points) + self.result.rmse_x = math.sqrt(sum_res_x2 / n) + self.result.rmse_y = math.sqrt(sum_res_y2 / n) + self.result.rmse_z = math.sqrt(sum_res_z2 / n) + self.result.rmse_2d = math.sqrt((sum_res_x2 + sum_res_y2) / n) + self.result.rmse_3d = math.sqrt((sum_res_x2 + sum_res_y2 + sum_res_z2) / n) + self.result.max_residual_2d = max_res_2d + self.result.max_residual_3d = max_res_3d + self.result.control_points = self.control_points + + def transform_points(self) -> List[CORPoint]: + """Transformiert alle Punkte""" + if self.result is None: + raise ValueError("Transformation noch nicht berechnet!") + + self.transformed_points = [] + + cos_r = math.cos(self.result.rotation_rad) + sin_r = math.sin(self.result.rotation_rad) + + for p in self.points_to_transform: + x_trans = cos_r * p.x - sin_r * p.y + self.result.translation_x + y_trans = sin_r * p.x + cos_r * p.y + self.result.translation_y + z_trans = p.z + self.result.translation_z + + self.transformed_points.append(CORPoint( + name=p.name, + x=x_trans, + y=y_trans, + z=z_trans + )) + + return self.transformed_points + + def transform_single_point(self, x: float, y: float, z: float) -> Tuple[float, float, float]: + """Transformiert einen einzelnen Punkt""" + if self.result is None: + raise ValueError("Transformation noch nicht berechnet!") + + cos_r = math.cos(self.result.rotation_rad) + sin_r = math.sin(self.result.rotation_rad) + + x_trans = cos_r * x - sin_r * y + self.result.translation_x + y_trans = sin_r * x + cos_r * y + self.result.translation_y + z_trans = z + self.result.translation_z + + return x_trans, y_trans, z_trans + + def get_transformation_report(self) -> str: + """Erstellt einen detaillierten Transformationsbericht""" + if self.result is None: + return "Keine Transformation berechnet." + + lines = [] + lines.append("=" * 70) + lines.append("GEOREFERENZIERUNG - TRANSFORMATIONSBERICHT") + lines.append("=" * 70) + lines.append("") + lines.append("TRANSFORMATIONSPARAMETER (4-Parameter, ohne Maßstab)") + lines.append("-" * 70) + lines.append(f" Translation X: {self.result.translation_x:>15.4f} m") + lines.append(f" Translation Y: {self.result.translation_y:>15.4f} m") + lines.append(f" Translation Z: {self.result.translation_z:>15.4f} m") + lines.append(f" Rotation: {self.result.rotation_gon:>15.6f} gon") + lines.append(f" {self.result.rotation_deg:>15.6f}°") + lines.append(f" {self.result.rotation_rad:>15.8f} rad") + lines.append(f" Maßstab: {1.0:>15.6f} (fest)") + lines.append("") + lines.append("QUALITÄTSPARAMETER") + lines.append("-" * 70) + lines.append(f" RMSE X: {self.result.rmse_x*1000:>15.2f} mm") + lines.append(f" RMSE Y: {self.result.rmse_y*1000:>15.2f} mm") + lines.append(f" RMSE Z: {self.result.rmse_z*1000:>15.2f} mm") + lines.append(f" RMSE 2D: {self.result.rmse_2d*1000:>15.2f} mm") + lines.append(f" RMSE 3D: {self.result.rmse_3d*1000:>15.2f} mm") + lines.append(f" Max. Residuum 2D: {self.result.max_residual_2d*1000:>15.2f} mm") + lines.append(f" Max. Residuum 3D: {self.result.max_residual_3d*1000:>15.2f} mm") + lines.append("") + lines.append("PASSPUNKTE UND RESIDUEN") + lines.append("-" * 70) + lines.append(f"{'Punkt':<10} {'vX [mm]':>10} {'vY [mm]':>10} {'vZ [mm]':>10} {'v2D [mm]':>10} {'v3D [mm]':>10}") + lines.append("-" * 70) + + for cp in self.result.control_points: + lines.append(f"{cp.name:<10} {cp.residual_x*1000:>10.2f} {cp.residual_y*1000:>10.2f} " + f"{cp.residual_z*1000:>10.2f} {cp.residual_2d*1000:>10.2f} {cp.residual_3d*1000:>10.2f}") + + lines.append("-" * 70) + lines.append(f"Anzahl Passpunkte: {len(self.control_points)}") + lines.append(f"Redundanz (2D): {2 * len(self.control_points) - 4}") + lines.append("") + lines.append("=" * 70) + + return "\n".join(lines) + + def get_control_points_comparison(self) -> str: + """Erstellt eine Vergleichstabelle für Passpunkte""" + if self.result is None: + return "Keine Transformation berechnet." + + lines = [] + lines.append("PASSPUNKT-KOORDINATEN: LOKAL → ZIEL") + lines.append("=" * 100) + lines.append(f"{'Punkt':<8} {'X_lokal':>12} {'Y_lokal':>12} {'Z_lokal':>10} | " + f"{'X_Ziel':>12} {'Y_Ziel':>12} {'Z_Ziel':>10}") + lines.append("-" * 100) + + for cp in self.control_points: + lines.append(f"{cp.name:<8} {cp.local_x:>12.3f} {cp.local_y:>12.3f} {cp.local_z:>10.3f} | " + f"{cp.target_x:>12.3f} {cp.target_y:>12.3f} {cp.target_z:>10.3f}") + + lines.append("=" * 100) + return "\n".join(lines) + + def export_report(self, filepath: str): + """Exportiert den vollständigen Bericht""" + report = self.get_transformation_report() + with open(filepath, 'w', encoding='utf-8') as f: + f.write(report) + + +class RigidBodyTransformation: + """ + Alternative Implementierung: Rigide Körpertransformation + 6 Parameter: 3 Translationen + 3 Rotationen + Für 3D-Daten mit mehreren Rotationsachsen + """ + + def __init__(self): + self.control_points: List[ControlPoint] = [] + self.translation = np.zeros(3) + self.rotation_matrix = np.eye(3) + + def compute(self, local_points: np.ndarray, target_points: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """ + Berechnet rigide Transformation mit Kabsch-Algorithmus + Keine Maßstabsänderung + """ + # Schwerpunkte + centroid_local = np.mean(local_points, axis=0) + centroid_target = np.mean(target_points, axis=0) + + # Zentrieren + local_centered = local_points - centroid_local + target_centered = target_points - centroid_target + + # Kovarianzmatrix + H = local_centered.T @ target_centered + + # SVD + U, S, Vt = np.linalg.svd(H) + + # Rotation + R = Vt.T @ U.T + + # Reflexionskorrektur + if np.linalg.det(R) < 0: + Vt[-1, :] *= -1 + R = Vt.T @ U.T + + # Translation + t = centroid_target - R @ centroid_local + + self.rotation_matrix = R + self.translation = t + + return R, t + + def transform(self, points: np.ndarray) -> np.ndarray: + """Transformiert Punkte""" + return (self.rotation_matrix @ points.T).T + self.translation diff --git a/modules/jxl_parser.py b/modules/jxl_parser.py new file mode 100644 index 0000000..32b5a91 --- /dev/null +++ b/modules/jxl_parser.py @@ -0,0 +1,616 @@ +""" +JXL Parser Module - Trimble JXL-Datei Parser +Liest und analysiert Trimble JXL-Dateien (XML-basiert) +""" + +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from typing import List, Dict, Optional, Tuple +import math +import copy + + +@dataclass +class Point: + """Repräsentiert einen vermessenen Punkt""" + name: str + code: str = "" + method: str = "" + survey_method: str = "" + classification: str = "" + deleted: bool = False + + # Grid-Koordinaten + north: Optional[float] = None + east: Optional[float] = None + elevation: Optional[float] = None + + # Kreis-Messungen (Rohwerte) + horizontal_circle: Optional[float] = None # in Gon + vertical_circle: Optional[float] = None # in Gon + edm_distance: Optional[float] = None # in Meter + face: str = "Face1" + + # Standardabweichungen + hz_std_error: Optional[float] = None + vz_std_error: Optional[float] = None + dist_std_error: Optional[float] = None + + # Referenzen + station_id: str = "" + target_id: str = "" + backsight_id: str = "" + + # Zusätzliche Infos + timestamp: str = "" + record_id: str = "" + pressure: Optional[float] = None + temperature: Optional[float] = None + + +@dataclass +class Station: + """Repräsentiert eine Instrumentenstation""" + name: str + theodolite_height: float = 0.0 + station_type: str = "" # ReflineStationSetup, StandardResection, etc. + instrument_id: str = "" + atmosphere_id: str = "" + scale_factor: float = 1.0 + + # Berechnete Koordinaten + north: Optional[float] = None + east: Optional[float] = None + elevation: Optional[float] = None + + # Orientierung + orientation_correction: Optional[float] = None + + record_id: str = "" + timestamp: str = "" + + +@dataclass +class Target: + """Repräsentiert ein Ziel/Prisma""" + prism_type: str = "" + prism_constant: float = 0.0 + target_height: float = 0.0 + record_id: str = "" + + +@dataclass +class BackBearing: + """Repräsentiert einen Rückwärtseinschnitt""" + station: str = "" + backsight: str = "" + face1_hz: Optional[float] = None + face2_hz: Optional[float] = None + orientation_correction: Optional[float] = None + record_id: str = "" + station_record_id: str = "" + + +@dataclass +class Instrument: + """Repräsentiert ein Vermessungsinstrument""" + instrument_type: str = "" + model: str = "" + serial: str = "" + edm_precision: float = 0.001 + edm_ppm: float = 1.5 + hz_precision: float = 0.0 + vz_precision: float = 0.0 + edm_constant: float = 0.0 + record_id: str = "" + + +@dataclass +class Atmosphere: + """Atmosphärische Bedingungen""" + pressure: float = 1013.25 + temperature: float = 20.0 + ppm: float = 0.0 + refraction_coefficient: float = 0.13 + record_id: str = "" + + +@dataclass +class Line: + """Repräsentiert eine Referenzlinie""" + name: str = "" + start_point: str = "" + end_point: str = "" + start_station: float = 0.0 + + +class JXLParser: + """Parser für Trimble JXL-Dateien""" + + def __init__(self): + self.job_name: str = "" + self.coordinate_system: str = "" + self.zone_name: str = "" + self.datum_name: str = "" + self.angle_units: str = "Gons" # Gons oder Degrees + self.distance_units: str = "Metres" + + self.points: Dict[str, Point] = {} + self.stations: Dict[str, Station] = {} + self.targets: Dict[str, Target] = {} + self.backbearings: Dict[str, BackBearing] = {} + self.instruments: Dict[str, Instrument] = {} + self.atmospheres: Dict[str, Atmosphere] = {} + self.lines: Dict[str, Line] = {} + + # Alle Messungen (auch gelöschte) + self.all_point_records: List[Point] = [] + + self.raw_xml = None + self.file_path: str = "" + + def parse(self, file_path: str) -> bool: + """Parst eine JXL-Datei""" + self.file_path = file_path + try: + tree = ET.parse(file_path) + self.raw_xml = tree + root = tree.getroot() + + # Job-Informationen + self.job_name = root.get('jobName', '') + + # FieldBook durchsuchen + fieldbook = root.find('FieldBook') + if fieldbook is None: + return False + + for element in fieldbook: + self._parse_element(element) + + # Stationskoordinaten aus Punkten zuweisen + self._assign_station_coordinates() + + return True + + except Exception as e: + print(f"Fehler beim Parsen: {e}") + return False + + def _parse_element(self, element): + """Parst ein einzelnes Element""" + tag = element.tag + record_id = element.get('ID', '') + timestamp = element.get('TimeStamp', '') + + if tag == 'CoordinateSystemRecord': + self._parse_coordinate_system(element) + elif tag == 'UnitsRecord': + self._parse_units(element) + elif tag == 'PointRecord': + self._parse_point(element, record_id, timestamp) + elif tag == 'StationRecord': + self._parse_station(element, record_id, timestamp) + elif tag == 'TargetRecord': + self._parse_target(element, record_id) + elif tag == 'BackBearingRecord': + self._parse_backbearing(element, record_id) + elif tag == 'InstrumentRecord': + self._parse_instrument(element, record_id) + elif tag == 'AtmosphereRecord': + self._parse_atmosphere(element, record_id) + elif tag == 'LineRecord': + self._parse_line(element) + + def _parse_coordinate_system(self, element): + """Parst Koordinatensystem-Informationen""" + system = element.find('SystemName') + zone = element.find('ZoneName') + datum = element.find('DatumName') + + if system is not None and system.text: + self.coordinate_system = system.text + if zone is not None and zone.text: + self.zone_name = zone.text + if datum is not None and datum.text: + self.datum_name = datum.text + + def _parse_units(self, element): + """Parst Einheiten""" + angle = element.find('AngleUnits') + distance = element.find('DistanceUnits') + + if angle is not None and angle.text: + self.angle_units = angle.text + if distance is not None and distance.text: + self.distance_units = distance.text + + def _parse_point(self, element, record_id: str, timestamp: str): + """Parst einen Punkt""" + point = Point(name="") + point.record_id = record_id + point.timestamp = timestamp + + # Basisdaten + name_elem = element.find('Name') + point.name = name_elem.text if name_elem is not None and name_elem.text else "" + + code_elem = element.find('Code') + point.code = code_elem.text if code_elem is not None and code_elem.text else "" + + method_elem = element.find('Method') + point.method = method_elem.text if method_elem is not None and method_elem.text else "" + + survey_elem = element.find('SurveyMethod') + point.survey_method = survey_elem.text if survey_elem is not None and survey_elem.text else "" + + class_elem = element.find('Classification') + point.classification = class_elem.text if class_elem is not None and class_elem.text else "" + + deleted_elem = element.find('Deleted') + point.deleted = deleted_elem is not None and deleted_elem.text == 'true' + + # Station und Target IDs + station_id_elem = element.find('StationID') + point.station_id = station_id_elem.text if station_id_elem is not None and station_id_elem.text else "" + + target_id_elem = element.find('TargetID') + point.target_id = target_id_elem.text if target_id_elem is not None and target_id_elem.text else "" + + backsight_id_elem = element.find('BackBearingID') + point.backsight_id = backsight_id_elem.text if backsight_id_elem is not None and backsight_id_elem.text else "" + + # Grid-Koordinaten + grid = element.find('Grid') + if grid is not None: + north = grid.find('North') + east = grid.find('East') + elev = grid.find('Elevation') + + if north is not None and north.text: + try: + point.north = float(north.text) + except ValueError: + pass + if east is not None and east.text: + try: + point.east = float(east.text) + except ValueError: + pass + if elev is not None and elev.text: + try: + point.elevation = float(elev.text) + except ValueError: + pass + + # ComputedGrid-Koordinaten (falls vorhanden) + computed_grid = element.find('ComputedGrid') + if computed_grid is not None: + north = computed_grid.find('North') + east = computed_grid.find('East') + elev = computed_grid.find('Elevation') + + if north is not None and north.text: + try: + point.north = float(north.text) + except ValueError: + pass + if east is not None and east.text: + try: + point.east = float(east.text) + except ValueError: + pass + if elev is not None and elev.text: + try: + point.elevation = float(elev.text) + except ValueError: + pass + + # Kreis-Messungen + circle = element.find('Circle') + if circle is not None: + hz = circle.find('HorizontalCircle') + vz = circle.find('VerticalCircle') + dist = circle.find('EDMDistance') + face = circle.find('Face') + + if hz is not None and hz.text: + point.horizontal_circle = float(hz.text) + if vz is not None and vz.text: + point.vertical_circle = float(vz.text) + if dist is not None and dist.text: + point.edm_distance = float(dist.text) + if face is not None and face.text: + point.face = face.text + + # Standardabweichungen + hz_std = circle.find('HorizontalCircleStandardError') + vz_std = circle.find('VerticalCircleStandardError') + dist_std = circle.find('EDMDistanceStandardError') + + if hz_std is not None and hz_std.text: + point.hz_std_error = float(hz_std.text) + if vz_std is not None and vz_std.text: + point.vz_std_error = float(vz_std.text) + if dist_std is not None and dist_std.text: + point.dist_std_error = float(dist_std.text) + + # Atmosphäre + pressure_elem = element.find('Pressure') + temp_elem = element.find('Temperature') + + if pressure_elem is not None and pressure_elem.text: + point.pressure = float(pressure_elem.text) + if temp_elem is not None and temp_elem.text: + point.temperature = float(temp_elem.text) + + # Zur Liste aller Punkt-Records hinzufügen + self.all_point_records.append(point) + + # Nur nicht-gelöschte Punkte zum Dictionary hinzufügen + # Überschreibe vorhandene Punkte (neuester Wert) + if not point.deleted and point.name: + self.points[point.name] = point + + def _parse_station(self, element, record_id: str, timestamp: str): + """Parst eine Station""" + name_elem = element.find('StationName') + name = name_elem.text if name_elem is not None and name_elem.text else "" + + station = Station(name=name) + station.record_id = record_id + station.timestamp = timestamp + + th_elem = element.find('TheodoliteHeight') + if th_elem is not None and th_elem.text: + station.theodolite_height = float(th_elem.text) + + type_elem = element.find('StationType') + if type_elem is not None and type_elem.text: + station.station_type = type_elem.text + + inst_elem = element.find('InstrumentID') + if inst_elem is not None and inst_elem.text: + station.instrument_id = inst_elem.text + + atm_elem = element.find('AtmosphereID') + if atm_elem is not None and atm_elem.text: + station.atmosphere_id = atm_elem.text + + scale_elem = element.find('ScaleFactor') + if scale_elem is not None and scale_elem.text: + station.scale_factor = float(scale_elem.text) + + ori_elem = element.find('OrientationCorrection') + if ori_elem is not None and ori_elem.text: + station.orientation_correction = float(ori_elem.text) + + self.stations[record_id] = station + + def _parse_target(self, element, record_id: str): + """Parst ein Target/Prisma""" + target = Target() + target.record_id = record_id + + type_elem = element.find('PrismType') + if type_elem is not None and type_elem.text: + target.prism_type = type_elem.text + + const_elem = element.find('PrismConstant') + if const_elem is not None and const_elem.text: + target.prism_constant = float(const_elem.text) + + height_elem = element.find('TargetHeight') + if height_elem is not None and height_elem.text: + target.target_height = float(height_elem.text) + + self.targets[record_id] = target + + def _parse_backbearing(self, element, record_id: str): + """Parst einen Rückwärtseinschnitt""" + bb = BackBearing() + bb.record_id = record_id + + station_elem = element.find('Station') + if station_elem is not None and station_elem.text: + bb.station = station_elem.text + + bs_elem = element.find('BackSight') + if bs_elem is not None and bs_elem.text: + bb.backsight = bs_elem.text + + face1_elem = element.find('Face1HorizontalCircle') + if face1_elem is not None and face1_elem.text: + bb.face1_hz = float(face1_elem.text) + + face2_elem = element.find('Face2HorizontalCircle') + if face2_elem is not None and face2_elem.text: + bb.face2_hz = float(face2_elem.text) + + ori_elem = element.find('OrientationCorrection') + if ori_elem is not None and ori_elem.text: + bb.orientation_correction = float(ori_elem.text) + + station_record_elem = element.find('StationRecordID') + if station_record_elem is not None and station_record_elem.text: + bb.station_record_id = station_record_elem.text + + self.backbearings[record_id] = bb + + def _parse_instrument(self, element, record_id: str): + """Parst ein Instrument""" + inst = Instrument() + inst.record_id = record_id + + type_elem = element.find('Type') + if type_elem is not None and type_elem.text: + inst.instrument_type = type_elem.text + + model_elem = element.find('Model') + if model_elem is not None and model_elem.text: + inst.model = model_elem.text + + serial_elem = element.find('Serial') + if serial_elem is not None and serial_elem.text: + inst.serial = serial_elem.text + + edm_prec_elem = element.find('EDMPrecision') + if edm_prec_elem is not None and edm_prec_elem.text: + inst.edm_precision = float(edm_prec_elem.text) + + edm_ppm_elem = element.find('EDMppm') + if edm_ppm_elem is not None and edm_ppm_elem.text: + inst.edm_ppm = float(edm_ppm_elem.text) + + hz_prec_elem = element.find('HorizontalAnglePrecision') + if hz_prec_elem is not None and hz_prec_elem.text: + inst.hz_precision = float(hz_prec_elem.text) + + vz_prec_elem = element.find('VerticalAnglePrecision') + if vz_prec_elem is not None and vz_prec_elem.text: + inst.vz_precision = float(vz_prec_elem.text) + + edm_const_elem = element.find('InstrumentAppliedEDMConstant') + if edm_const_elem is not None and edm_const_elem.text: + inst.edm_constant = float(edm_const_elem.text) + + self.instruments[record_id] = inst + + def _parse_atmosphere(self, element, record_id: str): + """Parst Atmosphäre-Daten""" + atm = Atmosphere() + atm.record_id = record_id + + press_elem = element.find('Pressure') + if press_elem is not None and press_elem.text: + atm.pressure = float(press_elem.text) + + temp_elem = element.find('Temperature') + if temp_elem is not None and temp_elem.text: + atm.temperature = float(temp_elem.text) + + ppm_elem = element.find('PPM') + if ppm_elem is not None and ppm_elem.text: + atm.ppm = float(ppm_elem.text) + + refr_elem = element.find('RefractionCoefficient') + if refr_elem is not None and refr_elem.text: + atm.refraction_coefficient = float(refr_elem.text) + + self.atmospheres[record_id] = atm + + def _parse_line(self, element): + """Parst eine Referenzlinie""" + line = Line() + + name_elem = element.find('Name') + if name_elem is not None and name_elem.text: + line.name = name_elem.text + + start_elem = element.find('StartPoint') + if start_elem is not None and start_elem.text: + line.start_point = start_elem.text + + end_elem = element.find('EndPoint') + if end_elem is not None and end_elem.text: + line.end_point = end_elem.text + + station_elem = element.find('StartStation') + if station_elem is not None and station_elem.text: + line.start_station = float(station_elem.text) + + if line.name: + self.lines[line.name] = line + + def _assign_station_coordinates(self): + """Weist den Stationen Koordinaten aus berechneten Punkten zu""" + for station_id, station in self.stations.items(): + if station.name in self.points: + point = self.points[station.name] + station.north = point.north + station.east = point.east + station.elevation = point.elevation + + def get_active_points(self) -> Dict[str, Point]: + """Gibt nur aktive (nicht gelöschte) Punkte zurück""" + return {name: p for name, p in self.points.items() if not p.deleted} + + def get_control_points(self) -> Dict[str, Point]: + """Gibt Passpunkte zurück (BackSight klassifiziert)""" + return {name: p for name, p in self.points.items() + if p.classification == 'BackSight' and not p.deleted} + + def get_measurements_for_point(self, point_name: str) -> List[Point]: + """Gibt alle Messungen für einen Punkt zurück""" + return [p for p in self.all_point_records if p.name == point_name] + + def get_measurements_from_station(self, station_id: str) -> List[Point]: + """Gibt alle Messungen von einer Station zurück""" + return [p for p in self.all_point_records + if p.station_id == station_id and not p.deleted] + + def get_prism_constants(self) -> Dict[str, float]: + """Gibt alle verwendeten Prismenkonstanten zurück""" + return {tid: t.prism_constant for tid, t in self.targets.items()} + + def modify_prism_constant(self, target_id: str, new_constant: float): + """Ändert die Prismenkonstante für ein Target""" + if target_id in self.targets: + self.targets[target_id].prism_constant = new_constant + + def remove_point(self, point_name: str): + """Entfernt einen Punkt (markiert als gelöscht)""" + if point_name in self.points: + del self.points[point_name] + + def get_station_list(self) -> List[Tuple[str, str]]: + """Gibt Liste der Stationen mit Typ zurück""" + result = [] + for sid, station in self.stations.items(): + result.append((station.name, station.station_type)) + return result + + def get_reference_line(self) -> Optional[Line]: + """Gibt die erste gefundene Referenzlinie zurück""" + if self.lines: + return list(self.lines.values())[0] + return None + + def gon_to_rad(self, gon: float) -> float: + """Konvertiert Gon zu Radiant""" + return gon * math.pi / 200.0 + + def rad_to_gon(self, rad: float) -> float: + """Konvertiert Radiant zu Gon""" + return rad * 200.0 / math.pi + + def get_summary(self) -> str: + """Gibt eine Zusammenfassung der geladenen Daten zurück""" + summary = [] + summary.append(f"Job: {self.job_name}") + summary.append(f"Koordinatensystem: {self.coordinate_system}") + summary.append(f"Zone: {self.zone_name}") + summary.append(f"Datum: {self.datum_name}") + summary.append(f"Winkeleinheit: {self.angle_units}") + summary.append(f"") + summary.append(f"Anzahl Punkte (aktiv): {len(self.get_active_points())}") + summary.append(f"Anzahl Stationen: {len(self.stations)}") + summary.append(f"Anzahl Messungen gesamt: {len(self.all_point_records)}") + summary.append(f"Anzahl Targets/Prismen: {len(self.targets)}") + summary.append(f"Anzahl Referenzlinien: {len(self.lines)}") + + # Stationsübersicht + summary.append(f"\nStationen:") + for sid, station in self.stations.items(): + summary.append(f" - {station.name}: {station.station_type}") + + # Prismenkonstanten + summary.append(f"\nPrismenkonstanten:") + for tid, target in self.targets.items(): + summary.append(f" - {target.prism_type}: {target.prism_constant*1000:.1f} mm") + + return "\n".join(summary) + + def create_copy(self): + """Erstellt eine tiefe Kopie des Parsers""" + return copy.deepcopy(self) diff --git a/modules/network_adjustment.py b/modules/network_adjustment.py new file mode 100644 index 0000000..56d9883 --- /dev/null +++ b/modules/network_adjustment.py @@ -0,0 +1,633 @@ +""" +Netzausgleichungs-Modul +Methode der kleinsten Quadrate für geodätische Netze +Basiert auf Beobachtungen aus JXL-Dateien +""" + +import math +import numpy as np +from scipy import sparse +from scipy.sparse.linalg import spsolve +from typing import List, Dict, Tuple, Optional, Set +from dataclasses import dataclass, field +from .jxl_parser import JXLParser, Point, Station + + +@dataclass +class Observation: + """Repräsentiert eine geodätische Beobachtung""" + obs_type: str # 'direction', 'distance', 'zenith', 'height_diff' + from_station: str + to_point: str + value: float # Messwert (Gon, Meter, etc.) + std_dev: float # Standardabweichung + residual: float = 0.0 # Verbesserung + weight: float = 1.0 + + def __post_init__(self): + if self.std_dev > 0: + self.weight = 1.0 / (self.std_dev ** 2) + + +@dataclass +class AdjustedPoint: + """Ausgeglichener Punkt mit Genauigkeitsangaben""" + name: str + x: float # East + y: float # North + z: float # Elevation + + # A-priori Koordinaten + x_approx: float = 0.0 + y_approx: float = 0.0 + z_approx: float = 0.0 + + # Korrekturen + dx: float = 0.0 + dy: float = 0.0 + dz: float = 0.0 + + # Standardabweichungen + std_x: float = 0.0 + std_y: float = 0.0 + std_z: float = 0.0 + std_position: float = 0.0 # 2D Punktlagegenauigkeit + + # Konfidenzellipse + semi_major: float = 0.0 + semi_minor: float = 0.0 + orientation: float = 0.0 # in Gon + + # Status + is_fixed: bool = False + + +@dataclass +class AdjustmentResult: + """Ergebnis der Netzausgleichung""" + # Ausgeglichene Punkte + adjusted_points: Dict[str, AdjustedPoint] = field(default_factory=dict) + + # Beobachtungen mit Residuen + observations: List[Observation] = field(default_factory=list) + + # Globale Qualitätsparameter + sigma_0_priori: float = 1.0 + sigma_0_posteriori: float = 0.0 + chi_square: float = 0.0 + redundancy: int = 0 + + # RMSE der Residuen + rmse_directions: float = 0.0 # in mgon + rmse_distances: float = 0.0 # in mm + rmse_zenith: float = 0.0 # in mgon + + # Iterationsinfo + iterations: int = 0 + converged: bool = False + + # Statistiken + num_points: int = 0 + num_fixed_points: int = 0 + num_observations: int = 0 + num_unknowns: int = 0 + + +class NetworkAdjustment: + """ + Netzausgleichung nach der Methode der kleinsten Quadrate + Gauß-Markov-Modell mit Beobachtungsgleichungen + """ + + def __init__(self, parser: JXLParser): + self.parser = parser + self.observations: List[Observation] = [] + self.points: Dict[str, AdjustedPoint] = {} + self.fixed_points: Set[str] = set() + self.result: Optional[AdjustmentResult] = None + + # Konfiguration + self.max_iterations: int = 20 + self.convergence_limit: float = 1e-8 # Meter + self.sigma_0_priori: float = 1.0 + + # Standard-Genauigkeiten (falls nicht aus JXL) + self.default_std_direction: float = 0.0003 # Gon (0.3 mgon) + self.default_std_distance: float = 0.002 # Meter + self.default_std_zenith: float = 0.0003 # Gon + + def extract_observations(self): + """Extrahiert Beobachtungen aus JXL-Daten""" + self.observations = [] + + for station_id, station in self.parser.stations.items(): + # Messungen von dieser Station + measurements = self.parser.get_measurements_from_station(station_id) + + # Backbearing für Orientierung finden + orientation = 0.0 + for bb_id, bb in self.parser.backbearings.items(): + if bb.station_record_id == station_id: + if bb.orientation_correction is not None: + orientation = bb.orientation_correction + break + + for meas in measurements: + if meas.name and not meas.deleted: + # Richtung + if meas.horizontal_circle is not None: + std = meas.hz_std_error if meas.hz_std_error else self.default_std_direction + azimuth = meas.horizontal_circle + orientation + + self.observations.append(Observation( + obs_type='direction', + from_station=station.name, + to_point=meas.name, + value=azimuth, + std_dev=std + )) + + # Strecke + if meas.edm_distance is not None: + std = meas.dist_std_error if meas.dist_std_error else self.default_std_distance + + # Prismenkonstante berücksichtigen + prism_const = 0.0 + if meas.target_id in self.parser.targets: + prism_const = self.parser.targets[meas.target_id].prism_constant + + distance = meas.edm_distance + prism_const + + self.observations.append(Observation( + obs_type='distance', + from_station=station.name, + to_point=meas.name, + value=distance, + std_dev=std + )) + + # Zenitwinkel + if meas.vertical_circle is not None: + std = meas.vz_std_error if meas.vz_std_error else self.default_std_zenith + + self.observations.append(Observation( + obs_type='zenith', + from_station=station.name, + to_point=meas.name, + value=meas.vertical_circle, + std_dev=std + )) + + return self.observations + + def initialize_points(self): + """Initialisiert Näherungskoordinaten aus JXL-Daten""" + self.points = {} + + # Alle aktiven Punkte + for name, point in self.parser.get_active_points().items(): + self.points[name] = AdjustedPoint( + name=name, + x=point.east if point.east is not None else 0.0, + y=point.north if point.north is not None else 0.0, + z=point.elevation if point.elevation is not None else 0.0, + x_approx=point.east if point.east is not None else 0.0, + y_approx=point.north if point.north is not None else 0.0, + z_approx=point.elevation if point.elevation is not None else 0.0 + ) + + # Stationen hinzufügen + for station_id, station in self.parser.stations.items(): + if station.name not in self.points: + self.points[station.name] = AdjustedPoint( + name=station.name, + x=station.east if station.east is not None else 0.0, + y=station.north if station.north is not None else 0.0, + z=station.elevation if station.elevation is not None else 0.0, + x_approx=station.east if station.east is not None else 0.0, + y_approx=station.north if station.north is not None else 0.0, + z_approx=station.elevation if station.elevation is not None else 0.0 + ) + + def set_fixed_point(self, point_name: str): + """Setzt einen Punkt als Festpunkt""" + if point_name in self.points: + self.fixed_points.add(point_name) + self.points[point_name].is_fixed = True + + def set_fixed_points_auto(self): + """Setzt automatisch Festpunkte (Referenzpunkte)""" + # Referenzlinie als Festpunkte + ref_line = self.parser.get_reference_line() + if ref_line: + self.set_fixed_point(ref_line.start_point) + self.set_fixed_point(ref_line.end_point) + + # Zusätzlich: Erste Station als Festpunkt falls keine Referenzlinie + if not self.fixed_points: + stations = list(self.parser.stations.values()) + if stations: + first_station = min(stations, key=lambda s: s.timestamp) + self.set_fixed_point(first_station.name) + + def adjust(self) -> AdjustmentResult: + """ + Führt die Netzausgleichung durch + Iterative Lösung nach Gauß-Newton + """ + if not self.observations: + self.extract_observations() + + if not self.points: + self.initialize_points() + + if not self.fixed_points: + self.set_fixed_points_auto() + + # Unbekannte bestimmen (nur nicht-fixierte Punkte) + unknown_points = [name for name in self.points.keys() + if name not in self.fixed_points] + + num_unknowns = len(unknown_points) * 2 # Nur X, Y (2D-Ausgleichung) + num_observations = len([o for o in self.observations + if o.obs_type in ['direction', 'distance']]) + + if num_unknowns == 0: + raise ValueError("Keine unbekannten Punkte!") + + if num_observations < num_unknowns: + raise ValueError(f"Unterbestimmtes System: {num_observations} Beobachtungen, " + f"{num_unknowns} Unbekannte") + + # Index-Mapping für Unbekannte + point_index = {name: i for i, name in enumerate(unknown_points)} + + # Iterative Lösung + converged = False + iteration = 0 + + while not converged and iteration < self.max_iterations: + iteration += 1 + + # Designmatrix A und Beobachtungsvektor l erstellen + A, l, P = self._build_normal_equations(point_index, num_unknowns) + + # Normalgleichungssystem: N = A^T * P * A, n = A^T * P * l + N = A.T @ np.diag(P) @ A + n = A.T @ np.diag(P) @ l + + # Lösung: x = N^-1 * n + try: + dx = np.linalg.solve(N, n) + except np.linalg.LinAlgError: + # Regularisierung bei singulärer Matrix + N += np.eye(num_unknowns) * 1e-10 + dx = np.linalg.solve(N, n) + + # Koordinaten aktualisieren + max_correction = 0.0 + for name, idx in point_index.items(): + i = idx * 2 + self.points[name].dx = dx[i] + self.points[name].dy = dx[i + 1] + self.points[name].x += dx[i] + self.points[name].y += dx[i + 1] + + max_correction = max(max_correction, + abs(dx[i]), abs(dx[i + 1])) + + # Konvergenzprüfung + if max_correction < self.convergence_limit: + converged = True + + # Nachbearbeitung + self._compute_residuals() + self._compute_accuracy(point_index, num_unknowns) + + # Ergebnis zusammenstellen + self.result = AdjustmentResult( + adjusted_points=self.points, + observations=self.observations, + sigma_0_priori=self.sigma_0_priori, + iterations=iteration, + converged=converged, + num_points=len(self.points), + num_fixed_points=len(self.fixed_points), + num_observations=num_observations, + num_unknowns=num_unknowns, + redundancy=num_observations - num_unknowns + ) + + self._compute_global_statistics() + + return self.result + + def _build_normal_equations(self, point_index: Dict[str, int], + num_unknowns: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Erstellt Designmatrix A und Beobachtungsvektor l""" + + # Nur Richtungen und Strecken für 2D-Ausgleichung + relevant_obs = [o for o in self.observations + if o.obs_type in ['direction', 'distance']] + + n_obs = len(relevant_obs) + A = np.zeros((n_obs, num_unknowns)) + l = np.zeros(n_obs) + P = np.zeros(n_obs) + + for i, obs in enumerate(relevant_obs): + from_pt = self.points.get(obs.from_station) + to_pt = self.points.get(obs.to_point) + + if from_pt is None or to_pt is None: + continue + + dx = to_pt.x - from_pt.x + dy = to_pt.y - from_pt.y + dist = math.sqrt(dx**2 + dy**2) + + if dist < 1e-10: + continue + + from_idx = point_index.get(obs.from_station) + to_idx = point_index.get(obs.to_point) + + if obs.obs_type == 'direction': + # Azimut berechnen + azimuth_calc = math.atan2(dx, dy) * 200.0 / math.pi + if azimuth_calc < 0: + azimuth_calc += 400.0 + + # Partielle Ableitungen (in Gon) + # dAz/dx = dy / (rho * s^2) + # dAz/dy = -dx / (rho * s^2) + rho = 200.0 / math.pi + factor = rho / (dist ** 2) + + if from_idx is not None: + A[i, from_idx * 2] = -dy * factor # dAz/dx_from + A[i, from_idx * 2 + 1] = dx * factor # dAz/dy_from + + if to_idx is not None: + A[i, to_idx * 2] = dy * factor # dAz/dx_to + A[i, to_idx * 2 + 1] = -dx * factor # dAz/dy_to + + # Verkürzung l = beobachtet - berechnet + diff = obs.value - azimuth_calc + # Normalisierung auf [-200, 200] Gon + while diff > 200: + diff -= 400 + while diff < -200: + diff += 400 + l[i] = diff + + elif obs.obs_type == 'distance': + # Partielle Ableitungen + # ds/dx = dx/s + # ds/dy = dy/s + + if from_idx is not None: + A[i, from_idx * 2] = -dx / dist + A[i, from_idx * 2 + 1] = -dy / dist + + if to_idx is not None: + A[i, to_idx * 2] = dx / dist + A[i, to_idx * 2 + 1] = dy / dist + + # Verkürzung + l[i] = obs.value - dist + + # Gewicht + P[i] = obs.weight + + return A, l, P + + def _compute_residuals(self): + """Berechnet Verbesserungen (Residuen) für alle Beobachtungen""" + for obs in self.observations: + from_pt = self.points.get(obs.from_station) + to_pt = self.points.get(obs.to_point) + + if from_pt is None or to_pt is None: + continue + + dx = to_pt.x - from_pt.x + dy = to_pt.y - from_pt.y + dz = to_pt.z - from_pt.z + dist_2d = math.sqrt(dx**2 + dy**2) + dist_3d = math.sqrt(dx**2 + dy**2 + dz**2) + + if obs.obs_type == 'direction': + azimuth_calc = math.atan2(dx, dy) * 200.0 / math.pi + if azimuth_calc < 0: + azimuth_calc += 400.0 + + residual = obs.value - azimuth_calc + while residual > 200: + residual -= 400 + while residual < -200: + residual += 400 + obs.residual = residual + + elif obs.obs_type == 'distance': + obs.residual = obs.value - dist_3d + + elif obs.obs_type == 'zenith': + if dist_2d > 0: + zenith_calc = math.atan2(dist_2d, dz) * 200.0 / math.pi + obs.residual = obs.value - zenith_calc + + def _compute_accuracy(self, point_index: Dict[str, int], num_unknowns: int): + """Berechnet Genauigkeitsmaße für ausgeglichene Punkte""" + # Vereinfachte Berechnung der Standardabweichungen + # Vollständige Varianzfortpflanzung würde Inverse von N erfordern + + # Erstelle Normalgleichungsmatrix erneut + A, l, P = self._build_normal_equations(point_index, num_unknowns) + N = A.T @ np.diag(P) @ A + + try: + Q = np.linalg.inv(N) # Kofaktormatrix + except np.linalg.LinAlgError: + Q = np.eye(num_unknowns) * 0.001 + + # A-posteriori Varianzfaktor + v = A @ np.zeros(num_unknowns) - l # Residuen (vereinfacht) + redundancy = len(l) - num_unknowns + + if redundancy > 0: + sum_pvv = np.sum(P * v**2) + sigma_0_post = math.sqrt(sum_pvv / redundancy) + else: + sigma_0_post = self.sigma_0_priori + + self.sigma_0_posteriori = sigma_0_post + + # Punktgenauigkeiten + for name, idx in point_index.items(): + i = idx * 2 + + # Standardabweichungen + self.points[name].std_x = sigma_0_post * math.sqrt(abs(Q[i, i])) + self.points[name].std_y = sigma_0_post * math.sqrt(abs(Q[i + 1, i + 1])) + + # Punktlagegenauigkeit + self.points[name].std_position = math.sqrt( + self.points[name].std_x**2 + self.points[name].std_y**2 + ) + + # Konfidenzellipse (vereinfacht) + # Vollständige Berechnung würde Eigenwertanalyse erfordern + cov_xy = Q[i, i + 1] if i + 1 < num_unknowns else 0 + var_x = Q[i, i] + var_y = Q[i + 1, i + 1] + + # Eigenwerte für Ellipse + trace = var_x + var_y + det = var_x * var_y - cov_xy**2 + discriminant = trace**2 / 4 - det + + if discriminant >= 0: + sqrt_disc = math.sqrt(discriminant) + lambda1 = trace / 2 + sqrt_disc + lambda2 = trace / 2 - sqrt_disc + + self.points[name].semi_major = sigma_0_post * math.sqrt(max(lambda1, 0)) + self.points[name].semi_minor = sigma_0_post * math.sqrt(max(lambda2, 0)) + + if abs(var_x - var_y) > 1e-10: + self.points[name].orientation = 0.5 * math.atan2(2 * cov_xy, var_x - var_y) * 200 / math.pi + + def _compute_global_statistics(self): + """Berechnet globale Statistiken""" + if self.result is None: + return + + # RMSE für verschiedene Beobachtungstypen + dir_residuals = [o.residual for o in self.observations if o.obs_type == 'direction'] + dist_residuals = [o.residual for o in self.observations if o.obs_type == 'distance'] + zen_residuals = [o.residual for o in self.observations if o.obs_type == 'zenith'] + + if dir_residuals: + self.result.rmse_directions = math.sqrt(sum(r**2 for r in dir_residuals) / len(dir_residuals)) * 1000 # mgon + + if dist_residuals: + self.result.rmse_distances = math.sqrt(sum(r**2 for r in dist_residuals) / len(dist_residuals)) * 1000 # mm + + if zen_residuals: + self.result.rmse_zenith = math.sqrt(sum(r**2 for r in zen_residuals) / len(zen_residuals)) * 1000 # mgon + + self.result.sigma_0_posteriori = self.sigma_0_posteriori + + # Chi-Quadrat-Test + if self.result.redundancy > 0: + self.result.chi_square = (self.sigma_0_posteriori / self.sigma_0_priori) ** 2 * self.result.redundancy + + def get_adjustment_report(self) -> str: + """Erstellt einen detaillierten Ausgleichungsbericht""" + if self.result is None: + return "Keine Ausgleichung durchgeführt." + + lines = [] + lines.append("=" * 80) + lines.append("NETZAUSGLEICHUNG - ERGEBNISBERICHT") + lines.append("=" * 80) + lines.append("") + + # Allgemeine Informationen + lines.append("ALLGEMEINE INFORMATIONEN") + lines.append("-" * 80) + lines.append(f"Job: {self.parser.job_name}") + lines.append(f"Anzahl Punkte: {self.result.num_points}") + lines.append(f" davon Festpunkte: {self.result.num_fixed_points}") + lines.append(f" davon Neupunkte: {self.result.num_points - self.result.num_fixed_points}") + lines.append(f"Anzahl Beobachtungen: {self.result.num_observations}") + lines.append(f"Anzahl Unbekannte: {self.result.num_unknowns}") + lines.append(f"Redundanz: {self.result.redundancy}") + lines.append(f"Iterationen: {self.result.iterations}") + lines.append(f"Konvergiert: {'Ja' if self.result.converged else 'Nein'}") + lines.append("") + + # Qualitätsparameter + lines.append("GLOBALE QUALITÄTSPARAMETER") + lines.append("-" * 80) + lines.append(f"Sigma-0 a-priori: {self.sigma_0_priori:.4f}") + lines.append(f"Sigma-0 a-posteriori: {self.result.sigma_0_posteriori:.4f}") + lines.append(f"Chi-Quadrat: {self.result.chi_square:.2f}") + lines.append(f"RMSE Richtungen: {self.result.rmse_directions:.2f} mgon") + lines.append(f"RMSE Strecken: {self.result.rmse_distances:.2f} mm") + lines.append(f"RMSE Zenitwinkel: {self.result.rmse_zenith:.2f} mgon") + lines.append("") + + # Festpunkte + lines.append("FESTPUNKTE") + lines.append("-" * 80) + lines.append(f"{'Punkt':<12} {'X [m]':>14} {'Y [m]':>14} {'Z [m]':>12}") + lines.append("-" * 80) + for name in self.fixed_points: + if name in self.points: + p = self.points[name] + lines.append(f"{name:<12} {p.x:>14.4f} {p.y:>14.4f} {p.z:>12.4f}") + lines.append("") + + # Ausgeglichene Koordinaten + lines.append("AUSGEGLICHENE KOORDINATEN (NEUPUNKTE)") + lines.append("-" * 80) + lines.append(f"{'Punkt':<12} {'X [m]':>14} {'Y [m]':>14} {'Z [m]':>12} {'σX [mm]':>10} {'σY [mm]':>10} {'σPos [mm]':>10}") + lines.append("-" * 80) + + for name, p in sorted(self.points.items()): + if name not in self.fixed_points: + lines.append(f"{name:<12} {p.x:>14.4f} {p.y:>14.4f} {p.z:>12.4f} " + f"{p.std_x*1000:>10.2f} {p.std_y*1000:>10.2f} {p.std_position*1000:>10.2f}") + lines.append("") + + # Beobachtungen und Residuen + lines.append("BEOBACHTUNGEN UND VERBESSERUNGEN") + lines.append("-" * 80) + lines.append(f"{'Von':<10} {'Nach':<10} {'Typ':<10} {'Messwert':>14} {'Residuum':>12}") + lines.append("-" * 80) + + for obs in sorted(self.observations, key=lambda x: (x.from_station, x.to_point)): + if obs.obs_type == 'direction': + unit = "gon" + res_str = f"{obs.residual*1000:.2f} mgon" + elif obs.obs_type == 'distance': + unit = "m" + res_str = f"{obs.residual*1000:.2f} mm" + elif obs.obs_type == 'zenith': + unit = "gon" + res_str = f"{obs.residual*1000:.2f} mgon" + else: + unit = "" + res_str = f"{obs.residual:.4f}" + + lines.append(f"{obs.from_station:<10} {obs.to_point:<10} {obs.obs_type:<10} " + f"{obs.value:>12.4f} {unit:<2} {res_str:>12}") + + lines.append("") + lines.append("=" * 80) + + return "\n".join(lines) + + def export_adjusted_points(self, filepath: str, format: str = 'csv'): + """Exportiert ausgeglichene Punkte""" + if format == 'csv': + lines = ["Punkt;X;Y;Z;Sigma_X;Sigma_Y;Sigma_Pos;Festpunkt"] + for name, p in sorted(self.points.items()): + fixed = "Ja" if p.is_fixed else "Nein" + lines.append(f"{name};{p.x:.4f};{p.y:.4f};{p.z:.4f};" + f"{p.std_x*1000:.2f};{p.std_y*1000:.2f};{p.std_position*1000:.2f};{fixed}") + else: + lines = [] + for name, p in sorted(self.points.items()): + lines.append(f"{name}\t{p.x:.4f}\t{p.y:.4f}\t{p.z:.4f}") + + with open(filepath, 'w', encoding='utf-8') as f: + f.write("\n".join(lines)) + + def export_report(self, filepath: str): + """Exportiert den vollständigen Bericht""" + report = self.get_adjustment_report() + with open(filepath, 'w', encoding='utf-8') as f: + f.write(report) diff --git a/modules/transformation.py b/modules/transformation.py new file mode 100644 index 0000000..e0da406 --- /dev/null +++ b/modules/transformation.py @@ -0,0 +1,321 @@ +""" +Koordinatentransformations-Modul +Unterstützt Rotation, Translation in XY und Z +KEINE Maßstabsänderung (wie vom Benutzer gefordert) +""" + +import math +import numpy as np +from typing import List, Tuple, Optional, Dict +from dataclasses import dataclass +from .cor_generator import CORPoint + + +@dataclass +class TransformationParameters: + """Parameter für die Koordinatentransformation""" + # Translation + dx: float = 0.0 # Verschiebung in X (East) + dy: float = 0.0 # Verschiebung in Y (North) + dz: float = 0.0 # Verschiebung in Z (Höhe) + + # Rotation um Z-Achse (in Gon) + rotation_gon: float = 0.0 + + # Drehpunkt (für Rotation) + pivot_x: float = 0.0 + pivot_y: float = 0.0 + + def rotation_rad(self) -> float: + """Gibt Rotation in Radiant zurück""" + return self.rotation_gon * math.pi / 200.0 + + +class CoordinateTransformer: + """Transformiert Koordinaten: Rotation und Translation""" + + def __init__(self): + self.params = TransformationParameters() + self.original_points: List[CORPoint] = [] + self.transformed_points: List[CORPoint] = [] + + def set_points(self, points: List[CORPoint]): + """Setzt die zu transformierenden Punkte""" + self.original_points = points.copy() + self.transformed_points = [] + + def set_manual_parameters(self, dx: float, dy: float, dz: float, + rotation_gon: float, pivot_x: float = 0.0, + pivot_y: float = 0.0): + """Setzt Transformationsparameter manuell""" + self.params.dx = dx + self.params.dy = dy + self.params.dz = dz + self.params.rotation_gon = rotation_gon + self.params.pivot_x = pivot_x + self.params.pivot_y = pivot_y + + def compute_from_two_points(self, + point1_name: str, + point2_name: str, + z_reference_name: Optional[str] = None) -> bool: + """ + Berechnet Transformation aus zwei Punkten: + - point1 wird zum Ursprung (0,0) + - point2 definiert die Y-Richtung (Nordrichtung) + - z_reference (optional) definiert Z=0 + """ + # Finde Punkte + point1 = None + point2 = None + z_ref = None + + for p in self.original_points: + if p.name == point1_name: + point1 = p + elif p.name == point2_name: + point2 = p + if z_reference_name and p.name == z_reference_name: + z_ref = p + + if point1 is None or point2 is None: + return False + + # Drehpunkt ist point1 + self.params.pivot_x = point1.x + self.params.pivot_y = point1.y + + # Translation: point1 zum Ursprung + self.params.dx = -point1.x + self.params.dy = -point1.y + + # Rotation: point2 soll auf der positiven Y-Achse liegen + # Berechne Richtung von point1 zu point2 + dx_12 = point2.x - point1.x + dy_12 = point2.y - point1.y + + # Aktueller Winkel zur Y-Achse (Nordrichtung) + current_angle_rad = math.atan2(dx_12, dy_12) # atan2(x,y) für Azimut + + # Rotation um diesen Winkel (negativ, um auf Y-Achse zu drehen) + self.params.rotation_gon = -current_angle_rad * 200.0 / math.pi + + # Z-Verschiebung + if z_ref: + self.params.dz = -z_ref.z + else: + self.params.dz = -point1.z + + return True + + def transform(self) -> List[CORPoint]: + """Führt die Transformation durch""" + self.transformed_points = [] + + rot_rad = self.params.rotation_rad() + cos_r = math.cos(rot_rad) + sin_r = math.sin(rot_rad) + + for p in self.original_points: + # 1. Zum Drehpunkt verschieben + x_shifted = p.x - self.params.pivot_x + y_shifted = p.y - self.params.pivot_y + + # 2. Rotation anwenden + x_rotated = x_shifted * cos_r - y_shifted * sin_r + y_rotated = x_shifted * sin_r + y_shifted * cos_r + + # 3. Translation anwenden + x_final = x_rotated + self.params.pivot_x + self.params.dx + y_final = y_rotated + self.params.pivot_y + self.params.dy + z_final = p.z + self.params.dz + + self.transformed_points.append(CORPoint( + name=p.name, + x=x_final, + y=y_final, + z=z_final + )) + + return self.transformed_points + + def transform_single_point(self, x: float, y: float, z: float) -> Tuple[float, float, float]: + """Transformiert einen einzelnen Punkt""" + rot_rad = self.params.rotation_rad() + cos_r = math.cos(rot_rad) + sin_r = math.sin(rot_rad) + + # Zum Drehpunkt verschieben + x_shifted = x - self.params.pivot_x + y_shifted = y - self.params.pivot_y + + # Rotation + x_rotated = x_shifted * cos_r - y_shifted * sin_r + y_rotated = x_shifted * sin_r + y_shifted * cos_r + + # Translation + x_final = x_rotated + self.params.pivot_x + self.params.dx + y_final = y_rotated + self.params.pivot_y + self.params.dy + z_final = z + self.params.dz + + return x_final, y_final, z_final + + def inverse_transform(self, x: float, y: float, z: float) -> Tuple[float, float, float]: + """Inverse Transformation""" + # Inverse Translation + x_inv = x - self.params.dx - self.params.pivot_x + y_inv = y - self.params.dy - self.params.pivot_y + z_inv = z - self.params.dz + + # Inverse Rotation + rot_rad = -self.params.rotation_rad() + cos_r = math.cos(rot_rad) + sin_r = math.sin(rot_rad) + + x_rotated = x_inv * cos_r - y_inv * sin_r + y_rotated = x_inv * sin_r + y_inv * cos_r + + # Zurück vom Drehpunkt + x_final = x_rotated + self.params.pivot_x + y_final = y_rotated + self.params.pivot_y + + return x_final, y_final, z_inv + + def get_transformation_matrix(self) -> np.ndarray: + """Gibt die 4x4 Transformationsmatrix zurück""" + rot_rad = self.params.rotation_rad() + cos_r = math.cos(rot_rad) + sin_r = math.sin(rot_rad) + + # Translation zum Drehpunkt + T1 = np.array([ + [1, 0, 0, -self.params.pivot_x], + [0, 1, 0, -self.params.pivot_y], + [0, 0, 1, 0], + [0, 0, 0, 1] + ]) + + # Rotation + R = np.array([ + [cos_r, -sin_r, 0, 0], + [sin_r, cos_r, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ]) + + # Translation zurück und zusätzliche Verschiebung + T2 = np.array([ + [1, 0, 0, self.params.pivot_x + self.params.dx], + [0, 1, 0, self.params.pivot_y + self.params.dy], + [0, 0, 1, self.params.dz], + [0, 0, 0, 1] + ]) + + return T2 @ R @ T1 + + def get_parameters_report(self) -> str: + """Gibt einen Bericht über die Transformationsparameter zurück""" + report = [] + report.append("=" * 50) + report.append("KOORDINATENTRANSFORMATION - PARAMETER") + report.append("=" * 50) + report.append("") + report.append("Translation:") + report.append(f" dX (East): {self.params.dx:+.4f} m") + report.append(f" dY (North): {self.params.dy:+.4f} m") + report.append(f" dZ (Höhe): {self.params.dz:+.4f} m") + report.append("") + report.append("Rotation:") + report.append(f" Winkel: {self.params.rotation_gon:+.6f} gon") + report.append(f" Winkel: {self.params.rotation_gon * 0.9:+.6f}°") + report.append(f" Winkel: {self.params.rotation_rad():+.8f} rad") + report.append("") + report.append("Drehpunkt:") + report.append(f" X: {self.params.pivot_x:.4f} m") + report.append(f" Y: {self.params.pivot_y:.4f} m") + report.append("") + report.append("Hinweis: Keine Maßstabsänderung (Maßstab = 1.0)") + report.append("=" * 50) + + return "\n".join(report) + + def get_comparison_table(self) -> str: + """Erstellt eine Vergleichstabelle Original vs. Transformiert""" + if not self.original_points or not self.transformed_points: + return "Keine Daten verfügbar." + + lines = [] + lines.append("=" * 90) + lines.append("KOORDINATENVERGLEICH: ORIGINAL → TRANSFORMIERT") + lines.append("=" * 90) + lines.append(f"{'Punkt':<10} {'X_orig':>12} {'Y_orig':>12} {'Z_orig':>10} | " + f"{'X_trans':>12} {'Y_trans':>12} {'Z_trans':>10}") + lines.append("-" * 90) + + for orig, trans in zip(self.original_points, self.transformed_points): + lines.append(f"{orig.name:<10} {orig.x:>12.4f} {orig.y:>12.4f} {orig.z:>10.4f} | " + f"{trans.x:>12.4f} {trans.y:>12.4f} {trans.z:>10.4f}") + + lines.append("=" * 90) + return "\n".join(lines) + + +class LocalSystemTransformer: + """ + Spezielle Transformation für lokale Systeme + Transformiert in ein System mit definiertem Ursprung und Ausrichtung + """ + + def __init__(self): + self.origin_point: Optional[str] = None + self.direction_point: Optional[str] = None + self.z_reference_point: Optional[str] = None + self.transformer = CoordinateTransformer() + + def setup_local_system(self, points: List[CORPoint], + origin_name: str, + direction_name: str, + z_ref_name: Optional[str] = None) -> bool: + """ + Richtet ein lokales Koordinatensystem ein: + - origin_name: Punkt bei (0,0) + - direction_name: Punkt definiert Y-Richtung (liegt auf positiver Y-Achse) + - z_ref_name: Punkt definiert Z=0 + """ + self.origin_point = origin_name + self.direction_point = direction_name + self.z_reference_point = z_ref_name + + self.transformer.set_points(points) + + success = self.transformer.compute_from_two_points( + origin_name, + direction_name, + z_ref_name + ) + + if success: + self.transformer.transform() + + return success + + def get_transformed_points(self) -> List[CORPoint]: + """Gibt die transformierten Punkte zurück""" + return self.transformer.transformed_points + + def get_report(self) -> str: + """Gibt einen vollständigen Bericht zurück""" + report = [] + report.append("=" * 60) + report.append("LOKALES KOORDINATENSYSTEM") + report.append("=" * 60) + report.append(f"Ursprung (0,0): {self.origin_point}") + report.append(f"Y-Richtung: {self.direction_point}") + if self.z_reference_point: + report.append(f"Z-Referenz (0): {self.z_reference_point}") + report.append("") + report.append(self.transformer.get_parameters_report()) + report.append("") + report.append(self.transformer.get_comparison_table()) + + return "\n".join(report)