Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions Ammeters/base_ammeter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
NotImplementedErrorMsg = "Subclasses must implement this property."

class AmmeterEmulatorBase(ABC):
def __init__(self, port: int):
def __init__(self, port: int, chaos_mode: bool = False):
self.port = port
self.chaos_mode = chaos_mode
random.seed(time.time()) # Seed the random number generator for each instance

def start_server(self):
Expand All @@ -27,9 +28,24 @@ def start_server(self):
print(f"Connected by {addr}")
data = conn.recv(1024)
if data == self.get_current_command:
# Call the specific measure_current() method defined in subclasses
current = self.measure_current()
conn.sendall(str(current).encode('utf-8'))
if self.chaos_mode and random.random() < 0.10:
fault = random.choice(['sleep', 'garbage', 'close'])
if fault == 'sleep':
time.sleep(5) # Delay longer than typical timeout
current = self.measure_current()
try:
conn.sendall(str(current).encode('utf-8'))
except Exception:
pass
elif fault == 'garbage':
conn.sendall(b'ERR_NO_DATA')
elif fault == 'close':
# Abruptly close without sending
pass
else:
# Call the specific measure_current() method defined in subclasses
current = self.measure_current()
conn.sendall(str(current).encode('utf-8'))

@property
@abstractmethod
Expand Down
58 changes: 52 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,31 @@ This project provides emulators for different types of ammeters: Greenlee, ENTES

## Project Structure

- `main.py`: Main script to start the ammeter emulators in the background.
- `Ammeters/`
- `main.py`: Main script to start the ammeter emulators and request current measurements.
- `Circutor_Ammeter.py`: Emulator for the CIRCUTOR ammeter.
- `Entes_Ammeter.py`: Emulator for the ENTES ammeter.
- `Greenlee_Ammeter.py`: Emulator for the Greenlee ammeter.
- `base_ammeter.py`: Base class for all ammeter emulators.
- `client.py`: Client to request current measurements from the ammeter emulators.
- `config/`
- `config.yaml`: Configuration file for the ammeter emulators.
- `config.yaml`: Configuration file for the test framework and emulators.
- `examples/`
- `run_test.py`: super lyze example for run test **don't use it**.
- `run_framework.py`: Production-ready CLI script to run automated tests.
- `compare_runs.py`: CLI script to compare two historical JSON archives.
- `src/`
- `testing/`
- `AmmeterTester.py`: Class to test the ammeter emulators.
- `test_framework.py`: Contains `AmmeterTestFramework`, the unified testing API and sampling engine.
- `utils/`
- `config.py`: Configuration settings.
- `logger.py`: Logging setup.
- `config.py`: Configuration loader.
- `logger.py`: Logging setup and file handling.
- `Utils.py`: Utility functions, including `generate_random_float`.
- `analysis.py`: Statistical calculation module.
- `visualization.py`: Matplotlib plotting module.
- `comparison.py`: Historical run comparison utility.
- `tests/`
- `integration/`: End-to-end pytest verification (e.g., `test_api.py`).
- `unit/`: Isolated pytest unit tests (e.g., `test_analysis.py`, `test_comparison.py`).

## Usage

Expand All @@ -46,6 +53,45 @@ python3 main.py

---

## Sample Test Results

When you execute a test run using the framework, it generates a comprehensive JSON report containing the raw data arrays, test metadata, and calculated statistics. Here is an example of a generated result file (`results/greenlee_20260506_145000_a1b2c3d4.json`):

```json
{
"ammeter_type": "greenlee",
"measurements": [
0.5010,
5.8526,
0.0742,
0.0197,
1.2961,
5.7601,
0.0731,
0.0555,
1.1312,
0.0484
],
"count": 10,
"expected_count": 10,
"duration_seconds": 4.5129,
"sampling_frequency_hz": 2.0,
"statistics": {
"mean": 1.4812,
"median": 0.2876,
"min": 0.0197,
"max": 5.8526,
"stdev": 2.3266
},
"test_id": "a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"timestamp": "20260506_145000",
"plot_path": "results/greenlee_20260506_145000_plot.png",
"archive_path": "results/greenlee_20260506_145000_a1b2c3d4.json"
}
```

---

## Design Decisions & Bug Fixes

### 1. Emulator Communication Fix (PR #10)
Expand Down
1 change: 1 addition & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
testing:
error_simulation: false
sampling:
measurements_count: 10
total_duration_seconds: 5
Expand Down
60 changes: 43 additions & 17 deletions examples/compare_runs.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
import os
import sys
import argparse
from pathlib import Path

# Ensure the root directory is in the python path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Ensure the root directory is in the path for imports
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from src.utils.comparison import compare_historical_runs
from src.utils.config import load_config

def main():
print("=== Ammeter Test Framework - Historical Comparison ===\n")

# 1. Load config to find the output directory dynamically
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
config_path = os.path.join(project_root, 'config', 'config.yaml')
config = load_config(config_path)
output_dir = config.get('result_management', {}).get('output_dir', 'results')
# Load config to find the output directory dynamically
project_root = Path(__file__).resolve().parent.parent
config_path = project_root / 'config' / 'config.yaml'
config = load_config(str(config_path))
output_dir = Path(config.get('result_management', {}).get('output_dir', 'results'))

