From b7ae7d02330a26290902582e1b9cd23c42cbfc66 Mon Sep 17 00:00:00 2001 From: bbean Date: Mon, 8 Apr 2024 15:25:52 -0600 Subject: [PATCH 01/17] initial server code from realpython.com --- opencsp/app/sofast/sofast_server.py | 69 +++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 opencsp/app/sofast/sofast_server.py diff --git a/opencsp/app/sofast/sofast_server.py b/opencsp/app/sofast/sofast_server.py new file mode 100644 index 000000000..4f3fcc50e --- /dev/null +++ b/opencsp/app/sofast/sofast_server.py @@ -0,0 +1,69 @@ +""" +A simple (unsecured & slow) web-server to provide a cURL API to the SOFAST systems. + +Starter code for this server from "How to Launch an HTTP Server in One Line of Python Code" by realpython.com +(https://realpython.com/python-http-server/). +""" + +import json +from functools import cached_property +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qsl, urlparse + +import opencsp.common.lib.tool.log_tools as lt + + +class SofastServerHandler(BaseHTTPRequestHandler): + @cached_property + def url(self): + return urlparse(self.path) + + @cached_property + def query_data(self): + return dict(parse_qsl(self.url.query)) + + @cached_property + def post_data(self): + content_length = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(content_length) + + @cached_property + def form_data(self): + return dict(parse_qsl(self.post_data.decode("utf-8"))) + + @cached_property + def cookies(self): + return SimpleCookie(self.headers.get("Cookie")) + + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(self.get_response().encode("utf-8")) + + def do_POST(self): + self.do_GET() + + def get_response(self): + return json.dumps( + { + "path": self.url.path, + "query_data": self.query_data, + "post_data": self.post_data.decode("utf-8"), + "form_data": self.form_data, + "cookies": { + name: cookie.value + for name, cookie in self.cookies.items() + }, + } + ) + + +if __name__ == "__main__": + port = 8000 + lt.warn("Warning in SofastServer: this server is unsecured. " + + f"It is suggested that you restrict outside access to port {port} of the host computer.") + lt.info(f"Starting server on port {port}...") + server = HTTPServer(("0.0.0.0", port), SofastServerHandler) + server.serve_forever() From 9df3cf4f62fc8a455b796b2e08fa42fe2bf1b0d2 Mon Sep 17 00:00:00 2001 From: bbean Date: Tue, 9 Apr 2024 14:25:03 -0600 Subject: [PATCH 02/17] adding some initial ideas for the sofast server interface --- opencsp/app/sofast/SofastServer.py | 96 +++++++ opencsp/app/sofast/__init__.py | 18 ++ opencsp/app/sofast/lib/ServerState.py | 283 ++++++++++++++++++++ opencsp/common/lib/opencsp_path/__init__.py | 2 +- 4 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 opencsp/app/sofast/SofastServer.py create mode 100644 opencsp/app/sofast/lib/ServerState.py diff --git a/opencsp/app/sofast/SofastServer.py b/opencsp/app/sofast/SofastServer.py new file mode 100644 index 000000000..2f259b219 --- /dev/null +++ b/opencsp/app/sofast/SofastServer.py @@ -0,0 +1,96 @@ +""" +A simple (unsecured & slow) web-server to provide a cURL API to the SOFAST systems. + +Starter code for this server from "How to Launch an HTTP Server in One Line of Python Code" by realpython.com +(https://realpython.com/python-http-server/). +""" + +from concurrent.futures import ThreadPoolExecutor +import gc +import json +from functools import cached_property +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qsl, urlparse + +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +import opencsp.common.lib.tool.log_tools as lt +import opencsp.app.sofast.lib.ServerState as ss + + +class SofastServer(BaseHTTPRequestHandler): + @cached_property + def url(self): + return urlparse(self.path) + + @cached_property + def query_data(self): + return dict(parse_qsl(self.url.query)) + + @cached_property + def post_data(self): + content_length = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(content_length) + + @cached_property + def form_data(self): + return dict(parse_qsl(self.post_data.decode("utf-8"))) + + @cached_property + def cookies(self): + return SimpleCookie(self.headers.get("Cookie")) + + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(self.get_response().encode("utf-8")) + + def do_POST(self): + self.do_GET() + + def get_response(self): + return json.dumps( + { + "path": self.url.path, + "query_data": self.query_data, + "post_data": self.post_data.decode("utf-8"), + "form_data": self.form_data, + "cookies": { + name: cookie.value + for name, cookie in self.cookies.items() + }, + } + ) + + +if __name__ == "__main__": + port = 8000 + + # Start the server + lt.warn("Warning in SofastServer: this server is unsecured. " + + f"It is suggested that you restrict outside access to port {port} of the host computer.") + lt.info(f"Starting server on port {port}...") + server = HTTPServer(("0.0.0.0", port), SofastServer) + + # Initialize the IO devices + state = ss.ServerState() + state.init_io() + + # Lock in the currently allocated memory, to improve garbage collector performance + gc.collect() + gc.freeze() + + # Start a new thread for the server + # The minimum time between server evaulation loops is determined by the GIL: + # https://docs.python.org/3/library/sys.html#sys.setswitchinterval + pool = ThreadPoolExecutor(max_workers=1) + pool.submit(server.serve_forever) + + # Start the GUI thread + ImageProjection.instance().root.mainloop() + + # GUI has exited, shutdown everything + state.close_all() + server.shutdown() + pool.shutdown() diff --git a/opencsp/app/sofast/__init__.py b/opencsp/app/sofast/__init__.py index e69de29bb..f3c6162e0 100644 --- a/opencsp/app/sofast/__init__.py +++ b/opencsp/app/sofast/__init__.py @@ -0,0 +1,18 @@ +_sofast_server_settings_key = "sofast_server" +_sofast_server_settings_default: dict[str, None] = { + "log_output_dir": None, + "camera_files": None, + "projector_file": None, + "calibration_file": None, + "mirror_measure_point": None, + "mirror_screen_distance": None, + "fixed_pattern_diameter_and_spacing": None, +} +""" +log_output_dir: Where to save log output to from the server. +camera_files: Where to find the camera .h5 file(s), which define the default cameras to connect to on server start. +projector_file: Where to find the projection .h5 file, which defines the default screen space for the projector. +calibration_file: Where to find the calibration .h5 file, which defines the default camera-screen response calibration. +""" + +_settings_list = [[_sofast_server_settings_key, _sofast_server_settings_default]] diff --git a/opencsp/app/sofast/lib/ServerState.py b/opencsp/app/sofast/lib/ServerState.py new file mode 100644 index 000000000..b77c32388 --- /dev/null +++ b/opencsp/app/sofast/lib/ServerState.py @@ -0,0 +1,283 @@ +import asyncio +from concurrent.futures import ThreadPoolExecutor +from typing import Generic, TypeVar + +from opencsp.app.sofast.lib.MeasurementSofastFixed import MeasurementSofastFixed +from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe +from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe +from opencsp.app.sofast.lib.SystemSofastFixed import SystemSofastFixed +from opencsp.common.lib.camera.ImageAcquisitionAbstract import ImageAcquisitionAbstract +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +import opencsp.common.lib.geometry.Vxyz as vxyz +import opencsp.common.lib.tool.exception_tools as et +import opencsp.common.lib.tool.log_tools as lt +import opencsp.common.lib.tool.time_date_tools as tdt + + +T = TypeVar('T') + + +class ControlledContext(Generic[T]): + def __init__(self, o: T): + self.o = o + self.mutex = asyncio.Lock() + + def __enter__(self): + self.mutex.acquire() + return self.o + + def __exit__(self, exc_type, exc_value, traceback): + self.mutex.release() + return False + + +class ServerState: + _instance: ControlledContext['ServerState'] = None + + def __init__(self): + # systems + self._system_fixed: ControlledContext[SystemSofastFixed] = None + self._system_fringe: ControlledContext[SystemSofastFringe] = None + + # measurements + self._last_measurement_fixed: MeasurementSofastFixed = None + self._last_measurement_fringe: list[MeasurementSofastFringe] = None + self.fixed_measurement_name: str = None + self.fringe_measurement_name: str = None + + # configurations + self._mirror_measure_point: vxyz.Vxyz = None + self._mirror_measure_distance: float = None + self._fixed_pattern_diameter: int = None + self._fixed_pattern_spacing: int = None + + # statuses + self._running_measurement_fixed = False + self._running_measurement_fringe = False + self._processing_measurement_fixed = False + self._processing_measurement_fringe = False + + # processing thread + self._processing_pool = ThreadPoolExecutor(max_workers=1) + + if ServerState._instance is None: + ServerState._instance = ControlledContext(self) + else: + lt.error_and_raise(RuntimeError, "Error in ServerState(): " + + "this class is supposed to be a singleton, but another instance already exists!") + + @staticmethod + def instance() -> ControlledContext['ServerState']: + return ServerState._instance + + @property + def system_fixed(self) -> ControlledContext[SystemSofastFixed]: + if self._system_fixed is None: + display_data = ImageProjection.instance().display_data + size_x, size_y = display_data['size_x'], display_data['size_y'] + width_pattern = self._fixed_pattern_diameter + spacing_pattern = self._fixed_pattern_spacing + self._system_fixed = ControlledContext(SystemSofastFixed(size_x, size_y, width_pattern, spacing_pattern)) + return self._system_fixed + + @property + def system_fringe(self) -> ControlledContext[SystemSofastFringe]: + if self._system_fringe is None: + self._system_fringe = ControlledContext(SystemSofastFringe()) + return self._system_fringe + + @property + def projector_available(self) -> bool: + """ + Returns False for the collection period of a sofast measurement. Returns True when available to start a new + measurement. + """ + if self._running_measurement_fringe: + return False + elif self._running_measurement_fixed: + return False + else: + return True + + @property + def has_fixed_measurement(self) -> bool: + """ + Returns False for the processing or collection periods of a fixed measurement. Returns True when results are + available. + """ + if self._last_measurement_fixed is None: + return False + elif self._running_measurement_fixed: + lt.error("Programmer error in ServerState.fixed_measurement_ready(): " + + f"expected 'last_measurement' to be None while 'running', but {self._last_measurement_fixed=} {self._running_measurement_fixed}") + return False + elif self._processing_measurement_fixed: + lt.error("Programmer error in ServerState.fixed_measurement_ready(): " + + f"expected 'last_measurement' to be None while 'processing', but {self._last_measurement_fixed=} {self._processing_measurement_fixed}") + return False + else: + return True + + @property + def last_measurement_fixed(self) -> list[MeasurementSofastFixed]: + if not self.has_fixed_measurement: + return None + return self._last_measurement_fixed + + @property + def has_fringe_measurement(self) -> bool: + """ + Returns False for the processing or collection periods of a fringe measurement. Returns True when results are + available. + """ + if self._last_measurement_fringe is None: + return False + elif self._running_measurement_fringe: + lt.error("Programmer error in ServerState.fringe_measurement_ready(): " + + f"expected 'last_measurement' to be None while 'running', but {self._last_measurement_fringe=} {self._running_measurement_fringe}") + return False + elif self._processing_measurement_fringe: + lt.error("Programmer error in ServerState.fringe_measurement_ready(): " + + f"expected 'last_measurement' to be None while 'processing', but {self._last_measurement_fringe=} {self._processing_measurement_fringe}") + return False + else: + return True + + @property + def last_measurement_fringe(self) -> list[MeasurementSofastFringe]: + if not self.has_fringe_measurement: + return None + return self._last_measurement_fringe + + @property + def busy(self) -> bool: + """ + Returns True if running a measurement collection or processing a measurement. Returns False when all resources + all available for starting a new measurement. + """ + if self._running_measurement_fixed: + return False + elif self._running_measurement_fringe: + return False + elif self._processing_measurement_fixed: + return False + elif self._processing_measurement_fringe: + return False + else: + return True + + def _connect_default_cameras(self): + pass + + def _load_default_projector(self): + pass + + def _load_default_calibration(self): + pass + + def _get_default_mirror_distance_measurement(self): + pass + + def init_io(self): + self._connect_default_cameras() + self._load_default_projector() + self._load_default_calibration() + + def start_measure_fringes(self, name: str = None) -> bool: + """Starts collection and processing of fringe measurement image data. + + Returns + ------- + success: bool + True if the measurement was able to be started. False if the system resources are busy. + """ + # Check that system resources are available + if self._running_measurement_fringe or self._processing_measurement_fringe: + lt.warn("Warning in server_api.run_measurment_fringe(): " + + "Attempting to start another fringe measurement before the last fringe measurement has finished.") + return False + if not self.projector_available: + lt.warn("Warning in server_api.run_measurment_fringe(): " + + "Attempting to start a fringe measurement while the projector is already in use.") + return False + + # Latch the name value + self.fringe_measurement_name = name + + # Update statuses + self._last_measurement_fringe = None + self._running_measurement_fringe = True + + # Start the measurement + lt.debug("ServerState: collecting fringes") + with self.system_fringe as sys: + sys.run_measurement(self._on_collect_fringes_done) + + return True + + def _on_collect_fringes_done(self): + lt.debug("ServerState: finished collecting fringes") + if not self._running_measurement_fringe: + lt.error("Programmer error in server_api._on_collect_fringes_done(): " + + "Did not expect for this method to be called while self._running_measurement_fringe was not True!") + + # Update statuses + self._processing_measurement_fringe = True + self._running_measurement_fringe = False + + # Start the processing + self._processing_pool.submit(self._process_fringes) + + def _process_fringes(self): + lt.debug("ServerState: processing fringes") + if not self._processing_measurement_fringe: + lt.error("Programmer error in server_api._process_fringes(): " + + "Did not expect for this method to be called while self._processing_measurement_fringe was not True!") + + # process the fringes + with self.system_fringe as sys: + name = "fringe_measurement_"+tdt.current_date_time_string_forfile() + if self.fringe_measurement_name != None: + name = self.fringe_measurement_name + self._last_measurement_fringe = sys.get_measurements( + self._mirror_measure_point, self._mirror_measure_distance, name) + + # update statuses + self._processing_fringes = False + + def close_all(self): + """Closes all cameras, projectors, and sofast systems (currently just sofast fringe)""" + with et.ignored(Exception): + lt.debug("ServerState.close_all(): Stopping processing thread(s)") + self._processing_pool.shutdown(wait=True, cancel_futures=True) + self._processing_pool = None + + with et.ignored(Exception): + lt.debug("ServerState.close_all(): Closing the cameras") + for camera in ImageAcquisitionAbstract.instances(): + with et.ignored(Exception): + camera.close() + + with et.ignored(Exception): + lt.debug("ServerState.close_all(): Closing the projector") + ImageProjection.instance().close() + + with et.ignored(Exception): + lt.debug("ServerState.close_all(): Closing the sofast fringe system") + if self._system_fringe is not None: + with self._system_fringe as sys: + sys.close_all() + self._system_fringe = None + + # TODO uncomment in the case that sofast fixed ever gains a close() or close_all() method + # with et.ignored(Exception): + # lt.debug("ServerState.close_all(): Closing the sofast fixed system") + # if self._system_fixed is not None: + # with self._system_fixed as sys: + # sys.close_all() + # self._system_fixed = None + + with et.ignored(Exception): + lt.debug("ServerState.close_all(): Unassigning this instance as the singleton ServerState") + if self == ServerState._instance: + ServerState._instance = None diff --git a/opencsp/common/lib/opencsp_path/__init__.py b/opencsp/common/lib/opencsp_path/__init__.py index 984d7405c..57e4458d5 100644 --- a/opencsp/common/lib/opencsp_path/__init__.py +++ b/opencsp/common/lib/opencsp_path/__init__.py @@ -15,7 +15,7 @@ """ _settings_list = [[_orp_settings_key, _orp_settings_default]] -_opencsp_settings_packages = ["common.lib.tool"] +_opencsp_settings_packages = ["common.lib.tool", "app.sofast"] _opencsp_code_settings_packages = ["contrib.scripts"] opencsp_settings = {} From 11ee8fa4658024cb088b28d3b66af8beafc814c8 Mon Sep 17 00:00:00 2001 From: bbean Date: Tue, 9 Apr 2024 14:38:46 -0600 Subject: [PATCH 03/17] add ServerState usage example to SofastServer --- opencsp/app/sofast/SofastServer.py | 64 +++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/opencsp/app/sofast/SofastServer.py b/opencsp/app/sofast/SofastServer.py index 2f259b219..f898af579 100644 --- a/opencsp/app/sofast/SofastServer.py +++ b/opencsp/app/sofast/SofastServer.py @@ -6,11 +6,13 @@ """ from concurrent.futures import ThreadPoolExecutor +import dataclasses import gc import json from functools import cached_property from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, HTTPServer +from traceback import format_exception from urllib.parse import parse_qsl, urlparse from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection @@ -18,9 +20,20 @@ import opencsp.app.sofast.lib.ServerState as ss +@dataclasses.dataclass +class _UrlParseResult: + """Helper class to define the slot names of the ParseResult type""" + scheme: str + net_location: str + path: str + params: str + query: str + fragment: str + + class SofastServer(BaseHTTPRequestHandler): @cached_property - def url(self): + def url(self) -> _UrlParseResult: return urlparse(self.path) @cached_property @@ -50,18 +63,29 @@ def do_POST(self): self.do_GET() def get_response(self): - return json.dumps( - { - "path": self.url.path, - "query_data": self.query_data, - "post_data": self.post_data.decode("utf-8"), - "form_data": self.form_data, - "cookies": { - name: cookie.value - for name, cookie in self.cookies.items() - }, - } - ) + action = "N/A" + ret = { + "error": None + } + + try: + action = self.url.path.split("/")[-1] + + if action == "start_measure_fringes": + with ss.ServerState.instance() as state: + name = self.query_data["name"] + ret["success"] = state.start_measure_fringes(name) + + elif action == "is_busy": + with ss.ServerState.instance() as state: + ret["is_busy"] = state.busy + + except Exception as ex: + lt.error("Error in SofastServer with action " + action + ": " + repr(ex)) + ret["error"] = repr(ex) + ret["trace"] = "".join(format_exception(ex)) + + return json.dumps(ret) if __name__ == "__main__": @@ -74,8 +98,9 @@ def get_response(self): server = HTTPServer(("0.0.0.0", port), SofastServer) # Initialize the IO devices - state = ss.ServerState() - state.init_io() + ss.ServerState() + with ss.ServerState.instance() as state: + state.init_io() # Lock in the currently allocated memory, to improve garbage collector performance gc.collect() @@ -84,13 +109,14 @@ def get_response(self): # Start a new thread for the server # The minimum time between server evaulation loops is determined by the GIL: # https://docs.python.org/3/library/sys.html#sys.setswitchinterval - pool = ThreadPoolExecutor(max_workers=1) - pool.submit(server.serve_forever) + server_pool = ThreadPoolExecutor(max_workers=1) + server_pool.submit(server.serve_forever) # Start the GUI thread ImageProjection.instance().root.mainloop() # GUI has exited, shutdown everything - state.close_all() + with ss.ServerState.instance() as state: + state.close_all() server.shutdown() - pool.shutdown() + server_pool.shutdown() From 6193382acc61db93b8f4f64bcbed0b3e8fe1ce8c Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 10 Apr 2024 11:06:02 -0600 Subject: [PATCH 04/17] add Braden's suggestions for additional Sofast files and settings --- opencsp/app/sofast/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/opencsp/app/sofast/__init__.py b/opencsp/app/sofast/__init__.py index f3c6162e0..d3821bf5a 100644 --- a/opencsp/app/sofast/__init__.py +++ b/opencsp/app/sofast/__init__.py @@ -7,6 +7,12 @@ "mirror_measure_point": None, "mirror_screen_distance": None, "fixed_pattern_diameter_and_spacing": None, + "spatial_orientation": None, + "display_shape_file": None, + "dot_locations_file": None, + "facet_files": None, + "ensemble_file": None, + "surface_shape_file": None, } """ log_output_dir: Where to save log output to from the server. From 3f9560ba43ad1632363b805adfdf8818f145aec6 Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 10 Apr 2024 12:07:09 -0600 Subject: [PATCH 05/17] flesh out the handler a bit --- opencsp/app/sofast/SofastServer.py | 59 +++++++++++++++++++++---- opencsp/app/sofast/__init__.py | 1 + opencsp/app/sofast/lib/ServerState.py | 62 ++++++++++++++++++++++++--- 3 files changed, 108 insertions(+), 14 deletions(-) diff --git a/opencsp/app/sofast/SofastServer.py b/opencsp/app/sofast/SofastServer.py index f898af579..e4fc3d483 100644 --- a/opencsp/app/sofast/SofastServer.py +++ b/opencsp/app/sofast/SofastServer.py @@ -12,12 +12,15 @@ from functools import cached_property from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, HTTPServer +import os from traceback import format_exception from urllib.parse import parse_qsl, urlparse +import opencsp.app.sofast.lib.ServerState as ss from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +from opencsp.common.lib.opencsp_path import opencsp_settings +import opencsp.common.lib.tool.file_tools as ft import opencsp.common.lib.tool.log_tools as lt -import opencsp.app.sofast.lib.ServerState as ss @dataclasses.dataclass @@ -54,22 +57,31 @@ def cookies(self): return SimpleCookie(self.headers.get("Cookie")) def do_GET(self): - self.send_response(200) + response_code, response_msg = self.get_response() + self.send_response(response_code) self.send_header("Content-Type", "application/json") self.end_headers() - self.wfile.write(self.get_response().encode("utf-8")) + self.wfile.write(response_msg.encode("utf-8")) def do_POST(self): self.do_GET() - def get_response(self): + def get_response(self) -> tuple[int, str]: action = "N/A" ret = { "error": None } + response_code = 200 try: - action = self.url.path.split("/")[-1] + if "/" in self.url.path: + action = self.url.path.split("/")[-1] + else: + action = self.url.path + + if action == "help": + ret["actions"] = ["help", "start_measure_fringes", + "is_busy", "save_measure_fringes", "get_results_fringes"] if action == "start_measure_fringes": with ss.ServerState.instance() as state: @@ -80,12 +92,43 @@ def get_response(self): with ss.ServerState.instance() as state: ret["is_busy"] = state.busy + elif action == "save_measure_fringes": + if "saves_output_dir" in opencsp_settings and ft.directory_exists(opencsp_settings["saves_output_dir"]): + measurement = None + with ss.ServerState.instance() as state: + if state.has_fringe_measurement: + measurement = state.last_measurement_fringe[0] + file_name_ext = state.fringe_measurement_name+".h5" + if measurement is not None: + file_path_name_ext = os.path.join(opencsp_settings["saves_output_dir"], file_name_ext) + measurement.save_to_hdf(file_path_name_ext) + ret["file_name_ext"] = file_name_ext + else: + ret["error"] = "Fringe measurement is not ready" + ret["trace"] = "SofastServer.get_response::save_measure_fringes" + else: + ret["error"] = "Measurements save directory not speicified in settings" + ret["trace"] = "SofastServer.get_response::save_measure_fringes" + + elif action == "get_results_fringes": + pass + + else: + ret = { + "error": f"Unknown action \"{action}\"", + "trace": "SofastServer.get_response" + } + response_code = 404 + except Exception as ex: lt.error("Error in SofastServer with action " + action + ": " + repr(ex)) - ret["error"] = repr(ex) - ret["trace"] = "".join(format_exception(ex)) + ret = { + "error": repr(ex), + "trace": "".join(format_exception(ex)) + } + response_code = 500 - return json.dumps(ret) + return response_code, json.dumps(ret) if __name__ == "__main__": diff --git a/opencsp/app/sofast/__init__.py b/opencsp/app/sofast/__init__.py index d3821bf5a..4069f2ec3 100644 --- a/opencsp/app/sofast/__init__.py +++ b/opencsp/app/sofast/__init__.py @@ -1,6 +1,7 @@ _sofast_server_settings_key = "sofast_server" _sofast_server_settings_default: dict[str, None] = { "log_output_dir": None, + "saves_output_dir": None, "camera_files": None, "projector_file": None, "calibration_file": None, diff --git a/opencsp/app/sofast/lib/ServerState.py b/opencsp/app/sofast/lib/ServerState.py index b77c32388..f52d50905 100644 --- a/opencsp/app/sofast/lib/ServerState.py +++ b/opencsp/app/sofast/lib/ServerState.py @@ -4,6 +4,7 @@ from opencsp.app.sofast.lib.MeasurementSofastFixed import MeasurementSofastFixed from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe +from opencsp.app.sofast.lib.ProcessSofastFringe import ProcessSofastFringe as Sofast from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe from opencsp.app.sofast.lib.SystemSofastFixed import SystemSofastFixed from opencsp.common.lib.camera.ImageAcquisitionAbstract import ImageAcquisitionAbstract @@ -70,6 +71,27 @@ def __init__(self): def instance() -> ControlledContext['ServerState']: return ServerState._instance + @property + @staticmethod + def _state_lock() -> asyncio.Lock: + """ + Return the mutex for providing exclusive access to ServerState singleton. + + I (BGB) use "with instance()" when calling state methods from code external to the state class, and "with + _state_lock()" when modifying critical sections of code within the state class (mostly for thread safety in + regards to the processing thread). However, the following statements are equivalent and can be used + interchangeably:: + + # 1. using the instance() method + with self.instance() as state: + # do stuff + + # 2. using the _state_lock() method + with self._state_lock: + # do stuff + """ + return ServerState.instance().mutex + @property def system_fixed(self) -> ControlledContext[SystemSofastFixed]: if self._system_fixed is None: @@ -186,6 +208,13 @@ def init_io(self): def start_measure_fringes(self, name: str = None) -> bool: """Starts collection and processing of fringe measurement image data. + Once this method is called it returns immediately without waiting for collection and processing to finish. + self.has_fringe_measurement will be False during the period, and will transition to True once collection and + processing have both finished. + + The collection is queued up for the main thread (aka the tkinter thread), and processing is done in + another thread once collection has finished. + Returns ------- success: bool @@ -205,30 +234,48 @@ def start_measure_fringes(self, name: str = None) -> bool: self.fringe_measurement_name = name # Update statuses - self._last_measurement_fringe = None - self._running_measurement_fringe = True + # Critical section, these statuses updates need to be thread safe + with self._state_lock: + self._last_measurement_fringe = None + self._running_measurement_fringe = True # Start the measurement lt.debug("ServerState: collecting fringes") with self.system_fringe as sys: + # Run the measurement in the main thread (aka the tkinter thread) sys.run_measurement(self._on_collect_fringes_done) return True def _on_collect_fringes_done(self): + """ + Registers the change in state from having finished capturing fringe images, and starts processing. + + This method is evaluated in the main thread (aka the tkinter thread), and so certain critical sections of code + are protected to ensure a consistent state is maintained. + """ lt.debug("ServerState: finished collecting fringes") if not self._running_measurement_fringe: lt.error("Programmer error in server_api._on_collect_fringes_done(): " + "Did not expect for this method to be called while self._running_measurement_fringe was not True!") # Update statuses - self._processing_measurement_fringe = True - self._running_measurement_fringe = False + # Critical section, these statuses updates need to be thread safe + with self._state_lock: + self._processing_measurement_fringe = True + self._running_measurement_fringe = False # Start the processing self._processing_pool.submit(self._process_fringes) def _process_fringes(self): + """ + Processes the fringe images captured during self.system_fringe.run_measurement() and stores the result to + self._last_measurement_fringe. + + This method is evaluated in the _processing_pool thread, and so certain critical sections of code are protected + to ensure a consistent state is maintained. + """ lt.debug("ServerState: processing fringes") if not self._processing_measurement_fringe: lt.error("Programmer error in server_api._process_fringes(): " + @@ -239,11 +286,14 @@ def _process_fringes(self): name = "fringe_measurement_"+tdt.current_date_time_string_forfile() if self.fringe_measurement_name != None: name = self.fringe_measurement_name - self._last_measurement_fringe = sys.get_measurements( + new_fringe_measurement = sys.get_measurements( self._mirror_measure_point, self._mirror_measure_distance, name) # update statuses - self._processing_fringes = False + # Critical section, these statuses updates need to be thread safe + with self._state_lock: + self._processing_fringes = False + self._last_measurement_fringe = new_fringe_measurement def close_all(self): """Closes all cameras, projectors, and sofast systems (currently just sofast fringe)""" From de6627dc541f040dc7133c3a9eee28b1dc1f1f76 Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 10 Apr 2024 13:43:06 -0600 Subject: [PATCH 06/17] separate out ControlledContext and Executor from SofastService --- opencsp/app/sofast/lib/Executor.py | 142 ++++++++++++++++++ opencsp/app/sofast/lib/ServerState.py | 106 ++++++------- opencsp/common/lib/csp/Facet.py | 3 +- .../common/lib/process/ControlledContext.py | 39 +++++ 4 files changed, 229 insertions(+), 61 deletions(-) create mode 100644 opencsp/app/sofast/lib/Executor.py create mode 100644 opencsp/common/lib/process/ControlledContext.py diff --git a/opencsp/app/sofast/lib/Executor.py b/opencsp/app/sofast/lib/Executor.py new file mode 100644 index 000000000..bc3c89638 --- /dev/null +++ b/opencsp/app/sofast/lib/Executor.py @@ -0,0 +1,142 @@ +from concurrent.futures import ThreadPoolExecutor +import dataclasses +from typing import Callable + +import numpy as np +import numpy.typing as npt + +from opencsp.app.sofast.lib.DefinitionFacet import DefinitionFacet +from opencsp.app.sofast.lib.DisplayShape import DisplayShape +from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe +from opencsp.app.sofast.lib.ProcessSofastFringe import ProcessSofastFringe as Sofast +from opencsp.app.sofast.lib.SpatialOrientation import SpatialOrientation +from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe +from opencsp.common.lib.camera.Camera import Camera +from opencsp.common.lib.csp.Facet import Facet +from opencsp.common.lib.deflectometry.Surface2DAbstract import Surface2DAbstract +import opencsp.common.lib.geometry.Vxyz as vxyz +import opencsp.common.lib.process.ControlledContext as cc +import opencsp.common.lib.tool.exception_tools as et +import opencsp.common.lib.tool.log_tools as lt +import opencsp.common.lib.tool.time_date_tools as tdt + + +@dataclasses.dataclass +class FixedResults: + pass # TODO + + +@dataclasses.dataclass +class FringeResults: + measurement: MeasurementSofastFringe + """The collected measurement data""" + sofast: Sofast + """The object create for processing the measurement""" + facet: Facet + """The facet representation""" + res: float + """The resolution of the slopes matrix, in meters""" + focal_length_x: float + """Focal length in the x axis""" + focal_length_y: float + """Focal length in the y axis""" + slopes: npt.NDArray[np.float_] + """X and Y slopes of the facet, as measured. Shape (2, facet_width/res, facet_height/res)""" + slopes_error: npt.NDArray[np.float_] | None + """X and Y slopes error of the facet relative to the reference facet. Shape (2, facet_width/res, facet_height/res)""" + + +class Executor: + """Class to handle collection and processing of sofast measurements asynchronously in the main thread (aka the + tkinter thread) and a separate processing thread.""" + + def __init__(self): + self.on_fringe_collected: Callable + self.on_fringe_processed: Callable[[FringeResults], None] + self.on_fixed_collected: Callable + self.on_fixed_processed: Callable[[FixedResults], None] + + # processing thread + self._processing_pool = ThreadPoolExecutor(max_workers=1) + + # don't try to close multiple times + self.is_closed = False + + def __del__(self): + with et.ignored(Exception): + self.close() + + def start_collect_fringe(self, controlled_system: cc.ControlledContext[SystemSofastFringe]): + """Starts collection of fringe measurement image data. + + Once this method is called it returns immediately without waiting for collection and processing to finish. The + collection is queued up for the main thread (aka the tkinter thread), and on_fringe_collected is called when + finished. + """ + # Start the collection + lt.debug("Executor: collecting fringes") + + # Run the measurement in the main thread (aka the tkinter thread) + with controlled_system as system: + system.run_measurement(self.on_fringe_collected) + + def start_process_fringe(self, controlled_system: cc.ControlledContext[SystemSofastFringe], mirror_measure_point: vxyz.Vxyz, mirror_measure_distance: float, orientation: SpatialOrientation, camera: Camera, display: DisplayShape, facet_data: DefinitionFacet, surface: Surface2DAbstract, measurement_name: str = None, reference_facet: Facet = None): + """ + Processes the given fringe collected from system.run_measurement(). + + This method is evaluated in this instance's _processing_pool thread, and on_fringe_processed is called from that + thread once processing has finished. + """ + lt.debug("Executor: processing fringes") + + name = "fringe_measurement_"+tdt.current_date_time_string_forfile() + if measurement_name != None: + name = measurement_name + + def _process_fringes(): + # Get the measurement + with controlled_system as system: + measurements = system.get_measurements(mirror_measure_point, mirror_measure_distance, name) + measurement = measurements[0] + + # Process the measurement + sofast = Sofast(measurements[0], orientation, camera, display) + sofast.process_optic_singlefacet(facet_data, surface) + facet: Facet = sofast.get_optic() + + # Get the focal lengths + surf_coefs = sofast.data_characterization_facet[0].surf_coefs_facet + focal_lengths_xy = [1 / 4 / surf_coefs[2], 1 / 4 / surf_coefs[5]] + + # Create interpolation axes + res = 0.1 # meters + left, right, bottom, top = facet.axis_aligned_bounding_box + x_vec = np.arange(left, right, res) # meters + y_vec = np.arange(bottom, top, res) # meters + + # Calculate current mirror slope + slopes_cur = facet.orthorectified_slope_array(x_vec, y_vec) # radians + + # Calculate slope difference (error) + slopes_diff: npt.NDArray[np.float_] = None + if reference_facet is not None: + slopes_ref = reference_facet.orthorectified_slope_array(x_vec, y_vec) # radians + slopes_diff = slopes_cur - slopes_ref # radians + + # Call the callback + ret = FringeResults(measurement, sofast, facet, res, focal_lengths_xy, slopes_cur, slopes_diff) + self.on_fringe_processed(ret) + + self._processing_pool.submit(_process_fringes) + + def close(self): + """Closes the processing thread (may take several seconds)""" + # don't try to close multiple times + if self.is_closed: + return + self.is_closed = True + + with et.ignored(Exception): + lt.debug("Executor.close(): Stopping processing thread") + self._processing_pool.shutdown(wait=True, cancel_futures=True) + self._processing_pool = None diff --git a/opencsp/app/sofast/lib/ServerState.py b/opencsp/app/sofast/lib/ServerState.py index f52d50905..42653f84f 100644 --- a/opencsp/app/sofast/lib/ServerState.py +++ b/opencsp/app/sofast/lib/ServerState.py @@ -1,48 +1,27 @@ import asyncio -from concurrent.futures import ThreadPoolExecutor -from typing import Generic, TypeVar -from opencsp.app.sofast.lib.MeasurementSofastFixed import MeasurementSofastFixed -from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe -from opencsp.app.sofast.lib.ProcessSofastFringe import ProcessSofastFringe as Sofast +import opencsp.app.sofast.lib.Executor as sfe from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe from opencsp.app.sofast.lib.SystemSofastFixed import SystemSofastFixed from opencsp.common.lib.camera.ImageAcquisitionAbstract import ImageAcquisitionAbstract from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection import opencsp.common.lib.geometry.Vxyz as vxyz +import opencsp.common.lib.process.ControlledContext as cc import opencsp.common.lib.tool.exception_tools as et import opencsp.common.lib.tool.log_tools as lt -import opencsp.common.lib.tool.time_date_tools as tdt - - -T = TypeVar('T') - - -class ControlledContext(Generic[T]): - def __init__(self, o: T): - self.o = o - self.mutex = asyncio.Lock() - - def __enter__(self): - self.mutex.acquire() - return self.o - - def __exit__(self, exc_type, exc_value, traceback): - self.mutex.release() - return False class ServerState: - _instance: ControlledContext['ServerState'] = None + _instance: cc.ControlledContext['ServerState'] = None def __init__(self): # systems - self._system_fixed: ControlledContext[SystemSofastFixed] = None - self._system_fringe: ControlledContext[SystemSofastFringe] = None + self._system_fixed: cc.ControlledContext[SystemSofastFixed] = None + self._system_fringe: cc.ControlledContext[SystemSofastFringe] = None # measurements - self._last_measurement_fixed: MeasurementSofastFixed = None - self._last_measurement_fringe: list[MeasurementSofastFringe] = None + self._last_measurement_fixed: sfe.FixedResults = None + self._last_measurement_fringe: sfe.FringeResults = None self.fixed_measurement_name: str = None self.fringe_measurement_name: str = None @@ -58,17 +37,25 @@ def __init__(self): self._processing_measurement_fixed = False self._processing_measurement_fringe = False - # processing thread - self._processing_pool = ThreadPoolExecutor(max_workers=1) + # processing manager + self._executor = sfe.Executor() + # don't try to close more than once + self.is_closed = False + + # assign this as the global static instance if ServerState._instance is None: - ServerState._instance = ControlledContext(self) + ServerState._instance = cc.ControlledContext(self) else: lt.error_and_raise(RuntimeError, "Error in ServerState(): " + "this class is supposed to be a singleton, but another instance already exists!") + def __del__(self): + with et.ignored(Exception): + self.close_all() + @staticmethod - def instance() -> ControlledContext['ServerState']: + def instance() -> cc.ControlledContext['ServerState']: return ServerState._instance @property @@ -93,19 +80,19 @@ def _state_lock() -> asyncio.Lock: return ServerState.instance().mutex @property - def system_fixed(self) -> ControlledContext[SystemSofastFixed]: + def system_fixed(self) -> cc.ControlledContext[SystemSofastFixed]: if self._system_fixed is None: display_data = ImageProjection.instance().display_data size_x, size_y = display_data['size_x'], display_data['size_y'] width_pattern = self._fixed_pattern_diameter spacing_pattern = self._fixed_pattern_spacing - self._system_fixed = ControlledContext(SystemSofastFixed(size_x, size_y, width_pattern, spacing_pattern)) + self._system_fixed = cc.ControlledContext(SystemSofastFixed(size_x, size_y, width_pattern, spacing_pattern)) return self._system_fixed @property - def system_fringe(self) -> ControlledContext[SystemSofastFringe]: + def system_fringe(self) -> cc.ControlledContext[SystemSofastFringe]: if self._system_fringe is None: - self._system_fringe = ControlledContext(SystemSofastFringe()) + self._system_fringe = cc.ControlledContext(SystemSofastFringe()) return self._system_fringe @property @@ -141,7 +128,7 @@ def has_fixed_measurement(self) -> bool: return True @property - def last_measurement_fixed(self) -> list[MeasurementSofastFixed]: + def last_measurement_fixed(self) -> sfe.FixedResults: if not self.has_fixed_measurement: return None return self._last_measurement_fixed @@ -166,7 +153,7 @@ def has_fringe_measurement(self) -> bool: return True @property - def last_measurement_fringe(self) -> list[MeasurementSofastFringe]: + def last_measurement_fringe(self) -> sfe.FringeResults: if not self.has_fringe_measurement: return None return self._last_measurement_fringe @@ -240,14 +227,12 @@ def start_measure_fringes(self, name: str = None) -> bool: self._running_measurement_fringe = True # Start the measurement - lt.debug("ServerState: collecting fringes") - with self.system_fringe as sys: - # Run the measurement in the main thread (aka the tkinter thread) - sys.run_measurement(self._on_collect_fringes_done) + self._executor.on_fringe_collected = self._on_fringe_collected + self._executor.start_collect_fringe(self.system_fringe) return True - def _on_collect_fringes_done(self): + def _on_fringe_collected(self): """ Registers the change in state from having finished capturing fringe images, and starts processing. @@ -266,9 +251,11 @@ def _on_collect_fringes_done(self): self._running_measurement_fringe = False # Start the processing - self._processing_pool.submit(self._process_fringes) + self._executor.on_fringe_processed = self._on_fringe_processed + self._executor.start_process_fringe(self.system_fringe, self.mirror_measure_point, self.mirror_measure_distance, self.orientation, + self.camera, self.display, self.facet_data, self.surface, self.fringe_measurement_name, self.reference_facet) - def _process_fringes(self): + def _on_fringe_processed(self, fringe_results: sfe.FringeResults): """ Processes the fringe images captured during self.system_fringe.run_measurement() and stores the result to self._last_measurement_fringe. @@ -276,31 +263,28 @@ def _process_fringes(self): This method is evaluated in the _processing_pool thread, and so certain critical sections of code are protected to ensure a consistent state is maintained. """ - lt.debug("ServerState: processing fringes") + lt.debug("ServerState: finished processing fringes") if not self._processing_measurement_fringe: lt.error("Programmer error in server_api._process_fringes(): " + "Did not expect for this method to be called while self._processing_measurement_fringe was not True!") - # process the fringes - with self.system_fringe as sys: - name = "fringe_measurement_"+tdt.current_date_time_string_forfile() - if self.fringe_measurement_name != None: - name = self.fringe_measurement_name - new_fringe_measurement = sys.get_measurements( - self._mirror_measure_point, self._mirror_measure_distance, name) - # update statuses # Critical section, these statuses updates need to be thread safe with self._state_lock: self._processing_fringes = False - self._last_measurement_fringe = new_fringe_measurement + self._last_measurement_fringe = fringe_results def close_all(self): """Closes all cameras, projectors, and sofast systems (currently just sofast fringe)""" + # don't try to close more than once + if self.is_closed: + return + self.is_closed = True + with et.ignored(Exception): - lt.debug("ServerState.close_all(): Stopping processing thread(s)") - self._processing_pool.shutdown(wait=True, cancel_futures=True) - self._processing_pool = None + lt.debug("ServerState.close_all(): Stopping processing thread") + self._executor.close() + self._executor = None with et.ignored(Exception): lt.debug("ServerState.close_all(): Closing the cameras") @@ -318,14 +302,16 @@ def close_all(self): with self._system_fringe as sys: sys.close_all() self._system_fringe = None + self._system_fringe = None + with et.ignored(Exception): + lt.debug("ServerState.close_all(): Closing the sofast fixed system") # TODO uncomment in the case that sofast fixed ever gains a close() or close_all() method - # with et.ignored(Exception): - # lt.debug("ServerState.close_all(): Closing the sofast fixed system") # if self._system_fixed is not None: # with self._system_fixed as sys: # sys.close_all() # self._system_fixed = None + self._system_fixed = None with et.ignored(Exception): lt.debug("ServerState.close_all(): Unassigning this instance as the singleton ServerState") diff --git a/opencsp/common/lib/csp/Facet.py b/opencsp/common/lib/csp/Facet.py index bda94bca1..9a8a5c0ef 100644 --- a/opencsp/common/lib/csp/Facet.py +++ b/opencsp/common/lib/csp/Facet.py @@ -3,6 +3,7 @@ from typing import Callable import numpy as np +import numpy.typing as npt from scipy.spatial.transform import Rotation from opencsp.common.lib.csp.MirrorAbstract import MirrorAbstract @@ -96,7 +97,7 @@ def survey_of_points( return points, normals # facet parent - def orthorectified_slope_array(self, x_vec: np.ndarray, y_vec: np.ndarray) -> np.ndarray: + def orthorectified_slope_array(self, x_vec: np.ndarray, y_vec: np.ndarray) -> npt.NDArray[np.float_]: """Returns X and Y surface slopes in ndarray format given X and Y sampling axes in the facet's child coordinate reference frame. diff --git a/opencsp/common/lib/process/ControlledContext.py b/opencsp/common/lib/process/ControlledContext.py new file mode 100644 index 000000000..cae6fd65c --- /dev/null +++ b/opencsp/common/lib/process/ControlledContext.py @@ -0,0 +1,39 @@ +import asyncio +from typing import Generic, TypeVar + + +T = TypeVar('T') + + +class ControlledContext(Generic[T]): + """A simple way to protect object access for multithreading contention. + + This class is intended to wrap an object so that it can only be accessed in a controller manner. It has a built in mutex and __enter__/__exit__ functions. The object can best be accessed by a "with" statement. For example:: + + def set_to_one(controlled_val: ControlledContext[list[int]]): + with controlled_val as val: + for i in range(len(val)): + val[i] = 1 + + threading_sensitive_value: list[int] = [0] + controlled_tsv = ControlledContext(threading_sensitive_value) + + thread.start(set_to_one, controlled_tsv) + print(str(tsv[0])) # will sometimes print '1' + + with controlled_tsv as tsv: + tsv[0] = 0 + print(str(tsv[0])) # will always print '0' + """ + + def __init__(self, o: T): + self.o = o + self.mutex = asyncio.Lock() + + def __enter__(self): + self.mutex.acquire() + return self.o + + def __exit__(self, exc_type, exc_value, traceback): + self.mutex.release() + return False From bb6e4126365993376c373427dd1fc5f04963f99b Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 10 Apr 2024 13:43:47 -0600 Subject: [PATCH 07/17] flesh out SofastServer.get_response('get_results_fringes') --- opencsp/app/sofast/SofastServer.py | 45 ++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/opencsp/app/sofast/SofastServer.py b/opencsp/app/sofast/SofastServer.py index e4fc3d483..abe6a6088 100644 --- a/opencsp/app/sofast/SofastServer.py +++ b/opencsp/app/sofast/SofastServer.py @@ -16,6 +16,8 @@ from traceback import format_exception from urllib.parse import parse_qsl, urlparse +import numpy as np + import opencsp.app.sofast.lib.ServerState as ss from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection from opencsp.common.lib.opencsp_path import opencsp_settings @@ -106,28 +108,53 @@ def get_response(self) -> tuple[int, str]: else: ret["error"] = "Fringe measurement is not ready" ret["trace"] = "SofastServer.get_response::save_measure_fringes" + response_code = 409 else: ret["error"] = "Measurements save directory not speicified in settings" ret["trace"] = "SofastServer.get_response::save_measure_fringes" + response_code = 500 elif action == "get_results_fringes": - pass + measurement = None + with ss.ServerState.instance() as state: + if state.has_fringe_measurement: + measurement = state.last_measurement_fringe + state.system_fringe + if measurement is not None: + ret.update({ + "focal_length_x": measurement.focal_length_x, + "focal_length_y": measurement.focal_length_y, + "slope_error_x": np.average(measurement.slopes_error[0]), + "slope_error_y": np.average(measurement.slopes_error[1]), + "slope_error": np.average(measurement.slopes_error), + "slope_stddev": np.std(measurement.slopes_error) + }) + else: + ret["error"] = "Fringe measurement is not ready" + ret["trace"] = "SofastServer.get_response::get_results_fringes" + response_code = 409 else: - ret = { - "error": f"Unknown action \"{action}\"", - "trace": "SofastServer.get_response" - } + ret["error"] = f"Unknown action \"{action}\"" + ret["trace"] = "SofastServer.get_response::N/A" response_code = 404 except Exception as ex: lt.error("Error in SofastServer with action " + action + ": " + repr(ex)) - ret = { - "error": repr(ex), - "trace": "".join(format_exception(ex)) - } + ret["error"] = repr(ex), + ret["trace"] = "".join(format_exception(ex)) response_code = 500 + # sanity check: did we synchronize the error and response_code? + if response_code != 200: + if ret["error"] is None: + lt.error_and_raise( + RuntimeError, f"Programmer error in SofastServer.get_response({action}): " + f"did not correctly set 'error' to match {response_code=}!") + if ret["error"] is not None: + if response_code == 200: + lt.error_and_raise( + RuntimeError, f"Programmer error in SofastServer.get_response({action}): " + f"did not correctly set response_code to match {ret['error']=}!") + return response_code, json.dumps(ret) From 9562b05d73dd4cc58c3f19bab6e31597b7dbcac6 Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 10 Apr 2024 13:46:05 -0600 Subject: [PATCH 08/17] formatting --- opencsp/app/sofast/SofastServer.py | 52 +++++++++++------- opencsp/app/sofast/lib/Executor.py | 16 +++++- opencsp/app/sofast/lib/ServerState.py | 79 ++++++++++++++++++--------- opencsp/app/sofast/sofast_server.py | 11 ++-- 4 files changed, 106 insertions(+), 52 deletions(-) diff --git a/opencsp/app/sofast/SofastServer.py b/opencsp/app/sofast/SofastServer.py index abe6a6088..b7329c4db 100644 --- a/opencsp/app/sofast/SofastServer.py +++ b/opencsp/app/sofast/SofastServer.py @@ -28,6 +28,7 @@ @dataclasses.dataclass class _UrlParseResult: """Helper class to define the slot names of the ParseResult type""" + scheme: str net_location: str path: str @@ -70,9 +71,7 @@ def do_POST(self): def get_response(self) -> tuple[int, str]: action = "N/A" - ret = { - "error": None - } + ret = {"error": None} response_code = 200 try: @@ -82,8 +81,13 @@ def get_response(self) -> tuple[int, str]: action = self.url.path if action == "help": - ret["actions"] = ["help", "start_measure_fringes", - "is_busy", "save_measure_fringes", "get_results_fringes"] + ret["actions"] = [ + "help", + "start_measure_fringes", + "is_busy", + "save_measure_fringes", + "get_results_fringes", + ] if action == "start_measure_fringes": with ss.ServerState.instance() as state: @@ -100,7 +104,7 @@ def get_response(self) -> tuple[int, str]: with ss.ServerState.instance() as state: if state.has_fringe_measurement: measurement = state.last_measurement_fringe[0] - file_name_ext = state.fringe_measurement_name+".h5" + file_name_ext = state.fringe_measurement_name + ".h5" if measurement is not None: file_path_name_ext = os.path.join(opencsp_settings["saves_output_dir"], file_name_ext) measurement.save_to_hdf(file_path_name_ext) @@ -121,14 +125,16 @@ def get_response(self) -> tuple[int, str]: measurement = state.last_measurement_fringe state.system_fringe if measurement is not None: - ret.update({ - "focal_length_x": measurement.focal_length_x, - "focal_length_y": measurement.focal_length_y, - "slope_error_x": np.average(measurement.slopes_error[0]), - "slope_error_y": np.average(measurement.slopes_error[1]), - "slope_error": np.average(measurement.slopes_error), - "slope_stddev": np.std(measurement.slopes_error) - }) + ret.update( + { + "focal_length_x": measurement.focal_length_x, + "focal_length_y": measurement.focal_length_y, + "slope_error_x": np.average(measurement.slopes_error[0]), + "slope_error_y": np.average(measurement.slopes_error[1]), + "slope_error": np.average(measurement.slopes_error), + "slope_stddev": np.std(measurement.slopes_error), + } + ) else: ret["error"] = "Fringe measurement is not ready" ret["trace"] = "SofastServer.get_response::get_results_fringes" @@ -141,7 +147,7 @@ def get_response(self) -> tuple[int, str]: except Exception as ex: lt.error("Error in SofastServer with action " + action + ": " + repr(ex)) - ret["error"] = repr(ex), + ret["error"] = (repr(ex),) ret["trace"] = "".join(format_exception(ex)) response_code = 500 @@ -149,11 +155,17 @@ def get_response(self) -> tuple[int, str]: if response_code != 200: if ret["error"] is None: lt.error_and_raise( - RuntimeError, f"Programmer error in SofastServer.get_response({action}): " + f"did not correctly set 'error' to match {response_code=}!") + RuntimeError, + f"Programmer error in SofastServer.get_response({action}): " + + f"did not correctly set 'error' to match {response_code=}!", + ) if ret["error"] is not None: if response_code == 200: lt.error_and_raise( - RuntimeError, f"Programmer error in SofastServer.get_response({action}): " + f"did not correctly set response_code to match {ret['error']=}!") + RuntimeError, + f"Programmer error in SofastServer.get_response({action}): " + + f"did not correctly set response_code to match {ret['error']=}!", + ) return response_code, json.dumps(ret) @@ -162,8 +174,10 @@ def get_response(self) -> tuple[int, str]: port = 8000 # Start the server - lt.warn("Warning in SofastServer: this server is unsecured. " + - f"It is suggested that you restrict outside access to port {port} of the host computer.") + lt.warn( + "Warning in SofastServer: this server is unsecured. " + + f"It is suggested that you restrict outside access to port {port} of the host computer." + ) lt.info(f"Starting server on port {port}...") server = HTTPServer(("0.0.0.0", port), SofastServer) diff --git a/opencsp/app/sofast/lib/Executor.py b/opencsp/app/sofast/lib/Executor.py index bc3c89638..7295f4bca 100644 --- a/opencsp/app/sofast/lib/Executor.py +++ b/opencsp/app/sofast/lib/Executor.py @@ -80,7 +80,19 @@ def start_collect_fringe(self, controlled_system: cc.ControlledContext[SystemSof with controlled_system as system: system.run_measurement(self.on_fringe_collected) - def start_process_fringe(self, controlled_system: cc.ControlledContext[SystemSofastFringe], mirror_measure_point: vxyz.Vxyz, mirror_measure_distance: float, orientation: SpatialOrientation, camera: Camera, display: DisplayShape, facet_data: DefinitionFacet, surface: Surface2DAbstract, measurement_name: str = None, reference_facet: Facet = None): + def start_process_fringe( + self, + controlled_system: cc.ControlledContext[SystemSofastFringe], + mirror_measure_point: vxyz.Vxyz, + mirror_measure_distance: float, + orientation: SpatialOrientation, + camera: Camera, + display: DisplayShape, + facet_data: DefinitionFacet, + surface: Surface2DAbstract, + measurement_name: str = None, + reference_facet: Facet = None, + ): """ Processes the given fringe collected from system.run_measurement(). @@ -89,7 +101,7 @@ def start_process_fringe(self, controlled_system: cc.ControlledContext[SystemSof """ lt.debug("Executor: processing fringes") - name = "fringe_measurement_"+tdt.current_date_time_string_forfile() + name = "fringe_measurement_" + tdt.current_date_time_string_forfile() if measurement_name != None: name = measurement_name diff --git a/opencsp/app/sofast/lib/ServerState.py b/opencsp/app/sofast/lib/ServerState.py index 42653f84f..df9ef4578 100644 --- a/opencsp/app/sofast/lib/ServerState.py +++ b/opencsp/app/sofast/lib/ServerState.py @@ -47,8 +47,11 @@ def __init__(self): if ServerState._instance is None: ServerState._instance = cc.ControlledContext(self) else: - lt.error_and_raise(RuntimeError, "Error in ServerState(): " + - "this class is supposed to be a singleton, but another instance already exists!") + lt.error_and_raise( + RuntimeError, + "Error in ServerState(): " + + "this class is supposed to be a singleton, but another instance already exists!", + ) def __del__(self): with et.ignored(Exception): @@ -117,12 +120,16 @@ def has_fixed_measurement(self) -> bool: if self._last_measurement_fixed is None: return False elif self._running_measurement_fixed: - lt.error("Programmer error in ServerState.fixed_measurement_ready(): " + - f"expected 'last_measurement' to be None while 'running', but {self._last_measurement_fixed=} {self._running_measurement_fixed}") + lt.error( + "Programmer error in ServerState.fixed_measurement_ready(): " + + f"expected 'last_measurement' to be None while 'running', but {self._last_measurement_fixed=} {self._running_measurement_fixed}" + ) return False elif self._processing_measurement_fixed: - lt.error("Programmer error in ServerState.fixed_measurement_ready(): " + - f"expected 'last_measurement' to be None while 'processing', but {self._last_measurement_fixed=} {self._processing_measurement_fixed}") + lt.error( + "Programmer error in ServerState.fixed_measurement_ready(): " + + f"expected 'last_measurement' to be None while 'processing', but {self._last_measurement_fixed=} {self._processing_measurement_fixed}" + ) return False else: return True @@ -142,12 +149,16 @@ def has_fringe_measurement(self) -> bool: if self._last_measurement_fringe is None: return False elif self._running_measurement_fringe: - lt.error("Programmer error in ServerState.fringe_measurement_ready(): " + - f"expected 'last_measurement' to be None while 'running', but {self._last_measurement_fringe=} {self._running_measurement_fringe}") + lt.error( + "Programmer error in ServerState.fringe_measurement_ready(): " + + f"expected 'last_measurement' to be None while 'running', but {self._last_measurement_fringe=} {self._running_measurement_fringe}" + ) return False elif self._processing_measurement_fringe: - lt.error("Programmer error in ServerState.fringe_measurement_ready(): " + - f"expected 'last_measurement' to be None while 'processing', but {self._last_measurement_fringe=} {self._processing_measurement_fringe}") + lt.error( + "Programmer error in ServerState.fringe_measurement_ready(): " + + f"expected 'last_measurement' to be None while 'processing', but {self._last_measurement_fringe=} {self._processing_measurement_fringe}" + ) return False else: return True @@ -209,12 +220,16 @@ def start_measure_fringes(self, name: str = None) -> bool: """ # Check that system resources are available if self._running_measurement_fringe or self._processing_measurement_fringe: - lt.warn("Warning in server_api.run_measurment_fringe(): " + - "Attempting to start another fringe measurement before the last fringe measurement has finished.") + lt.warn( + "Warning in server_api.run_measurment_fringe(): " + + "Attempting to start another fringe measurement before the last fringe measurement has finished." + ) return False if not self.projector_available: - lt.warn("Warning in server_api.run_measurment_fringe(): " + - "Attempting to start a fringe measurement while the projector is already in use.") + lt.warn( + "Warning in server_api.run_measurment_fringe(): " + + "Attempting to start a fringe measurement while the projector is already in use." + ) return False # Latch the name value @@ -241,8 +256,10 @@ def _on_fringe_collected(self): """ lt.debug("ServerState: finished collecting fringes") if not self._running_measurement_fringe: - lt.error("Programmer error in server_api._on_collect_fringes_done(): " + - "Did not expect for this method to be called while self._running_measurement_fringe was not True!") + lt.error( + "Programmer error in server_api._on_collect_fringes_done(): " + + "Did not expect for this method to be called while self._running_measurement_fringe was not True!" + ) # Update statuses # Critical section, these statuses updates need to be thread safe @@ -252,8 +269,18 @@ def _on_fringe_collected(self): # Start the processing self._executor.on_fringe_processed = self._on_fringe_processed - self._executor.start_process_fringe(self.system_fringe, self.mirror_measure_point, self.mirror_measure_distance, self.orientation, - self.camera, self.display, self.facet_data, self.surface, self.fringe_measurement_name, self.reference_facet) + self._executor.start_process_fringe( + self.system_fringe, + self.mirror_measure_point, + self.mirror_measure_distance, + self.orientation, + self.camera, + self.display, + self.facet_data, + self.surface, + self.fringe_measurement_name, + self.reference_facet, + ) def _on_fringe_processed(self, fringe_results: sfe.FringeResults): """ @@ -265,8 +292,10 @@ def _on_fringe_processed(self, fringe_results: sfe.FringeResults): """ lt.debug("ServerState: finished processing fringes") if not self._processing_measurement_fringe: - lt.error("Programmer error in server_api._process_fringes(): " + - "Did not expect for this method to be called while self._processing_measurement_fringe was not True!") + lt.error( + "Programmer error in server_api._process_fringes(): " + + "Did not expect for this method to be called while self._processing_measurement_fringe was not True!" + ) # update statuses # Critical section, these statuses updates need to be thread safe @@ -306,11 +335,11 @@ def close_all(self): with et.ignored(Exception): lt.debug("ServerState.close_all(): Closing the sofast fixed system") - # TODO uncomment in the case that sofast fixed ever gains a close() or close_all() method - # if self._system_fixed is not None: - # with self._system_fixed as sys: - # sys.close_all() - # self._system_fixed = None + # TODO uncomment in the case that sofast fixed ever gains a close() or close_all() method + # if self._system_fixed is not None: + # with self._system_fixed as sys: + # sys.close_all() + # self._system_fixed = None self._system_fixed = None with et.ignored(Exception): diff --git a/opencsp/app/sofast/sofast_server.py b/opencsp/app/sofast/sofast_server.py index 4f3fcc50e..40163bbb0 100644 --- a/opencsp/app/sofast/sofast_server.py +++ b/opencsp/app/sofast/sofast_server.py @@ -52,18 +52,17 @@ def get_response(self): "query_data": self.query_data, "post_data": self.post_data.decode("utf-8"), "form_data": self.form_data, - "cookies": { - name: cookie.value - for name, cookie in self.cookies.items() - }, + "cookies": {name: cookie.value for name, cookie in self.cookies.items()}, } ) if __name__ == "__main__": port = 8000 - lt.warn("Warning in SofastServer: this server is unsecured. " + - f"It is suggested that you restrict outside access to port {port} of the host computer.") + lt.warn( + "Warning in SofastServer: this server is unsecured. " + + f"It is suggested that you restrict outside access to port {port} of the host computer." + ) lt.info(f"Starting server on port {port}...") server = HTTPServer(("0.0.0.0", port), SofastServerHandler) server.serve_forever() From 603998933585bf7ed5e4a901ff5d4e2fc153d512 Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 10 Apr 2024 16:18:42 -0600 Subject: [PATCH 09/17] fix Surface2DPlano surface_type description, add hdf5 load and save methods to MirrorPoint, add load_from_hdf_guess_type to Surface2DAbstract, improved hdf5 comments --- opencsp/common/lib/csp/MirrorPoint.py | 52 ++++++++++++++++++- .../lib/deflectometry/Surface2DAbstract.py | 27 +++++++++- .../lib/deflectometry/Surface2DPlano.py | 4 +- opencsp/common/lib/tool/hdf5_tools.py | 32 +++++++++--- 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/opencsp/common/lib/csp/MirrorPoint.py b/opencsp/common/lib/csp/MirrorPoint.py index 63b7ec86c..8f678ef17 100644 --- a/opencsp/common/lib/csp/MirrorPoint.py +++ b/opencsp/common/lib/csp/MirrorPoint.py @@ -8,7 +8,6 @@ import numpy as np import scipy.interpolate as interp -import opencsp.common.lib.render_control.RenderControlPointSeq as rcps from opencsp.common.lib.csp.MirrorAbstract import MirrorAbstract from opencsp.common.lib.geometry.FunctionXYDiscrete import FunctionXYDiscrete as FXYD from opencsp.common.lib.geometry.Pxy import Pxy @@ -19,9 +18,11 @@ from opencsp.common.lib.geometry.Vxyz import Vxyz from opencsp.common.lib.render.View3d import View3d from opencsp.common.lib.render_control.RenderControlMirror import RenderControlMirror +import opencsp.common.lib.tool.hdf5_tools as h5 +import opencsp.common.lib.tool.log_tools as lt -class MirrorPoint(MirrorAbstract): +class MirrorPoint(MirrorAbstract, h5.HDF5_IO_Abstract): def __init__( self, surface_points: Pxyz, @@ -211,3 +212,50 @@ def draw(self, view: View3d, mirror_style: RenderControlMirror, transform: Trans # If surface is interpolated, draw mirror using MirrorAbstract method else: super().draw(view, mirror_style, transform) + + def save_to_hdf(self, file: str, prefix: str = ''): + """ + Saves the key attributes describing this mirror to the given file. Data is stored as: PREFIX + Folder/Field_1 + + Parameters + ---------- + file : str + HDF file to save to + prefix : str, optional + Prefix to append to folder path within HDF file (folders must be separated by "/"). + Default is empty string ''. + """ + data = ['MirrorPoint', self.surface_points, self.normal_vectors, self.region, self.interpolation_type] + datasets = [ + prefix + 'ParamsMirror/mirror_type', + prefix + 'ParamsMirror/surface_points', + prefix + 'ParamsMirror/normal_vectors', + prefix + 'ParamsMirror/shape', + prefix + 'ParamsMirror/interpolation_type', + ] + h5.save_hdf5_datasets(data, datasets, file) + + @classmethod + def load_from_hdf(cls, file: str, prefix: str = ''): + """ + Loads the key attributes from the given file and contructs a new MirrorPoint instance. Assumes data is stored as: PREFIX + Folder/Field_1 + + Parameters + ---------- + file : str + HDF file to load from + prefix : str, optional + Prefix to append to folder path within HDF file (folders must be separated by "/"). + Default is empty string ''. + """ + datasets = h5.load_hdf5_datasets([prefix + "ParamsMirror/mirror_type"], file) + if datasets["mirror_type"] != 'MirrorPoint': + lt.error_and_raise(ValueError, f'{prefix}ParamsMirror file is not of type "MirrorPoint"') + + datasets_path_names = [ + prefix + 'ParamsMirror/surface_points', + prefix + 'ParamsMirror/normal_vectors', + prefix + 'ParamsMirror/shape', + prefix + 'ParamsMirror/interpolation_type', + ] + h5.load_hdf5_datasets(datasets_path_names, file) diff --git a/opencsp/common/lib/deflectometry/Surface2DAbstract.py b/opencsp/common/lib/deflectometry/Surface2DAbstract.py index 928e5612e..0814b87f2 100644 --- a/opencsp/common/lib/deflectometry/Surface2DAbstract.py +++ b/opencsp/common/lib/deflectometry/Surface2DAbstract.py @@ -6,10 +6,10 @@ from opencsp.common.lib.geometry.Uxyz import Uxyz from opencsp.common.lib.geometry.Vxyz import Vxyz -from opencsp.common.lib.tool.hdf5_tools import HDF5_IO_Abstract +import opencsp.common.lib.tool.hdf5_tools as h5 -class Surface2DAbstract(HDF5_IO_Abstract): +class Surface2DAbstract(h5.HDF5_IO_Abstract): """Representation of 2d surface for SOFAST processing""" def __init__(self): @@ -131,3 +131,26 @@ def plot_intersection_points( axes.set_xlabel('x (meter)') axes.set_ylabel('y (meter)') axes.set_zlabel('z (meter)') + + @classmethod + def load_from_hdf_guess_type(cls, file: str, prefix: str = ''): + """ + Attempt to guess and load a Surface2D description from the given file. Assumes data is stored as: PREFIX + Folder/Field_1 + + Parameters + ---------- + file : str + HDF file to load from + prefix : str, optional + Prefix to append to folder path within HDF file (folders must be separated by "/"). + Default is empty string ''. + """ + # Imports are here to avoid circular references + # Get the surface type + data = h5.load_hdf5_datasets([prefix + 'ParamsSurface/surface_type'], file) + if data['surface_type'] == 'parabolic': + from opencsp.common.lib.deflectometry.Surface2DParabolic import Surface2DParabolic + return Surface2DParabolic.load_from_hdf(file, prefix) + else: + from opencsp.common.lib.deflectometry.Surface2DPlano import Surface2DPlano + return Surface2DPlano.load_from_hdf(file, prefix) diff --git a/opencsp/common/lib/deflectometry/Surface2DPlano.py b/opencsp/common/lib/deflectometry/Surface2DPlano.py index 000c9709e..f31bf0b78 100644 --- a/opencsp/common/lib/deflectometry/Surface2DPlano.py +++ b/opencsp/common/lib/deflectometry/Surface2DPlano.py @@ -203,7 +203,7 @@ def shift_all(self, v_align_optic_step: Vxyz) -> None: self.v_optic_screen_optic += v_align_optic_step def save_to_hdf(self, file: str, prefix: str = ''): - data = [self.robust_least_squares, self.downsample, 'parabolic'] + data = [self.robust_least_squares, self.downsample, 'plano'] datasets = [ prefix + 'ParamsSurface/robust_least_squares', prefix + 'ParamsSurface/downsample', @@ -215,7 +215,7 @@ def save_to_hdf(self, file: str, prefix: str = ''): def load_from_hdf(cls, file: str, prefix: str = ''): # Check surface type data = load_hdf5_datasets([prefix + 'ParamsSurface/surface_type'], file) - if data['surface_type'] != 'parabolic': + if data['surface_type'] != 'plano': raise ValueError(f'Surface2DPlano cannot load surface type, {data["surface_type"]:s}') # Load diff --git a/opencsp/common/lib/tool/hdf5_tools.py b/opencsp/common/lib/tool/hdf5_tools.py index 228664f98..2fc82c39a 100644 --- a/opencsp/common/lib/tool/hdf5_tools.py +++ b/opencsp/common/lib/tool/hdf5_tools.py @@ -9,7 +9,7 @@ import opencsp.common.lib.tool.log_tools as lt -def save_hdf5_datasets(data: list, datasets: list, file: str): +def save_hdf5_datasets(data: list[any], datasets: list[str], file: str): """Saves data to HDF5 file""" with h5py.File(file, 'a') as f: # Loop through datasets @@ -21,15 +21,35 @@ def save_hdf5_datasets(data: list, datasets: list, file: str): f.create_dataset(dataset, data=d) -def load_hdf5_datasets(datasets: list, file: str): - """Loads datasets from HDF5 file""" +def load_hdf5_datasets(datasets_path_names: list[str], file: str) -> dict[str, str | h5py.Dataset]: + """Loads datasets from HDF5 file. + + Note, since the return value uses just the name for each dataset as the key, it is possible that fewer values are + returned than are requested. For example, if datasets_path_names=["foo/baz", "bar/baz"], only a single value for + "baz" will be present in the returned dictionary. + + Parameters + ---------- + datasets_path_names: list[str] + The list of datasets to load from the HDF5 file. Each entry should contain the group (path) and name of the + dataset, with the path seperator character '/'. For example 'foo/bar/baz'. + file: str + The "path/name.ext" of the HDF5 to load the datasets from. + + Returns + ------- + datasets: dict[str, str|Dataset] + The names and values of the given datasets_path_names. The "name" portion (the part after the last path + seperator '/') of each given dataset_path_name is used as the key. The value is the loaded value from the HDF5 + file, with string values already decoded from bytes into a python string. + """ with h5py.File(file, 'r') as f: kwargs: dict[str, str | h5py.Dataset] = {} # Loop through fields to retreive - for dataset in datasets: + for dataset_path_name in datasets_path_names: # Get data and get dataset name - data = f[dataset] - name = dataset.split('/')[-1] + data = f[dataset_path_name] + name = dataset_path_name.split('/')[-1] # Format data shape if np.ndim(data) == 0 and np.size(data) == 1: From 086d2c256aa90f808a294e29579fdf1871552552 Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 10 Apr 2024 16:19:14 -0600 Subject: [PATCH 10/17] add logger setup to SofastServer --- opencsp/app/sofast/SofastServer.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/opencsp/app/sofast/SofastServer.py b/opencsp/app/sofast/SofastServer.py index b7329c4db..005e28821 100644 --- a/opencsp/app/sofast/SofastServer.py +++ b/opencsp/app/sofast/SofastServer.py @@ -23,6 +23,7 @@ from opencsp.common.lib.opencsp_path import opencsp_settings import opencsp.common.lib.tool.file_tools as ft import opencsp.common.lib.tool.log_tools as lt +import opencsp.common.lib.tool.time_date_tools as tdt @dataclasses.dataclass @@ -173,6 +174,13 @@ def get_response(self) -> tuple[int, str]: if __name__ == "__main__": port = 8000 + # Set up the logger + log_output_dir = opencsp_settings["sofast_server"]["log_output_dir"] + if log_output_dir is not None and ft.directory_exists(log_output_dir): + log_name_ext = "SofastServer_" + tdt.current_date_time_string_forfile() + ".log" + log_path_name_ext = os.path.join(log_output_dir, log_name_ext) + lt.logger(log_path_name_ext) + # Start the server lt.warn( "Warning in SofastServer: this server is unsecured. " @@ -185,6 +193,7 @@ def get_response(self) -> tuple[int, str]: ss.ServerState() with ss.ServerState.instance() as state: state.init_io() + state.load_default_settings() # Lock in the currently allocated memory, to improve garbage collector performance gc.collect() From c12c87dddd986d21c328eaae5cb447e5a9ff22a7 Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 10 Apr 2024 16:19:44 -0600 Subject: [PATCH 11/17] load default values for ServerState --- opencsp/app/sofast/__init__.py | 23 +++-- opencsp/app/sofast/lib/ServerState.py | 122 ++++++++++++++++++++++---- 2 files changed, 123 insertions(+), 22 deletions(-) diff --git a/opencsp/app/sofast/__init__.py b/opencsp/app/sofast/__init__.py index 4069f2ec3..aa1fa6e46 100644 --- a/opencsp/app/sofast/__init__.py +++ b/opencsp/app/sofast/__init__.py @@ -1,18 +1,24 @@ _sofast_server_settings_key = "sofast_server" -_sofast_server_settings_default: dict[str, None] = { +_sofast_server_settings: dict[str, any] = { "log_output_dir": None, - "saves_output_dir": None, - "camera_files": None, + "saves_output_dir": None +} + +_sofast_defaults_settings_key = "sofast_defaults" +_sofast_defaults_settings: dict[str, any] = { + "cameras": None, "projector_file": None, "calibration_file": None, "mirror_measure_point": None, "mirror_screen_distance": None, + "camera_calibration_file": None, "fixed_pattern_diameter_and_spacing": None, - "spatial_orientation": None, + "spatial_orientation_file": None, "display_shape_file": None, "dot_locations_file": None, - "facet_files": None, - "ensemble_file": None, + "facet_definition_files": None, + "ensemble_definition_file": None, + "reference_facet_file": None, "surface_shape_file": None, } """ @@ -22,4 +28,7 @@ calibration_file: Where to find the calibration .h5 file, which defines the default camera-screen response calibration. """ -_settings_list = [[_sofast_server_settings_key, _sofast_server_settings_default]] +_settings_list = [ + [_sofast_server_settings_key, _sofast_server_settings], + [_sofast_defaults_settings_key, _sofast_defaults_settings] +] diff --git a/opencsp/app/sofast/lib/ServerState.py b/opencsp/app/sofast/lib/ServerState.py index df9ef4578..4c475f33e 100644 --- a/opencsp/app/sofast/lib/ServerState.py +++ b/opencsp/app/sofast/lib/ServerState.py @@ -1,17 +1,29 @@ import asyncio +from opencsp.app.sofast.lib.DefinitionEnsemble import DefinitionEnsemble +from opencsp.app.sofast.lib.DefinitionFacet import DefinitionFacet +from opencsp.app.sofast.lib.DisplayShape import DisplayShape +from opencsp.app.sofast.lib.DotLocationsFixedPattern import DotLocationsFixedPattern import opencsp.app.sofast.lib.Executor as sfe +from opencsp.app.sofast.lib.ImageCalibrationAbstract import ImageCalibrationAbstract +from opencsp.app.sofast.lib.SpatialOrientation import SpatialOrientation from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe from opencsp.app.sofast.lib.SystemSofastFixed import SystemSofastFixed +from opencsp.common.lib.camera.Camera import Camera from opencsp.common.lib.camera.ImageAcquisitionAbstract import ImageAcquisitionAbstract +from opencsp.common.lib.csp.Facet import Facet +from opencsp.common.lib.csp.MirrorPoint import MirrorPoint from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +from opencsp.common.lib.deflectometry.Surface2DAbstract import Surface2DAbstract import opencsp.common.lib.geometry.Vxyz as vxyz import opencsp.common.lib.process.ControlledContext as cc +from opencsp.common.lib.opencsp_path import opencsp_settings import opencsp.common.lib.tool.exception_tools as et import opencsp.common.lib.tool.log_tools as lt class ServerState: + _default_io_initialized: bool = False _instance: cc.ControlledContext['ServerState'] = None def __init__(self): @@ -31,6 +43,21 @@ def __init__(self): self._fixed_pattern_diameter: int = None self._fixed_pattern_spacing: int = None + # default values + self.mirror_measure_point: vxyz.Vxyz = None + self.mirror_screen_distance: float = None + self.camera_calibration: Camera = None + self.fixed_pattern_diameter_and_spacing: list[int] = None + self.spatial_orientation: SpatialOrientation = None + self.display_shape: DisplayShape = None + self.dot_locations: DotLocationsFixedPattern = None + self.facet_definitions: list[DefinitionFacet] = None + self.ensemble_definition: DefinitionEnsemble = None + self.surface_shape: Surface2DAbstract = None + if not ServerState._default_io_initialized: + self.init_io() + self.load_default_settings() + # statuses self._running_measurement_fixed = False self._running_measurement_fringe = False @@ -187,21 +214,86 @@ def busy(self) -> bool: return True def _connect_default_cameras(self): - pass + camera_descriptions = opencsp_settings["sofast_defaults"]["camera_files"] + if camera_descriptions is not None: + cam_options = ImageAcquisitionAbstract.cam_options() + for camera_description in camera_descriptions: + cam_options[camera_description]() def _load_default_projector(self): - pass - - def _load_default_calibration(self): - pass - - def _get_default_mirror_distance_measurement(self): - pass + projector_file = opencsp_settings["sofast_defaults"]["projector_file"] + if projector_file is not None: + if ImageProjection.instance() is not None: + ImageProjection.instance().close() + ImageProjection.load_from_hdf_and_display(projector_file) def init_io(self): + """Connects to the default cameras and projector""" + ServerState._default_io_initialized = True self._connect_default_cameras() self._load_default_projector() - self._load_default_calibration() + + def load_default_settings(self): + """Loads default settings for fringe and fixed measurements""" + # load default calibration + calibration_file = opencsp_settings["sofast_defaults"]["calibration_file"] + if calibration_file is not None: + calibration = ImageCalibrationAbstract.load_from_hdf_guess_type(calibration_file) + with self.system_fringe as sys: + sys.calibration = calibration + + # latch default mirror measure point + self.mirror_measure_point = opencsp_settings["sofast_defaults"]["mirror_measure_point"] + + # latch default mirror measure distance + self.mirror_screen_distance = opencsp_settings["sofast_defaults"]["mirror_screen_distance"] + + # load the default camera calibration + camera_calibration_file = opencsp_settings["sofast_defaults"]["camera_calibration_file"] + if camera_calibration_file is not None: + self.camera_calibration = Camera.load_from_hdf(camera_calibration_file) + + # latch fixed pattern diameter and spacing + self.fixed_pattern_diameter_and_spacing = opencsp_settings["sofast_defaults"]["fixed_pattern_diameter_and_spacing"] + + # latch default spatial orientation + spatial_orientation_file = opencsp_settings["sofast_defaults"]["spatial_orientation_file"] + if spatial_orientation_file is not None: + self.spatial_orientation = SpatialOrientation.load_from_hdf(spatial_orientation_file) + + # load default display shape + display_shape_file = opencsp_settings["sofast_defaults"]["display_shape_file"] + if display_shape_file is not None: + self.display_shape = DisplayShape.load_from_hdf(display_shape_file) + + # load default dot locations + dot_locations_file = opencsp_settings["sofast_defaults"]["dot_locations_file"] + if dot_locations_file != None: + self.dot_locations = DotLocationsFixedPattern.load_from_hdf(dot_locations_file) + + # load default facet definitions + facet_files = opencsp_settings["sofast_defaults"]["facet_definition_files"] + if facet_files is not None: + if self.facet_definitions is None: + self.facet_definitions = [] + self.facet_definitions.clear() + for facet_file in facet_files: + self.facet_definitions.append(DefinitionFacet.load_from_hdf(facet_file)) + + # load default ensemble definition + ensemble_file = opencsp_settings["sofast_defaults"]["ensemble_definition_file"] + if ensemble_file is not None: + self.ensemble_definition = DefinitionEnsemble.load_from_hdf(ensemble_file) + + # load default reference facet (for slope error computation) + reference_facet_file = opencsp_settings["sofast_defaults"]["reference_facet_file"] + if reference_facet_file is not None: + self.reference_facet = Facet(MirrorPoint.load_from_hdf(reference_facet_file)) + + # load default surface shape + surface_shape_file = opencsp_settings["sofast_defaults"]["surface_shape_file"] + if surface_shape_file is not None: + self.surface_shape = Surface2DAbstract.load_from_hdf_guess_type(surface_shape_file) def start_measure_fringes(self, name: str = None) -> bool: """Starts collection and processing of fringe measurement image data. @@ -272,12 +364,12 @@ def _on_fringe_collected(self): self._executor.start_process_fringe( self.system_fringe, self.mirror_measure_point, - self.mirror_measure_distance, - self.orientation, - self.camera, - self.display, - self.facet_data, - self.surface, + self.mirror_screen_distance, + self.spatial_orientation, + self.camera_calibration, + self.display_shape, + self.facet_definitions[0], + self.surface_shape, self.fringe_measurement_name, self.reference_facet, ) From ef46859bab2894f3b009073a02fdc3488564411c Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 10 Apr 2024 16:21:28 -0600 Subject: [PATCH 12/17] formatting --- opencsp/app/sofast/__init__.py | 7 ++--- opencsp/app/sofast/lib/ServerState.py | 26 ++++++++++--------- .../lib/deflectometry/Surface2DAbstract.py | 2 ++ 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/opencsp/app/sofast/__init__.py b/opencsp/app/sofast/__init__.py index aa1fa6e46..228534985 100644 --- a/opencsp/app/sofast/__init__.py +++ b/opencsp/app/sofast/__init__.py @@ -1,8 +1,5 @@ _sofast_server_settings_key = "sofast_server" -_sofast_server_settings: dict[str, any] = { - "log_output_dir": None, - "saves_output_dir": None -} +_sofast_server_settings: dict[str, any] = {"log_output_dir": None, "saves_output_dir": None} _sofast_defaults_settings_key = "sofast_defaults" _sofast_defaults_settings: dict[str, any] = { @@ -30,5 +27,5 @@ _settings_list = [ [_sofast_server_settings_key, _sofast_server_settings], - [_sofast_defaults_settings_key, _sofast_defaults_settings] + [_sofast_defaults_settings_key, _sofast_defaults_settings], ] diff --git a/opencsp/app/sofast/lib/ServerState.py b/opencsp/app/sofast/lib/ServerState.py index 4c475f33e..c2e278b78 100644 --- a/opencsp/app/sofast/lib/ServerState.py +++ b/opencsp/app/sofast/lib/ServerState.py @@ -235,44 +235,46 @@ def init_io(self): def load_default_settings(self): """Loads default settings for fringe and fixed measurements""" + sofast_default_settings = opencsp_settings["sofast_defaults"] + # load default calibration - calibration_file = opencsp_settings["sofast_defaults"]["calibration_file"] + calibration_file = sofast_default_settings["calibration_file"] if calibration_file is not None: calibration = ImageCalibrationAbstract.load_from_hdf_guess_type(calibration_file) with self.system_fringe as sys: sys.calibration = calibration # latch default mirror measure point - self.mirror_measure_point = opencsp_settings["sofast_defaults"]["mirror_measure_point"] + self.mirror_measure_point = sofast_default_settings["mirror_measure_point"] # latch default mirror measure distance - self.mirror_screen_distance = opencsp_settings["sofast_defaults"]["mirror_screen_distance"] + self.mirror_screen_distance = sofast_default_settings["mirror_screen_distance"] # load the default camera calibration - camera_calibration_file = opencsp_settings["sofast_defaults"]["camera_calibration_file"] + camera_calibration_file = sofast_default_settings["camera_calibration_file"] if camera_calibration_file is not None: self.camera_calibration = Camera.load_from_hdf(camera_calibration_file) # latch fixed pattern diameter and spacing - self.fixed_pattern_diameter_and_spacing = opencsp_settings["sofast_defaults"]["fixed_pattern_diameter_and_spacing"] + self.fixed_pattern_diameter_and_spacing = sofast_default_settings["fixed_pattern_diameter_and_spacing"] # latch default spatial orientation - spatial_orientation_file = opencsp_settings["sofast_defaults"]["spatial_orientation_file"] + spatial_orientation_file = sofast_default_settings["spatial_orientation_file"] if spatial_orientation_file is not None: self.spatial_orientation = SpatialOrientation.load_from_hdf(spatial_orientation_file) # load default display shape - display_shape_file = opencsp_settings["sofast_defaults"]["display_shape_file"] + display_shape_file = sofast_default_settings["display_shape_file"] if display_shape_file is not None: self.display_shape = DisplayShape.load_from_hdf(display_shape_file) # load default dot locations - dot_locations_file = opencsp_settings["sofast_defaults"]["dot_locations_file"] + dot_locations_file = sofast_default_settings["dot_locations_file"] if dot_locations_file != None: self.dot_locations = DotLocationsFixedPattern.load_from_hdf(dot_locations_file) # load default facet definitions - facet_files = opencsp_settings["sofast_defaults"]["facet_definition_files"] + facet_files = sofast_default_settings["facet_definition_files"] if facet_files is not None: if self.facet_definitions is None: self.facet_definitions = [] @@ -281,17 +283,17 @@ def load_default_settings(self): self.facet_definitions.append(DefinitionFacet.load_from_hdf(facet_file)) # load default ensemble definition - ensemble_file = opencsp_settings["sofast_defaults"]["ensemble_definition_file"] + ensemble_file = sofast_default_settings["ensemble_definition_file"] if ensemble_file is not None: self.ensemble_definition = DefinitionEnsemble.load_from_hdf(ensemble_file) # load default reference facet (for slope error computation) - reference_facet_file = opencsp_settings["sofast_defaults"]["reference_facet_file"] + reference_facet_file = sofast_default_settings["reference_facet_file"] if reference_facet_file is not None: self.reference_facet = Facet(MirrorPoint.load_from_hdf(reference_facet_file)) # load default surface shape - surface_shape_file = opencsp_settings["sofast_defaults"]["surface_shape_file"] + surface_shape_file = sofast_default_settings["surface_shape_file"] if surface_shape_file is not None: self.surface_shape = Surface2DAbstract.load_from_hdf_guess_type(surface_shape_file) diff --git a/opencsp/common/lib/deflectometry/Surface2DAbstract.py b/opencsp/common/lib/deflectometry/Surface2DAbstract.py index 0814b87f2..727ee2759 100644 --- a/opencsp/common/lib/deflectometry/Surface2DAbstract.py +++ b/opencsp/common/lib/deflectometry/Surface2DAbstract.py @@ -150,7 +150,9 @@ def load_from_hdf_guess_type(cls, file: str, prefix: str = ''): data = h5.load_hdf5_datasets([prefix + 'ParamsSurface/surface_type'], file) if data['surface_type'] == 'parabolic': from opencsp.common.lib.deflectometry.Surface2DParabolic import Surface2DParabolic + return Surface2DParabolic.load_from_hdf(file, prefix) else: from opencsp.common.lib.deflectometry.Surface2DPlano import Surface2DPlano + return Surface2DPlano.load_from_hdf(file, prefix) From cbd7bc55fcfb0ffec4dec47097d1cc003faea45d Mon Sep 17 00:00:00 2001 From: bbean Date: Thu, 11 Apr 2024 19:39:14 -0600 Subject: [PATCH 13/17] add unit tests (or do these count as integration tests?) for Executor, fix a ton of bugs --- opencsp/app/sofast/SofastServer.py | 9 + opencsp/app/sofast/lib/Executor.py | 122 +++++---- opencsp/app/sofast/lib/ServerState.py | 42 +++- opencsp/app/sofast/lib/SystemSofastFringe.py | 42 ++-- opencsp/app/sofast/lib/test/test_Executor.py | 232 ++++++++++++++++++ .../sofast/test/ImageAcquisition_no_camera.py | 3 +- opencsp/app/sofast/test/test_DisplayShape.py | 14 +- opencsp/common/lib/camera/test/test_Camera.py | 22 +- .../common/lib/process/ControlledContext.py | 12 +- 9 files changed, 412 insertions(+), 86 deletions(-) create mode 100644 opencsp/app/sofast/lib/test/test_Executor.py diff --git a/opencsp/app/sofast/SofastServer.py b/opencsp/app/sofast/SofastServer.py index 005e28821..d3b602095 100644 --- a/opencsp/app/sofast/SofastServer.py +++ b/opencsp/app/sofast/SofastServer.py @@ -102,14 +102,23 @@ def get_response(self) -> tuple[int, str]: elif action == "save_measure_fringes": if "saves_output_dir" in opencsp_settings and ft.directory_exists(opencsp_settings["saves_output_dir"]): measurement = None + processing_error = None with ss.ServerState.instance() as state: if state.has_fringe_measurement: measurement = state.last_measurement_fringe[0] file_name_ext = state.fringe_measurement_name + ".h5" + else: + processing_error = state.processing_error if measurement is not None: file_path_name_ext = os.path.join(opencsp_settings["saves_output_dir"], file_name_ext) measurement.save_to_hdf(file_path_name_ext) ret["file_name_ext"] = file_name_ext + elif processing_error is not None: + ret["error"] = ( + f"Unexpected {repr(processing_error)} error encountered during measurement processing" + ) + ret["trace"] = "".join(format_exception(processing_error)) + response_code = 500 else: ret["error"] = "Fringe measurement is not ready" ret["trace"] = "SofastServer.get_response::save_measure_fringes" diff --git a/opencsp/app/sofast/lib/Executor.py b/opencsp/app/sofast/lib/Executor.py index 7295f4bca..56195668f 100644 --- a/opencsp/app/sofast/lib/Executor.py +++ b/opencsp/app/sofast/lib/Executor.py @@ -8,12 +8,13 @@ from opencsp.app.sofast.lib.DefinitionFacet import DefinitionFacet from opencsp.app.sofast.lib.DisplayShape import DisplayShape from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe -from opencsp.app.sofast.lib.ProcessSofastFringe import ProcessSofastFringe as Sofast +from opencsp.app.sofast.lib.ProcessSofastFringe import ProcessSofastFringe from opencsp.app.sofast.lib.SpatialOrientation import SpatialOrientation from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe from opencsp.common.lib.camera.Camera import Camera from opencsp.common.lib.csp.Facet import Facet from opencsp.common.lib.deflectometry.Surface2DAbstract import Surface2DAbstract +from opencsp.common.lib.deflectometry.Surface2DParabolic import Surface2DParabolic import opencsp.common.lib.geometry.Vxyz as vxyz import opencsp.common.lib.process.ControlledContext as cc import opencsp.common.lib.tool.exception_tools as et @@ -30,7 +31,7 @@ class FixedResults: class FringeResults: measurement: MeasurementSofastFringe """The collected measurement data""" - sofast: Sofast + sofast: ProcessSofastFringe """The object create for processing the measurement""" facet: Facet """The facet representation""" @@ -47,16 +48,28 @@ class FringeResults: class Executor: - """Class to handle collection and processing of sofast measurements asynchronously in the main thread (aka the - tkinter thread) and a separate processing thread.""" + """Class to handle the collection and processing of Sofast measurements.""" - def __init__(self): + def __init__(self, asynchronous_processing=True): + """ + Class to handle collection and processing of sofast measurements. + + Collection is handled in the main thread (aka the tkinter thread). Processing is handled either in a separate + processing thread, or in the calling thread. + + Parameters + ---------- + asynchronous : bool, optional + If True then processing is done in a separate thread, if False then it is done on the calling thread. By + default True. + """ self.on_fringe_collected: Callable - self.on_fringe_processed: Callable[[FringeResults], None] + self.on_fringe_processed: Callable[[FringeResults | None, Exception | None], None] self.on_fixed_collected: Callable - self.on_fixed_processed: Callable[[FixedResults], None] + self.on_fixed_processed: Callable[[FixedResults | None, Exception | None], None] # processing thread + self.asynchronous_processing = asynchronous_processing self._processing_pool = ThreadPoolExecutor(max_workers=1) # don't try to close multiple times @@ -106,40 +119,67 @@ def start_process_fringe( name = measurement_name def _process_fringes(): - # Get the measurement - with controlled_system as system: - measurements = system.get_measurements(mirror_measure_point, mirror_measure_distance, name) - measurement = measurements[0] - - # Process the measurement - sofast = Sofast(measurements[0], orientation, camera, display) - sofast.process_optic_singlefacet(facet_data, surface) - facet: Facet = sofast.get_optic() - - # Get the focal lengths - surf_coefs = sofast.data_characterization_facet[0].surf_coefs_facet - focal_lengths_xy = [1 / 4 / surf_coefs[2], 1 / 4 / surf_coefs[5]] - - # Create interpolation axes - res = 0.1 # meters - left, right, bottom, top = facet.axis_aligned_bounding_box - x_vec = np.arange(left, right, res) # meters - y_vec = np.arange(bottom, top, res) # meters - - # Calculate current mirror slope - slopes_cur = facet.orthorectified_slope_array(x_vec, y_vec) # radians - - # Calculate slope difference (error) - slopes_diff: npt.NDArray[np.float_] = None - if reference_facet is not None: - slopes_ref = reference_facet.orthorectified_slope_array(x_vec, y_vec) # radians - slopes_diff = slopes_cur - slopes_ref # radians - - # Call the callback - ret = FringeResults(measurement, sofast, facet, res, focal_lengths_xy, slopes_cur, slopes_diff) - self.on_fringe_processed(ret) - - self._processing_pool.submit(_process_fringes) + try: + with controlled_system as system: + # Get the measurement + measurements = system.get_measurements(mirror_measure_point, mirror_measure_distance, name) + measurement = measurements[0] + + # Apply calibration to the fringe images + measurement.calibrate_fringe_images(system.calibration) + + # Process the measurement + sofast = ProcessSofastFringe(measurements[0], orientation, camera, display) + # sofast.params.geometry_data_debug.debug_active = True + sofast.process_optic_singlefacet(facet_data, surface) + facet: Facet = sofast.get_optic() + + # Get the focal lengths (if a parabolic mirror) + facet_idx = 0 + surf_coefs = sofast.data_characterization_facet[facet_idx].surf_coefs_facet + if surf_coefs.size >= 6: + if not isinstance(surface, Surface2DParabolic): + lt.warn( + "Warning in Executor.start_process_fringe(): " + + "did not expect a non-parabolic mirror to have a focal point" + ) + focal_length_x, focal_length_y = 1 / 4 / surf_coefs[2], 1 / 4 / surf_coefs[5] + else: + if isinstance(surface, Surface2DParabolic): + lt.warn( + "Warning in Executor.start_process_fringe(): " + + "expected a parabolic mirror to have a focal point" + ) + focal_length_x, focal_length_y = None, None + + # Create interpolation axes + res = 0.1 # meters + left, right, bottom, top = facet.axis_aligned_bounding_box + x_vec = np.arange(left, right, res) # meters + y_vec = np.arange(bottom, top, res) # meters + + # Calculate current mirror slope + slopes_cur = facet.orthorectified_slope_array(x_vec, y_vec) # radians + + # Calculate slope difference (error) + slopes_diff: npt.NDArray[np.float_] = None + if reference_facet is not None: + slopes_ref = reference_facet.orthorectified_slope_array(x_vec, y_vec) # radians + slopes_diff = slopes_cur - slopes_ref # radians + + # Call the callback + ret = FringeResults( + measurement, sofast, facet, res, focal_length_x, focal_length_y, slopes_cur, slopes_diff + ) + self.on_fringe_processed(ret, None) + except Exception as ex: + lt.error("Error in Executor.start_process_fringe(): " + repr(ex)) + self.on_fringe_processed(None, ex) + + if self.asynchronous_processing: + self._processing_pool.submit(_process_fringes) + else: + _process_fringes() def close(self): """Closes the processing thread (may take several seconds)""" diff --git a/opencsp/app/sofast/lib/ServerState.py b/opencsp/app/sofast/lib/ServerState.py index c2e278b78..57de2fe65 100644 --- a/opencsp/app/sofast/lib/ServerState.py +++ b/opencsp/app/sofast/lib/ServerState.py @@ -36,6 +36,7 @@ def __init__(self): self._last_measurement_fringe: sfe.FringeResults = None self.fixed_measurement_name: str = None self.fringe_measurement_name: str = None + self._processing_error: Exception = None # configurations self._mirror_measure_point: vxyz.Vxyz = None @@ -190,6 +191,10 @@ def has_fringe_measurement(self) -> bool: else: return True + @property + def processing_error(self) -> Exception | None: + return self._processing_error + @property def last_measurement_fringe(self) -> sfe.FringeResults: if not self.has_fringe_measurement: @@ -334,6 +339,7 @@ def start_measure_fringes(self, name: str = None) -> bool: with self._state_lock: self._last_measurement_fringe = None self._running_measurement_fringe = True + self._processing_error = None # Start the measurement self._executor.on_fringe_collected = self._on_fringe_collected @@ -376,7 +382,7 @@ def _on_fringe_collected(self): self.reference_facet, ) - def _on_fringe_processed(self, fringe_results: sfe.FringeResults): + def _on_fringe_processed(self, fringe_results: sfe.FringeResults | None, ex: Exception | None): """ Processes the fringe images captured during self.system_fringe.run_measurement() and stores the result to self._last_measurement_fringe. @@ -384,18 +390,30 @@ def _on_fringe_processed(self, fringe_results: sfe.FringeResults): This method is evaluated in the _processing_pool thread, and so certain critical sections of code are protected to ensure a consistent state is maintained. """ - lt.debug("ServerState: finished processing fringes") - if not self._processing_measurement_fringe: - lt.error( - "Programmer error in server_api._process_fringes(): " - + "Did not expect for this method to be called while self._processing_measurement_fringe was not True!" - ) + if ex is not None: + with self._state_lock: + self._processing_fringes = False + self._processing_error = ex + return - # update statuses - # Critical section, these statuses updates need to be thread safe - with self._state_lock: - self._processing_fringes = False - self._last_measurement_fringe = fringe_results + try: + lt.debug("ServerState: finished processing fringes") + if not self._processing_measurement_fringe: + lt.error( + "Programmer error in server_api._process_fringes(): " + + "Did not expect for this method to be called while self._processing_measurement_fringe was not True!" + ) + + # update statuses + # Critical section, these statuses updates need to be thread safe + with self._state_lock: + self._processing_fringes = False + self._last_measurement_fringe = fringe_results + + except Exception as ex2: + with self._state_lock: + self._processing_fringes = False + self._processing_error = ex2 def close_all(self): """Closes all cameras, projectors, and sofast systems (currently just sofast fringe)""" diff --git a/opencsp/app/sofast/lib/SystemSofastFringe.py b/opencsp/app/sofast/lib/SystemSofastFringe.py index 6bcfa03b6..6e91a45bb 100644 --- a/opencsp/app/sofast/lib/SystemSofastFringe.py +++ b/opencsp/app/sofast/lib/SystemSofastFringe.py @@ -193,20 +193,27 @@ def _measure_sequence_display( im_cap_list : list[list[ndarray, ...], ...] 2D images captured by camera. run_next : Callable - Function that is run after all images have been captured. + Function that is run after all images have been captured. Also evaluated if an exception is encountered. """ - image_projection = ImageProjection.instance() + try: + image_projection = ImageProjection.instance() - # Display image - frame_idx = len(im_cap_list[0]) - image_projection.display_image_in_active_area(im_disp_list[frame_idx]) + # Display image + frame_idx = len(im_cap_list[0]) + image_projection.display_image_in_active_area(im_disp_list[frame_idx]) - # Wait, then capture image - self.root.after( - image_projection.display_data['image_delay'], - lambda: self._measure_sequence_capture(im_disp_list, im_cap_list, run_next), - ) + # Wait, then capture image + self.root.after( + image_projection.display_data['image_delay'], + lambda: self._measure_sequence_capture(im_disp_list, im_cap_list, run_next), + ) + + except Exception as ex: + # make sure that whatever we are trying to do when we finish, that it happens + if run_next is not None: + run_next() + raise def _measure_sequence_capture( self, im_disp_list: list, im_cap_list: list[list], run_next: Callable | None = None @@ -394,8 +401,8 @@ def capture_mask_images(self, run_next: Callable | None = None) -> None: Parameters ---------- run_next : Callable, optional - Function that is called after all images are captured. The default - is self.close_all(). TODO is this still the default? + Function that is called after all images are captured. The default is self.close_all(). TODO is this still + the default? Also evaluated if an exception is encountered. """ # Initialize mask image list @@ -414,8 +421,8 @@ def capture_fringe_images(self, run_next: Callable | None = None) -> None: Parameters ---------- run_next : Callable - Function that is called after all images are captured. The default - is self.close_all(). TODO is this still the default? + Function that is called after all images are captured. The default is self.close_all(). TODO is this still + the default? Also evaluated if an exception is encountered. """ # Check fringes/camera have been loaded @@ -443,8 +450,8 @@ def capture_mask_and_fringe_images(self, run_next: Callable | None = None) -> No Parameters ---------- run_next : Callable - Function that is called after all images are captured. The default - is self.close_all(). TODO is this still the default? + Function that is called after all images are captured. The default is self.close_all(). TODO is this still + the default? Also evaluated if an exception is encountered. """ # Check fringes/camera have been loaded @@ -469,7 +476,8 @@ def run_measurement(self, on_done: Callable = None) -> None: Params ------ on_done: Callable - The function to call when capturing fringe images has finished. + The function to call when capturing fringe images has finished. Also evaluated if an exception is + encountered. Raises ------ diff --git a/opencsp/app/sofast/lib/test/test_Executor.py b/opencsp/app/sofast/lib/test/test_Executor.py new file mode 100644 index 000000000..351dc077b --- /dev/null +++ b/opencsp/app/sofast/lib/test/test_Executor.py @@ -0,0 +1,232 @@ +"""Unit test suite to test ImageCalibrationGlobal class +""" + +import os +import time +import unittest + +import numpy as np +import pytest + +from opencsp.app.sofast.lib.DefinitionFacet import DefinitionFacet +from opencsp.app.sofast.lib.DisplayShape import DisplayShape +from opencsp.app.sofast.test.test_DisplayShape import TestDisplayShape as t_display +import opencsp.app.sofast.lib.Executor as executor +from opencsp.app.sofast.lib.Fringes import Fringes +from opencsp.app.sofast.lib.ImageCalibrationGlobal import ImageCalibrationGlobal +import opencsp.app.sofast.lib.SpatialOrientation as so +from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe +import opencsp.app.sofast.test.ImageAcquisition_no_camera as ianc +from opencsp.common.lib.camera.Camera import Camera +from opencsp.common.lib.camera.test.test_Camera import TestCamera as t_camera +from opencsp.common.lib.camera.ImageAcquisitionAbstract import ImageAcquisitionAbstract +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +from opencsp.common.lib.deflectometry.Surface2DAbstract import Surface2DAbstract +from opencsp.common.lib.deflectometry.Surface2DParabolic import Surface2DParabolic +from opencsp.common.lib.deflectometry.Surface2DPlano import Surface2DPlano +from opencsp.common.lib.deflectometry.test.test_ImageProjection import _ImageProjection as _ip +from opencsp.common.lib.geometry.Vxyz import Vxyz +import opencsp.common.lib.opencsp_path.opencsp_root_path as orp +import opencsp.common.lib.process.ControlledContext as cc +import opencsp.common.lib.tool.exception_tools as et +import opencsp.common.lib.tool.file_tools as ft + + +@pytest.mark.no_xvfb +class TestExecutor(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + # Get data directories + path, _, _ = ft.path_components(__file__) + cls.data_dir = os.path.join(path, "data", "input", "Executor") + cls.out_dir = os.path.join(path, "data", "output", "Executor") + cls.so_file = os.path.join(orp.opencsp_code_dir(), 'test/data/sofast_fringe/data_expected_facet/data.h5') + ft.create_directories_if_necessary(cls.data_dir) + ft.create_directories_if_necessary(cls.out_dir) + + def setUp(self): + # placeholders + self.sys_fringe: cc.ControlledContext[SystemSofastFringe] = None + self.fringe_collected = False + self.fringe_processed = False + self.fringe_results: executor.FringeResults = None + self.reported_error: Exception = None + + # build the default executor instance + self.executor = executor.Executor() + self.executor.on_fringe_collected = self._on_fringe_collected + self.executor.on_fringe_processed = self._on_fringe_processed + + # build the default system instance + self._build_sys_fringe() + + # build the default surface instances + self.surface_plano = Surface2DPlano(False, 10) + self.surface_parabolic = Surface2DParabolic((500, 500), False, 10) + + def tearDown(self): + with et.ignored(Exception): + self.executor.close() + with et.ignored(Exception): + ImageAcquisitionAbstract.instance().close() + with et.ignored(Exception): + ImageProjection.instance().close() + with et.ignored(Exception): + with self.sys_fringe as sys: + sys.close_all() + + def _build_sys_fringe(self): + # Build Fringe system for testing + fringes = Fringes.from_num_periods() + _IancProduceFringes(fringes) + ImageProjection.in_new_window(_ip.display_dict) + self.sys_fringe = cc.ControlledContext(SystemSofastFringe()) + projector_values = np.arange(0, 255, (255 - 0) / 9) + camera_response = np.arange(5, 50, (50 - 5) / 9) + calibration_global = ImageCalibrationGlobal(camera_response, projector_values) + with self.sys_fringe as sys: + sys.calibration = calibration_global + sys.set_fringes(fringes) + + def _start_processing(self, surface: Surface2DAbstract): + mirror_measure_point = Vxyz((0, 0, 0)) + mirror_measure_dist = 10 + spatial_orientation = so.SpatialOrientation.load_from_hdf(self.so_file) + camera_calibration = Camera( + t_camera.intrinsic_mat, t_camera.distortion_coef_zeros, t_camera.image_shape_xy, "IdealCamera" + ) + display_shape = DisplayShape(t_display.grid_data_rect2D, "Rectangular2D") + facet = DefinitionFacet( + Vxyz(np.array([[-2.4, 2.7, 2.7, -2.35], [-1.15, -1.3, 1.3, 1.15], [0, 0, 0, 0]])), Vxyz([0, 0, 0]) + ) + self.executor.start_process_fringe( + self.sys_fringe, + mirror_measure_point, + mirror_measure_dist, + spatial_orientation, + camera_calibration, + display_shape, + facet, + surface, + ) + + def _on_fringe_collected(self): + self.fringe_collected = True + ImageProjection.instance().close() + + def _on_fringe_processed(self, fringe_results: executor.FringeResults, ex: Exception): + self.fringe_processed = True + self.fringe_results = fringe_results + self.reported_error = ex + + def test_collect_fringe_does_not_block(self): + # Prep the collection + self.executor.start_collect_fringe(self.sys_fringe) + + # If we get here, then that means the collection has been queued + pass + + def test_collect_fringe_completes(self): + # Prep the collection + self.executor.start_collect_fringe(self.sys_fringe) + self.assertFalse(self.fringe_collected) + + # Start the collection + ImageProjection.instance().root.mainloop() + + # If we get here, then the collection should have finished and mainloop() exited + self.assertTrue(self.fringe_collected) + + def test_process_fringe_synchronous(self): + # Run the collection + self.executor.asynchronous_processing = False + self.executor.start_collect_fringe(self.sys_fringe) + ImageProjection.instance().root.mainloop() + + # Start the processing + self.assertFalse(self.fringe_processed) + self._start_processing(self.surface_plano) + + # Check that processing finished + self.assertTrue(self.fringe_processed) + if self.reported_error is not None: + raise self.reported_error + self.assertIsNotNone(self.fringe_results) + + def test_process_fringe_asynchronous(self): + # Run the collection + self.executor.start_collect_fringe(self.sys_fringe) + ImageProjection.instance().root.mainloop() + + # Start the processing + self.assertFalse(self.fringe_processed) + with self.sys_fringe as sys: # blocks the executor's process thread from continuing + self._start_processing(self.surface_plano) + self.assertFalse(self.fringe_processed) + # processing can start at this point + + # Wait until processing is done + tstart = time.time() + while not self.fringe_processed: + if time.time() > tstart + 10: + break + time.sleep(0.1) + + # Check that processing finished + self.assertTrue(self.fringe_processed) + if self.reported_error is not None: + raise self.reported_error + self.assertIsNotNone(self.fringe_results) + + def test_process_fringe_parbolic(self): + # Run the collection + self.executor.asynchronous_processing = False + self.executor.start_collect_fringe(self.sys_fringe) + ImageProjection.instance().root.mainloop() + + # Start the processing + self.assertFalse(self.fringe_processed) + self._start_processing(self.surface_parabolic) + + # Check that processing finished + self.assertTrue(self.fringe_processed) + if self.reported_error is not None: + raise self.reported_error + self.assertIsNotNone(self.fringe_results) + self.assertIsInstance(self.fringe_results.focal_length_x, float) + self.assertIsInstance(self.fringe_results.focal_length_y, float) + + +class _IancProduceFringes(ianc.ImageAcquisition): + """Class for unit testing. Mimics a camera by returning first a light image for the mask, then a dark image + for the mask, then cycling through all fringe images.""" + + def __init__(self, fringes: Fringes): + super().__init__() + self.index = -2 + self.fringes = fringes + self.fringe_images = None + + def get_frame(self) -> np.ndarray: + x, y = self.frame_size + + if self.index < 0: + # mask images + frame = np.zeros((y, x), "uint8") + if self.index == -1: + frame[10:-10, 10:-10] = self.max_value + else: + # fringe images + if self.fringe_images is None: + self.fringe_images = self.fringes.get_frames(x, y, "uint8", [0, self.max_value]) + frame = self.fringe_images[:, :, self.index] + + self.index += 1 + if self.index >= self.fringes.num_images: + self.index = 0 + + return frame + + +if __name__ == '__main__': + unittest.main() diff --git a/opencsp/app/sofast/test/ImageAcquisition_no_camera.py b/opencsp/app/sofast/test/ImageAcquisition_no_camera.py index 2c616f20a..895027b42 100644 --- a/opencsp/app/sofast/test/ImageAcquisition_no_camera.py +++ b/opencsp/app/sofast/test/ImageAcquisition_no_camera.py @@ -35,7 +35,8 @@ def instance_matches(self, possible_matches: list[ImageAcquisitionAbstract]) -> def get_frame(self) -> np.ndarray: # Return test image - return np.zeros(self._frame_size, dtype=np.uint8) + x, y = self._frame_size + return np.zeros((y, x), dtype=np.uint8) @property def gain(self) -> float: diff --git a/opencsp/app/sofast/test/test_DisplayShape.py b/opencsp/app/sofast/test/test_DisplayShape.py index 99e565817..1e28615e4 100644 --- a/opencsp/app/sofast/test/test_DisplayShape.py +++ b/opencsp/app/sofast/test/test_DisplayShape.py @@ -13,18 +13,24 @@ class TestDisplayShape(unittest.TestCase): + LX = 5.0 # meters + LY = 5.0 # meters + LZ = 3.0 # meters + grid_data_rect2D = {'screen_x': LX, 'screen_y': LY, 'screen_model': 'rectangular2D'} + @classmethod def setUpClass(cls): # Define screen X and Y extent - LX = 5.0 # meters - LY = 5.0 # meters - LZ = 3.0 # meters + LX = TestDisplayShape.LX + LY = TestDisplayShape.LY + LZ = TestDisplayShape.LZ # Define test points cls.test_Vxy_pts = Vxy(([0, 0.5, 1, 0, 0.5, 1, 0, 0.5, 1], [0, 0, 0, 0.5, 0.5, 0.5, 1, 1, 1])) # Define rectangular input data - cls.grid_data_rect2D = {'screen_x': LX, 'screen_y': LY, 'screen_model': 'rectangular2D'} + # done in class definition + # cls.grid_data_rect2D = {'screen_x': LX, 'screen_y': LY, 'screen_model': 'rectangular2D'} # Define 2D input data cls.grid_data_2D = { diff --git a/opencsp/common/lib/camera/test/test_Camera.py b/opencsp/common/lib/camera/test/test_Camera.py index 3199fe114..e98e0cdfa 100644 --- a/opencsp/common/lib/camera/test/test_Camera.py +++ b/opencsp/common/lib/camera/test/test_Camera.py @@ -8,17 +8,21 @@ from opencsp.common.lib.geometry.Vxyz import Vxyz -class TestVxyz: +class TestCamera: + intrinsic_mat = np.array([[1000, 0, 500.5], [0, 1000, 250.5], [0, 0, 1]]) + distortion_coef_zeros = np.array([0.0, 0.0, 0.0, 0.0]) + distortion_coef_real = np.array([0.01, 0.02, 0.001, 0.002]) + image_shape_xy = (1000, 500) + @classmethod def setup_class(cls): # Create camera parameters: f=1000 pixels, 1000 x 1000 pixels - intrinsic_mat = np.array([[1000, 0, 500.5], [0, 1000, 250.5], [0, 0, 1]]) - distortion_coef_zeros = np.array([0.0, 0.0, 0.0, 0.0]) - distortion_coef_real = np.array([0.01, 0.02, 0.001, 0.002]) - image_shape_xy = (1000, 500) - - cls.camera_ideal = Camera(intrinsic_mat, distortion_coef_zeros, image_shape_xy, 'Test Ideal Camera') - cls.camera_real = Camera(intrinsic_mat, distortion_coef_real, image_shape_xy, 'Test Real Camera') + cls.camera_ideal = Camera( + TestCamera.intrinsic_mat, TestCamera.distortion_coef_zeros, TestCamera.image_shape_xy, 'Test Ideal Camera' + ) + cls.camera_real = Camera( + TestCamera.intrinsic_mat, TestCamera.distortion_coef_real, TestCamera.image_shape_xy, 'Test Real Camera' + ) # Define upper left 3D point and image location cls.Vxyz_ul = Vxyz((-1, -0.5, 2)) @@ -112,7 +116,7 @@ def test_image_shape(self): if __name__ == '__main__': - Test = TestVxyz() + Test = TestCamera() Test.setup_class() Test.test_center_ray() diff --git a/opencsp/common/lib/process/ControlledContext.py b/opencsp/common/lib/process/ControlledContext.py index cae6fd65c..7dec9ba6c 100644 --- a/opencsp/common/lib/process/ControlledContext.py +++ b/opencsp/common/lib/process/ControlledContext.py @@ -1,6 +1,8 @@ import asyncio from typing import Generic, TypeVar +import opencsp.common.lib.tool.log_tools as lt + T = TypeVar('T') @@ -31,9 +33,15 @@ def __init__(self, o: T): self.mutex = asyncio.Lock() def __enter__(self): - self.mutex.acquire() + asyncio.run(self.mutex.acquire()) return self.o def __exit__(self, exc_type, exc_value, traceback): - self.mutex.release() + if self.mutex.locked(): + self.mutex.release() + else: + lt.warn( + "Warning in ControlledContext.__exit__(): " + + f"Mutex not locked for object {self.o}. This probably means that it was free'd by a nested function!" + ) return False From db6a5f6484bee725555bdb2834f56550033af0e5 Mon Sep 17 00:00:00 2001 From: bbean Date: Thu, 11 Apr 2024 23:50:22 -0600 Subject: [PATCH 14/17] move ImageAcquisitionWithFringes to ImageAcquisition_no_camera --- opencsp/app/sofast/__init__.py | 2 +- opencsp/app/sofast/lib/test/test_Executor.py | 34 +------------------ .../sofast/test/ImageAcquisition_no_camera.py | 32 +++++++++++++++++ 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/opencsp/app/sofast/__init__.py b/opencsp/app/sofast/__init__.py index 228534985..34a1587f0 100644 --- a/opencsp/app/sofast/__init__.py +++ b/opencsp/app/sofast/__init__.py @@ -3,7 +3,7 @@ _sofast_defaults_settings_key = "sofast_defaults" _sofast_defaults_settings: dict[str, any] = { - "cameras": None, + "camera_names_and_indexes": None, "projector_file": None, "calibration_file": None, "mirror_measure_point": None, diff --git a/opencsp/app/sofast/lib/test/test_Executor.py b/opencsp/app/sofast/lib/test/test_Executor.py index 351dc077b..15031111b 100644 --- a/opencsp/app/sofast/lib/test/test_Executor.py +++ b/opencsp/app/sofast/lib/test/test_Executor.py @@ -2,7 +2,6 @@ """ import os -import time import unittest import numpy as np @@ -78,7 +77,7 @@ def tearDown(self): def _build_sys_fringe(self): # Build Fringe system for testing fringes = Fringes.from_num_periods() - _IancProduceFringes(fringes) + ianc.ImageAcquisitionWithFringes(fringes) ImageProjection.in_new_window(_ip.display_dict) self.sys_fringe = cc.ControlledContext(SystemSofastFringe()) projector_values = np.arange(0, 255, (255 - 0) / 9) @@ -197,36 +196,5 @@ def test_process_fringe_parbolic(self): self.assertIsInstance(self.fringe_results.focal_length_y, float) -class _IancProduceFringes(ianc.ImageAcquisition): - """Class for unit testing. Mimics a camera by returning first a light image for the mask, then a dark image - for the mask, then cycling through all fringe images.""" - - def __init__(self, fringes: Fringes): - super().__init__() - self.index = -2 - self.fringes = fringes - self.fringe_images = None - - def get_frame(self) -> np.ndarray: - x, y = self.frame_size - - if self.index < 0: - # mask images - frame = np.zeros((y, x), "uint8") - if self.index == -1: - frame[10:-10, 10:-10] = self.max_value - else: - # fringe images - if self.fringe_images is None: - self.fringe_images = self.fringes.get_frames(x, y, "uint8", [0, self.max_value]) - frame = self.fringe_images[:, :, self.index] - - self.index += 1 - if self.index >= self.fringes.num_images: - self.index = 0 - - return frame - - if __name__ == '__main__': unittest.main() diff --git a/opencsp/app/sofast/test/ImageAcquisition_no_camera.py b/opencsp/app/sofast/test/ImageAcquisition_no_camera.py index 895027b42..a86125911 100644 --- a/opencsp/app/sofast/test/ImageAcquisition_no_camera.py +++ b/opencsp/app/sofast/test/ImageAcquisition_no_camera.py @@ -4,6 +4,7 @@ import numpy as np +from opencsp.app.sofast.lib.Fringes import Fringes from opencsp.common.lib.camera.ImageAcquisitionAbstract import ImageAcquisitionAbstract @@ -89,3 +90,34 @@ def __init__(self): def calibrate_exposure(self): self.is_calibrated = True + + +class ImageAcquisitionWithFringes(ImageAcquisition): + """Class for unit testing. Mimics a camera by returning first a light image for the mask, then a dark image + for the mask, then cycling through all fringe images.""" + + def __init__(self, fringes: Fringes): + super().__init__() + self.index = -2 + self.fringes = fringes + self.fringe_images = None + + def get_frame(self) -> np.ndarray: + x, y = self.frame_size + + if self.index < 0: + # mask images + frame = np.zeros((y, x), "uint8") + if self.index == -1: + frame[10:-10, 10:-10] = self.max_value + else: + # fringe images + if self.fringe_images is None: + self.fringe_images = self.fringes.get_frames(x, y, "uint8", [0, self.max_value]) + frame = self.fringe_images[:, :, self.index] + + self.index += 1 + if self.index >= self.fringes.num_images: + self.index = 0 + + return frame From 0735f1a5e0b0d08355a09ed8b3af309d3b8b2321 Mon Sep 17 00:00:00 2001 From: bbean Date: Fri, 12 Apr 2024 14:45:05 -0600 Subject: [PATCH 15/17] fix ControlledContext, add timeout option to ControlledContext, add test_collect_fringe_from_other_thread, add _on_tick handler for Executor --- opencsp/app/sofast/lib/Executor.py | 45 +++++++++++++++++-- opencsp/app/sofast/lib/SystemSofastFringe.py | 27 ++++++----- opencsp/app/sofast/lib/test/test_Executor.py | 24 +++++++++- .../common/lib/process/ControlledContext.py | 29 +++++++----- 4 files changed, 100 insertions(+), 25 deletions(-) diff --git a/opencsp/app/sofast/lib/Executor.py b/opencsp/app/sofast/lib/Executor.py index 56195668f..e65ac5e13 100644 --- a/opencsp/app/sofast/lib/Executor.py +++ b/opencsp/app/sofast/lib/Executor.py @@ -1,6 +1,7 @@ from concurrent.futures import ThreadPoolExecutor import dataclasses from typing import Callable +import queue import numpy as np import numpy.typing as npt @@ -13,6 +14,7 @@ from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe from opencsp.common.lib.camera.Camera import Camera from opencsp.common.lib.csp.Facet import Facet +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection from opencsp.common.lib.deflectometry.Surface2DAbstract import Surface2DAbstract from opencsp.common.lib.deflectometry.Surface2DParabolic import Surface2DParabolic import opencsp.common.lib.geometry.Vxyz as vxyz @@ -72,6 +74,13 @@ def __init__(self, asynchronous_processing=True): self.asynchronous_processing = asynchronous_processing self._processing_pool = ThreadPoolExecutor(max_workers=1) + # tkinter thread + if ImageProjection.instance() is None: + lt.error_and_raise( + RuntimeError, "Error in Executor(): ImageProjection must exist before initializing Executor instance.") + ImageProjection.instance().root.after(100, self._on_tick) + self.tasks: queue.Queue[Callable] = queue.Queue() + # don't try to close multiple times self.is_closed = False @@ -79,6 +88,28 @@ def __del__(self): with et.ignored(Exception): self.close() + def _on_tick(self): + """Function that runs every 100ms in the tkinter thread.""" + # stop evaluating if this instance is closed + if self.is_closed: + return + + try: + # evaluate any pending tasks + while True: + task = self.tasks.get_nowait() + lt.error("evaluating task") + task() + + except queue.Empty: + pass + + finally: + # run again in another 100ms + ip = ImageProjection.instance() + if ip is not None: + ip.root.after(100, self._on_tick) + def start_collect_fringe(self, controlled_system: cc.ControlledContext[SystemSofastFringe]): """Starts collection of fringe measurement image data. @@ -89,9 +120,17 @@ def start_collect_fringe(self, controlled_system: cc.ControlledContext[SystemSof # Start the collection lt.debug("Executor: collecting fringes") - # Run the measurement in the main thread (aka the tkinter thread) - with controlled_system as system: - system.run_measurement(self.on_fringe_collected) + # The collection task: + def task(): + with controlled_system as system: + system.run_measurement(self.on_fringe_collected) + + if self.asynchronous_processing: + # Run the measurement in the main thread (aka the tkinter thread) + self.tasks.put(task) + else: + # Run the measurement in this thread + task() def start_process_fringe( self, diff --git a/opencsp/app/sofast/lib/SystemSofastFringe.py b/opencsp/app/sofast/lib/SystemSofastFringe.py index 6e91a45bb..312b12647 100644 --- a/opencsp/app/sofast/lib/SystemSofastFringe.py +++ b/opencsp/app/sofast/lib/SystemSofastFringe.py @@ -212,6 +212,7 @@ def _measure_sequence_display( except Exception as ex: # make sure that whatever we are trying to do when we finish, that it happens if run_next is not None: + lt.error(f"Error in SystemSofastFringe._measure_sequence_display(): {ex}") run_next() raise @@ -232,19 +233,25 @@ def _measure_sequence_capture( Function that is run after all images have been captured. """ - for ims_cap, im_aq in zip(im_cap_list, self.image_acquisitions): - # Capture and save image - im = im_aq.get_frame() + try: + for ims_cap, im_aq in zip(im_cap_list, self.image_acquisitions): + # Capture and save image + im = im_aq.get_frame() - # Check for image saturation - self.check_saturation(im, im_aq.max_value) + # Check for image saturation + self.check_saturation(im, im_aq.max_value) - # Reshape image if necessary - if np.ndim(im) == 2: - im = im[..., np.newaxis] + # Reshape image if necessary + if np.ndim(im) == 2: + im = im[..., np.newaxis] - # Save image to image list - ims_cap.append(im) + # Save image to image list + ims_cap.append(im) + except Exception as ex: + lt.error(f"Error in SystemSofastFringe._measure_sequence_capture(): {ex}") + if run_next is not None: + run_next() + raise if len(im_cap_list[0]) < len(im_disp_list): # Display next image if not finished diff --git a/opencsp/app/sofast/lib/test/test_Executor.py b/opencsp/app/sofast/lib/test/test_Executor.py index 15031111b..01f8bd2aa 100644 --- a/opencsp/app/sofast/lib/test/test_Executor.py +++ b/opencsp/app/sofast/lib/test/test_Executor.py @@ -1,7 +1,10 @@ -"""Unit test suite to test ImageCalibrationGlobal class +"""Unit test suite to test Executor class """ +from concurrent.futures import ThreadPoolExecutor import os +import time +from tkinter import Tk import unittest import numpy as np @@ -29,6 +32,7 @@ import opencsp.common.lib.process.ControlledContext as cc import opencsp.common.lib.tool.exception_tools as et import opencsp.common.lib.tool.file_tools as ft +import opencsp.common.lib.tool.log_tools as lt @pytest.mark.no_xvfb @@ -51,6 +55,9 @@ def setUp(self): self.fringe_results: executor.FringeResults = None self.reported_error: Exception = None + # build the default projector instance + ImageProjection.in_new_window(_ip.display_dict) + # build the default executor instance self.executor = executor.Executor() self.executor.on_fringe_collected = self._on_fringe_collected @@ -78,7 +85,6 @@ def _build_sys_fringe(self): # Build Fringe system for testing fringes = Fringes.from_num_periods() ianc.ImageAcquisitionWithFringes(fringes) - ImageProjection.in_new_window(_ip.display_dict) self.sys_fringe = cc.ControlledContext(SystemSofastFringe()) projector_values = np.arange(0, 255, (255 - 0) / 9) camera_response = np.arange(5, 50, (50 - 5) / 9) @@ -136,6 +142,20 @@ def test_collect_fringe_completes(self): # If we get here, then the collection should have finished and mainloop() exited self.assertTrue(self.fringe_collected) + def test_collect_fringe_from_other_thread(self): + pool = ThreadPoolExecutor(max_workers=1) + result = pool.submit(self.executor.start_collect_fringe, self.sys_fringe) + + # Start the collection + ImageProjection.instance().root.mainloop() + + # Check for errors + if result.exception() is not None: + raise result.exception() + + # If we get here, then the collection should have finished and mainloop() exited + self.assertTrue(self.fringe_collected) + def test_process_fringe_synchronous(self): # Run the collection self.executor.asynchronous_processing = False diff --git a/opencsp/common/lib/process/ControlledContext.py b/opencsp/common/lib/process/ControlledContext.py index 7dec9ba6c..fc9b5be44 100644 --- a/opencsp/common/lib/process/ControlledContext.py +++ b/opencsp/common/lib/process/ControlledContext.py @@ -1,4 +1,5 @@ import asyncio +import threading from typing import Generic, TypeVar import opencsp.common.lib.tool.log_tools as lt @@ -28,20 +29,28 @@ def set_to_one(controlled_val: ControlledContext[list[int]]): print(str(tsv[0])) # will always print '0' """ - def __init__(self, o: T): + def __init__(self, o: T, timeout: float = 1): self.o = o - self.mutex = asyncio.Lock() + self.rlock = threading.RLock() + self.timeout = timeout + self.timed_out = False + + async def _acquire_with_timeout(self): + if self.timeout: + if not self.rlock.acquire(timeout=self.timeout): + self.timed_out = True + else: + self.rlock.acquire() def __enter__(self): - asyncio.run(self.mutex.acquire()) + asyncio.run(self._acquire_with_timeout()) + if self.timed_out: + lt.error_and_raise(asyncio.TimeoutError, + f"Failed to acquire lock for {self.o} within {self.timeout} seconds") return self.o def __exit__(self, exc_type, exc_value, traceback): - if self.mutex.locked(): - self.mutex.release() - else: - lt.warn( - "Warning in ControlledContext.__exit__(): " - + f"Mutex not locked for object {self.o}. This probably means that it was free'd by a nested function!" - ) + self.rlock.release() + + # return False to enable exceptions to be re-raised return False From d91fb9893a586d6647acb1944e0c224fcdbc7c26 Mon Sep 17 00:00:00 2001 From: bbean Date: Fri, 12 Apr 2024 14:48:06 -0600 Subject: [PATCH 16/17] add test_ServerState.py, fix ServerState sensitive_strings_allowed_binary_files_20240412_144750.csv --- opencsp/app/sofast/__init__.py | 5 +- opencsp/app/sofast/lib/DefinitionFacet.py | 2 +- opencsp/app/sofast/lib/ServerState.py | 131 +++++++----- .../data/input/ServerState/calibration.h5 | Bin 0 -> 18816 bytes .../input/ServerState/camera_calibration.h5 | Bin 0 -> 47585 bytes .../data/input/ServerState/display_shape.h5 | Bin 0 -> 10624 bytes .../input/ServerState/facet_definition.h5 | Bin 0 -> 4752 bytes .../test/data/input/ServerState/projector.h5 | Bin 0 -> 22560 bytes .../input/ServerState/spatial_orientation.h5 | Bin 0 -> 5024 bytes .../data/input/ServerState/surface_shape.h5 | Bin 0 -> 10624 bytes .../app/sofast/lib/test/test_ServerState.py | 198 ++++++++++++++++++ 11 files changed, 279 insertions(+), 57 deletions(-) create mode 100644 opencsp/app/sofast/lib/test/data/input/ServerState/calibration.h5 create mode 100644 opencsp/app/sofast/lib/test/data/input/ServerState/camera_calibration.h5 create mode 100644 opencsp/app/sofast/lib/test/data/input/ServerState/display_shape.h5 create mode 100644 opencsp/app/sofast/lib/test/data/input/ServerState/facet_definition.h5 create mode 100644 opencsp/app/sofast/lib/test/data/input/ServerState/projector.h5 create mode 100644 opencsp/app/sofast/lib/test/data/input/ServerState/spatial_orientation.h5 create mode 100644 opencsp/app/sofast/lib/test/data/input/ServerState/surface_shape.h5 create mode 100644 opencsp/app/sofast/lib/test/test_ServerState.py diff --git a/opencsp/app/sofast/__init__.py b/opencsp/app/sofast/__init__.py index 34a1587f0..5e7f95954 100644 --- a/opencsp/app/sofast/__init__.py +++ b/opencsp/app/sofast/__init__.py @@ -9,14 +9,15 @@ "mirror_measure_point": None, "mirror_screen_distance": None, "camera_calibration_file": None, - "fixed_pattern_diameter_and_spacing": None, "spatial_orientation_file": None, "display_shape_file": None, - "dot_locations_file": None, "facet_definition_files": None, "ensemble_definition_file": None, "reference_facet_file": None, "surface_shape_file": None, + "fixed_pattern_diameter_and_spacing": None, + "dot_locations_file": None, + "num_fringe_periods": None, } """ log_output_dir: Where to save log output to from the server. diff --git a/opencsp/app/sofast/lib/DefinitionFacet.py b/opencsp/app/sofast/lib/DefinitionFacet.py index 8f2792bb1..cb0ff7154 100644 --- a/opencsp/app/sofast/lib/DefinitionFacet.py +++ b/opencsp/app/sofast/lib/DefinitionFacet.py @@ -87,7 +87,7 @@ def save_to_hdf(self, file: str, prefix: str = '') -> None: hdf5_tools.save_hdf5_datasets(data, datasets, file) @classmethod - def load_from_hdf(cls, file: str, prefix: str) -> 'DefinitionFacet': + def load_from_hdf(cls, file: str, prefix: str = '') -> 'DefinitionFacet': """Loads DefinitionFacet object from given file. Data is stored in PREFIX + DefinitionFacet/... Parameters diff --git a/opencsp/app/sofast/lib/ServerState.py b/opencsp/app/sofast/lib/ServerState.py index 57de2fe65..6e2e567db 100644 --- a/opencsp/app/sofast/lib/ServerState.py +++ b/opencsp/app/sofast/lib/ServerState.py @@ -1,10 +1,11 @@ -import asyncio +import threading from opencsp.app.sofast.lib.DefinitionEnsemble import DefinitionEnsemble from opencsp.app.sofast.lib.DefinitionFacet import DefinitionFacet from opencsp.app.sofast.lib.DisplayShape import DisplayShape from opencsp.app.sofast.lib.DotLocationsFixedPattern import DotLocationsFixedPattern import opencsp.app.sofast.lib.Executor as sfe +from opencsp.app.sofast.lib.Fringes import Fringes from opencsp.app.sofast.lib.ImageCalibrationAbstract import ImageCalibrationAbstract from opencsp.app.sofast.lib.SpatialOrientation import SpatialOrientation from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe @@ -23,7 +24,6 @@ class ServerState: - _default_io_initialized: bool = False _instance: cc.ControlledContext['ServerState'] = None def __init__(self): @@ -55,8 +55,7 @@ def __init__(self): self.facet_definitions: list[DefinitionFacet] = None self.ensemble_definition: DefinitionEnsemble = None self.surface_shape: Surface2DAbstract = None - if not ServerState._default_io_initialized: - self.init_io() + self.init_io() self.load_default_settings() # statuses @@ -73,7 +72,7 @@ def __init__(self): # assign this as the global static instance if ServerState._instance is None: - ServerState._instance = cc.ControlledContext(self) + ServerState._instance = cc.ControlledContext(self, 10) else: lt.error_and_raise( RuntimeError, @@ -90,8 +89,7 @@ def instance() -> cc.ControlledContext['ServerState']: return ServerState._instance @property - @staticmethod - def _state_lock() -> asyncio.Lock: + def _state_lock(self) -> threading.RLock: """ Return the mutex for providing exclusive access to ServerState singleton. @@ -108,7 +106,7 @@ def _state_lock() -> asyncio.Lock: with self._state_lock: # do stuff """ - return ServerState.instance().mutex + return ServerState.instance().rlock @property def system_fixed(self) -> cc.ControlledContext[SystemSofastFixed]: @@ -123,7 +121,10 @@ def system_fixed(self) -> cc.ControlledContext[SystemSofastFixed]: @property def system_fringe(self) -> cc.ControlledContext[SystemSofastFringe]: if self._system_fringe is None: - self._system_fringe = cc.ControlledContext(SystemSofastFringe()) + sys = SystemSofastFringe() + sys.calibration = self.calibration + sys.set_fringes(self.fringes) + self._system_fringe = cc.ControlledContext(sys, timeout=10) return self._system_fringe @property @@ -163,7 +164,7 @@ def has_fixed_measurement(self) -> bool: return True @property - def last_measurement_fixed(self) -> sfe.FixedResults: + def last_measurement_fixed(self) -> sfe.FixedResults | None: if not self.has_fixed_measurement: return None return self._last_measurement_fixed @@ -196,7 +197,7 @@ def processing_error(self) -> Exception | None: return self._processing_error @property - def last_measurement_fringe(self) -> sfe.FringeResults: + def last_measurement_fringe(self) -> sfe.FringeResults | None: if not self.has_fringe_measurement: return None return self._last_measurement_fringe @@ -208,28 +209,35 @@ def busy(self) -> bool: all available for starting a new measurement. """ if self._running_measurement_fixed: - return False + return True elif self._running_measurement_fringe: - return False + return True elif self._processing_measurement_fixed: - return False + return True elif self._processing_measurement_fringe: - return False - else: return True + else: + return False def _connect_default_cameras(self): - camera_descriptions = opencsp_settings["sofast_defaults"]["camera_files"] - if camera_descriptions is not None: + """Connects to the cameras specified in the settings.json file. See app/sofast/__init__.py for more information.""" + camera_names_and_indexes = opencsp_settings["sofast_defaults"]["camera_names_and_indexes"] + if camera_names_and_indexes is not None: cam_options = ImageAcquisitionAbstract.cam_options() - for camera_description in camera_descriptions: - cam_options[camera_description]() + for camera_name, idx in camera_names_and_indexes: + with et.ignored(Exception): + cam_options[camera_name](idx) def _load_default_projector(self): + """Opens the projector specified in the settings.json file in a new window. See app/sofast/__init__.py for more information.""" projector_file = opencsp_settings["sofast_defaults"]["projector_file"] if projector_file is not None: + params = ImageProjection.load_from_hdf(projector_file) if ImageProjection.instance() is not None: - ImageProjection.instance().close() + if ImageProjection.instance().display_data == params: + return + else: + ImageProjection.instance().close() ImageProjection.load_from_hdf_and_display(projector_file) def init_io(self): @@ -245,9 +253,7 @@ def load_default_settings(self): # load default calibration calibration_file = sofast_default_settings["calibration_file"] if calibration_file is not None: - calibration = ImageCalibrationAbstract.load_from_hdf_guess_type(calibration_file) - with self.system_fringe as sys: - sys.calibration = calibration + self.calibration = ImageCalibrationAbstract.load_from_hdf_guess_type(calibration_file) # latch default mirror measure point self.mirror_measure_point = sofast_default_settings["mirror_measure_point"] @@ -302,6 +308,11 @@ def load_default_settings(self): if surface_shape_file is not None: self.surface_shape = Surface2DAbstract.load_from_hdf_guess_type(surface_shape_file) + # latch default fringe periods + num_fringe_periods = sofast_default_settings["num_fringe_periods"] + if num_fringe_periods is not None: + self.fringes = Fringes.from_num_periods(*num_fringe_periods) + def start_measure_fringes(self, name: str = None) -> bool: """Starts collection and processing of fringe measurement image data. @@ -318,18 +329,19 @@ def start_measure_fringes(self, name: str = None) -> bool: True if the measurement was able to be started. False if the system resources are busy. """ # Check that system resources are available - if self._running_measurement_fringe or self._processing_measurement_fringe: - lt.warn( - "Warning in server_api.run_measurment_fringe(): " - + "Attempting to start another fringe measurement before the last fringe measurement has finished." - ) - return False - if not self.projector_available: - lt.warn( - "Warning in server_api.run_measurment_fringe(): " - + "Attempting to start a fringe measurement while the projector is already in use." - ) - return False + with self._state_lock: + if self._running_measurement_fringe or self._processing_measurement_fringe: + lt.warn( + "Warning in server_api.run_measurment_fringe(): " + + "Attempting to start another fringe measurement before the last fringe measurement has finished." + ) + return False + if not self.projector_available: + lt.warn( + "Warning in server_api.run_measurment_fringe(): " + + "Attempting to start a fringe measurement while the projector is already in use." + ) + return False # Latch the name value self.fringe_measurement_name = name @@ -338,12 +350,17 @@ def start_measure_fringes(self, name: str = None) -> bool: # Critical section, these statuses updates need to be thread safe with self._state_lock: self._last_measurement_fringe = None - self._running_measurement_fringe = True self._processing_error = None + self._running_measurement_fringe = True # Start the measurement - self._executor.on_fringe_collected = self._on_fringe_collected - self._executor.start_collect_fringe(self.system_fringe) + try: + self._executor.on_fringe_collected = self._on_fringe_collected + self._executor.start_collect_fringe(self.system_fringe) + except Exception: + with self._state_lock: + self._running_measurement_fringe = False + raise return True @@ -368,19 +385,24 @@ def _on_fringe_collected(self): self._running_measurement_fringe = False # Start the processing - self._executor.on_fringe_processed = self._on_fringe_processed - self._executor.start_process_fringe( - self.system_fringe, - self.mirror_measure_point, - self.mirror_screen_distance, - self.spatial_orientation, - self.camera_calibration, - self.display_shape, - self.facet_definitions[0], - self.surface_shape, - self.fringe_measurement_name, - self.reference_facet, - ) + try: + self._executor.on_fringe_processed = self._on_fringe_processed + self._executor.start_process_fringe( + self.system_fringe, + self.mirror_measure_point, + self.mirror_screen_distance, + self.spatial_orientation, + self.camera_calibration, + self.display_shape, + self.facet_definitions[0], + self.surface_shape, + self.fringe_measurement_name, + self.reference_facet, + ) + except Exception: + with self._state_lock: + self._processing_measurement_fringe = False + raise def _on_fringe_processed(self, fringe_results: sfe.FringeResults | None, ex: Exception | None): """ @@ -456,5 +478,6 @@ def close_all(self): with et.ignored(Exception): lt.debug("ServerState.close_all(): Unassigning this instance as the singleton ServerState") - if self == ServerState._instance: - ServerState._instance = None + with ServerState._instance as state: + if self == state: + ServerState._instance = None diff --git a/opencsp/app/sofast/lib/test/data/input/ServerState/calibration.h5 b/opencsp/app/sofast/lib/test/data/input/ServerState/calibration.h5 new file mode 100644 index 0000000000000000000000000000000000000000..953057b013e2698c82158b9534ac9cb804451f5d GIT binary patch literal 18816 zcmeI1K~EDw6vyATHdYLg8e@=C4<0yq>H!j>OK71aM2JY@!2}1~K$FE1Kof3zEH{pR z1mm6W(D*6*Cb+Zj|J<;I!lA)}|7kk!&70Zj%y0kg%uY|*&BgJtsWI`XT9pSfk-3(g zBPGikv9i^Ff)l{@V1Ja<3BPl{=)i4$bMe>cFb#h09PY>N_-|l4m-gn-@qT32nZAqqT7SFR z^cuF`%{p{z?Tb3L{%9`}2&9s&W19JuN=XNSl6-{bX3J^4uc_7FfPE;9$I>_l|4JJ5 z^`%lN>!6DM%0YrI+7|~Py+Zll7FAbenZ__qpAH_6>2Lbs;C80pq&B$S{i=Ny>U>_p zzJhJQ&ceP{o6Mu!M0pWqk&P_Pg=$wc9&VugQSF&SJPLpM^lKOOarj(BlG!v%lPX(% z<|IG@BtQZrKmx-faC`g8{+-xYUgn`YN85|ozWPPF@AfBE{;hS|F5g#cINw)K=ouHz zpYl8SJ$LDm011!)36Q`o5m;JSUCGuj{Ow}voPQ4Ysd%2f6nD2=taU!`KgoT1BtQZr zKmsH{0wh2JBtQZrKmsH{0;5TQ&;O%&UpOifAOR8}0TLhq5+DH*AOR8}0TLJ{0Y3i^ elP3Ej0TLhq5+DH*AOR8}0TLhq5+H%mB=85jAfMO( literal 0 HcmV?d00001 diff --git a/opencsp/app/sofast/lib/test/data/input/ServerState/camera_calibration.h5 b/opencsp/app/sofast/lib/test/data/input/ServerState/camera_calibration.h5 new file mode 100644 index 0000000000000000000000000000000000000000..d1ce8f49879409ee1f0214e445c676dbd27643ed GIT binary patch literal 47585 zcmeI5PfQ$D9LL|XB|>W>Xk)+=Cqv`ER935<{p1a*QrEgslULP2&Y9(S6V0&QRYXf zE-vrn{rmMM#)gNL)|CBA%FWczt(2?mozx=T?*!t*gQLdANj5aj={oxvi|Uk>u^mgP z@w`x9VtzrXtoC=)R{U1(v$DVOS4V~-`mWCIr+V0LMc?noz1(o^IAuidHp_QkpmOo! z(z!~#_A!!=PEab>(c;lcy%m<%pQ7@xZzHOe7&DNFB2tkSDqyTCIPLP-(OMuO8jlHv-a`Dua+LIt=GoYy${va z6aTyVSZ%#MQ5(AwtLSI^?MhkdM{||sn~m-aY-R&88`{tQ&<=J;*OxvbC3fY27AOV* z5C8!X009ta7J=$g+Rl&U*K2z75NL-8`q8&zULPO3O=aD)P49Ke9zY76d>5 z1V8`;KmY_l00ck)1V8`;Kp@lvg5t~k@iFz{%SN9eGbJ7))JCIoJ5C@dzHGx6U*#l{ zRwm&l9ozOIBH!F1o2+y!moMlTw44_{X-~@UWQe0G29pm1tJXgD0G7A-Zy0)HJz5Hx#z5P;K?N_~?Y5sUSd0#4iR+hKxXUhS8 zHovQAt9g*;lHCiuGa#HcAV^PyDNrcMu@9Us}a?dHkCMO-n4%j_+;gaA5WZo_r~YHe>nN=XPvQ6KX~%WcMpF& zP;B|;@vfUc4Cs^k?|)F?574bQV0h<6vO;bwJ7^PjwFd|U4H&xnDmza@g~aCFk7XTt zP9K&txhXqsO6moq*T0c%_r|?;l6pipM#`J*ayc8qoCH$$bKh>Asolw5uD<pUu`h>q`Z^_N`tLuBxul_5K#si1GVjc(_+Py2}NQ{nsBz9P5N|#A8L$;uR z0;6X>j-4!6*_pYt0=g2s`_u2K6j1GVR2!H?xfPjy{*7*SQZa*kqz`syey?BAO^K_t5-|-+m z7z89xf!rr2Kfj^V~{{eNsuz$&(g-lU0p-AiV zXmp{KU)gS1x<<^rDw#czrm`uskZfW)m(<&|ot-Jny57>8)O{y+an3F13QKR@OLLZE zyQXF;_g2=mGWoQ(Jf~7FEis!YEYQNU;tg^=H4BDW^w0b%v&6Gm@}=}19ZEVt61E@! z0w4eaAOHgUh(P^u9mQKW9tZjxkMBBsw3dA`L=XT05C8!X0D=2WAn5M_c=cD6*Z%(@J=YLu659H3^=%HgN>K~63|4pj<_*8GCKM({1|={ z?CaeHc?nepB8^uP?%us?``(X#`;PZgu}{*ey%eY;nS?lO1ulP0=QHfV}SYq>{2{K*VpqdYvnTdnS6b=bQ*jn&`WYa?7t#VDIZ;^3188ySx+CNGlpT` z_q#1;P;WY2SH)PD7AGud=jpc+!wWS@mp|#ZM8f59FE|WIk2nEczZT7BNc!RF^&36c z?OZ==?Sa;AKis-473(Ii;s`^dS3{FYXiWoDzb-WY*LVVv>c2?4&uHsOl(T#V)hgqQR z(RO$qQz)#3=dsScOn6>2db1XeGi#-N$ID?$3FUiRTk0@LzJ-7g5CTF#2nd155m;Vb zIgDgo`CA@2!w7bg)NWN!P*c@Ct+O#at+PkOV|ITFV72S)RGUU(XxbV8UF8Ar}W&$qtm&2YB{|F+i&w8fZ7*OM@{R;D8TzBdt!xv5D)@F YKnMr{As_^VfDjM@LO=)zfxjm31p!QO9{>OV literal 0 HcmV?d00001 diff --git a/opencsp/app/sofast/lib/test/data/input/ServerState/facet_definition.h5 b/opencsp/app/sofast/lib/test/data/input/ServerState/facet_definition.h5 new file mode 100644 index 0000000000000000000000000000000000000000..6d4d78c2fe0f22f859e8b33e5d52745c3c11acfa GIT binary patch literal 4752 zcmeD5aB<`1lHy_j0S*oZ76t(@6Gr@pf(b$p5f~pPp8#brLg@}Dy@CnCU}OM61_lYJ zxFFPgbaf#?uC5F~l`!*RG*lbI16Bx&112y^kEjsvaCHm-c{l>%j~$CVT#nm^aJf(?IDqJ~_WAFSV$c0nSg&D=Es)Oo6F`mA4u&0UUI&pT7$z4a3r%0yN#i z^rNR^9L9`zVMYd4NLd06I7S9Y$TKoQ1fanRtUn|*FQ{M-jSB|`6K)2R|4@aj zKv9rMOiU01IiNKrGh+iM#KZ&$1t%-utbt%a(iIcDUT+Y@?+RG@(_qK1ZluW?g8}n_ zSu;U^)d5DIJp%$v2hzac(|!p53yijhirbI0@EY~+Xb6mkz-S1JhQMeD42KXPwp|Hp aufy7v2tK*($_LPPC9Ee63(bLTR{{WsPk}`M literal 0 HcmV?d00001 diff --git a/opencsp/app/sofast/lib/test/data/input/ServerState/projector.h5 b/opencsp/app/sofast/lib/test/data/input/ServerState/projector.h5 new file mode 100644 index 0000000000000000000000000000000000000000..364b009e49ffc660761871a1e92caeb1f2d01e5f GIT binary patch literal 22560 zcmeI2zi-n(6vr<~3!xGsNDOU3V7Br@P<{wH7STeG5UqfL%?Z?{M){@25iL{3vSnb% zz>tBV149P>fDQ~D7&X59YmoIX^FCCb!Qe z-6lGt+DdZ(<%uGYoqx2b223*S>S>#F#xR~0^Y5+2)$#}Honx0vuJ%;jEGHbY+)uyR zlx}E+bgA{5+E>3bB-|XmfAg+3+T^!yXu}{M#qlD*`pwZoIZNf#^kB#N>czsG(lD=^ z2I-d9>!6MY+ne~do)DvYEg^7BlsTs4ilpp`M8bW-si&x})8t3x3@AUD)ZsL(V5vm? zjHaUQ=)Ib~&d}LfS1GZ}J$t}L|8j~zO`Yyv&(RzijcXh^-sJ^3JR_^es1QAUyztyb zMRFi)LglHeS}Yd|=5l$}td(rXbgZgVRdId{(&I?f?2YaT=VQSRHd=s(}BSkGVdJif)6M_7d?;fvF4%A-K519iFo5q zxSFTwyJY=xc-{=DcpjgQYD|RZ8C2bx49|;R zSM6Bq>R=xKd%o=lap4){c^U5WZ3pu>>^|(zSnGplt&}fX%Km7V%&Ps#GS_rX*I)Pi zO1WyQL)a=u)pvcF!}q$S^t*M`Ue~AgD*8ybJoi@2ubJ|2n|>SN#h3C<-gMS0ZudlW z#a?#IRcnbl==r)q?Y}M5XaqgJn_#PR&4b#NO{ozrn-D<4>%4;5_=ES7@{k0w4eaAOHd&00JNY0w4ea oAOHeKPXPb_AN?Gl2Ot0fAOHd&00JNY0w4eaAOHd&FlYk*0PhqEF8}}l literal 0 HcmV?d00001 diff --git a/opencsp/app/sofast/lib/test/data/input/ServerState/spatial_orientation.h5 b/opencsp/app/sofast/lib/test/data/input/ServerState/spatial_orientation.h5 new file mode 100644 index 0000000000000000000000000000000000000000..d68195434d90647d126383e88977b9a53b4a3cc0 GIT binary patch literal 5024 zcmeD5aB<`1lHy_j0S*oZ76t(@6Gr@pf(6155f~pPp8#brLg@}Dy@CnCU}OM61_lYJ zxFFPgbaf#?uC5F~l`!*RG*lbI16Bx&112y^kEjsvaCHm-c{l>0CFbM(l z^y>iiR|hoxz`R)$pPZN*Uz}W&nhH+2Wk`Gw6Q(Y|pd>RnJ|CRSQ&T`CCj_9UuV6oa z7f>38m75CCbPH3Do{nMWkJ6|iz{tP~31eu$F+yuJMn;GLG*}rBv;>U_P zxEW0TLlv+BML{~5n3%u@a6rvtW^CYu*o+>Nuo@H9T?55~q$_3y1E>Qg2;p}LEd6P) z<5vgsb%P*&b;CvWz*oa>3!Uce|C?>#cr5jr{kj!!^0G(8VI{*Lx literal 0 HcmV?d00001 diff --git a/opencsp/app/sofast/lib/test/data/input/ServerState/surface_shape.h5 b/opencsp/app/sofast/lib/test/data/input/ServerState/surface_shape.h5 new file mode 100644 index 0000000000000000000000000000000000000000..3c6b903ae273e7c6c7ec627b561be23101fa57b2 GIT binary patch literal 10624 zcmeHLy-or_5T4^Fo+d_63$U`pH?Wi_NI`@ECU$rr3JC&o5T&Dp!i3V2($dGU^bvdr zd)?Wc5rYAOi3#W|xt+P$*}a`_KlbLfpE!!IukEY>7TX3N1hmT4JC*L9vG6^&pOa#d z?2&x*7{vno0ffjNr18i7a+z2RlqZkRlDfgG-)J@)ME)xT60v9+13sgunNQEeGX^8= zG@WZV+iqSu#gZ~IFAvyZPC1Fc39lKbOuU@M-+dyk4r0zZi2L#*!2ErpP|isXw|+Oa z>`CefIn4X!F7cNC7vnts-PtVf=oa|RUP8cG^hUERW8iIwrB}V@uqB{?_vx^Ab?sLx zfp(9_qdVF(j@Rn0YKPC2GIsz2Ru)PcUisjFW;NKHK0W4NWM#Tvgqpj->3+PArnjg2 zEqd#lVgX)&85lC48#Ytc>@cd-f`A|(2nYg#fFSU<5cr-~noNmb|6Yjbd@-aFOSEA= zY1(XvN0V7~Xac#+DGJ>%Ilt^te#s;29;)iGvJi4M9`okaeTv&;;iDjjBXtS1O0CkW zIMw`Ry{PJHsdm*WyZO5gPrX?$wB43!a8&Kyw4G+j#kPz3ZOwJA8&y0EQN=YUBl%XR zp)Je?&Zmvn&+FOi7t;Ic<%rJ literal 0 HcmV?d00001 diff --git a/opencsp/app/sofast/lib/test/test_ServerState.py b/opencsp/app/sofast/lib/test/test_ServerState.py new file mode 100644 index 000000000..61745de3b --- /dev/null +++ b/opencsp/app/sofast/lib/test/test_ServerState.py @@ -0,0 +1,198 @@ +""" +Unit test suite to test ServerState class +""" + +from concurrent.futures import ThreadPoolExecutor +import os +import time +import unittest + +import numpy as np +import pytest + +from opencsp.app.sofast.lib.DefinitionFacet import DefinitionFacet +from opencsp.app.sofast.lib.DisplayShape import DisplayShape +from opencsp.app.sofast.lib.ServerState import ServerState +from opencsp.app.sofast.test.test_DisplayShape import TestDisplayShape as t_display +import opencsp.app.sofast.lib.Executor as executor +from opencsp.app.sofast.lib.Fringes import Fringes +from opencsp.app.sofast.lib.ImageCalibrationGlobal import ImageCalibrationGlobal +import opencsp.app.sofast.lib.SpatialOrientation as so +from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe +import opencsp.app.sofast.test.ImageAcquisition_no_camera as ianc +from opencsp.common.lib.camera.Camera import Camera +from opencsp.common.lib.camera.test.test_Camera import TestCamera as t_camera +from opencsp.common.lib.camera.ImageAcquisitionAbstract import ImageAcquisitionAbstract +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +from opencsp.common.lib.deflectometry.Surface2DAbstract import Surface2DAbstract +from opencsp.common.lib.deflectometry.Surface2DParabolic import Surface2DParabolic +from opencsp.common.lib.deflectometry.Surface2DPlano import Surface2DPlano +from opencsp.common.lib.deflectometry.test.test_ImageProjection import _ImageProjection as _ip +from opencsp.common.lib.geometry.Vxyz import Vxyz +from opencsp.common.lib.opencsp_path import opencsp_settings +import opencsp.common.lib.opencsp_path.opencsp_root_path as orp +import opencsp.common.lib.process.ControlledContext as cc +import opencsp.common.lib.tool.exception_tools as et +import opencsp.common.lib.tool.file_tools as ft +import opencsp.common.lib.tool.log_tools as lt + + +@pytest.mark.no_xvfb +class TestServerState(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + # Get data directories + path, _, _ = ft.path_components(__file__) + cls.data_dir = os.path.join(path, "data", "input", "ServerState") + cls.out_dir = os.path.join(path, "data", "output", "ServerState") + ft.create_directories_if_necessary(cls.data_dir) + ft.create_directories_if_necessary(cls.out_dir) + + def setUp(self): + # make sure we can load everything + sofast_settings = opencsp_settings["sofast_defaults"] + sofast_settings["projector_file"] = os.path.join(self.data_dir, "projector.h5") + sofast_settings["calibration_file"] = os.path.join(self.data_dir, "calibration.h5") + sofast_settings["mirror_measure_point"] = [0, 0, 0] + sofast_settings["mirror_screen_distance"] = 10 + sofast_settings["camera_calibration_file"] = os.path.join(self.data_dir, "camera_calibration.h5") + sofast_settings["fixed_pattern_diameter_and_spacing"] = [10, 5] + sofast_settings["spatial_orientation_file"] = os.path.join(self.data_dir, "spatial_orientation.h5") + sofast_settings["display_shape_file"] = os.path.join(self.data_dir, "display_shape.h5") + sofast_settings["facet_definition_files"] = [os.path.join(self.data_dir, "facet_definition.h5")] + sofast_settings["surface_shape_file"] = os.path.join(self.data_dir, "surface_shape.h5") + sofast_settings["num_fringe_periods"] = [4, 4] + + # build the default camera + self.fringes = Fringes.from_num_periods() + ianc.ImageAcquisitionWithFringes(self.fringes) + + # build the default server state instance + ServerState() + + def tearDown(self): + with et.ignored(Exception): + ImageAcquisitionAbstract.instance().close() + with et.ignored(Exception): + ImageProjection.instance().close() + with et.ignored(Exception): + with ServerState.instance() as state: + state.close_all() + + def _wait_on_state_busy(self, desired_busy_signal: bool): + tstart = time.time() + while time.time() < tstart + 15: + time.sleep(0.1) + with ServerState.instance() as state: + if state.busy == desired_busy_signal: + return + lt.error_and_raise(RuntimeError, f"Timed out while waiting for busy == {desired_busy_signal}") + + # def test_initial_state(self): + # with ServerState.instance() as state: + # self.assertFalse(state.is_closed) + # self.assertFalse(state.busy) + # self.assertTrue(state.projector_available) + + # self.assertFalse(state.has_fixed_measurement) + # self.assertIsNone(state.last_measurement_fixed) + # # TODO add test data for fixed and uncomment + # # self.assertIsNotNone(state.system_fixed) + # # with state.system_fixed as sys: + # # self.assertIsNotNone(sys) + # # self.assertIsInstance(sys, SystemSofastFixed) + + # self.assertFalse(state.has_fringe_measurement) + # self.assertIsNone(state.last_measurement_fringe) + # self.assertIsNotNone(state.system_fringe) + # with state.system_fringe as sys: + # self.assertIsNotNone(sys) + # self.assertIsInstance(sys, SystemSofastFringe) + + # self.assertIsNone(state.processing_error) + + def _test_start_measure_fringes(self): + """ + This is a fairly large test that checks the state values of the state instance before, during, and after a + fringe measurement. + + There are several parts to this test: + 1. Check the state before the measurement + 2. Start the measurement + 3. Check the state during the measurement + 4. Check the state after the measurement + 5. Start a new measurement + 6. Check the state during a 2nd measurement + """ + try: + # 1. check the initial state + with ServerState.instance() as state: + self.assertFalse(state.busy) + self.assertFalse(state.has_fringe_measurement) + self.assertTrue(state.projector_available) + + # 2. start the measurement + with ServerState.instance() as state: + self.assertTrue(state.start_measure_fringes()) + + # wait for the measurement to start + self._wait_on_state_busy(True) + + # 3. check that the state changed to reflect that a measurement is running + with ServerState.instance() as state: + self.assertTrue(state.busy) + self.assertFalse(state.projector_available) + self.assertFalse(state.has_fringe_measurement) + self.assertIsNone(state.last_measurement_fringe) + # # starting a new measurement while the current measurement is running should fail + # self.assertFalse(state.start_measure_fringes()) + + # wait for the measurement to finish + self._wait_on_state_busy(False) + + # 4. check that the measurement finished and we have some results + with ServerState.instance() as state: + self.assertFalse(state.busy) + self.assertTrue(state.has_fringe_measurement) + self.assertIsNotNone(state.last_measurement_fringe) + + # 5. start a 2nd measurement + with ServerState.instance() as state: + self.assertTrue(state.start_measure_fringes()) + + # wait for the measurement to start + self._wait_on_state_busy(True) + + # 6. check that the state changed to reflect that a measurement is running + with ServerState.instance() as state: + self.assertTrue(state.busy) + self.assertFalse(state.projector_available) + self.assertFalse(state.has_fringe_measurement) + self.assertIsNone(state.last_measurement_fringe) + # starting a new measurement while the current measurement is running should fail + self.assertFalse(state.start_measure_fringes()) + + # wait for the measurement to finish + self._wait_on_state_busy(False) + + except Exception as ex: + lt.error(ex) + raise + + finally: + # close the ImageProjection instance in order to exit the main loop + ImageProjection.instance().close() + + def test_start_measure_fringes(self): + pool = ThreadPoolExecutor(max_workers=1) + result = pool.submit(self._test_start_measure_fringes) + + # check if we succeeded + ex = result.exception() + if ex is not None: + raise ex + + +if __name__ == '__main__': + lt.logger(level=lt.log.DEBUG) + unittest.main() From d0cb901ab64d4f8fbc17383d411bcf3db95c253e Mon Sep 17 00:00:00 2001 From: bbean Date: Fri, 12 Apr 2024 14:49:28 -0600 Subject: [PATCH 17/17] formatting --- opencsp/app/sofast/lib/Executor.py | 3 ++- opencsp/common/lib/process/ControlledContext.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/opencsp/app/sofast/lib/Executor.py b/opencsp/app/sofast/lib/Executor.py index e65ac5e13..20dba889f 100644 --- a/opencsp/app/sofast/lib/Executor.py +++ b/opencsp/app/sofast/lib/Executor.py @@ -77,7 +77,8 @@ def __init__(self, asynchronous_processing=True): # tkinter thread if ImageProjection.instance() is None: lt.error_and_raise( - RuntimeError, "Error in Executor(): ImageProjection must exist before initializing Executor instance.") + RuntimeError, "Error in Executor(): ImageProjection must exist before initializing Executor instance." + ) ImageProjection.instance().root.after(100, self._on_tick) self.tasks: queue.Queue[Callable] = queue.Queue() diff --git a/opencsp/common/lib/process/ControlledContext.py b/opencsp/common/lib/process/ControlledContext.py index fc9b5be44..01346a0f8 100644 --- a/opencsp/common/lib/process/ControlledContext.py +++ b/opencsp/common/lib/process/ControlledContext.py @@ -45,8 +45,9 @@ async def _acquire_with_timeout(self): def __enter__(self): asyncio.run(self._acquire_with_timeout()) if self.timed_out: - lt.error_and_raise(asyncio.TimeoutError, - f"Failed to acquire lock for {self.o} within {self.timeout} seconds") + lt.error_and_raise( + asyncio.TimeoutError, f"Failed to acquire lock for {self.o} within {self.timeout} seconds" + ) return self.o def __exit__(self, exc_type, exc_value, traceback):