Holger Frey
5 years ago
12 changed files with 1235 additions and 2 deletions
@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
|
||||
.DEFAULT_GOAL := help |
||||
|
||||
define BROWSER_PYSCRIPT |
||||
import os, webbrowser, sys |
||||
|
||||
try: |
||||
from urllib import pathname2url |
||||
except: |
||||
from urllib.request import pathname2url |
||||
|
||||
webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) |
||||
endef |
||||
export BROWSER_PYSCRIPT |
||||
|
||||
define PRINT_HELP_PYSCRIPT |
||||
import re, sys |
||||
|
||||
for line in sys.stdin: |
||||
match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) |
||||
if match: |
||||
target, help = match.groups() |
||||
print("%-20s %s" % (target, help)) |
||||
endef |
||||
export PRINT_HELP_PYSCRIPT |
||||
|
||||
BROWSER := python -c "$$BROWSER_PYSCRIPT" |
||||
|
||||
help: |
||||
@python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) |
||||
|
||||
clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts
|
||||
|
||||
clean-build: ## remove build artifacts
|
||||
rm -fr build/ |
||||
rm -fr dist/ |
||||
rm -fr .eggs/ |
||||
find . -name '*.egg-info' -exec rm -fr {} + |
||||
find . -name '*.egg' -exec rm -f {} + |
||||
|
||||
clean-pyc: ## remove Python file artifacts
|
||||
find . -name '*.pyc' -exec rm -f {} + |
||||
find . -name '*.pyo' -exec rm -f {} + |
||||
find . -name '*~' -exec rm -f {} + |
||||
find . -name '__pycache__' -exec rm -fr {} + |
||||
|
||||
clean-test: ## remove test and coverage artifacts
|
||||
rm -f .coverage |
||||
rm -fr htmlcov/ |
||||
|
||||
lint: ## check style with flake8
|
||||
black sartorius_logger tests |
||||
flake8 sartorius_logger tests |
||||
|
||||
test: ## run tests quickly (without tests for example.py) with the default Python
|
||||
pytest tests -x --disable-warnings -k "not app" |
||||
|
||||
coverage: ## check code coverage with the default Python
|
||||
pytest tests --cov=sartorius_logger |
||||
coverage html |
||||
$(BROWSER) htmlcov/index.html |
||||
|
||||
|
@ -1,3 +1,3 @@
@@ -1,3 +1,3 @@
|
||||
# sartoriuslogger |
||||
# Sartorius Logger |
||||
|
||||
Record serial measurements of a Sartorius Quintix Scale |
||||
Make time series measurements with a Sartorius scale. |
||||
|
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
[build-system] |
||||
requires = ["flit"] |
||||
build-backend = "flit.buildapi" |
||||
|
||||
[tool.flit.metadata] |
||||
module = "sartorius_logger" |
||||
author = "Holger Frey" |
||||
author-email = "frey@imtek.de" |
||||
home-page = "https://git.cpi.imtek.uni-freiburg.de/holgi/sartorius_logger" |
||||
description-file = "README.md" |
||||
classifiers = [ |
||||
"Development Status :: 4 - Beta", |
||||
"Intended Audience :: Developers", |
||||
"License :: Freely Distributable", |
||||
"Programming Language :: Python :: 3.7", |
||||
"Programming Language :: Python :: 3.8", |
||||
"Programming Language :: Python :: 3 :: Only", |
||||
"Topic :: Scientific/Engineering", |
||||
] |
||||
requires = [ |
||||
"sartoriusb >= 0.2.0", |
||||
"pandas >= 0.25.0", |
||||
"openpyxl >= 3.0.0", |
||||
"tqdm >= 4.37.0" |
||||
] |
||||
requires-python = ">=3.7" |
||||
|
||||
[tool.flit.metadata.requires-extra] |
||||
test = [ |
||||
"black", |
||||
"flake8", |
||||
"flake8-comprehensions", |
||||
"pytest >=4.0.0", |
||||
"pytest-cov", |
||||
"pytest-mock", |
||||
] |
||||
dev = [ |
||||
"keyring", |
||||
] |
||||
|
||||
[tool.black] |
||||
line-length = 79 |
||||
py37 = true |
||||
include = '\.pyi?$' |
||||
exclude = ''' |
||||
/( |
||||
\.git |
||||
| \.tox |
||||
| \.venv |
||||
| build |
||||
| dist |
||||
)/ |
||||
''' |
@ -0,0 +1,175 @@
@@ -0,0 +1,175 @@
|
||||
""" Sartorius Logger |
||||
|
||||
Make time series measurements with a Sartorius sartoriusb. |
||||
""" |
||||
|
||||
__version__ = "0.0.1" |
||||
|
||||
import pandas |
||||
import sartoriusb |
||||
import time |
||||
|
||||
from collections import namedtuple |
||||
from datetime import datetime |
||||
from tqdm import tqdm |
||||
|
||||
from .datalogger import DataLogger, NullLogger |
||||
from .parsers import parse_cli_arguments |
||||
|
||||
|
||||
SCALE_INFO_LABELS = { |
||||
sartoriusb.CMD_INFO_TYPE: "Scale Model", |
||||
sartoriusb.CMD_INFO_SNR: "Scale Serial Number", |
||||
sartoriusb.CMD_INFO_VERSION_SCALE: "Software Version of Scale", |
||||
sartoriusb.CMD_INFO_VERSION_CONTROL_UNIT: "Software Version of Control Unit", # noqa: E501 |
||||
} |
||||
|
||||
MEASUREMENT_KEYS = [ |
||||
"nr", |
||||
"time", |
||||
"mode", |
||||
"value", |
||||
"unit", |
||||
"stable", |
||||
"message", |
||||
] |
||||
|
||||
|
||||
Result = namedtuple("Result", ["info", "scale", "data", "log_file"]) |
||||
|
||||
|
||||
def get_scale_info(conn): |
||||
""" returns the available scale information """ |
||||
data = {} |
||||
|
||||
for command, label in SCALE_INFO_LABELS.items(): |
||||
raw_data_lines = conn.get(command) |
||||
if raw_data_lines: |
||||
raw_data = raw_data_lines[0] |
||||
raw_data = raw_data.strip() |
||||
parts = raw_data.split(maxsplit=1) |
||||
info = parts[1] if len(parts) > 1 else "" |
||||
else: |
||||
# propably a timeout of the serial connection |
||||
info = "" |
||||
data[label] = info |
||||
|
||||
return data |
||||
|
||||
|
||||
def _measure_and_log(nr, conn, logger): |
||||
""" performs and logs one measurement |
||||
|
||||
:params nr: number of measurement |
||||
:params conn: connection to the scale |
||||
:params log: data logger instance |
||||
:returns: dict for measurement point |
||||
""" |
||||
measurement = conn.measure() |
||||
data_list = [nr, datetime.now()] + list(measurement) |
||||
logger.add_list(data_list) |
||||
|
||||
data_dict = dict(zip(MEASUREMENT_KEYS, data_list)) |
||||
|
||||
# for the pandas data frame the value should be transformed into a float |
||||
try: |
||||
data_dict["value"] = float(data_dict["value"]) |
||||
except (ValueError, TypeError): |
||||
pass |
||||
|
||||
return data_dict |
||||
|
||||
|
||||
def no_progress_bar(iterator): |
||||
"""" as stub function for not displaying a progress bar """ |
||||
return iterator |
||||
|
||||
|
||||
def _get_log_file_path(settings): |
||||
""" constructs the path to the log file """ |
||||
|
||||
now = datetime.now() |
||||
log_file_name = now.strftime("%Y-%m-%d %H-%M-%S") + ".txt" |
||||
return settings.directory / log_file_name |
||||
|
||||
|
||||
def _log_measurement_info(logger, settings): |
||||
""" logs all measurement info """ |
||||
nr_of_measurements = 1 + ( |
||||
settings.duration.seconds // settings.interval.seconds |
||||
) |
||||
measurement_info = { |
||||
"Measurements": nr_of_measurements, |
||||
"Duration": f"{settings.duration.value}{settings.duration.unit}", |
||||
"Interval": f"{settings.interval.value}{settings.interval.unit}", |
||||
"Com-Port": settings.port, |
||||
} |
||||
logger.add_section("Measurement Settings", measurement_info.items()) |
||||
return measurement_info |
||||
|
||||
|
||||
def _log_scale_info(logger, conn): |
||||
""" logs common scale info """ |
||||
scale_info = get_scale_info(conn) |
||||
logger.add_section("Scale Info", scale_info.items()) |
||||
return scale_info |
||||
|
||||
|
||||
def measure_series(settings, progress_bar=no_progress_bar, data_logger=None): |
||||
""" serial measurements |
||||
|
||||
will return the data as pandas data frames in a "Result" named tuple |
||||
|
||||
:params settings: parser.Settings named tuple |
||||
:params progress_bar: progress bar function to use |
||||
:params data_logger: class of the data logger to use |
||||
:returns: named tuple "Result" |
||||
""" |
||||
data_logger = data_logger or NullLogger() |
||||
|
||||
data_collection = [] |
||||
|
||||
with data_logger as logger: |
||||
measurement_info = _log_measurement_info(logger, settings) |
||||
|
||||
with sartoriusb.SartoriusUsb(settings.port) as conn: |
||||
scale_info = _log_scale_info(logger, conn) |
||||
|
||||
# add column headers |
||||
headers = [item.capitalize() for item in MEASUREMENT_KEYS] |
||||
logger.add_section( |
||||
"Measured Data", [headers], append_empty_line=False |
||||
) |
||||
|
||||
nr_of_measurements = measurement_info["Measurements"] |
||||
for i in progress_bar(range(1, nr_of_measurements)): |
||||
data = _measure_and_log(i, conn, logger) |
||||
data_collection.append(data) |
||||
time.sleep(settings.interval.seconds) |
||||
|
||||
data = _measure_and_log(nr_of_measurements, conn, logger) |
||||
data_collection.append(data) |
||||
|
||||
data_df = pandas.DataFrame(data_collection).set_index("time") |
||||
info_df = pandas.DataFrame(measurement_info.items()).set_index(0) |
||||
scale_df = pandas.DataFrame(scale_info.items()).set_index(0) |
||||
|
||||
return Result(info_df, scale_df, data_df, data_logger.path) |
||||
|
||||
|
||||
def export_as_excel(measurement_result): |
||||
""" saves the collected data as an Excel file """ |
||||
excel_path = measurement_result.log_file.with_suffix(".xlsx") |
||||
with pandas.ExcelWriter(excel_path) as writer: |
||||
measurement_result.data.to_excel(writer, sheet_name="Measurements") |
||||
measurement_result.info.to_excel(writer, sheet_name="Settings") |
||||
measurement_result.scale.to_excel(writer, sheet_name="Scale") |
||||
|
||||
|
||||
def cli(): |
||||
settings = parse_cli_arguments() |
||||
log_file_path = _get_log_file_path(settings) |
||||
result = measure_series( |
||||
settings, progress_bar=tqdm, data_logger=DataLogger(log_file_path) |
||||
) |
||||
export_as_excel(result) |
@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
|
||||
""" data logger """ |
||||
|
||||
from pathlib import Path |
||||
|
||||
|
||||
CR = chr(13) |
||||
LF = chr(10) |
||||
|
||||
|
||||
def is_container(obj): |
||||
"""' checks if the object is iterable but not string or bytes """ |
||||
return hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes)) |
||||
|
||||
|
||||
class DataLogger: |
||||
def __init__(self, log_path, none_rep="", sep="\t", end=CR + LF): |
||||
""" initializes the data logger class |
||||
|
||||
:params log_path: file path of the log file |
||||
:params none_rep: string representation for None values |
||||
:params sep: character used to separate values in a list |
||||
:params end: character used for line ending |
||||
""" |
||||
|
||||
self.path = Path(log_path) |
||||
self.end = end |
||||
self.sep = sep |
||||
self.none_rep = none_rep |
||||
|
||||
self.handle = None |
||||
|
||||
def open(self): |
||||
""" opens the log file for writing """ |
||||
if self.handle is None: |
||||
self.path.parent.mkdir(parents=True, exist_ok=True) |
||||
self.handle = self.path.open("a") |
||||
|
||||
def close(self): |
||||
""" closes the log file for writing """ |
||||
if self.handle: |
||||
self.handle.close() |
||||
self.handle = None |
||||
|
||||
def __call__(self, data): |
||||
""" writes data to the log |
||||
|
||||
:params data: either a string or a container, like a list |
||||
""" |
||||
if is_container(data): |
||||
self.add_list(data) |
||||
else: |
||||
self.add(data) |
||||
|
||||
def add(self, text): |
||||
""" adds one line of text to the logfile """ |
||||
if self.handle is None: |
||||
raise IOError("Log file not opened for writing") |
||||
text = self._universal_new_lines(text) |
||||
self.handle.write(text) |
||||
|
||||
def add_list(self, data): |
||||
""" adds the values in a list to one line in the log FileCache |
||||
|
||||
The string set in the `sep` property is used to separate the values |
||||
in the output, None values are represented by the string in `none_rep` |
||||
property. |
||||
""" |
||||
conveted_nones = ((self.none_rep if v is None else v) for v in data) |
||||
value_strings = (str(v) for v in conveted_nones) |
||||
line = self.sep.join(value_strings) |
||||
self.add(line) |
||||
|
||||
def add_section(self, title, list_of_data, append_empty_line=True): |
||||
""" adds a section to the log file """ |
||||
title = title.strip() |
||||
self.add(f"[{title}]") |
||||
for data in list_of_data: |
||||
self.add_list(data) |
||||
if append_empty_line: |
||||
self.add("") |
||||
|
||||
def __enter__(self): |
||||
""" Context manager: opens the log file for writing """ |
||||
self.open() |
||||
return self |
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback): |
||||
""" Context manager: closes the log file """ |
||||
self.close() |
||||
|
||||
def _universal_new_lines(self, text): |
||||
""" unifies new line characters of a text |
||||
|
||||
The `end` property is used as a newline character and ensures one is |
||||
present at the end of the text. |
||||
""" |
||||
lines = text.splitlines() |
||||
return self.end.join(lines) + self.end |
||||
|
||||
|
||||
class NullLogger: |
||||
""" A stub logger with the DataLogger interface that does nothing """ |
||||
|
||||
def __init__(self, *args, **kargs): |
||||
pass |
||||
|
||||
def __call__(self, *args, **kargs): |
||||
pass |
||||
|
||||
def __getattr__(self, key): |
||||
return self |
||||
|
||||
def __enter__(self): |
||||
return self |
||||
|
||||
def __exit__(self, *args, **kargs): |
||||
pass |
@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
|
||||
import argparse |
||||
import re |
||||
|
||||
from collections import namedtuple |
||||
from pathlib import Path |
||||
|
||||
|
||||
TIME_UNIT_FACTORS = { |
||||
"s": 1, |
||||
"m": 60, |
||||
"h": 60 * 60, |
||||
} |
||||
|
||||
ParsedDuration = namedtuple("ParsedDuration", ["value", "unit", "seconds"]) |
||||
|
||||
Settings = namedtuple( |
||||
"Settings", ["duration", "interval", "directory", "port"] |
||||
) |
||||
|
||||
|
||||
def parse_duration(raw_value, default_unit="m"): |
||||
"""' parses a duration like 10sec, 5min or 2h |
||||
|
||||
:params raw_value: string value to parse |
||||
:params default_unit: default unit to use, if no (known) unit is present |
||||
:returns: ParsedTime named tuple or None |
||||
""" |
||||
match = re.match(r"(?P<value>\d+)(?P<unit>.*)", raw_value) |
||||
if not match: |
||||
return None |
||||
value = int(match["value"]) |
||||
unit = _normalize_time_unit(match["unit"], default_unit=default_unit) |
||||
factor = TIME_UNIT_FACTORS[unit] |
||||
return ParsedDuration(value, unit, value * factor) |
||||
|
||||
|
||||
def parse_cli_arguments(): |
||||
""" parses command line interface arguments """ |
||||
parser = argparse.ArgumentParser( |
||||
description="Make time series measurements with a Sartorius scale.", |
||||
epilog=( |
||||
"Times can be specified as 10s, 10m, or 10h" |
||||
"for seconds, minutes or hours respectively." |
||||
"A relative directory path starts at the Desktop." |
||||
), |
||||
) |
||||
parser.add_argument("port", nargs="?", default="COM4", metavar="COMPORT") |
||||
parser.add_argument("-d", "--duration", nargs="?", default="30m") |
||||
parser.add_argument("-i", "--interval", nargs="?", default="10s") |
||||
parser.add_argument("-o", "--output", nargs="?", metavar="DIRECTORY") |
||||
raw_arguments = parser.parse_args() |
||||
return _normalize_cli_arguments(raw_arguments) |
||||
|
||||
|
||||
# helper functions |
||||
|
||||
|
||||
def _normalize_time_unit(raw_unit, default_unit): |
||||
""" checks if a known time unit is present, else uses the default unit """ |
||||
known_units = "hms" |
||||
found = [unit for unit in known_units if unit in raw_unit.lower()] |
||||
unit = found[0] if found else default_unit |
||||
return unit |
||||
|
||||
|
||||
def _normalize_cli_arguments(raw_arguments): |
||||
""" transforms cli arguments into a suitable form """ |
||||
# time related stuff |
||||
duration = parse_duration(raw_arguments.duration, default_unit="m") |
||||
interval = parse_duration(raw_arguments.interval, default_unit="s") |
||||
|
||||
# directory stuff |
||||
dir_path = _check_output_directory_path(raw_arguments.output) |
||||
|
||||
return Settings(duration, interval, dir_path, raw_arguments.port) |
||||
|
||||
|
||||
def _check_output_directory_path(raw_path): |
||||
""" returns the absolue path of the output directory |
||||
|
||||
The desktop path (~/Desktop) is considered the default directory. If a |
||||
relative path is provided, it is considered relative to the desktop. |
||||
""" |
||||
# the desktop path is the default output directory |
||||
out_path = desktop_path = Path.home() / "Desktop" |
||||
|
||||
# if a relative path is provided, make it relative to the desktop |
||||
if raw_path: |
||||
out_path = Path(raw_path) |
||||
if not out_path.is_absolute(): |
||||
out_path = desktop_path / out_path |
||||
out_path = out_path.resolve() |
||||
|
||||
# if the provided path is something strange like an existing file, |
||||
# use the default path (Desktop) |
||||
if out_path.exists() and not out_path.is_dir(): |
||||
out_path = desktop_path |
||||
|
||||
return out_path |
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
""" Test package for the sartorius_logger package """ |
@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
|
||||
import sartoriusb |
||||
|
||||
from sartorius_logger.datalogger import DataLogger |
||||
|
||||
|
||||
class DummyLogger(DataLogger): |
||||
""" A DataLogger implementation for testing """ |
||||
|
||||
def __init__(self, *args, **kargs): |
||||
super().__init__("log/file.txt", end="") |
||||
self.handle = StubFile() |
||||
|
||||
def open(self): |
||||
""" simulates opening the log file for writing """ |
||||
pass |
||||
|
||||
def close(self): |
||||
""" simulates closing the log file for writing """ |
||||
pass |
||||
|
||||
|
||||
class StubFile: |
||||
""" a stub file handle that captures everything written to it """ |
||||
|
||||
def __init__(self): |
||||
self.captured = [] |
||||
|
||||
def write(self, data): |
||||
""" simulates a filehandle write """ |
||||
self.captured.append(data) |
||||
|
||||
def __eq__(self, other): |
||||
""" quick way of comparing the captured output to something other """ |
||||
return self.captured == other |
||||
|
||||
|
||||
class DummyScale: |
||||
""" simulates a sartoriusb.SartoriusUSB class """ |
||||
|
||||
_responses = { |
||||
sartoriusb.CMD_INFO_TYPE: "Model: Quintix-1\r\n", |
||||
sartoriusb.CMD_INFO_SNR: "SerialNumber: 0815 \r", |
||||
sartoriusb.CMD_INFO_VERSION_SCALE: "ScaleVersion beta1\n", |
||||
sartoriusb.CMD_INFO_VERSION_CONTROL_UNIT: "Version.command 1.2.3", |
||||
} |
||||
|
||||
_measurements = [ |
||||
"G + 850.1 \r\n", |
||||
"G + 851.0 \r\n", |
||||
"G + 852.2 \r\n", |
||||
"G + 853.0 mg \r\n", |
||||
"G + 854.4 mg \r\n", |
||||
"G + 857.0 mg \r\n", |
||||
"G + 857.0 mg \r\n", |
||||
"G + 858.7 mg \r\n", |
||||
"G + 859.0 mg \r\n", |
||||
"G - 1000.0 mg \r\n", |
||||
" Low \r\n", |
||||
"G 0.0 \r\n", |
||||
"G 0.0 mg \r\n", |
||||
] |
||||
|
||||
def __init__(self, *args, measurements=None, **kargs): |
||||
""" intializes the dummy scale |
||||
|
||||
:params measurements: overides for the build in measurements |
||||
:params responses: overides for the build in responses |
||||
""" |
||||
if measurements is None: |
||||
self.measurements = self._measurements.copy() |
||||
else: |
||||
self.measurements = measurements |
||||
self.responses = self._responses.copy() |
||||
self.iterator = iter(self.measurements) |
||||
|
||||
def get(self, command): |
||||
""" simulates the get() method """ |
||||
response = self.responses.get(command, False) |
||||
return [response] if response else [] |
||||
|
||||
def measure(self): |
||||
""" simulates the measure() method """ |
||||
try: |
||||
value = next(self.iterator) |
||||
return sartoriusb.parse_measurement(value) |
||||
except StopIteration: |
||||
return sartoriusb.Measurement( |
||||
None, None, None, None, "Connection Timeout" |
||||
) |
||||
|
||||
def __enter__(self): |
||||
return self |
||||
|
||||
def __exit__(self, *args, **kargs): |
||||
pass |
@ -0,0 +1,290 @@
@@ -0,0 +1,290 @@
|
||||
""" tests for the sartorius_logger package """ |
||||
|
||||
import pytest |
||||
|
||||
|
||||
@pytest.fixture(scope="session") |
||||
def settings_fixture(): |
||||
from sartorius_logger.parsers import ParsedDuration, Settings |
||||
from pathlib import Path |
||||
|
||||
duration = ParsedDuration(5, "m", 5 * 60) |
||||
interval = ParsedDuration(10, "s", 10) |
||||
directory = Path("/something/made/up") |
||||
|
||||
yield Settings(duration, interval, directory, "usb") |
||||
|
||||
|
||||
@pytest.fixture() |
||||
def logger_fixture(): |
||||
from . import stubs |
||||
|
||||
yield stubs.DummyLogger() |
||||
|
||||
|
||||
@pytest.fixture() |
||||
def scale_fixture(): |
||||
from . import stubs |
||||
|
||||
yield stubs.DummyScale() |
||||
|
||||
|
||||
def test_get_scale_info(scale_fixture): |
||||
from sartorius_logger import get_scale_info |
||||
|
||||
result = get_scale_info(scale_fixture) |
||||
|
||||
assert result == { |
||||
"Scale Model": "Quintix-1", |
||||
"Scale Serial Number": "0815", |
||||
"Software Version of Scale": "beta1", |
||||
"Software Version of Control Unit": "1.2.3", |
||||
} |
||||
|
||||
def test_get_scale_info_with_timeout(scale_fixture): |
||||
from sartorius_logger import get_scale_info |
||||
from sartoriusb import CMD_INFO_SNR |
||||
|
||||
scale_fixture.responses[CMD_INFO_SNR] = "" |
||||
result = get_scale_info(scale_fixture) |
||||
|
||||
assert result == { |
||||
"Scale Model": "Quintix-1", |
||||
"Scale Serial Number": "", |
||||
"Software Version of Scale": "beta1", |
||||
"Software Version of Control Unit": "1.2.3", |
||||
} |
||||
|
||||
|
||||
def test_measure_and_log(scale_fixture, logger_fixture): |
||||
from sartorius_logger import _measure_and_log |
||||
from datetime import datetime |
||||
|
||||
result = _measure_and_log(42, scale_fixture, logger_fixture) |
||||
|
||||
reported_time = result.pop("time") |
||||
assert isinstance(reported_time, datetime) |
||||
assert (datetime.now() - reported_time).total_seconds() < 10 |
||||
assert result == { |
||||
"nr": 42, |
||||
"mode": "G", |
||||
"value": 850.1, |
||||
"unit": None, |
||||
"stable": False, |
||||
"message": None, |
||||
} |
||||
assert logger_fixture.handle == [ |
||||
"\t".join(["42", str(reported_time), "G", "+850.1", "", "False", ""]) |
||||
] |
||||
|
||||
|
||||
def test_measure_and_log_with_none_value(logger_fixture): |
||||
from sartorius_logger import _measure_and_log |
||||
from datetime import datetime |
||||
from . import stubs |
||||
|
||||
scale = stubs.DummyScale(measurements=[]) |
||||
result = _measure_and_log(42, scale, logger_fixture) |
||||
|
||||
reported_time = result.pop("time") |
||||
assert isinstance(reported_time, datetime) |
||||
assert result == { |
||||
"nr": 42, |
||||
"mode": None, |
||||
"value": None, |
||||
"unit": None, |
||||
"stable": None, |
||||
"message": "Connection Timeout", |
||||
} |
||||
assert logger_fixture.handle.captured == [ |
||||
"\t".join( |
||||
["42", str(reported_time), "", "", "", "", "Connection Timeout"] |
||||
) |
||||
] |
||||
|
||||
|
||||
def test_no_progress_bar_returns_iterator_unchanged(): |
||||
from sartorius_logger import no_progress_bar |
||||
|
||||
iterator = [] |
||||
|
||||
assert no_progress_bar(iterator) is iterator |
||||
|
||||
|
||||
def test_get_log_file_path(settings_fixture): |
||||
from sartorius_logger import _get_log_file_path |
||||
from datetime import datetime |
||||
from pathlib import Path |
||||
|
||||
result = _get_log_file_path(settings_fixture) |
||||
|
||||
assert isinstance(result, Path) |
||||
assert result.suffix == ".txt" |
||||
dt = datetime.strptime(result.stem, "%Y-%m-%d %H-%M-%S") |
||||
assert (datetime.now() - dt).total_seconds() < 10 |
||||
|
||||
|
||||
def test_log_measurement_info(logger_fixture, settings_fixture): |
||||
from sartorius_logger import _log_measurement_info |
||||
|
||||
result = _log_measurement_info(logger_fixture, settings_fixture) |
||||
|
||||
assert result == { |
||||
"Measurements": 31, |
||||
"Duration": "5m", |
||||
"Interval": "10s", |
||||
"Com-Port": "usb", |
||||
} |
||||
assert logger_fixture.handle == [ |
||||
"[Measurement Settings]", |
||||
"Measurements\t31", |
||||
"Duration\t5m", |
||||
"Interval\t10s", |
||||
"Com-Port\tusb", |
||||
"", |
||||
] |
||||
|
||||
|
||||
def test_log_scale_info(logger_fixture, scale_fixture): |
||||
from sartorius_logger import _log_scale_info |
||||
|
||||
result = _log_scale_info(logger_fixture, scale_fixture) |
||||
|
||||
assert result == { |
||||
"Scale Model": "Quintix-1", |
||||
"Scale Serial Number": "0815", |
||||
"Software Version of Scale": "beta1", |
||||
"Software Version of Control Unit": "1.2.3", |
||||
} |
||||
assert logger_fixture.handle == [ |
||||
"[Scale Info]", |
||||
"Scale Model\tQuintix-1", |
||||
"Scale Serial Number\t0815", |
||||
"Software Version of Scale\tbeta1", |
||||
"Software Version of Control Unit\t1.2.3", |
||||
"", |
||||
] |
||||
|
||||
|
||||
def test_measure_series(mocker, settings_fixture, logger_fixture): |
||||
from sartorius_logger import measure_series, Result |
||||
from pandas import DataFrame |
||||
from pathlib import Path |
||||
|
||||
from .stubs import DummyScale |
||||
|
||||
mocker.patch("sartorius_logger.sartoriusb.SartoriusUsb", DummyScale) |
||||
mocker.patch("sartorius_logger.time.sleep") |
||||
result = measure_series(settings_fixture, data_logger=logger_fixture) |
||||
|
||||
assert isinstance(result, Result) |
||||
|
||||
assert isinstance(result.log_file, Path) |
||||
assert result.log_file.parent == Path("log") |
||||
assert result.log_file.suffix == ".txt" |
||||
|
||||
assert isinstance(result.info, DataFrame) |
||||
assert result.info.to_dict() == { |
||||
1: { |
||||
"Measurements": 31, |
||||
"Duration": "5m", |
||||
"Interval": "10s", |
||||
"Com-Port": "usb", |
||||
} |
||||
} |
||||
|
||||
assert isinstance(result.scale, DataFrame) |
||||
assert result.scale.to_dict() == { |
||||
1: { |
||||
"Scale Model": "Quintix-1", |
||||
"Scale Serial Number": "0815", |
||||
"Software Version of Scale": "beta1", |
||||
"Software Version of Control Unit": "1.2.3", |
||||
} |
||||
} |
||||
|
||||
assert isinstance(result.data, DataFrame) |
||||
df_dict = {k: list(v.values()) for k, v in result.data.to_dict().items()} |
||||
|
||||
assert df_dict["nr"] == list(range(1, 32)) |
||||
assert df_dict["mode"] == ["G"] * 10 + [None, "G", "G"] + [None] * 18 |
||||
# some hassle with nan values |
||||
values = [v if "." in str(v) else None for v in df_dict["value"]] |
||||
assert ( |
||||
values |
||||
== [850.1, 851.0, 852.2, 853.0, 854.4, 857.0, 857.0] |
||||
+ [858.7, 859.0, -1000.0, None, 0.0, 0.0] |
||||
+ [None] * 18 |
||||
) |
||||
assert ( |
||||
df_dict["unit"] |
||||
== [None, None, None, "mg", "mg", "mg", "mg"] |
||||
+ ["mg", "mg", "mg", None, None, "mg"] |
||||
+ [None] * 18 |
||||
) |
||||
assert ( |
||||
df_dict["stable"] |
||||
== [False, False, False, True, True, True, True] |
||||
+ [True, True, True, None, False, True] |
||||
+ [None] * 18 |
||||
) |
||||
assert ( |
||||
df_dict["message"] |
||||
== [None] * 10 + ["Low", None, None] + ["Connection Timeout"] * 18 |
||||
) |
||||
|
||||
log = logger_fixture.handle.captured |
||||
assert len(log) == 45 |
||||
assert log[0] == "[Measurement Settings]" |
||||
assert log[6] == "[Scale Info]" |
||||
assert log[12] == "[Measured Data]" |
||||
assert log[13] == "Nr\tTime\tMode\tValue\tUnit\tStable\tMessage" |
||||
assert log[14].startswith("1\t") |
||||
assert log[14].endswith("\tG\t+850.1\t\tFalse\t") |
||||
assert log[20].startswith("7\t") |
||||
assert log[20].endswith("\tG\t+857.0\tmg\tTrue\t") |
||||
assert log[24].startswith("11\t") |
||||
assert log[24].endswith("\t\t\t\t\tLow") |
||||
assert log[-1].startswith("31\t") |
||||
assert log[-1].endswith("\t\t\t\t\tConnection Timeout") |
||||
|
||||
|
||||
def test_export_as_excel(mocker, settings_fixture, logger_fixture): |
||||
from sartorius_logger import measure_series, export_as_excel |
||||
from pathlib import Path |
||||
from tempfile import TemporaryDirectory |
||||
from .stubs import DummyScale |
||||
|
||||
mocker.patch("sartorius_logger.sartoriusb.SartoriusUsb", DummyScale) |
||||
mocker.patch("sartorius_logger.time.sleep") |
||||
result = measure_series(settings_fixture, data_logger=logger_fixture) |
||||
|
||||
with TemporaryDirectory() as temp_path: |
||||
temp_log_file = Path(temp_path) / "measurement.txt" |
||||
expected_xls_path = Path(temp_path) / "measurement.xlsx" |
||||
result = result._replace(log_file=temp_log_file) |
||||
export_as_excel(result) |
||||
|
||||
assert expected_xls_path.is_file() |
||||
|
||||
|
||||
def test_cli(mocker, settings_fixture): |
||||
mocker.patch("sartorius_logger.parse_cli_arguments", return_value=settings_fixture) |
||||
mocker.patch("sartorius_logger.measure_series", return_value="Results") |
||||
mocker.patch("sartorius_logger.export_as_excel") |
||||
|
||||
from sartorius_logger import cli, export_as_excel, measure_series |
||||
from sartorius_logger.datalogger import DataLogger |
||||
from tqdm import tqdm |
||||
from unittest.mock import call |
||||
|
||||
cli() |
||||
|
||||
assert measure_series.call_count == 1 |
||||
print(measure_series.call_args) |
||||
call_args, call_kargs = measure_series.call_args |
||||
assert call_args == (settings_fixture, ) |
||||
assert call_kargs["progress_bar"] == tqdm |
||||
assert isinstance(call_kargs["data_logger"], DataLogger) |
||||
assert export_as_excel.call_count == 1 |
||||
assert export_as_excel.call_args == call("Results") |
@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
|
||||
""" tests for the sartorius_logger.datalogger module """ |
||||
|
||||
import pytest |
||||
|
||||
from unittest.mock import call |
||||
|
||||
|
||||
@pytest.mark.parametrize( |
||||
"value,expected", |
||||
[ |
||||
([], True), |
||||
((), True), |
||||
({}, True), |
||||
(set(), True), |
||||
(iter("A"), True), |
||||
("string", False), |
||||
(b"bytes", False), |
||||
], |
||||
) |
||||
def test_is_container(value, expected): |
||||
from sartorius_logger.datalogger import is_container |
||||
|
||||
result = is_container(value) |
||||
|
||||
assert result == expected |
||||
|
||||
|
||||
def test_datalogger_init(): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
from pathlib import Path |
||||
|
||||
logger = DataLogger("/logs", none_rep="<none>", sep=";", end="<br>") |
||||
|
||||
assert isinstance(logger.path, Path) |
||||
assert logger.path == Path("/logs") |
||||
assert logger.none_rep == "<none>" |
||||
assert logger.sep == ";" |
||||
assert logger.end == "<br>" |
||||
assert logger.handle is None |
||||
|
||||
|
||||
def test_datalogger_open(mocker): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
from pathlib import Path |
||||
from tempfile import TemporaryDirectory |
||||
|
||||
with TemporaryDirectory() as tmp_dir: |
||||
tmp_dir_path = Path(tmp_dir) |
||||
log_folder = tmp_dir_path / "new_folder" |
||||
log_path = log_folder / "test.log" |
||||
|
||||
logger = DataLogger(log_path) |
||||
logger.open() |
||||
|
||||
assert log_folder.exists() |
||||
assert log_path.exists() |
||||
|
||||
logger.close() |
||||
|
||||
|
||||
def test_datalogger_open_on_already_opened_connection(mocker): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
|
||||
logger = DataLogger("log_path") |
||||
logger.handle = "not None" |
||||
logger.path = mocker.Mock() |
||||
|
||||
logger.open() |
||||
|
||||
assert logger.path.open.call_count == 0 |
||||
|
||||
|
||||
def test_datalogger_close(mocker): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
|
||||
logger = DataLogger("log_path") |
||||
logger.handle = mocker.Mock() |
||||
mocked_handle = logger.handle |
||||
|
||||
logger.close() |
||||
|
||||
assert mocked_handle.close.call_count == 1 |
||||
assert logger.handle is None |
||||
|
||||
|
||||
@pytest.mark.parametrize( |
||||
"value,add_count,list_count", [(["a"], 0, 1), ("b", 1, 0)] |
||||
) |
||||
def test_datalogger_call(mocker, value, add_count, list_count): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
|
||||
logger = DataLogger("log_path") |
||||
mocker.patch.object(logger, "add") |
||||
mocker.patch.object(logger, "add_list") |
||||
|
||||
logger(value) |
||||
|
||||
assert logger.add.call_count == add_count |
||||
assert logger.add_list.call_count == list_count |
||||
|
||||
|
||||
def test_datalogger_add(mocker): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
|
||||
logger = DataLogger("log_path", end="<br>") |
||||
logger.handle = mocker.Mock() |
||||
|
||||
logger.add("some \n text \r lines") |
||||
|
||||
assert logger.handle.write.call_count == 1 |
||||
assert logger.handle.write.call_args == call( |
||||
"some <br> text <br> lines<br>" |
||||
) |
||||
|
||||
|
||||
def test_datalogger_add_raises_error_if_logfile_not_opened(): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
|
||||
logger = DataLogger("log_path") |
||||
|
||||
with pytest.raises(IOError): |
||||
logger.add("whoops") |
||||
|
||||
|
||||
def test_datalogger_add_list(mocker): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
|
||||
logger = DataLogger("log_path", none_rep="REDACTED", sep="; ", end="<br>") |
||||
logger.add = mocker.Mock() |
||||
|
||||
logger.add_list(["foo", 1, None, "", "bar"]) |
||||
|
||||
assert logger.add.call_count == 1 |
||||
assert logger.add.call_args == call("foo; 1; REDACTED; ; bar") |
||||
|
||||
|
||||
@pytest.mark.parametrize("add_empty_line", [True, False]) |
||||
def test_datalogger_add_section(mocker, add_empty_line): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
|
||||
logger = DataLogger("log_path", none_rep="REDACTED", sep="; ", end="<br>") |
||||
logger.add = mocker.Mock() |
||||
|
||||
logger.add_section( |
||||
"A really cool title\n", [[1, 2], [3, 4, 5]], add_empty_line |
||||
) |
||||
|
||||
assert logger.add.call_count == 4 if add_empty_line else 3 |
||||
assert logger.add.call_args_list[:3] == [ |
||||
call("[A really cool title]"), |
||||
call("1; 2"), |
||||
call("3; 4; 5"), |
||||
] |
||||
if add_empty_line: |
||||
logger.add.call_args = call("") |
||||
|
||||
|
||||
def test_datalogger_as_context_manager(mocker): |
||||
from sartorius_logger.datalogger import DataLogger |
||||
|
||||
logger = DataLogger("log_path") |
||||
logger.open = mocker.Mock() |
||||
logger.close = mocker.Mock() |
||||
|
||||
with logger as context: |
||||
assert logger is context |
||||
|
||||
assert logger.open.call_count == 1 |
||||
assert logger.close.call_count == 1 |
||||
|
||||
|
||||
def test_datalogger_null_logger_init_should_not_raise_error(mocker): |
||||
from sartorius_logger.datalogger import NullLogger |
||||
|
||||
NullLogger("log_path", end="<br>", unknown="nothing") |
||||
|
||||
|
||||
def test_datalogger_null_logger_is_callable(mocker): |
||||
from sartorius_logger.datalogger import NullLogger |
||||
|
||||
nl = NullLogger() |
||||
|
||||
assert nl("whatever") is None |
||||
|
||||
|
||||
def test_datalogger_null_logger_attribute_access_returns_instance(mocker): |
||||
from sartorius_logger.datalogger import NullLogger |
||||
|
||||
nl = NullLogger() |
||||
|
||||
assert nl.some_really_weird_attribute is nl |
||||
|
||||
|
||||
def test_datalogger_null_logger_allows_method_calls(mocker): |
||||
from sartorius_logger.datalogger import NullLogger |
||||
|
||||
nl = NullLogger() |
||||
|
||||
assert nl.calling_any_method(1, b=2) is None |
||||
|
||||
|
||||
def test_datalogger_null_as_context_manager(mocker): |
||||
from sartorius_logger.datalogger import NullLogger |
||||
|
||||
with NullLogger() as nl: |
||||
assert isinstance(nl, NullLogger) |
@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
|
||||
""" tests for the sartorius_logger.parsers module """ |
||||
|
||||
import pytest |
||||
|
||||
from collections import namedtuple |
||||
|
||||
|
||||
@pytest.mark.parametrize( |
||||
"text,value,unit,seconds", |
||||
[ |
||||
("1", 1, "h", 60 * 60), |
||||
("12", 12, "h", 12 * 60 * 60), |
||||
("3s", 3, "s", 3), |
||||
("4sec", 4, "s", 4), |
||||
("5M", 5, "m", 5 * 60), |
||||
("6mins", 6, "m", 6 * 60), |
||||
("7h", 7, "h", 7 * 60 * 60), |
||||
("8hours", 8, "h", 8 * 60 * 60), |
||||
("9x", 9, "h", 9 * 60 * 60), |
||||
("10s", 10, "s", 10), |
||||
("11.5 m", 11, "m", 11 * 60), |
||||
], |
||||
) |
||||
def test_parse_duration(text, value, unit, seconds): |
||||
from sartorius_logger.parsers import parse_duration, ParsedDuration |
||||
|
||||
result = parse_duration(text, default_unit="h") |
||||
|
||||
assert isinstance(result, ParsedDuration) |
||||
assert result == (value, unit, seconds) |
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["m", "", "x1"]) |
||||
def test_parse_duration_none_if_not_number_on_start(value): |
||||
from sartorius_logger.parsers import parse_duration |
||||
|
||||
result = parse_duration(value) |
||||
|
||||
assert result is None |
||||
|
||||
|
||||
@pytest.mark.parametrize( |
||||
"value,expected", |
||||
[ |
||||
("s", "s"), |
||||
("m", "m"), |
||||
("h", "h"), |
||||
("sec", "s"), |
||||
("min", "m"), |
||||
("hours", "h"), |
||||
("seconds", "s"), |
||||
("minutes", "m"), |
||||
("hours", "h"), |
||||
("", "<deafault>"), |
||||
("unknown", "<deafault>"), |
||||
], |
||||
) |
||||
def test_normalize_time_unit(value, expected): |
||||
from sartorius_logger.parsers import _normalize_time_unit |
||||
|
||||
result = _normalize_time_unit(value, default_unit="<deafault>") |
||||
|
||||
assert result == expected |
||||
|
||||
|
||||
def test_parse_cli_arguments(mocker): |
||||
from sartorius_logger.parsers import parse_cli_arguments |
||||
|
||||
mocked_argparse = mocker.patch( |
||||
"sartorius_logger.parsers.argparse.ArgumentParser" |
||||
) |
||||
mocked_normalize = mocker.patch( |
||||
"sartorius_logger.parsers._normalize_cli_arguments" |
||||
) |
||||
|
||||
parse_cli_arguments() |
||||
|
||||
assert mocked_argparse.call_count == 1 |
||||
assert mocked_normalize.call_count == 1 |
||||
|
||||
|
||||
def test_normalize_cli_arguments(mocker): |
||||
from sartorius_logger.parsers import ( |
||||
_normalize_cli_arguments, |
||||
Settings, |
||||
) |
||||
import pathlib |
||||
|
||||
Dummy = namedtuple("Dummy", ["duration", "interval", "output", "port"]) |
||||
arguments = Dummy("2h", "30min", "New Folder", "usb") |
||||
|
||||
result = _normalize_cli_arguments(arguments) |
||||
|
||||
assert isinstance(result, Settings) |
||||
assert result.duration == (2, "h", 2 * 60 * 60) |
||||
assert result.interval == (30, "m", 30 * 60) |
||||
assert result.port == "usb" |
||||
assert isinstance(result.directory, pathlib.Path) |
||||
assert result.directory.name == "New Folder" |
||||
assert result.directory.parent.name == "Desktop" |
||||
|
||||
|
||||
@pytest.mark.parametrize( |
||||
"value,expected", |
||||
[ |
||||
("", ("example", "Desktop")), |
||||
("Some Folder", ("example", "Desktop", "Some Folder")), |
||||
("/Some/Folder", ("Some", "Folder",)), |
||||
], |
||||
) |
||||
def test_check_output_directory_path(mocker, value, expected): |
||||
from sartorius_logger.parsers import _check_output_directory_path, Path |
||||
|
||||
mocker.patch.object(Path, "home", return_value=Path("/example")) |
||||
mocker.patch.object(Path, "exists", return_value=False) |
||||
|
||||
result = _check_output_directory_path(value) |
||||
|
||||
assert result == Path("/").joinpath(*expected) |
||||
|
||||
|
||||
def test_check_output_directory_path_exist_not_dir(mocker): |
||||
from sartorius_logger.parsers import _check_output_directory_path, Path |
||||
|
||||
mocker.patch.object(Path, "home", return_value=Path("/example")) |
||||
mocker.patch.object(Path, "exists", return_value=True) |
||||
mocker.patch.object(Path, "is_dir", return_value=False) |
||||
|
||||
result = _check_output_directory_path("/some/existing/folder") |
||||
|
||||
assert result == Path("/") / "example" / "Desktop" |
Loading…
Reference in new issue