#!/usr/bin/env python3
"""Module for reading InsetChart.json channels."""
from csv import writer as CsvWriter
from datetime import datetime
import json
from pathlib import Path
from typing import Dict, List, Union
import warnings
import pandas as pd
_CHANNELS = "Channels"
_DTK_VERSION = "DTK_Version"
_DATETIME = "DateTime"
_REPORT_TYPE = "Report_Type"
_REPORT_VERSION = "Report_Version"
_SIMULATION_TIMESTEP = "Simulation_Timestep"
_START_TIME = "Start_Time"
_TIMESTEPS = "Timesteps"
_KNOWN_KEYS = {
_CHANNELS,
_DTK_VERSION,
_DATETIME,
_REPORT_TYPE,
_REPORT_VERSION,
_SIMULATION_TIMESTEP,
_START_TIME,
_TIMESTEPS,
}
_TYPE_INSETCHART = "InsetChart"
_UNITS = "Units"
_DATA = "Data"
_HEADER = "Header"
[docs]class Channel(object):
def __init__(self, title: str, units: str, data: List) -> None:
self._title = title
self._units = units
self._data = data
return
@property
def title(self) -> str:
return self._title
@title.setter
def title(self, title: str) -> None:
self._title = f"{title}"
return
@property
def units(self) -> str:
return self._units
@units.setter
def units(self, units: str) -> None:
self._units = f"{units}"
return
@property
def data(self):
return self._data
def __getitem__(self, item):
"""Index into channel data by time step"""
return self._data[item]
def __setitem__(self, key, value) -> None:
"""Update channel data by time step"""
self._data[key] = value
return
[docs] def as_dictionary(self) -> Dict:
return {self.title: {_UNITS: self.units, _DATA: list(self.data)}}
[docs]class ChannelReport(object):
def __init__(self, filename: str = None, **kwargs):
if filename is not None:
assert isinstance(filename, str), "filename must be a string"
self._from_file(filename)
else:
self._header = Header(**kwargs)
self._channels = {}
return
@property
def header(self) -> Header:
return self._header
# pass-through to header
@property
def dtk_version(self) -> str:
return self._header.dtk_version
@dtk_version.setter
def dtk_version(self, version: str) -> None:
self._header.dtk_version = version
return
@property
def time_stamp(self) -> str:
return self._header.time_stamp
@time_stamp.setter
def time_stamp(self, time_stamp: Union[datetime, str]) -> None:
self._header.time_stamp = time_stamp
return
@property
def report_type(self) -> str:
return self._header.report_type
@report_type.setter
def report_type(self, report_type: str) -> None:
self._header.report_type = report_type
return
@property
def report_version(self) -> str:
"""major.minor"""
return self._header.report_version
@report_version.setter
def report_version(self, version: str) -> None:
self._header.report_version = version
return
@property
def step_size(self) -> int:
""">= 1"""
return self._header.step_size
@step_size.setter
def step_size(self, size: int) -> None:
""">= 1"""
self._header.step_size = size
return
@property
def start_time(self) -> int:
""">= 0"""
return self._header.start_time
@start_time.setter
def start_time(self, time: int) -> None:
""">= 0"""
self._header.start_time = time
return
@property
def num_time_steps(self) -> int:
"""> 0"""
return self._header.num_time_steps
@num_time_steps.setter
def num_time_steps(self, count: int):
"""> 0"""
self._header.num_time_steps = count
return
# end pass-through
@property
def num_channels(self) -> int:
return len(self._channels)
@property
def channel_names(self) -> List:
return sorted(self._channels)
@property
def channels(self) -> Dict:
"""Channel objects keyed on channel name/title"""
return self._channels
def __getitem__(self, item: str) -> Channel:
"""Return Channel object by channel name/title"""
return self._channels[item]
[docs] def as_dataframe(self) -> pd.DataFrame:
"""Return underlying data as a Pandas DataFrame"""
dataframe = pd.DataFrame(
{key: self.channels[key].data for key in self.channel_names}
)
return dataframe
[docs] def write_file(self, filename: str, indent: int = 0, separators=(",", ":")) -> None:
"""Write inset chart to specified text file."""
# in case this was generated locally, lets do some consistency checks
assert len(self._channels) > 0, "Report has no channels."
counts = set([len(channel.data) for title, channel in self.channels.items()])
assert (
len(counts) == 1
), f"Channels do not all have the same number of values ({counts})"
self._header.num_channels = len(self._channels)
self.num_time_steps = len(self._channels[self.channel_names[0]].data)
with open(filename, "w", encoding="utf-8") as file:
channels = {}
for _, channel in self.channels.items():
# https://stackoverflow.com/questions/38987/how-do-i-merge-two-dictionaries-in-a-single-expression
channels = {**channels, **channel.as_dictionary()}
chart = {_HEADER: self.header.as_dictionary(), _CHANNELS: channels}
json.dump(chart, file, indent=indent, separators=separators)
return
def _from_file(self, filename: str) -> None:
def validate_file(_jason) -> None:
assert _HEADER in _jason, f"'{filename}' missing '{_HEADER}' object."
assert (
_CHANNELS in _jason[_HEADER]
), f"'{filename}' missing '{_HEADER}/{_CHANNELS}' key."
assert (
_TIMESTEPS in _jason[_HEADER]
), f"'{filename}' missing '{_HEADER}/{_TIMESTEPS}' key."
assert _CHANNELS in _jason, f"'{filename}' missing '{_CHANNELS}' object."
num_channels = _jason[_HEADER][_CHANNELS]
channels_len = len(_jason[_CHANNELS])
assert num_channels == channels_len, (
f"'{filename}': "
+ f"'{_HEADER}/{_CHANNELS}' ({num_channels}) does not match number of {_CHANNELS} ({channels_len})."
)
return
def validate_channel(_channel, _title, _header) -> None:
assert _UNITS in _channel, f"Channel '{_title}' missing '{_UNITS}' entry."
assert _DATA in _channel, f"Channel '{_title}' missing '{_DATA}' entry."
count = len(_channel[_DATA])
assert (
count == _header.num_time_steps
), f"Channel '{title}' data values ({count}) does not match header Time_Steps ({_header.num_time_steps})."
return
with open(filename, "rb") as file:
jason = json.load(file)
validate_file(jason)
header_dict = jason[_HEADER]
self._header = Header(**header_dict)
self._channels = {}
channels = jason[_CHANNELS]
for title, channel in channels.items():
validate_channel(channel, title, self._header)
units = channel[_UNITS]
data = channel[_DATA]
self._channels[title] = Channel(title, units, data)
return
[docs] def to_csv(self, filename: Union[str, Path], channel_names: List[str]=None, transpose: bool=False) -> None:
"""
Write each channel from the report to a row, CSV style, in the given file.
Channel name goes in the first column, channel data goes into subsequent columns.
Args:
filename: string or path specifying destination file
channel_names: optional list of channels (by name) to write to the file
transpose: write channels as columns rather than rows
"""
if channel_names is None:
channel_names = self.channel_names
if not transpose: # default
data_frame = pd.DataFrame([[channel_name] + list(self[channel_name]) for channel_name in channel_names])
# data_frame = pd.DataFrame(([channel_name] + list(self[channel_name]) for channel_name in channel_names))
data_frame.to_csv(filename, header=False, index=False)
else: # transposed
self.as_dataframe().to_csv(filename, header=True, index=True, index_label="timestep")
return