cicwave 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cicwave/__init__.py +25 -0
- cicwave/cli.py +176 -0
- cicwave/command.py +71 -0
- cicwave/ngraw.py +105 -0
- cicwave/pivot.py +240 -0
- cicwave/theme.py +108 -0
- cicwave/wave_pg.py +3962 -0
- cicwave/wavefiles.py +935 -0
- cicwave-0.5.0.dist-info/METADATA +242 -0
- cicwave-0.5.0.dist-info/RECORD +14 -0
- cicwave-0.5.0.dist-info/WHEEL +5 -0
- cicwave-0.5.0.dist-info/entry_points.txt +5 -0
- cicwave-0.5.0.dist-info/licenses/LICENSE +21 -0
- cicwave-0.5.0.dist-info/top_level.txt +1 -0
cicwave/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cicwave - Advanced waveform viewer with PyQtGraph backend
|
|
3
|
+
|
|
4
|
+
A standalone waveform viewer extracted from cicsim, focused on providing
|
|
5
|
+
high-performance visualization of simulation data with PyQtGraph and Qt6.
|
|
6
|
+
|
|
7
|
+
Supports multiple file formats:
|
|
8
|
+
- ngspice .raw files
|
|
9
|
+
- CSV, TSV, Excel
|
|
10
|
+
- Parquet, HDF5, Feather
|
|
11
|
+
- VCD digital waveforms
|
|
12
|
+
- LitePoint .iqvsa IQ capture files
|
|
13
|
+
|
|
14
|
+
Features:
|
|
15
|
+
- GPU-accelerated rendering with PyQtGraph
|
|
16
|
+
- Multi-dimensional data pivoting
|
|
17
|
+
- Session save/restore
|
|
18
|
+
- Export to PDF/PNG/SVG
|
|
19
|
+
- Digital waveform support
|
|
20
|
+
- Engineering unit formatting
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "1.0.0"
|
|
24
|
+
__author__ = "Carsten Wulff"
|
|
25
|
+
__email__ = "carsten@wulff.no"
|
cicwave/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
######################################################################
|
|
3
|
+
## Copyright (c) 2020 Carsten Wulff Software, Norway
|
|
4
|
+
## ###################################################################
|
|
5
|
+
## Created : wulff at 2020-10-23
|
|
6
|
+
## ###################################################################
|
|
7
|
+
## The MIT License (MIT)
|
|
8
|
+
##
|
|
9
|
+
## Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
## of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
## in the Software without restriction, including without limitation the rights
|
|
12
|
+
## to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
## copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
## furnished to do so, subject to the following conditions:
|
|
15
|
+
##
|
|
16
|
+
## The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
## copies or substantial portions of the Software.
|
|
18
|
+
##
|
|
19
|
+
## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
## IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
## FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
## AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
## LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
## OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
## SOFTWARE.
|
|
26
|
+
##
|
|
27
|
+
######################################################################
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import glob as _glob
|
|
32
|
+
import click
|
|
33
|
+
from .command import setup_logging
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_wave_x_from_cli_env(cli_x):
|
|
37
|
+
"""Apply --x, else CICWAVE_X environment variable."""
|
|
38
|
+
if cli_x:
|
|
39
|
+
return cli_x
|
|
40
|
+
v = os.environ.get("CICWAVE_X")
|
|
41
|
+
if v and str(v).strip():
|
|
42
|
+
return str(v).strip()
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _expand_glob_patterns(files, patterns):
|
|
47
|
+
"""Merge positional ``files`` with files matched by --glob ``patterns``.
|
|
48
|
+
|
|
49
|
+
Each pattern is expanded with ``glob.glob(..., recursive=True)`` so that
|
|
50
|
+
``**`` works. Order: positional first, then each pattern in order. Files
|
|
51
|
+
are de-duplicated while preserving first-seen order. Patterns that match
|
|
52
|
+
nothing are reported on stderr but not fatal.
|
|
53
|
+
"""
|
|
54
|
+
out = []
|
|
55
|
+
seen = set()
|
|
56
|
+
for f in files:
|
|
57
|
+
if f not in seen:
|
|
58
|
+
seen.add(f)
|
|
59
|
+
out.append(f)
|
|
60
|
+
for pat in patterns or ():
|
|
61
|
+
matches = sorted(_glob.glob(pat, recursive=True))
|
|
62
|
+
if not matches:
|
|
63
|
+
print("warning: --glob '%s' matched no files" % pat,
|
|
64
|
+
file=sys.stderr)
|
|
65
|
+
continue
|
|
66
|
+
for m in matches:
|
|
67
|
+
if m not in seen:
|
|
68
|
+
seen.add(m)
|
|
69
|
+
out.append(m)
|
|
70
|
+
return tuple(out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run_wave_pg(files, x, sheet, pivot_spec=None,
|
|
74
|
+
pivot_info_flag=False, session_path=None, export_path=None):
|
|
75
|
+
"""Run the PyQtGraph waveform viewer."""
|
|
76
|
+
x = _resolve_wave_x_from_cli_env(x)
|
|
77
|
+
|
|
78
|
+
if pivot_spec:
|
|
79
|
+
from .pivot import load_spec, pivot_info, apply_pivot
|
|
80
|
+
from .wavefiles import WaveFile
|
|
81
|
+
spec = load_spec(pivot_spec)
|
|
82
|
+
|
|
83
|
+
if pivot_info_flag:
|
|
84
|
+
for f in files:
|
|
85
|
+
wf = WaveFile(f, x or "")
|
|
86
|
+
print("--- %s ---" % f)
|
|
87
|
+
print(pivot_info(wf.df, spec))
|
|
88
|
+
print()
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if not x and spec.get('columns'):
|
|
92
|
+
x = spec['columns']
|
|
93
|
+
|
|
94
|
+
# Import the PyQtGraph backend
|
|
95
|
+
try:
|
|
96
|
+
from .wave_pg import CmdWavePg
|
|
97
|
+
except ImportError as e:
|
|
98
|
+
print("Error: PyQtGraph backend requires PySide6 and pyqtgraph")
|
|
99
|
+
print("Install with: pip install PySide6 pyqtgraph")
|
|
100
|
+
print(f" ({e})")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
c = CmdWavePg(x)
|
|
104
|
+
|
|
105
|
+
if session_path:
|
|
106
|
+
c.win.applySession(session_path)
|
|
107
|
+
|
|
108
|
+
if pivot_spec:
|
|
109
|
+
for f in files:
|
|
110
|
+
wf = WaveFile(f, x or "")
|
|
111
|
+
pivoted = apply_pivot(wf.df, spec)
|
|
112
|
+
name = "pivot(%s)" % os.path.basename(f)
|
|
113
|
+
c.openDataFrame(pivoted, name,
|
|
114
|
+
pivot_spec_path=os.path.abspath(pivot_spec),
|
|
115
|
+
original_path=os.path.abspath(f))
|
|
116
|
+
else:
|
|
117
|
+
for f in files:
|
|
118
|
+
c.openFile(f, sheet_name=sheet)
|
|
119
|
+
|
|
120
|
+
if export_path:
|
|
121
|
+
c.exportAndExit(export_path)
|
|
122
|
+
else:
|
|
123
|
+
c.run()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@click.command()
|
|
127
|
+
@click.argument("files", nargs=-1)
|
|
128
|
+
@click.option("--glob", "globs", multiple=True,
|
|
129
|
+
help="Glob pattern (repeatable). Supports ** for recursion. "
|
|
130
|
+
"Useful on shells like PowerShell that don't auto-expand.")
|
|
131
|
+
@click.option("--x", default=None,
|
|
132
|
+
help="X-axis column; else CICWAVE_X; else saved default; else auto")
|
|
133
|
+
@click.option("--sheet", default=None, help="Sheet name for Excel files (default: first sheet)")
|
|
134
|
+
@click.option("--pivot", default=None, help="Pivot spec file (YAML/JSON)")
|
|
135
|
+
@click.option("--pivot-info", is_flag=True, default=False, help="Print pivot dimensions and exit")
|
|
136
|
+
@click.option("--session", default=None, help="Load session file (.cicwave.yaml)")
|
|
137
|
+
@click.option("--export", default=None, help="Export plot to file (PDF/PNG/SVG) and exit")
|
|
138
|
+
@click.option("--color/--no-color", default=True, help="Enable/Disable color output")
|
|
139
|
+
@click.option("--debug", is_flag=True, default=False, help="Enable debug logging")
|
|
140
|
+
def main(files, globs, x, sheet, pivot, pivot_info, session, export, color, debug):
|
|
141
|
+
"""cicwave: Advanced waveform viewer with PyQtGraph backend.
|
|
142
|
+
|
|
143
|
+
A high-performance waveform viewer focused on PyQtGraph and Qt6 for
|
|
144
|
+
advanced visualization of simulation data.
|
|
145
|
+
|
|
146
|
+
Supports: .raw, .csv, .tsv, .xlsx, .json, .parquet, .feather, .h5,
|
|
147
|
+
.pkl, .vcd (digital), .iqvsa (LitePoint), and more.
|
|
148
|
+
|
|
149
|
+
\b
|
|
150
|
+
Pivot:
|
|
151
|
+
--pivot spec.yaml Reshape data using pivot spec before viewing
|
|
152
|
+
--pivot-info Print unique values per pivot dimension and exit
|
|
153
|
+
|
|
154
|
+
\b
|
|
155
|
+
Session:
|
|
156
|
+
--session plot.cicwave.yaml Load saved session
|
|
157
|
+
--export plot.pdf Export to file and exit (no GUI)
|
|
158
|
+
--session s.yaml --export out.pdf Restore session and export
|
|
159
|
+
|
|
160
|
+
\b
|
|
161
|
+
Globs:
|
|
162
|
+
--glob 'data/*.csv' Repeatable, supports ** for recursion
|
|
163
|
+
--glob '**/*.raw' Useful on PowerShell which doesn't
|
|
164
|
+
auto-expand patterns
|
|
165
|
+
"""
|
|
166
|
+
# Set up logging
|
|
167
|
+
import logging
|
|
168
|
+
level = logging.DEBUG if debug else logging.INFO
|
|
169
|
+
setup_logging(color=color, level=level)
|
|
170
|
+
|
|
171
|
+
files = _expand_glob_patterns(files, globs)
|
|
172
|
+
_run_wave_pg(files, x, sheet, pivot, pivot_info, session, export)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
main()
|
cicwave/command.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
######################################################################
|
|
2
|
+
## Copyright (c) 2020 Carsten Wulff Software, Norway
|
|
3
|
+
## ###################################################################
|
|
4
|
+
## Created : wulff at 2020-10-24
|
|
5
|
+
## ###################################################################
|
|
6
|
+
## The MIT License (MIT)
|
|
7
|
+
##
|
|
8
|
+
## Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
## of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
## in the Software without restriction, including without limitation the rights
|
|
11
|
+
## to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
## copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
## furnished to do so, subject to the following conditions:
|
|
14
|
+
##
|
|
15
|
+
## The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
## copies or substantial portions of the Software.
|
|
17
|
+
##
|
|
18
|
+
## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
## IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
## FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
## AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
+
## LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
+
## OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
+
## SOFTWARE.
|
|
25
|
+
##
|
|
26
|
+
######################################################################
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("cicwave")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ColoredFormatter(logging.Formatter):
|
|
34
|
+
COLORS = {
|
|
35
|
+
logging.DEBUG: '\033[36m',
|
|
36
|
+
logging.INFO: '\033[32m',
|
|
37
|
+
logging.WARNING: '\033[93m',
|
|
38
|
+
logging.ERROR: '\033[31m',
|
|
39
|
+
logging.CRITICAL: '\033[31m',
|
|
40
|
+
}
|
|
41
|
+
RESET = '\033[0m'
|
|
42
|
+
|
|
43
|
+
def format(self, record):
|
|
44
|
+
msg = super().format(record)
|
|
45
|
+
color = self.COLORS.get(record.levelno, '')
|
|
46
|
+
return f"{color}{msg}{self.RESET}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def setup_logging(color=True, level=logging.INFO):
|
|
50
|
+
"""Configure the cicwave logger with optional colored output."""
|
|
51
|
+
log = logging.getLogger("cicwave")
|
|
52
|
+
log.setLevel(level)
|
|
53
|
+
log.handlers.clear()
|
|
54
|
+
|
|
55
|
+
handler = logging.StreamHandler()
|
|
56
|
+
handler.setLevel(level)
|
|
57
|
+
|
|
58
|
+
if color:
|
|
59
|
+
formatter = ColoredFormatter("%(message)s")
|
|
60
|
+
else:
|
|
61
|
+
formatter = logging.Formatter("%(levelname)-7s | %(message)s")
|
|
62
|
+
|
|
63
|
+
handler.setFormatter(formatter)
|
|
64
|
+
log.addHandler(handler)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Command:
|
|
68
|
+
"""Minimal base command class for cicwave."""
|
|
69
|
+
|
|
70
|
+
def __init__(self):
|
|
71
|
+
pass
|
cicwave/ngraw.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# MIT license: https://opensource.org/licenses/MIT
|
|
2
|
+
# See https://github.com/Isotel/mixedsim/blob/master/python/ngspice_read.py
|
|
3
|
+
# for a more complete library. Isotel's version is GPL licensed
|
|
4
|
+
from __future__ import division
|
|
5
|
+
#import division
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
BSIZE_SP = 512 # Max size of a line of data; we don't want to read the
|
|
9
|
+
# whole file to find a line, in case file does not have
|
|
10
|
+
# expected structure.
|
|
11
|
+
MDATA_LIST = [b'title', b'date', b'plotname', b'flags', b'no. variables',
|
|
12
|
+
b'no. points', b'dimensions', b'command', b'option']
|
|
13
|
+
def ngRawRead(fname: str):
|
|
14
|
+
"""Read ngspice binary raw files. Return tuple of the data, and the
|
|
15
|
+
plot metadata. The dtype of the data contains field names. This is
|
|
16
|
+
not very robust yet, and only supports ngspice.
|
|
17
|
+
>>> darr, mdata = rawread('test.py')
|
|
18
|
+
>>> darr.dtype.names
|
|
19
|
+
>>> plot(np.real(darr['frequency']), np.abs(darr['v(out)']))
|
|
20
|
+
"""
|
|
21
|
+
# Example header of raw file
|
|
22
|
+
# Title: rc band pass example circuit
|
|
23
|
+
# Date: Sun Feb 21 11:29:14 2016
|
|
24
|
+
# Plotname: AC Analysis
|
|
25
|
+
# Flags: complex
|
|
26
|
+
# No. Variables: 3
|
|
27
|
+
# No. Points: 41
|
|
28
|
+
# Variables:
|
|
29
|
+
# 0 frequency frequency grid=3
|
|
30
|
+
# 1 v(out) voltage
|
|
31
|
+
# 2 v(in) voltage
|
|
32
|
+
# Binary:
|
|
33
|
+
with open(fname, 'rb') as fp:
|
|
34
|
+
plot = {}
|
|
35
|
+
arrs = []
|
|
36
|
+
plots = []
|
|
37
|
+
names = dict()
|
|
38
|
+
ind = 0
|
|
39
|
+
while (True):
|
|
40
|
+
mdata = fp.readline(BSIZE_SP).split(b':', maxsplit=1)
|
|
41
|
+
if len(mdata) == 2:
|
|
42
|
+
if mdata[0].lower() in MDATA_LIST:
|
|
43
|
+
plot[mdata[0].lower()] = mdata[1].strip()
|
|
44
|
+
if mdata[0].lower() == b'variables':
|
|
45
|
+
nvars = int(plot[b'no. variables'])
|
|
46
|
+
npoints = int(plot[b'no. points'])
|
|
47
|
+
plot['varnames'] = []
|
|
48
|
+
plot['varunits'] = []
|
|
49
|
+
for varn in range(nvars):
|
|
50
|
+
|
|
51
|
+
varspec = (fp.readline(BSIZE_SP).strip()
|
|
52
|
+
.decode('ascii').split())
|
|
53
|
+
if varn != int(varspec[0]):
|
|
54
|
+
raise ValueError(f"Variable index mismatch in {fname}: expected {varn}, got {varspec[0]}")
|
|
55
|
+
|
|
56
|
+
#- Skip duplicated variables
|
|
57
|
+
if(varspec[1] not in names):
|
|
58
|
+
names[varspec[1]] = 1
|
|
59
|
+
else:
|
|
60
|
+
varspec[1] += str(ind)
|
|
61
|
+
ind +=1
|
|
62
|
+
plot['varnames'].append(varspec[1])
|
|
63
|
+
plot['varunits'].append(varspec[2])
|
|
64
|
+
if mdata[0].lower() == b'binary':
|
|
65
|
+
rowdtype = np.dtype({'names': plot['varnames'],
|
|
66
|
+
'formats': [np.complex128 if b'complex'
|
|
67
|
+
in plot[b'flags']
|
|
68
|
+
else np.float64]*nvars})
|
|
69
|
+
arrs.append(np.fromfile(fp, dtype=rowdtype, count=npoints))
|
|
70
|
+
plots.append(plot)
|
|
71
|
+
fp.readline()
|
|
72
|
+
else:
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
return (arrs, plots)
|
|
76
|
+
|
|
77
|
+
def toDataFrames(ngarr):
|
|
78
|
+
(arrs,plots) = ngarr
|
|
79
|
+
|
|
80
|
+
dfs = list()
|
|
81
|
+
for i in range(0,len(plots)):
|
|
82
|
+
df = pd.DataFrame(data=arrs[i],columns=plots[i]['varnames'])
|
|
83
|
+
dfs.append(df)
|
|
84
|
+
return dfs
|
|
85
|
+
|
|
86
|
+
def toDataFrame(fraw):
|
|
87
|
+
(arrs,plots) = ngRawRead(fraw)
|
|
88
|
+
|
|
89
|
+
dfs = list()
|
|
90
|
+
for i in range(0,len(plots)):
|
|
91
|
+
df = pd.DataFrame(data=arrs[i],columns=plots[i]['varnames'])
|
|
92
|
+
dfs.append(df)
|
|
93
|
+
|
|
94
|
+
if(len(dfs)> 0):
|
|
95
|
+
return dfs[0]
|
|
96
|
+
else:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == '__main__':
|
|
104
|
+
arrs, plots = ngRawRead('test.raw')
|
|
105
|
+
print(arrs)
|
cicwave/pivot.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Pivot spec loader for cicwave.
|
|
5
|
+
|
|
6
|
+
A pivot spec (YAML or JSON) defines how to reshape a flat DataFrame
|
|
7
|
+
into a wide format suitable for waveform plotting.
|
|
8
|
+
|
|
9
|
+
Spec format::
|
|
10
|
+
|
|
11
|
+
index: Parameter # split by — each unique value becomes a wave
|
|
12
|
+
columns: Frequency # (optional) x-axis; rows with None/NaN dropped
|
|
13
|
+
values: Measurement # y-axis values
|
|
14
|
+
conditions: # further split waves by these; each unique combo
|
|
15
|
+
- Temp # of (index x conditions) becomes its own wave
|
|
16
|
+
- Config
|
|
17
|
+
aliases: # optional: short names for long condition values
|
|
18
|
+
Config:
|
|
19
|
+
c0: "LV"
|
|
20
|
+
|
|
21
|
+
Use ``--pivot-info`` to discover the cN keys for each condition column.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import yaml
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_spec(path):
|
|
30
|
+
with open(path) as f:
|
|
31
|
+
if path.endswith('.json'):
|
|
32
|
+
return json.load(f)
|
|
33
|
+
return yaml.safe_load(f)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_json_kv_array(val):
|
|
37
|
+
if not isinstance(val, str):
|
|
38
|
+
return False
|
|
39
|
+
val = val.strip()
|
|
40
|
+
if not (val.startswith('[') and val.endswith(']')):
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
arr = json.loads(val)
|
|
44
|
+
return (isinstance(arr, list) and len(arr) > 0
|
|
45
|
+
and all(isinstance(d, dict) and 'value' in d for d in arr))
|
|
46
|
+
except (json.JSONDecodeError, TypeError):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_kv_semicolon(val):
|
|
51
|
+
"""Detect 'KEY=VAL;KEY=VAL;...' patterns."""
|
|
52
|
+
if not isinstance(val, str):
|
|
53
|
+
return False
|
|
54
|
+
parts = [p for p in val.strip().split(';') if p]
|
|
55
|
+
return len(parts) >= 2 and all('=' in p for p in parts)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_structured_value(val):
|
|
59
|
+
s = str(val)
|
|
60
|
+
return _is_json_kv_array(s) or _is_kv_semicolon(s)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _shorten_value(val):
|
|
64
|
+
"""Auto-shorten a condition value for column names."""
|
|
65
|
+
try:
|
|
66
|
+
if isinstance(val, float) and val == int(val):
|
|
67
|
+
return str(int(val))
|
|
68
|
+
except (ValueError, OverflowError):
|
|
69
|
+
pass
|
|
70
|
+
s = str(val)
|
|
71
|
+
if _is_json_kv_array(s):
|
|
72
|
+
arr = json.loads(s)
|
|
73
|
+
return '_'.join(str(d.get('value', '')) for d in arr)
|
|
74
|
+
if _is_kv_semicolon(s):
|
|
75
|
+
parts = [p.split('=', 1)[1] for p in s.strip().split(';') if p and '=' in p]
|
|
76
|
+
return '_'.join(parts)
|
|
77
|
+
return s
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _condition_prefix(name):
|
|
81
|
+
return name[0].upper()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build_alias_map(unique_vals, aliases_section):
|
|
85
|
+
"""Map cN keys to user-defined aliases, falling back to auto-shorten."""
|
|
86
|
+
indexed = {("c%d" % i): v for i, v in enumerate(unique_vals)}
|
|
87
|
+
result = {}
|
|
88
|
+
for ckey, orig_val in indexed.items():
|
|
89
|
+
if aliases_section and ckey in aliases_section:
|
|
90
|
+
result[orig_val] = str(aliases_section[ckey])
|
|
91
|
+
else:
|
|
92
|
+
result[orig_val] = _shorten_value(orig_val)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def pivot_info(df, spec):
|
|
97
|
+
"""Return a human-readable summary of pivot dimensions and unique values."""
|
|
98
|
+
index_col = spec['index']
|
|
99
|
+
columns_col = spec.get('columns')
|
|
100
|
+
values_col = spec['values']
|
|
101
|
+
conditions = spec.get('conditions', [])
|
|
102
|
+
|
|
103
|
+
available = set(df.columns)
|
|
104
|
+
lines = []
|
|
105
|
+
|
|
106
|
+
lines.append("available columns: %s" % ', '.join(sorted(available)))
|
|
107
|
+
lines.append("")
|
|
108
|
+
|
|
109
|
+
if index_col in available:
|
|
110
|
+
lines.append("index: %s (%d unique)" % (index_col, df[index_col].nunique()))
|
|
111
|
+
for v in sorted(df[index_col].dropna().unique(), key=str):
|
|
112
|
+
lines.append(" %s" % v)
|
|
113
|
+
else:
|
|
114
|
+
lines.append("index: %s ** NOT FOUND **" % index_col)
|
|
115
|
+
|
|
116
|
+
if columns_col:
|
|
117
|
+
lines.append("")
|
|
118
|
+
if columns_col in available:
|
|
119
|
+
lines.append("columns: %s (%d unique)" % (
|
|
120
|
+
columns_col, df[columns_col].nunique()))
|
|
121
|
+
for v in sorted(df[columns_col].unique(), key=lambda x: str(x)):
|
|
122
|
+
lines.append(" %s" % v)
|
|
123
|
+
else:
|
|
124
|
+
lines.append("columns: %s ** NOT FOUND (will be skipped) **" % columns_col)
|
|
125
|
+
|
|
126
|
+
lines.append("")
|
|
127
|
+
if values_col in available:
|
|
128
|
+
lines.append("values: %s" % values_col)
|
|
129
|
+
else:
|
|
130
|
+
lines.append("values: %s ** NOT FOUND **" % values_col)
|
|
131
|
+
|
|
132
|
+
if conditions:
|
|
133
|
+
lines.append("")
|
|
134
|
+
lines.append("conditions:")
|
|
135
|
+
json_cols = []
|
|
136
|
+
for cond in conditions:
|
|
137
|
+
unique_vals = sorted(df[cond].dropna().unique(), key=str)
|
|
138
|
+
lines.append("")
|
|
139
|
+
lines.append(" %s (%d unique)" % (cond, len(unique_vals)))
|
|
140
|
+
has_json = any(_is_structured_value(v) for v in unique_vals)
|
|
141
|
+
for i, v in enumerate(unique_vals):
|
|
142
|
+
if has_json:
|
|
143
|
+
lines.append(" c%d: %s" % (i, _shorten_value(v)))
|
|
144
|
+
else:
|
|
145
|
+
lines.append(" %s" % _shorten_value(v))
|
|
146
|
+
if has_json:
|
|
147
|
+
json_cols.append(cond)
|
|
148
|
+
|
|
149
|
+
if json_cols:
|
|
150
|
+
lines.append("")
|
|
151
|
+
lines.append("Suggested aliases for your pivot spec:")
|
|
152
|
+
lines.append("aliases:")
|
|
153
|
+
for cond in json_cols:
|
|
154
|
+
lines.append(" %s:" % cond)
|
|
155
|
+
unique_vals = sorted(df[cond].dropna().unique(), key=str)
|
|
156
|
+
for i, v in enumerate(unique_vals):
|
|
157
|
+
short = _shorten_value(v)
|
|
158
|
+
lines.append(' c%d: "%s"' % (i, short))
|
|
159
|
+
|
|
160
|
+
return "\n".join(lines)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def apply_pivot(df, spec):
|
|
164
|
+
"""Reshape *df* according to *spec*, return a wide DataFrame.
|
|
165
|
+
|
|
166
|
+
``index`` values become wave names (one column per unique value).
|
|
167
|
+
``columns`` (if present) becomes the x-axis — rows with None/NaN
|
|
168
|
+
in that column are dropped. ``conditions`` further split waves so
|
|
169
|
+
each unique (index × conditions) combo is its own wave.
|
|
170
|
+
"""
|
|
171
|
+
index_col = spec['index']
|
|
172
|
+
columns_col = spec.get('columns')
|
|
173
|
+
values_col = spec['values']
|
|
174
|
+
conditions = spec.get('conditions', [])
|
|
175
|
+
aliases = spec.get('aliases', {})
|
|
176
|
+
|
|
177
|
+
available = set(df.columns)
|
|
178
|
+
missing = []
|
|
179
|
+
if index_col not in available:
|
|
180
|
+
missing.append("index '%s'" % index_col)
|
|
181
|
+
if values_col not in available:
|
|
182
|
+
missing.append("values '%s'" % values_col)
|
|
183
|
+
for cond in conditions:
|
|
184
|
+
if cond not in available:
|
|
185
|
+
missing.append("condition '%s'" % cond)
|
|
186
|
+
if missing:
|
|
187
|
+
raise KeyError(
|
|
188
|
+
"Pivot spec references missing columns: %s\n"
|
|
189
|
+
"Available columns: %s" % (
|
|
190
|
+
', '.join(missing), ', '.join(sorted(available))))
|
|
191
|
+
|
|
192
|
+
has_xaxis = columns_col and columns_col in available
|
|
193
|
+
|
|
194
|
+
alias_maps = {}
|
|
195
|
+
for cond in conditions:
|
|
196
|
+
unique_vals = sorted(df[cond].dropna().unique(), key=str)
|
|
197
|
+
alias_maps[cond] = _build_alias_map(unique_vals, aliases.get(cond))
|
|
198
|
+
|
|
199
|
+
keep = [index_col, values_col] + conditions
|
|
200
|
+
if has_xaxis:
|
|
201
|
+
keep.insert(1, columns_col)
|
|
202
|
+
sub = df[keep].copy()
|
|
203
|
+
|
|
204
|
+
if has_xaxis:
|
|
205
|
+
sub = sub.dropna(subset=[columns_col])
|
|
206
|
+
|
|
207
|
+
wave_col = sub[index_col].astype(str)
|
|
208
|
+
for cond in conditions:
|
|
209
|
+
amap = alias_maps.get(cond, {})
|
|
210
|
+
labels = sub[cond].map(
|
|
211
|
+
lambda v: amap.get(v, _shorten_value(v)))
|
|
212
|
+
wave_col = wave_col + '_' + _condition_prefix(cond) + labels.astype(str)
|
|
213
|
+
sub['_wave'] = wave_col
|
|
214
|
+
|
|
215
|
+
if has_xaxis:
|
|
216
|
+
result = sub.pivot_table(
|
|
217
|
+
index=columns_col,
|
|
218
|
+
columns='_wave',
|
|
219
|
+
values=values_col,
|
|
220
|
+
aggfunc='mean',
|
|
221
|
+
)
|
|
222
|
+
result.columns.name = None
|
|
223
|
+
result = result.reset_index()
|
|
224
|
+
# Try to make x-axis numeric
|
|
225
|
+
try:
|
|
226
|
+
result[columns_col] = pd.to_numeric(result[columns_col])
|
|
227
|
+
result = result.sort_values(columns_col)
|
|
228
|
+
except (ValueError, TypeError):
|
|
229
|
+
pass
|
|
230
|
+
else:
|
|
231
|
+
result = sub.pivot_table(
|
|
232
|
+
index='_wave',
|
|
233
|
+
columns=index_col,
|
|
234
|
+
values=values_col,
|
|
235
|
+
aggfunc='mean',
|
|
236
|
+
)
|
|
237
|
+
result.columns.name = None
|
|
238
|
+
result = result.reset_index().rename(columns={'_wave': 'condition'})
|
|
239
|
+
|
|
240
|
+
return result
|