166 lines
5.7 KiB
Python
166 lines
5.7 KiB
Python
"""resync_csv.py
|
||
|
||
Entfernt aus einem 'reworked' Scope-CSV alle Daten, in denen die Achse
|
||
stillsteht, und schreibt ein NEUES CSV-File. Das Ursprungsfile wird
|
||
nicht geloescht oder ueberschrieben.
|
||
|
||
Wichtig: Die Stromspalte ('current_mA') hat eine andere Zeitachse
|
||
('Current Time [ms]', Sampling 10 ms) als die Achsenspalten
|
||
('Time [ms]', Sampling 2 ms). Beide Zeitspalten bleiben in jeder
|
||
Zeile unveraendert erhalten, damit der Stromkanal weiterhin auf seiner
|
||
eigenen Zeitbasis referenziert ist. Der Stillstand wird ausschließlich
|
||
aus der ACTPOS-Spalte erkannt.
|
||
|
||
Stillstand-Erkennung:
|
||
Eine Zeile gilt als stillstehend, wenn die ACTPOS-Spanne
|
||
(max - min) in einem zentrierten Fenster um diese Zeile
|
||
(Standard: 100 ms) unterhalb der Schwelle (Standard: 0.001) liegt.
|
||
|
||
Verwendung:
|
||
python resync_csv.py <Pfad_zu_csv> [--threshold 0.001] [--window-ms 100]
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import sys
|
||
|
||
import pandas as pd
|
||
|
||
TIME_COL = "Time [ms]"
|
||
POS_COL = "ACTPOS"
|
||
OUTPUT_SUFFIX = "_resynced"
|
||
|
||
|
||
# --------------------------------------------------
|
||
# CSV einlesen (wie im Scope-Viewer)
|
||
# --------------------------------------------------
|
||
def load_reworked_csv(path):
|
||
"""Liest den 'reworked' Scope-Export (Semikolon-getrennt) ein.
|
||
|
||
Zeilen ohne numerischen Zeitwert (z.B. 'EOF') werden verworfen,
|
||
leere Signalzellen werden NaN.
|
||
"""
|
||
df = pd.read_csv(path, sep=";", low_memory=False)
|
||
df.columns = [str(c).strip() for c in df.columns]
|
||
|
||
if TIME_COL not in df.columns:
|
||
raise ValueError(f"Spalte '{TIME_COL}' nicht gefunden in {os.path.basename(path)}")
|
||
if POS_COL not in df.columns:
|
||
raise ValueError(f"Spalte '{POS_COL}' nicht gefunden in {os.path.basename(path)}")
|
||
|
||
df[TIME_COL] = pd.to_numeric(df[TIME_COL], errors="coerce")
|
||
df = df.dropna(subset=[TIME_COL])
|
||
|
||
for col in df.columns:
|
||
if col != TIME_COL:
|
||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||
|
||
if df.empty:
|
||
raise ValueError(f"Keine Daten in {path}")
|
||
|
||
return df
|
||
|
||
|
||
# --------------------------------------------------
|
||
# Stillstand-Erkennung
|
||
# --------------------------------------------------
|
||
def find_moving_mask(df, window_ms, threshold, sample_ms=2.0):
|
||
"""Gibt Boolesche Maske zurueck: True = Achse bewegt sich.
|
||
|
||
Basis ist ausschliesslich ACTPOS (2-ms-Kanal). Der Stromkanal wird
|
||
nicht betrachtet, da er auf einer eigenen 10-ms-Zeitachse liegt.
|
||
|
||
Eine Zeile gilt als bewegend, wenn die ACTPOS-Spanne (max-min) im
|
||
zentrierten Fenster um die Zeile die Schwelle erreicht. Zeilen
|
||
direkt am Rand einer Bewegung (halb Fensterbreite) bleiben daher
|
||
erhalten; ausgedehnte Stillstandsphasen werden komplett entfernt.
|
||
"""
|
||
window = max(1, int(round(window_ms / sample_ms)))
|
||
pos = df[POS_COL]
|
||
|
||
rmax = pos.rolling(2 * window + 1, center=True, min_periods=1).max()
|
||
rmin = pos.rolling(2 * window + 1, center=True, min_periods=1).min()
|
||
|
||
return (rmax - rmin) >= threshold
|
||
|
||
|
||
# --------------------------------------------------
|
||
# Ausgabe
|
||
# --------------------------------------------------
|
||
def unique_output_path(src_path):
|
||
"""Zielt auf <Stem>_resynced.csv, bei Belegung zahlt hoch (1, 2, ...)."""
|
||
directory = os.path.dirname(os.path.abspath(src_path))
|
||
stem = os.path.splitext(os.path.basename(src_path))[0]
|
||
|
||
candidate = os.path.join(directory, f"{stem}{OUTPUT_SUFFIX}.csv")
|
||
counter = 1
|
||
while os.path.exists(candidate):
|
||
candidate = os.path.join(directory, f"{stem}{OUTPUT_SUFFIX}_{counter}.csv")
|
||
counter += 1
|
||
return candidate
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Entfernt Stillstandsdaten aus einem reworked Scope-CSV "
|
||
"und schreibt ein neues CSV (Ursprungsfile bleibt unverändert)."
|
||
)
|
||
parser.add_argument("csv", help="Pfad zum Quelldatei (.csv)")
|
||
parser.add_argument(
|
||
"--threshold", type=float, default=1e-3,
|
||
help="Minimale ACTPOS-Spanne im Fenster, ab der Bewegung gilt "
|
||
"(Standard: 0.001)"
|
||
)
|
||
parser.add_argument(
|
||
"--window-ms", type=float, default=100.0,
|
||
help="Breite des zentrierten Erkennungs-Fensters in ms (Standard: 100)"
|
||
)
|
||
parser.add_argument(
|
||
"--output",
|
||
help="Optionaler Ziel-Pfad. Standard: <Quelle>_resynced.csv im selben Ordner"
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
src = os.path.abspath(args.csv)
|
||
if not os.path.isfile(src):
|
||
sys.exit(f"Fehler: Datei nicht gefunden: {src}")
|
||
|
||
# Sicherheitsnetz: Ursprungsfile darf nie das Ziel werden
|
||
if args.output:
|
||
dst = os.path.abspath(args.output)
|
||
if dst == src:
|
||
sys.exit("Fehler: Output-Pfad ist identisch mit der Quelldatei.")
|
||
else:
|
||
dst = unique_output_path(src)
|
||
|
||
df = load_reworked_csv(src)
|
||
n_in = len(df)
|
||
|
||
moving = find_moving_mask(df, args.window_ms, args.threshold)
|
||
out = df[moving]
|
||
n_out = len(out)
|
||
|
||
if n_out == 0:
|
||
print("Keine Bewegung erkannt – es wurde keine Datei geschrieben.")
|
||
sys.exit(1)
|
||
|
||
# Zeilenreihenfolge (inkl. beider Zeitachsen) unverändert erhalten
|
||
out.to_csv(dst, sep=";", index=False, na_rep="")
|
||
# EOF-Marker wie in den Original-Exports anhaengen
|
||
with open(dst, "a", encoding="utf-8") as f:
|
||
f.write("\n" + ";".join(["EOF"] + [""] * (len(out.columns) - 1)) + "\n")
|
||
|
||
removed = n_in - n_out
|
||
removed_time = df.loc[~moving, TIME_COL]
|
||
removed_ms = (
|
||
float(removed_time.max() - removed_time.min()) if len(removed_time) else 0.0
|
||
)
|
||
print(f"Eingelesen: {n_in} Zeilen aus {os.path.basename(src)}")
|
||
print(f"Entfernt (still): {removed} Zeilen (ca. {removed_ms / 1000:.1f} s Zeitspanne)")
|
||
print(f"Behalten (bewegt): {n_out} Zeilen")
|
||
print(f"Neues File: {dst}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|