# 2. Setup CLI argument parser
# Setup CLI argument parser
import argparse
parser = argparse.ArgumentParser(description="Compare two historical ammeter test runs.")
parser.add_argument('--file1', type=str, help='Filename of the first test run')
parser.add_argument('--file2', type=str, help='Filename of the second test run')
args = parser.parse_args()

# 3. Interactive Fallback Logic
# Interactive Fallback Logic
file1 = args.file1
file2 = args.file2

Expand All @@ -36,15 +36,41 @@ def main():
print("\nError: Both filenames must be provided. Exiting.")
sys.exit(1)

# 4. Safely construct full paths
# If the user typed the full path, use it. Otherwise, prepend the output_dir.
path1 = file1 if os.path.isabs(file1) else os.path.join(output_dir, file1)
path2 = file2 if os.path.isabs(file2) else os.path.join(output_dir, file2)
# Safely construct full paths
path1 = Path(file1) if Path(file1).is_absolute() else output_dir / file1
path2 = Path(file2) if Path(file2).is_absolute() else output_dir / file2

print(f"\nComparing [{file1}] vs [{file2}]...\n")

# 5. Execute Comparison
compare_historical_runs(path1, path2)
# Execute Comparison
try:
comparison_data = compare_historical_runs(str(path1), str(path2))

# Simple table formatting
run1 = comparison_data['run1']
run2 = comparison_data['run2']

m1_mean = f"{run1.get('mean'):.4f}" if run1.get('mean') is not None else "N/A"
m2_mean = f"{run2.get('mean'):.4f}" if run2.get('mean') is not None else "N/A"
m1_max = f"{run1.get('max'):.4f}" if run1.get('max') is not None else "N/A"
m2_max = f"{run2.get('max'):.4f}" if run2.get('max') is not None else "N/A"
dur1 = f"{run1.get('duration', 0):.4f}"
dur2 = f"{run2.get('duration', 0):.4f}"

print("\n" + "="*60)
print(f"{'Metric':<20} | {'Run 1':<15} | {'Run 2':<15}")
print("-" * 60)
print(f"{'Ammeter Type':<20} | {run1.get('ammeter') or 'N/A':<15} | {run2.get('ammeter') or 'N/A':<15}")
print(f"{'Count':<20} | {run1.get('count') or 0:<15} | {run2.get('count') or 0:<15}")
print(f"{'Duration (s)':<20} | {dur1:<15} | {dur2:<15}")
print(f"{'Mean (A)':<20} | {m1_mean:<15} | {m2_mean:<15}")
print(f"{'Max (A)':<20} | {m1_max:<15} | {m2_max:<15}")
print("="*60 + "\n")

except FileNotFoundError as e:
print(f"Error: {e}")
except Exception as e:
print(f"Comparison failed: {e}")

if __name__ == "__main__":
main()
6 changes: 3 additions & 3 deletions examples/run_framework.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import json
import argparse
import os
import sys
from pathlib import Path

# Ensure the root directory is in the path for imports
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from src.testing.test_framework import AmmeterTestFramework

Expand Down Expand Up @@ -34,4 +34,4 @@ def main():
sys.exit(1)

if __name__ == "__main__":
main()
main()
28 changes: 16 additions & 12 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,36 @@
from Ammeters.Entes_Ammeter import EntesAmmeter
from Ammeters.Greenlee_Ammeter import GreenleeAmmeter
from Ammeters.client import request_current_from_ammeter
from src.utils.config import load_config


def run_greenlee_emulator():
greenlee = GreenleeAmmeter(5000)
def run_greenlee_emulator(chaos_mode: bool = False):
greenlee = GreenleeAmmeter(5000, chaos_mode=chaos_mode)
greenlee.start_server()

def run_entes_emulator():
entes = EntesAmmeter(5001)
def run_entes_emulator(chaos_mode: bool = False):
entes = EntesAmmeter(5001, chaos_mode=chaos_mode)
entes.start_server()

def run_circutor_emulator():
circutor = CircutorAmmeter(5002)
def run_circutor_emulator(chaos_mode: bool = False):
circutor = CircutorAmmeter(5002, chaos_mode=chaos_mode)
circutor.start_server()

def start_emulators():
def start_emulators(chaos_mode: bool = False):
# Start each ammeter in a separate thread
threading.Thread(target=run_greenlee_emulator, daemon=True).start()
threading.Thread(target=run_entes_emulator, daemon=True).start()
threading.Thread(target=run_circutor_emulator, daemon=True).start()
threading.Thread(target=run_greenlee_emulator, args=(chaos_mode,), daemon=True).start()
threading.Thread(target=run_entes_emulator, args=(chaos_mode,), daemon=True).start()
threading.Thread(target=run_circutor_emulator, args=(chaos_mode,), daemon=True).start()

# Wait for the servers to start, if you have problem restarting the servers between runs try increasing sleep time.
time.sleep(5)

if __name__ == "__main__":
print("Starting ammeter emulators...")
start_emulators()
config = load_config("config/config.yaml")
chaos_mode = config.get("testing", {}).get("error_simulation", False)

print(f"Starting ammeter emulators... (Chaos Mode: {chaos_mode})")
start_emulators(chaos_mode)

# Request an initial reading just to verify connection
request_current_from_ammeter(5000, b'MEASURE_GREENLEE -get_measurement')
Expand Down
Loading