"""Artifact data generator. cli options ----------- :: ./BioProController/RaspBerryPi3/python/biopro/generator.py [OPTIONS] [FILE] OPTIONS -D, --device DEVICE device ID -L, --library PATH library search path -T, --type TYPE output data format. raw : raw data (default) bprf : BioPro Recording File (output to file only) -a, --amplitude VALUE signal amplitude, default 500 -c, --channel CH[,CH] channel list, default [0] -g, --generator GENERATOR generator, could be: zero : all zero sin : sine save (default) ramp : ramp (Elite, Neulive serial) TC4VAF2 Elite_Legacy TDC4VAF2 Elite Neulive (NeuliveSTI serial) NeuliveSTI NeuliveSTI1.0 TDBC4VCTH (EliteZM serial) EliteZM I4V4Z4T4 (mode :IV, :FG, :CV, :ZT, :VT, :IT) -h, --help print help document -n, --noise VALUE noise factor, default 0 -p just list parameter, do not run anything -s, --sample-rate RATE sampling rate in unit 1/s. could be [N]K, default 100. -t, --duration VALUE total time duration in unit second. could be [N]h[N]m[N][.NNN]. also can use 'inf' to run forever. default 1. OPTIONS (--type=bprf) --bprf-verbose [NEWLINE] print what it write to stdout, default NEWLINE = \n --split-size SIZE file split for every size, could be [N]K, [N]M, [N]G. --split-time TIME file split for every second ARGUMENTS FILE output file, '-' output to stdout (default) Class ----- """ import sys from math import sin, pi from random import random as _random from typing import IO, Type from biopro.devlib.data import I4V4Z4T4DataDecoder, EODInterrupt from biopro.devlib.library import * from biopro.util.cli import * from biopro.util.text import part_prefix, part_suffix from .recording import RecordingData, RecordingMetaFile, RecordingFileWriter T = TypeVar('T') def _sign(v: float) -> int: return 1 if v > 0 else -1 RT_1 = Tuple[float, float, int, Optional[RecordingData]] class Generator(metaclass=abc.ABCMeta): """abstract artifact data generator""" __slots__ = ('device', 'configuration', 'channel', 'sample_rate', 'amplitude', 'noise', '_start_time_stamp', '_prev_time_stamp') def __init__(self, options: 'GeneratorOptions', configuration: Optional[DeviceConfiguration] = None): # basic options self.device = options.device # addiction device configuration self.configuration = configuration self.channel = tuple(options.channel) self.sample_rate = options.sample_rate self.amplitude = options.amplitude self.noise = options.noise # internal data self._start_time_stamp: Optional[float] = None self._prev_time_stamp: Optional[float] = None def data_should_generated(self, time_stamp: float, max_time_delta: float = None) -> RT_1: """calculate how many data should be generated according to the *sample_rate* and how long did previous function be called. :param time_stamp: current time stamp :param max_time_delta: max time delta :return: time stamp, time pass and the number of data should be generated. """ if self._start_time_stamp is None: self._start_time_stamp = time_stamp self._prev_time_stamp = time_stamp return time_stamp, 0.0, 0, None else: delta = time_stamp - self._prev_time_stamp if delta < 0: return self._prev_time_stamp, 0.0, 0, None if max_time_delta is not None and max_time_delta < delta: delta = max_time_delta count = int(delta * self.sample_rate) if count == 0: return time_stamp, 0.0, 0, None elif count > 0xFFFF: count = 0xFFF0 delta = count / self.sample_rate data = self.new_data(int(1000 * (self._prev_time_stamp - self._start_time_stamp))) else: data = self.new_data(int(1000 * (self._prev_time_stamp - self._start_time_stamp))) self._prev_time_stamp += delta return self._prev_time_stamp, delta, count, data def new_data(self, time_stamp: int) -> RecordingData: return RecordingData(self.device, time_stamp, self.sample_rate) @abc.abstractmethod def get_data(self, time_stamp: float) -> List[RecordingData]: """ generating data. :param time_stamp: current time stamp in unit second :return: list of (channel data) """ pass def message(self) -> Optional[str]: """this method behavior as same as :func:`biopro.devlib.data.DataDecodeFormat.message`. :return: """ return None def factor_noise(self) -> float: """ :return: noise factor """ return 1 + self.noise * _random() - self.noise / 2 def new_meta_configuration(self) -> SimpleDeviceConfiguration: return SimpleDeviceConfiguration({ DeviceConfiguration.LIBRARY: 'GENERATOR', DeviceConfiguration.VERSION: '0.4.0', DeviceConfiguration.SAMPLE_RATE: self.sample_rate, DeviceConfiguration.CHANNEL: self.channel, DeviceConfiguration.AMP_GAIN: 1, }) class ZeroGenerator(Generator): __slots__ = () def __init__(self, options: 'GeneratorOptions', configuration: Optional[DeviceConfiguration] = None): super().__init__(options, configuration) def get_data(self, time_stamp: float) -> List[RecordingData]: if time_stamp < 0: raise ValueError('negative time stamp value : ' + str(time_stamp)) ret = [] next_time_stamp = -1 while next_time_stamp < time_stamp: next_time_stamp, time_pass, count, data = self.data_should_generated(time_stamp) if data is not None: for i in range(count): channel_id = self.channel[i % len(self.channel)] value = int(self.amplitude * self.factor_noise()) data.append_data(channel_id, value) ret.append(data) return ret def new_meta_configuration(self) -> SimpleDeviceConfiguration: ret = super().new_meta_configuration() ret.set_parameter('MODE', 'zero') return ret class SinWaveGenerator(Generator): """Artifact sin ware data generator. """ TIME_CONSTANT = 'TIME_CONSTANT' __slots__ = ('time_constant', '_phase') def __init__(self, options: 'GeneratorOptions', configuration: Optional[DeviceConfiguration] = None): super().__init__(options, configuration) self.time_constant = 1 self._phase = 0.0 def get_data(self, time_stamp: float) -> List[RecordingData]: if time_stamp < 0: raise ValueError('negative time stamp value : ' + str(time_stamp)) ret = [] t = self.time_constant channel_size = len(self.channel) next_time_stamp = -1 while next_time_stamp < time_stamp: next_time_stamp, time_pass, count, data = self.data_should_generated(time_stamp) if data is not None: for i in range(count): channel_id = self.channel[i % channel_size] value = int(self.amplitude * sin(self._phase / t * pi / 180)) data.append_data(channel_id, value) self._phase += 1 ret.append(data) return ret def new_meta_configuration(self) -> SimpleDeviceConfiguration: ret = super().new_meta_configuration() ret.set_parameter('MODE', 'sin') ret.set_parameter(self.TIME_CONSTANT, self.time_constant) return ret class RampGenerator(Generator): """Artifact ramp data generator""" __slots__ = ('_count',) def __init__(self, options: 'GeneratorOptions', configuration: Optional[DeviceConfiguration] = None): super().__init__(options, configuration) self._count = -self.amplitude def get_data(self, time_stamp: float) -> List[RecordingData]: if time_stamp < 0: raise ValueError('negative time stamp value : ' + str(time_stamp)) ret = [] next_time_stamp = -1 while next_time_stamp < time_stamp: next_time_stamp, time_pass, count, data = self.data_should_generated(time_stamp) if data is not None: amp = self.amplitude cnt = self._count for i in range(count): channel_id = self.channel[i % len(self.channel)] value = int(cnt * self.factor_noise()) data.append_data(channel_id, value) cnt += 1 if cnt > amp: cnt = -amp self._count = cnt ret.append(data) return ret def new_meta_configuration(self) -> SimpleDeviceConfiguration: ret = super().new_meta_configuration() ret.set_parameter('MODE', 'ramp') return ret class DeviceGenerator(Generator): SUPPORT = { 'TC4VAF2': ('Elite_Legacy', '0.0.0'), 'Elite_Legacy': ('Elite_Legacy', '0.0.0'), 'TDC4VAF2': ('Elite', '0.1.0'), 'Elite': ('Elite', '0.1.0'), 'Neulive': ('Neulive', '1.2.0'), } __slots__ = ('_phase',) def __init__(self, options: 'GeneratorOptions', configuration: DeviceConfiguration): if configuration is None: raise TypeError('NoneType configuration') super().__init__(options, configuration) self.amplitude = min(512.0, abs(self.amplitude)) self._phase = 0 def get_data(self, time_stamp: float) -> List[RecordingData]: if time_stamp < 0: raise ValueError('negative time stamp value : ' + str(time_stamp)) ret = [] time_step = 10 / self.sample_rate nxt = -1 # next_time_stamp while nxt < time_stamp: nxt, time_pass, count, data = self.data_should_generated(time_stamp, time_step) if data is not None and len(self.channel) != 0: for i in range(count): channel_id = self.channel[i % len(self.channel)] value = int(self.amplitude * sin(self._phase * pi / 180) * self.factor_noise()) data.append_data(channel_id, value) self._phase += 1 ret.append(data) return ret def new_meta_configuration(self) -> SimpleDeviceConfiguration: conf = SimpleDeviceConfiguration(self.configuration) conf.set_parameter(DeviceConfiguration.SAMPLE_RATE, self.sample_rate) conf.set_parameter(DeviceConfiguration.CHANNEL, self.channel) conf.set_parameter(DeviceConfiguration.AMP_GAIN, 1) return conf class NeuliveSTIGenerator(Generator): SUPPORT = { 'NeuliveSTI': ('NeuliveSTI1.0', '1.0.0'), 'NeuliveSTI1.0': ('NeuliveSTI1.0', '1.0.0'), 'TDBC4VCTH': ('NeuliveSTI1.0', '1.0.0'), } __slots__ = ('voltage_drop_rate', '_voltage') def __init__(self, options: 'GeneratorOptions', configuration: DeviceConfiguration): if configuration is None: raise TypeError('NoneType configuration') super().__init__(options, configuration) s = set(self.channel) # NeuliveSTI only has 3 channels s.intersection({0, 1, 2}) # force add zero channel (battery channel) s.add(0) self.channel = tuple(sorted(s)) # sample rate force to 1/s self.sample_rate = 1 self.voltage_drop_rate = 10 # mv / sec self._voltage = 4300 # mv def get_data(self, time_stamp: float) -> List[RecordingData]: if time_stamp < 0: raise ValueError('negative time stamp value : ' + str(time_stamp)) ret = [] time_step = 1 # 1 second nxt = -1 # next_time_stamp while nxt < time_stamp: nxt, _, count, data = self.data_should_generated(time_stamp, time_step) if data is not None and len(self.channel) != 0: for _ in range(count): data.append_data(0, self._voltage) if 1 in self.channel: data.append_data(1, int(100 * self.factor_noise())) if 2 in self.channel: data.append_data(2, int(100 * self.factor_noise())) self._voltage = int(self._voltage - self.sample_rate) ret.append(data) return ret def new_data(self, time_stamp: int) -> RecordingData: return RecordingData(self.device, time_stamp, 0) def new_meta_configuration(self) -> SimpleDeviceConfiguration: conf = SimpleDeviceConfiguration(self.configuration) conf.set_parameter(DeviceConfiguration.SAMPLE_RATE, self.sample_rate) conf.set_parameter(DeviceConfiguration.CHANNEL, self.channel) conf.set_parameter(DeviceConfiguration.AMP_GAIN, 1) return conf class EliteZMGenerator(Generator): MODE = ( "I-V Curve", "Cyclic Voltammetry", "Function Generator", "R-T Curve", "V-T Curve", "I-T Curve", ) VOLTAGE = tuple(map(lambda v: (v + 1) * 0x0001, range(65536))) VOLTAGE_STEP = (30, 60, 90, 120) STEP_TIME = (0.5, 1.0, 1.5, 2.0) SUPPORT = { 'EliteZM': ('EliteZM', '1.2.30'), 'I4V4Z4T4': ('EliteZM', '1.2.30'), } __slots__ = ('_data_format', 'working_mode', 'voltage_origin', 'voltage_final', 'voltage_step', '_voltage', '_voltage_step', '_cycle_number', '_phase', '_message', '_is_eod') def __init__(self, options: 'GeneratorOptions', configuration: DeviceConfiguration): if configuration is None: raise TypeError('NoneType configuration') super().__init__(options, configuration) self.working_mode = configuration.get('MODE', 0) if isinstance(self.working_mode, str): self.working_mode = self.MODE.index(self.working_mode) if self.working_mode == 0: # IV self.channel = (0, 1) elif self.working_mode == 1: # CV self.channel = (0, 1) elif self.working_mode == 2: # FG self.channel = (1,) elif self.working_mode == 3: # ZT self.channel = (2,) elif self.working_mode == 4: # VT self.channel = (1,) elif self.working_mode == 5: # IT self.channel = (0,) else: raise ValueError('unsupported mode : ' + str(self.working_mode)) # sample rate force to 1/s self.sample_rate = 1 self.voltage_origin = configuration.get('VOLT_ORIGIN', self.VOLTAGE[0]) # unit: V self.voltage_final = configuration.get('VOLT_FINAL', self.VOLTAGE[-1]) # unit: V self.voltage_step = configuration.get('VOLT_STEP', self.VOLTAGE_STEP[0]) # unit: mV # internal data self._voltage = self.voltage_origin * 1000 if self.voltage_origin <= self.voltage_final: self._voltage_step = self.voltage_step else: self._voltage_step = -self.voltage_step self._cycle_number = 0 self._phase = 0 self._message: Optional[str] = None self._is_eod = False def get_data(self, time_stamp: float) -> List[RecordingData]: if time_stamp < 0: raise ValueError('negative time stamp value : ' + str(time_stamp)) if self._is_eod: raise EODInterrupt() ret = [] nxt = -1 # next_time_stamp while nxt < time_stamp: nxt, data = self._gen_data(time_stamp) if data is not None: ret.append(data) if self._is_eod: break return ret def _gen_data(self, time_stamp: float) -> Tuple[float, Optional[RecordingData]]: nxt, _, count, data = self.data_should_generated(time_stamp, 1) if data is not None: for _ in range(count): current = 500 * (sin(self._phase * pi / 180) + 1.1) # current if 0 in self.channel: data.append_data(0 + 3 * self._cycle_number, int(current)) # voltage if 1 in self.channel: data.append_data(1 + 3 * self._cycle_number, int(self._voltage)) # impedance if 2 in self.channel: if current == 0: data.append_data(2 + 3 * self._cycle_number, 0) else: data.append_data(2 + 3 * self._cycle_number, int(self._voltage / current)) self._next_voltage() if self._is_eod: break self._phase += 1 return nxt, data def _next_voltage(self): """change voltage value. """ nxt_voltage = self._voltage + self._voltage_step if nxt_voltage >= self.voltage_final * 1000: self._voltage = self.voltage_final * 1000 self._voltage_step = -abs(self._voltage_step) if self.working_mode == 0: self._is_eod = True elif self.working_mode == 1 and self.voltage_final < self.voltage_origin: self._cycle_number += 1 self._message = I4V4Z4T4DataDecoder.MESSAGE_CYCLE_COMPLETE elif nxt_voltage <= self.voltage_origin * 1000: self._voltage = self.voltage_origin * 1000 self._voltage_step = abs(self._voltage_step) if self.working_mode == 0: self._is_eod = True elif self.working_mode == 1 and self.voltage_origin < self.voltage_final: self._cycle_number += 1 self._message = I4V4Z4T4DataDecoder.MESSAGE_CYCLE_COMPLETE else: self._voltage = nxt_voltage def message(self) -> Optional[str]: ret = self._message self._message = None return ret def new_data(self, time_stamp: int) -> RecordingData: return RecordingData(self.device, time_stamp, 0) def new_meta_configuration(self) -> SimpleDeviceConfiguration: conf = SimpleDeviceConfiguration(self.configuration) conf.set_parameter(DeviceConfiguration.SAMPLE_RATE, self.sample_rate) conf.set_parameter(DeviceConfiguration.CHANNEL, self.channel) conf.set_parameter(DeviceConfiguration.AMP_GAIN, 1) ### conf.set_parameter('MODE', self.MODE[self.working_mode]) conf.set_parameter('VOLT_ORIGIN', self.voltage_origin) conf.set_parameter('VOLT_FINAL', self.voltage_final) conf.set_parameter('VOLT_STEP', self.voltage_step) conf.set_parameter('STEP_TIME', 1.0) return conf # noinspection PyUnusedLocal class GeneratorOptions(CliOptions): def __init__(self): # general options self.generator_type: str = 'sin' self.generator_cls: Type[Generator] = SinWaveGenerator self.configuration: Optional[DeviceConfiguration] = None self.device: int = 0 self.channel: List[int] = [0] self.sample_rate = 100 self.amplitude = 500 self.noise = 0.0 # library path self.repository = DeviceLibraryRepository() @cli_options('-g', '--generator', value='GENERATOR') def _generator_type(self, opt: str, value: str): """generator, could be: zero : all zero sin : sine save (default) ramp : ramp (Elite, Neulive serial) TC4VAF2 Elite_Legacy TDC4VAF2 Elite Neulive (NeuliveSTI serial) NeuliveSTI NeuliveSTI1.0 TDBC4VCTH (EliteZM serial) EliteZM I4V4Z4T4 (mode :IV, :FG, :CV, :ZT, :VT, :IT) """ self.set_generator(value) def set_generator(self, generator_type: str): self.generator_type = generator_type if generator_type == 'zero': self.generator_cls = ZeroGenerator self.configuration = None elif generator_type == 'sin': self.generator_cls = SinWaveGenerator self.configuration = None elif generator_type == 'ramp': self.generator_cls = RampGenerator self.configuration = None else: try: lib, ver = DeviceGenerator.SUPPORT[generator_type] self.configuration = self._new_device_configuration(lib, ver) self.generator_cls = DeviceGenerator return except KeyError: pass try: lib, ver = NeuliveSTIGenerator.SUPPORT[generator_type] self.configuration = self._new_device_configuration(lib, ver) self.generator_cls = NeuliveSTIGenerator return except KeyError: pass try: lib, ver = EliteZMGenerator.SUPPORT[generator_type] self.configuration = self._new_device_configuration(lib, ver) self.generator_cls = EliteZMGenerator # init ZM options self.configuration.set_parameter('VOLT_ORIGIN', EliteZMGenerator.VOLTAGE[0]) self.configuration.set_parameter('VOLT_FINAL', EliteZMGenerator.VOLTAGE[-1]) return except KeyError: pass raise RuntimeError('unknown generator type : ' + generator_type) @cli_options('-D', '--device', value='DEVICE') def _device(self, opt: str, value: str): """device ID""" self.device = int(value) @cli_options('-c', '--channel', value='CH[,CH]') def _channel(self, opt: str, value: str): """channel list, default [0]""" self.channel = list(map(int, value.split(','))) @cli_options('-s', '--sample-rate', value='RATE') def _sample_rate(self, opt: str, value: str): """sampling rate in unit 1/s. could be [N]K, default 100. """ if value.endswith('K'): self.sample_rate = int(value[:-1]) * 1000 else: self.sample_rate = int(value) @cli_options('-a', '--amplitude', value='VALUE') def _amplitude(self, opt: str, value: str): """signal amplitude, default 500""" self.amplitude = int(value) @cli_options('-n', '--noise', value='VALUE') def _noise(self, opt: str, value: str): """noise factor, default 0""" self.noise = float(value) @cli_options('-L', '--library', value='PATH') def _library_path(self, opt: str, value: str): """library search path""" self.repository.add_library_path(value) def get_generator(self) -> Generator: return self.generator_cls(self, self.configuration) def _new_device_configuration(self, library_name: str, library_version: str) -> DeviceConfiguration: library = self.repository.get_library(library_name, library_version) if library is None: raise RuntimeError('library ' + library_name + ' ' + library_version + ' not found') parameter = DeviceParameter(library) return DeviceParameterConfiguration(library, parameter) # noinspection PyUnusedLocal class Main(CliMain, GeneratorOptions): def __init__(self): super().__init__() GeneratorOptions.__init__(self) # internal property self._generator: Generator = None # output self.output_type = 'raw' self.output_file = '-' # time self.duration: Optional[float] = 1.0 '''time duration in unit second''' self.list_parameter = False # recording file writer options self.writer_split_time: Optional[int] = None self.writer_split_size: Optional[int] = None self.writer_print_data = None @cli_flags('-h', '--help', force_return=True) def _help(self, opt: str): """print help document""" self.print_help() def set_generator(self, generator_type: str): super().set_generator(generator_type) c = self.configuration if c is None: return m: Dict[str, str] = {} h: List[CliHandleOption] = [] for k in c.keys(): if k.startswith('_'): continue if k in (DeviceConfiguration.CHANNEL, DeviceConfiguration.SAMPLE_RATE): # do not include channel and sample_rate options continue o = '--' + k.lower().replace('_', '-') m[o] = k h.append(CliHandleOption(o, help_doc=k)) if len(h) > 0: def _callback(flag: str, value: Optional[str]): c.set_parameter(m[flag], value) self.extend_options_callback(_callback, *h, help_section='Configuration for ' + c.library) @cli_options('-T', '--type', value='TYPE') def _output_type(self, opt: str, value: str): """output data format. raw : raw data (default) bprf : BioPro Recording File (output to file only) """ self.output_type = value @cli_options('-t', '--duration', value='VALUE') def _duration(self, opt: str, value: str): """total time duration in unit second. could be [N]h[N]m[N][.NNN]. also can use 'inf' to run forever. default 1. """ if value == 'inf': self.duration = None else: self.duration = self._parse_time(value) @cli_flags('-p') def _list_parameter(self, flag: str): """just list parameter, do not run anything""" self.list_parameter = True @cli_arguments('?', value='FILE') def _file(self, pos: int, value: str): """output file, '-' output to stdout (default)""" self.output_file = value @cli_options('--split-time', value='TIME') @cli_help_section('OPTIONS (--type=bprf)') def _split_time(self, opt: str, value: str): """file split for every second""" if self.output_type != 'bprf': print('WARN', opt + ' only work for --type=bprf') self.writer_split_time = int(value) @cli_options('--split-size', value='SIZE') @cli_help_section('OPTIONS (--type=bprf)') def _split_size(self, opt: str, value: str): """file split for every size, could be [N]K, [N]M, [N]G.""" if self.output_type != 'bprf': print('WARN', opt + ' only work for --type=bprf') self.writer_split_size = self._parse_size(value) @cli_options('--bprf-verbose', value='NEWLINE', optional=True) @cli_help_section('OPTIONS (--type=bprf)') def _bprf_verbose(self, opt: str, value: str): """print what it write to stdout, default NEWLINE = \\n""" if self.output_type != 'bprf': print('WARN', opt + ' only work for --type=bprf') if value is None: self.writer_print_data = '\n' else: self.writer_print_data = value @staticmethod def _parse_size(expr: str) -> int: factor = 1 if expr.endswith('K'): factor = 1000 expr = expr[:-1] elif expr.endswith('M'): factor = 1000 * 1000 expr = expr[:-1] elif expr.endswith('G'): factor = 1000 * 1000 * 1000 expr = expr[:-1] return int(expr) * factor @staticmethod def _parse_time(expr: str) -> float: """parse time expression :param expr: expression in [N]h[N]m[N][.NNN] :return: second """ t_h, expr = part_prefix(expr, 'h', missing=0) t_h = int(t_h) if len(expr) > 0: t_m, expr = part_prefix(expr, 'm', missing=0) t_m = int(t_m) else: t_m = 0 if len(expr) > 0: t_s, t_u = part_suffix(expr, '.') t_s = int(t_s) if t_u is not None: t_u = t_u[:3] if len(t_u) < 3: t_u += '0' t_u = int(t_u) else: t_u = 0 else: t_s = t_u = 0 return t_u * 1e-3 + t_s + t_m * 60 + t_h * 60 * 60 def run(self): self._generator = self.get_generator() if self.list_parameter: configuration = self._generator.configuration for para in configuration.keys(): print(para) else: if self.output_type == 'raw': if self.output_file == '-': self._run(sys.stdout) else: with open(self.output_file, 'w') as output: self._run(output) elif self.output_type == 'bprf': if self.output_file == '-': raise RuntimeError('bprf cannot output to stdout') meta = RecordingMetaFile(self.output_file) meta.configuration = self._generator.new_meta_configuration() with RecordingFileWriter(meta) as output: output.splitting_threshold_size = self.writer_split_size output.splitting_threshold_time = self.writer_split_time self._run(output) else: raise RuntimeError('unknown output type : ' + self.output_type) def _run(self, output: Union[IO, RecordingFileWriter]): if self.duration is None: raise RuntimeError() is_writer = isinstance(output, RecordingFileWriter) # init first time self._generator.get_data(0.0) try: if self.writer_print_data or not is_writer: for data in self._generator.get_data(self.duration): if len(data) > 0: if is_writer: output.write(data) for time_stamp, channel_id, value in data.entry_iter(): print('%.4f' % time_stamp, channel_id, value, end=self.writer_print_data) else: for data in self._generator.get_data(self.duration): if len(data) > 0: output.write(data) except EODInterrupt: pass if __name__ == '__main__': Main().main()