Skip to content
Draft
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
107 changes: 107 additions & 0 deletions docs/nightly_reports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Nightly Observation Reports

## Overview

POCS now automatically generates nightly observation reports that provide a comprehensive summary of each observing night. These reports are generated at the end of the night during the housekeeping state.

## Report Contents

Each nightly report includes:

1. **Observations Summary**
- Total number of observation sequences
- Number of unique fields observed
- Details for each field including:
- Field name
- Start time
- Number of exposures taken vs. planned

2. **Safety Summary**
- Status of all safety checks:
- AC Power Connected
- Dark Sky
- Weather Safe
- Root Disk Space
- Image Disk Space
- Reasons for non-observation (if any):
- Unsafe weather conditions
- Not dark enough for observations
- AC power issues
- Insufficient disk space

## Configuration

Reports are saved to a configurable directory. By default, they are saved to `~/reports`, but this can be customized in your POCS configuration:

```yaml
directories:
reports: /path/to/your/reports/directory
```

## Report Files

Reports are saved with the naming convention:
```
nightly_report_YYYYMMDD.txt
```

For example: `nightly_report_20260213.txt`

## Sample Report

```
================================================================================
PANOPTES Nightly Observation Report - 2026-02-13
================================================================================

OBSERVATIONS SUMMARY
--------------------------------------------------------------------------------
Total observation sequences: 2
Unique fields observed: 2

Observations by field:
- M42OrionNebula: 1 sequence(s)
* Started: 2026-02-13T01:30:00, Exposures: 0/30
- M31Andromeda: 1 sequence(s)
* Started: 2026-02-13T03:15:00, Exposures: 0/40

SAFETY SUMMARY
--------------------------------------------------------------------------------
Most recent safety check:
AC Power Connected...................... ✓ PASS
Dark Sky................................ ✓ PASS
Weather Safe............................ ✗ FAIL
Root Disk Space......................... ✓ PASS
Image Disk Space........................ ✓ PASS

WARNING: Some safety checks failed.
Reasons for non-observation:
- Unsafe weather conditions

================================================================================
End of Report
================================================================================
```

## Implementation Details

The nightly report feature is implemented in:
- `src/panoptes/pocs/utils/report.py` - Core report generation logic
- `src/panoptes/pocs/state/states/default/housekeeping.py` - Integration with state machine

The report generator queries the POCS database for:
- Observed list from the scheduler
- Most recent safety check data

Reports are generated automatically during the housekeeping state at the end of each night, before the observed list is reset.

## Testing

Comprehensive tests are included in:
- `tests/utils/test_report.py` - Unit tests for report generation
- `tests/test_housekeeping_state.py` - Integration tests

Run tests with:
```bash
pytest tests/utils/test_report.py tests/test_housekeeping_state.py -v
```
34 changes: 34 additions & 0 deletions src/panoptes/pocs/state/states/default/housekeeping.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,44 @@
from datetime import datetime
from pathlib import Path

from panoptes.pocs.utils.report import NightlyReport


def on_enter(event_data):
""" """
pocs = event_data.model
pocs.next_state = "sleeping"

pocs.say("Resetting the list of observations and doing some cleanup!")

# Generate nightly report before cleanup
try:
observed_list = pocs.observatory.scheduler.observed_list

# Create report
report_generator = NightlyReport(db=pocs.db)

# Get reports directory from config or use default
reports_dir = Path(
pocs.get_config("directories.reports", default="~/reports")
).expanduser()

# Generate filename with date
date_str = datetime.now().strftime("%Y%m%d")
report_path = reports_dir / f"nightly_report_{date_str}.txt"

# Generate and save report
report_text = report_generator.generate_report(
observed_list=observed_list,
output_path=report_path
)

pocs.logger.info(f"Nightly report generated: {report_path}")
pocs.say(f"Generated nightly report with {len(observed_list)} observation(s)")

except Exception as e: # pragma: no cover
pocs.logger.warning(f"Problem generating nightly report: {e!r}")

# Cleanup existing observations
try:
pocs.observatory.scheduler.reset_observed_list()
Expand Down
188 changes: 188 additions & 0 deletions src/panoptes/pocs/utils/report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""Nightly report generation for POCS observations.

This module provides utilities to generate end-of-night reports summarizing
observations taken and reasons for non-observation (weather, errors, etc.).
"""
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional

from panoptes.pocs.base import PanBase


class NightlyReport(PanBase):
"""Generate nightly observation reports.

Creates a summary report at the end of each observing night that includes:
- List of observations successfully taken
- Count of exposures per field
- Reasons for non-observation (weather, safety, errors)
- Safety check statistics
"""

def __init__(self, db=None, *args, **kwargs):
"""Initialize the nightly report generator.

