Skip to content

Commit ecd9b6c

Browse files
semperventGrant, JoshMacFarland, Midgie
authored
Add badges, Classifiers, CI/CD Testing, Improved TWST Collect, opensampl-server2 (#7)
This adds badges to the README.md for quick knowledge of the Python software's current status, as well as classifiers to better enable package discovery, and a CI/CD workflow for running pytest. Extends the Microchip TWST collect script to collect all data entries by default and moves the filtering of which measurements should be sent to the db to the MicrochipTWST Probe Object. An additional entrypoint opensampl-server2 was added which more directly wraps docker compose while providing the correct compose file and env file. --------- Co-authored-by: Grant, Josh <grantjn@ornl.gov> Co-authored-by: MacFarland, Midgie <macfarlandmj@ornl.gov>
1 parent 6a13f8c commit ecd9b6c

11 files changed

Lines changed: 412 additions & 102 deletions

File tree

.github/workflows/tests.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Testing with Pytest
2+
on:
3+
push:
4+
branches:
5+
- main
6+
pull_request:
7+
branches:
8+
- main
9+
workflow_dispatch:
10+
11+
jobs:
12+
uv-pytest:
13+
name: python
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Install uv
20+
uses: astral-sh/setup-uv@v6
21+
22+
- name: Install the project
23+
run: uv sync --all-extras --dev
24+
25+
- name: Run tests
26+
run: uv run pytest tests/

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ This project adheres to [Semantic Versioning](https://semver.org/).
3737
*Unreleased* versions radiate potential—-and dread. Once you merge an infernal PR, move its bullet under a new version heading with the actual release date.*
3838
3939
-->
40+
41+
## [1.1.2] - 2025-07-24
42+
### Added
43+
- 🔥 README badges
44+
- 🧪 Added testing CI/CD
45+
- 🏛️ Added classifiers for easier discovery
46+
- 🔥 opensampl-server2 which passes everything directly to docker compose with correct compose and env
47+
- 🔥 opensampl-collect entry point for accessing the collection scripts
48+
49+
### Changed
50+
- ⚡ MicrochipTWST collection script takes all data by default rather than specific readings
51+
- ⚡ MicrochipTWST collection script can take optional server and control ports
52+
- ⚡ MicrochipTWST probe object measurement logic updated to reflect collection
53+
54+
### Fixed
55+
- 🩹 Dataframe insertion of time data more durable against single row collisions
56+
57+
4058
## [1.1.1] - 2025-07-09
4159
### Fixed
4260
- 🩹 Added tabulate dependency to pyproject.toml

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# OpenSAMPL
22

3+
<div align="center">
4+
<!-- PyPI → version -->
5+
<a href="https://pypi.org/project/opensampl/"><img src="https://img.shields.io/pypi/v/opensampl?logo=pypi" alt="PyPI"></a>
6+
<!-- MIT licence -->
7+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-lightgrey.svg" alt="MIT licence"></a>
8+
<!-- Supported Python versions (will show “missing” until you add the trove classifiers) -->
9+
<a href="https://pypi.org/project/opensampl/"><img src="https://img.shields.io/pypi/pyversions/opensampl?logo=python" alt="python versions"></a>
10+
<!-- Universal wheel? -->
11+
<a href="https://pypi.org/project/opensampl/"><img src="https://img.shields.io/pypi/wheel/opensampl" alt="wheel"></a>
12+
<!-- Monthly downloads -->
13+
<a href="https://pypistats.org/packages/opensampl"><img src="https://img.shields.io/pypi/dm/opensampl?label=downloads%20%28month%29" alt="downloads per month"></a>
14+
<!-- GitHub Actions CI -->
15+
<a href="https://github.com/ORNL/OpenSAMPL/actions/workflows/publish.yml"><img src="https://github.com/ORNL/OpenSAMPL/actions/workflows/publish.yml/badge.svg" alt="PyPi Publishing"></a>
16+
<a href="https://github.com/ORNL/OpenSAMPL/actions/workflows/lint.yml"><img src="https://github.com/ORNL/OpenSAMPL/actions/workflows/lint.yml/badge.svg" alt="ruff Formating and Linting"></a>
17+
<a href="https://github.com/ORNL/OpenSAMPL/actions/workflows/tests.yml"><img src="https://github.com/ORNL/OpenSAMPL/actions/workflows/tests.yml/badge.svg" alt="PyTest Testing"></a>
18+
<!-- Docs on GitHub Pages -->
19+
<a href="https://ornl.github.io/OpenSAMPL/"><img src="https://img.shields.io/website?url=https%3A%2F%2Fornl.github.io%2FOpenSAMPL%2F&label=docs&logo=github" alt="docs"></a>
20+
</div>
21+
22+
323
OpenSAMPL was created to provide a set of Python tools for managing clock data in a TimescaleDB database, specifically designed for synchronization analytics and monitoring.
424
This project came out of [**CAST**](https://cast.ornl.gov), the **C**enter for **A**lternative **S**yncrhonization and **T**iming, a research group at Oak Ridge National Laboratory (ORNL).
525
The name OpenSAMPL stands for **O**pen **S**ynchronization **A**nalytics and **M**onitoring **PL**atform, and provides the code and logic for uploading, managing, and visualizing clock data from various sources, including ADVA probes and Microchip TWST data files,

opensampl/collect/cli.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Consolidated CLI entry point for opensampl.collect tools."""
2+
3+
import click
4+
5+
from opensampl.collect.microchip.twst.generate_twst_files import collect_files as collect_twst_files
6+
7+
8+
@click.group()
9+
def cli():
10+
"""OpenSAMPL data collection tools."""
11+
pass
12+
13+
14+
@cli.group()
15+
def microchip():
16+
"""Microchip device collection tools."""
17+
pass
18+
19+
20+
@microchip.command()
21+
@click.option("--ip", required=True, help="IP address of the modem")
22+
@click.option("--control-port", required=False, default=1700, help="Control port of the modem (default: 1700)")
23+
@click.option("--status-port", required=False, default=1900, help="Status port of the modem (default: 1900)")
24+
@click.option("--dump-interval", default=300, help="Duration between file dumps in seconds (default: 300 = 5 minutes)")
25+
@click.option(
26+
"--total-duration", default=None, type=int, help="Total duration to run in seconds (default: run indefinitely)"
27+
)
28+
@click.option("--output-dir", default="./output", help="Output directory for CSV files (default: ./output)")
29+
def twst(ip: str, control_port: int, status_port: int, dump_interval: int, total_duration: int, output_dir: str):
30+
"""
31+
Collect data from Microchip TWST modems.
32+
33+
This command connects to TWST modems via IP address to collect measurement data including
34+
offset and EBNO tracking values, along with contextual information. Data is saved to
35+
CSV files with YAML metadata headers for comprehensive data logging.
36+
37+
Examples:
38+
opensampl-collect microchip twst --ip 192.168.1.100
39+
opensampl-collect microchip twst --ip 192.168.1.100 --dump-interval 600 --total-duration 3600
40+
41+
"""
42+
collect_twst_files(
43+
host=ip,
44+
control_port=control_port,
45+
status_port=status_port,
46+
dump_interval=dump_interval,
47+
total_duration=total_duration,
48+
output_dir=output_dir,
49+
)
50+
51+
52+
if __name__ == "__main__":
53+
cli()

opensampl/collect/microchip/twst/context.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,19 @@ class ModemContextReader(ModemReader):
2525
including local station details and remote station tracking data.
2626
"""
2727

28-
def __init__(self, host: str, prompt: str = "ATS 6502>"):
28+
def __init__(self, host: str, prompt: str = "ATS 6502>", port: int = 1700):
2929
"""
3030
Initialize ModemContextReader.
3131
3232
Args:
3333
host: IP address or hostname of the ATS6502 modem.
3434
prompt: Command prompt string expected from the modem.
35+
port: what port to connect to for commands (default 1700).
3536
3637
"""
3738
self.result = SimpleNamespace()
3839
self.prompt = prompt
39-
super().__init__(host=host, port=1700)
40+
super().__init__(host=host, port=port)
4041

4142
@staticmethod
4243
def finished_ok(line: str) -> bool:
Lines changed: 93 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
"""
2-
Microchip TWST modem data collection and CSV generation tool.
2+
Microchip TWST modem data collection and CSV generation library.
33
44
This module provides functionality to collect measurements from Microchip TWST modems
5-
and save them to timestamped CSV files. It includes both programmatic and command-line
6-
interfaces for flexible data collection with configurable intervals and durations.
5+
and save them to timestamped CSV files. It provides a programmatic interface for
6+
flexible data collection with configurable intervals and durations.
77
88
The tool connects to TWST modems via IP address to collect measurement data including
99
offset and EBNO tracking values, along with contextual information. Data is saved to
1010
CSV files with YAML metadata headers for comprehensive data logging.
1111
1212
Example:
13-
Run from command line with required IP address:
14-
$ python generate_twst_files.py --ip 192.168.1.100
13+
Import and use programmatically:
14+
from opensampl.collect.microchip.twst.generate_twst_files import collect_files
15+
collect_files(host="192.168.1.100", dump_interval=600, total_duration=3600)
1516
16-
Run with custom intervals and duration:
17-
$ python generate_twst_files.py --ip 192.168.1.100 --dump-interval 600 --total-duration 3600
17+
Use via CLI (recommended):
18+
$ opensampl-collect microchip twst --ip 192.168.1.100
19+
$ opensampl-collect microchip twst --ip 192.168.1.100 --dump-interval 600 --total-duration 3600
1820
1921
"""
2022

@@ -24,7 +26,7 @@
2426
from pathlib import Path
2527
from typing import Optional
2628

27-
import click
29+
from loguru import logger
2830

2931
from opensampl.collect.microchip.twst.context import ModemContextReader
3032
from opensampl.collect.microchip.twst.readings import ModemStatusReader
@@ -38,16 +40,32 @@ async def collect_data(status_reader: ModemStatusReader, context_reader: ModemCo
3840
status_reader: ModemStatusReader instance for collecting measurements.
3941
context_reader: ModemContextReader instance for collecting context data.
4042
41-
"""
42-
await asyncio.gather(status_reader.collect_readings(), context_reader.get_context())
43-
43+
Raises:
44+
Exception: Re-raises any exception from the collection process.
4445
45-
def collect_files(host: str, output_dir: str, dump_interval: int, total_duration: Optional[int] = None):
46+
"""
47+
try:
48+
await asyncio.gather(status_reader.collect_readings(), context_reader.get_context())
49+
except Exception as e:
50+
logger.error(f"Error during data collection: {e}")
51+
raise
52+
53+
54+
def collect_files(
55+
host: str,
56+
control_port: int = 1700,
57+
status_port: int = 1900,
58+
output_dir: str = "./output",
59+
dump_interval: int = 300,
60+
total_duration: Optional[int] = None,
61+
):
4662
"""
4763
Continuously collect blocks of modem measurements and save to timestamped CSV files.
4864
4965
Args:
5066
host: IP address or hostname of the modem.
67+
control_port: Control port for modem (default: 1700)
68+
status_port: Status port for modem (default: 1900)
5169
output_dir: Directory path where CSV files will be saved.
5270
dump_interval: Duration in seconds between each data collection cycle.
5371
total_duration: Optional total runtime in seconds. If None, runs indefinitely.
@@ -61,66 +79,90 @@ def collect_files(host: str, output_dir: str, dump_interval: int, total_duration
6179
output_path.mkdir(parents=True, exist_ok=True)
6280

6381
start_time = time.time()
82+
consecutive_failures = 0
83+
max_consecutive_failures = 5
84+
base_retry_delay = 30 # seconds
85+
max_retry_delay = 300 # 5 minutes
86+
87+
logger.info(f"Starting data collection from {host}, saving to {output_path}")
6488

6589
while True:
6690
if total_duration and (time.time() - start_time) >= total_duration:
91+
logger.info("Total duration reached, stopping collection")
6792
break
6893

69-
status_reader = ModemStatusReader(host=host, duration=dump_interval, keys=["meas:offset", "tracking:ebno"])
70-
context_reader = ModemContextReader(host=host, prompt="TWModem-32>")
94+
try:
95+
status_reader = ModemStatusReader(host=host, duration=dump_interval, port=status_port)
96+
context_reader = ModemContextReader(host=host, prompt="TWModem-32>", port=control_port)
97+
98+
logger.debug(f"Starting data collection cycle for {host}")
99+
asyncio.run(collect_data(status_reader, context_reader))
100+
101+
# Write to CSV file
102+
timestamp_str = context_reader.result.timestamp
103+
output_file = output_path / f"{host}_6502-Modem_{timestamp_str}.csv"
104+
105+
with output_file.open("w", newline="") as f:
106+
f.write(context_reader.get_result_as_yaml_comment())
107+
f.write("\n")
108+
109+
writer = csv.writer(f)
110+
writer.writerow(["timestamp", "reading", "value"])
111+
writer.writerows(status_reader.readings)
112+
113+
logger.info(f"Wrote {len(status_reader.readings)} readings to {output_file}")
114+
115+
# Reset failure counter on successful collection
116+
consecutive_failures = 0
71117

72-
asyncio.run(collect_data(status_reader, context_reader))
118+
except Exception as e:
119+
consecutive_failures += 1
120+
logger.error(f"Collection failed (attempt {consecutive_failures}): {e}")
73121

74-
# Write to CSV file
75-
timestamp_str = context_reader.result.timestamp
76-
output_file = output_path / f"{host}_6502-Modem_{timestamp_str}.csv"
122+
if consecutive_failures >= max_consecutive_failures:
123+
logger.critical(
124+
f"Maximum consecutive failures ({max_consecutive_failures}) reached. Stopping collection."
125+
)
126+
break
77127

78-
with output_file.open("w", newline="") as f:
79-
f.write(context_reader.get_result_as_yaml_comment())
80-
f.write("\n")
128+
# Calculate exponential backoff delay
129+
retry_delay = min(base_retry_delay * (2 ** (consecutive_failures - 1)), max_retry_delay)
130+
logger.warning(f"Retrying in {retry_delay} seconds...")
81131

82-
writer = csv.writer(f)
83-
writer.writerow(["timestamp", "reading", "value"])
84-
writer.writerows(status_reader.readings)
132+
try:
133+
time.sleep(retry_delay)
134+
except KeyboardInterrupt:
135+
logger.info("Keyboard interrupt received, stopping collection")
136+
break
85137

86138

87-
def main(ip_address: str, dump_interval: int, total_duration: int, output_dir: str):
139+
def main(
140+
ip_address: str, control_port: int, status_port: int, dump_interval: int, total_duration: int, output_dir: str
141+
):
88142
"""
89143
Start modem data collection.
90144
91145
Args:
92146
ip_address: IP address of the modem.
147+
control_port: Control port for modem (default: 1700)
148+
status_port: Status port for modem (default: 1900)
93149
dump_interval: Duration between file dumps in seconds.
94150
total_duration: Total duration to run in seconds, or None for indefinite.
95151
output_dir: Output directory for CSV files.
96152
97153
"""
98-
collect_files(host=ip_address, dump_interval=dump_interval, total_duration=total_duration, output_dir=output_dir)
99-
100-
101-
@click.command()
102-
@click.option("--ip", required=True, help="IP address of the modem")
103-
@click.option("--dump-interval", default=300, help="Duration between file dumps in seconds (default: 300 = 5 minutes)")
104-
@click.option(
105-
"--total-duration", default=None, type=int, help="Total duration to run in seconds (default: run indefinitely)"
106-
)
107-
@click.option("--output-dir", default="./output", help="Output directory for CSV files (default: ./output)")
108-
def main_click(ip: str, dump_interval: int, total_duration: int, output_dir: str):
109-
"""
110-
Click command-line interface for modem data collection.
111-
112-
Args:
113-
ip: IP address of the modem (required).
114-
dump_interval: Duration between file dumps in seconds (default: 300).
115-
total_duration: Total duration to run in seconds (default: None for indefinite).
116-
output_dir: Output directory for CSV files (default: './output').
117-
118-
This function serves as the entry point for the click CLI, collecting
119-
modem measurements and saving them to timestamped CSV files.
120-
121-
"""
122-
collect_files(host=ip, dump_interval=dump_interval, total_duration=total_duration, output_dir=output_dir)
154+
collect_files(
155+
host=ip_address,
156+
control_port=control_port,
157+
status_port=status_port,
158+
dump_interval=dump_interval,
159+
total_duration=total_duration,
160+
output_dir=output_dir,
161+
)
123162

124163

125164
if __name__ == "__main__":
126-
main_click()
165+
import sys
166+
167+
logger.error("This module is now used as a library. Use 'opensampl-collect microchip twst' for CLI access.")
168+
sys.exit(1)

0 commit comments

Comments
 (0)