Skip to content

Commit 1fc1322

Browse files
authored
Merge pull request #220 from bbean23/219-code-feature-parse-nsttf-data
219 code feature parse nsttf data
2 parents 65804d1 + a0919e3 commit 1fc1322

File tree

3 files changed

+684
-0
lines changed

3 files changed

+684
-0
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import datetime as dt
2+
import re
3+
4+
from contrib.site_specific.NSTTFHeliostatLogsParser import NSTTFHeliostatLogsParser
5+
from opencsp import opencsp_settings
6+
import opencsp.common.lib.tool.file_tools as ft
7+
import opencsp.common.lib.tool.log_tools as lt
8+
9+
if __name__ == "__main__":
10+
experiment_path = ft.join(
11+
opencsp_settings["opencsp_root_path"]["collaborative_dir"],
12+
"NSTTF_Optics/Experiments/2024-06-16_FluxMeasurement",
13+
)
14+
log_path = ft.join(experiment_path, "2_Data/context/heliostat_logs")
15+
log_name_exts = ft.files_in_directory(log_path)
16+
log_path_name_exts = [ft.join(log_path, log_name_ext) for log_name_ext in log_name_exts]
17+
# example log name: "log_ 5_ 3_2024_13" for May 3rd 2024 at 1pm
18+
# replacement: "2024/5/3"
19+
save_path = ft.join(experiment_path, "4_Analysis/maybe_slow_13_more_time")
20+
from_regex = re.compile(r".*_ ?([0-9]{1,2})_ ?([0-9]{1,2})_([0-9]{4})_ ?([0-9]{1,2})")
21+
to_pattern = r"\3/\1/\2"
22+
23+
rows = [5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
24+
ncols = [9, 9, 9, 10, 11, 12, 14, 14, 14, 6]
25+
expected_hnames = []
26+
for row, ncols in zip(rows, ncols):
27+
for col in range(1, ncols + 1):
28+
for ew in ["E", "W"]:
29+
expected_hnames.append(f"_{row:02d}{ew}{col:02d}")
30+
31+
times: dict[tuple[dt.datetime, dt.datetime]] = {
32+
"_05W01": (dt.time(13, 18, 34), dt.time(13, 18, 54)),
33+
"_05E06": (dt.time(13, 19, 31), dt.time(13, 19, 52)),
34+
"_09W01": (dt.time(13, 20, 29), dt.time(13, 20, 49)),
35+
"_14W01": (dt.time(13, 21, 21), dt.time(13, 21, 44)),
36+
"_14W06": (dt.time(13, 22, 31), dt.time(13, 22, 56)),
37+
}
38+
to_datetime = lambda time: dt.datetime.combine(dt.date(2024, 6, 16), time)
39+
for hel in times:
40+
enter, leave = times[hel]
41+
times[hel] = to_datetime(enter), to_datetime(leave)
42+
total_delta = times["_14W06"][1] - times["_05W01"][0]
43+
lt.info(f"{total_delta=}")
44+
45+
parser = NSTTFHeliostatLogsParser.NsttfLogsParser()
46+
parser.filename_datetime_replacement = (from_regex, to_pattern)
47+
parser.filename_datetime_format = r"%Y/%m/%d"
48+
parser.load_heliostat_logs(
49+
log_path_name_exts, usecols=["Helio", "Time ", "X Targ", "Z Targ", "Az", "Az Targ", "Elev", "El Targ"]
50+
)
51+
parser.check_for_missing_heliostats(expected_hnames)
52+
53+
maybe_slow_helios = [
54+
"_06W03",
55+
"_6E08",
56+
"_08E08",
57+
"_08W08",
58+
"_10E10",
59+
"_10W08",
60+
"_11E11",
61+
"_11E07",
62+
"_11W09",
63+
"_12W05",
64+
"_12E10",
65+
"_13E09",
66+
"_14E06",
67+
]
68+
series_columns_labels_list = [
69+
{'Az': "{helio}Az", 'Az Targ': "{helio}AzTarg"},
70+
{'Elev': "{helio}El", 'El Targ': "{helio}ElTarg"},
71+
]
72+
73+
for heliostat in maybe_slow_helios:
74+
helio = heliostat.lstrip("_")
75+
76+
for series_columns_labels in series_columns_labels_list[1:]:
77+
title = helio + " " + ",".join([s for s in series_columns_labels])
78+
for series in series_columns_labels:
79+
series_columns_labels[series] = series_columns_labels[series].replace("{helio}", helio)
80+
81+
fig_record = parser.prepare_figure(title, "Time", "X Targ (m)")
82+
hparser = parser.filter(
83+
heliostat_names=heliostat, datetime_range=(dt.time(13, 26, 50), dt.time(13, 29, 30))
84+
)
85+
hparser.plot('Time ', series_columns_labels, scatter_plot=False)
86+
87+
# # datetimes = hparser.datetimes
88+
# # if len(datetimes) > 4:
89+
# # xticks = []
90+
# # for i in range(0, len(datetimes), int(len(datetimes) / 4)):
91+
# # datetime: pandas.DatetimeIndex = datetimes[i]
92+
# # xticks.append((datetime, f"{datetime.time}"))
93+
# # fig_record.view.axis.set_xticks([tick_label[0] for tick_label in xticks], [
94+
# # tick_label[1] for tick_label in xticks])
95+
# xticks = [dt.datetime(2024, 6, 16, 13, 26, 50) + dt.timedelta(s) for s in range(0, 80, 20)]
96+
# xlabels = [str(xtick) for xtick in xticks]
97+
# fig_record.view.axis.set_xticks(xticks, xlabels)
98+
99+
fig_record.view.show(legend=True, block=False, grid=True)
100+
fig_record.save(save_path, title, "png")
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import copy
2+
import datetime as dt
3+
import re
4+
5+
import numpy as np
6+
import pandas
7+
import pandas.core
8+
9+
import opencsp.common.lib.render.Color as color
10+
import opencsp.common.lib.render.figure_management as fm
11+
import opencsp.common.lib.render.view_spec as vs
12+
import opencsp.common.lib.render_control.RenderControlAxis as rca
13+
import opencsp.common.lib.render_control.RenderControlFigure as rcf
14+
import opencsp.common.lib.render_control.RenderControlFigureRecord as rcfr
15+
import opencsp.common.lib.render_control.RenderControlPointSeq as rcps
16+
import opencsp.common.lib.tool.file_tools as ft
17+
import opencsp.common.lib.tool.log_tools as lt
18+
import opencsp.common.lib.tool.typing_tools as tt
19+
20+
21+
class NSTTFHeliostatLogsParser:
22+
"""Parser for NSTTF style log output from the heliostat control software."""
23+
24+
def __init__(
25+
self, name: str, dtype: dict[str, any], date_column_formats: dict[str, str], heliostat_name_column: str
26+
):
27+
# register inputs
28+
self.name = name
29+
self.dtype = dtype
30+
self.date_column_formats = date_column_formats
31+
self.heliostat_name_column = heliostat_name_column
32+
33+
# internal values
34+
self.filename_datetime_replacement: tuple[re.Pattern, str] = None
35+
self.filename_datetime_format: str = None
36+
37+
# plotting values
38+
self.figure_rec: rcfr.RenderControlFigureRecord = None
39+
self.nplots = 0
40+
self.parent_parser: NSTTFHeliostatLogsParser = None
41+
42+
@classmethod
43+
def NsttfLogsParser(cls):
44+
dtype = {
45+
# "Main T": ,
46+
"Time": str,
47+
"Helio": str,
48+
"Mode": str,
49+
"Sleep": str,
50+
"Track": int,
51+
"X Targ": float,
52+
"Y Targ": float,
53+
"Z Targ": float,
54+
"az offset": float,
55+
"el offset": float,
56+
# "reserved": ,
57+
"Az Targ": float,
58+
"El Targ": float,
59+
"Az": float,
60+
"Elev": float,
61+
# "Az Amp": ,
62+
# "El Amp": ,
63+
# "Az Falt": ,
64+
# "El Falt": ,
65+
# "Az Cnt": ,
66+
# "El Cnt": ,
67+
# "Az Drive": ,
68+
# "El Drive": ,
69+
"Trk Time": float,
70+
"Exec Time": float,
71+
"Delta Time": float,
72+
# "Ephem Num": ,
73+
# "Status Word": ,
74+
}
75+
# date format for "09:59:59.999" style timestamp
76+
# https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior
77+
date_column_formats = {"Time ": r"%H:%M:%S.%f"}
78+
heliostat_name_column = "Helio"
79+
80+
return cls('NsttfLogsParser', dtype, date_column_formats, heliostat_name_column)
81+
82+
@property
83+
def column_names(self) -> list[str]:
84+
return list(self.data.columns)
85+
86+
@property
87+
def heliostats(self) -> tt.Series[str]:
88+
return self.column(self.heliostat_name_column)
89+
90+
@property
91+
def datetimes(self) -> tt.Series[dt.datetime]:
92+
dt_col = next(iter(self.date_column_formats.keys()))
93+
return self.column(dt_col)
94+
95+
@datetimes.setter
96+
def datetimes(self, datetimes: pandas.Series):
97+
dt_col = next(iter(self.date_column_formats.keys()))
98+
self.data[dt_col] = datetimes
99+
100+
def column(self, column_name: str) -> pandas.Series:
101+
if column_name is None:
102+
lt.error_and_raise(ValueError, "Error in HeliostatLogsParser.column(): column_name is None")
103+
if column_name not in self.column_names:
104+
lt.error_and_raise(
105+
KeyError,
106+
"Error in HeliostatLogsParser.column(): "
107+
+ f"can't find column \"{column_name}\", should be one of {self.column_names}",
108+
)
109+
return self.data[column_name]
110+
111+
def load_heliostat_logs(self, log_path_name_exts: str | list[str], usecols: list[str] = None, nrows: int = None):
112+
# normalize input
113+
if isinstance(log_path_name_exts, str):
114+
log_path_name_exts = [log_path_name_exts]
115+
for i, log_path_name_ext in enumerate(log_path_name_exts):
116+
log_path_name_exts[i] = ft.norm_path(log_path_name_ext)
117+
118+
# validate input
119+
for log_path_name_ext in log_path_name_exts:
120+
if not ft.file_exists(log_path_name_ext):
121+
lt.error_and_raise(
122+
FileNotFoundError,
123+
f"Error in HeliostatLogsParser({self.name}).load_heliostat_logs(): "
124+
+ f"file \"{log_path_name_ext}\" does not exist!",
125+
)
126+
127+
# load the logs
128+
data_list: list[pandas.DataFrame] = []
129+
for i, log_path_name_ext in enumerate(log_path_name_exts):
130+
lt.info(f"Loading {log_path_name_ext}... ")
131+
132+
data = pandas.read_csv(
133+
log_path_name_ext,
134+
delimiter="\t",
135+
header='infer',
136+
# parse_dates=self.parse_dates,
137+
dtype=self.dtype,
138+
skipinitialspace=True,
139+
# date_format=self.date_format,
140+
usecols=usecols,
141+
nrows=nrows,
142+
)
143+
data_list.append(data)
144+
self.data = pandas.concat(data_list)
145+
data_list.clear()
146+
147+
# try to guess the date from the file name
148+
date = None
149+
if self.filename_datetime_format is not None:
150+
_, log_name, _ = ft.path_components(log_path_name_ext)
151+
if self.filename_datetime_replacement is not None:
152+
repl_pattern, repl_sub = self.filename_datetime_replacement
153+
formatted_log_name: str = repl_pattern.sub(repl_sub, log_name)
154+
date = formatted_log_name
155+
else:
156+
date = log_name
157+
158+
# parse any necessary dates
159+
# masterlog _ 5_ 3_2024_13.lvm
160+
for date_col in self.date_column_formats:
161+
dt_format = self.date_column_formats[date_col]
162+
col_to_parse = self.data[date_col]
163+
if not r"%d" in dt_format and r"%j" not in dt_format:
164+
if date is not None:
165+
col_to_parse = date + " " + self.data[date_col]
166+
dt_format = self.filename_datetime_format + " " + dt_format
167+
168+
self.data[date_col] = pandas.to_datetime(col_to_parse, format=dt_format)
169+
170+
lt.info("..done")
171+
172+
def filter(
173+
self,
174+
heliostat_names: str | list[str] = None,
175+
columns_equal: list[tuple[str, any]] = None,
176+
columns_almost_equal: list[tuple[str, float]] = None,
177+
datetime_range: tuple[dt.datetime, dt.datetime] | tuple[dt.time, dt.time] = None,
178+
) -> "NSTTFHeliostatLogsParser":
179+
if isinstance(heliostat_names, str):
180+
heliostat_names = [heliostat_names]
181+
182+
# copy of the data to be filtered
183+
new_data = self.data
184+
if heliostat_names is not None:
185+
new_data = new_data[new_data[self.heliostat_name_column].isin(heliostat_names)]
186+
187+
# filter by datetime
188+
if datetime_range is not None:
189+
dt_col = next(iter(self.date_column_formats.keys()))
190+
if isinstance(datetime_range[0], dt.datetime):
191+
# user specified dates+times
192+
matches = (new_data[dt_col] >= datetime_range[0]) & (new_data[dt_col] < datetime_range[1])
193+
elif isinstance(datetime_range[0], dt.time):
194+
# user specified just times, select by all matches across all dates
195+
dates: set[dt.date] = set([val.date() for val in self.datetimes])
196+
matches = np.full_like(new_data[dt_col], fill_value=False, dtype=np.bool_)
197+
for date in dates:
198+
fromval = pandas.to_datetime(dt.datetime.combine(date, datetime_range[0]))
199+
toval = pandas.to_datetime(dt.datetime.combine(date, datetime_range[1]))
200+
matches |= (new_data[dt_col] >= fromval) & (new_data[dt_col] < toval)
201+
else:
202+
lt.error_and_raise(
203+
ValueError,
204+
"Error in HeliostatLogsParser.filter(): "
205+
+ f"unexpected type for datetime_range, expected datetime or time but got {type(datetime_range[0])}",
206+
)
207+
new_data = new_data[matches]
208+
209+
# filter by generic exact values
210+
if columns_equal is not None:
211+
for column_name, value in columns_almost_equal:
212+
new_data = new_data[new_data[column_name] == value]
213+
214+
# filter by generic approximate values
215+
if columns_almost_equal is not None:
216+
# definition for 'almost_equal' from np.testing.assert_almost_equal()
217+
# abs(desired-actual) < float64(1.5 * 10**(-decimal))
218+
decimal = 7
219+
error_bar = 1.5 * 10 ** (-decimal)
220+
for column_name, value in columns_almost_equal:
221+
matches = np.abs(new_data[column_name] - value) < error_bar
222+
new_data = new_data[matches]
223+
224+
# create a copy with the filtered data
225+
ret = copy.copy(self)
226+
ret.data = new_data
227+
ret.parent_parser = self
228+
229+
return ret
230+
231+
def check_for_missing_heliostats(self, expected_heliostat_names: list[str]) -> tuple[list[str], list[str]]:
232+
extra_hnames, missing_hnames = [], copy.copy(expected_heliostat_names)
233+
hnames = set(self.data["Helio"])
234+
for hname in hnames:
235+
if hname in missing_hnames:
236+
missing_hnames.remove(hname)
237+
else:
238+
extra_hnames.append(hname)
239+
240+
lt.info(f"Missing {len(missing_hnames)} expected heliostats: {missing_hnames}")
241+
lt.info(f"Found {len(extra_hnames)} extra heliostats: {extra_hnames}")
242+
243+
return missing_hnames, extra_hnames
244+
245+
def prepare_figure(self, title: str = None, x_label: str = None, y_label: str = None):
246+
# normalize input
247+
if title is None:
248+
title = f"{self.__class__.__name__} ({self.name})"
249+
if x_label is None:
250+
x_label = "x"
251+
if y_label is None:
252+
y_label = "y"
253+
254+
# get the plot ready
255+
view_spec = vs.view_spec_pq()
256+
axis_control = rca.RenderControlAxis(x_label=x_label, y_label=y_label)
257+
figure_control = rcf.RenderControlFigure(tile=False)
258+
self.fig_record = fm.setup_figure(
259+
figure_control=figure_control,
260+
axis_control=axis_control,
261+
view_spec=view_spec,
262+
equal=False,
263+
number_in_name=False,
264+
title=title,
265+
code_tag=f"{__file__}.build_plot()",
266+
)
267+
self.nplots = 0
268+
269+
return self.fig_record
270+
271+
def plot(self, x_axis_column: str, series_columns_labels: dict[str, str], scatter_plot=False):
272+
view = self.fig_record.view
273+
x_values = self.data[x_axis_column].to_list()
274+
275+
# populate the plot
276+
for series_column in series_columns_labels:
277+
series_label = series_columns_labels[series_column]
278+
series_values = self.data[series_column].to_list()
279+
if scatter_plot:
280+
view.draw_pq(
281+
(x_values, series_values),
282+
label=series_label,
283+
style=rcps.default(color=color._PlotColors()[self.nplots]),
284+
)
285+
else:
286+
view.draw_pq_list(
287+
list(zip(x_values, series_values)),
288+
label=series_label,
289+
style=rcps.outline(color=color._PlotColors()[self.nplots]),
290+
)
291+
self.nplots += 1
292+
293+
# bubble up the nplots value to the parent parser
294+
curr_parser = self
295+
while curr_parser.parent_parser is not None:
296+
curr_parser.parent_parser.nplots = curr_parser.nplots
297+
curr_parser = curr_parser.parent_parser

0 commit comments

Comments
 (0)