Args:
db: Database instance to query for observation and safety data.
"""
super().__init__(*args, **kwargs)
self.db = db

def generate_report(
self,
observed_list: Optional[Dict] = None,
output_path: Optional[Path] = None,
date: Optional[str] = None
) -> str:
"""Generate a comprehensive nightly report.

Args:
observed_list: Dictionary of observations from the scheduler's observed_list.
output_path: Optional path to save the report to a file.
date: Optional date string for the report. Defaults to current date.

Returns:
str: The formatted report text.
"""
if date is None:
date = datetime.now().strftime("%Y-%m-%d")

report_lines = []
report_lines.append("=" * 80)
report_lines.append(f"PANOPTES Nightly Observation Report - {date}")
report_lines.append("=" * 80)
report_lines.append("")

# Observations summary
obs_summary = self._summarize_observations(observed_list)
report_lines.extend(obs_summary)
report_lines.append("")

# Safety summary
safety_summary = self._summarize_safety_checks()
report_lines.extend(safety_summary)
report_lines.append("")

report_lines.append("=" * 80)
report_lines.append("End of Report")
report_lines.append("=" * 80)

report_text = "\n".join(report_lines)

# Save to file if path provided
if output_path:
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(report_text)
self.logger.info(f"Report saved to {output_path}")

return report_text

def _summarize_observations(self, observed_list: Optional[Dict] = None) -> List[str]:
"""Summarize observations taken during the night.

Args:
observed_list: Dictionary of observations from scheduler.

Returns:
List[str]: Lines of text summarizing observations.
"""
lines = ["OBSERVATIONS SUMMARY", "-" * 80]

if not observed_list or len(observed_list) == 0:
lines.append("No observations were completed during this night.")
return lines

# Group observations by field
field_stats = defaultdict(lambda: {"count": 0, "seq_times": []})

for seq_time, observation in observed_list.items():
field_name = observation.field.field_name
field_stats[field_name]["count"] += 1
field_stats[field_name]["seq_times"].append(seq_time)

lines.append(f"Total observation sequences: {len(observed_list)}")
lines.append(f"Unique fields observed: {len(field_stats)}")
lines.append("")
lines.append("Observations by field:")

for field_name, stats in sorted(field_stats.items()):
lines.append(f" - {field_name}: {stats['count']} sequence(s)")
for seq_time in stats["seq_times"]:
obs = observed_list[seq_time]
exp_count = getattr(obs, 'current_exp_num', 0)
min_exp = getattr(obs, 'min_nexp', 0)
lines.append(f" * Started: {seq_time}, Exposures: {exp_count}/{min_exp}")

return lines

def _summarize_safety_checks(self) -> List[str]:
"""Summarize safety check results during the night.

Returns:
List[str]: Lines of text summarizing safety checks.
"""
lines = ["SAFETY SUMMARY", "-" * 80]

if self.db is None:
lines.append("Database not available for safety summary.")
return lines

try:
# Get the most recent safety record
safety_record = self.db.get_current("safety")

if safety_record is None:
lines.append("No safety data available.")
return lines

# Extract the actual data from the record
safety_data = safety_record.get("data", safety_record)

lines.append("Most recent safety check:")

# List each safety check and its status
safety_checks = {
"ac_power": "AC Power Connected",
"is_dark": "Dark Sky",
"good_weather": "Weather Safe",
"free_space_root": "Root Disk Space",
"free_space_images": "Image Disk Space"
}

all_safe = True
for key, label in safety_checks.items():
if key in safety_data:
status = "✓ PASS" if safety_data[key] else "✗ FAIL"
lines.append(f" {label:.<40} {status}")
if not safety_data[key]:
all_safe = False

lines.append("")
if all_safe:
lines.append("All safety checks passed.")
else:
lines.append("WARNING: Some safety checks failed.")
lines.append("Reasons for non-observation:")

failure_reasons = []
if not safety_data.get("good_weather", True):
failure_reasons.append(" - Unsafe weather conditions")
if not safety_data.get("is_dark", True):
failure_reasons.append(" - Not dark enough for observations")
if not safety_data.get("ac_power", True):
failure_reasons.append(" - AC power disconnected")
if not safety_data.get("free_space_root", True):
failure_reasons.append(" - Insufficient disk space (root)")
if not safety_data.get("free_space_images", True):
failure_reasons.append(" - Insufficient disk space (images)")

lines.extend(failure_reasons)

except Exception as e:
lines.append(f"Error retrieving safety data: {e!r}")
self.logger.warning(f"Error in safety summary: {e!r}")

return lines
Loading
Loading