diff --git a/backend/routers/projects.py b/backend/routers/projects.py index d35aabc..f748d6e 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -644,6 +644,8 @@ async def ftp_download_to_server( '.flac': 'audio', '.m4a': 'audio', '.aac': 'audio', + # Sound level meter measurement files + '.rnd': 'measurement', # Data files '.csv': 'data', '.txt': 'data', @@ -1023,6 +1025,176 @@ async def download_project_file( ) +@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse) +async def view_rnd_file( + request: Request, + project_id: str, + file_id: str, + db: Session = Depends(get_db), +): + """ + View an RND (sound level meter measurement) file. + Returns a dedicated page with data table and charts. + """ + from backend.models import DataFile + from pathlib import Path + + # Get the file record + file_record = db.query(DataFile).filter_by(id=file_id).first() + if not file_record: + raise HTTPException(status_code=404, detail="File not found") + + # Verify file belongs to this project + session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() + if not session or session.project_id != project_id: + raise HTTPException(status_code=403, detail="File does not belong to this project") + + # Build full file path + file_path = Path("data") / file_record.file_path + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found on disk") + + # Get project info + project = db.query(Project).filter_by(id=project_id).first() + + # Get location info if available + location = None + if session.location_id: + location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() + + # Get unit info if available + unit = None + if session.unit_id: + unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() + + # Parse file metadata + metadata = {} + if file_record.file_metadata: + try: + metadata = json.loads(file_record.file_metadata) + except json.JSONDecodeError: + pass + + return templates.TemplateResponse("rnd_viewer.html", { + "request": request, + "project": project, + "project_id": project_id, + "file": file_record, + "file_id": file_id, + "session": session, + "location": location, + "unit": unit, + "metadata": metadata, + "filename": file_path.name, + }) + + +@router.get("/{project_id}/files/{file_id}/rnd-data") +async def get_rnd_data( + project_id: str, + file_id: str, + db: Session = Depends(get_db), +): + """ + Get parsed RND file data as JSON. + Returns the measurement data for charts and tables. + """ + from backend.models import DataFile + from pathlib import Path + import csv + import io + + # Get the file record + file_record = db.query(DataFile).filter_by(id=file_id).first() + if not file_record: + raise HTTPException(status_code=404, detail="File not found") + + # Verify file belongs to this project + session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() + if not session or session.project_id != project_id: + raise HTTPException(status_code=403, detail="File does not belong to this project") + + # Build full file path + file_path = Path("data") / file_record.file_path + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found on disk") + + # Read and parse the RND file + try: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + + # Parse as CSV + reader = csv.DictReader(io.StringIO(content)) + rows = [] + headers = [] + + for row in reader: + if not headers: + headers = list(row.keys()) + # Clean up values - strip whitespace and handle special values + cleaned_row = {} + for key, value in row.items(): + if key: # Skip empty keys + cleaned_key = key.strip() + cleaned_value = value.strip() if value else '' + # Convert numeric values + if cleaned_value and cleaned_value not in ['-.-', '-', '']: + try: + cleaned_value = float(cleaned_value) + except ValueError: + pass + elif cleaned_value in ['-.-', '-']: + cleaned_value = None + cleaned_row[cleaned_key] = cleaned_value + rows.append(cleaned_row) + + # Detect file type (Leq vs Lp) based on columns + file_type = 'unknown' + if headers: + header_str = ','.join(headers).lower() + if 'leq' in header_str: + file_type = 'leq' # Time-averaged data + elif 'lp(main)' in header_str or 'lp (main)' in header_str: + file_type = 'lp' # Instantaneous data + + # Get summary statistics + summary = { + "total_rows": len(rows), + "file_type": file_type, + "headers": [h.strip() for h in headers if h.strip()], + } + + # Calculate min/max/avg for key metrics if available + metrics_to_summarize = ['Leq(Main)', 'Lmax(Main)', 'Lmin(Main)', 'Lpeak(Main)', 'Lp(Main)'] + for metric in metrics_to_summarize: + values = [row.get(metric) for row in rows if isinstance(row.get(metric), (int, float))] + if values: + summary[f"{metric}_min"] = min(values) + summary[f"{metric}_max"] = max(values) + summary[f"{metric}_avg"] = sum(values) / len(values) + + # Get time range + if rows: + first_time = rows[0].get('Start Time', '') + last_time = rows[-1].get('Start Time', '') + summary['time_start'] = first_time + summary['time_end'] = last_time + + return { + "success": True, + "summary": summary, + "headers": summary["headers"], + "data": rows, + } + + except Exception as e: + logger.error(f"Error parsing RND file: {e}") + raise HTTPException(status_code=500, detail=f"Error parsing file: {str(e)}") + + @router.get("/types/list", response_class=HTMLResponse) async def get_project_types(request: Request, db: Session = Depends(get_db)): """ diff --git a/templates/partials/projects/unified_files.html b/templates/partials/projects/unified_files.html index d0a55b4..a9b284c 100644 --- a/templates/partials/projects/unified_files.html +++ b/templates/partials/projects/unified_files.html @@ -72,6 +72,10 @@ + {% elif file.file_type == 'measurement' %} + + + {% else %} @@ -95,6 +99,7 @@ +
+ {% if file.file_type == 'measurement' or file.file_path.endswith('.rnd') %} + + + + + View + + {% endif %}