Add ability to load .lvm from multisim

This commit is contained in:
KenwoodFox 2025-10-21 09:44:01 -04:00
parent d4a0503403
commit 66be32fd88
7 changed files with 7386 additions and 3 deletions

View File

@ -7,6 +7,13 @@ This package is just neat enough to combine those together.
Supports pulling the metadata, time encoding and etc from each of the loadable
types. All data is normalized to standard SI units (volts and seconds) for consistency.
Scopes supported:
```
Gwinstek GEO
Owon HDS
NI LVM
```
## Instalation
```bash
# Install directly from my server
@ -45,7 +52,7 @@ pipenv install -e .
from scope_parser import parse_owon_data, parse_gwinstek_data
# Parse your oscilloscope data (auto-detects file format)
data = parse_owon_data("your_file.CSV") # or parse_gwinstek_data, etc
data = parse_owon_data("your_file.CSV") # or parse_gwinstek_data, parse_ni_data, etc
# Access channel data
channel = data['CH1']

File diff suppressed because it is too large Load Diff

47
examples/example_lvm.py Normal file
View File

@ -0,0 +1,47 @@
import matplotlib.pyplot as plt
from scope_parser import parse_ni_data
data = parse_ni_data("Example NI Sinsoidal.lvm")
print(data)
ch1 = data["CH1"]
ch2 = data["CH2"]
print(ch1.metadata)
print(ch2.metadata)
# Plot both waveforms
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8))
# CH 1 plot
ax1.plot(ch1.time_values * 1000, ch1.voltage_values, "b-", linewidth=0.8)
ax1.set_title("LVM Channel 1")
ax1.set_xlabel("Time (ms)")
ax1.set_ylabel("Voltage (V)")
ax1.grid(True, alpha=0.3)
ax1.text(
0.02,
0.95,
f"Average: {ch1.average:.3f} V",
transform=ax1.transAxes,
verticalalignment="top",
bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.8),
)
# CH 2 plot
ax2.plot(ch2.time_values * 1000, ch2.voltage_values, "r-", linewidth=0.8)
ax2.set_title("LVM Channel 2")
ax2.set_xlabel("Time (ms)")
ax2.set_ylabel("Voltage (V)")
ax2.grid(True, alpha=0.3)
ax2.text(
0.02,
0.95,
f"Average: {ch2.average:.3f} V",
transform=ax2.transAxes,
verticalalignment="top",
bbox=dict(boxstyle="round", facecolor="lightcoral", alpha=0.8),
)
plt.tight_layout()
plt.show()

View File

@ -2,8 +2,14 @@
Joe's Really Simple Scope Parser
"""
from .parsers import parse_owon_data, parse_gwinstek_data
from .parsers import parse_owon_data, parse_gwinstek_data, parse_ni_data
from .data import ScopeData, ChannelData
__version__ = "0.1.0"
__all__ = ["parse_owon_data", "parse_gwinstek_data", "ScopeData", "ChannelData"]
__all__ = [
"parse_owon_data",
"parse_gwinstek_data",
"parse_ni_data",
"ScopeData",
"ChannelData",
]

View File

@ -98,3 +98,24 @@ class ScopeData:
def num_channels(self) -> int:
"""Get number of channels."""
return len(self.channels)
def __repr__(self) -> str:
"""String representation showing channel information."""
lines = [f"ScopeData({self.num_channels} channels)"]
for channel_name, channel_data in self.channels.items():
lines.append(f" {channel_name}:")
lines.append(f" Samples: {len(channel_data.voltage_values)}")
lines.append(
f" Time: {channel_data.time_values.min():.6f} to {channel_data.time_values.max():.6f} s"
)
lines.append(
f" Voltage: {channel_data.voltage_values.min():.6f} to {channel_data.voltage_values.max():.6f} V"
)
if channel_data.frequency:
lines.append(f" Frequency: {channel_data.frequency} Hz")
if channel_data.vpp:
lines.append(f" Peak-to-peak: {channel_data.vpp:.6f} V")
lines.append(f" Average: {channel_data.average:.6f} V")
return "\n".join(lines)

138
scope_parser/ni_parser.py Normal file
View File

@ -0,0 +1,138 @@
"""
Parser for National Instruments LVM files.
"""
import re
from typing import Dict, List
import numpy as np
from .base_parser import BaseOscilloscopeParser
from .data import ScopeData, ChannelData
class NIParser(BaseOscilloscopeParser):
"""Parser for National Instruments LVM files."""
date_time = None
def can_parse(self, file_path: str) -> bool:
"""Check if the file is valid/readable."""
try:
with open(file_path, "r") as f:
first_lines = f.readlines()
has_header = first_lines[0].strip().startswith("LabVIEW Measurement")
has_end_of_header = any(
"***End_of_Header***" in line for line in first_lines
)
if not (has_header and has_end_of_header):
return False
date_time_pattern = re.compile(r"Date\s*(.*)\s*Time\s*(.*)")
for line in first_lines:
match = date_time_pattern.search(line)
if match:
date = match.group(1).strip()
time = match.group(2).strip()
self.date_time = f"{date} {time}"
break
return True
except:
return False
def parse(self, file_path: str) -> ScopeData:
"""Parse NI LVM file."""
with open(file_path, "r") as f:
lines = f.readlines()
channels = {}
global_metadata = {}
# Parse global metadata (before first ***End_of_Header***)
for i, line in enumerate(lines):
line = line.strip()
if line == "***End_of_Header***":
break
if "\t" in line and not line.startswith("***"):
parts = line.split("\t")
if len(parts) >= 2:
key = parts[0].strip()
value = parts[1].strip()
if value:
global_metadata[key] = value
# Find all data blocks (after ***End_of_Header***)
block_starts = []
for i, line in enumerate(lines):
if line.strip() == "***End_of_Header***":
# Look for the next data block (X_Value_1 line)
for j in range(i + 1, len(lines)):
if lines[j].strip().startswith("X_Value_1"):
if j not in block_starts: # Avoid duplicates
block_starts.append(j)
break
# Parse each block
for block_idx, start_line in enumerate(block_starts):
block_metadata = {}
time_data = []
voltage_data = []
# Find the block metadata (look backwards from X_Value_1 line)
metadata_start = start_line
for i in range(start_line - 1, -1, -1):
line = lines[i].strip()
if line == "***End_of_Header***":
metadata_start = i + 1
break
# Parse block metadata
for i in range(metadata_start, start_line):
line = lines[i].strip()
if (
"\t" in line
and not line.startswith("***")
and not line.startswith("Page_")
):
parts = line.split("\t")
if len(parts) >= 2:
key = parts[0].strip()
value = parts[1].strip()
if value and value != "---": # Only add non-empty values
block_metadata[key] = value
# Parse data
data_start = start_line + 1 # Skip the X_Value_1 header line
for line in lines[data_start:]:
if not line.strip() or line.strip().startswith("***"):
break
try:
parts = line.split("\t")
if len(parts) >= 2:
time_data.append(float(parts[0]))
voltage_data.append(float(parts[1]))
except ValueError:
continue
if time_data and voltage_data:
# Create unique channel name for each block
channel_name = f"CH{block_idx + 1}"
# Add block metadata to channel metadata
channel_metadata = {**global_metadata, **block_metadata}
if self.date_time:
channel_metadata["Date_Time"] = self.date_time
channel_data = ChannelData(
channel_name=channel_name,
voltage_values=np.array(voltage_data),
time_values=np.array(time_data),
metadata=channel_metadata,
)
channels[channel_name] = channel_data
return ScopeData(channels=channels, metadata=global_metadata)

View File

@ -4,6 +4,7 @@ Main Parsers module
from .owon_parser import OwonParser
from .gwinstek_parser import GwinstekParser
from .ni_parser import NIParser
from .data import ScopeData
@ -17,3 +18,9 @@ def parse_gwinstek_data(file_path: str) -> ScopeData:
"""Parse Gwinstek oscilloscope CSV file."""
parser = GwinstekParser()
return parser.parse(file_path)
def parse_ni_data(file_path: str) -> ScopeData:
"""Parse National Instruments LVM file."""
parser = NIParser()
return parser.parse(file_path)