diff --git a/.gitignore b/.gitignore index 292edd0..5f01d44 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.vscode .idea # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e3a9d..3a55bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [0.5.x] - 2026-xx-xx +## [0.5.0] - 2026-xx-xx This is a **preproduction alpha release**, not yet fit for production and operational environments. This release should only be used for: - testing @@ -35,6 +35,7 @@ Feedback and bug reports are highly appreciated to improve future versions. - Fixed issue with wrong video being selected upon click in time series when one or several filters are active. - Backend issue fixed, which resulted in an error when the device is not assigned any IP-address. - Syncing videos with LiveORC without any files resulted in an error. Video is now not synced if no files are selected. +- Velocimetry processing now is a fully independent subprocess. This prevents unnecessary memory leaks. ### Security diff --git a/dashboard/src/views/videoComponents/paginatedVideos.jsx b/dashboard/src/views/videoComponents/paginatedVideos.jsx index a9f4070..0d66ee8 100644 --- a/dashboard/src/views/videoComponents/paginatedVideos.jsx +++ b/dashboard/src/views/videoComponents/paginatedVideos.jsx @@ -1,5 +1,5 @@ import {useState, useEffect} from "react"; -import {sync_video, patchVideo} from "../../utils/apiCalls/video.jsx" +import {sync_video, patchVideo, getVideoId} from "../../utils/apiCalls/video.jsx" import {getLogLineStyle} from "../../utils/helpers.jsx"; import {VideoDetailsModal} from "./videoDetailsModal.jsx"; import {getStatusIcon, getSyncStatusIcon, getVideoConfigIcon, getVideoConfigTitle} from "./videoHelpers.jsx"; @@ -32,18 +32,30 @@ const PaginatedVideos = ({startDate, endDate, setStartDate, setEndDate, videoRun const [totalDataCount, setTotalDataCount] = useState(0); // total amount of records with filtering const [currentPage, setCurrentPage] = useState(1); // Tracks current page const [rowsPerPage, setRowsPerPage] = useState(10); // Rows per page (default 25) - const [selectedVideo, setSelectedVideo] = useState(null); // For modal views, to select the right video const [uploadedVideo, setUploadedVideo] = useState(null); const [videoLogData, setVideoLogData] = useState(""); - const [showLog, setShowLog] = useState(false); - const [showModal, setShowModal] = useState(false); // State for modal visibility - const [showConfigModal, setShowConfigModal] = useState(false); - const [showRunModal, setShowRunModal] = useState(false); + const [modalState, setModalState] = useState({"type": null, "video": null}); const [selectedIds, setSelectedIds] = useState([]); // Array of selected video IDs // allow for setting messages const {setMessageInfo} = useMessage(); + // check at mount if a parameter for the editVideoId is provided. If so, open edit modal with video + useEffect(() => { + const setVideoFromParams = async () => { + const params = new URLSearchParams(window.location.search); + const videoId = params.get("editVideoId") + if (videoId) { + const video = await getVideoId(videoId); + if (video) { + console.log("VIDEO: ", video); + openModal("run", video) + } + } + } + setVideoFromParams(); + }, []) + // Data must be updated when the page changes, when start and end date changes useEffect(() => { // set loading @@ -120,23 +132,23 @@ const PaginatedVideos = ({startDate, endDate, setStartDate, setEndDate, videoRun useEffect(() => { // ensure that table information is always up-to-date - if (!selectedVideo) return; + if (!modalState.video) return; setData(prevData => { return prevData.map(video => { - if (video.id === selectedVideo.id) { + if (video.id === modalState.video.id) { return { ...video, - time_series: selectedVideo.time_series, - video_config: selectedVideo.video_config, - allowed_to_run: selectedVideo.allowed_to_run[0] || selectedVideo.allowed_to_run, - status: selectedVideo.status, - sync_status: selectedVideo.sync_status + time_series: modalState.video.time_series, + video_config: modalState.video.video_config, + allowed_to_run: modalState.video.allowed_to_run[0] || modalState.video.allowed_to_run, + status: modalState.video.status, + sync_status: modalState.video.sync_status }; } return video; }); }); - }, [selectedVideo]); + }, [modalState.video]); const renderVideoConfigButton = (video) => { return ( @@ -179,52 +191,63 @@ const PaginatedVideos = ({startDate, endDate, setStartDate, setEndDate, videoRun }; // Handle the "Delete" button action const handleShowLog = (video) => { - setSelectedVideo(video); api.get(`/video/${video.id}/log/`) .then((response) => { const lines = response.data.split("\n"); setVideoLogData(lines); - setShowLog(true); - }) .catch((error) => { setVideoLogData([`No log available for video with ID: ${video.id}`]) console.error('Error fetching log for video with ID:', video.id, error); - setShowLog(true); + }) + .finally(() => { + // always open the modal + openModal("log", video); + }); } + const openModal = (type, video) => { + setModalState({type, video}); + } + const closeModal = () => { + setModalState({type: null, video: null}); + } + + // only set the video in the modal to a different value + const setVideoModal = (video) => { + setModalState({ + ...modalState, + video: video, + }) + } const handleView = (video) => { - setSelectedVideo(video); - setShowModal(true); + openModal("view", video); }; const handleRun = (video) => { - setSelectedVideo(video); - setShowRunModal(true); + openModal("run", video); } const handleSync = (video) => { sync_video(video, setMessageInfo); } const handleVideoConfig = (video) => { - setSelectedVideo(video); // Set the selected video - setShowConfigModal(true); // Open the modal + openModal("config", video); } // Function to handle configuration selection and API call const handleConfigSelection = async (event) => { const {value} = event.target; try { - await patchVideo(selectedVideo.id, {video_config_id: value ? value : null}).then(async () => { - await api.get(`/video/${selectedVideo.id}/`).then((r) => { - setSelectedVideo(r.data); + await patchVideo(modalState.video.id, {video_config_id: value ? value : null}).then(async () => { + await api.get(`/video/${modalState.video.id}/`).then((r) => { // update table if required setData(prevData => { return prevData.map(video => { - if (video.id === selectedVideo.id) { + if (video.id === r.data.id) { return { ...video, video_config_id: value, @@ -235,12 +258,18 @@ const PaginatedVideos = ({startDate, endDate, setStartDate, setEndDate, videoRun return video; }); }); + if (value) { + setMessageInfo('success', `Video configuration ${value} selected for video ${modalState.video.id}`); + } else { + setMessageInfo('success', `Cleared video configuration for video ${modalState.video.id}`); + + } + closeModal() }) }); // Success feedback - setMessageInfo('success', `Video configuration ${value} selected for video ${selectedVideo.id}`); - // setSelectedVideo(null) - setShowConfigModal(false); + setMessageInfo('success', `Video configuration ${value} selected for video ${modalState.video.id}`); + closeModal(); setSavingConfig(false); } catch (error) { // Error handling @@ -412,77 +441,16 @@ const PaginatedVideos = ({startDate, endDate, setStartDate, setEndDate, videoRun - {/*/!*Modal for selecting a VideoConfig or creating a new Video Config*!/*/} - {/*/!* Modal for video config *!/*/} - {/* setShowConfigModal(false)}*/} - {/* contentLabel="Video Configurations"*/} - - {/* style={{*/} - {/* overlay: {*/} - {/* backgroundColor: "rgba(0, 0, 0, 0.6)",*/} - {/* },*/} - - {/* content: {*/} - {/* maxWidth: "600px",*/} - {/* maxHeight: "400px",*/} - {/* margin: "auto",*/} - {/* padding: "20px",*/} - {/* },*/} - {/* }}*/} - {/*>*/} - - {/*
*/} - {/*

Video Configurations

*/} - {/* setShowConfigModal(false)}*/} - {/* aria-label="Close"*/} - {/* >*/} - {/* ×*/} - {/* */} - {/*
*/} - {/* {selectedVideo &&

Configuring Video: {selectedVideo.id}

}*/} - {/* {selectedVideo?.video_config?.id ?*/} - {/* selectedVideo?.video_config?.sample_video_id === selectedVideo?.id ? (*/} - {/*

{`This is the reference video for config: ${selectedVideo.video_config.id}: ${selectedVideo.video_config.name}. Click edit to modify.`}

*/} - {/* ) : (*/} - {/*

{`Current selected config: ${selectedVideo.video_config.id}: ${selectedVideo.video_config.name}`}

*/} - - {/* ) : (

No config selected. Select one below or start editing a new config.

)}*/} - {/*
Select an Existing Config:
*/} - {/*
*/} - {/* */} - {/*
*/} - {/*
*/} - {/*
Create new or edit existing config:
*/} - {/* */} - {/*
*/} - {/**/} - {/* modal for video log file display */} setShowLog(false)} + isOpen={modalState.type === "log" && modalState.video !== null} + onRequestClose={closeModal} contentLabel="Video Log" style={{ overlay: { @@ -499,9 +467,8 @@ const PaginatedVideos = ({startDate, endDate, setStartDate, setEndDate, videoRun }} >
- {/*
*/}
- {selectedVideo &&

Log for video: {selectedVideo.id}

} + {modalState.video &&

Log for video: {modalState.video.id} - {modalState.video.timestamp.toLocaleString()}

}
- - {/*Modal for editing / analyzing video */} - {showModal && selectedVideo && ( + {modalState.type === "view" && modalState.video && ( )} {/*Modal for running video */} - {showRunModal && selectedVideo && ( - + {modalState.type === "run" && modalState.video && ( + )}
); diff --git a/dashboard/src/views/videoComponents/timeSeriesChangeModal.jsx b/dashboard/src/views/videoComponents/timeSeriesChangeModal.jsx index 88a413e..89e751a 100644 --- a/dashboard/src/views/videoComponents/timeSeriesChangeModal.jsx +++ b/dashboard/src/views/videoComponents/timeSeriesChangeModal.jsx @@ -11,7 +11,7 @@ import { getFrameUrl, useDebouncedImageUrl, PolygonDrawer } from "../../utils/im import {FaSpinner} from "react-icons/fa"; -export const TimeSeriesChangeModal = ({setShowModal, video, setVideo}) => { +export const TimeSeriesChangeModal = ({video, setVideo, closeModal}) => { const [loading, setLoading] = useState(false); const [loadingConfig, setLoadingConfig] = useState(false); const [saving, setSaving] = useState(false); @@ -54,7 +54,13 @@ export const TimeSeriesChangeModal = ({setShowModal, video, setVideo}) => { return getFrameUrl(video, frameNr, rotate); }, onUrlReady: (url, { cached }) => { - setLoading(true) + if (cached) { + // If the image is cached, we can set loading to false immediately + setLoading(false); + } else { + // Otherwise, we'll wait for the onLoad event of the image + setLoading(true) + } }, delayMs: 300 }); @@ -103,6 +109,31 @@ export const TimeSeriesChangeModal = ({setShowModal, video, setVideo}) => { } }, [crossSection, imageRef.current, imgDims, videoConfig?.camera_config?.gcps]) + // useEffect(() => { + // // If the image URL is already loaded from cache, the may be complete + // // before React attaches the onLoad handler. Ensure we clear the loading + // // spinner and set dimensions in that case. + // if (!imageUrl) return; + // const img = imageRef.current; + // if (!img) return; + + // if (img.complete) { + // handleImageLoad(); + // } else { + // const onLoad = () => handleImageLoad(); + // const onError = () => { + // setLoading(false); + // console.error('Image failed to load.'); + // }; + // img.addEventListener('load', onLoad); + // img.addEventListener('error', onError); + // return () => { + // img.removeEventListener('load', onLoad); + // img.removeEventListener('error', onError); + // }; + // } + // }, [imageUrl]); + useEffect(() => { if (crossSection && videoConfig && imageRef?.current && !loading && imgDims.width > 0 && imgDims.height > 0) { // Debounce getWettedSurface by 300ms @@ -136,19 +167,16 @@ export const TimeSeriesChangeModal = ({setShowModal, video, setVideo}) => { setCSWaterLines(drawLines); } } - ) + ).catch(error => { + console.error("Error fetching water lines:", error); + setCSWaterLines([]); + }) }, 100); // Cleanup if dependencies change within 300ms return () => clearTimeout(timeoutWaterLevel); } }, [waterLevel, crossSection, videoConfig, imageRef.current, loading, imgDims]) - // Close modal - const closeModal = () => { - setShowModal(false); - setVideo(null); - }; - const handleImageLoad = () => { if (imageRef.current && imageUrl) { setImgDims({ @@ -194,7 +222,7 @@ export const TimeSeriesChangeModal = ({setShowModal, video, setVideo}) => { try { await run_video(video, setMessageInfo); setMessageInfo('success', 'Video submitted successfully'); - setShowModal(false); + closeModal(); } catch (error) { console.error(error.response.data.detail); } @@ -331,6 +359,7 @@ export const TimeSeriesChangeModal = ({setShowModal, video, setVideo}) => { setLoading(false); // Always unset loading on error console.error('Image failed to load.'); }} + src={imageUrl} alt="img-set-water-level" /> @@ -388,7 +417,7 @@ export const TimeSeriesChangeModal = ({setShowModal, video, setVideo}) => { ) } TimeSeriesChangeModal.propTypes = { - setShowModal: PropTypes.func.isRequired, video: PropTypes.object.isRequired, setVideo: PropTypes.func.isRequired, + closeModal: PropTypes.func.isRequired, }; diff --git a/dashboard/src/views/videoComponents/videoConfigModal.jsx b/dashboard/src/views/videoComponents/videoConfigModal.jsx index a1b7b08..570cb0b 100644 --- a/dashboard/src/views/videoComponents/videoConfigModal.jsx +++ b/dashboard/src/views/videoComponents/videoConfigModal.jsx @@ -8,15 +8,17 @@ import PropTypes from "prop-types"; import {FaSpinner} from "react-icons/fa"; -export const VideoConfigModal = ({showModal, setShowModal, saving, setSaving, video, handleConfigSelection}) => { +export const VideoConfigModal = ({modalState, closeModal, saving, setSaving, handleConfigSelection}) => { const [loading, setLoading] = useState(false); const [availableVideoConfigs, setAvailableVideoConfigs] = useState([]); - + const [video, setVideo] = useState(null); // set navigation const navigate = useNavigate(); // Fetch the existing video configs when the modal is opened useEffect(() => { + // set video on more convenient local state + setVideo(modalState.video) setLoading(true); setSaving(false); api.get("/video_config/") // Replace with your endpoint for fetching video configs @@ -28,7 +30,7 @@ export const VideoConfigModal = ({showModal, setShowModal, saving, setSaving, vi setLoading(false); console.error("Error fetching video configs:", error); }); - }, []); + }, [modalState]); const createNewVideoConfig = (video_id) => { @@ -36,15 +38,14 @@ export const VideoConfigModal = ({showModal, setShowModal, saving, setSaving, vi navigate(`/video_config/${video_id}`); } - return ( <> {/*Modal for selecting a VideoConfig or creating a new Video Config*/} {/* Modal for video config */} { - setShowModal(false); + closeModal(); setSaving(false); } } @@ -74,7 +75,7 @@ export const VideoConfigModal = ({showModal, setShowModal, saving, setSaving, vi cursor: "pointer", lineHeight: "1", }} - onClick={() => setShowModal(false)} + onClick={() => closeModal()} aria-label="Close" > × @@ -114,10 +115,9 @@ export const VideoConfigModal = ({showModal, setShowModal, saving, setSaving, vi ) } VideoConfigModal.propTypes = { - showModal: PropTypes.bool.isRequired, - setShowModal: PropTypes.func.isRequired, + modalState: PropTypes.object.isRequired, + closeModal: PropTypes.func.isRequired, saving: PropTypes.bool.isRequired, setSaving: PropTypes.func.isRequired, - video: PropTypes.object, handleConfigSelection: PropTypes.func.isRequired, }; diff --git a/dashboard/src/views/videoComponents/videoDetailsModal.jsx b/dashboard/src/views/videoComponents/videoDetailsModal.jsx index 06ac3e5..f4d8445 100644 --- a/dashboard/src/views/videoComponents/videoDetailsModal.jsx +++ b/dashboard/src/views/videoComponents/videoDetailsModal.jsx @@ -3,14 +3,15 @@ import {useState} from "react"; import {VideoDetails} from "./videoDetails.jsx"; -export const VideoDetailsModal = ({selectedVideo, setSelectedVideo, setShowModal}) => { +export const VideoDetailsModal = ({selectedVideo, closeModal}) => { const [videoError, setVideoError] = useState(false); // tracks errors in finding video in modal display const [imageError, setImageError] = useState(false); // tracks errors in finding image in modal display // Close modal - const closeModal = () => { - setSelectedVideo(null); - setShowModal(false); + const closeActions = () => { + closeModal(); + // setSelectedVideo(null); + // setShowModal(false); setImageError(false); setVideoError(false); }; @@ -26,7 +27,7 @@ export const VideoDetailsModal = ({selectedVideo, setSelectedVideo, setShowModal
@@ -36,7 +37,7 @@ export const VideoDetailsModal = ({selectedVideo, setSelectedVideo, setShowModal diff --git a/orc_api/crud/generic.py b/orc_api/crud/generic.py index 1f43299..4f3304b 100644 --- a/orc_api/crud/generic.py +++ b/orc_api/crud/generic.py @@ -1,19 +1,17 @@ """Generic CRUD operations, used in multiple CRUD modules.""" from datetime import datetime -from typing import Optional +from typing import Any, Optional from sqlalchemy.orm.query import Query -from orc_api.db import Base - def get_closest( query: Query, - model: Base, + model: Any, timestamp: datetime, allowed_dt: Optional[float] = None, -): +) -> Optional[Any]: """Fetch the model instance closest to the given timestamp. This function queries to find the model instance that is closest diff --git a/orc_api/crud/video.py b/orc_api/crud/video.py index b36297c..5f5f43d 100644 --- a/orc_api/crud/video.py +++ b/orc_api/crud/video.py @@ -29,19 +29,19 @@ def filter_status(query: Query, status: Optional[models.VideoStatus] = None): return query -def filter_sync_status(query: Query, sync_status: Optional[models.VideoStatus] = None): +def filter_sync_status(query: Query, sync_status: Optional[models.SyncStatus] = None): """Filter query by start and stop datetime.""" if sync_status: query = query.where(models.Video.sync_status == sync_status) return query -def get_query_by_id(db: Session, id: int): +def get_query_by_id(db: Session, id: int) -> Query: """Get a single video in a query (e.g. for updating.""" return db.query(models.Video).filter(models.Video.id == id) -def get(db: Session, id: int): +def get(db: Session, id: int) -> Optional[models.Video]: """Get a single video.""" query = get_query_by_id(db=db, id=id) if query.count() == 0: @@ -53,7 +53,7 @@ def get_closest( db: Session, timestamp: datetime, allowed_dt: Optional[float] = None, -): +) -> Optional[models.Video]: """Fetch the video closest to the given timestamp (None if further away than allowed_dt).""" return generic.get_closest(db.query(models.Video), models.Video, timestamp, allowed_dt) @@ -70,7 +70,7 @@ def get_closest_no_ts( return generic.get_closest(q, models.Video, timestamp, allowed_dt) -def get_ids(db: Session, ids: List[int] = None) -> List[models.Video]: +def get_ids(db: Session, ids: Optional[List[int]] = None) -> List[models.Video]: """List videos from provided ids.""" ids = [] if ids is None else ids query = db.query(models.Video).filter(models.Video.id.in_(ids)) diff --git a/orc_api/log.py b/orc_api/log.py index 3430f45..a8c2fb8 100644 --- a/orc_api/log.py +++ b/orc_api/log.py @@ -5,6 +5,7 @@ import logging.handlers import os import sys +from typing import Optional import anyio from fastapi import WebSocket @@ -28,7 +29,7 @@ def filter(self, record: logging.LogRecord) -> bool: def setuplog( name: str = "orc-os", - path: str = None, + path: Optional[str] = None, log_level: int = 20, fmt: str = FMT, append: bool = True, @@ -105,7 +106,7 @@ def remove_file_handler(logger, name_contains="hello_world"): logger.debug(f"Removed file handler to {handler.baseFilename} from logger.") -def start_logger(verbose, quiet, log_path=None): +def start_logger(verbose, quiet, log_path=None) -> logging.Logger: if not log_path: # set it here to a fixed location log_path = os.path.join(__home__, "log") @@ -164,9 +165,9 @@ async def stream_new_lines(websocket: WebSocket, fn: str): await asyncio.sleep(0.1) # Wait before trying to read more lines -if "ALEMBIC_RUNNING" not in os.environ: - logger = start_logger(True, False, log_path=LOG_DIRECTORY) -else: - logger = None +# if "ALEMBIC_RUNNING" not in os.environ: +logger = start_logger(True, False, log_path=LOG_DIRECTORY) +# else: +# logger = None __all__ = ["logger", "get_last_lines", "stream_new_lines"] diff --git a/orc_api/main.py b/orc_api/main.py index 23775f0..0c7fa51 100644 --- a/orc_api/main.py +++ b/orc_api/main.py @@ -18,6 +18,7 @@ ) from orc_api.database import get_session from orc_api.db import VideoStatus +from orc_api.log import logger from orc_api.routers import ( auth, callback_url, @@ -51,8 +52,6 @@ @asynccontextmanager async def lifespan(app: FastAPI): """Start the scheduler and logger.""" - from orc_api.log import logger - logger.info("Starting ORC-OS API") scheduler = BackgroundScheduler() scheduler.start() diff --git a/orc_api/schemas/cross_section.py b/orc_api/schemas/cross_section.py index c114727..71495de 100644 --- a/orc_api/schemas/cross_section.py +++ b/orc_api/schemas/cross_section.py @@ -213,6 +213,13 @@ def get_bbox_dry_wet(self, h: float, camera: bool = True, dry: bool = False): pols = self.obj.get_bbox_dry_wet(h=h, camera=camera, dry=dry) return [list(map(list, pol.exterior.coords)) for pol in pols.geoms] + def validate_h_a(self, h_a: float) -> bool: + """Validate if the water level is above the lowest point in the cross section.""" + # convert to coordinate datum + z_a = self.camera_config.obj.h_to_z(h_a) + # check if z_a is above the lowest point in the cross section + return z_a > np.array(self.z).min() + class CrossSectionUpdate(CrossSectionBase): """Update model with several input fields from user.""" diff --git a/orc_api/schemas/video.py b/orc_api/schemas/video.py index c315f6c..80f8558 100644 --- a/orc_api/schemas/video.py +++ b/orc_api/schemas/video.py @@ -1,7 +1,6 @@ """Video schema.""" import glob -import json import os import subprocess import time @@ -11,14 +10,14 @@ import numpy as np import xarray as xr from pydantic import BaseModel, ConfigDict, Field, computed_field -from pyorc.service import velocity_flow +from pyorc.service import velocity_flow_subprocess from sqlalchemy.orm import Session from orc_api import crud, timeout_before_shutdown from orc_api import db as models from orc_api.database import get_session from orc_api.db import Video -from orc_api.log import add_filehandler, logger, remove_file_handler +from orc_api.log import logger from orc_api.schemas.base import RemoteModel from orc_api.schemas.time_series import TimeSeriesResponse from orc_api.schemas.video_config import VideoConfigBase, VideoConfigResponse, VideoConfigUpdate @@ -200,13 +199,14 @@ def run(self, base_path: str, prefix: str = "", shutdown_after_task: bool = Fals with get_session() as session: try: # set up a temporary additional log file handler - print(f"Adding log file handler for video {self.id} {self.get_log_file(base_path=base_path)}") rec = crud.video.get(session, id=self.id) + if not rec: + raise ValueError(f"Video with id {self.id} does not exist in database.") rec.status = models.VideoStatus.TASK session.commit() session.refresh(rec) # now also show the state PROCESSING in web socket - filename = os.path.split(self.file)[1] + filename = os.path.split(self.file)[1] if self.file else None video_run_state.update( video_id=self.id, video_file=filename, @@ -223,7 +223,7 @@ def run(self, base_path: str, prefix: str = "", shutdown_after_task: bool = Fals # check for h_a h_a = None if self.time_series is None else self.time_series.h - logger.debug(f"Checked for water level in time series, found {h_a}") + logger.debug(f"Checked for water level in time series, found {h_a:.3f} m.") # overrule with set level if the video configuration is made with the current video if self.video_config.sample_video_id == self.id: @@ -231,21 +231,21 @@ def run(self, base_path: str, prefix: str = "", shutdown_after_task: bool = Fals h_a = self.video_config.camera_config.gcps.h_ref # assemble all information - logger.info(f"Water level set to {h_a}") + logger.info(f"Water level set to {h_a:.3f} m.") + # check if h_a is above lowest point in cross section + validate_h_a_cross = self.video_config.cross_section_wl_rt.validate_h_a(h_a=h_a) + if not validate_h_a_cross: + raise Exception( + f"Provided water level {h_a:.3f} m is not above the lowest point in the cross section. " + f"Please provide a higher water level, or adjust the cross section." + ) output = os.path.join(self.get_path(base_path=base_path), "output") cameraconfig = self.video_config.camera_config.data.model_dump() # get the rotated/translated cross-section - cross_section_feats = self.video_config.cross_section_rt.features - # dump the used features in the output path of the video - cross = os.path.join(self.get_path(base_path=base_path), "cross_section.geojson") - with open(cross, "w") as f: - json.dump(cross_section_feats, f) + cross = self.video_config.cross_section_rt.features # if h_a is not available and a cross section is available, make cross section file for water level if h_a is None and self.video_config.cross_section_wl: - cross_section_wl_feats = self.video_config.cross_section_wl_rt.features - cross_wl = os.path.join(self.get_path(base_path=base_path), "cross_section_wl.geojson") - with open(cross_wl, "w") as f: - json.dump(cross_section_wl_feats, f) + cross_wl = self.video_config.cross_section_wl_rt.features else: cross_wl = None # get the recipe with any required fields filled @@ -266,10 +266,11 @@ def run(self, base_path: str, prefix: str = "", shutdown_after_task: bool = Fals f"{self.get_output_path(base_path=base_path).split(base_path)[-1]}" ) # run the video with pyorc with an additional logger handler - add_filehandler( - logger=logger, path=self.get_log_file(base_path=base_path), function="velocimetry", log_level=10 + logger.info( + "Starting video processing with pyorc. You can check logs per video record after running in " + "the video view." ) - velocity_flow( + res = velocity_flow_subprocess( recipe=recipe, videofile=videofile, cameraconfig=cameraconfig, @@ -280,7 +281,12 @@ def run(self, base_path: str, prefix: str = "", shutdown_after_task: bool = Fals cross_wl=cross_wl, logger=logger, ) - remove_file_handler(logger, name_contains="pyorc.log") + if res.returncode != 0: + raise Exception( + f"Error running video, pyorc returned non-zero exit code: {res.returncode} and error output " + f"{res.stderr}" + "Please check the log belonging to video" + ) self.image = rel_img_fn # update time series (before video, in case time series with optical water level is added in the process logger.info("Updating time series belonging to video.") @@ -289,16 +295,14 @@ def run(self, base_path: str, prefix: str = "", shutdown_after_task: bool = Fals self.status = models.VideoStatus.DONE video_run_state.update(status=VideoRunStatus.SUCCESS, message="Processing successful.") except Exception as e: - # ensure the file handler is removed, even if an error occurs - remove_file_handler(logger, name_contains="pyorc.log") # ensure status is ERROR, but continue afterwards self.status = models.VideoStatus.ERROR # also show this state in the web socket video_run_state.update(status=VideoRunStatus.ERROR, message=f"Error running video: {filename}: {e}") logger.error(f"Error running video, response: {e}, VideoStatus set to ERROR.") - finally: - # the last handler should be our file handler. - remove_file_handler(logger, name_contains="pyorc.log") + # finally: + # # the last handler should be our file handler. + # remove_file_handler(logger, name_contains="pyorc.log") update_data = self.serialize_for_db() if self.time_series: diff --git a/tests/test_schemas/test_video_schemas.py b/tests/test_schemas/test_video_schemas.py index a59bfff..e1da09e 100644 --- a/tests/test_schemas/test_video_schemas.py +++ b/tests/test_schemas/test_video_schemas.py @@ -62,7 +62,7 @@ def test_video_run_daemon_shutdown(tmpdir, video_response_no_ts, session_video_c class ShutdownException(Exception): pass - def mock_velocity_flow(**kwargs): + def mock_velocity_flow_subprocess(**kwargs): return None def mock_update_timeseries(self, *args, **kwargs): @@ -83,7 +83,7 @@ def mock_subprocess_call(*args, **kwargs): mock_shutdown = mock.Mock(side_effect=mock_subprocess_call) monkeypatch.setattr("subprocess.call", mock_shutdown) monkeypatch.setattr("time.sleep", mock_timeout) - monkeypatch.setattr("orc_api.schemas.video.velocity_flow", mock_velocity_flow) + monkeypatch.setattr("orc_api.schemas.video.velocity_flow_subprocess", mock_velocity_flow_subprocess) monkeypatch.setattr("orc_api.schemas.video.VideoResponse.update_timeseries", mock_update_timeseries) monkeypatch.setattr("orc_api.schemas.video.VideoResponse.sync_remote", mock_update_timeseries) with pytest.raises(ShutdownException):