diff --git a/.gitignore b/.gitignore index 9b75fe4..a9cbafb 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,31 @@ dmypy.json .pytype/ # Cython debug symbols -cython_debug/ \ No newline at end of file +cython_debug/ + +.vscode/ + +temp.py +temp2.py +to_save.yaml +to_load.yaml +out.json +dashapp.py +dashapp-tut.py +data.csv +e1.json +e1.yaml +e1mongo.yaml +e2.yaml +e3.yaml +e4.yaml +e4mongo.yaml +project_const.py +pw.py +.DS_Store +blank.yaml +pw.txt +ximea_linux_sp_beta.tgz +package +firmware/ +to_implement/ diff --git a/README.md b/README.md index 53b9bd0..3b17e50 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,16 @@ Lab Automation is a modular framework for automating multiple devices or instrum 4. [Usage](#usage) - [Automation](#automation) - [Optimization](#autonomous-process-optimization) -5. [Further Details](#further-details) +5. [Dash UI](#dash-ui) + - [UI Setup Instructions](#ui-setup-instructions) + - [Adding Recipes for the Recipe Builder](#adding-recipes-for-the-recipe-builder) + - [Next Steps](#next-steps) +6. [Further Details](#further-details) - [Creating Device Modules](#creating-device-modules) - [Creating Command Modules](#creating-command-modules) - [Composite Commands](#composite-commands) - [Program Scheme](#program-scheme) -6. [License](#license) +7. [License](#license) ## Description Lab Automation was developed for the purpose of automating laboratory experiments to enable high-throughput data collection and process optimization. It can be used to automate experimental procedures that involve instruments from different vendors, with different communication protocols, and also home-built equipment. The automated procedures can then be tied into sequential model-based optimization algorithms (e.g. Bayesian optimization) to enable self-driving, autonomous lab experiments. @@ -111,6 +115,96 @@ Example 6 emulates optimization of a material processing experiment. It uses a f You will need some more packages to run example 6 (listed above). Run example 6 as stated above from root. +## Dash UI +The entire functionality of the app can also be accessed through a user-friendly Dash app interface. This includes creating recipes, running them, granulated manual control, and new features involved in automated process optimization, such as parameter set sampling, the recipe builder, and the solution mapper. + +### UI Setup Instructions + +1. Clone this repository. +``` +git clone https://github.com/moleculemaker/Lab_Automation +``` +2. Enter the Lab Automation folder. +``` +cd Lab_Automation +``` +3. Check out the dash-ui branch. +``` +git checkout dash-ui +``` +4. Create a virtual environment. +``` +python3 -m venv venv +``` +5. Activate the virtual environment. + +Mac: +``` +source venv/bin/activate +``` +Windows: +``` +venv\Scripts\activate +``` +6. Install the requirements. +``` +pip install -r requirements.txt +``` +7. Add a .env file to the aamp_app folder and aamp_app/db/validation containing your MongoDB credentials. The files should be in the following format: +``` +MONGO_URI=mongodb+srv://: +MONGO_DB_NAME=diaogroup +``` +8. Enter the aamp_app folder. +``` +cd aamp_app +``` +9. When running the app for the first time with an empty database, the correct collections with validation schemas need to be created. Uncomment the following lines in app.py: +``` +# solutions.init_collection() +# devices.init_collection() +# films.init_collection() +# recipes.init_collection() +``` +They can be commented again once the collections are created in the database. + +10. Run the following: +``` +pip install --upgrade dotenv +plotly_get_chrome +``` + +11. Run the app +``` +python -m app +``` + +### Adding Recipes for the Recipe Builder +Right now the recipe builder is formatted to work with one recipe template, and that is recipe_sample.py. A keyword search for this in the app.py file will reveal the place where it is being read in and saved as a constant string literal. To add a new recipe: +1. Include it as a Python file in the recipes directory. +2. Replace all literals for template variables where appropriate (concentration, printing gap, arm position, etc.). You can refer to recipe_sample.py for an example implementation of this. Adding additional template variables requires modification of the generate_recipes callback in app.py. +3. Change the filename being read into app.py from recipe_sample.py to your new recipe's filename. +4. Ensure that the generate_recipes callback runs without error with the new recipe. +5. By default, all recipes are ignored. If you want git to track the new recipe, add !\.py to the .gitignore file in the recipes folder. + +### Next Steps + +There are a number of pending tasks required for the Dash UI and automated optimization workflow to be fully operational: +- Send experimental results to the Bayesian optimizer +- Receive parameters from the optimizer in the recipe builder, either through database triggers or directly in the app +- Validate custom inputs in the sampler +- Add different sampling methods to the sampler (currently uses simple random sampling) +- Allow a campaign to have more than one polymer +- Attach polymer information to each parameter set rather than to campaign metadata +- Validate the solution map JSON +- Add a way to update or include more recipe templates +- Get the Ximea Camera module working on Mac and Linux devices +- Test running recipes sequentially in the recipe builder +- Visualize parameter space exploration over the course of a campaign +- Visualize optimal parameter ranges over the course of a campaign +- Track campaign progress in the UI, along with logging and error tracking +- Merge the dash-ui branch to master once a full campaign can be run through the UI (and most of the above objectives are complete) + ## Further Details ### Creating Device Modules Please see the devices folder for examples of implementing device modules diff --git a/aamp_app/Image_Processing/Image_processing/__init__.py b/aamp_app/Image_Processing/Image_processing/__init__.py new file mode 100644 index 0000000..f4c9e10 --- /dev/null +++ b/aamp_app/Image_Processing/Image_processing/__init__.py @@ -0,0 +1,18 @@ +"""Custom image processing pipeline for coverage analysis.""" + +from .config import UniformityConfig +from .coverage import analyze_film_coverage +from .pipeline import analyze_image +from .scoring import compute_uniformity_score, _match_reference_for +from .uvvis import analyze_uvvis_data, find_uvvis_file + +__all__ = [ + "UniformityConfig", + "analyze_film_coverage", + "analyze_image", + "compute_uniformity_score", + "_match_reference_for", + "analyze_uvvis_data", + "find_uvvis_file", +] + diff --git a/aamp_app/Image_Processing/Image_processing/config.py b/aamp_app/Image_Processing/Image_processing/config.py new file mode 100644 index 0000000..ba36c9b --- /dev/null +++ b/aamp_app/Image_Processing/Image_processing/config.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional +from bson import ObjectId + + +@dataclass +class UniformityConfig: + """Lightweight configuration object. + + Maintains compatibility with the existing imageproc.uniformity.config interface + by keeping the same field names. + """ + + campaign_id: Optional[ObjectId] = None + use_reference: bool = True + reference_name: str = "background_normal_0deg" + + roi_x: int = 0 + roi_y: int = 0 + roi_width: int = 100 + roi_height: int = 100 + + uvvis_abs_threshold: float = 0.1 + uvvis_wavelength_min: float = 300.0 + uvvis_wavelength_max: float = 800.0 + uvvis_required: bool = False + + coverage_threshold: float = 0.1 + coverage_smoothing: float = 1.5 + # Sigma multiplier (K value) used in HSV S channel threshold calculation. Adjust default value here if needed. + coverage_k: float = 3.0 + coverage_min_std: float = 10.0 + + # Uniformity calculation sensitivity parameters (for Reference-based Absolute Scoring) + uniformity_std_sensitivity: float = 15.0 # K1: Sensitivity to Std difference + uniformity_ent_sensitivity: float = 2.0 # K2: Sensitivity to Entropy difference + + compute_learning_features: bool = False + + extra: dict = field(default_factory=dict) + + @property + def roi_bbox(self) -> tuple[int, int, int, int]: + return (self.roi_x, self.roi_y, self.roi_width, self.roi_height) + diff --git a/aamp_app/Image_Processing/Image_processing/coverage.py b/aamp_app/Image_Processing/Image_processing/coverage.py new file mode 100644 index 0000000..c4c7850 --- /dev/null +++ b/aamp_app/Image_Processing/Image_processing/coverage.py @@ -0,0 +1,149 @@ +"""HSV-based film coverage analysis module. + +Instead of the traditional reference subtraction approach, +this module calculates coverage by combining dynamic thresholds +based on reference image S-channel statistics with sample image HSV information. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Tuple, Dict + +import cv2 +import numpy as np +from skimage.measure import shannon_entropy + + +@dataclass +class ROIParams: + x: int + y: int + width: int + height: int + + +def _clamp_roi(image: np.ndarray, roi: ROIParams) -> ROIParams: + """Clamp ROI to ensure it stays within image boundaries.""" + h, w = image.shape[:2] + x = max(0, min(roi.x, w - 1)) + y = max(0, min(roi.y, h - 1)) + width = max(1, min(roi.width, w - x)) + height = max(1, min(roi.height, h - y)) + return ROIParams(x, y, width, height) + + +def _extract_roi(image: np.ndarray, roi: ROIParams) -> np.ndarray: + """Extract the image region corresponding to the clamped ROI.""" + roi = _clamp_roi(image, roi) + return image[roi.y:roi.y + roi.height, roi.x:roi.x + roi.width] + + +def _ensure_same_shape(sample_roi: np.ndarray, reference_roi: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Resize reference ROI if sample and reference ROI sizes differ.""" + if sample_roi.shape[:2] == reference_roi.shape[:2]: + return sample_roi, reference_roi + resized_ref = cv2.resize(reference_roi, (sample_roi.shape[1], sample_roi.shape[0])) + return sample_roi, resized_ref + + +def _apply_mask_smoothing(mask: np.ndarray, sigma: float) -> np.ndarray: + """Apply Gaussian blur to reduce noise.""" + if sigma <= 0: + return mask + blurred = cv2.GaussianBlur(mask, (0, 0), sigma) + return (blurred > 127).astype(np.uint8) * 255 + + +def analyze_film_coverage( + image: np.ndarray, + reference: np.ndarray, + roi_x: int, + roi_y: int, + roi_width: int, + roi_height: int, + smoothing: float = 1.5, + k: float = 3.0, + min_std: float = 10.0, +) -> Tuple[float, np.ndarray, np.ndarray, np.ndarray, Dict[str, float]]: + """HSV-based film coverage analysis. + + Args: + image: Sample BGR image + reference: Reference BGR image + roi_x, roi_y, roi_width, roi_height: ROI coordinates + smoothing: Gaussian sigma for mask post-processing + k: Standard deviation multiplier for dynamic threshold + min_std: Minimum value to use when standard deviation is 0 + + Returns: + (coverage_pct, sample_roi_bgr, reference_roi_bgr, final_mask, debug_info) + """ + roi = ROIParams(roi_x, roi_y, roi_width, roi_height) + sample_roi = _extract_roi(image, roi) + reference_roi = _extract_roi(reference, roi) + sample_roi, reference_roi = _ensure_same_shape(sample_roi, reference_roi) + + # Convert to HSV + sample_hsv = cv2.cvtColor(sample_roi, cv2.COLOR_BGR2HSV) + reference_hsv = cv2.cvtColor(reference_roi, cv2.COLOR_BGR2HSV) + + # Reference S channel statistics + ref_s = reference_hsv[:, :, 1].astype(np.float32) + ref_s_mean = float(np.mean(ref_s)) + ref_s_std = float(np.std(ref_s)) + effective_std = ref_s_std if ref_s_std > 0 else float(min_std) + saturation_threshold = float(np.clip(ref_s_mean + k * effective_std, 0, 255)) + + # Condition A: S channel dynamic threshold + sample_s = sample_hsv[:, :, 1].astype(np.float32) + condition_a = sample_s > saturation_threshold + + # Condition B: V channel Otsu (dark regions) + sample_v = sample_hsv[:, :, 2].astype(np.uint8) + otsu_threshold, otsu_mask = cv2.threshold( + sample_v, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU + ) + condition_b = otsu_mask > 0 + + # Final mask + final_mask = np.logical_or(condition_a, condition_b).astype(np.uint8) * 255 + final_mask = _apply_mask_smoothing(final_mask, smoothing) + + total_pixels = final_mask.size + film_pixels = int(np.sum(final_mask > 0)) + coverage_percentage = float(film_pixels) / float(total_pixels) if total_pixels else 0.0 + + # Reference statistics (for Uniformity calculation) + ref_s_uint8 = reference_hsv[:, :, 1].astype(np.uint8) + ref_entropy = float(shannon_entropy(ref_s_uint8)) + + # Sample statistics (for Uniformity calculation) + sample_s_uint8 = sample_hsv[:, :, 1].astype(np.uint8) + sample_std = float(np.std(sample_s_uint8.astype(np.float32))) + sample_entropy = float(shannon_entropy(sample_s_uint8)) + + debug_info: Dict[str, float] = { + "coverage_percentage": coverage_percentage, + "roi_width": float(sample_roi.shape[1]), + "roi_height": float(sample_roi.shape[0]), + "ref_s_mean": ref_s_mean, + "ref_s_std": ref_s_std, + "effective_std": effective_std, + "saturation_threshold": saturation_threshold, + "k_value": k, + "min_std_floor": min_std, + "otsu_threshold": float(otsu_threshold), + "condition_a_pixels": int(np.sum(condition_a)), + "condition_b_pixels": int(np.sum(condition_b)), + "film_pixels": film_pixels, + "total_pixels": int(total_pixels), + # Reference statistics (for Uniformity calculation) + "ref_std": ref_s_std, + "ref_entropy": ref_entropy, + "sample_std": sample_std, + "sample_entropy": sample_entropy, + } + + return coverage_percentage, sample_roi, reference_roi, final_mask, debug_info + diff --git a/aamp_app/Image_Processing/Image_processing/pipeline.py b/aamp_app/Image_Processing/Image_processing/pipeline.py new file mode 100644 index 0000000..e3f5744 --- /dev/null +++ b/aamp_app/Image_Processing/Image_processing/pipeline.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Tuple + +import numpy as np + +from .config import UniformityConfig +from .coverage import analyze_film_coverage +from .scoring import analyze_uniformity +from .uvvis import analyze_uvvis_data + + +def analyze_image( + image_name, + fs, + image_bgr: np.ndarray, + cfg: UniformityConfig, + ref_bgr: np.ndarray | None = None, +) -> Tuple[ + Dict[str, Any], + np.ndarray | None, + np.ndarray | None, + np.ndarray | None, + Dict[str, Any] | None, +]: + """Simplified coverage analysis pipeline.""" + + sample_roi = None + reference_roi = None + coverage_mask = None + coverage_debug: Dict[str, Any] | None = None + + metrics: Dict[str, Any] = { + "coverage_percentage": None, + "coverage_threshold_met": False, + "sample_status": "reference_missing" if cfg.use_reference else "reference_not_required", + } + + if image_bgr is None: + metrics["sample_status"] = "image_not_loaded" + return metrics, sample_roi, reference_roi, coverage_mask, coverage_debug + + if cfg.use_reference and ref_bgr is None: + metrics["coverage_reason"] = "reference_not_found" + return metrics, sample_roi, reference_roi, coverage_mask, coverage_debug + + uvvis_stats: Dict[str, Any] | None = None + if image_name is not None: + uvvis_passed, uvvis_stats = analyze_uvvis_data( + image_name=image_name, + fs=fs, + campaign_id=cfg.campaign_id, + abs_threshold=cfg.uvvis_abs_threshold, + wavelength_min=cfg.uvvis_wavelength_min, + wavelength_max=cfg.uvvis_wavelength_max, + ) + metrics["uvvis_passed"] = uvvis_passed + metrics["uvvis_reason"] = uvvis_stats.get("uvvis_reason") if uvvis_stats else None + + reference = ref_bgr if ref_bgr is not None else image_bgr + + coverage_pct, sample_roi, reference_roi, mask, debug_info = analyze_film_coverage( + image=image_bgr, + reference=reference, + roi_x=cfg.roi_x, + roi_y=cfg.roi_y, + roi_width=cfg.roi_width, + roi_height=cfg.roi_height, + smoothing=cfg.coverage_smoothing, + k=cfg.coverage_k, + min_std=cfg.coverage_min_std, + ) + + coverage_mask = mask + coverage_debug = debug_info + + threshold_met = coverage_pct >= cfg.coverage_threshold + status = "coverage_pass" if threshold_met else "coverage_fail" + comparator = ">=" if threshold_met else "<" + + metrics = { + "coverage_percentage": coverage_pct, + "coverage_threshold_met": threshold_met, + "sample_status": status, + "coverage_reason": f"coverage {coverage_pct:.3f} {comparator} threshold {cfg.coverage_threshold:.3f}", + } + + for key, value in debug_info.items(): + metrics[f"coverage_{key}"] = value + + # Uniformity Score calculation (Reference-based Absolute Scoring) + # ref_stats is already included in debug_info (calculated in coverage.py) + # May return None if reference is not available + ref_stats = { + "ref_std": debug_info.get("ref_std"), # May be None + "ref_entropy": debug_info.get("ref_entropy"), # May be None + "sample_std": debug_info.get("sample_std", 0.0), + "sample_entropy": debug_info.get("sample_entropy", 0.0), + } + + # Use ideal reference if reference is not available + use_model_ref = (ref_stats.get("ref_std") is None) or (not cfg.use_reference or ref_bgr is None) + + if sample_roi is not None: + uniformity_score, uniformity_debug = analyze_uniformity(ref_stats, cfg, use_model_reference=use_model_ref) + metrics["uniformity_score"] = uniformity_score + for key, value in uniformity_debug.items(): + metrics[f"uniformity_{key}"] = value + # Also add to coverage_debug + if coverage_debug is not None: + coverage_debug.update(uniformity_debug) + else: + # Set default value if sample ROI is not available + metrics["uniformity_score"] = None + if coverage_debug is not None: + coverage_debug["uniformity_score"] = None + + if uvvis_stats: + metrics.update(uvvis_stats) + + return metrics, sample_roi, reference_roi, coverage_mask, coverage_debug + diff --git a/aamp_app/Image_Processing/Image_processing/run_active_learning_v4.py b/aamp_app/Image_Processing/Image_processing/run_active_learning_v4.py new file mode 100644 index 0000000..8076878 --- /dev/null +++ b/aamp_app/Image_Processing/Image_processing/run_active_learning_v4.py @@ -0,0 +1,1252 @@ +#!/usr/bin/env python3 +""" +Active Learning Script for PProDOT Film Optimization (v4). + +Phase 3: Active Learning & Mapping +- Step 1: Repeated Stratified 5-Fold CV for model validation +- Step 2: Uncertainty-based active learning suggestion + +v3 Changes: +- CV predictions are now clipped to match training data clipping + (pred_abs clipped to [0, 2.0], pred_cov clipped to [0, 1.0]) + This ensures consistency between training data and predictions for R² calculation. + +v4 Changes: +- Added predicted values (pred_abs, pred_cov, pred_uni) to suggestions CSV +- Added uncertainty values (sigma_abs, sigma_cov, sigma_uni) to suggestions CSV +- Column order: score, p_valid, score_raw, predictions, uncertainties, weights, contributions, etc. +""" + +import torch +import numpy as np +import pandas as pd +from pathlib import Path +from typing import Tuple, Dict, List, Optional +from scipy.stats import norm +import random +import warnings +warnings.filterwarnings('ignore') + +# BoTorch imports +from botorch.models import SingleTaskGP +from botorch.fit import fit_gpytorch_mll +from botorch.optim import optimize_acqf +from botorch.utils.transforms import normalize, unnormalize +from botorch.utils.sampling import draw_sobol_samples + +# GPyTorch imports +from gpytorch.mlls import ExactMarginalLogLikelihood +from gpytorch.kernels import ScaleKernel, RBFKernel +from gpytorch.priors import GammaPrior + +# Scikit-learn imports +from sklearn.model_selection import RepeatedStratifiedKFold +from sklearn.metrics import r2_score, mean_squared_error, accuracy_score + + +# ============================================================================ +# Configuration +# ============================================================================ + +# Parameter bounds (original scale) - Same as constrained_bo_ver2.py +BOUNDS_ORIGINAL = torch.tensor([ + [0.01, 20.0], # Speed + [25.0, 107.0], # Temperature + [50.0, 200.0], # Gap + [5.0, 15.0] # Volume +]).T + +# Log scale parameters +LOG_SCALE_PARAMS = [True, False, False, False] # Only Speed is log scale + +# UV-Vis clipping threshold +UVVIS_CLIP_THRESHOLD = 2.0 + +# Valid thresholds (Phase 3: Mapping focus - lower threshold for coverage) +VALID_ABS_THRESHOLD = 0.1 +VALID_COV_THRESHOLD = 0.1 # Phase 3: Any film formation is considered valid for mapping +UVVIS_THRESHOLD = VALID_ABS_THRESHOLD # Alias for backward compatibility +COVERAGE_THRESHOLD = VALID_COV_THRESHOLD # Alias for backward compatibility + +# Hard cutoff for P(valid) in active learning +HARD_CUTOFF_P_VALID = 0.05 + +# CV settings +N_SPLITS = 5 +N_REPEATS = 5 + +# Active Learning settings +SOBOL_GRID_SIZE = 3000 +BATCH_SIZE = 8 + +UNI_CLIP_PERCENTILE = 95 # Clip σ_Uni at 95th percentile + +# Output Settings +SOLVENT = "CB" +CONCENTRATION = 40 + +# Random Seed +SEED = 42 + + +# ============================================================================ +# Data Loading & Preprocessing +# ============================================================================ + +def load_and_preprocess_data(csv_path: Path, round_num: Optional[int] = None) -> Tuple[pd.DataFrame, int, torch.Tensor]: + """ + Load CSV and preprocess data. + Also loads previous round suggestions for round-level diversity. + + Args: + csv_path: Path to CSV file + round_num: Round number to use (uses data from rounds <= round_num). + If None, uses maximum round in CSV. + + Returns: + df: Preprocessed DataFrame with Valid column (filtered by round_num if specified) + current_round: Round number used (round_num if specified, else max round in data) + X_prev_suggestions: Previous round suggestions as tensor (n_prev, 4) or None + """ + print("=" * 60) + print("[Data] Loading and preprocessing...") + print("=" * 60) + + if not csv_path.exists(): + raise FileNotFoundError(f"CSV file not found: {csv_path}") + + df = pd.read_csv(csv_path) + print(f" Loaded {len(df)} rows from {csv_path}") + + # Filter by round number if specified (similar to constrained_bo_ver2.py) + if round_num is not None and 'round#' in df.columns: + df = df[df['round#'] <= round_num].copy() + if len(df) == 0: + raise ValueError(f"No data found for round {round_num} in {csv_path}") + rounds_used = sorted(df['round#'].unique().tolist()) + print(f" Using data from rounds: {rounds_used} (round_num={round_num})") + if -1 in rounds_used: + print(f" (Includes historical data from round -1)") + current_round = round_num + elif 'round#' in df.columns: + current_round = int(df['round#'].max()) + print(f" Current round (auto-detected): {current_round}") + else: + current_round = 0 + print(f" Warning: No 'round#' column found, assuming round 0") + + # Clip uvvis_max_abs to 2.0 + if 'uvvis_max_abs' in df.columns: + # Drop NaNs in uvvis_max_abs + n_nans = df['uvvis_max_abs'].isna().sum() + if n_nans > 0: + print(f" Dropping {n_nans} rows with NaN uvvis_max_abs") + df = df.dropna(subset=['uvvis_max_abs']) + + n_clipped = (df['uvvis_max_abs'] > UVVIS_CLIP_THRESHOLD).sum() + if n_clipped > 0: + print(f" Clipping {n_clipped} uvvis_max_abs values > {UVVIS_CLIP_THRESHOLD} to {UVVIS_CLIP_THRESHOLD}") + df['uvvis_max_abs'] = df['uvvis_max_abs'].clip(upper=UVVIS_CLIP_THRESHOLD) + + if 'coverage_percentage' in df.columns: + n_nans = df['coverage_percentage'].isna().sum() + if n_nans > 0: + print(f" Dropping {n_nans} rows with NaN coverage_percentage") + df = df.dropna(subset=['coverage_percentage']) + + # Calculate Valid column + if 'Valid' not in df.columns: + df['Valid'] = (df['uvvis_max_abs'] >= UVVIS_THRESHOLD) & (df['coverage_percentage'] >= COVERAGE_THRESHOLD) + print(f" Calculated Valid column: {df['Valid'].sum()}/{len(df)} valid samples ({100*df['Valid'].mean():.1f}%)") + + # Load previous round suggestions for round-level diversity + # Get data directory from CSV path + csv_dir = csv_path.parent + # Check both data_dir and results directory for previous suggestions + results_dir_check = csv_dir / "results" + X_prev_suggestions = None + if current_round > 0: + # Try results directory first, then fall back to data_dir + prev_suggestions_path = results_dir_check / f"round{current_round}_suggestions.csv" + if not prev_suggestions_path.exists(): + prev_suggestions_path = csv_dir / f"round{current_round}_suggestions.csv" + if prev_suggestions_path.exists(): + print(f" Loading previous round suggestions from {prev_suggestions_path}") + prev_df = pd.read_csv(prev_suggestions_path) + + # Extract inputs from previous suggestions (same format as main data) + if all(col in prev_df.columns for col in ['Speed', 'Temperature', 'gap', 'Precursor Volume']): + speed_prev = prev_df['Speed'].values + temp_prev = prev_df['Temperature'].values + gap_prev = prev_df['gap'].values + vol_prev = prev_df['Precursor Volume'].values + + # Convert Speed to log scale + speed_prev_log = np.log10(speed_prev) + + # Stack inputs: [Speed_log, Temperature, gap, Volume] + X_prev_suggestions = torch.tensor( + np.column_stack([speed_prev_log, temp_prev, gap_prev, vol_prev]), + dtype=torch.float64 + ) + print(f" Loaded {len(X_prev_suggestions)} previous round suggestions") + else: + print(f" Warning: Previous suggestions file missing required columns, skipping") + else: + print(f" No previous round suggestions found (expected: {prev_suggestions_path})") + + # Check required columns + required_cols = ['Speed', 'Temperature', 'gap', 'Precursor Volume', + 'uvvis_max_abs', 'coverage_percentage', 'uniformity_score'] + missing_cols = [col for col in required_cols if col not in df.columns] + if missing_cols: + raise ValueError(f"Missing required columns: {missing_cols}") + + return df, current_round, X_prev_suggestions + + +def prepare_inputs_outputs(df: pd.DataFrame) -> Tuple[torch.Tensor, Dict[str, torch.Tensor], torch.Tensor]: + """ + Prepare inputs (X) and outputs (Y) for model training. + + Returns: + X: Input tensor (n_samples, 4) [Speed_log, Temperature, gap, Volume] + Y_dict: Dictionary with Y_abs, Y_cov, Y_uni, Y_valid + valid_mask: Boolean mask for valid samples (for uniformity model) + """ + # Extract inputs + speed = df['Speed'].values + temperature = df['Temperature'].values + gap = df['gap'].values + volume = df['Precursor Volume'].values + + # Convert Speed to log scale + speed_log = np.log10(speed) + + # Stack inputs: [Speed_log, Temperature, gap, Volume] + X = torch.tensor(np.column_stack([speed_log, temperature, gap, volume]), dtype=torch.float64) + + # Extract outputs + Y_abs = torch.tensor(df['uvvis_max_abs'].values, dtype=torch.float64).reshape(-1, 1) + Y_cov = torch.tensor(df['coverage_percentage'].values, dtype=torch.float64).reshape(-1, 1) + + # Uniformity: only valid samples with non-NaN uniformity_score + uniformity_values = df['uniformity_score'].values + valid_mask = (df['Valid'].values) & (~pd.isna(uniformity_values)) + # Ensure valid_mask is 1D boolean array for proper indexing + valid_mask = valid_mask.astype(bool) + + Y_uni = torch.tensor(uniformity_values, dtype=torch.float64).reshape(-1, 1) + Y_valid = torch.tensor(df['Valid'].values, dtype=torch.float64).reshape(-1, 1) + + Y_dict = { + 'abs': Y_abs, + 'cov': Y_cov, + 'uni': Y_uni, + 'valid': Y_valid + } + + return X, Y_dict, valid_mask + + +def create_strata(df: pd.DataFrame) -> np.ndarray: + """ + Create stratification labels (3 groups). + + Groups: + 0: Valid (Abs >= 0.1 & Cov >= 0.1) - successful film + 1: Invalid & Abs >= 0.1 (thick but low coverage/fragmented) + 2: Invalid & Abs < 0.1 (thin film failure) + """ + valid = df['Valid'].values + abs_high = (df['uvvis_max_abs'] >= UVVIS_THRESHOLD).values + + strata = np.zeros(len(df), dtype=int) + strata[valid] = 0 # Valid samples + strata[(~valid) & (abs_high)] = 1 # Invalid but thick + strata[(~valid) & (~abs_high)] = 2 # Invalid and thin + + return strata + + +# ============================================================================ +# GP Model Building +# ============================================================================ + +def build_gp_model(X_normalized: torch.Tensor, Y: torch.Tensor) -> SingleTaskGP: + """ + Build and fit a GP model. + + Args: + X_normalized: Normalized input tensor (n_samples, n_dims) + Y: Output tensor (n_samples, 1) + + Returns: + Fitted GP model + """ + model = SingleTaskGP( + X_normalized, + Y, + covar_module=ScaleKernel( + RBFKernel( + lengthscale_prior=GammaPrior(3.0, 1.0), + ard_num_dims=4 + ), + outputscale_prior=GammaPrior(2.0, 0.5) + ) + ) + + model.train() + mll = ExactMarginalLogLikelihood(model.likelihood, model) + fit_gpytorch_mll(mll) + model.eval() + + return model + + +# ============================================================================ +# Step 1: Cross-Validation +# ============================================================================ + +def run_cross_validation( + X: torch.Tensor, + Y_dict: Dict[str, torch.Tensor], + valid_mask: np.ndarray, + strata: np.ndarray, + bounds: torch.Tensor +) -> Tuple[pd.DataFrame, Dict[str, float]]: + """ + Run Repeated Stratified K-Fold Cross-Validation. + + Returns: + results_df: DataFrame with CV results for all folds + mean_r2_dict: Dictionary with mean R² scores for weighting calculation + """ + print("\n" + "=" * 60) + print("[CV] Running Repeated Stratified 5-Fold CV...") + print("=" * 60) + + # Normalize bounds for log-scale Speed + bounds_normalized = bounds.clone() + for i, is_log in enumerate(LOG_SCALE_PARAMS): + if is_log: + bounds_normalized[0, i] = np.log10(bounds[0, i].item()) + bounds_normalized[1, i] = np.log10(bounds[1, i].item()) + + # Check if we have enough samples per stratum + unique_strata, counts = np.unique(strata, return_counts=True) + min_samples_per_stratum = counts.min() + + if min_samples_per_stratum < N_SPLITS: + print(f" Warning: Minimum samples per stratum ({min_samples_per_stratum}) < n_splits ({N_SPLITS})") + print(f" Falling back to 2-group stratification (Valid/Invalid)") + strata = (strata < 2).astype(int) # Valid=True -> 0, Valid=False -> 1 + + # Initialize CV + rskf = RepeatedStratifiedKFold(n_splits=N_SPLITS, n_repeats=N_REPEATS, random_state=42) + + # Store results + results = [] + + fold_idx = 0 + for train_idx, test_idx in rskf.split(X, strata): + fold_idx += 1 + + # Split data + X_train = X[train_idx] + X_test = X[test_idx] + + Y_abs_train = Y_dict['abs'][train_idx] + Y_abs_test = Y_dict['abs'][test_idx] + + Y_cov_train = Y_dict['cov'][train_idx] + Y_cov_test = Y_dict['cov'][test_idx] + + # Uniformity: only valid samples + valid_train_mask = valid_mask[train_idx] + Y_uni_test = Y_dict['uni'][test_idx] + valid_test_mask = valid_mask[test_idx] + + # Normalize inputs + X_train_norm = normalize(X_train.double(), bounds_normalized.double()) + X_test_norm = normalize(X_test.double(), bounds_normalized.double()) + + # Build and train models + model_abs = build_gp_model(X_train_norm, Y_abs_train) + model_cov = build_gp_model(X_train_norm, Y_cov_train) + + # Uniformity model: only if we have enough valid samples (>= 2) + if valid_train_mask.sum() >= 2: + # Fix: First slice Y_uni by train_idx, then apply valid_train_mask + Y_uni_train_full = Y_dict['uni'][train_idx] # First slice by train_idx + valid_train_mask_tensor = torch.tensor(valid_train_mask, dtype=torch.bool) + X_uni_train = X_train[valid_train_mask_tensor] + Y_uni_train = Y_uni_train_full[valid_train_mask_tensor] # Then apply mask + X_uni_train_norm = normalize(X_uni_train.double(), bounds_normalized.double()) + model_uni = build_gp_model(X_uni_train_norm, Y_uni_train) + else: + model_uni = None + + # Predictions + with torch.no_grad(): + # Absorbance + posterior_abs = model_abs.posterior(X_test_norm) + pred_abs = posterior_abs.mean.squeeze(-1).cpu().numpy() + # Clip predictions to match data clipping (consistency with training data) + pred_abs = np.clip(pred_abs, 0.0, UVVIS_CLIP_THRESHOLD) + + # Coverage + posterior_cov = model_cov.posterior(X_test_norm) + pred_cov = posterior_cov.mean.squeeze(-1).cpu().numpy() + # Clip coverage to [0, 1] for physical consistency + pred_cov = np.clip(pred_cov, 0.0, 1.0) + + # Uniformity (only for valid test samples) + if model_uni is not None and valid_test_mask.sum() > 0: + valid_test_mask_tensor = torch.tensor(valid_test_mask, dtype=torch.bool) + X_uni_test_norm = normalize(X_test[valid_test_mask_tensor].double(), bounds_normalized.double()) + posterior_uni = model_uni.posterior(X_uni_test_norm) + pred_uni = posterior_uni.mean.squeeze(-1).cpu().numpy() + else: + pred_uni = np.full(valid_test_mask.sum(), np.nan) if valid_test_mask.sum() > 0 else np.array([]) + + # Ground truth + true_abs = Y_abs_test.squeeze().cpu().numpy() + true_cov = Y_cov_test.squeeze().cpu().numpy() + if valid_test_mask.sum() > 0: + valid_test_mask_tensor = torch.tensor(valid_test_mask, dtype=torch.bool) + true_uni = Y_uni_test[valid_test_mask_tensor].squeeze().cpu().numpy() + else: + true_uni = np.array([]) + + # Metrics + r2_abs = r2_score(true_abs, pred_abs) + rmse_abs = np.sqrt(mean_squared_error(true_abs, pred_abs)) + + r2_cov = r2_score(true_cov, pred_cov) + rmse_cov = np.sqrt(mean_squared_error(true_cov, pred_cov)) + + if len(true_uni) > 0 and not np.isnan(pred_uni).all(): + r2_uni = r2_score(true_uni, pred_uni) + rmse_uni = np.sqrt(mean_squared_error(true_uni, pred_uni)) + else: + r2_uni = np.nan + rmse_uni = np.nan + + # Classification accuracy (Physics-informed) + pred_valid = (pred_abs >= UVVIS_THRESHOLD) & (pred_cov >= COVERAGE_THRESHOLD) + true_valid = valid_test_mask + accuracy = accuracy_score(true_valid, pred_valid) + + results.append({ + 'fold': fold_idx, + 'repeat': (fold_idx - 1) // N_SPLITS + 1, + 'split': ((fold_idx - 1) % N_SPLITS) + 1, + 'r2_abs': r2_abs, + 'rmse_abs': rmse_abs, + 'r2_cov': r2_cov, + 'rmse_cov': rmse_cov, + 'r2_uni': r2_uni, + 'rmse_uni': rmse_uni, + 'accuracy': accuracy + }) + + if fold_idx % 10 == 0: + print(f" Completed {fold_idx}/{N_SPLITS * N_REPEATS} folds...") + + results_df = pd.DataFrame(results) + + # Summary statistics + print("\n[CV] Summary Statistics:") + print("-" * 60) + for metric in ['r2_abs', 'rmse_abs', 'r2_cov', 'rmse_cov', 'r2_uni', 'rmse_uni', 'accuracy']: + mean_val = results_df[metric].mean() + std_val = results_df[metric].std() + print(f" {metric:12s}: {mean_val:8.4f} ± {std_val:8.4f}") + + # Compute mean R² scores for adaptive weighting + # Handle NaN values in r2_uni (when valid samples are insufficient) + mean_r2_abs = float(results_df['r2_abs'].mean()) + mean_r2_cov = float(results_df['r2_cov'].mean()) + mean_r2_uni = float(results_df['r2_uni'].dropna().mean()) if results_df['r2_uni'].notna().any() else 0.0 + + mean_r2_dict = { + 'abs_r2': mean_r2_abs, + 'cov_r2': mean_r2_cov, + 'uni_r2': mean_r2_uni + } + + print(f"\n[CV] Mean R² Scores (for weighting):") + print(f" Abs: {mean_r2_abs:.4f}, Cov: {mean_r2_cov:.4f}, Uni: {mean_r2_uni:.4f}") + + return results_df, mean_r2_dict + + +# ============================================================================ +# Step 2: Active Learning Suggestion +# ============================================================================ + +def compute_active_learning_scores( + models: Dict[str, SingleTaskGP], + X_train_normalized: torch.Tensor, + bounds_normalized: torch.Tensor, + X_train_all: torch.Tensor, + mean_r2_dict: Dict[str, float], + X_prev_suggestions: torch.Tensor = None +) -> Tuple[torch.Tensor, torch.Tensor, Dict]: + """ + Compute active learning scores using Adaptive Weighting based on CV R² scores. + + Args: + models: Dictionary with 'abs', 'cov', 'uni' GP models + X_train_normalized: Normalized training data [0, 1] + bounds_normalized: Normalized bounds + X_train_all: Original scale training data (for diversity calculation) + mean_r2_dict: Dictionary with mean R² scores from CV + X_prev_suggestions: Previous round suggestions (optional) + + Returns: + candidates_normalized: Suggested candidates (in [0, 1] normalized space) + scores: Final scores for each candidate + metadata: Dictionary with intermediate values including weights and contributions + """ + print("\n" + "=" * 60) + print("[Optimization] Active Learning Suggestion...") + print("=" * 60) + + # Step 0: Compute adaptive weights from CV R² scores + print("Step 0: Computing adaptive weights from CV R² scores...") + r2_abs = mean_r2_dict['abs_r2'] + r2_cov = mean_r2_dict['cov_r2'] + r2_uni = mean_r2_dict['uni_r2'] + + # Raw weights: max(0.1, 1.0 - R²) + raw_w_abs = max(0.1, 1.0 - r2_abs) + raw_w_cov = max(0.1, 1.0 - r2_cov) + raw_w_uni = max(0.1, 1.0 - r2_uni) + + # Normalize to sum to 1.0 + total_raw = raw_w_abs + raw_w_cov + raw_w_uni + w_abs = raw_w_abs / total_raw + w_cov = raw_w_cov / total_raw + w_uni = raw_w_uni / total_raw + + print(f" R² scores: Abs={r2_abs:.4f}, Cov={r2_cov:.4f}, Uni={r2_uni:.4f}") + print(f" Adaptive weights: W_Abs={w_abs:.4f}, W_Cov={w_cov:.4f}, W_Uni={w_uni:.4f}") + + # Step 1: Generate Sobol grid for normalization + print(f"Step 1: Generating {SOBOL_GRID_SIZE} Sobol samples for normalization...") + unit_bounds = torch.stack([ + torch.zeros(4, dtype=torch.float64), + torch.ones(4, dtype=torch.float64) + ]) + + sobol_grid_unit = draw_sobol_samples( + bounds=unit_bounds, + n=1, + q=SOBOL_GRID_SIZE + ).squeeze(0).double() # (SOBOL_GRID_SIZE, 4) in [0, 1] + + # Model expects inputs in [0, 1] normalized space (no transformation needed) + # Use unit hypercube samples directly as model input + sobol_grid_norm = sobol_grid_unit + + # Predict uncertainties on Sobol grid (all in [0, 1] normalized space) + with torch.no_grad(): + posterior_abs = models['abs'].posterior(sobol_grid_norm) + sigma_abs_pool = posterior_abs.stddev.squeeze(-1).cpu().numpy() + + posterior_cov = models['cov'].posterior(sobol_grid_norm) + sigma_cov_pool = posterior_cov.stddev.squeeze(-1).cpu().numpy() + + posterior_uni = models['uni'].posterior(sobol_grid_norm) + sigma_uni_pool = posterior_uni.stddev.squeeze(-1).cpu().numpy() + + # Compute normalization statistics for Abs and Cov (no clipping) + mean_abs = sigma_abs_pool.mean() + std_abs = sigma_abs_pool.std() + mean_cov = sigma_cov_pool.mean() + std_cov = sigma_cov_pool.std() + + # For σ_Uni: Clip first, then recompute statistics on clipped values + # This prevents invalid region's explosive σ from making valid region's σ look too small + uni_clip_threshold = np.percentile(sigma_uni_pool, UNI_CLIP_PERCENTILE) + sigma_uni_pool_clipped = np.clip(sigma_uni_pool, None, uni_clip_threshold) + mean_uni = sigma_uni_pool_clipped.mean() # Recompute mean on clipped values + std_uni = sigma_uni_pool_clipped.std() # Recompute std on clipped values + + print(f" Sigma statistics (Abs): mean={mean_abs:.4f}, std={std_abs:.4f}") + print(f" Sigma statistics (Cov): mean={mean_cov:.4f}, std={std_cov:.4f}") + print(f" Sigma statistics (Uni): mean={mean_uni:.4f}, std={std_uni:.4f} (after clipping at {uni_clip_threshold:.4f})") + + # Step 2: Generate candidate pool using Sobol sampling + print(f"\nStep 2: Generating candidate pool (batch_size={BATCH_SIZE})...") + + # Generate large candidate pool using Sobol sampling + pool_size = BATCH_SIZE * 20 # 160 candidates + candidates_pool_unit = draw_sobol_samples( + bounds=unit_bounds, + n=1, + q=pool_size * 2 # Larger pool for better diversity + ).squeeze(0).double() # (pool_size*2, 4) in [0, 1] + + # Model expects inputs in [0, 1] normalized space (no transformation needed) + # Use unit hypercube samples directly as model input + candidates_pool_norm = candidates_pool_unit + + # Compute scores for all candidates + print(f" Computing scores for {len(candidates_pool_norm)} candidates...") + + with torch.no_grad(): + # Predict uncertainties + posterior_abs = models['abs'].posterior(candidates_pool_norm) + mu_abs = posterior_abs.mean.squeeze(-1) + sigma_abs = posterior_abs.stddev.squeeze(-1) + + posterior_cov = models['cov'].posterior(candidates_pool_norm) + mu_cov = posterior_cov.mean.squeeze(-1) + sigma_cov = posterior_cov.stddev.squeeze(-1) + + posterior_uni = models['uni'].posterior(candidates_pool_norm) + mu_uni = posterior_uni.mean.squeeze(-1) + sigma_uni = posterior_uni.stddev.squeeze(-1) + + # Apply physical constraints to predictions + # Absorbance: Data is clipped at 2.0 during loading, so clamp predictions to [0, 2.0] for safety + mu_abs = torch.clamp(mu_abs, 0.0, 2.0) + + # Coverage: Must be in [0, 1] (0% to 100%) + # This prevents probability calculation distortion from unrealistic predictions (e.g., 1.2 or -0.1) + mu_cov = torch.clamp(mu_cov, 0.0, 1.0) + + # Clip σ_Uni BEFORE normalization (clip raw sigma_uni values) + sigma_uni_np = sigma_uni.cpu().numpy() + sigma_uni_clipped = np.clip(sigma_uni_np, None, uni_clip_threshold) + + # Normalize uncertainties (after clipping for σ_Uni) + # Convert to torch tensors for consistent type handling + norm_sigma_abs = torch.tensor((sigma_abs.cpu().numpy() - mean_abs) / (std_abs + 1e-8), dtype=torch.float64) + norm_sigma_cov = torch.tensor((sigma_cov.cpu().numpy() - mean_cov) / (std_cov + 1e-8), dtype=torch.float64) + norm_sigma_uni = torch.tensor((sigma_uni_clipped - mean_uni) / (std_uni + 1e-8), dtype=torch.float64) + + # Compute P(valid) + # P(valid) = P(Abs >= 0.1) * P(Cov >= 0.1) + # Using normal approximation + mu_abs_np = mu_abs.cpu().numpy() + sigma_abs_np = sigma_abs.cpu().numpy() + mu_cov_np = mu_cov.cpu().numpy() # mu_cov is already clamped to [0, 1] + sigma_cov_np = sigma_cov.cpu().numpy() + + # Calculate probabilities using clamped mu_cov (physical constraint applied) + p_abs_high = 1 - norm.cdf((VALID_ABS_THRESHOLD - mu_abs_np) / (sigma_abs_np + 1e-8)) + p_cov_high = 1 - norm.cdf((VALID_COV_THRESHOLD - mu_cov_np) / (sigma_cov_np + 1e-8)) + + # Combine probabilities + p_valid = p_abs_high * p_cov_high + + # Clip final probability to [0, 1] range (after calculation) + p_valid = np.clip(p_valid, 0.0, 1.0) + + # Convert p_valid to torch tensor to avoid type conflicts + p_valid_tensor = torch.tensor(p_valid, dtype=torch.float64) + + # Compute individual terms (contributions) for each component + term_abs = torch.tensor(w_abs, dtype=torch.float64) * norm_sigma_abs + term_cov = torch.tensor(w_cov, dtype=torch.float64) * norm_sigma_cov + term_uni = torch.tensor(w_uni, dtype=torch.float64) * norm_sigma_uni * (p_valid_tensor ** 2) + + # Compute raw score using adaptive weighting + # Score = W_Abs * Norm(σ_Abs) + W_Cov * Norm(σ_Cov) + W_Uni * Norm(σ_Uni) * P_valid^2 + score_raw = term_abs + term_cov + term_uni + + # Hard cutoff: P(valid) < HARD_CUTOFF_P_VALID 이면 Score를 0으로 + score_raw[p_valid_tensor < HARD_CUTOFF_P_VALID] = 0.0 + + # Diversity bonus (멀리 있을수록 좋음) + print(f" Applying diversity bonus...") + + # Combine current training data with previous round suggestions for round-level diversity + if X_prev_suggestions is not None: + X_train_all_with_prev = torch.cat([X_train_all, X_prev_suggestions], dim=0) + print(f" Including {len(X_prev_suggestions)} previous round suggestions in diversity calculation") + else: + X_train_all_with_prev = X_train_all + + X_train_norm = normalize(X_train_all_with_prev.double(), bounds_normalized.double()) + + # Compute all distances at once (more efficient) + distances = torch.cdist(candidates_pool_norm, X_train_norm) # (n_candidates, n_train) + min_dists = distances.min(dim=1).values # (n_candidates,) + + # Diversity weight: Tanh 방식 (부드러운 변환) + # 거리가 가까우면(0에 가까움) weight가 0에 가까워지고, + # 거리가 멀면 weight가 1에 가까워져서 score를 보존함 + # Scale factor 0.2는 정규화된 공간(0~1)에서의 거리 감도 조절용 + diversity_weight = torch.tanh(min_dists / 0.2) + + # Apply diversity weight to raw scores + scores_final = torch.tensor(score_raw, dtype=torch.float64) * diversity_weight + + # Compute density median (nearest neighbor distance median) + print(f" Computing density median...") + # Combine all existing data points + if X_prev_suggestions is not None: + X_all_existing = torch.cat([X_train_normalized, normalize(X_prev_suggestions.double(), bounds_normalized.double())], dim=0) + else: + X_all_existing = X_train_normalized + + # Compute distance matrix (excluding self-distances) + distances_all = torch.cdist(X_all_existing, X_all_existing) + # Fill diagonal with inf to exclude self-distances + distances_all.fill_diagonal_(float('inf')) + # Get minimum distance for each point (nearest neighbor) + min_dists_all = distances_all.min(dim=1).values + # Compute median + density_median = float(torch.median(min_dists_all).item()) + print(f" Density median (nearest neighbor distance): {density_median:.6f}") + + # Select top candidates + top_indices = torch.argsort(scores_final, descending=True)[:BATCH_SIZE] + candidates_selected = candidates_pool_norm[top_indices] + scores_selected = scores_final[top_indices] + + print(f"\n Selected {BATCH_SIZE} candidates:") + for i, idx in enumerate(top_indices): + p_valid_val = p_valid_tensor[idx].item() + print(f" [{i+1}] Score: {scores_final[idx]:.4f}, P(valid): {p_valid_val:.4f}") + + # Compute contributions and primary reasons for selected candidates + contrib_abs_selected = term_abs[top_indices].cpu().numpy() + contrib_cov_selected = term_cov[top_indices].cpu().numpy() + contrib_uni_selected = term_uni[top_indices].cpu().numpy() + + # Primary reason: which term has the maximum contribution + primary_reasons = [] + for i in range(len(top_indices)): + contribs = { + 'Uncertainty (Abs)': contrib_abs_selected[i], + 'Uncertainty (Cov)': contrib_cov_selected[i], + 'Uncertainty (Uni)': contrib_uni_selected[i] + } + primary_reason = max(contribs, key=contribs.get) + primary_reasons.append(primary_reason) + + # Extract predictions and uncertainties for selected candidates + pred_abs_selected = mu_abs[top_indices].cpu().numpy() + pred_cov_selected = mu_cov[top_indices].cpu().numpy() + pred_uni_selected = mu_uni[top_indices].cpu().numpy() + sigma_abs_selected = sigma_abs[top_indices].cpu().numpy() + sigma_cov_selected = sigma_cov[top_indices].cpu().numpy() + sigma_uni_selected = sigma_uni[top_indices].cpu().numpy() + + metadata = { + 'sigma_abs_pool_mean': mean_abs, + 'sigma_abs_pool_std': std_abs, + 'sigma_cov_pool_mean': mean_cov, + 'sigma_cov_pool_std': std_cov, + 'sigma_uni_pool_mean': mean_uni, + 'sigma_uni_pool_std': std_uni, + 'uni_clip_threshold': uni_clip_threshold, + 'p_valid': p_valid_tensor[top_indices].cpu().numpy(), + 'scores_raw': score_raw[top_indices].cpu().numpy() if isinstance(score_raw, torch.Tensor) else score_raw[top_indices], + 'scores_final': scores_selected.cpu().numpy(), + # Predictions + 'pred_abs': pred_abs_selected, + 'pred_cov': pred_cov_selected, + 'pred_uni': pred_uni_selected, + # Uncertainties + 'sigma_abs': sigma_abs_selected, + 'sigma_cov': sigma_cov_selected, + 'sigma_uni': sigma_uni_selected, + # Adaptive weighting + 'weight_abs': w_abs, + 'weight_cov': w_cov, + 'weight_uni': w_uni, + 'contrib_abs': contrib_abs_selected, + 'contrib_cov': contrib_cov_selected, + 'contrib_uni': contrib_uni_selected, + 'primary_reason': primary_reasons, + 'density_median': density_median + } + + return candidates_selected, scores_selected, metadata + + +def train_all_models(df: pd.DataFrame) -> Tuple[Dict[str, SingleTaskGP], Dict[str, float], torch.Tensor, torch.Tensor]: + """ + Train all GP models and compute normalization statistics. + + Args: + df: DataFrame with accumulated data + + Returns: + models: Dictionary of fitted models + normalization_stats: Dictionary of normalization statistics + X: Original input tensor + bounds_normalized: Normalized bounds tensor + """ + print("\n" + "=" * 60) + print("[Model] Training all models...") + print("=" * 60) + + # Prepare inputs and outputs + X, Y_dict, valid_mask = prepare_inputs_outputs(df) + + # Prepare bounds (with log scale conversion) + bounds_normalized = BOUNDS_ORIGINAL.clone() + for i, is_log in enumerate(LOG_SCALE_PARAMS): + if is_log: + bounds_normalized[0, i] = np.log10(BOUNDS_ORIGINAL[0, i].item()) + bounds_normalized[1, i] = np.log10(BOUNDS_ORIGINAL[1, i].item()) + + X_normalized = normalize(X.double(), bounds_normalized.double()) + + # Build models + model_abs = build_gp_model(X_normalized, Y_dict['abs']) + model_cov = build_gp_model(X_normalized, Y_dict['cov']) + + # Uniformity model: only valid samples + valid_mask_tensor = torch.tensor(valid_mask, dtype=torch.bool) + if valid_mask_tensor.sum() >= 2: + X_uni = X[valid_mask_tensor] + Y_uni = Y_dict['uni'][valid_mask_tensor] + X_uni_normalized = normalize(X_uni.double(), bounds_normalized.double()) + model_uni = build_gp_model(X_uni_normalized, Y_uni) + else: + model_uni = None + print(" Warning: Not enough valid samples for Uniformity model") + + models = { + 'abs': model_abs, + 'cov': model_cov, + 'uni': model_uni + } + + # Normalization statistics will be computed in compute_active_learning_scores() + # to ensure consistency with run_active_learning.py (ver1) + # Return empty dict here; it will be populated from metadata in main() + # This avoids generating Sobol samples here, which would change the random state + # and cause different suggestions than ver1 + normalization_stats = {} + + return models, normalization_stats, X, bounds_normalized + + +def calculate_model_predictions_on_grid( + models: Dict[str, SingleTaskGP], + stats: Dict[str, float], + grid_tensor: torch.Tensor, + weights: Dict[str, float] +) -> Dict[str, np.ndarray]: + """ + Calculate model predictions and acquisition scores on a grid. + + Args: + models: Dictionary of trained models + stats: Normalization statistics + grid_tensor: Normalized grid points (n_points, 4) + weights: Adaptive weights {'abs': w_abs, 'cov': w_cov, 'uni': w_uni} + + Returns: + Dictionary with keys: + 'mean_abs', 'mean_cov', 'mean_uni', 'p_valid', + 'std_abs', 'std_cov', 'std_uni', + 'score' + """ + # Unpack stats + mean_abs = stats['sigma_abs_mean'] + std_abs = stats['sigma_abs_std'] + mean_cov = stats['sigma_cov_mean'] + std_cov = stats['sigma_cov_std'] + mean_uni = stats['sigma_uni_mean'] + std_uni = stats['sigma_uni_std'] + uni_clip_threshold = stats['uni_clip_threshold'] + + # Unpack weights + w_abs = weights['abs'] + w_cov = weights['cov'] + w_uni = weights['uni'] + + with torch.no_grad(): + # Predict + posterior_abs = models['abs'].posterior(grid_tensor) + mu_abs = posterior_abs.mean.squeeze(-1) + sigma_abs = posterior_abs.stddev.squeeze(-1) + + posterior_cov = models['cov'].posterior(grid_tensor) + mu_cov = posterior_cov.mean.squeeze(-1) + sigma_cov = posterior_cov.stddev.squeeze(-1) + + if models['uni'] is not None: + posterior_uni = models['uni'].posterior(grid_tensor) + mu_uni = posterior_uni.mean.squeeze(-1) + sigma_uni = posterior_uni.stddev.squeeze(-1) + else: + mu_uni = torch.zeros_like(mu_abs) + sigma_uni = torch.zeros_like(sigma_abs) + + # Apply constraints + mu_abs = torch.clamp(mu_abs, 0.0, 2.0) + mu_cov = torch.clamp(mu_cov, 0.0, 1.0) + + # Clip sigma_uni + sigma_uni_np = sigma_uni.cpu().numpy() + sigma_uni_clipped = np.clip(sigma_uni_np, None, uni_clip_threshold) + + # Normalize uncertainties + norm_sigma_abs = (sigma_abs.cpu().numpy() - mean_abs) / (std_abs + 1e-8) + norm_sigma_cov = (sigma_cov.cpu().numpy() - mean_cov) / (std_cov + 1e-8) + norm_sigma_uni = (sigma_uni_clipped - mean_uni) / (std_uni + 1e-8) + + # Compute P(valid) + mu_abs_np = mu_abs.cpu().numpy() + sigma_abs_np = sigma_abs.cpu().numpy() + mu_cov_np = mu_cov.cpu().numpy() + sigma_cov_np = sigma_cov.cpu().numpy() + + p_abs_high = 1 - norm.cdf((VALID_ABS_THRESHOLD - mu_abs_np) / (sigma_abs_np + 1e-8)) + p_cov_high = 1 - norm.cdf((VALID_COV_THRESHOLD - mu_cov_np) / (sigma_cov_np + 1e-8)) + p_valid = p_abs_high * p_cov_high + p_valid = np.clip(p_valid, 0.0, 1.0) + + # Compute Score + term_abs = w_abs * norm_sigma_abs + term_cov = w_cov * norm_sigma_cov + term_uni = w_uni * norm_sigma_uni * (p_valid ** 2) + + score = term_abs + term_cov + term_uni + + # Hard cutoff + score[p_valid < HARD_CUTOFF_P_VALID] = 0.0 + + return { + 'mean_abs': mu_abs_np, + 'mean_cov': mu_cov_np, + 'mean_uni': mu_uni.cpu().numpy(), + 'p_valid': p_valid, + 'std_abs': sigma_abs_np, + 'std_cov': sigma_cov_np, + 'std_uni': sigma_uni_np, + 'score': score + } + + +# ============================================================================ +# Task 1: Evaluation & Logging (New) +# ============================================================================ + +def get_data_density_metrics(X_norm: torch.Tensor) -> Dict[str, float]: + """ + Compute data density metrics based on nearest neighbor distances in normalized space. + + Args: + X_norm: Normalized input tensor (n_samples, n_dims) + + Returns: + Dictionary with Density_Median, Density_P25, Density_P75 + """ + if len(X_norm) < 2: + return { + 'Density_Median': 0.0, + 'Density_P25': 0.0, + 'Density_P75': 0.0 + } + + # Compute distance matrix (excluding self-distances) + distances = torch.cdist(X_norm, X_norm) + # Fill diagonal with inf to exclude self-distances + distances.fill_diagonal_(float('inf')) + + # Get minimum distance for each point (nearest neighbor) + min_dists = distances.min(dim=1).values + min_dists_np = min_dists.cpu().numpy() + + # Compute stats + median = float(np.median(min_dists_np)) + p25 = float(np.percentile(min_dists_np, 25)) + p75 = float(np.percentile(min_dists_np, 75)) + + return { + 'Density_Median': median, + 'Density_P25': p25, + 'Density_P75': p75 + } + + +def evaluate_current_state(df: pd.DataFrame, save_cv_path: Optional[Path] = None) -> Dict[str, float]: + """ + Evaluate current state of the campaign. + + Args: + df: DataFrame with accumulated data + save_cv_path: Optional path to save CV results CSV + + Returns: + Dictionary containing all metrics (CV performance, Density, Weights) + """ + print("\n" + "=" * 60) + print("[Evaluation] Evaluating current state...") + print("=" * 60) + + # Prepare inputs and outputs + X, Y_dict, valid_mask = prepare_inputs_outputs(df) + + # Create strata + strata = create_strata(df) + + # Prepare bounds (with log scale conversion) + bounds_normalized = BOUNDS_ORIGINAL.clone() + for i, is_log in enumerate(LOG_SCALE_PARAMS): + if is_log: + bounds_normalized[0, i] = np.log10(BOUNDS_ORIGINAL[0, i].item()) + bounds_normalized[1, i] = np.log10(BOUNDS_ORIGINAL[1, i].item()) + + # 1. Density Metrics + X_normalized = normalize(X.double(), bounds_normalized.double()) + density_metrics = get_data_density_metrics(X_normalized) + print(f" Density Median: {density_metrics['Density_Median']:.4f}") + + # 2. Cross-Validation + cv_results, mean_r2_dict = run_cross_validation(X, Y_dict, valid_mask, strata, BOUNDS_ORIGINAL) + + if save_cv_path: + cv_results.to_csv(save_cv_path, index=False) + print(f" CV results saved to {save_cv_path}") + + # 3. Adaptive Weights + r2_abs = mean_r2_dict['abs_r2'] + r2_cov = mean_r2_dict['cov_r2'] + r2_uni = mean_r2_dict['uni_r2'] + + raw_w_abs = max(0.1, 1.0 - r2_abs) + raw_w_cov = max(0.1, 1.0 - r2_cov) + raw_w_uni = max(0.1, 1.0 - r2_uni) + + total_raw = raw_w_abs + raw_w_cov + raw_w_uni + w_abs = raw_w_abs / total_raw + w_cov = raw_w_cov / total_raw + w_uni = raw_w_uni / total_raw + + # Assemble Metrics + metrics = { + # Basic + 'Total_Samples': len(df), + 'Valid_Samples': int(df['Valid'].sum()), + 'Valid_Rate': float(df['Valid'].mean()), + + # CV Performance (Abs) + 'CV_Abs_R2_Mean': cv_results['r2_abs'].mean(), + 'CV_Abs_R2_Std': cv_results['r2_abs'].std(), + 'CV_Abs_RMSE_Mean': cv_results['rmse_abs'].mean(), + + # CV Performance (Cov) + 'CV_Cov_R2_Mean': cv_results['r2_cov'].mean(), + 'CV_Cov_R2_Std': cv_results['r2_cov'].std(), + 'CV_Cov_RMSE_Mean': cv_results['rmse_cov'].mean(), + + # CV Performance (Uni) + 'CV_Uni_R2_Mean': cv_results['r2_uni'].mean(), + 'CV_Uni_R2_Std': cv_results['r2_uni'].std(), + 'CV_Uni_RMSE_Mean': cv_results['rmse_uni'].mean(), + + # CV Performance (Accuracy) + 'CV_Valid_Acc_Mean': cv_results['accuracy'].mean(), + 'CV_Valid_Acc_Std': cv_results['accuracy'].std(), + + # Strategy Weights + 'W_Abs': w_abs, + 'W_Cov': w_cov, + 'W_Uni': w_uni + } + + # Add Density Metrics + metrics.update(density_metrics) + + return metrics + + +def append_progress_log(round_num: int, metrics_dict: Dict[str, float], log_path: Path): + """ + Append metrics to the campaign progress log CSV. + """ + # Create row dictionary + row = {'Round': round_num} + row.update(metrics_dict) + + # Create DataFrame + df_row = pd.DataFrame([row]) + + # Define column order (optional, but good for readability) + cols_order = [ + 'Round', 'Total_Samples', 'Valid_Samples', 'Valid_Rate', + 'CV_Abs_R2_Mean', 'CV_Abs_R2_Std', 'CV_Abs_RMSE_Mean', + 'CV_Cov_R2_Mean', 'CV_Cov_R2_Std', 'CV_Cov_RMSE_Mean', + 'CV_Uni_R2_Mean', 'CV_Uni_R2_Std', 'CV_Uni_RMSE_Mean', + 'CV_Valid_Acc_Mean', 'CV_Valid_Acc_Std', + 'Density_Median', 'Density_P25', 'Density_P75', + 'W_Abs', 'W_Cov', 'W_Uni' + ] + + # Reorder columns if they exist in the row + existing_cols = [c for c in cols_order if c in df_row.columns] + remaining_cols = [c for c in df_row.columns if c not in cols_order] + df_row = df_row[existing_cols + remaining_cols] + + # Append to file + if not log_path.exists(): + df_row.to_csv(log_path, index=False) + print(f"[Log] Created new progress log at {log_path}") + else: + df_row.to_csv(log_path, mode='a', header=False, index=False) + print(f"[Log] Appended Round {round_num} stats to {log_path}") + + +# ============================================================================ +# Main Function +# ============================================================================ + +def main(): + """Main execution function.""" + # Set random seeds for reproducibility + torch.manual_seed(SEED) + np.random.seed(SEED) + random.seed(SEED) + if torch.cuda.is_available(): + torch.cuda.manual_seed(SEED) + + # Data directory - specify directory path here + # All files (input CSV and output files) will be in this directory + data_dir = Path("/home/weiqizhang/Lab_Automation/aamp_app/Image_Processing/Round5") + + # Round number - specify which round to use (uses data from rounds <= round_num) + # Suggestions will be generated for round_num + 1 + # round_num = 8 # Removed hardcoded value to use auto-detection or CLI if needed + + # Construct CSV path from directory + csv_path = data_dir / "PProDOT_CB_Campaign_parameters.csv" + + # Load and preprocess data (including previous round suggestions) + # If round_num is None, it will use the max round in the CSV + df, current_round, X_prev_suggestions = load_and_preprocess_data(csv_path, round_num=None) + + # ======================================================================== + # Step 1: Evaluate Current State & Log Progress + # ======================================================================== + + # Create results directory if it doesn't exist + results_dir = data_dir / "results" + results_dir.mkdir(parents=True, exist_ok=True) + + # Define paths (all output files go to results directory) + cv_output_path = results_dir / f"cv_results_round{current_round}_v4.csv" + progress_log_path = results_dir / "campaign_progress_v4.csv" + + # Evaluate + metrics = evaluate_current_state(df, save_cv_path=cv_output_path) + + # Log progress + append_progress_log(current_round, metrics, progress_log_path) + + # Extract mean R2 scores for adaptive weighting + mean_r2_dict = { + 'abs_r2': metrics['CV_Abs_R2_Mean'], + 'cov_r2': metrics['CV_Cov_R2_Mean'], + 'uni_r2': metrics['CV_Uni_R2_Mean'] + } + + # ======================================================================== + # Step 2: Active Learning Suggestion + # ======================================================================== + + # Train all models + models, normalization_stats, X, bounds_normalized = train_all_models(df) + + X_normalized = normalize(X.double(), bounds_normalized.double()) + + # Compute active learning scores (with previous round suggestions for diversity) + candidates_normalized, scores, metadata = compute_active_learning_scores( + models, X_normalized, bounds_normalized, X, mean_r2_dict, X_prev_suggestions + ) + + # Update normalization_stats from metadata (for visualization) + normalization_stats = { + 'sigma_abs_mean': metadata['sigma_abs_pool_mean'], + 'sigma_abs_std': metadata['sigma_abs_pool_std'], + 'sigma_cov_mean': metadata['sigma_cov_pool_mean'], + 'sigma_cov_std': metadata['sigma_cov_pool_std'], + 'sigma_uni_mean': metadata['sigma_uni_pool_mean'], + 'sigma_uni_std': metadata['sigma_uni_pool_std'], + 'uni_clip_threshold': metadata['uni_clip_threshold'] + } + + # Decode candidates to original scale + candidates_unnorm = unnormalize(candidates_normalized, bounds_normalized.double()) + candidates_original = candidates_unnorm.clone() + for i, is_log in enumerate(LOG_SCALE_PARAMS): + if is_log: + candidates_original[:, i] = 10 ** candidates_unnorm[:, i] + + # Round discrete parameters + candidates_original[:, 1] = torch.round(candidates_original[:, 1]) # Temperature + candidates_original[:, 2] = torch.round(candidates_original[:, 2]) # Gap + candidates_original[:, 3] = torch.round(candidates_original[:, 3]) # Volume + candidates_original[:, 0] = torch.round(candidates_original[:, 0] * 100.0) / 100.0 # Speed (2 decimals) + + # Save suggestions to results directory + next_round = current_round + 1 + suggestions_path = results_dir / f"round{next_round}_suggestions_v4.csv" + + # Determine starting sample number + # If we have previous data for this round (unlikely for new suggestions, but good for consistency), continue numbering + # Otherwise start from 1 + start_sample = 1 + + # Construct DataFrame with requested column order + # Header: round#, Sample #, Temperature, Speed, gap, Solvent, Concentration, Precursor Volume + + suggestions_df = pd.DataFrame({ + 'round#': [next_round] * BATCH_SIZE, + 'Sample #': range(start_sample, start_sample + BATCH_SIZE), + 'Temperature': candidates_original[:, 1].cpu().numpy().astype(int), + 'Speed': candidates_original[:, 0].cpu().numpy(), + 'gap': candidates_original[:, 2].cpu().numpy().astype(int), + 'Solvent': [SOLVENT] * BATCH_SIZE, + 'Concentration': [CONCENTRATION] * BATCH_SIZE, + 'Precursor Volume': candidates_original[:, 3].cpu().numpy().astype(int), + + # Metadata follows + 'score': scores.cpu().numpy(), + 'p_valid': metadata['p_valid'], + 'score_raw': metadata['scores_raw'], + # Predictions + 'pred_abs': metadata['pred_abs'], + 'pred_cov': metadata['pred_cov'], + 'pred_uni': metadata['pred_uni'], + # Uncertainties + 'sigma_abs': metadata['sigma_abs'], + 'sigma_cov': metadata['sigma_cov'], + 'sigma_uni': metadata['sigma_uni'], + # Adaptive weighting information + 'Weight_Abs': [metadata['weight_abs']] * len(candidates_original), + 'Weight_Cov': [metadata['weight_cov']] * len(candidates_original), + 'Weight_Uni': [metadata['weight_uni']] * len(candidates_original), + # Contributions + 'Contrib_Abs': metadata['contrib_abs'], + 'Contrib_Cov': metadata['contrib_cov'], + 'Contrib_Uni': metadata['contrib_uni'], + # Primary reason and density + 'Primary_Reason': metadata['primary_reason'], + 'Density_Median': [metadata['density_median']] * len(candidates_original) + }) + + suggestions_df.to_csv(suggestions_path, index=False) + print(f"\n[Optimization] Suggestions saved to {suggestions_path}") + + print("\n" + "=" * 60) + print("Active Learning Complete!") + print("=" * 60) + + +if __name__ == "__main__": + main() + diff --git a/aamp_app/Image_Processing/Image_processing/scoring.py b/aamp_app/Image_Processing/Image_processing/scoring.py new file mode 100644 index 0000000..d68de44 --- /dev/null +++ b/aamp_app/Image_Processing/Image_processing/scoring.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Tuple + +import cv2 +import numpy as np + +from .config import UniformityConfig +# analyze_image is only used inside compute_uniformity_score function, so use lazy import + + +# Ideal (Perfect) Reference statistics (used when reference is not available) +MODEL_REF_STD = 2.3 +MODEL_REF_ENTROPY = 2.9 + + +def analyze_uniformity( + ref_stats: Dict[str, float], + cfg: UniformityConfig, + use_model_reference: bool = False, +) -> Tuple[float, Dict[str, float]]: + """Calculate Uniformity Score using Reference-based Absolute Scoring (RAS) method. + + Args: + ref_stats: Dictionary containing Reference and Sample statistics + - ref_std: Reference S channel standard deviation (None if not available) + - ref_entropy: Reference S channel entropy (None if not available) + - sample_std: Sample S channel standard deviation + - sample_entropy: Sample S channel entropy + cfg: UniformityConfig object (includes sensitivity parameters) + use_model_reference: If True, use ideal reference (default: False) + + Returns: + (uniformity_score, debug_info) + - uniformity_score: float value between 0 and 1 + - debug_info: Contains all intermediate calculation values (including use_model_reference flag) + """ + sample_std = ref_stats.get("sample_std", 0.0) + sample_entropy = ref_stats.get("sample_entropy", 0.0) + + # Reference statistics: use actual reference if available, otherwise use ideal reference + if use_model_reference or ref_stats.get("ref_std") is None: + ref_std = MODEL_REF_STD + ref_entropy = MODEL_REF_ENTROPY + use_model_reference = True + else: + ref_std = ref_stats.get("ref_std", 0.0) + ref_entropy = ref_stats.get("ref_entropy", 0.0) + + # Step 1: Roughness Score (Std) + delta_std = max(0.0, sample_std - ref_std) + score_std = np.exp(-delta_std / cfg.uniformity_std_sensitivity) + + # Step 2: Texture Score (Entropy) + delta_ent = max(0.0, sample_entropy - ref_entropy) + score_ent = np.exp(-delta_ent / cfg.uniformity_ent_sensitivity) + + # Step 3: Final Score + final_score = (score_std * 0.5) + (score_ent * 0.5) + + debug_info: Dict[str, float] = { + "ref_std": ref_std, + "ref_entropy": ref_entropy, + "sample_std": sample_std, + "sample_entropy": sample_entropy, + "delta_std": delta_std, + "delta_entropy": delta_ent, + "score_std": float(score_std), + "score_entropy": float(score_ent), + "uniformity_std_sensitivity": cfg.uniformity_std_sensitivity, + "uniformity_ent_sensitivity": cfg.uniformity_ent_sensitivity, + "use_model_reference": 1.0 if use_model_reference else 0.0, # Convert bool to float + "uniformity_score": float(final_score), + } + + return float(final_score), debug_info + + +def compute_uniformity_score( + image_path: str, + metadata: Dict[str, Any] | None = None, + verbose: bool = False, +) -> float: + """Calculate temporary Uniformity score based on coverage.""" + # Lazy import to avoid circular dependency + from .pipeline import analyze_image + + image_path_obj = Path(image_path) + if not image_path_obj.exists(): + raise FileNotFoundError(f"Image not found: {image_path}") + + image_bgr = cv2.imread(str(image_path_obj)) + if image_bgr is None: + raise ValueError(f"Could not load image: {image_path}") + + metadata = metadata or {} + cfg = _create_config_from_metadata(metadata, image_path_obj) + + reference_bgr = None + if cfg.use_reference: + ref_path = _match_reference_for(image_path_obj, cfg.reference_dir, cfg.reference_name) + if ref_path is not None and ref_path.exists(): + reference_bgr = cv2.imread(str(ref_path)) + elif verbose: + print(f"[WARN] Reference not found for {image_path_obj.name}") + + metrics, *_ = analyze_image( + image_bgr=image_bgr, + cfg=cfg, + ref_bgr=reference_bgr, + image_path=image_path_obj, + ) + + # Use new CV-based uniformity score (full ROI based) + uniformity_score = metrics.get("uniformity_score") + if uniformity_score is not None: + return float(uniformity_score) + + # Fallback: coverage percentage (previous method) + coverage_pct = metrics.get("coverage_percentage") + if coverage_pct is None: + return 0.0 + return float(coverage_pct) + + +def _create_config_from_metadata(metadata: Dict[str, Any], image_path: Path) -> UniformityConfig: + return UniformityConfig( + input_dir=str(image_path.parent), + use_reference=metadata.get("reference_dir") is not None, + reference_dir=metadata.get("reference_dir"), + reference_name=metadata.get("reference_name", "background_normal_0deg"), + roi_x=metadata.get("roi_x", 0), + roi_y=metadata.get("roi_y", 0), + roi_width=metadata.get("roi_width", 100), + roi_height=metadata.get("roi_height", 100), + uvvis_dir=metadata.get("uvvis_dir"), + uvvis_abs_threshold=metadata.get("uvvis_abs_threshold", 0.1), + uvvis_wavelength_min=metadata.get("uvvis_wavelength_min", 300.0), + uvvis_wavelength_max=metadata.get("uvvis_wavelength_max", 800.0), + coverage_threshold=metadata.get("coverage_threshold", 0.1), + coverage_smoothing=metadata.get("coverage_smoothing", 1.5), + coverage_k=metadata.get("coverage_k", 3.0), + coverage_min_std=metadata.get("coverage_min_std", 10.0), + uniformity_std_sensitivity=metadata.get("uniformity_std_sensitivity", 20.0), + uniformity_ent_sensitivity=metadata.get("uniformity_ent_sensitivity", 3.0), + compute_learning_features=metadata.get("compute_learning_features", False), + ) + + +def _match_reference_for( + image_path: Path, + reference_dir: str | None, + reference_name: str | None, +) -> Path | None: + """Find image matching reference_name pattern within reference_dir.""" + + search_dir = Path(reference_dir) if reference_dir else image_path.parent + if reference_name: + for candidate in sorted(search_dir.iterdir()): + if candidate.is_file() and candidate.stem.startswith(reference_name): + return candidate + + if reference_dir: + same_name = Path(reference_dir) / image_path.name + try: + if same_name.exists() and same_name.resolve() != image_path.resolve(): + return same_name + except Exception: + if same_name.exists() and str(same_name) != str(image_path): + return same_name + + return None + diff --git a/aamp_app/Image_Processing/Image_processing/uvvis.py b/aamp_app/Image_Processing/Image_processing/uvvis.py new file mode 100644 index 0000000..26db2b9 --- /dev/null +++ b/aamp_app/Image_Processing/Image_processing/uvvis.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from io import BytesIO +from typing import Dict, Tuple +import re + +import pandas as pd + + +def _extract_round_sample_id(image_stem: str) -> str | None: + match = re.search(r"(R\d+S\d+)", image_stem, re.IGNORECASE) + if not match: + return None + return match.group(1).upper() + + +def find_uvvis_file(image_name, fs, campaign_id): + round_sample_id = _extract_round_sample_id(image_name) + if round_sample_id is None: + return None + + candidates = fs.find_one({"filename": {"$regex": f"^{round_sample_id}.*\\.csv$"}, "campaign_id": campaign_id}) + if candidates is None: + return None + return candidates + + +def _find_header_row(uvvis_data: bytes) -> int | None: + text = uvvis_data.decode("utf-8", errors="ignore") + for idx, line in enumerate(text.splitlines()): + if "wavelength" in line.lower(): + return idx + return None + + +def analyze_uvvis_data( + image_name, + fs, + campaign_id, + abs_threshold: float = 0.1, + wavelength_min: float = 300.0, + wavelength_max: float = 800.0, +) -> Tuple[bool, Dict[str, float | str]]: + """Load UV-Vis CSV, compute simple statistics, and threshold check.""" + + uvvis_file = find_uvvis_file(image_name, fs, campaign_id) + if uvvis_file is None: + return False, { + "uvvis_reason": "file_not_found", + "uvvis_abs_threshold": abs_threshold, + } + + data = uvvis_file.read() + header_row = _find_header_row(data) + read_kwargs = {"encoding": "utf-8", "engine": "python"} + try: + if header_row is not None: + df = pd.read_csv(BytesIO(data), header=header_row, **read_kwargs) + else: + df = pd.read_csv(BytesIO(data), **read_kwargs) + except Exception: + return False, { + "uvvis_reason": "read_error", + "uvvis_file": str(uvvis_file.filename), + "uvvis_abs_threshold": abs_threshold, + } + + df = df.dropna(axis=1, how="all") + df = df.rename(columns=lambda c: str(c).strip()) + + abs_col = None + wavelength_col = None + for col in df.columns: + cname = str(col).lower() + if abs_col is None and ("abs" in cname or "absorbance" in cname): + abs_col = col + if wavelength_col is None and ("wavelength" in cname or cname.startswith("wl")): + wavelength_col = col + + if abs_col is None or wavelength_col is None: + return False, { + "uvvis_reason": "missing_columns", + "uvvis_file": str(uvvis_file.filename), + "uvvis_abs_threshold": abs_threshold, + } + + df = df[[wavelength_col, abs_col]].copy() + df[wavelength_col] = pd.to_numeric(df[wavelength_col], errors="coerce") + df[abs_col] = pd.to_numeric(df[abs_col], errors="coerce") + df = df.dropna() + df = df[(df[wavelength_col] >= wavelength_min) & (df[wavelength_col] <= wavelength_max)] + + if df.empty: + return False, { + "uvvis_reason": "empty_data", + "uvvis_file": str(uvvis_file.filename), + "uvvis_abs_threshold": abs_threshold, + } + + max_abs = float(df[abs_col].max()) + mean_abs = float(df[abs_col].mean()) + median_abs = float(df[abs_col].median()) + min_abs = float(df[abs_col].min()) + data_points = int(len(df)) + peak_idx = int(df[abs_col].idxmax()) + peak_wavelength = float(df.loc[peak_idx, wavelength_col]) + + passed = max_abs >= abs_threshold + + stats: Dict[str, float | str] = { + "uvvis_file": str(uvvis_file.filename), + "uvvis_abs_threshold": abs_threshold, + "uvvis_wavelength_min": wavelength_min, + "uvvis_wavelength_max": wavelength_max, + "uvvis_passed": passed, + "uvvis_reason": "ok" if passed else "below_threshold", + "uvvis_max_abs": max_abs, + "uvvis_mean_abs": mean_abs, + "uvvis_median_abs": median_abs, + "uvvis_min_abs": min_abs, + "uvvis_data_points": data_points, + "uvvis_peak_wavelength": peak_wavelength, + } + + return passed, stats diff --git a/aamp_app/Image_Processing/README.md b/aamp_app/Image_Processing/README.md new file mode 100644 index 0000000..38a60df --- /dev/null +++ b/aamp_app/Image_Processing/README.md @@ -0,0 +1,308 @@ +# PProDOT Demo Campaign + +A project for optimizing PProDOT film manufacturing through image analysis and Bayesian optimization. + +## Overview + +This project provides tools for: +- **Image Analysis**: Analyzing film coverage and uniformity from sample images +- **UV-Vis Analysis**: Processing UV-Vis spectroscopy data +- **Bayesian Optimization**: Optimizing manufacturing parameters (Speed, Temperature, Gap, Volume) using constrained Bayesian optimization + +## Project Structure + +``` +PProDOT_Demo_campaign/ +├── Image_processing/ # Image analysis module +│ ├── __init__.py # Module exports +│ ├── config.py # Configuration dataclass (UniformityConfig) +│ ├── coverage.py # HSV-based film coverage analysis +│ ├── pipeline.py # Main image analysis pipeline +│ ├── scoring.py # Uniformity score calculation +│ └── uvvis.py # UV-Vis data analysis +├── constrained_bo_ver2.py # Constrained Bayesian Optimizer +├── image_processing_changhyun.py # Batch image processing script +├── requirements.txt # Python dependencies +└── README.md # This file +``` + +## Module Descriptions + +### Image_processing Module + +A comprehensive image analysis module for evaluating film quality. + +#### `config.py` - UniformityConfig +Configuration dataclass that holds all analysis parameters: +- ROI (Region of Interest) coordinates +- Reference image settings +- UV-Vis analysis thresholds +- Coverage and uniformity calculation parameters + +**Usage:** +```python +from Image_processing import UniformityConfig + +cfg = UniformityConfig( + roi_x=580, + roi_y=400, + roi_width=913, + roi_height=415, + reference_dir="path/to/reference", + uvvis_dir="path/to/uvvis", + coverage_threshold=0.1, + uniformity_std_sensitivity=15.0, + uniformity_ent_sensitivity=2.0 +) +``` + +#### `coverage.py` - Film Coverage Analysis +HSV-based film coverage analysis module. Instead of traditional reference subtraction, it uses: +- Dynamic thresholds based on reference image S-channel statistics +- Sample image HSV information +- Otsu thresholding on V channel for dark regions + +**Key Functions:** +- `analyze_film_coverage()`: Main function that calculates coverage percentage and generates masks + +**Returns:** +- Coverage percentage (0-1) +- Sample and reference ROI images +- Binary mask showing film regions +- Debug information dictionary + +#### `pipeline.py` - Analysis Pipeline +Main pipeline that orchestrates the entire analysis process: +- Loads and processes images +- Analyzes coverage +- Calculates uniformity scores +- Processes UV-Vis data if available + +**Key Functions:** +- `analyze_image()`: Complete analysis pipeline for a single image + +#### `scoring.py` - Uniformity Scoring +Calculates uniformity scores using Reference-based Absolute Scoring (RAS): +- Compares sample statistics (std, entropy) with reference +- Uses ideal reference model when actual reference is unavailable +- Returns score between 0 and 1 + +**Key Functions:** +- `analyze_uniformity()`: Calculate uniformity score from statistics +- `compute_uniformity_score()`: High-level function to compute score from image path + +#### `uvvis.py` - UV-Vis Analysis +Processes UV-Vis spectroscopy CSV files: +- Finds matching UV-Vis files based on sample ID +- Extracts absorbance data +- Checks against thresholds + +**Key Functions:** +- `analyze_uvvis_data()`: Analyze UV-Vis file and return statistics +- `find_uvvis_file()`: Locate UV-Vis file for a given image + +### constrained_bo_ver2.py + +Constrained Bayesian Optimization system for parameter optimization. + +**Purpose:** Optimizes manufacturing parameters (Speed, Temperature, Gap, Precursor Volume) to maximize uniformity score while satisfying constraints (UV-Vis ≥ 0.1 AND Coverage ≥ 0.9). + +**Key Features:** +- **Objective Model**: Learns uniformity_score from valid samples only +- **Constraint Model**: Learns P(valid|x) from all samples +- **Acquisition Function**: EI(x) × P(valid|x) +- Handles discrete parameters (Temperature, Gap, Volume) +- Log-scale handling for Speed parameter +- Avoids duplicate parameter combinations + +**Usage:** +```python +from constrained_bo_ver2 import ConstrainedBayesianOptimizer +import torch + +# Define parameter bounds: [Speed, Temperature, gap, volume] +bounds = torch.tensor([ + [0.01, 20.0], # Speed + [25.0, 107.0], # Temperature + [50, 200], # Gap + [5, 15] # Volume +]).T + +# Initialize optimizer +optimizer = ConstrainedBayesianOptimizer( + bounds=bounds, + csv_path='data/Round0_3/PProDOT_CB_Campaign_parameters.csv', + round_num=0, + batch_size=8, + discrete_or_not=[False, True, True, True], + discrete_points=[...] # Discrete values for each parameter +) + +# Suggest candidates +candidates, metadata = optimizer.suggest() + +# Save to CSV +optimizer.save_candidates_to_csv(candidates, metadata=metadata) +``` + +**Input CSV Format:** +The CSV should contain columns: +- `round#`, `Sample #` +- `Speed`, `Temperature`, `gap`, `Precursor Volume` +- `uvvis_max_abs`, `coverage_percentage`, `uniformity_score` + +### image_processing_changhyun.py + +Batch image processing script for analyzing multiple directories of images. + +**Purpose:** Processes images from multiple directories, calculates metrics, and saves results to CSV files. + +**Features:** +- Processes images from specified directories +- Matches images with reference and UV-Vis data +- Generates debug mask images with analysis results +- Saves comprehensive results to CSV + +**Usage:** +```python +# Edit main() function to specify directories +main_dirs = [ + Path("data/Round0"), + Path("data/Round0_1"), + # Add more directories... +] + +# Run the script +python image_processing_changhyun.py +``` + +**Directory Structure Expected:** +``` +data/ +└── Round0/ + ├── Raw/ # Sample images (e.g., R0S01.png) + ├── blank/ # Reference images + ├── UV-Vis/ # UV-Vis CSV files + └── PProDOT_CB_Campaign_parameters.csv # Parameter data +``` + +**Output:** +- `processing_results_simple.csv`: Analysis results with all metrics +- `Debug_Masks/`: Visual debug images showing analysis results + +## Setup + +### Virtual Environment Activation + +**Windows (PowerShell):** +```powershell +.\venv\Scripts\Activate.ps1 +``` + +**Windows (Command Prompt):** +```cmd +.\venv\Scripts\activate.bat +``` + +### Package Installation + +```powershell +pip install -r requirements.txt +``` + +## Dependencies + +- **numpy**: Numerical computations +- **opencv-python**: Image processing +- **pandas**: Data manipulation +- **scikit-image**: Image analysis utilities +- **torch**: PyTorch for Bayesian optimization +- **botorch**: Bayesian optimization library +- **gpytorch**: Gaussian process models + +## Workflow + +### 1. Image Analysis Workflow + +1. **Prepare Data Structure:** + - Organize images in `Raw/` directory + - Place reference images in `blank/` directory + - Add UV-Vis CSV files to `UV-Vis/` directory + +2. **Run Batch Processing:** + ```python + python image_processing_changhyun.py + ``` + +3. **Review Results:** + - Check `processing_results_simple.csv` for metrics + - Review `Debug_Masks/` for visual analysis + +### 2. Bayesian Optimization Workflow + +1. **Prepare CSV with Initial Data:** + - Include parameter values and corresponding metrics + - Ensure columns: `round#`, `Sample #`, `Speed`, `Temperature`, `gap`, `Precursor Volume`, `uvvis_max_abs`, `coverage_percentage`, `uniformity_score` + +2. **Run Optimization:** + ```python + python constrained_bo_ver2.py + ``` + +3. **Review Suggestions:** + - Check generated candidates + - Check metadata (EI, P(valid), scores)) + - New candidates are saved to CSV + +4. **Iterate:** + - Run experiments with suggested parameters + - Add results to CSV + - Update `round_num` and run again + +## Key Concepts + +### Coverage Analysis +- Uses HSV color space for better film detection +- Dynamic threshold based on reference S-channel statistics +- Combines saturation and value channel analysis + +### Uniformity Score +- Reference-based Absolute Scoring (RAS) method +- Compares sample roughness (std) and texture (entropy) with reference +- Score ranges from 0 (poor) to 1 (excellent) + +### Validity Constraints +- **UV-Vis constraint**: `uvvis_max_abs >= 0.1` +- **Coverage constraint**: `coverage_percentage >= 0.9` +- Only valid samples are used for objective model training + +### Bayesian Optimization +- Uses Gaussian Process (GP) models for both objective and constraints +- Acquisition function balances exploration and exploitation +- Handles discrete and continuous parameters appropriately + +## Notes + +- Speed parameter is handled in log scale for better optimization +- Reference images should be named with prefix matching `reference_name` (default: "background_normal_0deg") +- Image filenames should follow pattern: `R{round#}S{sample#}.{ext}` (e.g., R0S01.png) +- UV-Vis files should match pattern: `{round_sample_id}_*.csv` (e.g., R0S01_*.csv) + +## Troubleshooting + +**Issue: Reference images not found** +- Check `reference_dir` path in configuration +- Verify reference image naming matches `reference_name` pattern + +**Issue: UV-Vis files not found** +- Ensure UV-Vis directory path is correct +- Check filename pattern matches sample ID extraction + +**Issue: Low uniformity scores** +- Adjust `uniformity_std_sensitivity` and `uniformity_ent_sensitivity` parameters +- Review ROI coordinates to ensure correct region is analyzed + +**Issue: BO not suggesting good candidates** +- Check if enough valid samples exist in CSV +- Verify constraint thresholds are appropriate +- Review objective model predictions in metadata diff --git a/aamp_app/Image_Processing/constrained_bo_ver2.py b/aamp_app/Image_Processing/constrained_bo_ver2.py new file mode 100644 index 0000000..b08f476 --- /dev/null +++ b/aamp_app/Image_Processing/constrained_bo_ver2.py @@ -0,0 +1,767 @@ +#!/usr/bin/env python3 +""" +Constrained Bayesian Optimization for PProDOT Film Optimization. + +This implements a constrained BO system with: +- Objective Model: Learns uniformity_score from valid samples only +- Constraint Model: Learns P(valid|x) from all samples +- Acquisition: EI(x) × P(valid|x) +""" +from io import BytesIO +import torch +import numpy as np +import pandas as pd +from pathlib import Path +from typing import Optional, List, Tuple + +# BoTorch imports +from botorch.models import SingleTaskGP +from botorch.fit import fit_gpytorch_mll +from botorch.acquisition import qLogNoisyExpectedImprovement +from botorch.optim import optimize_acqf +from botorch.utils.transforms import normalize, unnormalize +from botorch.utils.sampling import draw_sobol_samples + +# GPyTorch imports +from gpytorch.mlls import ExactMarginalLogLikelihood +from gpytorch.kernels import ScaleKernel, RBFKernel +from gpytorch.priors import GammaPrior + + +class ConstrainedBayesianOptimizer: + """ + Constrained Bayesian Optimizer with separate objective and constraint models. + + Objective Model: Learns uniformity_score from valid samples (uvvis>=0.1 AND coverage>=0.9) + Constraint Model: Learns P(valid|x) from all samples + Acquisition: EI(x) × P(valid|x) + """ + + def __init__( + self, + bounds: torch.Tensor, + csv_data: bytes, + round_num: int = 0, + batch_size: int = 8, + seed: int = 42, + discrete_or_not: List[bool] = [False, True, True, True], + discrete_points: Optional[List[torch.Tensor]] = None, + verbose: bool = True, + log_scale_params: List[bool] = [True, False, False, False], # Speed is log scale + uvvis_threshold: float = 0.1, + coverage_threshold: float = 0.9 + ): + """ + Initialize Constrained Bayesian Optimizer. + + Args: + bounds: Parameter bounds tensor of shape (2, n_dims) [original scale] + csv_data: Path to CSV file containing training data + round_num: Current round number (uses data from rounds <= round_num) + batch_size: Number of candidates to suggest per iteration + seed: Random seed + discrete_or_not: List indicating which parameters are discrete + discrete_points: List of discrete values for each parameter + verbose: Whether to print progress messages + log_scale_params: List indicating which parameters are in log scale + uvvis_threshold: UV-Vis absorbance threshold for validity + coverage_threshold: Coverage percentage threshold for validity + """ + # Set device (default CPU) + self.device = torch.device("cpu") + + # Convert bounds to log scale for log-scale parameters + self.log_scale_params = log_scale_params + bounds_log = bounds.clone() + for i, is_log in enumerate(log_scale_params): + if is_log: + bounds_log[0, i] = np.log10(bounds[0, i].item()) + bounds_log[1, i] = np.log10(bounds[1, i].item()) + self.bounds = bounds_log.double() + self.bounds_original = bounds.double() + + self.batch_size = batch_size + self.seed = seed + self.discrete_or_not = discrete_or_not + self.discrete_points = discrete_points if discrete_points is not None else [] + self.csv_data = csv_data + self.round_num = round_num + self.verbose = verbose + self.uvvis_threshold = uvvis_threshold + self.coverage_threshold = coverage_threshold + + torch.manual_seed(seed) + np.random.seed(seed) + + assert bounds.shape[0] == 2, "Bounds must have shape (2, n_dims)" + assert (bounds[1] > bounds[0]).all(), "Upper bounds must be greater than lower bounds" + + self.n_dims = bounds.shape[1] + self.objective_model = None + self.constraint_model = None + self.train_X_all = None # All samples (normalized) + self.train_Y_obj = None # Objective values (valid samples only) + self.train_X_obj = None # Valid samples only (normalized) + self.train_Y_valid = None # Validity flags (all samples) + self.train_Y_mean = None # For denormalization + self.train_Y_std = None + + # Set to track already-seen parameter combinations + self.seen_param_keys = set() + + # Load initial data from CSV + self._load_data() + + def _load_data(self) -> None: + """Load and preprocess training data from CSV file.""" + df = pd.read_csv(BytesIO(self.csv_data)) + + # Filter by round number: use all rounds up to and including round_num + if 'round#' in df.columns: + df_all_rounds = df[df['round#'] <= self.round_num].copy() + if self.verbose: + rounds_used = sorted(df_all_rounds['round#'].unique().tolist()) + print(f" Using data from rounds: {rounds_used}") + else: + df_all_rounds = df.copy() + + # Build seen_param_keys from all rows (including NaN uniformity_score) + self.seen_param_keys.clear() + for _, row in df_all_rounds.iterrows(): + try: + sp = round(float(row['Speed']), 2) + T = int(round(float(row['Temperature']))) + g = int(round(float(row['gap']))) + v = int(round(float(row['Precursor Volume']))) + self.seen_param_keys.add((sp, T, g, v)) + except (KeyError, ValueError, TypeError): + # Skip rows with missing or invalid parameter values + continue + + if self.verbose: + print(f" Seen parameter combinations: {len(self.seen_param_keys)}") + + # Filter rows with valid uniformity_score for training + df = df_all_rounds.dropna(subset=['uniformity_score']).copy() + + if len(df) == 0: + raise ValueError(f"No valid data found for round {self.round_num} in {self.csv_data}") + + # Extract parameters: [Speed, Temperature, gap, Precursor Volume] + speed = df['Speed'].values + temperature = df['Temperature'].values + gap = df['gap'].values + volume = df['Precursor Volume'].values + + # Convert Speed to log scale + speed_log = np.log10(speed) + + # Stack in order: [Speed (log), Temperature, gap, volume] + train_X_all = np.column_stack([speed_log, temperature, gap, volume]) + train_Y_all = df['uniformity_score'].values.reshape(-1, 1) + + # Compute validity flags + uvvis_values = df['uvvis_max_abs'].values if 'uvvis_max_abs' in df.columns else np.zeros(len(df)) + coverage_values = df['coverage_percentage'].values if 'coverage_percentage' in df.columns else np.zeros(len(df)) + + valid_mask = (uvvis_values >= self.uvvis_threshold) & (coverage_values >= self.coverage_threshold) + valid_flags = valid_mask.astype(float).reshape(-1, 1) + + if self.verbose: + n_valid = valid_mask.sum() + print(f" Total samples: {len(df)}") + print(f" Valid samples (uvvis>={self.uvvis_threshold} AND coverage>={self.coverage_threshold}): {n_valid}/{len(df)} ({100*n_valid/len(df):.1f}%)") + + # Store data + self.train_X_all = torch.tensor(train_X_all, dtype=torch.float64) + self.train_Y_valid = torch.tensor(valid_flags, dtype=torch.float64) + + # Objective model uses only valid samples + if valid_mask.sum() > 0: + self.train_X_obj = torch.tensor(train_X_all[valid_mask], dtype=torch.float64) + self.train_Y_obj = torch.tensor(train_Y_all[valid_mask], dtype=torch.float64) + + # Store normalization parameters for objective + self.train_Y_mean = self.train_Y_obj.mean().item() + self.train_Y_std = max(self.train_Y_obj.std().item(), 1e-6) # Numerical stability + + if self.verbose: + print(f" Objective model: {len(self.train_X_obj)} valid samples") + print(f" Best observed score: {self.train_Y_obj.max().item():.6f}") + else: + raise ValueError("No valid samples found for objective model!") + + if self.verbose: + print(f" Constraint model: {len(self.train_X_all)} total samples") + print(f" Validity rate: {valid_flags.mean().item():.2%}") + + def _build_models(self) -> None: + """Build objective and constraint GP models.""" + if self.train_X_obj is None or self.train_Y_obj is None: + raise ValueError("No objective training data available.") + if self.train_X_all is None or self.train_Y_valid is None: + raise ValueError("No constraint training data available.") + + # Normalize all data + train_X_all_normalized = normalize(self.train_X_all.double(), self.bounds.double()) + train_X_obj_normalized = normalize(self.train_X_obj.double(), self.bounds.double()) + + # Normalize objective values + train_Y_obj_normalized = (self.train_Y_obj.double() - self.train_Y_mean) / self.train_Y_std + + # Constraint values are already 0/1, no normalization needed + + # Build Objective Model (valid samples only) + self.objective_model = SingleTaskGP( + train_X_obj_normalized, + train_Y_obj_normalized, + covar_module=ScaleKernel( + RBFKernel( + lengthscale_prior=GammaPrior(3.0, 1.0), + ard_num_dims=self.n_dims + ), + outputscale_prior=GammaPrior(2.0, 0.5) + ) + ) + + self.objective_model.train() + mll_obj = ExactMarginalLogLikelihood(self.objective_model.likelihood, self.objective_model) + fit_gpytorch_mll(mll_obj) + self.objective_model.eval() + + # Build Constraint Model (all samples) + self.constraint_model = SingleTaskGP( + train_X_all_normalized, + self.train_Y_valid.double(), + covar_module=ScaleKernel( + RBFKernel( + lengthscale_prior=GammaPrior(3.0, 1.0), + ard_num_dims=self.n_dims + ), + outputscale_prior=GammaPrior(2.0, 0.5) + ) + ) + + self.constraint_model.train() + mll_const = ExactMarginalLogLikelihood(self.constraint_model.likelihood, self.constraint_model) + fit_gpytorch_mll(mll_const) + self.constraint_model.eval() + + if self.verbose: + print(" Models fitted successfully") + + def _decode_and_snap(self, candidates_normalized: torch.Tensor) -> torch.Tensor: + """ + Decode normalized candidates to original scale with discrete snapping. + + Args: + candidates_normalized: Tensor of shape (n, n_dims) in normalized [0,1] space + + Returns: + Tensor of shape (n, n_dims) in original scale with discrete snapping applied + """ + # Inverse transform + candidates_unnormalized = unnormalize(candidates_normalized, self.bounds.double()) + + # Convert log-scale parameters back to original scale + candidates_original = candidates_unnormalized.clone() + for i, is_log in enumerate(self.log_scale_params): + if is_log: + candidates_original[:, i] = 10 ** candidates_unnormalized[:, i] + + # Apply discrete constraints + for i in range(candidates_original.shape[0]): + for j in range(candidates_original.shape[1]): + if self.discrete_or_not[j] and j < len(self.discrete_points): + column_values = self.discrete_points[j] + closest_value = torch.abs(column_values - candidates_original[i, j]) + candidates_original[i, j] = column_values[torch.argmin(closest_value)] + + # Round Speed (continuous variable) to 2 decimal places + candidates_original[:, 0] = torch.round(candidates_original[:, 0] * 100.0) / 100.0 + + return candidates_original + + def _compute_metadata(self, candidates_normalized: torch.Tensor, acquisition_function) -> dict: + """ + Compute all metadata for candidates. + + Args: + candidates_normalized: Tensor of shape (n, n_dims) in normalized space + acquisition_function: qLogNoisyExpectedImprovement instance + + Returns: + Dictionary with all metadata arrays + """ + with torch.no_grad(): + # EI log and raw + ei_log = acquisition_function(candidates_normalized.unsqueeze(1)).squeeze(-1) # (n,) + ei_raw = torch.clamp(torch.exp(ei_log), min=0.0) + + # Constraint GP posterior + constraint_posterior = self.constraint_model.posterior(candidates_normalized) + mu_valid_raw = constraint_posterior.mean.squeeze(-1) # (n,) + p_valid = torch.sigmoid(mu_valid_raw) + + # Objective GP posterior + objective_posterior = self.objective_model.posterior(candidates_normalized) + mu_obj = objective_posterior.mean.squeeze(-1) # (n,) + sigma_obj = objective_posterior.stddev.squeeze(-1) # (n,) + + # Denormalize objective predictions + mu_obj_denorm = mu_obj * self.train_Y_std + self.train_Y_mean + + # Constrained score + score_constrained = ei_raw * p_valid + + return { + 'ei_log': ei_log.cpu().numpy(), + 'ei_raw': ei_raw.cpu().numpy(), + 'p_valid': p_valid.cpu().numpy(), + 'score_constrained': score_constrained.cpu().numpy(), + 'mu_obj': mu_obj_denorm.cpu().numpy(), + 'sigma_obj': sigma_obj.cpu().numpy(), + 'mu_valid_raw': mu_valid_raw.cpu().numpy() + } + + def suggest(self, verbose: bool = None) -> Tuple[torch.Tensor, dict]: + """ + Suggest next batch of candidates using constrained acquisition. + + Acquisition: EI_raw(x) × P(valid|x) + + Returns: + candidates: Tensor of shape (batch_size, n_dims) containing suggested candidates + metadata: Dictionary with all metadata for each candidate + """ + if verbose is None: + verbose = self.verbose + + if verbose: + print("=" * 60) + print("GENERATING CONSTRAINED BO CANDIDATES") + print("=" * 60) + print(f"Objective samples: {len(self.train_X_obj)}") + print(f"Constraint samples: {len(self.train_X_all)}") + print(f"Batch size: {self.batch_size}") + print(f"Seen parameter combinations: {len(self.seen_param_keys)}") + print("=" * 60) + + # Build models + if verbose: + print("Building GP models...") + self._build_models() + + # Normalize training data + train_X_all_normalized = normalize(self.train_X_all.double(), self.bounds.double()) + train_X_obj_normalized = normalize(self.train_X_obj.double(), self.bounds.double()) + + # Unit bounds for normalized space + unit_bounds = torch.stack([ + torch.zeros(self.n_dims, dtype=torch.float64, device=self.device), + torch.ones(self.n_dims, dtype=torch.float64, device=self.device), + ]) + + # Acquisition function + acquisition_function = qLogNoisyExpectedImprovement( + model=self.objective_model, + X_baseline=train_X_obj_normalized, + prune_baseline=True, + cache_root=True + ) + + # Step 1: Generate main EI candidate pool (larger than batch_size) + pool_q = self.batch_size * 5 # 40 candidates for batch_size=8 + if verbose: + print(f"Step 1: Generating main EI candidate pool (q={pool_q})...") + + candidates_pool, _ = optimize_acqf( + acq_function=acquisition_function, + bounds=unit_bounds, + q=pool_q, + num_restarts=40, + raw_samples=500, + options={"batch_limit": 5, "maxiter": 200} + ) + + if verbose: + print(f" Generated {pool_q} candidate pool") + + # Step 2: Compute metadata for all candidates + if verbose: + print("Step 2: Computing metadata (EI, P(valid), scores)...") + + metadata_pool = self._compute_metadata(candidates_pool, acquisition_function) + score_constrained = torch.tensor(metadata_pool['score_constrained'], dtype=torch.float64) + + if verbose: + print(f" EI_log range: [{metadata_pool['ei_log'].min():.4f}, {metadata_pool['ei_log'].max():.4f}]") + print(f" EI_raw range: [{metadata_pool['ei_raw'].min():.4f}, {metadata_pool['ei_raw'].max():.4f}]") + print(f" P(valid) range: [{metadata_pool['p_valid'].min():.4f}, {metadata_pool['p_valid'].max():.4f}]") + print(f" Score_constrained range: [{score_constrained.min():.4f}, {score_constrained.max():.4f}]") + + # Step 3: Filter by uniqueness and select top candidates + if verbose: + print("Step 3: Filtering duplicates and selecting candidates...") + + sorted_indices = torch.argsort(score_constrained, descending=True) + selected_indices = [] # Store indices in candidates_pool + selected_original = [] + selected_metadata = { + 'ei_log': [], 'ei_raw': [], 'p_valid': [], 'score_constrained': [], + 'mu_obj': [], 'sigma_obj': [], 'mu_valid_raw': [], + 'candidate_rank': [], 'source': [] + } + + rank = 1 + for idx in sorted_indices: + if len(selected_indices) >= self.batch_size: + break + + # Decode and snap + candidate_norm = candidates_pool[idx:idx+1] + candidate_orig = self._decode_and_snap(candidate_norm) + + # Create parameter tuple + sp = round(float(candidate_orig[0, 0]), 2) + T = int(round(float(candidate_orig[0, 1]))) + g = int(round(float(candidate_orig[0, 2]))) + v = int(round(float(candidate_orig[0, 3]))) + param_key = (sp, T, g, v) + + # Check if already seen + if param_key in self.seen_param_keys: + continue + + # Add to selected + self.seen_param_keys.add(param_key) + selected_indices.append(idx.item()) # Store as int for indexing + selected_original.append(candidate_orig) + + # Store metadata + selected_metadata['ei_log'].append(metadata_pool['ei_log'][idx]) + selected_metadata['ei_raw'].append(metadata_pool['ei_raw'][idx]) + selected_metadata['p_valid'].append(metadata_pool['p_valid'][idx]) + selected_metadata['score_constrained'].append(metadata_pool['score_constrained'][idx]) + selected_metadata['mu_obj'].append(metadata_pool['mu_obj'][idx]) + selected_metadata['sigma_obj'].append(metadata_pool['sigma_obj'][idx]) + selected_metadata['mu_valid_raw'].append(metadata_pool['mu_valid_raw'][idx]) + selected_metadata['candidate_rank'].append(rank) + selected_metadata['source'].append('main_ei') + rank += 1 + + n_selected_main = len(selected_indices) + if verbose: + print(f" Selected {n_selected_main} unique candidates from main pool") + + # Step 4: Sobol fallback with max-min distance exploration + if len(selected_indices) < self.batch_size: + if verbose: + print(f"Step 4: Sobol fallback with max-min distance exploration to fill remaining {self.batch_size - len(selected_indices)} slots...") + + k = self.batch_size - len(selected_indices) + + # Construct X_seen: previous data + this round's main EI selections + X_prev = train_X_all_normalized # (N_prev, d) - already normalized + if n_selected_main > 0: + X_round = candidates_pool[torch.tensor(selected_indices, dtype=torch.long, device=candidates_pool.device)] # (n_selected_main, d) - already normalized + X_seen = torch.cat([X_prev, X_round], dim=0) # (N_prev + n_selected_main, d) + else: + X_seen = X_prev # No main EI selections yet + + if verbose: + if n_selected_main > 0: + print(f" X_seen: {X_seen.shape[0]} points (previous: {X_prev.shape[0]}, this round: {X_round.shape[0]})") + else: + print(f" X_seen: {X_seen.shape[0]} points (previous: {X_prev.shape[0]}, this round: 0)") + + # Generate and filter Sobol pool + fallback_pool_size = self.batch_size * 20 # 160 for batch_size=8 + max_pool_retries = 5 + valid_sobol_pool_norm = [] + valid_sobol_param_keys = [] + + n_sobol = fallback_pool_size + pool_retry = 0 + + while len(valid_sobol_pool_norm) < k and pool_retry < max_pool_retries: + # Generate Sobol samples + sobol_raw = draw_sobol_samples( + bounds=unit_bounds, + n=1, + q=n_sobol + ).squeeze(0).double() # (n_sobol, d) + + # Filter duplicates + for i in range(sobol_raw.shape[0]): + candidate_norm = sobol_raw[i:i+1] # (1, d) + candidate_orig = self._decode_and_snap(candidate_norm) + + # Create parameter tuple + sp = round(float(candidate_orig[0, 0]), 2) + T = int(round(float(candidate_orig[0, 1]))) + g = int(round(float(candidate_orig[0, 2]))) + v = int(round(float(candidate_orig[0, 3]))) + param_key = (sp, T, g, v) + + # Check if already seen + if param_key not in self.seen_param_keys: + valid_sobol_pool_norm.append(candidate_norm) + valid_sobol_param_keys.append(param_key) + + if len(valid_sobol_pool_norm) < k: + n_sobol *= 2 + pool_retry += 1 + if verbose: + print(f" Pool retry {pool_retry}: Found {len(valid_sobol_pool_norm)}/{k} valid candidates, expanding pool to {n_sobol}") + + if len(valid_sobol_pool_norm) < k: + raise RuntimeError( + f"ConstrainedBO: could not generate {k} unique Sobol candidates " + f"after {max_pool_retries} pool expansion retries. " + f"Consider relaxing the uniqueness constraint or adjusting the search space." + ) + + # Stack valid pool + sobol_pool_norm = torch.cat(valid_sobol_pool_norm, dim=0) # (M, d) where M >= k + + if verbose: + print(f" Valid Sobol pool: {sobol_pool_norm.shape[0]} candidates") + + # Greedy max-min distance selection + selected_fallback_norm = [] + selected_fallback_param_keys = [] + remaining_pool = sobol_pool_norm.clone() + remaining_param_keys = valid_sobol_param_keys.copy() + X_seen_current = X_seen.clone() + + for i in range(k): + # Compute minimum distances to X_seen + distances = torch.cdist(remaining_pool, X_seen_current) # (M_remaining, N_seen) + dist_min, _ = distances.min(dim=1) # (M_remaining,) + + # Select candidate with maximum min-distance + best_idx = dist_min.argmax().item() + x_best = remaining_pool[best_idx:best_idx+1] # (1, d) + param_key_best = remaining_param_keys[best_idx] + + # Add to selected + selected_fallback_norm.append(x_best) + selected_fallback_param_keys.append(param_key_best) + X_seen_current = torch.cat([X_seen_current, x_best], dim=0) + + # Remove from remaining pool + mask = torch.ones(remaining_pool.shape[0], dtype=torch.bool, device=remaining_pool.device) + mask[best_idx] = False + remaining_pool = remaining_pool[mask] + remaining_param_keys = [rk for j, rk in enumerate(remaining_param_keys) if j != best_idx] + + if verbose and (i + 1) % max(1, k // 4) == 0: + print(f" Selected {i+1}/{k} fallback candidates (min distance: {dist_min[best_idx]:.4f})") + + # Stack selected fallback candidates + selected_fallback_norm = torch.cat(selected_fallback_norm, dim=0) # (k, d) + + # Compute metadata for selected fallback candidates + if verbose: + print(f" Computing metadata for {k} fallback candidates...") + + with torch.no_grad(): + # Objective GP posterior + objective_posterior = self.objective_model.posterior(selected_fallback_norm) + mu_obj_fallback = objective_posterior.mean.squeeze(-1) # (k,) + sigma_obj_fallback = objective_posterior.stddev.squeeze(-1) # (k,) + mu_obj_fallback_denorm = mu_obj_fallback * self.train_Y_std + self.train_Y_mean + + # EI + ei_log_fallback = acquisition_function(selected_fallback_norm.unsqueeze(1)).squeeze(-1) # (k,) + ei_raw_fallback = torch.clamp(torch.exp(ei_log_fallback), min=0.0) + + # Constraint GP posterior + constraint_posterior = self.constraint_model.posterior(selected_fallback_norm) + mu_valid_raw_fallback = constraint_posterior.mean.squeeze(-1) # (k,) + p_valid_fallback = torch.sigmoid(mu_valid_raw_fallback) + + # Constrained score + score_constrained_fallback = ei_raw_fallback * p_valid_fallback + + # Decode and snap for final output + selected_fallback_original = self._decode_and_snap(selected_fallback_norm) + + # Add to selected lists + for i in range(k): + param_key = selected_fallback_param_keys[i] + self.seen_param_keys.add(param_key) + selected_indices.append(len(selected_indices)) # Dummy index for Sobol + selected_original.append(selected_fallback_original[i:i+1]) + + # Store metadata + selected_metadata['ei_log'].append(ei_log_fallback[i].item()) + selected_metadata['ei_raw'].append(ei_raw_fallback[i].item()) + selected_metadata['p_valid'].append(p_valid_fallback[i].item()) + selected_metadata['score_constrained'].append(score_constrained_fallback[i].item()) + selected_metadata['mu_obj'].append(mu_obj_fallback_denorm[i].item()) + selected_metadata['sigma_obj'].append(sigma_obj_fallback[i].item()) + selected_metadata['mu_valid_raw'].append(mu_valid_raw_fallback[i].item()) + selected_metadata['candidate_rank'].append(rank) + selected_metadata['source'].append('sobol_fallback') + rank += 1 + + if verbose: + print(f" Selected {k} fallback candidates using max-min distance exploration") + + # Stack selected candidates + candidates_final = torch.cat(selected_original, dim=0).float() + + # Re-rank all selected candidates by score_constrained + final_scores = torch.tensor(selected_metadata['score_constrained'], dtype=torch.float64) + final_rank_order = torch.argsort(final_scores, descending=True) + + # Reorder candidates and metadata + candidates_final = candidates_final[final_rank_order] + for key in selected_metadata: + if key == 'candidate_rank': + # Re-assign ranks 1 to batch_size + selected_metadata[key] = list(range(1, self.batch_size + 1)) + else: + # Reorder by final_rank_order + arr = np.array(selected_metadata[key]) + selected_metadata[key] = arr[final_rank_order.cpu().numpy()].tolist() + + if verbose: + print(f"\nFinal {self.batch_size} candidates (ranked by score_constrained):") + for i in range(self.batch_size): + print(f" [{selected_metadata['candidate_rank'][i]}] [{selected_metadata['source'][i]}] " + f"Speed={candidates_final[i, 0]:.2f}, " + f"Temp={candidates_final[i, 1]:.1f}, " + f"Gap={candidates_final[i, 2]:.1f}, " + f"Volume={candidates_final[i, 3]:.1f}") + print(f" EI_log={selected_metadata['ei_log'][i]:.4f}, " + f"EI_raw={selected_metadata['ei_raw'][i]:.4f}, " + f"P(valid)={selected_metadata['p_valid'][i]:.4f}, " + f"Score={selected_metadata['score_constrained'][i]:.4f}") + + return candidates_final, selected_metadata + + def save_candidates_to_csv( + self, + candidates: torch.Tensor, + metadata: Optional[dict] = None, + next_round_num: Optional[int] = None, + solvent: str = "CB", + concentration: int = 40 + ) -> None: + """ + Save suggested candidates to CSV file. + + Args: + candidates: Tensor of shape (batch_size, n_dims) containing suggested candidates + metadata: Optional dictionary with EI, P_valid, Score values for each candidate + next_round_num: Round number for new candidates (default: round_num + 1) + solvent: Solvent value for new rows (default: "CB") + concentration: Concentration value for new rows (default: 40) + """ + df = pd.read_csv(BytesIO(self.csv_data)) + + # Determine next round number + if next_round_num is None: + next_round_num = self.round_num + 1 + + # Determine starting sample number + if 'Sample #' in df.columns and 'round#' in df.columns and len(df) > 0: + round_df = df[df['round#'] == next_round_num] + if len(round_df) > 0: + start_sample = int(round_df['Sample #'].max()) + 1 + else: + start_sample = 1 # New round, start from 1 + else: + start_sample = 1 + + # Convert candidates to numpy + candidates_np = candidates.cpu().numpy() + batch_size = candidates_np.shape[0] + + # Create new rows + new_rows = [] + for i in range(batch_size): + speed = float(candidates_np[i, 0]) + temperature = float(candidates_np[i, 1]) + gap = float(candidates_np[i, 2]) + volume = float(candidates_np[i, 3]) + + new_row = { + 'Unnamed: 0': 'PProDOT', + 'round#': next_round_num, + 'Sample #': start_sample + i, + 'Temperature': int(round(temperature)), + 'Speed': round(speed, 2), # Round to 2 decimal places to avoid float precision issues + 'gap': int(round(gap)), + 'Solvent': solvent, + 'Concentration': concentration, + 'Precursor Volume': int(round(volume)), + 'uvvis_max_abs': np.nan, + 'uniformity_score': np.nan, + } + + # Add metadata if provided + if metadata is not None: + # Required metadata + if 'ei_log' in metadata and i < len(metadata['ei_log']): + new_row['ei_log'] = metadata['ei_log'][i] + if 'ei_raw' in metadata and i < len(metadata['ei_raw']): + new_row['ei_raw'] = metadata['ei_raw'][i] + if 'p_valid' in metadata and i < len(metadata['p_valid']): + new_row['p_valid'] = metadata['p_valid'][i] + if 'score_constrained' in metadata and i < len(metadata['score_constrained']): + new_row['score_constrained'] = metadata['score_constrained'][i] + if 'candidate_rank' in metadata and i < len(metadata['candidate_rank']): + new_row['candidate_rank'] = int(metadata['candidate_rank'][i]) + if 'source' in metadata and i < len(metadata['source']): + new_row['source'] = metadata['source'][i] + + # Additional analysis metadata + if 'mu_obj' in metadata and i < len(metadata['mu_obj']): + new_row['mu_obj'] = metadata['mu_obj'][i] + if 'sigma_obj' in metadata and i < len(metadata['sigma_obj']): + new_row['sigma_obj'] = metadata['sigma_obj'][i] + if 'mu_valid_raw' in metadata and i < len(metadata['mu_valid_raw']): + new_row['mu_valid_raw'] = metadata['mu_valid_raw'][i] + + # Update seen_param_keys immediately + param_key = (round(speed, 2), int(round(temperature)), int(round(gap)), int(round(volume))) + self.seen_param_keys.add(param_key) + + for col in df.columns: + if col not in new_row: + new_row[col] = np.nan + + new_rows.append(new_row) + + new_df = pd.DataFrame(new_rows) + + # Ensure all columns from new_df are included (especially metadata columns) + # If metadata columns don't exist in df, add them with NaN + for col in new_df.columns: + if col not in df.columns: + df[col] = np.nan + + # Reorder new_df to match df columns (including newly added ones) + new_df = new_df[df.columns] + combined_df = pd.concat([df, new_df], ignore_index=True) + + if self.verbose: + print(f"\nSaved {batch_size} candidates") + print(f" Round: {next_round_num}, Samples: {start_sample} to {start_sample + batch_size - 1}") + if metadata is not None: + print(f" Metadata (EI, P_valid, Score) also saved") + + return combined_df + + def reload_data(self) -> None: + """Reload data from CSV file (useful after CSV is updated).""" + if self.verbose: + print(f"Reloading data from {self.csv_data}...") + self._load_data() + # Reset models to force refitting + self.objective_model = None + self.constraint_model = None diff --git a/aamp_app/Image_Processing/image_processing_changhyun.py b/aamp_app/Image_Processing/image_processing_changhyun.py new file mode 100644 index 0000000..937173b --- /dev/null +++ b/aamp_app/Image_Processing/image_processing_changhyun.py @@ -0,0 +1,522 @@ +#!/usr/bin/env python3 +"""Simple image processing script using compute_uniformity_score. + +Processes images from 'data/Round0/Raw' directory. +""" + +import sys +from pathlib import Path + +# Add project root to Python path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from typing import Dict, Any + +import pandas as pd +import cv2 +import numpy as np +from .Image_processing.scoring import compute_uniformity_score, _match_reference_for +from .Image_processing.pipeline import analyze_image +from .Image_processing.config import UniformityConfig + + +def _save_mask_with_footer( + mask: np.ndarray, + sample_roi: np.ndarray | None, + source_image: np.ndarray | None, + roi_coords: tuple[int, int, int, int], + image_name: str, + coverage_pct: float | None, + uniformity_value: float | None, + uvvis_max: float | None, + debug_info: dict | None, + output_path: Path, +): + if mask is None: + return + + def _prepare_panel( + img: np.ndarray, + label: str | None = None, + draw_rect: bool = False, + rect: tuple[int, int, int, int] | None = None, + ) -> np.ndarray: + if img.dtype != np.uint8: + img_disp = np.clip(img, 0, 255).astype(np.uint8) + else: + img_disp = img.copy() + if len(img_disp.shape) == 2: + img_disp = cv2.cvtColor(img_disp, cv2.COLOR_GRAY2BGR) + + if draw_rect and rect is not None: + x, y, w, h = rect + cv2.rectangle(img_disp, (x, y), (x + w, y + h), (0, 0, 255), 2) + + if label: + label_strip = np.full((30, img_disp.shape[1], 3), 255, dtype=np.uint8) + cv2.putText( + label_strip, + label, + (10, 20), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (0, 0, 0), + 1, + cv2.LINE_AA, + ) + panel = cv2.vconcat([label_strip, img_disp]) + else: + panel = img_disp + panel = cv2.copyMakeBorder(panel, 1, 1, 1, 1, cv2.BORDER_CONSTANT, value=(0, 0, 0)) + return panel + + def _pad_to_width(img: np.ndarray, width: int) -> np.ndarray: + if img.shape[1] >= width: + if img.shape[1] == width: + return img + scale = width / img.shape[1] + new_h = int(img.shape[0] * scale) + return cv2.resize(img, (width, new_h), interpolation=cv2.INTER_AREA) + pad_total = width - img.shape[1] + left = pad_total // 2 + right = pad_total - left + return cv2.copyMakeBorder(img, 0, 0, left, right, cv2.BORDER_CONSTANT, value=(255, 255, 255)) + + def _resize_to_height(img: np.ndarray, height: int) -> np.ndarray: + if img.shape[0] == height: + return img + scale = height / img.shape[0] + new_w = max(1, int(round(img.shape[1] * scale))) + interpolation = cv2.INTER_AREA if scale < 1 else cv2.INTER_CUBIC + return cv2.resize(img, (new_w, height), interpolation=interpolation) + + if len(mask.shape) == 2: + mask_binary = mask + else: + mask_binary = cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY) + + if sample_roi is None: + sample_roi_color = np.zeros((mask_binary.shape[0], mask_binary.shape[1], 3), dtype=np.uint8) + else: + sample_roi_color = sample_roi + if sample_roi_color.dtype != np.uint8: + sample_roi_color = np.clip(sample_roi_color, 0, 255).astype(np.uint8) + if len(sample_roi_color.shape) == 2: + sample_roi_color = cv2.cvtColor(sample_roi_color, cv2.COLOR_GRAY2BGR) + + masked_roi = cv2.bitwise_and(sample_roi_color, sample_roi_color, mask=mask_binary) + mask_color = cv2.cvtColor(mask_binary, cv2.COLOR_GRAY2BGR) + + roi_x, roi_y, roi_w, roi_h = roi_coords + if source_image is None: + full_panel_img = np.zeros_like(sample_roi_color) + else: + if source_image.dtype != np.uint8: + full_panel_img = np.clip(source_image, 0, 255).astype(np.uint8) + else: + full_panel_img = source_image.copy() + + panel_full = _prepare_panel(full_panel_img, None, True, (roi_x, roi_y, roi_w, roi_h)) + panel_original = _prepare_panel(sample_roi_color, "Original ROI") + panel_masked = _prepare_panel(masked_roi, "Masked ROI (film=color, no film=black)") + panel_binary = _prepare_panel(mask_color, "Binary Mask (film=white, no film=black)") + + # Unify all panels to maximum height (panel 1 may have different height since it has no title) + max_height = max(panel_full.shape[0], panel_original.shape[0], panel_masked.shape[0], panel_binary.shape[0]) + panel_full = _resize_to_height(panel_full, max_height) + panel_original = _resize_to_height(panel_original, max_height) + panel_masked = _resize_to_height(panel_masked, max_height) + panel_binary = _resize_to_height(panel_binary, max_height) + + col1_width = max(panel_full.shape[1], panel_original.shape[1]) + col2_width = max(panel_masked.shape[1], panel_binary.shape[1]) + + panel_full = _pad_to_width(panel_full, col1_width) + panel_original = _pad_to_width(panel_original, col1_width) + panel_masked = _pad_to_width(panel_masked, col2_width) + panel_binary = _pad_to_width(panel_binary, col2_width) + + spacer_col = np.full((max_height, 10, 3), 255, dtype=np.uint8) + + top_row = cv2.hconcat([panel_full, spacer_col, panel_masked]) + bottom_row = cv2.hconcat([panel_original, spacer_col.copy(), panel_binary]) + + spacer_row = np.full((10, top_row.shape[1], 3), 255, dtype=np.uint8) + stacked = cv2.vconcat([top_row, spacer_row, bottom_row]) + + # Add title (from filename up to _mask) + title_text = output_path.stem.replace("_mask", "") + title_height = 50 + title_bar = np.full((title_height, stacked.shape[1], 3), 255, dtype=np.uint8) + cv2.putText( + title_bar, + title_text, + (10, 35), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (0, 0, 0), + 2, + cv2.LINE_AA, + ) + + # Create footer (divided into multiple columns) + debug_info = debug_info or {} + cov_display = (coverage_pct or 0.0) * 100 + uniformity_score = debug_info.get("uniformity_score", uniformity_value if uniformity_value is not None else 0.0) + + # Reference-based Uniformity statistics + ref_std = debug_info.get("ref_std", 0.0) + ref_entropy = debug_info.get("ref_entropy", 0.0) + sample_std = debug_info.get("sample_std", 0.0) + sample_entropy = debug_info.get("sample_entropy", 0.0) + delta_std = debug_info.get("delta_std", 0.0) + delta_entropy = debug_info.get("delta_entropy", 0.0) + std_sensitivity = debug_info.get("uniformity_std_sensitivity", 15.0) + ent_sensitivity = debug_info.get("uniformity_ent_sensitivity", 2.0) + use_model_ref = debug_info.get("use_model_reference", 0.0) > 0.5 # Convert float to bool + + uvvis_display = uvvis_max if uvvis_max is not None else 0.0 + threshold_val = debug_info.get("saturation_threshold", 0.0) + ref_mean = debug_info.get("ref_s_mean", 0.0) + + footer_height = 200 + footer = np.full((footer_height, stacked.shape[1], 3), 255, dtype=np.uint8) + + # Two-column layout + col_width = stacked.shape[1] // 2 + left_col_x = 15 + right_col_x = col_width + 15 + + font = cv2.FONT_HERSHEY_SIMPLEX + font_scale = 0.7 + color = (0, 0, 0) + thickness = 1 + line_height = 28 + y_start = 25 + + # Reference notation (whether Model Reference is used) + ref_label = "Ref" if not use_model_ref else "Model Ref" + + # Left column + left_lines = [ + f"File: {image_name}", + f"Coverage: {cov_display:.1f}%", + f"Uniformity: {uniformity_score:.3f}", + f" ({ref_label} Std: {ref_std:.2f}, Sample: {sample_std:.2f})", + f" ({ref_label} Ent: {ref_entropy:.2f}, Sample: {sample_entropy:.2f})", + f"UV-Vis max (300-800nm): {uvvis_display:.3f}", + ] + for idx, text in enumerate(left_lines): + y = y_start + idx * line_height + cv2.putText(footer, text, (left_col_x, y), font, font_scale, color, thickness, cv2.LINE_AA) + + # Right column + right_lines = [ + f"Threshold: {threshold_val:.1f}", + f"Ref Mean: {ref_mean:.1f}", + f"Delta Std: {delta_std:.2f} (K1: {std_sensitivity:.1f})", + f"Delta Ent: {delta_entropy:.2f} (K2: {ent_sensitivity:.1f})", + ] + if use_model_ref: + right_lines.append("Note: Model Reference used") + for idx, text in enumerate(right_lines): + y = y_start + idx * line_height + cv2.putText(footer, text, (right_col_x, y), font, font_scale, color, thickness, cv2.LINE_AA) + + combined = cv2.vconcat([title_bar, stacked, footer]) + cv2.imwrite(str(output_path), combined) + + +def process_single_directory( + main_dir: Path, + roi_x: int, + roi_y: int, + roi_width: int, + roi_height: int, + uvvis_abs_threshold: float, + coverage_threshold: float, + metadata_base: Dict[str, Any], +) -> None: + """Process images for a single directory.""" + + raw_dir = main_dir / "Raw" + reference_dir = main_dir / "blank" + uvvis_dir = main_dir / "UV-Vis" + initial_parameters_csv = main_dir / "PProDOT_CB_Campaign_parameters.csv" + output_csv = main_dir / "processing_results_simple.csv" + + print(f"\n{'='*60}") + print(f"Processing directory: {main_dir}") + print(f"{'='*60}") + + # Load initial parameters CSV if available + parameters_df = None + if initial_parameters_csv.exists(): + parameters_df = pd.read_csv(initial_parameters_csv) + if parameters_df.columns[0] == '' or parameters_df.columns[0].startswith('Unnamed'): + parameters_df = parameters_df.iloc[:, 1:] + parameters_df.columns = parameters_df.columns.str.strip() + + # Prepare metadata for compute_uniformity_score + metadata = metadata_base.copy() + metadata.update({ + "reference_dir": str(reference_dir.resolve()), + "uvvis_dir": str(uvvis_dir.resolve()), + }) + + # Find all image files + if not raw_dir.exists(): + print(f" Warning: Raw directory not found: {raw_dir}") + return + + image_exts = {'.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff'} + image_files = [f for f in raw_dir.iterdir() if f.is_file() and f.suffix.lower() in image_exts] + + if len(image_files) == 0: + print(f" Warning: No image files found in {raw_dir}") + return + + print(f"Found {len(image_files)} image files") + + debug_mask_dir = main_dir / "Debug_Masks" + debug_mask_dir.mkdir(parents=True, exist_ok=True) + + # Process each image + results = [] + for i, image_path in enumerate(sorted(image_files), 1): + print(f"[{i}/{len(image_files)}] Processing {image_path.name}...") + sample_roi = None + final_mask = None + debug_info = None + + # Extract round# and Sample # from filename (R0S01 -> round=0, sample=1) + import re + match = re.match(r'R(\d+)S(\d+)', image_path.stem, re.IGNORECASE) + round_num = int(match.group(1)) if match else None + sample_num = int(match.group(2)) if match else None + + # Compute uniformity score and full metrics + try: + # Use verbose=True for first image to diagnose issues + verbose = (i == 1) + uniformity_score = compute_uniformity_score( + image_path=str(image_path), + metadata=metadata, + verbose=verbose + ) + + # Also get full metrics for CSV + img = cv2.imread(str(image_path)) + if img is not None: + cfg = UniformityConfig( + input_dir=str(image_path.parent), + use_reference=True, + reference_dir=str(reference_dir.resolve()), + reference_name="background_normal_0deg", + roi_x=roi_x, + roi_y=roi_y, + roi_width=roi_width, + roi_height=roi_height, + uvvis_dir=str(uvvis_dir.resolve()), + uvvis_abs_threshold=uvvis_abs_threshold, + coverage_threshold=coverage_threshold, + coverage_k=metadata.get("coverage_k", 3.0), + uniformity_std_sensitivity=metadata.get("uniformity_std_sensitivity", 15.0), + uniformity_ent_sensitivity=metadata.get("uniformity_ent_sensitivity", 2.0), + compute_learning_features=True, + ) + + # Load reference image (required for ROI feature computation) + ref_path = _match_reference_for(image_path, cfg.reference_dir, cfg.reference_name) + ref_bgr = None + if ref_path is not None and ref_path.exists(): + ref_bgr = cv2.imread(str(ref_path)) + + ( + metrics, + sample_roi, + reference_roi, + final_mask, + debug_info, + ) = analyze_image(img, cfg, ref_bgr=ref_bgr, image_path=image_path) + + # Get uniformity score from metrics or debug_info (new CV-based calculation) + uniformity_score_from_metrics = metrics.get("uniformity_score") + uniformity_score_from_debug = debug_info.get("uniformity_score") if isinstance(debug_info, dict) else None + if uniformity_score_from_metrics is not None: + uniformity_score = uniformity_score_from_metrics + elif uniformity_score_from_debug is not None: + uniformity_score = uniformity_score_from_debug + + # Start with basic info + result = { + "round#": round_num, + "Sample #": sample_num, + "image_name": image_path.name, + "image_path": str(image_path), + "uniformity_score": uniformity_score, + } + + # Add all metrics + result.update(metrics) + else: + result = { + "round#": round_num, + "Sample #": sample_num, + "image_name": image_path.name, + "image_path": str(image_path), + "uniformity_score": uniformity_score, + } + except Exception as e: + print(f" Error: {e}") + import traceback + traceback.print_exc() + uniformity_score = None + result = { + "round#": round_num, + "Sample #": sample_num, + "image_name": image_path.name, + "image_path": str(image_path), + "uniformity_score": None, + } + + # Add parameters from CSV if available + if parameters_df is not None and round_num is not None and sample_num is not None: + param_row = parameters_df[ + (parameters_df["round#"] == round_num) & + (parameters_df["Sample #"] == sample_num) + ] + if not param_row.empty: + row = param_row.iloc[0] + result["Temperature"] = row.get("Temperature", None) + result["Speed"] = row.get("Speed", None) + result["gap"] = row.get("gap", None) + result["Solvent"] = row.get("Solvent", None) + result["Concentration"] = row.get("Concentration", None) + result["Precursor Volume"] = row.get("Precursor Volume", None) + + if isinstance(debug_info, dict): + result.update(debug_info) + + if final_mask is not None: + coverage_pct = result.get("coverage_percentage") + uvvis_max = result.get("uvvis_max_abs") + uniformity_val = uniformity_score if uniformity_score is not None else coverage_pct + threshold_val = (debug_info or {}).get("saturation_threshold", metadata.get("coverage_k", 0.0)) + mask_path = debug_mask_dir / f"{image_path.stem}_mask_th{threshold_val:.1f}.png" + _save_mask_with_footer( + final_mask, + sample_roi, + img, + (roi_x, roi_y, roi_width, roi_height), + image_path.name, + coverage_pct, + uniformity_val, + uvvis_max, + debug_info, + mask_path, + ) + + results.append(result) + + # Save to CSV with preferred column order + df = pd.DataFrame(results) + preferred_order = [ + "round#", + "Sample #", + "image_name", + "image_path", + "coverage_percentage", + "uvvis_max_abs", + "uniformity_score", + ] + remaining_cols = [col for col in df.columns if col not in preferred_order] + df = df[[col for col in preferred_order if col in df.columns] + remaining_cols] + df.to_csv(output_csv, index=False) + print(f"\nResults saved to {output_csv}") + return results + + +def main(): + """Main entry point.""" + + # ============================================ + # Configuration: Choose method for processing multiple directories + # ============================================ + + # Method 1: Specify directly as a list + main_dirs = [ + Path("Round0"), + # Path("data/Round0_1"), + # Path("data/Round0_2"), + # Path("data/Round0_3"), + # Path("data/Round1"), + # Path("data/Round2"), + # Path("data/Round3"), + # Path("data/Round4"), + # Path("data/Round5"), + # Path("data/PProDOT_2nd_dataset") + # Add desired directories + ] + + # Method 2: Automatically discover all Round* directories under data/ (comment out Method 1 first) + # data_base = Path("data") + # if data_base.exists(): + # main_dirs = [d for d in data_base.iterdir() if d.is_dir() and d.name.startswith("Round")] + # else: + # main_dirs = [] + + # Common settings + roi_x = 580 + roi_y = 400 + roi_width = 913 + roi_height = 415 + + # Thresholds + uvvis_abs_threshold = 0.1 + coverage_threshold = 0.1 + + # Common metadata (reference_dir and uvvis_dir are automatically set for each directory) + metadata_base = { + "roi_x": roi_x, + "roi_y": roi_y, + "roi_width": roi_width, + "roi_height": roi_height, + "uvvis_abs_threshold": uvvis_abs_threshold, + "coverage_threshold": coverage_threshold, + "coverage_k": 1.0, + "uniformity_std_sensitivity": 15.0, # K1: Sensitivity to Std difference + "uniformity_ent_sensitivity": 2.0, # K2: Sensitivity to Entropy difference + } + + # Process each directory + for main_dir in main_dirs: + if not main_dir.exists(): + print(f"Warning: Directory not found: {main_dir}, skipping...") + continue + + try: + process_single_directory( + main_dir=main_dir, + roi_x=roi_x, + roi_y=roi_y, + roi_width=roi_width, + roi_height=roi_height, + uvvis_abs_threshold=uvvis_abs_threshold, + coverage_threshold=coverage_threshold, + metadata_base=metadata_base, + ) + except Exception as e: + print(f"Error processing {main_dir}: {e}") + import traceback + traceback.print_exc() + continue + + print(f"\n{'='*60}") + print("All directories processed!") + print(f"{'='*60}") + + +if __name__ == "__main__": + main() diff --git a/aamp_app/Image_Processing/requirements.txt b/aamp_app/Image_Processing/requirements.txt new file mode 100644 index 0000000..2579f17 --- /dev/null +++ b/aamp_app/Image_Processing/requirements.txt @@ -0,0 +1,11 @@ +# Core dependencies +numpy>=1.21.0 +opencv-python>=4.5.0 +pandas>=1.3.0 +scikit-image>=0.18.0 + +# PyTorch and Bayesian Optimization +torch>=1.11.0 +botorch>=0.6.0 +gpytorch>=1.6.0 + diff --git a/commands/__init__.py b/aamp_app/__init__.py similarity index 100% rename from commands/__init__.py rename to aamp_app/__init__.py diff --git a/aamp_app/adding_devices.md b/aamp_app/adding_devices.md new file mode 100644 index 0000000..eeccde5 --- /dev/null +++ b/aamp_app/adding_devices.md @@ -0,0 +1,308 @@ +# Adding Devices to the Device Dropdown +This is a step-by-step guide for how to add devices and commands to the dropdowns in the View tab + +### Step 1: + +Ensure that you have the device_name.py file and the device_name_commands.py file for the device you want to add. device_name.py should be in aamp_app/devices and device_name_commands.py should be in aamp_app/commands. You will have to refer to both of these files later to populate the util file correctly. + +### Step 2: + +Navigate to aamp_app/util.py. This file contains the configurations for each devices and their respective commands as they show up in the UI. + +### Step 3: + +Import the device at the top of the file. For example: +```python +from devices.psd6_syringe_pump import PSD6SyringePump +``` + +Also import all functions from its respective commands file: +```python +from commands.psd6_syringe_pump_commands import * +``` + +### Step 4: + +Add the device to the devices_ref_redundancy object. The device specifications should match what is outlined in the device's python file. All parameters that don't already have a default value defined in the class and are not optional must be included. For example, here is the python file defining the PSD6SyringePump: +```python +class PSD6SyringePump(SerialDevice): + status_dict = { + '@': "Pump busy - no error", + '`': "Pump ready - no error", + 'a': "Initialization error - pump failed to initialize", + 'b': "Invalid command - unrecognized command is used.", + 'c': "Invalid operand - invalid parameter is given with a command.", + 'd': "Invalid command sequence - command communication protocol is incorrect", + 'f': "EEPROM failure - EEPROM is faulty", + 'g': "Syringe not initialized - syringe failed to initialize", + 'i': "Syringe overload - syringe encounters excessive back pressure", + 'j': "Valve overload - valve drive encounters excessive back pressure", + 'k': "Syringe move not allowed - valve is in the bypass or throughput position, syringe move commands are not allowed", + 'o': "Pump busy - command buffer is full" + } + + # volume_factor = {'ul': 1.0, 'ml': 1000.0} + # time_factor = {'s': 1.0, 'min': 1.0/60.0} + + def __init__( + self, + name: str, + port: str, + baudrate: int = 9600, + timeout: Optional[float] = 10.0, + stroke_volume: float = 5000.0, + stroke_steps: int = 6000, + default_flowrate: float = 1000.0, + # volume_unit: str = 'ul', + # time_unit: str = 's', + port_dead_volumes: List[float] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + poll_interval: float = 0.1): + + super().__init__(name, port, baudrate, timeout) + self._stroke_volume = stroke_volume + self._stroke_steps = stroke_steps + self._default_flowrate = default_flowrate + # self._volume_unit = volume_unit + # self._time_unit = time_unit + self._port_dead_volumes = port_dead_volumes + self._poll_interval = poll_interval + + # technically if I send a command that is out of range of these values + # the pump should be able to give me an error message anyways + # but if I store a default flowrate in this object I'd rather + # have it be correct, than let the pump handle it because + # setting a wrong default flowrate here does not yield an error until + # later when some other command is sent that uses it + self._max_steps_per_sec = 10000 + self._min_steps_per_sec = 2 + + # # this is ok to leave out since anything using a position immediately issues a command + # self._min_position = 0 + # self._max_position = 6000 + +``` + +And here is the corresponding device specification in JSON in util.py: +```python +"PSD6SyringePump": { + "obj": PSD6SyringePump, + "serial": True, + "serial_sequence": ["PSD6SyringePumpInitialize"], + "import_device": "from devices.psd6_syringe_pump import PSD6SyringePump", + "import_commands": "from commands.psd6_syringe_pump_commands import *", + "init": { + "default_code": "PSD6SyringePump(name='PSD6SyringePump', port='COM5', baudrate=9600, timeout=10.0)", + "obj_name": "PSD6SyringePump", + "args": { + "name": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "port": { + "default": "COM5", + "type": str, + "notes": "Port", + }, + "baudrate": { + "default": 9600, + "type": int, + "notes": "Baudrate", + }, + "timeout": { + "default": 10.0, + "type": float, + "notes": "Timeout", + }, + }, + }, + # ... +} +``` + +### Step 5: + +Add a nested object to the device object containing each command. Similarly to the previous step, this requires looking at what parameters each function takes and their defaults. Here are the functions in the commands file: + +```python +class PSD6SyringePumpConnect(PSD6SyringePumpParentCommand): + """Open the serial port to the PSD6 syringe pump.""" + + def __init__(self, receiver: PSD6SyringePump, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_serial()) + +class PSD6SyringePumpInitialize(PSD6SyringePumpParentCommand): + def __init__(self, receiver: PSD6SyringePump, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + +class PSD6SyringePumpMoveValve(PSD6SyringePumpParentCommand): + def __init__(self, receiver: PSD6SyringePump, valve_num: int, **kwargs): + super().__init__(receiver, **kwargs) + self._params['valve_num'] = valve_num + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_valve_position(self._params['valve_num'])) + +class PSD6SyringePumpMoveAbsolute(PSD6SyringePumpParentCommand): + def __init__(self, receiver: PSD6SyringePump, volume: float, valve_num: Optional[int] = None, flowrate: Optional[float] = None, **kwargs): + super().__init__(receiver, **kwargs) + self._params['volume'] = volume + self._params['valve_num'] = valve_num + self._params['flowrate'] = flowrate + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_syringe_absolute_volume(self._params['volume'], self._params['valve_num'], self._params['flowrate'])) + +class PSD6SyringePumpInfuse(PSD6SyringePumpParentCommand): + def __init__(self, receiver: PSD6SyringePump, volume: float, valve_num: Optional[int] = None, flowrate: Optional[float] = None, **kwargs): + super().__init__(receiver, **kwargs) + self._params['volume'] = volume + self._params['valve_num'] = valve_num + self._params['flowrate'] = flowrate + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.infuse_syringe_volume(self._params['volume'], self._params['valve_num'], self._params['flowrate'])) + +class PSD6SyringePumpWithdraw(PSD6SyringePumpParentCommand): + def __init__(self, receiver: PSD6SyringePump, volume: float, valve_num: Optional[int] = None, flowrate: Optional[float] = None, **kwargs): + super().__init__(receiver, **kwargs) + self._params['volume'] = volume + self._params['valve_num'] = valve_num + self._params['flowrate'] = flowrate + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.withdraw_syringe_volume(self._params['volume'], self._params['valve_num'], self._params['flowrate'])) +``` + +And here are the corresponding command objects in the util file: +```python +# As a nested object within the previously defined device object... +"commands": { + "PSD6SyringePumpConnect": { + "default_code": "PSD6SyringePumpConnect(receiver= '')", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": PSD6SyringePumpConnect, + }, + "PSD6SyringePumpInitialize": { + "default_code": "PSD6SyringePumpInitialize(receiver= '')", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": PSD6SyringePumpInitialize, + }, + "PSD6SyringePumpMoveValve": { + "default_code": "PSD6SyringePumpMoveValve(receiver= '', valve_num=0)", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "valve_num": { + "default": 0, + "type": int, + "notes": "Valve number.", + }, + }, + "obj": PSD6SyringePumpMoveValve, + }, + "PSD6SyringePumpMoveAbsolute": { + "default_code": "PSD6SyringePumpMoveAbsolute(receiver= '', volume=0.0, valve_num= 0, flowrate=0.0)", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "volume": { + "default": 0.0, + "type": float, + "notes": "Volume.", + }, + "valve_num": { + "default": 0, + "type": int, + "notes": "Valve number.", + }, + "flowrate": { + "default": 0.0, + "type": float, + "notes": "Flowrate.", + }, + }, + "obj": PSD6SyringePumpMoveAbsolute, + }, + "PSD6SyringePumpInfuse": { + "default_code": "PSD6SyringePumpInfuse(receiver= '', volume=0.0, valve_num= 0, flowrate=0.0)", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "volume": { + "default": 0.0, + "type": float, + "notes": "Volume.", + }, + "valve_num": { + "default": 0, + "type": int, + "notes": "Valve number.", + }, + "flowrate": { + "default": 0.0, + "type": float, + "notes": "Flowrate.", + }, + }, + "obj": PSD6SyringePumpInfuse, + }, + "PSD6SyringePumpWithdraw": { + "default_code": "PSD6SyringePumpWithdraw(receiver= '', volume=0.0, valve_num= 0, flowrate=0.0)", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "volume": { + "default": 0.0, + "type": float, + "notes": "Volume.", + }, + "valve_num": { + "default": 0, + "type": int, + "notes": "Valve number.", + }, + "flowrate": { + "default": 0.0, + "type": float, + "notes": "Flowrate.", + }, + }, + "obj": PSD6SyringePumpWithdraw, + }, + }, +``` + +### Step 6: + +To confirm that everything is working correctly, ensure all of the dropdowns are propagated correctly with the new device and commands, and that they can all be added to the database. \ No newline at end of file diff --git a/aamp_app/app.py b/aamp_app/app.py new file mode 100644 index 0000000..8faf31e --- /dev/null +++ b/aamp_app/app.py @@ -0,0 +1,3675 @@ +from datetime import datetime +from command_sequence import CommandSequence +from command_invoker import CommandInvoker +import json +from devices.device import SerialDevice +import util +from mongodb_helper import MongoDBHelper +import dash +from dash import dcc +from dash import html +from dash import dash_table +from dash import ctx +import dash_ag_grid as dag +from dash.dependencies import Input, Output, State, MATCH, ALL +import dash_bootstrap_components as dbc +import os, signal +import inspect +from bson.objectid import ObjectId +from console_interceptor import ConsoleInterceptor +from gridfs import GridFS +import base64 +import math +import numpy as np +import pandas as pd +from sklearn.decomposition import PCA +from sklearn.preprocessing import StandardScaler +import base64 +import io +import umap +import plotly.express as px +import plotly.graph_objects as go +from scipy.stats import gaussian_kde +from string import Template +from pymongo import MongoClient + +from db.validation import solutions, films, devices, recipes +try: + import serial.tools.list_ports +except ImportError: + _has_serial = False +else: + _has_serial = True +import typing + +def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + +load_env() + +mongo_uri = os.environ.get("MONGO_URI") +mongo_db_name = os.environ.get("MONGO_DB_NAME") + +mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, +) + + +try: + db_list = mongo.client.list_database_names() +except Exception: + print("Connection Failed. Try Again.") + os.kill(os.getpid(), signal.SIGINT) +# with open("pw.txt", "w") as f: +# f.write(mongo_username + "\n" + mongo_password) + +print("\nreset complete") +com = CommandSequence() +invoker = CommandInvoker(com, log_to_file=True, log_filename="mylog.log") +invoker.clear_log_file() +invoker.invoking = False +if os.path.isfile("blank.yaml"): + com.load_from_yaml("blank.yaml") +else: + with open("blank.yaml", "w") as f: + f.write( + """- [] +- [] +- ALL""" + ) + + +# mongo = MongoDBHelper( +# "mongodb+srv://" +# + mongo_username +# + ":" +# + mongo_password +# + "@mp3-cluster.rf3cato.mongodb.net/?retryWrites=true&w=majority", +# "diaogroup", +# ) +mongo_gridfs = GridFS(mongo.db, collection="recipes") +fs = GridFS(mongo.db) + +# solutions.init_collection() +# devices.init_collection() +# films.init_collection() +# recipes.init_collection() + +app = dash.Dash( + __name__, + external_stylesheets=[dbc.themes.BOOTSTRAP], + use_pages=True, + prevent_initial_callbacks="initial_duplicate", + suppress_callback_exceptions=True, +) +server = app.server + +navbar = dbc.NavbarSimple( + children=[ + dbc.NavItem(dbc.NavLink("Load", href="/load-recipe", external_link=True)), + dbc.NavItem(dbc.NavLink("View", href="/view-recipe", external_link=True)), + dbc.NavItem(dbc.NavLink("Code", href="/edit-recipe", external_link=True)), + dbc.NavItem(dbc.NavLink("Doc", href="/data", external_link=True)), + dbc.NavItem(dbc.NavLink("Execute", href="/execute-recipe", external_link=True)), + dbc.NavItem( + dbc.NavLink("Manual Control", href="/manual-control", external_link=True) + ), + dbc.NavItem(dbc.NavLink("DB Browser", href="/database", external_link=True)), + # dbc.NavItem(dbc.NavLink("Options", href="/options", external_link=True)), + dbc.NavItem( + dbc.NavLink("Real Time", href="/real-time-telemetry", external_link=True) + ), + # dbc.NavItem(dbc.NavLink("Images", href="/images", external_link=True)), + dbc.NavItem(dbc.NavLink("Sampler", href="/sampler", external_link=True)), + dbc.NavItem(dbc.NavLink("Recipe Builder", href="/recipe-builder", external_link=True)), + dbc.NavItem(dbc.NavLink("Solution Map", href="/solution-map", external_link=True)), + dbc.NavItem(dbc.NavLink("Autonomous Run", href="/bayesian-optimization", external_link=True)), + dbc.NavItem(dbc.NavLink("Manual Run", href="/manual-run", external_link=True)), + # dbc.DropdownMenu( + # children=[ + # # dbc.DropdownMenuItem( + # # "Load Recipe", href="/load-recipe", external_link=True + # # ), + # dbc.DropdownMenuItem( + # "Real Time Telemetry", + # href="/real-time-telemetry", + # external_link=True, + # ), + # dbc.DropdownMenuItem( + # "Edit Code", href="/edit-recipe", external_link=True + # ), + # dbc.DropdownMenuItem("Document", href="/data", external_link=True), + # # dbc.DropdownMenuItem("Options", href="/options", external_link=True), + # ], + # nav=True, + # in_navbar=True, + # label="Tools", + # ), + ], + brand="AAMP", + brand_href="/", + color="primary", + dark=True, + id="navbar", + className="mb-3", +) + + +app.layout = html.Div([dcc.Location(id="url"), navbar, dash.page_container]) + + +@app.callback(Output("navbar", "brand"), Input("url", "pathname")) +def print_pagename(url): # all pages + print("loading: " + url + "\n") + return "AAMP" + + +def update_upstream_recipe_dict(): + if not hasattr(com, 'document'): + # create_new_recipe_doc(n, "/load-recipe", "testfile") + com.document = { + '_id': ObjectId(), + 'file_name': 'testfile', + 'recipe_dict': { + 'devices': [], + 'commands': [], + 'execution_options': [] + }, + 'dash_friendly': True, + 'executions': [] + } + recipe_dict = com.get_recipe() + com.document['recipe_dict'] = { + 'devices': recipe_dict[0], + 'commands': recipe_dict[1], + 'execution_options': recipe_dict[2] + } + mongo.db['recipes'].update_one( + {'_id': com.document['_id']}, + {'$set': com.document}, + upsert=True + ) + return True + + + +def update_execution_upstream(execution): + if "document" in list(com.__dict__.keys()): + com.document["executions"].append(execution) + mongo.db["recipes"].update_one( + {"_id": com.document["_id"]}, {"$set": com.document} + ) + print("successfully updated execution upstream") + return True + else: + print("com.document not found") + return False + +# TODO: include where the log was generated from +def save_log_to_mongo(log_string): + """Save a log entry to the MongoDB logs collection.""" + try: + log_entry = { + "timestamp": f"Recipe_{datetime.now().strftime('%Y%m%d_%H%M%S')}", + "log": log_string, + } + mongo.db["logs"].insert_one(log_entry) # Save to 'logs' collection + print("Log saved to MongoDB successfully.") + except Exception as e: + print(f"Failed to save log to MongoDB: {e}") + +# --------------------------------------------------- +# Home Page +# --------------------------------------------------- + + +@app.callback( + Output("home-recipes-list-table", "data"), + [Input("home-refresh-list-button", "n_clicks")], + # prevent_initial_call=True, +) +def fetch_recipe_list(n_clicks): # homepage + docs = mongo.find_documents("recipes", {}) + docs = mongo.db["recipes"].find( + {}, + { + "_id": 0, + "file_name": 1, + "posix_friendly": 1, + "dash_friendly": 1, + "python_code": 1, + }, + ) + print("fetch_recipe_list") + data = [] + for doc in reversed(list(docs)): + file_name = doc.get("file_name", "") + posix_friendly = doc.get("posix_friendly", True) + dash_friendly = doc.get("dash_friendly", False) + if "python_code" in doc: + python_code = True + else: + python_code = False + data.append( + { + "file_name": file_name, + "posix_friendly": posix_friendly, + "dash_friendly": dash_friendly, + "python_code": python_code, + } + ) + return data + + +@app.callback( + Output("filename-input", "value"), + Input("home-recipes-list-table", "active_cell"), + [State("url", "pathname"), State("home-recipes-list-table", "data")], + # prevent_initial_call=True, +) +def fill_filename_input(active_cell, url, data): # homepage + if str(url) == "/load-recipe": + if active_cell is not None: + print("fill_filename_input") + return data[active_cell["row"]]["file_name"] + if "document" in list(com.__dict__.keys()): + print("fill_filename_input") + return com.document["file_name"] + return "" + + +@app.callback( + [ + Output("home-load-file-alert", "is_open"), + Output("home-load-file-alert", "children"), + Output("home-load-file-alert", "color"), + Output("home-load-file-alert", "duration"), + ], + [Input("filename-input-button", "n_clicks")], + [State("filename-input", "value")], + prevent_initial_call=True, +) +def get_document_from_db(n_clicks, filename): # homepage + print("get_document_from_db") + if filename is not None and filename != "": + # Extract the YAML content from the document + document = mongo.find_documents("recipes", {"file_name": filename})[0] + invoker.clear_log_file() + try: + com.clear_recipe() + com.load_from_dict(document["recipe_dict"]) + com.document = document + print("loaded from dict") + return [True, "Recipe loaded", "success", 10000] + + except Exception as e: + print("Failed load from dict: " + str(e)) + if os.name == "posix": + if "posix_friendly" in document and not document["posix_friendly"]: + return [ + True, + "This recipe is not compatible with your system (POSIX compatability error)", + "danger", + 8000, + ] + if ( + document.get("dash_friendly", "") == False + or document.get("python_code", "") == "" + ): + yaml_content = document.get("yaml_data", "") + # Update the YAML output + with open("to_load.yaml", "w") as file: + file.write(yaml_content) + com.load_from_yaml("to_load.yaml") + com.document = document + return [True, "Recipe loaded", "success", 10000] + else: + exec(document.get("python_code", "")) + com.load_from_yaml("to_save.yaml") + com.document = document + + # com.python_code = document.get("python_code", "") + + return [True, "Recipe loaded", "success", 10000] + + return [True, "No recipe selected", "warning", 3000] + + +@app.callback( + [ + Output("home-load-file-alert", "is_open", allow_duplicate=True), + Output("home-load-file-alert", "children", allow_duplicate=True), + Output("home-load-file-alert", "color", allow_duplicate=True), + Output("home-load-file-alert", "duration", allow_duplicate=True), + ], + Input("home-create-new-recipe-button", "n_clicks"), + [State("url", "pathname"), State("filename-input", "value")], + prevent_initial_call=True, +) +def create_new_recipe_doc(n, url, name): + if str(url) == "/load-recipe": + print("create_new_recipe_doc") + name = f"Recipe_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + mongo.db["recipes"].insert_one( + { + "file_name": name, + "recipe_dict": { + "devices": [], + "commands": [], + "execution_options": { + "output_files": [], + "default_execution_record_name": "Execution - " + str(name), + }, + }, + "dash_friendly": True, + "executions": [], + } + ) + fetch_recipe_list(1) + return [True, "Recipe created", "success", 10000] + + +# --------------------------------------------------- +# View Recipe Page +# --------------------------------------------------- + +@app.callback( + Output("devices-table-div", "children"), + [Input("refresh-button1", "n_clicks"), Input("devices-table", "data")], + [State("devices-table-div", "children")], +) +def update_device_table(n_clicks, data, table): + print("update_device_table") + + table_data1 = com.get_clean_device_list().copy() + table_data1_new = [ + { + "index": index, + "device_type": device[0], + "params": str(device[1]), + } + for index, device in enumerate(table_data1) + ] + + column_defs = [ + {"headerName": "Index", "field": "index"}, + {"headerName": "Device Type", "field": "device_type", "rowDrag": True}, + {"headerName": "Parameters", "field": "params", "flex": 1}, + ] + + grid = dag.AgGrid( + id="devices-table", + columnDefs=column_defs, + rowData=table_data1_new, + defaultColDef={"sortable": True, "filter": True, "resizable": True}, + dashGridOptions={ + "rowDragManaged": True, + "animateRows": True, + "rowSelection": "single", + }, + style={"height": 400, "width": "100%"}, + ) + + return html.Div(grid) + + + +@app.callback( + Output("commands-table", "data"), + Input("save-command-editor", "n_clicks"), + [ + State("commands-table", "active_cell"), + State("commands-table", "data"), + State("command-json-editor", "value"), + ], + prevent_initial_call=True, +) +def save_command(n_clicks, active_cell, data, value): # view-recipe page + print("save_command") + if active_cell is not None and data[active_cell["row"]]["params"] != str( + json.loads(value) + ): + com.command_list[data[active_cell["row"]]["index"]][0]._params = eval(value) + update_upstream_recipe_dict() + return None + return data + + +@app.callback( + [ + Output("devices-table", "data"), + Output("view-recipe-alert", "is_open", allow_duplicate=True), + Output("view-recipe-alert", "children", allow_duplicate=True), + Output("view-recipe-alert", "color", allow_duplicate=True), + Output("view-recipe-alert", "duration", allow_duplicate=True), + ], + [ + Input("save-device-editor", "n_clicks"), + Input("devices-table", "data") + ], + [ + State("devices-table", "active_cell"), + State("devices-table", "data"), + State("device-json-editor", "value"), + ], + prevent_initial_call=True, +) +def save_device(n_clicks, updated_data, active_cell, data, value): # view-recipe page + print("save_device") + if active_cell is not None and data[active_cell["row"]]["params"] != str( + json.loads(value) + ): + # data_row = data[active_cell["row"]] + params = eval(value) + com.device_by_name[params["name"]].update_init_args(params) + update_success = update_upstream_recipe_dict() + return None, True, "Device updated.", "success", 3000 + return data, False, "Something went wrong. Device not updated.", "danger", 3000 + + +@app.callback( + Output("command-json-editor", "value"), + [Input("command-editor-modal", "is_open")], + [State("commands-table", "active_cell"), State("commands-table", "data")], + prevent_initial_call=True, +) +def fill_command_json_editor(is_open, active_cell, data): # view-recipe page + if active_cell is not None and is_open: + print("fill_command_json_editor") + return json.dumps(eval(data[active_cell["row"]]["params"]), indent=4) + + return "" + + +@app.callback( + Output("add-device-dropdown", "options"), + [Input("device-add-modal", "is_open")], + [State("devices-table", "active_cell"), State("devices-table", "data")], + prevent_initial_call=True, +) +def fill_device_add_modal(is_open, active_cell, data): # view-recipe page + print("fill_device_add_modal") + return list(util.devices_ref_redundancy.keys()) + + +@app.callback( + [Output("add-device-json-editor", "value")], + [Input("add-device-dropdown", "value"), Input("device-add-modal", "is_open")], + prevent_initial_call=True, +) +def fill_device_add_json_editor(value, is_open): # view-recipe page + print("fill_device_add_json_editor") + if not is_open or value is None: + return [""] + # args_list = inspect.getfullargspec(util.devices_ref_redundancy[value]['obj'].__init__).args + args_dict = {} + # for arg in args_list: + # if arg != "self" and arg != "name": + # args_dict[arg] = None + # if arg == "name": + # args_dict[arg] = value + args_list = list(util.devices_ref_redundancy[value]["init"]["args"].keys()) + for arg in args_list: + args_dict[arg] = util.devices_ref_redundancy[value]["init"]["args"][arg][ + "default" + ] + return [(json.dumps(args_dict, indent=4))] + + +@app.callback( + [ + Output("device-json-editor", "value"), + Output("edit-device-serial-ports-info", "children"), + ], + [Input("device-editor-modal", "is_open")], + [State("devices-table", "active_cell"), State("devices-table", "data")], + prevent_initial_call=True, +) +def fill_device_json_editor(is_open, active_cell, data): # view-recipe page + print("fill_device_json_editor") + if active_cell is not None and is_open: + if _has_serial and isinstance( + com.device_by_name[eval(data[active_cell["row"]]["params"])["name"]], + SerialDevice, + ): + ports = serial.tools.list_ports.comports() + str_ports = "" + for port, desc, hwid in sorted(ports): + str_ports += f"{port}: {desc} [{hwid}]\n" + lines = str_ports.splitlines() + device_port_html = [ + html.Div(["COM Port Info:"], style={"fontWeight": "bold"}) + ] + device_port_html.append(html.Div([html.Div(line) for line in lines])) + else: + device_port_html = "" + return ( + json.dumps(eval(data[active_cell["row"]]["params"]), indent=4), + device_port_html, + ) + return "", "" + + +@app.callback( + [ + Output("save-command-editor", "disabled"), + Output("edit-command-error", "children"), + ], + Input("command-json-editor", "value"), + State("command-editor-modal", "is_open"), + prevent_initial_call=True, +) +def enable_save_command_button(value, is_open): # view-recipe page + if not is_open: + return False, "" + try: + parsed_json = json.loads(value) + if parsed_json["delay"] < 0: + return True, "Delay must be greater than or equal to 0" + print("enable_save_command_button") + return False, "" + except Exception as e: + if type(e) == json.decoder.JSONDecodeError: + return True, "Invalid JSON" + return True, str(type(e)) + + +@app.callback( + [Output("save-device-editor", "disabled"), Output("edit-device-error", "children")], + Input("device-json-editor", "value"), + State("device-editor-modal", "is_open"), + prevent_initial_call=True, +) +def enable_save_device_button(value, is_open): # view-recipe page + if not is_open: + return False, "" + try: + parsed_json = json.loads(value) + print("enable_save_device_button") + return False, "" + except Exception as e: + if type(e) == json.decoder.JSONDecodeError: + return True, "Invalid JSON" + return True, str(type(e)) + + +@app.callback( + [Output("add-device-editor", "disabled"), Output("add-device-error", "children")], + [Input("add-device-json-editor", "value"), Input("add-device-dropdown", "value")], + State("device-add-modal", "is_open"), + prevent_initial_call=True, +) +def enable_add_device_button(value, device_type, is_open): # view-recipe page + if value == "": + return True, "No device selected" + if not is_open: + return False, "" + try: + sig = inspect.signature(util.named_devices[device_type].__init__) + args = {} + for param in sig.parameters.values(): + arg_type = param.annotation + args[param.name] = ( + typing.get_args(arg_type)[0] + if typing.get_origin(arg_type) is typing.Union + else arg_type + ) + parsed_json = json.loads(value) + for key in parsed_json: + # print("\n" + key) + # print( + # "input: " + # + str(type((parsed_json[key]))) + # + ", expected: " + # + str(args[key]) + # ) + if type((parsed_json[key])) != args[key]: + return False, f"Invalid type for {key}. Expected {str(args[key])}" + # if not isinstance(parsed_json[key], args[key]): + # return True, f"Invalid type for {key}. Expected {str(args[key])}" + print("enable_add_device_button") + return False, "" + except Exception as e: + if type(e) == json.decoder.JSONDecodeError: + return True, "Invalid JSON" + return False, str(type(e)) + + +@app.callback( + [ + Output("view-recipe-alert", "is_open"), + Output("view-recipe-alert", "children"), + Output("view-recipe-alert", "color"), + Output("view-recipe-alert", "duration"), + ], + [Input("add-device-editor", "n_clicks")], + [ + State("add-device-dropdown", "value"), + State("url", "pathname"), + State("add-device-json-editor", "value"), + ], + prevent_initial_call=True, +) +def view_recipe_add_device(n, device_type, url, device_dict): + if str(url) == "/view-recipe": + com.add_device_from_dict(device_type, json.loads(device_dict)) + update_success = update_upstream_recipe_dict() + return ( + True, + f"Added {device_type}. Database updated: {update_success}", + "success", + 3000, + ) + + +@app.callback( + Output("edit-command-button", "disabled"), + Input("commands-table", "active_cell"), +) +def edit_command_button(table_div_children): # view-recipe page + active_cell = table_div_children + if active_cell is not None: + print("edit_command_button") + return False + else: + return True + + +@app.callback( + Output("edit-device-button", "disabled"), + Input("devices-table", "active_cell"), +) +def edit_device_button(table_div_children): # view-recipe page + active_cell = table_div_children + if active_cell is not None: + return False + else: + return True + + +@app.callback( + Output("delete-device-button", "disabled"), + Input("devices-table", "active_cell"), +) +def view_recipe_enable_delete_device_button(table_div_children): + active_cell = table_div_children + if active_cell is not None: + return False + else: + return True + + +@app.callback( + Output("delete-command-button", "disabled"), Input("commands-table", "active_cell") +) +def view_recipe_enable_delete_command_button(table_div_children): + active_cell = table_div_children + if active_cell is not None: + return False + else: + return True + + +@app.callback( + Output("devices-table", "data", allow_duplicate=True), + Input("delete-device-button", "n_clicks"), + [ + State("url", "pathname"), + State("devices-table", "active_cell"), + State("devices-table", "data"), + ], + prevent_initial_call=True, +) +def view_recipe_delete_device(n, url, active_cell, data): + if str(url) == "/view-recipe": + com.remove_device_by_index(data[active_cell["row"]]["index"]) + update_success = update_upstream_recipe_dict() + return None + + +@app.callback( + Output("commands-table", "data", allow_duplicate=True), + Input("delete-command-button", "n_clicks"), + [ + State("url", "pathname"), + State("commands-table", "active_cell"), + State("commands-table", "data"), + ], + prevent_initial_call=True, +) +def view_recipe_delete_command(n, url, active_cell, data): + if str(url) == "/view-recipe": + com.remove_command(data[active_cell["row"]]["index"]) + update_success = update_upstream_recipe_dict() + return None + + +@app.callback( + Output("commands-table-div", "children"), + [Input("refresh-button2", "n_clicks"), Input("commands-table", "data")], + [State("commands-table-div", "children")], +) +def update_commands_table(n_clicks, data, table): # view-recipe page + print("update_commands_table") + command_list = com.command_list.copy() + command_params = [] + for index, command in enumerate(command_list): + temp_dict_command_params = {"command": type(command[0]).__name__} + temp_dict_command_params.update( + {"params": str(command[0].get_init_args()), "index": index} + ) + command_params.append((temp_dict_command_params)) + # else: + # command_params.append(command._params) + + table_data2 = command_params + # add_command_accordian(0, to_add=[dbc.AccordionItem("new new", title="new new", item_id="new new")]) + + table = dash_table.DataTable( + id="commands-table", + data=table_data2, + columns=[ + {"name": "Index", "id": "index"}, + {"name": "Command", "id": "command"}, + {"name": "Parameters", "id": "params"}, + ], + style_cell={ + "overflow": "hidden", + "textOverflow": "ellipsis", + "maxWidth": 0, + "textAlign": "left", + "padding": "5px", + }, + style_cell_conditional=[ + {"if": {"column_id": "index"}, "width": "5%"}, + {"if": {"column_id": "command"}, "width": "20%"}, + {"if": {"column_id": "params"}, "width": "70%"}, + ], + # tooltip_data=[ + # { + # column: {"value": str(value), "type": "markdown"} + # for column, value in row.items() + # } + # for row in table_data2 + # ], + # tooltip_duration=None, + # editable = True, + ) + return table + + +@app.callback( + Output("view-recipe-command-add-modal", "is_open"), + Input("add-command-open-modal-button", "n_clicks"), + [State("url", "pathname")], + prevent_initial_call=True, +) +def view_recipe_open_add_command_modal(n, url): + if url == "/view-recipe": + return True + + +@app.callback( + Output("view-recipe-add-command-device-dropdown", "options"), + Input("view-recipe-command-add-modal", "is_open"), + State("url", "pathname"), + prevent_initial_call=True, +) +def view_recipe_fill_add_command_device_dropdown(is_open, url): + if url == "/view-recipe": + print("view_recipe_fill_add_command_device_dropdown") + return list(util.devices_ref_redundancy.keys()) + + +@app.callback( + Output("view-recipe-add-command-command-dropdown", "options"), + Input("view-recipe-add-command-device-dropdown", "value"), + State("url", "pathname"), + prevent_initial_call=True, +) +def view_recipe_fill_add_command_command_dropdown(device_type, url): + if url == "/view-recipe": + if device_type is None or device_type == "": + return [] + return list(util.devices_ref_redundancy[device_type]["commands"].keys()) + + +@app.callback( + Output("view-recipe-add-command-json-editor", "value"), + Input("view-recipe-add-command-command-dropdown", "value"), + [ + State("url", "pathname"), + State("view-recipe-add-command-device-dropdown", "value"), + ], + prevent_initial_call=True, +) +def view_recipe_fill_add_command_json_editor(command_type, url, device_type): + if url == "/view-recipe": + if command_type is None or command_type == "": + return "" + args_dict = {} + args_list = util.devices_ref_redundancy[device_type]["commands"][command_type][ + "args" + ] + for arg in args_list: + args_dict[arg] = args_list[arg]["default"] + args_dict["delay"] = 0.0 + return json.dumps(args_dict, indent=4) + + +@app.callback( + [ + Output("view-recipe-add-command-editor", "disabled"), + Output("add-command-error", "children"), + ], + Input("view-recipe-add-command-json-editor", "value"), + State("url", "pathname"), +) +def view_recipe_check_add_command_json(value, url): + if str(url) == "/view-recipe": + if value == "" or value is None: + return True, [] + try: + json.loads(value) + return False, [] + except Exception as e: + return True, ["Invalid JSON: " + str(e)] + + +@app.callback( + [ + Output("view-recipe-command-add-modal", "is_open", allow_duplicate=True), + Output("view-recipe-alert", "is_open", allow_duplicate=True), + Output("view-recipe-alert", "children", allow_duplicate=True), + Output("view-recipe-alert", "color", allow_duplicate=True), + Output("view-recipe-alert", "duration", allow_duplicate=True), + Output("commands-table", "data", allow_duplicate=True), + ], + Input("view-recipe-add-command-editor", "n_clicks"), + [ + State("url", "pathname"), + State("view-recipe-add-command-device-dropdown", "value"), + State("view-recipe-add-command-command-dropdown", "value"), + State("view-recipe-add-command-json-editor", "value"), + ], + prevent_initial_call=True, +) +def view_recipe_add_command(n, url, device_type, command_type, json_value): + if str(url) == "/view-recipe": + if json_value == "" or json_value is None: + return [False, True, ["Something went wrong"], "danger", 3000] + try: + com.add_command_from_dict(device_type, command_type, json.loads(json_value)) + update_upstream_recipe_dict() + return [False, True, ["Command added"], "success", 3000, None] + except Exception as e: + print(str(e)) + return [ + False, + True, + ["Something went wrong: " + str(e)], + "danger", + 3000, + None, + ] + + +@app.callback( + [ + Output("view-recipe-execution-options-output-files", "value"), + Output("view-recipe-execution-options-default-execution-record-name", "value"), + ], + Input("url", "pathname"), +) +def view_recipe_fill_execution_options(url): + if str(url) == "/view-recipe": + if "document" in com.__dict__.keys(): + try: + ls = com.document["recipe_dict"]["execution_options"]["output_files"] + except Exception as e: + print(str(e)) + ls = [] + filesToRet = "" + for item in ls: + filesToRet += item + "\n" + return [ + filesToRet, + com.document["recipe_dict"]["execution_options"][ + "default_execution_record_name" + ], + ] + return ["", ""] + + +@app.callback( + [ + Output("view-recipe-execution-options-saved-label", "children"), + Output("view-recipe-execution-options-saved-label", "style"), + ], + Input("view-recipe-execution-options-save-button", "n_clicks"), + [ + State("view-recipe-execution-options-output-files", "value"), + State("view-recipe-execution-options-default-execution-record-name", "value"), + State("url", "pathname"), + ], + prevent_initial_call=True, +) +def view_recipe_save_execution_options( + n, filenames, default_execution_record_name, url +): + if str(url) == "/view-recipe": + if filenames is not None: + com.execution_options["output_files"] = filenames.splitlines() + com.execution_options[ + "default_execution_record_name" + ] = default_execution_record_name + success = update_upstream_recipe_dict() + if success: + return ["Saved!", {"display": "block"}] + else: + return ["Something went wrong", {"display": "block"}] + return ["", {"display": "none"}] + + +# --------------------------------------------------- +# Python Edit Recipe Page +# --------------------------------------------------- + + +@app.callback( + [ + Output("ace-recipe-editor", "value", allow_duplicate=True), + Output("ace-editor-alert", "is_open"), + Output("ace-editor-alert", "children"), + Output("ace-editor-alert", "color"), + Output("ace-editor-alert", "duration"), + ], + [Input("refresh-button-ace", "n_clicks")], + State("url", "pathname"), + # prevent_initial_call=True, +) +def fill_ace_editor(n, url): # python-edit-recipe page + if url == "/edit-recipe": + if hasattr(com, "document"): + python_code = com.document.get("python_code", "") + if python_code is not None and python_code != "": + print("fill_ace_editor") + return [str(python_code), True, "Loaded!", "success", 1500] + else: + print("fill_ace_editor") + return ["", True, "No code available", "warning", 1000] + else: + return ["", True, "No code available", "warning", 1000] + else: + return ["", False, "na", "success", 0] + + +@app.callback( + [ + Output("ace-recipe-editor", "value", allow_duplicate=True), + Output("ace-editor-alert", "is_open", allow_duplicate=True), + Output("ace-editor-alert", "children", allow_duplicate=True), + Output("ace-editor-alert", "color", allow_duplicate=True), + Output("ace-editor-alert", "duration", allow_duplicate=True), + ], + Input("execute-and-save-button", "n_clicks"), + [State("ace-recipe-editor", "value")], + prevent_initial_call=True, +) +def execute_and_save(n, value): # python-edit-recipe page + print("execute_and_save") + if value is not None and value != "": + try: + exec(value) + doc_id = com.document.get("_id", "") + (mongo.update_yaml_file("recipes", doc_id, {"python_code": value})) + com.load_from_yaml("to_save.yaml") + com.document = mongo.find_documents("recipes", {"_id": doc_id})[0] + return [str(value), True, "Saved!", "success", 1500] + except Exception as e: + return [str(value), True, str(e), "danger", 5000] + else: + return ["", True, "No code to execute", "warning", 1000] + + # return ["", False, "", "success", 1500] + + +@app.callback( + [ + Output("add-device-dropdown-ace", "options"), + Output("add-device-dropdown-ace", "value"), + ], + [Input("device-add-modal-ace", "is_open")], + prevent_initial_call=True, +) +def fill_device_add_modal_ace(is_open): # python-edit-recipe page + if is_open: + print("fill_device_add_modal_ace") + return list(util.devices_ref_redundancy.keys()), "" + return [], "" + + +@app.callback( + [ + Output("add-command-device-dropdown-ace", "options"), + Output("add-command-device-dropdown-ace", "value"), + ], + [Input("command-add-modal-ace", "is_open")], + prevent_initial_call=True, +) +def fill_command_device_add_modal_ace(is_open): # python-edit-recipe page + if is_open: + print("fill_command_device_add_modal_ace") + return list(util.devices_ref_redundancy.keys()), "" + return [], "" + + +@app.callback( + [ + Output("add-command-command-dropdown-ace", "options"), + Output("add-command-command-dropdown-ace", "value"), + ], + [Input("add-command-device-dropdown-ace", "value")], + prevent_initial_call=True, +) +def fill_command_add_modal_ace(device): # python-edit-recipe page + if device is not None and device != "": + print("fill_command_add_modal_ace") + return list(util.devices_ref_redundancy[device]["commands"].keys()), "" + return [], "" + + +@app.callback( + Output("add-device-editor-ace", "disabled"), + [ + Input("add-device-dropdown-ace", "value"), + Input("device-add-modal-ace", "is_open"), + ], + State("device-add-modal-ace", "is_open"), + prevent_initial_call=True, +) +def enable_add_device_button_ace(value, is_openInp, is_open): # python-edit-recipe page + if value == "" or value is None: + return True + print("enable_add_device_button_ace") + return False + + +@app.callback( + Output("add-command-editor-ace", "disabled"), + [ + Input("add-command-command-dropdown-ace", "value"), + Input("command-add-modal-ace", "is_open"), + ], + State("command-add-modal-ace", "is_open"), + prevent_initial_call=True, +) +def enable_add_command_button_ace( + value, is_openInp, is_open +): # python-edit-recipe page + if value == "" or value is None: + return True + print("enable_add_command_button_ace") + return False + + +@app.callback( + [ + Output("ace-recipe-editor", "value"), + Output("ace-editor-alert", "is_open", allow_duplicate=True), + Output("ace-editor-alert", "children", allow_duplicate=True), + Output("ace-editor-alert", "color", allow_duplicate=True), + Output("ace-editor-alert", "duration", allow_duplicate=True), + ], + Input("add-device-editor-ace", "n_clicks"), + [State("ace-recipe-editor", "value"), State("add-device-dropdown-ace", "value")], + prevent_initial_call=True, +) +def add_device_to_recipe_ace(n_clicks, value, device_type): # python-edit-recipe page + print("add_device_to_recipe_ace") + if value == "" or value is None: + return ["", True, "No code in editor", "warning", 3000] + try: + value = str(value) + import_line = util.devices_ref_redundancy[device_type]["import_device"] + init_line = util.devices_ref_redundancy[device_type]["init"]["default_code"] + if import_line not in value: + value = import_line + "\n" + value + value = value.replace( + "##################################################\n##### Add commands to the command sequence", + "seq.add_device(" + + init_line + + ")\n\n##################################################\n##### Add commands to the command sequence", + ) + return [str(value), True, "Device added successfully", "success", 3000] + except Exception as e: + print(e) + return [str(value), True, "Error adding device: " + str(e), "danger", 6000] + + +@app.callback( + [ + Output("ace-recipe-editor", "value", allow_duplicate=True), + Output("ace-editor-alert", "is_open", allow_duplicate=True), + Output("ace-editor-alert", "children", allow_duplicate=True), + Output("ace-editor-alert", "color", allow_duplicate=True), + Output("ace-editor-alert", "duration", allow_duplicate=True), + ], + Input("add-command-editor-ace", "n_clicks"), + [ + State("ace-recipe-editor", "value"), + State("add-command-command-dropdown-ace", "value"), + State("add-command-device-dropdown-ace", "value"), + ], + prevent_initial_call=True, +) +def add_commands_to_recipe_ace( + n_clicks, value, command, device_type +): # python-edit-recipe page + print("add_commands_to_recipe_ace") + og_value = str(value) + if value == "" or value is None: + return ["", True, "No code in editor", "warning", 3000] + try: + value = str(value) + command_line = util.devices_ref_redundancy[device_type]["commands"][command][ + "default_code" + ] + import_line = util.devices_ref_redundancy[device_type]["import_commands"] + import_device_line = util.devices_ref_redundancy[device_type]["import_device"] + if import_device_line not in value: + raise Exception( + "Device (or its import '" + + import_device_line + + "') not found in recipe" + ) + if import_line not in value: + value = import_line + "\n" + value + if "\nrecipe_file = 'to_save.yaml'\nseq.save_to_yaml(recipe_file)" not in value: + raise Exception("Code is not in valid format") + value = value.replace( + "\nrecipe_file = 'to_save.yaml'\nseq.save_to_yaml(recipe_file)", + "\nseq.add_command(" + + command_line + + ")\n\n\nrecipe_file = 'to_save.yaml'\nseq.save_to_yaml(recipe_file)", + ) + return [str(value), True, "Command added successfully", "success", 3000] + except Exception as e: + print(e) + return [og_value, True, str("Error adding command: " + str(e)), "danger", 6000] + + +# --------------------------------------------------- +# Execute Recipe Page +# --------------------------------------------------- + + +def kill_execution(): # execute-recipe page + os.kill(os.getpid(), signal.SIGINT) + + +@app.callback( + Output("hidden-div", "children"), + Input("stop-button", "n_clicks"), + prevent_initial_call=True, +) +def stop_execution(n): # execute-recipe page + print("stop_execution") + kill_execution() + return [] + + +@app.callback( + Output("execute-recipe-output", "children"), + [Input("execute-button", "n_clicks")], + prevent_initial_call=True, +) +def execute_recipe(n_clicks): # execute-recipe page + print("execute_recipe") + invoker.invoking = True + invoker.invoke_commands() + invoker.invoking = False + return ["done"] + + +@app.callback( + Output("console-out2", "children"), + Input("interval1", "n_intervals"), + [State("url", "pathname")], + prevent_initial_call=True, +) +def update_output(n, url): # execute-recipe page + if url == "/execute-recipe": + log_string = "" + log_list = invoker.get_log_messages() + for msg in log_list: + log_string += msg + # log_string += '
' + return html.Pre(log_string) + return "" + + +@app.callback( + Output("console-out2", "children", allow_duplicate=True), + Input("reset-button", "n_clicks"), + prevent_initial_call=True, +) +def reset_console(n): # execute-recipe page + print("reset_console") + invoker.clear_log_file() + # dashLoggerHandler.queue = [] + return [] + + +@app.callback( + [ + Output("execute-recipe-upload-document", "value"), + Output("execute-recipe-upload-name", "value"), + ], + Input("reset-button", "n_clicks"), + State("url", "pathname"), +) +def execute_recipe_load_document_viewer(n, url): + if str(url) == "/execute-recipe": + if "document" in list(com.__dict__.keys()): + if com.device_list != []: + toRet = "" + recipe_ec = com.get_recipe() + for device in recipe_ec[0]: + toRet += str(device) + "\n" + toRet += "\n" + for command in recipe_ec[1]: + toRet += str(command) + "\n" + if "default_execution_record_name" in list( + com.execution_options.keys() + ): + return [ + toRet, + com.execution_options["default_execution_record_name"], + ] + return [toRet, "Execution"] + return ["", "No Recipe Loaded"] + + +@app.callback( + [Output("execute-recipe-upload-data-output", "children")], + Input("execute-recipe-upload-data-button", "n_clicks"), + [ + State("url", "pathname"), + State("execute-recipe-upload-name", "value"), + State("execute-recipe-upload-document", "value"), + State("console-out2", "children"), + State("execute-recipe-upload-notes", "value"), + State("execute-recipe-upload-files", "contents"), + State("execute-recipe-upload-files", "filename"), + ], + prevent_initial_call=True, +) +def execute_recipe_upload_data( + n_clicks, url, name, recipe_data, console_log, notes, files, filenames +): + if str(url) == "/execute-recipe": + print("execute_recipe_upload_data") + execution = {} + execution["name"] = name + if ( + isinstance(recipe_data, list) + and recipe_data is not None + and recipe_data != [] + ): + execution["recipe"] = recipe_data[0].split("\n") + elif recipe_data is not None and recipe_data != "": + execution["recipe"] = recipe_data.split("\n") + execution["notes"] = notes + execution["log"] = str(console_log["props"]["children"]).split("\n") + execution["files"] = [] + if isinstance(files, list): + for i, file in enumerate(files): + # print(file) + file_bytes = base64.b64decode(file + "==") + execution["files"].append( + mongo_gridfs.put(file_bytes, filename=filenames[i]) + ) + exec_success = update_execution_upstream(execution) + # exec_success = False + if exec_success: + return [html.P("Data uploaded successfully")] + else: + return [html.P("Error uploading data")] + + +# --------------------------------------------------- +# Data Page +# --------------------------------------------------- + + +def render_dict(data): # data (and maybe database) page + if isinstance(data, dict): + return [ + dbc.Accordion( + [ + dbc.AccordionItem( + [ + html.Div( + render_dict(value), + style={"margin-left": "15px"}, + ) + ], + title=key, + ) + for key, value in data.items() + ] + ) + ] + else: + return html.P(str(data)) + + +@app.callback( + Output("data-output-div2", "children"), + Input("load-data-button", "n_clicks"), + prevent_initial_call=True, +) +def load_data_accordion(n): # data page + if not hasattr(com, "document"): + print("load_data_accordion - no document") + return [] + print("load_data_accordion") + return render_dict(com.document) + + +# --------------------------------------------------- +# Manual Control Recipe Page +# --------------------------------------------------- + + +@app.callback( + Output("manual-control-device-dropdown", "options"), + Input("interval-manual-control", "n_intervals"), + State("url", "pathname"), +) +def fill_manual_control_device_dropdown(n, url): # manual-control page + if str(url) == "/manual-control": + print("fill_manual_control_device_dropdown") + return list(util.devices_ref_redundancy.keys()) + + +@app.callback( + [ + Output("manual-control-command-dropdown", "disabled"), + Output("manual-control-command-dropdown", "options"), + ], + Input("manual-control-device-dropdown", "value"), + State("url", "pathname"), + prevent_initial_call=True, +) +def fill_manual_control_command_dropdown(val, url): # manual-control page + if str(url) == "/manual-control": + print("fill_manual_control_command_dropdown") + if val is None or val == "": + return [True, []] + else: + if util.devices_ref_redundancy[val]["serial"] == False: + return [ + False, + list(util.devices_ref_redundancy[val]["commands"].keys()), + ] + else: + toRet = list(util.devices_ref_redundancy[val]["commands"].keys()).copy() + for command in util.devices_ref_redundancy[val]["serial_sequence"]: + if command in toRet: + toRet.remove(command) + return [False, toRet] + + +@app.callback( + [Output("manual-control-device-form", "children")], + Input("manual-control-device-dropdown", "value"), + State("url", "pathname"), +) +def create_manual_control_device_form(value, url): + if str(url) == "/manual-control": + print("create_manual_control_device_form") + if value is None or value == "": + return [[]] + else: + args = util.devices_ref_redundancy[value]["init"]["args"] + toRet = [] + for arg in args: + if arg == "port": + toRet.append( + dbc.Row( + [ + dbc.Label( + dbc.NavLink( + arg, + n_clicks=0, + id="manual-control-port-field", + style={ + "cursor": "pointer", + "color": "blue", + "textDecoration": "underline", + }, + ), + html_for=str(value + "+" + arg), + width=2, + ), + dbc.Col( + [ + dbc.Input( + id=str(value + "+" + arg), + value=args[arg]["default"], + placeholder=args[arg]["notes"], + ), + ], + width=10, + ), + ], + className="mb-2", + ) + ) + else: + toRet.append( + dbc.Row( + [ + dbc.Label( + [arg], html_for=str(value + "+" + arg), width=2 + ), + dbc.Col( + [ + dbc.Input( + id=str(value + "+" + arg), + value=args[arg]["default"], + placeholder=args[arg]["notes"], + ), + ], + width=10, + ), + ], + className="mb-2", + ) + ) + + return [toRet] + + +@app.callback( + [Output("manual-control-command-form", "children")], + [ + Input("manual-control-command-dropdown", "value"), + Input("manual-control-device-dropdown", "value"), + ], + [ + State("url", "pathname"), + State("manual-control-device-form", "children"), + ], +) +def create_manual_control_command_form(command, device, url, device_form): + if str(url) == "/manual-control": + print("create_manual_control_command_form") + if command is None or command == "" or device is None or device == "": + return [[]] + else: + toRet = [] + if util.devices_ref_redundancy[device]["serial"] == True: + seq_toRet = [] + for seq_command in util.devices_ref_redundancy[device][ + "serial_sequence" + ]: + seq_toRet.append(dbc.Row([dbc.Label([seq_command])])) + args = util.devices_ref_redundancy[device]["commands"][seq_command][ + "args" + ] + for arg in args: + seq_toRet.append( + dbc.Row( + [ + dbc.Label( + [arg], + html_for=str( + device + "+" + seq_command + "+" + arg + ), + width=2, + ), + dbc.Col( + [ + dbc.Input( + id=str( + device + + "+" + + seq_command + + "+" + + arg + ), + value=args[arg]["default"], + placeholder=args[arg]["notes"], + ), + ], + width=10, + ), + ], + className="mb-2", + ) + ) + toRet.append(dbc.Row(seq_toRet, className="mb-3")) + toRet.append(dbc.Row([dbc.Label([command])])) + args = util.devices_ref_redundancy[device]["commands"][command]["args"] + com_toRet = [] + for arg in args: + com_toRet.append( + dbc.Row( + [ + dbc.Label( + [arg], + html_for=str(device + command + "+" + arg), + width=2, + ), + dbc.Col( + [ + dbc.Input( + id=str(device + command + "+" + arg), + value=args[arg]["default"], + placeholder=args[arg]["notes"], + ), + ], + width=10, + ), + ], + className="mb-2", + ) + ) + toRet.append(dbc.Row(com_toRet, className="mb-3")) + return [toRet] + + +@app.callback( + Output("manual-control-device-dropdown", "value"), + Input("manual-control-clear-button", "n_clicks"), + State("url", "pathname"), + prevent_initial_call=True, +) +def manual_control_clear_form(n, url): + if str(url) == "/manual-control": + print("manual_control_clear_form") + return None + + +@app.callback( + Output("manual-control-clear-button", "disabled"), + Input("manual-control-device-dropdown", "value"), + State("url", "pathname"), +) +def manual_control_clear_button(value, url): + if str(url) == "/manual-control": + print("manual_control_clear_button") + if value is None or value == "": + return True + else: + return False + + +@app.callback( + Output("manual-control-open-execute-modal-button", "disabled"), + Input("manual-control-command-dropdown", "value"), + State("url", "pathname"), +) +def manual_control_execute_button(value, url): + if str(url) == "/manual-control": + print("manual_control_execute_button") + if value is None or value == "": + return True + else: + return False + + +@app.callback( + [ + Output("manual-control-execute-modal", "is_open", allow_duplicate=True), + Output("manual-control-execute-modal-body", "children", allow_duplicate=True), + ], + Input("manual-control-open-execute-modal-button", "n_clicks"), + State("url", "pathname"), + prevent_initial_call=True, +) +def open_manual_control_execute_modal(n, url): + if str(url) == "/manual-control": + print("open_manual_control_execute_modal") + return True, [] + + +@app.callback( + [ + Output("manual-control-command-dropdown", "className"), + Output("manual-control-alert", "is_open", allow_duplicate=True), + Output("manual-control-alert", "children", allow_duplicate=True), + Output("manual-control-alert", "color", allow_duplicate=True), + Output("manual-control-alert", "duration", allow_duplicate=True), + Output( + "manual-control-execute-modal-body-code", "children", allow_duplicate=True + ), + ], + Input("manual-control-open-execute-modal-button", "n_clicks"), + [ + State("url", "pathname"), + State("manual-control-command-dropdown", "className"), + State("manual-control-device-dropdown", "value"), + State("manual-control-command-dropdown", "value"), + State("manual-control-device-form", "children"), + State("manual-control-command-form", "children"), + ], + prevent_initial_call=True, +) +def manual_control_execute_fill_code( + n, url, opt, device, command, device_form, command_form +): + if str(url) == "/manual-control": + print("manual_control_execute_fill_code") + if device is None or device == "" or command is None or command == "": + return ( + opt, + True, + "Something went wrong. Check the fields below.", + "danger", + 3000, + ) + code = "" + code += util.devices_ref_redundancy[device]["import_device"] + code += "\n" + code += util.devices_ref_redundancy[device]["import_commands"] + code += "\n" + code += "from command_sequence import CommandSequence\nfrom command_invoker import CommandInvoker\n" + + instantiate_code = "" + instantiate_code += util.devices_ref_redundancy[device]["init"]["obj_name"] + instantiate_code += "(" + for i, arg in enumerate(util.devices_ref_redundancy[device]["init"]["args"]): + if i != 0: + instantiate_code += ", " + instantiate_code += arg + "=" + if util.devices_ref_redundancy[device]["init"]["args"][arg]["type"] == str: + instantiate_code += "'" + instantiate_code += str( + device_form[i]["props"]["children"][1]["props"]["children"][0][ + "props" + ]["value"] + ) + instantiate_code += "'" + else: + instantiate_code += str( + device_form[i]["props"]["children"][1]["props"]["children"][0][ + "props" + ]["value"] + ) + instantiate_code += ")" + code += str(device) + "_seq" + " = CommandSequence()" + code += "\n\n" + code += str(device) + "_seq" + ".add_device(" + instantiate_code + ")" + code += "\n" + + if util.devices_ref_redundancy[device]["serial"] == True: + for i, serial_seq_command in enumerate( + util.devices_ref_redundancy[device]["serial_sequence"] + ): + code += ( + str(device) + + "_seq" + + ".add_command(" + + str(serial_seq_command) + + "(" + ) + for ii, serial_seq_command_arg in enumerate( + util.devices_ref_redundancy[device]["commands"][serial_seq_command][ + "args" + ] + ): + if ii != 0: + code += ", " + if serial_seq_command_arg == "receiver": + code += ( + serial_seq_command_arg + + "=" + + str(device) + + "_seq.device_by_name['" + + str( + command_form[0]["props"]["children"][(2 * ii) + 1][ + "props" + ]["children"][1]["props"]["children"][0]["props"][ + "value" + ] + ) + + "']" + ) + elif ( + util.devices_ref_redundancy[device]["commands"][ + serial_seq_command + ]["args"][serial_seq_command_arg]["type"] + == str + ): + code += ( + serial_seq_command_arg + + "=" + + "'" + + str( + command_form[0]["props"]["children"][(2 * ii) + 1][ + "props" + ]["children"][1]["props"]["children"][0]["props"][ + "value" + ] + ) + + "'" + ) + else: + code += ( + serial_seq_command_arg + + "=" + + str( + command_form[0]["props"]["children"][(2 * ii) + 1][ + "props" + ]["children"][1]["props"]["children"][0]["props"][ + "value" + ] + ) + ) + + code += "))\n" + code += str(device) + "_seq" + ".add_command(" + str(command) + "(" + for ii, seq_command_arg in enumerate( + util.devices_ref_redundancy[device]["commands"][command]["args"] + ): + if ii != 0: + code += ", " + if seq_command_arg == "receiver": + code += ( + seq_command_arg + + "=" + + str(device) + + "_seq.device_by_name['" + + str( + command_form[2]["props"]["children"][ii]["props"][ + "children" + ][1]["props"]["children"][0]["props"]["value"] + ) + + "']" + ) + elif ( + util.devices_ref_redundancy[device]["commands"][command]["args"][ + seq_command_arg + ]["type"] + == str + ): + code += ( + seq_command_arg + + "=" + + "'" + + str( + command_form[2]["props"]["children"][ii]["props"][ + "children" + ][1]["props"]["children"][0]["props"]["value"] + ) + + "'" + ) + else: + code += ( + seq_command_arg + + "=" + + str( + command_form[2]["props"]["children"][ii]["props"][ + "children" + ][1]["props"]["children"][0]["props"]["value"] + ) + ) + + code += "))\n\n" + else: + code += str(device) + "_seq" + ".add_command(" + str(command) + "(" + for ii, seq_command_arg in enumerate( + util.devices_ref_redundancy[device]["commands"][command]["args"] + ): + if ii != 0: + code += ", " + if seq_command_arg == "receiver": + code += ( + seq_command_arg + + "=" + + str(device) + + "_seq.device_by_name['" + + str( + command_form[1]["props"]["children"][ii]["props"][ + "children" + ][1]["props"]["children"][0]["props"]["value"] + ) + + "']" + ) + elif ( + util.devices_ref_redundancy[device]["commands"][command]["args"][ + seq_command_arg + ]["type"] + == str + ): + code += ( + seq_command_arg + + "=" + + "'" + + str( + command_form[1]["props"]["children"][ii]["props"][ + "children" + ][1]["props"]["children"][0]["props"]["value"] + ) + + "'" + ) + else: + code += ( + seq_command_arg + + "=" + + str( + command_form[1]["props"]["children"][ii]["props"][ + "children" + ][1]["props"]["children"][0]["props"]["value"] + ) + ) + + code += "))\n\n" + code += ( + str(device) + + "_seq_invoker = CommandInvoker(" + + str(device) + + "_seq, False, False, False)\n" + ) + code += str(device) + "_seq_invoker.invoke_commands()" + interceptor = ConsoleInterceptor() + print("\n") + interceptor.start_interception() + print(code) + interceptor.stop_interception() + code_output = interceptor.get_intercepted_messages() + del interceptor + code_log_string = "" + for msg in code_output: + code_log_string += msg + # interceptor = ConsoleInterceptor() + # interceptor.start_interception() + # try: + # exec(code) + # interceptor.stop_interception() + # messages = interceptor.get_intercepted_messages() + # log_string = "" + # for msg in messages: + # if msg == '\r': + # log_string += '\n' + # else: + # log_string += msg + # print(messages) + + save_log_to_mongo(code_log_string) + + return ( + opt, + True, + "Command(s) ready to execute.", + "warning", + 0, + html.Pre(code_log_string), + ) + # except Exception as e: + # print(e) + # # interceptor.stop_interception() + # # messages = interceptor.get_intercepted_messages() + # # log_string = "" + # # for msg in messages: + # # log_string += msg + # return opt, True, "Something went wrong. Check logs.", "Warning", 0, True, html.Pre(code_log_string) + + return opt + + +@app.callback( + [ + Output("manual-control-alert", "is_open"), + Output("manual-control-alert", "children"), + Output("manual-control-alert", "color"), + Output("manual-control-alert", "duration"), + Output("manual-control-execute-modal-body", "children"), + ], + [Input("manual-control-execute-button", "n_clicks")], + [ + State("url", "pathname"), + State("manual-control-execute-modal-body-code", "children"), + ], + prevent_initial_call=True, +) +def manual_control_execute_code(n, url, code): + if str(url) == "/manual-control": + print("manual_control_execute_code") + interceptor = ConsoleInterceptor() + # print('\n') + # interceptor.start_interception() + # print(code) + # interceptor.stop_interception() + # code_output = interceptor.get_intercepted_messages() + # code_log_string = "" + # for msg in code_output: + # code_log_string += msg + # interceptor = ConsoleInterceptor() + interceptor.start_interception() + try: + exec(code["props"]["children"]) + interceptor.stop_interception() + messages = interceptor.get_intercepted_messages() + del interceptor + log_string = "" + for msg in messages: + if msg == "\r": + log_string += "\n" + else: + log_string += msg + return True, "Execution complete.", "success", 0, html.Pre(log_string) + except Exception as e: + print(e) + interceptor.stop_interception() + messages = interceptor.get_intercepted_messages() + del interceptor + log_string = "" + for msg in messages: + log_string += msg + return ( + True, + "Something went wrong. Check logs.", + "danger", + 0, + html.Pre(log_string), + ) + return False, "", "success", 0, html.Pre("") + + +@app.callback( + [ + Output("manual-control-port-modal", "is_open"), + Output("manual-control-serial-ports-info", "children"), + ], + Input("manual-control-port-field", "n_clicks"), + prevent_initial_call=True, +) +def open_fill_manual_control_serial(n): + if _has_serial and n != 0: + ports = serial.tools.list_ports.comports() + str_ports = "" + for port, desc, hwid in sorted(ports): + str_ports += f"{port}: {desc} [{hwid}]\n" + lines = str_ports.splitlines() + return True, [html.Div([html.Div(line) for line in lines])] + return False, [] + + +# --------------------------------------------------------------- +# Database page +# --------------------------------------------------------------- + + +@app.callback( + Output("database-db-dropdown", "options"), + Input("interval-database", "n_intervals"), + State("url", "pathname"), +) +def fill_database_db_dropdown(n, url): + if str(url) == "/database": + print("fill_database_db_dropdown") + # temporary provision to not expose other databases in demos + return [x for x in list(mongo.client.list_database_names()) if x == "aamp_test"] + + +@app.callback( + [ + Output("database-collection-dropdown", "disabled"), + Output("database-collection-dropdown", "options"), + ], + [Input("database-db-dropdown", "value")], + State("url", "pathname"), + prevent_initial_call=True, +) +def fill_database_collection_dropdown(db, url): # database page + if str(url) == "/database": + print("fill_database_collection_dropdown") + if db is not None and db != "": + return False, list(mongo.client[db].list_collection_names()) + return True, [] + + +@app.callback( + [ + Output("database-document-dropdown", "disabled"), + Output("database-document-dropdown", "options"), + ], + Input("database-collection-dropdown", "value"), + [State("url", "pathname"), State("database-db-dropdown", "value")], + prevent_initial_call=True, +) +def fill_database_document_dropdown(collection, url, db): + if str(url) == "/database": + if collection is not None and collection != "": + print("fill_database_document_dropdown") + docs = list(mongo.client[db][collection].find({})) + toRet = [] + for doc in docs: + toRet.append(str(doc["_id"])) + return False, toRet + else: + return True, [] + + +def process_schema(schema): + if isinstance(schema, dict): + if "bsonType" not in list(schema.keys()): + for key in list(schema.keys()): + schema[key] = process_schema(schema[key]) + elif "bsonType" in list(schema.keys()): + schema["Properties"] = {"Data Type": schema["bsonType"]} + del schema["bsonType"] + if "description" in list(schema.keys()): + schema["Properties"].update({"Description": schema["description"]}) + del schema["description"] + if "required" in list(schema.keys()): + # schema['Properties'].update({"Required": schema['required']}) + del schema["required"] + if "properties" in list(schema.keys()): + schema["Variables"] = process_schema(schema["properties"]) + del schema["properties"] + if "title" in list(schema.keys()): + del schema["title"] + + return schema + + +@app.callback( + Output("database-collection-schema", "children"), + [ + Input("database-collection-dropdown", "value"), + Input("database-db-dropdown", "value"), + ], + [State("url", "pathname")], + prevent_initial_call=True, +) +def fill_database_collection_schema(collection, db, url): + if str(url) == "/database": + print("fill_database_collection_schema") + if collection is not None and collection != "" and db is not None and db != "": + try: + print("Colln info:") + print(mongo.client[db].get_collection(collection)) + options = mongo.client[db].get_collection(collection).options() + if "validator" in options: + schema = options["validator"] + schema = process_schema(schema) + return render_dict(schema) + else: + return [""] + except Exception as e: + print(f"Error retrieving schema: {e}") + return ["Validation rules missing or something went wrong."] + + return [] + + +@app.callback( + Output("database-document-viewer", "children"), + [ + Input("database-document-dropdown", "value"), + Input("database-collection-dropdown", "value"), + ], + [State("url", "pathname"), State("database-db-dropdown", "value")], + prevent_initial_call=True, +) +def fill_database_document_viewer(document, collection, url, db): + if str(url) == "/database": + print("fill_database_document_viewer") + if document is not None and document != "" and db is not None and db != "": + # try: + doc = mongo.client[db][collection].find({"_id": ObjectId(document)})[0] + return render_dict(doc) + # except Exception as e: + # return ["Document missing or something went wrong"] + return [] + + +# --------------------------------------------------------------- +# Real Time Telemetry page +# --------------------------------------------------------------- +import random + + +@app.callback( + [Output("real-time-telemetry-div", "children")], + [ + Input("real-time-telemetry-device-dropdown", "value"), + Input("interval-real-time-telemetry", "n_intervals"), + ], + [State("url", "pathname")], + prevent_initial_call=True, +) +def fill_real_time_telemetry(device, n, url): + if str(url) == "/real-time-telemetry" and device is not None and device != "": + print("fill_real_time_telemetry") + if "telemetry" in list(util.devices_ref_redundancy[device].keys()): + device_parameter_options = util.devices_ref_redundancy[device]["telemetry"][ + "options" + ] + telemetry_data = {} + for parameter in util.devices_ref_redundancy[device]["telemetry"][ + "parameters" + ]: + parameter_options = util.devices_ref_redundancy[device]["telemetry"][ + "parameters" + ][parameter] + telemetry_data[parameter] = getattr( + util.devices_ref_redundancy[device]["default_obj"], + parameter_options["function_name"], + )() + telemetry_data["rand"] = random.randint(1, 10) + + metrics_data = telemetry_data + + metrics_cards = [] + for metric_name, metric_value in metrics_data.items(): + card = dbc.Card( + dbc.CardBody( + [ + html.H5(metric_name, className="card-title"), + html.P( + f"{metric_value}", + className="card-text", + style={"fontSize": "1.25rem"}, + ), + ] + ), + className="mb-3", + style={"width": "30%"}, + ) + metrics_cards.append(card) + + return [ + dbc.Row( + children=metrics_cards, + id="metrics-row", + style={"justifyContent": "space-around"}, + ) + ] + return [str(telemetry_data)] + return [""] + return [""] + +# --------------------------------------------------------------- +# Images page +# --------------------------------------------------------------- + +@app.callback( + Output("images-alert", "children"), + Output("images-alert", "is_open"), + Output("images-alert", "color"), + Input("upload-image-button", "n_clicks"), + [ + State("sample-number", "value"), + State("motor-speed", "value"), + State("temperature", "value"), + State("concentration", "value"), + State("printing-gap", "value"), + State("precursor-volume", "value"), + State("solvent", "value"), + State("image-upload", "contents"), + ], + prevent_initial_call=True, +) +def upload_image(n_clicks, sample_number, motor_speed, temperature, concentration, printing_gap, precursor_volume, solvent, image_contents): + print("upload_image") + if not image_contents: + return ( + "No image uploaded. Please upload an image.", + True, + "danger", + ) + + try: + header, encoded = image_contents.split(",", 1) + image_data = base64.b64decode(encoded) + + image_id = fs.put(image_data, filename=f"sample_{sample_number}.png") + + metadata = { + "sample_number": sample_number, + "motor_speed": motor_speed, + "temperature": temperature, + "concentration": concentration, + "printing_gap": printing_gap, + "precursor_volume": precursor_volume, + "solvent": solvent, + "image_id": str(image_id), + "timestamp": datetime.utcnow(), + } + mongo.db["images"].insert_one(metadata) + + return ( + f"Image uploaded successfully with ID: {image_id}", + True, + "success", + ) + except Exception as e: + print(f"Error uploading image: {e}") + return ( + f"Failed to upload image. Error: {str(e)}", + True, + "danger", + ) + + +# --------------------------------------------------------------- +# Sampler page +# --------------------------------------------------------------- + +# add the following to the temp choices d and c +# when adding to d, make sure to add three or four options evenly spaced between the min and max +# when adding to c, make sure to just add the min and max values + +# "1,4-Dichlorobenzene",C1=CC(=CC=C1Cl)Cl��,25,153.548 +# "1,2,4-Trihlorobenzene",C1=CC(=C(C=C1Cl)Cl)Cl,25,184.946 +# o-xylene,CC1=CC=CC=C1C��,25,119.5683 +# m-xylene,CC1=CC(=CC=C1)C,25,114.5912 +# p-xylene,CC1=CC=C(C=C1)C,25,113.775 +# mesitylene,CC1=CC(=CC(=C1)C)C,25,139.1875 +# toluene,CC1=CC=CC=C1�,25,87.7163 +# 1-Chloronaphthalene,C1=CC=C2C(=C1)C=CC=C2Cl,25,227.995 +# anisole,COC1=CC=CC=C1�,25,129.1316 +# Tetrahydrofuran,C1CCOC1��,25,45.7105 +# decane,CCCCCCCCCC,25,148.3072 + +# Add these constants from initial_point_generator.py +SOLV_NAMES = ["CF", "CB", "CB9:A1", "CB8:A2", "CB7:A3", "1,4-Dichlorobenzene", "1,2,4-Trihlorobenzene", "o-xylene", "m-xylene", "p-xylene", "mesitylene", "toluene", "1-Chloronaphthalene", "anisole", "Tetrahydrofuran", "decane"] +CONCEN_D = [2, 5, 10, 15, 20] +PRINT_GAP_D = [50, 100] +PREC_VOL_D = [6, 9, 12] +MOTOR_SPEEDS_D = [0.01, 0.0355, 0.126, 0.4472, 1.587, 5.635, 20] +SPEED_C = (0.01, 20.0) +PREC_VOL_C = (6.0, 12.0) +CONCEN_C = (1, 5) + +TEMP_CHOICES_D = { + "CF": [25, 41.3], + "CB": [25, 47.3, 62.9, 87.6, 107.4], + "CB9:A1": [25, 47.3, 62.9, 87.6, 107.4], + "CB8:A2": [25, 47.3, 62.9, 87.6, 107.4], + "CB7:A3": [25, 47.3, 62.9, 87.6, 107.4], + "1,4-Dichlorobenzene": [25, 47.3, 62.9, 87.6, 107.4, 135], + "1,2,4-Trihlorobenzene": [25, 47.3, 62.9, 87.6, 107.4, 135], + "o-xylene": [25, 47.3, 62.9, 87.6, 107.4, 119.6], + "m-xylene": [25, 47.3, 62.9, 87.6, 107.4, 114.6], + "p-xylene": [25, 47.3, 62.9, 87.6, 107.4, 113.8], + "mesitylene": [25, 47.3, 62.9, 87.6, 107.4, 135], + "toluene": [25, 47.3, 55.1, 62.9, 75.1, 87.7], + "1-Chloronaphthalene": [25, 47.3, 62.9, 87.6, 107.4, 135], + "anisole": [25, 47.3, 62.9, 87.6, 107.4, 129.1], + "Tetrahydrofuran": [25, 30.1, 35.4, 40, 45.7], + "decane": [25, 47.3, 62.9, 87.6, 107.4, 135], +} + +TEMP_CHOICES_C = { + "CF": (25, 41.3), + "CB": (25, 107.4), + "CB9:A1": (25, 107.4), + "CB8:A2": (25, 107.4), + "CB7:A3": (25, 107.4), + "1,4-Dichlorobenzene": (25, 135), + "1,2,4-Trihlorobenzene": (25, 135), + "o-xylene": (25, 119.6), + "m-xylene": (25, 114.6), + "p-xylene": (25, 113.8), + "mesitylene": (25, 135), + "toluene": (25, 87.7), + "1-Chloronaphthalene": (25, 135), + "anisole": (25, 129.1), + "Tetrahydrofuran": (25, 45.7), + "decane": (25, 135), +} + +from bson import ObjectId +import base64 + +# adding custom discrete parameter options +@app.callback( + Output({"type": "sampler-dropdown", "id": MATCH}, "options"), + Output({"type": "sampler-dropdown", "id": MATCH}, "value"), + Input({"type": "add-custom-button", "id": MATCH}, "n_clicks"), + State({"type": "custom-input", "id": MATCH}, "value"), + State({"type": "sampler-dropdown", "id": MATCH}, "options"), + State({"type": "sampler-dropdown", "id": MATCH}, "value"), + prevent_initial_call=True +) +def add_custom_value(n_clicks, custom_value, current_options, current_value): + if custom_value is not None: + new_option = {"label": str(custom_value), "value": custom_value} + if new_option not in current_options: + current_options.append(new_option) + current_value = current_value + [custom_value] if current_value else [custom_value] + return current_options, current_value + +@app.callback( + Output({"type": "sampler-temp-dropdown", "index": MATCH}, "options"), + Output({"type": "sampler-temp-dropdown", "index": MATCH}, "value"), + Input({"type": "add-custom-temp", "index": MATCH}, "n_clicks"), + State({"type": "custom-temp-input", "index": MATCH}, "value"), + State({"type": "sampler-temp-dropdown", "index": MATCH}, "options"), + State({"type": "sampler-temp-dropdown", "index": MATCH}, "value"), + prevent_initial_call=True +) +def add_custom_temperature(n_clicks, custom_temp, current_options, current_value): + if custom_temp is not None: + new_option = {"label": str(custom_temp), "value": custom_temp} + if new_option not in current_options: + current_options.append(new_option) + current_value = current_value + [custom_temp] if current_value else [custom_temp] + return current_options, current_value + +# updating based on selections and inputs +@app.callback( + Output({"type": "discrete-container", "param": MATCH}, "style"), + Output({"type": "continuous-container", "param": MATCH}, "style"), + Input({"type": "toggle", "param": MATCH}, "value") +) +def toggle_input_type(is_continuous): + if is_continuous: + return {"display": "none"}, {"display": "block"} + else: + return {"display": "block"}, {"display": "none"} + +@app.callback( + Output("gpc-data-output", "children"), + Output("gpc-data-store", "data"), + Input("gpc-data-upload", "contents"), + State("gpc-data-upload", "filename"), + prevent_initial_call=True +) +def process_gpc_data(contents, filename): + if contents is None: + return html.Div("No file uploaded yet."), None + + content_type, content_string = contents.split(',') + decoded = base64.b64decode(content_string) + + try: + if filename.endswith('.csv'): + df = pd.read_csv(io.StringIO(decoded.decode('utf-8'))) + elif filename.endswith(('.xlsx', '.xls')): + df = pd.read_excel(io.BytesIO(decoded)) + else: + return html.Div("Unsupported file type."), None + + gpc_data = df.to_dict('records') + + preview = html.Div([ + html.H5(f"GPC Data from {filename}"), + html.P(f"Successfully loaded {len(df)} rows"), + dash_table.DataTable( + data=df.head(5).to_dict('records'), + columns=[{'name': i, 'id': i} for i in df.columns], + style_table={'overflowX': 'auto'}, + ), + ]) + return preview, gpc_data + + except Exception as e: + return html.Div(f"Error processing file: {str(e)}"), None + + +@app.callback( + Output("sampler-smiles-string", "value"), + Output("sampler-polymer-image-preview", "children"), + Input("sampler-polymer-name", "value"), + Input("sampler-polymer-image", "contents"), + State("sampler-polymer-image", "filename"), + prevent_initial_call=True +) +def handle_polymer_inputs(polymer_name, image_contents, image_filename): + ctx = dash.callback_context + triggered_id = ctx.triggered[0]['prop_id'].split('.')[0] if ctx.triggered else None + + smiles = "" + image_preview = [] + + if triggered_id == "sampler-polymer-name" and polymer_name: + try: + campaign = mongo.db.campaigns.find_one({"polymer_name": polymer_name}) + if campaign: + smiles = campaign.get("smiles_string", "") + image_id = campaign.get("image_id") + if image_id: + try: + grid_out = fs.get(ObjectId(image_id)) + image_bytes = grid_out.read() + encoded = base64.b64encode(image_bytes).decode() + mime_type = grid_out.content_type if hasattr(grid_out, "content_type") else "image/png" + src = f"data:{mime_type};base64,{encoded}" + image_preview = [ + html.Img(src=src, style={'maxHeight': '200px', 'maxWidth': '100%'}), + html.P(grid_out.filename) + ] + except Exception as e: + print(f"Error retrieving image: {e}") + + except Exception as e: + print(f"Error in polymer name lookup: {e}") + + elif triggered_id == "sampler-polymer-image" and image_contents: + try: + content_type, content_string = image_contents.split(',') + decoded = base64.b64decode(content_string) + + image_preview = [ + html.Img(src=image_contents, style={'maxHeight': '200px', 'maxWidth': '100%'}), + html.P(image_filename) + ] + + except Exception as e: + print(f"Error processing image: {e}") + image_preview = [html.P("Invalid image file")] + + return smiles, image_preview + + +@app.callback( + Output("temperature-container", "style"), + Output("sampler-temperature-options", "children"), + Input("sampler-solvent-dropdown", "value") +) +def update_temperature_options(selected_solvents): + if not selected_solvents: + return {'display': 'none'}, [] + + temp_options = [] + for solvent in selected_solvents: + temp_options.append( + html.Div([ + dbc.Row( + [ + dbc.Col([html.H6(f"Temperature for {solvent}:")], width=8), + dbc.Col( + [ + dbc.Switch( + id={"type": "toggle", "param": f"temp-{solvent}"}, + label="Continuous", + value=False, + ), + ], + width=2, + ), + ], + className="mb-2", + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dcc.Dropdown( + id={"type": "sampler-temp-dropdown", "index": solvent}, + options=[{"label": str(temp), "value": temp} for temp in TEMP_CHOICES_D[solvent]], + multi=True, + value=TEMP_CHOICES_D[solvent], + ), + ], + width=8, + ), + dbc.Col( + dbc.InputGroup([ + dbc.Input(id={"type": "custom-temp-input", "index": solvent}, type="number", placeholder="Custom temperature"), + dbc.Button("Add", id={"type": "add-custom-temp", "index": solvent}, size="sm"), + ]), + width=4, + ) + ], + className="mb-3", + ), + ], + id={"type": "discrete-container", "param": f"temp-{solvent}"}, + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dbc.InputGroup( + [ + dbc.Input(id={"type": "temp-min", "index": solvent}, type="number", placeholder="Min", value=TEMP_CHOICES_C[solvent][0]), + dbc.Input(id={"type": "temp-max", "index": solvent}, type="number", placeholder="Max", value=TEMP_CHOICES_C[solvent][1]), + ] + ), + ], + width=4, + ), + ], + className="mb-3", + ), + ], + id={"type": "continuous-container", "param": f"temp-{solvent}"}, + style={"display": "none"}, + ), + ]) + ) + + return {'display': 'block'}, temp_options + +from scipy.stats import qmc + +@app.callback( + Output("sampler-results-table", "children"), + Output("sampler-alert", "children"), + Output("sampler-alert", "is_open"), + Output("sampler-alert", "color"), + Output("sampler-save-button", "disabled"), + Output("sampler-results-plots", "children"), + Input("sampler-generate-button", "n_clicks"), + [ + State("sampler-campaign-name", "value"), + State("sampler-polymer-name", "value"), + State("sampler-smiles-string", "value"), + State("sampler-mw", "value"), + State("sampler-pdi", "value"), + State("sampler-solvent-dropdown", "value"), + State({"type": "toggle", "param": ALL}, "value"), + State({"type": "toggle", "param": ALL}, "id"), + State({"type": "sampler-temp-dropdown", "index": ALL}, "value"), + State({"type": "sampler-temp-dropdown", "index": ALL}, "id"), + State({"type": "temp-min", "index": ALL}, "value"), + State({"type": "temp-min", "index": ALL}, "id"), + State({"type": "temp-max", "index": ALL}, "value"), + State({"type": "temp-max", "index": ALL}, "id"), + State({"type": "sampler-dropdown", "id": "concentration"}, "value"), + State({"type": "toggle", "param": "concentration"}, "value"), + State("concentration-min", "value"), + State("concentration-max", "value"), + State({"type": "sampler-dropdown", "id": "printing-gap"}, "value"), + State({"type": "toggle", "param": "printing-gap"}, "value"), + State("printing-gap-min", "value"), + State("printing-gap-max", "value"), + State({"type": "sampler-dropdown", "id": "precursor-volume"}, "value"), + State({"type": "toggle", "param": "precursor-volume"}, "value"), + State("precursor-volume-min", "value"), + State("precursor-volume-max", "value"), + State({"type": "sampler-dropdown", "id": "motor-speed"}, "value"), + State({"type": "toggle", "param": "motor-speed"}, "value"), + State("motor-speed-min", "value"), + State("motor-speed-max", "value"), + State("sampler-method-dropdown", "value"), + State("sampler-num-samples", "value"), + ], + prevent_initial_call=True, +) +# def generate_parameter_sets(n_clicks, campaign_name, polymer_name, smiles_string, mw, pdi, +# solvents, toggle_values, toggle_ids, temp_dropdown_values, +# temp_dropdown_ids, temp_min_values, temp_min_ids, +# temp_max_values, temp_max_ids, concentration_range, +# concentration_toggle, concentration_min, concentration_max, +# printing_gaps, printing_gap_toggle, printing_gap_min, +# printing_gap_max, precursor_vol, precursor_vol_toggle, +# precursor_vol_min, precursor_vol_max, motor_speeds, +# motor_speed_toggle, motor_speed_min, motor_speed_max, +# sampling_method, num_samples): +# if not solvents: +# return None, "Please select at least one solvent.", True, "danger", True + +# try: +# temp_toggles = {} +# temp_discrete_values = {} +# temp_min = {} +# temp_max = {} + +# for toggle_value, toggle_id in zip(toggle_values, toggle_ids): +# param = toggle_id["param"] +# if param.startswith("temp-"): +# solvent = param.replace("temp-", "") +# temp_toggles[solvent] = toggle_value + +# for dropdown_value, dropdown_id in zip(temp_dropdown_values, temp_dropdown_ids): +# solvent = dropdown_id["index"] +# temp_discrete_values[solvent] = dropdown_value + +# for min_value, min_id in zip(temp_min_values, temp_min_ids): +# solvent = min_id["index"] +# temp_min[solvent] = min_value + +# for max_value, max_id in zip(temp_max_values, temp_max_ids): +# solvent = max_id["index"] +# temp_max[solvent] = max_value + +# parameter_sets = [] +# sample_count = 1 + +# for solvent in solvents: +# is_temp_continuous = temp_toggles.get(solvent, False) + +# for _ in range(num_samples): +# if is_temp_continuous: +# min_temp = temp_min.get(solvent, TEMP_CHOICES_C[solvent][0]) +# max_temp = temp_max.get(solvent, TEMP_CHOICES_C[solvent][1]) +# temperature = round(random.uniform(min_temp, max_temp)) +# else: +# temp_options = temp_discrete_values.get(solvent, TEMP_CHOICES_D[solvent]) +# temperature = round(random.choice(temp_options)) + +# if concentration_toggle: +# concentration = round(random.uniform(concentration_min, concentration_max), 2) +# else: +# concentration = random.choice(concentration_range) if concentration_range else random.choice(CONCEN_D) + +# if printing_gap_toggle: +# printing_gap = round(random.uniform(printing_gap_min, printing_gap_max)) +# else: +# printing_gap = random.choice(printing_gaps) if printing_gaps else random.choice(PRINT_GAP_D) + +# if precursor_vol_toggle: +# precursor_volume = round(random.uniform(precursor_vol_min, precursor_vol_max), 1) +# else: +# precursor_volume = random.choice(precursor_vol) if precursor_vol else random.choice(PREC_VOL_D) + +# if motor_speed_toggle: +# log_speed_min = math.log10(motor_speed_min) +# log_speed_max = math.log10(motor_speed_max) +# log_speed = log_speed_min + random.random() * (log_speed_max - log_speed_min) +# motor_speed = round(10 ** log_speed, 2) +# else: +# motor_speed = random.choice(motor_speeds) if motor_speeds else random.choice(MOTOR_SPEEDS_D) + +# # make all the parameters normalized between 0 and 1 using min-max normalization +# if motor_speed_toggle: +# log_speed_min = math.log10(motor_speed_min) +# log_speed_max = math.log10(motor_speed_max) +# motor_speed_norm = (math.log10(motor_speed) - log_speed_min) / (log_speed_max - log_speed_min) if log_speed_max - log_speed_min != 0 else 0 +# else: +# motor_speeds_list = motor_speeds if motor_speeds else MOTOR_SPEEDS_D +# log_min = math.log10(min(motor_speeds_list)) +# log_max = math.log10(max(motor_speeds_list)) +# motor_speed_norm = (math.log10(motor_speed) - log_min) / (log_max - log_min) if log_max - log_min != 0 else 0 + +# if is_temp_continuous: +# min_temp = temp_min.get(solvent, TEMP_CHOICES_C[solvent][0]) +# max_temp = temp_max.get(solvent, TEMP_CHOICES_C[solvent][1]) +# temperature_norm = (temperature - min_temp) / (max_temp - min_temp) +# else: +# temp_options = temp_discrete_values.get(solvent, TEMP_CHOICES_D[solvent]) +# temperature_norm = (temperature - min(temp_options)) / (max(temp_options) - min(temp_options)) + +# if concentration_toggle: +# concentration_norm = (concentration - concentration_min) / (concentration_max - concentration_min) if concentration_max - concentration_min != 0 else 0 +# else: +# concentration_list = concentration_range if concentration_range else CONCEN_D +# concentration_norm = (concentration - min(concentration_list)) / (max(concentration_list) - min(concentration_list)) if max(concentration_list) - min(concentration_list) != 0 else 0 + +# if printing_gap_toggle: +# printing_gap_norm = (printing_gap - printing_gap_min) / (printing_gap_max - printing_gap_min) if printing_gap_max - printing_gap_min != 0 else 0 +# else: +# printing_gaps_list = printing_gaps if printing_gaps else PRINT_GAP_D +# printing_gap_norm = (printing_gap - min(printing_gaps_list)) / (max(printing_gaps_list) - min(printing_gaps_list)) if max(printing_gaps_list) - min(printing_gaps_list) != 0 else 0 + +# if precursor_vol_toggle: +# precursor_volume_norm = (precursor_volume - precursor_vol_min) / (precursor_vol_max - precursor_vol_min) if precursor_vol_max - precursor_vol_min != 0 else 0 +# else: +# precursor_vol_list = precursor_vol if precursor_vol else PREC_VOL_D +# precursor_volume_norm = (precursor_volume - min(precursor_vol_list)) / (max(precursor_vol_list) - min(precursor_vol_list)) if max(precursor_vol_list) - min(precursor_vol_list) != 0 else 0 + +# parameter_set = { +# "sample_no": sample_count, +# "campaign_name": campaign_name, +# "polymer_name": polymer_name, +# "smiles_string": smiles_string, +# "mw": mw, +# "pdi": pdi, +# "motor_speed": motor_speed, +# "temperature": temperature, +# "concentration": concentration, +# "printing_gap": printing_gap, +# "precursor_volume": precursor_volume, +# "solvent": solvent, +# "motor_speed_norm": motor_speed_norm, +# "temperature_norm": temperature_norm, +# "concentration_norm": concentration_norm, +# "printing_gap_norm": printing_gap_norm, +# "precursor_volume_norm": precursor_volume_norm +# } + +# parameter_sets.append(parameter_set) +# sample_count += 1 + +# df = pd.DataFrame(parameter_sets) +# df = df.sort_values(by=["solvent", "temperature"], ascending=[True, True]) +# parameter_sets = df.to_dict(orient="records") + +# table = dash_table.DataTable( +# id="sampler-results", +# columns=[ +# {"name": "Sample No", "id": "sample_no"}, +# {"name": "Motor Speed", "id": "motor_speed"}, +# {"name": "Temperature", "id": "temperature"}, +# {"name": "Concentration", "id": "concentration"}, +# {"name": "Printing Gap", "id": "printing_gap"}, +# {"name": "Precursor Volume", "id": "precursor_volume"}, +# {"name": "Solvent", "id": "solvent"}, +# ], +# data=parameter_sets, +# style_table={"overflowX": "auto"}, +# ) + +# if len(df) >= 2: +# X = df[['motor_speed_norm', 'temperature_norm', 'concentration_norm', +# 'printing_gap_norm', 'precursor_volume_norm']].values + +# pca = PCA(n_components=2) +# pca_result = pca.fit_transform(X) + +# reducer = umap.UMAP(random_state=42, n_neighbors=min(5, len(df)-1)) +# umap_result = reducer.fit_transform(X) + +# n_background = 1000 +# background_points = np.random.rand(n_background, X.shape[1]) + +# background_pca = pca.transform(background_points) +# background_umap = reducer.transform(background_points) + +# pca_fig = go.Figure() + +# x_min, x_max = background_pca[:,0].min(), background_pca[:,0].max() +# y_min, y_max = background_pca[:,1].min(), background_pca[:,1].max() + +# xi = np.linspace(x_min, x_max, 100) +# yi = np.linspace(y_min, y_max, 100) +# xi, yi = np.meshgrid(xi, yi) + +# positions = np.vstack([xi.ravel(), yi.ravel()]) +# values = np.vstack([background_pca[:,0], background_pca[:,1]]) +# kernel = gaussian_kde(values) +# z = np.reshape(kernel(positions).T, xi.shape) + +# pca_fig.add_trace(go.Contour( +# z=z, +# x=xi[0], +# y=yi[:,0], +# colorscale='Blues', +# showscale=False, +# opacity=0.5, +# name='Parameter Space Density' +# )) + +# for solvent in df['solvent'].unique(): +# mask = df['solvent'] == solvent +# pca_fig.add_trace(go.Scatter( +# x=pca_result[mask, 0], +# y=pca_result[mask, 1], +# mode='markers', +# marker=dict(size=8), +# name=solvent +# )) + +# pca_fig.update_layout( +# title='PCA Visualization of Parameter Sets', +# xaxis_title='PCA Component 1', +# yaxis_title='PCA Component 2' +# ) + +# umap_fig = go.Figure() + +# x_min, x_max = background_umap[:,0].min(), background_umap[:,0].max() +# y_min, y_max = background_umap[:,1].min(), background_umap[:,1].max() + +# xi = np.linspace(x_min, x_max, 100) +# yi = np.linspace(y_min, y_max, 100) +# xi, yi = np.meshgrid(xi, yi) + +# positions = np.vstack([xi.ravel(), yi.ravel()]) +# values = np.vstack([background_umap[:,0], background_umap[:,1]]) +# kernel = gaussian_kde(values) +# z = np.reshape(kernel(positions).T, xi.shape) + +# umap_fig.add_trace(go.Contour( +# z=z, +# x=xi[0], +# y=yi[:,0], +# colorscale='Blues', +# showscale=False, +# opacity=0.5, +# name='Parameter Space Density' +# )) + +# for solvent in df['solvent'].unique(): +# mask = df['solvent'] == solvent +# umap_fig.add_trace(go.Scatter( +# x=umap_result[mask, 0], +# y=umap_result[mask, 1], +# mode='markers', +# marker=dict(size=8), +# name=solvent +# )) + +# umap_fig.update_layout( +# title='UMAP Visualization of Parameter Sets', +# xaxis_title='UMAP Component 1', +# yaxis_title='UMAP Component 2' +# ) + +# res = html.Div([ +# table +# ]) +# plots = html.Div([ +# html.Div([ +# dcc.Graph(figure=pca_fig) +# ], className="col-md-6"), +# html.Div([ +# dcc.Graph(figure=umap_fig) +# ], className="col-md-6"), +# ], className="row") +# global p1, p2 +# p1 = pca_fig +# p2 = umap_fig +# else: +# res = html.Div([ +# html.P("Not enough data points for visualization. Generate more samples."), +# html.H3("Generated Parameter Sets"), +# table +# ]) +# plots = None + + +# return res, f"Generated {len(parameter_sets)} parameter sets using simple random sampling.", True, "success", False, plots +# except Exception as e: +# print(f"Error generating parameter sets: {e}") +# return None, f"Failed to generate parameter sets: {str(e)}", True, "danger", True, None +def generate_parameter_sets(n_clicks, campaign_name, polymer_name, smiles_string, mw, pdi, + solvents, toggle_values, toggle_ids, temp_dropdown_values, + temp_dropdown_ids, temp_min_values, temp_min_ids, + temp_max_values, temp_max_ids, concentration_range, + concentration_toggle, concentration_min, concentration_max, + printing_gaps, printing_gap_toggle, printing_gap_min, + printing_gap_max, precursor_vol, precursor_vol_toggle, + precursor_vol_min, precursor_vol_max, motor_speeds, + motor_speed_toggle, motor_speed_min, motor_speed_max, + sampling_method, num_samples): + + if not solvents: + return None, "Please select at least one solvent.", True, "danger", True, None + + try: + temp_toggles = {} + temp_discrete_values = {} + temp_min = {} + temp_max = {} + + for toggle_value, toggle_id in zip(toggle_values, toggle_ids): + param = toggle_id["param"] + if param.startswith("temp-"): + solvent = param.replace("temp-", "") + temp_toggles[solvent] = toggle_value + + for dropdown_value, dropdown_id in zip(temp_dropdown_values, temp_dropdown_ids): + temp_discrete_values[dropdown_id["index"]] = dropdown_value + + for min_value, min_id in zip(temp_min_values, temp_min_ids): + temp_min[min_id["index"]] = min_value + + for max_value, max_id in zip(temp_max_values, temp_max_ids): + temp_max[max_id["index"]] = max_value + + # --------------------------- + # Sobol Setup + # --------------------------- + if sampling_method == "sobol": + dim = 5 + sobol_engine = qmc.Sobol(d=dim, scramble=True) + sobol_points = sobol_engine.random(n=num_samples * len(solvents)) + sobol_index = 0 + + parameter_sets = [] + sample_count = 1 + + for solvent in solvents: + is_temp_continuous = temp_toggles.get(solvent, False) + + for _ in range(num_samples): + + if sampling_method == "sobol": + u = sobol_points[sobol_index] + sobol_index += 1 + + # ---------------- MOTOR SPEED ---------------- + if motor_speed_toggle: + log_min = math.log10(motor_speed_min) + log_max = math.log10(motor_speed_max) + + if sampling_method == "sobol": + log_speed = log_min + u[0] * (log_max - log_min) + else: + log_speed = log_min + random.random() * (log_max - log_min) + + motor_speed = round(10 ** log_speed, 3) + else: + motor_speed = random.choice(motor_speeds) if motor_speeds else random.choice(MOTOR_SPEEDS_D) + + # ---------------- TEMPERATURE ---------------- + if is_temp_continuous: + min_temp = temp_min.get(solvent, TEMP_CHOICES_C[solvent][0]) + max_temp = temp_max.get(solvent, TEMP_CHOICES_C[solvent][1]) + + if sampling_method == "sobol": + temperature = round(min_temp + u[1] * (max_temp - min_temp)) + else: + temperature = round(random.uniform(min_temp, max_temp)) + else: + temp_options = temp_discrete_values.get(solvent, TEMP_CHOICES_D[solvent]) + temperature = random.choice(temp_options) + + # ---------------- CONCENTRATION ---------------- + if concentration_toggle: + if sampling_method == "sobol": + concentration = round( + concentration_min + u[2] * (concentration_max - concentration_min), 2 + ) + else: + concentration = round(random.uniform(concentration_min, concentration_max), 2) + else: + concentration = random.choice(concentration_range) if concentration_range else random.choice(CONCEN_D) + + # ---------------- PRINTING GAP ---------------- + if printing_gap_toggle: + if sampling_method == "sobol": + printing_gap = round( + printing_gap_min + u[3] * (printing_gap_max - printing_gap_min) + ) + else: + printing_gap = round(random.uniform(printing_gap_min, printing_gap_max)) + else: + printing_gap = random.choice(printing_gaps) if printing_gaps else random.choice(PRINT_GAP_D) + + # ---------------- PRECURSOR VOLUME ---------------- + if precursor_vol_toggle: + if sampling_method == "sobol": + precursor_volume = round( + precursor_vol_min + u[4] * (precursor_vol_max - precursor_vol_min), 1 + ) + else: + precursor_volume = round(random.uniform(precursor_vol_min, precursor_vol_max), 1) + else: + precursor_volume = random.choice(precursor_vol) if precursor_vol else random.choice(PREC_VOL_D) + + # ---------------- NORMALIZATION ---------------- + log_min = math.log10(motor_speed_min) if motor_speed_toggle else math.log10(min(motor_speeds if motor_speeds else MOTOR_SPEEDS_D)) + log_max = math.log10(motor_speed_max) if motor_speed_toggle else math.log10(max(motor_speeds if motor_speeds else MOTOR_SPEEDS_D)) + motor_speed_norm = (math.log10(motor_speed) - log_min) / (log_max - log_min) if log_max - log_min != 0 else 0 + + if is_temp_continuous: + temperature_norm = (temperature - min_temp) / (max_temp - min_temp) if max_temp - min_temp != 0 else 0 + else: + temp_list = temp_discrete_values.get(solvent, TEMP_CHOICES_D[solvent]) + temperature_norm = (temperature - min(temp_list)) / (max(temp_list) - min(temp_list)) if max(temp_list) - min(temp_list) != 0 else 0 + + if concentration_toggle: + concentration_norm = (concentration - concentration_min) / (concentration_max - concentration_min) if concentration_max - concentration_min != 0 else 0 + else: + c_list = concentration_range if concentration_range else CONCEN_D + concentration_norm = (concentration - min(c_list)) / (max(c_list) - min(c_list)) if max(c_list) - min(c_list) != 0 else 0 + + if printing_gap_toggle: + printing_gap_norm = (printing_gap - printing_gap_min) / (printing_gap_max - printing_gap_min) if printing_gap_max - printing_gap_min != 0 else 0 + else: + pg_list = printing_gaps if printing_gaps else PRINT_GAP_D + printing_gap_norm = (printing_gap - min(pg_list)) / (max(pg_list) - min(pg_list)) if max(pg_list) - min(pg_list) != 0 else 0 + + if precursor_vol_toggle: + precursor_volume_norm = (precursor_volume - precursor_vol_min) / (precursor_vol_max - precursor_vol_min) if precursor_vol_max - precursor_vol_min != 0 else 0 + else: + pv_list = precursor_vol if precursor_vol else PREC_VOL_D + precursor_volume_norm = (precursor_volume - min(pv_list)) / (max(pv_list) - min(pv_list)) if max(pv_list) - min(pv_list) != 0 else 0 + + parameter_sets.append({ + "sample_no": sample_count, + "campaign_name": campaign_name, + "polymer_name": polymer_name, + "smiles_string": smiles_string, + "mw": mw, + "pdi": pdi, + "motor_speed": motor_speed, + "temperature": temperature, + "concentration": concentration, + "printing_gap": printing_gap, + "precursor_volume": precursor_volume, + "solvent": solvent, + "motor_speed_norm": motor_speed_norm, + "temperature_norm": temperature_norm, + "concentration_norm": concentration_norm, + "printing_gap_norm": printing_gap_norm, + "precursor_volume_norm": precursor_volume_norm + }) + + sample_count += 1 + + df = pd.DataFrame(parameter_sets) + df = df.sort_values(by=["solvent", "temperature"]) + + table = dash_table.DataTable( + id="sampler-results", + columns=[{"name": col.replace("_", " ").title(), "id": col} + for col in ["sample_no", "motor_speed", "temperature", + "concentration", "printing_gap", + "precursor_volume", "solvent"]], + data=df.to_dict("records"), + style_table={"overflowX": "auto"}, + ) + + return html.Div([table]), \ + f"Generated {len(df)} parameter sets using {sampling_method} sampling.", \ + True, "success", False, None + + except Exception as e: + print(f"Error generating parameter sets: {e}") + return None, f"Failed to generate parameter sets: {str(e)}", True, "danger", True, None + + +@app.callback( + Output("sampler-alert", "children", allow_duplicate=True), + Output("sampler-alert", "is_open", allow_duplicate=True), + Output("sampler-alert", "color", allow_duplicate=True), + Output("sampler-save-button", "disabled", allow_duplicate=True), + Input("sampler-save-button", "n_clicks"), + State("sampler-results", "data"), + State("gpc-data-store", "data"), + State("sampler-campaign-name", "value"), + State("sampler-polymer-name", "value"), + State("sampler-smiles-string", "value"), + State("sampler-mw", "value"), + State("sampler-pdi", "value"), + State("sampler-polymer-image", "contents"), + State("sampler-polymer-image", "filename"), + State({"type": "sampler-dropdown", "id": "concentration"}, "value"), + State({"type": "sampler-dropdown", "id": "printing-gap"}, "value"), + State({"type": "sampler-dropdown", "id": "precursor-volume"}, "value"), + State({"type": "sampler-dropdown", "id": "motor-speed"}, "value"), + prevent_initial_call=True +) +def save_parameter_sets_to_mongo(n_clicks, parameter_sets, gpc_data, campaign_name, polymer_name, + smiles_string, mw, pdi, image_contents, image_filename, + concentration_range, printing_gap, precursor_vol, motor_speed): + if not parameter_sets: + return "No parameter sets to save.", True, "warning", True + + try: + image_id = None + if image_contents and image_filename: + content_type, content_string = image_contents.split(',') + decoded = base64.b64decode(content_string) + image_id = fs.put(decoded, filename=image_filename) + + existing_campaign = mongo.db.campaigns.find_one({"campaign_name": campaign_name}) + + if existing_campaign: + campaign_id = existing_campaign["_id"] + pipeline = [ + {"$match": {"campaign_id": campaign_id}}, + {"$group": {"_id": None, "max_value": {"$max": "$batch_no"}}} + ] + + try: + result = list(mongo.db.sets.aggregate(pipeline)) + max_value = result[0]["max_value"] if result else None + except (IndexError, KeyError): + max_value = None + + batch_no = max_value + 1 if max_value is not None else 1 + update_data = {} + + if gpc_data: + update_data["gpc"] = gpc_data + if image_id: + update_data["image_id"] = image_id + + if update_data: + mongo.db.campaigns.update_one( + {"_id": campaign_id}, + {"$set": update_data} + ) + + sets_to_insert = [{ + "campaign_id": campaign_id, + "polymer_name": polymer_name, + "batch_no": batch_no, + "sample_no": p_set["sample_no"], + "motor_speed": p_set["motor_speed"], + "temperature": p_set["temperature"], + "concentration": p_set["concentration"], + "printing_gap": p_set["printing_gap"], + "precursor_volume": p_set["precursor_volume"], + "solvent": p_set["solvent"], + **{k: p_set[k] for k in p_set if k.endswith('_norm')} + } for p_set in parameter_sets] + + sets_result = mongo.db.sets.insert_many(sets_to_insert) + msg = f"Added {len(sets_result.inserted_ids)} parameter sets to existing campaign '{campaign_name}'" + if image_id: + msg += " with updated polymer image" + return msg, True, "success", False + + else: + campaign_doc = { + "campaign_name": campaign_name, + "smiles_string": smiles_string, + "mw": mw, + "pdi": pdi, + "created_at": datetime.now(), + "concentration_range": concentration_range, + "printing_gap": printing_gap, + "precursor_volume": precursor_vol, + "motor_speed": motor_speed, + } + + if gpc_data: + campaign_doc["gpc"] = gpc_data + if image_id: + campaign_doc["image_id"] = image_id + + campaign_result = mongo.db.campaigns.insert_one(campaign_doc) + campaign_id = campaign_result.inserted_id + batch_no = 1 + + sets_to_insert = [{ + "campaign_id": campaign_id, + "polymer_name": polymer_name, + "batch_no": batch_no, + "sample_no": p_set["sample_no"], + "motor_speed": p_set["motor_speed"], + "temperature": p_set["temperature"], + "concentration": p_set["concentration"], + "printing_gap": p_set["printing_gap"], + "precursor_volume": p_set["precursor_volume"], + "solvent": p_set["solvent"], + **{k: p_set[k] for k in p_set if k.endswith('_norm')} + } for p_set in parameter_sets] + + sets_result = mongo.db.sets.insert_many(sets_to_insert) + msg = f"Created new campaign '{campaign_name}' with {len(sets_result.inserted_ids)} parameter sets" + if image_id: + msg += " and polymer image" + return msg, True, "success", False + + except Exception as e: + print(f"Failed to save parameter sets: {str(e)}") + return f"Failed to save parameter sets: {str(e)}", True, "danger", True + + +# --------------------------------------------------------------- +# Recipe Builder page +# --------------------------------------------------------------- + + +@app.callback( + Output('recipe-builder-campaign-dropdown', 'options'), + Input('recipe-builder-campaign-dropdown', 'search_value') +) +def update_campaign_options(search_value): + try: + campaigns = list(mongo.db.campaigns.find({}, {"campaign_name": 1})) + campaign_options = [{"label": camp["campaign_name"], "value": camp["campaign_name"]} + for camp in campaigns] + if search_value: + campaign_options = [c for c in campaign_options + if search_value.lower() in c["label"].lower()] + + return campaign_options + + except Exception as e: + print(f"Error fetching campaigns: {e}") + return [] + +@app.callback( + Output("recipe-builder-run-button", "children"), + Input("recipe-builder-output", "children"), + State("recipe-builder-parameter-sets", "data"), + prevent_initial_call=True +) +def update_run_button_label(_, parameter_sets): + num_recipes = len(parameter_sets) if parameter_sets else 0 + return f"Run {num_recipes} recipe{'s' if num_recipes != 1 else ''}" + + +@app.callback( + Output("recipe-builder-sets", "children"), + Output("recipe-builder-parameter-sets", "data"), + Input("recipe-builder-campaign-dropdown", "value"), + prevent_initial_call=True +) +def display_campaign_sets(selected_campaign): + if not selected_campaign: + return html.Div("Select a campaign to view its parameter sets") + + try: + campaign = mongo.db.campaigns.find_one({"campaign_name": selected_campaign}) + if not campaign: + return html.Div(f"Campaign '{selected_campaign}' not found") + sets = list(mongo.db.sets.find({"campaign_id": campaign["_id"]})) + + if not sets: + return html.Div(f"No parameter sets found for campaign '{selected_campaign}'") + + for s in sets: + s["_id"] = str(s["_id"]) + s["campaign_id"] = str(s["campaign_id"]) + + columns = [{"name": k.title().replace("_", " "), "id": k} + for k in sets[0].keys() if k not in ["_id", "campaign_id"]] + + selected_rows = list(range(len(sets))) + + table = dash_table.DataTable( + id='recipe-builder-sets-table', + columns=columns, + data=sets, + style_table={'overflowX': 'auto'}, + page_size=10, + row_selectable='multi', + selected_rows=selected_rows + ) + + return table, sets + + except Exception as e: + print(f"Error loading parameter sets: {e}") + return html.Div("Error loading parameter sets", style={'color': 'red'}) + + +with open('../recipes/recipe_sample.py', 'r') as f: + RECIPE_TEMPLATE = Template(f.read()) + +@app.callback( + Output("recipe-builder-solution-position", "value"), + Output("recipe-alert", "children"), + Output("recipe-alert", "is_open"), + Input("recipe-builder-parameter-sets", "data"), + Input("recipe-builder-sets-table", "selected_rows"), + prevent_initial_call=True +) +def auto_find_solution_positions(parameter_sets, selected_rows): + if not parameter_sets or not selected_rows: + return dash.no_update, dash.no_update, dash.no_update + + solution_map = mongo.db.solution_map.find_one({}) + if not solution_map: + raise Exception("No solution map found in database") + solution_map.pop('_id', None) + + positions = [] + for idx in selected_rows: + params = parameter_sets[idx] + found = False + + for cell_id, cell in solution_map.items(): + if (cell['status'] == 'occupied' and + cell['polymer'] == params['polymer_name'] and + cell['solvent'] == params['solvent'] and + cell['concentration'] == params['concentration']): + + positions.append(cell_id) + found = True + break + + if not found: + err_msg = ( + f"No exact match found for {params['polymer_name']}/{params['solvent']}/{params['concentration']}%" + ) + return "", err_msg, True + + return ", ".join(positions), "Exact solution positions found", True + + +@app.callback( + Output("recipe-builder-output", "children"), + Output("recipe-builder-generated-scripts", "data"), + Input("recipe-builder-generate-button", "n_clicks"), + State("recipe-builder-sets-table", "selected_rows"), + State("recipe-builder-parameter-sets", "data"), + State("recipe-builder-solution-position", "value"), + prevent_initial_call=True +) +def generate_recipes(n_clicks, selected_rows, parameter_sets, solution_positions): + positions = [pos.strip() for pos in solution_positions.split(",")] if solution_positions else [] + + generated_scripts = [] + for idx, pos in zip(selected_rows, positions): + params = parameter_sets[idx] + + sub_dict = { + 'sample_no': params['sample_no'], + 'polymer': params['polymer_name'], + 'solvent': params['solvent'], + 'concentration': params['concentration'], + 'motor_speed': params['motor_speed'], + 'temperature': params['temperature'], + 'printing_gap': params['printing_gap'], + 'precursor_volume': params['precursor_volume'], + 'solution_position': pos + } + + try: + script = RECIPE_TEMPLATE.safe_substitute(sub_dict) + generated_scripts.append({ + "sample_no": params["sample_no"], + "script": script + }) + except Exception as e: + return html.Div(f"Error generating recipe: {str(e)}", style={'color': 'red'}) + + return html.Div([ + html.H4("Generated Recipes"), + html.Ul([ + html.Li([ + html.P(f"Sample {script['sample_no']}"), + html.Div([ + html.Pre( + script['script'], + style={ + 'height': '400px', + 'overflowY': 'auto', + 'backgroundColor': '#f8f9fa', + 'padding': '10px', + 'border': '1px solid #dee2e6' + } + ), + ]) + ]) for script in generated_scripts + ]) + ]), generated_scripts + + +@app.callback( + Output("recipe-builder-run-log", "children"), + Input("recipe-builder-run-button", "n_clicks"), + State("recipe-builder-generated-scripts", "data"), + prevent_initial_call=True +) +def run_recipes_sequentially(n_clicks, generated_scripts): + log_components = [] + interceptor = ConsoleInterceptor() + + try: + for script in generated_scripts: + code = script['script'] + log_components.append(html.H5(f"Running Sample {script['sample_no']}")) + + interceptor.start_interception() + try: + exec(code) + status = "Success" + alert_color = "success" + except Exception as e: + status = f"Error: {str(e)}" + alert_color = "danger" + finally: + interceptor.stop_interception() + + output = interceptor.get_intercepted_messages() + + log_components.extend([ + dbc.Alert(status, color=alert_color), + html.Pre( + f"Output:\n{output}", + style={ + 'backgroundColor': '#f8f9fa', + 'padding': '10px', + 'border': '1px solid #dee2e6', + 'maxHeight': '300px', + 'overflowY': 'auto' + } + ), + html.Hr() + ]) + + return log_components + + except Exception as e: + return html.Div(f"Critical error: {str(e)}", style={'color': 'red'}) + + finally: + del interceptor + + +# --------------------------------------------------- +# Solution Map Page +# --------------------------------------------------- + + +@app.callback( + Output("solution-map-json", "value"), + Input("solution-map-json", "id"), +) +def load_solution_map(_): + doc = mongo.db.solution_map.find_one({}) + if doc: + doc.pop("_id", None) + return json.dumps(doc, indent=2) + else: + return "{}" + +@app.callback( + Output("solution-map-alert", "children"), + Output("solution-map-alert", "is_open"), + Input("solution-map-save", "n_clicks"), + State("solution-map-json", "value"), + prevent_initial_call=True, +) +def save_solution_map(n_clicks, json_text): + try: + data = json.loads(json_text) + mongo.db.solution_map.replace_one({}, data, upsert=True) + return "Solution map saved!", True + except Exception as e: + return f"Error: {e}", True + + +if __name__ == "__main__": + app.run(debug=True) + + +# @app.callback( +# Output("data-output-div", "children"), +# Input("load-data-button", "n_clicks"), +# prevent_initial_call=True, +# ) +# def load_data(n): +# print('load_data') +# # val = "" +# # docs = mongo.db["recipes"].find() +# # for doc in docs: +# # val += str(doc) +# # val += "

" +# # nval = val. +# return (json.dumps(str(com.document))) + + +# class DashLoggerHandler(logging.StreamHandler): +# def __init__(self): +# logging.StreamHandler.__init__(self) +# self.queue = [] + +# def emit(self, record): +# msg = self.format(record) +# self.queue.append(msg) + + +# logger = logging.getLogger() +# logger.setLevel(logging.DEBUG) +# dashLoggerHandler = DashLoggerHandler() +# logger.addHandler(dashLoggerHandler) + +# logger = logging.getLogger(invoker.log.name) +# logger.setLevel(logging.DEBUG) +# from io import StringIO +# log_capture = StringIO() + +# # Create a stream handler and set its stream to the log_capture object +# stream_handler = logging.StreamHandler(log_capture) +# logger.addHandler(stream_handler) +# log_messages = [] + +# import sys +# from io import StringIO +# stringio = StringIO() +# sys.stdout = stringio +# @app.callback(Output('console-output', 'value'), [Input('update-interval', 'n_intervals')]) +# def show_console_output(n): +# # Retrieve console output from StringIO object +# stringio.seek(0) +# console_output = stringio.read() +# return console_output + +# @app.callback(Output("page-content", "children"), [Input("url", "pathname")]) +# def display_page(pathname): +# print("\n\nrefreshing to " + pathname) +# if pathname == "/": +# return home_layout +# elif pathname == "/edit-recipe": +# return edit_recipe_layout +# elif pathname == "/execute-recipe": +# return execute_recipe_layout +# elif pathname == "/data": +# return data_layout +# else: +# return html.Div("404") + + +# data_list4 = com.device_list + +# dl5 = [] +# for list in data_list4: +# dl5.append(list.__dict__) + +# print(dl5[2]['motor'].__dict__) + + +# @app.callback( +# Output("commands-accordion", "children"), +# Input("add-command-button", "n_clicks"), +# State("commands-accordion", "children"), +# prevent_initial_call=True, +# allow_duplicate=True +# ) +# def add_command_accordian(n_clicks, children): +# children=[ +# dbc.AccordionItem( +# "new item", title="new title", item_id="new") +# ] + +# return children + + +# @app.callback( +# Output("commands-accordion", "children"), +# Input("refresh-button2", "n_clicks"), +# State("commands-accordion", "children"), +# # allow_duplicate=True +# ) +# def load_commands_accordion(n_clicks, children): +# children = [] +# # print() +# command_list = com.get_unlooped_command_list().copy() +# # print(command_list) +# # for command in command_list: +# # if isinstance(command, CompositeCommand): +# # for sub_command in command._command_list: +# # sub_command._receiver = sub_command._receiver._name +# # sub_command = sub_command.__dict__ +# # else: +# # # print(command.__dict__) +# # command._receiver = command._receiver._name +# command_params = [] +# for command in command_list: +# # if isinstance(command, CompositeCommand): +# # print(type(command).__name__) +# temp_dict_command_params = {"command": type(command).__name__} +# temp_dict_command_params.update({"params": command.get_init_args()}) +# command_params.append(temp_dict_command_params) +# # print(command._params) +# # else: +# # command_params.append(command._params) +# # print(command_params) +# # print(com.get_command_names()) +# for index, command in enumerate(command_params): +# # print(command) +# children.append( +# dbc.AccordionItem( +# dcc.Markdown( +# children=[ +# "**Command Object:**", +# "```json", +# json.dumps(command, indent=4, cls=util.Encoder), +# # str(command.__dict__), +# "```", +# ], +# ), +# # str(command.__dict__), +# title=command["command"], +# item_id=command["command"] + str(index), +# ) +# ) +# return children + + +# @app.callback( +# Output("table-container3", "children"), +# [Input("refresh-button3", "n_clicks")], +# [State("table-container3", "children")], +# ) +# def update_table3(n_clicks, table): +# table_data3 = data_list3 +# table = dash_table.DataTable( +# data=table_data3, +# columns=[{"name": "Name", "id": "Name"}, {"name": "Value", "id": "Value"}], +# ) +# return table +@app.callback( + Output('optimization-status', 'children'), + Output('optimization-graph', 'figure'), + Output('results-table', 'data'), + Input('start-optimization', 'n_clicks'), + State('input-function', 'value') +) +def start_optimization(n_clicks, function_str): + if n_clicks is None or not function_str: + return "Click the button to start optimization.", {}, [] + + # Simulate Bayesian optimization process + iterations = 10 + results = [] + best_value = float('inf') + best_params = None + + for i in range(iterations): + # Simulate a random parameter and its evaluation + params = np.random.rand(2) + value = eval(function_str.replace('x', str(params[0])).replace('y', str(params[1]))) + results.append({ + "iteration": i + 1, + "best_value": value, + "parameters": f"x: {params[0]:.2f}, y: {params[1]:.2f}" + }) + if value < best_value: + best_value = value + best_params = params + # Create a simple graph to show the optimization process + fig = { + 'data': [ + {'x': [i + 1 for i in range(iterations)], 'y': [result['best_value'] for result in results], 'type': 'line', 'name': 'Best Value'} + ], + 'layout': { + 'title': 'Optimization Progress', + 'xaxis': {'title': 'Iteration'}, + 'yaxis': {'title': 'Best Value'}, + 'showlegend': True + } + } + return f"Optimization completed. Best value: {best_value:.2f} at parameters {best_params}.", fig, results diff --git a/command_invoker.py b/aamp_app/command_invoker.py similarity index 69% rename from command_invoker.py rename to aamp_app/command_invoker.py index 74a7d34..e11ccb7 100644 --- a/command_invoker.py +++ b/aamp_app/command_invoker.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple, Union +from typing import Optional, Tuple, Union, List from datetime import datetime import logging import time @@ -17,25 +17,26 @@ from commands.utility_commands import DelayPauseCommand -format = '[%(asctime)s] [%(levelname)-5s]: %(message)s' +format = "[%(asctime)s] [%(levelname)-5s]: %(message)s" log_formatter = logging.Formatter(format) logging.basicConfig(level=logging.INFO, format=format) class CommandInvoker: """Handles the execution and logging of a command sequence""" + log_directory = "" def __init__( - self, - command_seq: CommandSequence, - log_to_file: bool = True, - log_filename: Optional[str] = None, - alert_slack: bool = False) -> None: - + self, + command_seq: CommandSequence, + log_to_file: bool = True, + log_filename: Optional[str] = None, + alert_slack: bool = False, + ) -> None: if not _has_slack and alert_slack: raise ImportError("slackclient module is required to alert slack.") - + self._command_seq = command_seq self._log_to_file = log_to_file self._alert_slack = alert_slack @@ -44,21 +45,21 @@ def __init__( if self._log_to_file: if self._log_filename is None: - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - self._log_filename = "logs/" +timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self._log_filename = "logs/" + timestamp + + if not self._log_filename[-4:] == ".log": + self._log_filename += ".log" - if not self._log_filename[-4:] == '.log': - self._log_filename += '.log' - # self._log_filename = self.log_directory + self._log_filename - + self._file_handler = logging.FileHandler(self._log_filename) self._file_handler.setFormatter(log_formatter) if not len(self.log.handlers): self.log.addHandler(self._file_handler) if self._alert_slack: - self._slack_token = os.environ.get('SLACK_BOT_TOKEN') + self._slack_token = os.environ.get("SLACK_BOT_TOKEN") self._slack_client = slack.WebClient(token=self._slack_token) def invoke_commands(self) -> bool: @@ -83,7 +84,7 @@ def invoke_commands(self) -> bool: self.log.info("") self.log_command_names(unloop=False) - self.log.info("="*20 + "BEGINNING OF COMMAND SEQUENCE EXECUTION" + "="*20) + self.log.info("=" * 20 + "BEGINNING OF COMMAND SEQUENCE EXECUTION" + "=" * 20) command_generator = self._command_seq.yield_next_command() @@ -100,19 +101,23 @@ def invoke_commands(self) -> bool: break # Process the command's start delay - delay = command._params['delay'] + delay = command._params["delay"] if isinstance(delay, float) or isinstance(delay, int): if delay > 0.0: self.log.info("DELAY -> " + str(delay)) time.sleep(delay) elif delay == "PAUSE" or delay == "P": self.log.info("PAUSE -> Waiting for user to press enter") - print('') - print("Press ENTER to continue, type 'quit' to terminate execution immediately:") + print("") + print( + "Press ENTER to continue, type 'quit' to terminate execution immediately:" + ) userinput = input() if userinput == "quit": - self.log.info("PAUSE -> User terminated execution early by entering 'quit'") - has_error = True # Not really an error but returning False since an early termination (even if intentional) may disrupt a higher workflow + self.log.info( + "PAUSE -> User terminated execution early by entering 'quit'" + ) + has_error = True # Not really an error but returning False since an early termination (even if intentional) may disrupt a higher workflow break else: self.log.info("PAUSE -> User continued command execution") @@ -127,10 +132,22 @@ def invoke_commands(self) -> bool: # Check result if command.result.was_successful: - self.log.info("RESULT -> " + str(command.result.was_successful) + ", " + command.result.message) + self.log.info( + "RESULT -> " + + str(command.result.was_successful) + + ", " + + command.result.message + ) else: - self.log.error("RESULT -> " + str(command.result.was_successful) + ", " + command.result.message) - self.log.error("Received False result. Terminating command execution early!") + self.log.error( + "RESULT -> " + + str(command.result.was_successful) + + ", " + + command.result.message + ) + self.log.error( + "Received False result. Terminating command execution early!" + ) if self._alert_slack: self.log.info("Sending command details to slack.") self.alert_slack_command(command) @@ -139,7 +156,7 @@ def invoke_commands(self) -> bool: break # Go to next command # Finished command list execution - self.log.info("="*20 + "END OF COMMAND SEQUENCE EXECUTION" + "="*20) + self.log.info("=" * 20 + "END OF COMMAND SEQUENCE EXECUTION" + "=" * 20) self.log.info("") if self._log_to_file: print("") @@ -166,13 +183,19 @@ def alert_slack_command(self, command: Command): try: response = self._slack_client.chat_postMessage( channel="printer-bot-test", - text=("Error in the following command execution:\n" - "COMMAND -> " + command.name + "\n" - "RESULT -> " + str(command.result.was_successful) + ", " + command.result.message + "\n" - "See log file \"" + str(self._log_filename) + "\" for more details.") - ) + text=( + "Error in the following command execution:\n" + "COMMAND -> " + command.name + "\n" + "RESULT -> " + + str(command.result.was_successful) + + ", " + + command.result.message + + "\n" + 'See log file "' + str(self._log_filename) + '" for more details.' + ), + ) except SlackApiError as inst: - self.log.error("Could not send message to slack: " + inst.response['error']) + self.log.error("Could not send message to slack: " + inst.response["error"]) def alert_slack_message(self, message: str): """Attempt to send a message to a designated slack channel. @@ -184,21 +207,21 @@ def alert_slack_message(self, message: str): """ try: response = self._slack_client.chat_postMessage( - channel="printer-bot-test", - text=message) + channel="printer-bot-test", text=message + ) except SlackApiError as inst: - self.log.error("Could not send message to slack: " + inst.response['error']) - + self.log.error("Could not send message to slack: " + inst.response["error"]) + def upload_log_slack(self): """Attempt to upload the invoker's log file to a designated slack channel.""" try: - response = self._slack_client.files_upload( + response = self._slack_client.files_upload( file=self._log_filename, - initial_comment='Uploading log file with error: ' + self._log_filename, - channels='printer-bot-test' + initial_comment="Uploading log file with error: " + self._log_filename, + channels="printer-bot-test", ) except SlackApiError as inst: - self.log.error("Could not upload log file: " + inst.response['error']) + self.log.error("Could not upload log file: " + inst.response["error"]) def log_command_names(self, unloop: bool = False): """Log the names of each command in the sequence @@ -208,12 +231,14 @@ def log_command_names(self, unloop: bool = False): unloop : bool, optional Whether to unloop the commands sequence or not, by default False """ - self.log.info("="*20 + "LIST OF COMMAND NAMES" + "="*20) + self.log.info("=" * 20 + "LIST OF COMMAND NAMES" + "=" * 20) for name in self._command_seq.get_command_names(unloop): self.log.info(name) self.log.info("") - self.log.info("(Number of iterations: " + str(self._command_seq.num_iterations) + ")") - self.log.info("="*20 + "END OF COMMAND NAMES" + "="*20) + self.log.info( + "(Number of iterations: " + str(self._command_seq.num_iterations) + ")" + ) + self.log.info("=" * 20 + "END OF COMMAND NAMES" + "=" * 20) def log_command_names_descriptions(self, unloop: bool = False): """Log the names and descriptions of each command in the sequence @@ -223,24 +248,49 @@ def log_command_names_descriptions(self, unloop: bool = False): unloop : bool, optional Whether to unloop the commands sequence or not, by default False """ - self.log.info("="*20 + "LIST OF COMMAND NAMES/DESCRIPTIONS" + "="*20) + self.log.info("=" * 20 + "LIST OF COMMAND NAMES/DESCRIPTIONS" + "=" * 20) for name_desc in self._command_seq.get_command_names_descriptions(unloop): self.log.info(name_desc[0]) self.log.info(name_desc[1]) self.log.info("") - self.log.info("(Number of iterations: " + str(self._command_seq.num_iterations) + ")") - self.log.info("="*20 + "END OF COMMAND NAMES/DESCRIPTIONS" + "="*20) - - - + self.log.info( + "(Number of iterations: " + str(self._command_seq.num_iterations) + ")" + ) + self.log.info("=" * 20 + "END OF COMMAND NAMES/DESCRIPTIONS" + "=" * 20) + def get_log_messages(self): + """Get all log messages as a list. + Returns + ------- + list + List of log messages + """ + log_messages = [] + for handler in self.log.handlers: + if isinstance(handler, logging.FileHandler): + handler.flush() # Ensure all messages are written to the log file + with open(handler.baseFilename, "r") as log_file: + log_messages.extend(log_file.readlines()) + return log_messages + + def clear_log_file(self): + """Clear the log file by truncating its content.""" + if self._log_to_file: + if os.path.exists(self._log_filename): + with open(self._log_filename, "w") as log_file: + log_file.truncate(0) + self.log.info("Log file cleared.") + else: + self.log.warning("Log file does not exist.") + else: + self.log.warning("Log file is not being used.") # experiment name, id # is logging, log file, delay between commands, or delay list -# delay list can have a value like -1 or a str to indicate that -# we wait for user input before proceeding or type quit to terminate early +# delay list can have a value like -1 or a str to indicate that +# we wait for user input before proceeding or type quit to terminate early # this could also be implemented as a Command that waits for input and returns # consider also ctrl c exception termination, safely terminate and log # invoker observer/inspecter for more complex bookkeeping? @@ -249,7 +299,7 @@ def log_command_names_descriptions(self, unloop: bool = False): # for parallel processes with threading consider branches and loop done with the follow command nodes: jump, conditional jump, label, end (ala assembly, exapunks) # example of conditional jump, prompt user for y/n to jump to a previous label to loop, or jump to a future label to skip some commands -# on loops we can either do the same thing or change the arguments to command (e.g. to explore how printing speed changes each loop). +# on loops we can either do the same thing or change the arguments to command (e.g. to explore how printing speed changes each loop). # This can be done with a preset that is passed (e.g. a list of parameters corresponding to each loop) # or this can be done live, e.g. based on equation/condition or based on user input or based on input from a machine learning optimizer # how to check against infinite loop @@ -259,14 +309,14 @@ def log_command_names_descriptions(self, unloop: bool = False): # or an invoker manager or the client sees a new branch node and creates a new invoker and passes it the branch list # but what if a branch within a branch? need some sort of composite/recursive approach -# how to deal with commands with arguments that change every loop? +# how to deal with commands with arguments that change every loop? # Should we have command objects that persist throughout loops? if so, should they keep track of their own execution count? (this probably makes writing command classes more complicated) # should we generate new commands instead, cloning the loop section on each iteration with new commands? # whose job is it to change the command or generate new commands, the client or invoker # and how do we deal with commands with arguments that come from user input on each loop? # how can we make the user input argument and preset list argument methods as similar as possible? -# are commands changable after instantiation or should we just destroy and remake them? +# are commands changable after instantiation or should we just destroy and remake them? # if changable then we need update functions/getters to update name/description when arguments change which makes them more complicated -# if we should destroy and remake, we might as well make them when we are ready (after taking user input). +# if we should destroy and remake, we might as well make them when we are ready (after taking user input). # But this means the client doesnt have a singular invoker_command method call, and we should always push new commands to the stack/queue and have invoker invoke them # this means the invoker needs to wait when reaching the end of its current command list, meaning the client needs a definite way to terminate invoking on the invoker diff --git a/command_sequence.py b/aamp_app/command_sequence.py similarity index 73% rename from command_sequence.py rename to aamp_app/command_sequence.py index 6218bec..aee2a25 100644 --- a/command_sequence.py +++ b/aamp_app/command_sequence.py @@ -8,27 +8,39 @@ from commands.command import Command from devices.device import Device from commands.utility_commands import LoopStartCommand, LoopEndCommand +import inspect - +from bson.objectid import ObjectId # Representer.add_representer(ABCMeta, Representer.represent_name) # Should move loop interpretation to invoker? # Make functional with yaml safe_load # reconsider add_commands functionality for list of commands + class CommandSequence: """Maintains a command list with optional iterations and looping. Supports saving/loading.""" - recipe_directory = '' + + recipe_directory = "" def __init__(self): self.device_list = [] + self.db_device_list = [] self.command_list = [] - self.num_iterations = 'ALL' + self.db_command_list = [] + self.execution_options = {} + self.num_iterations = "ALL" # self.processed_devices = [] # self.processed_commands = [] # self.processed_delays = [] self.device_by_name = {} + @staticmethod + def _get_util_module(): + import util + + return util + def add_device(self, receiver: Device) -> bool: """Add a device to the device list then update the device dict. @@ -63,7 +75,7 @@ def remove_device(self, receiver_name: str): if device.name == receiver_name: del self.device_list[ndx] self.update_device_by_name() - break # Each device should have a unique name, always + break # Each device should have a unique name, always def remove_device_by_index(self, index: Optional[int] = None): """Remove a device from the device list by index then update the device dict. @@ -94,7 +106,7 @@ def add_command(self, command: Union[Command, List[Command]], index: Optional[in self.command_list.append(command) else: self.command_list.insert(index, command) - + def remove_command(self, index: Optional[int] = None): """Remove a command from the command list. @@ -117,7 +129,12 @@ def move_command_by_index(self, old_index: int, new_index: int): new_index : int The index to move the command(s) to """ - if old_index >= 0 and old_index <= len(self.command_list) - 1 and new_index >= 0 and new_index <= len(self.command_list) - 1: + if ( + old_index >= 0 + and old_index <= len(self.command_list) - 1 + and new_index >= 0 + and new_index <= len(self.command_list) - 1 + ): if old_index != new_index: self.command_list.insert(new_index, self.command_list.pop(old_index)) else: @@ -143,7 +160,12 @@ def add_loop_end(self, index: Optional[int] = None): """ self.add_command(LoopEndCommand(), index) - def add_command_iteration(self, command: Union[Command, List[Command]], index: Optional[int] = None, iteration: Optional[int] = None): + def add_command_iteration( + self, + command: Union[Command, List[Command]], + index: Optional[int] = None, + iteration: Optional[int] = None, + ): """Add command(s) as iterations to a pre-existing command in the command list. Parameters @@ -167,7 +189,9 @@ def add_command_iteration(self, command: Union[Command, List[Command]], index: O for ndx in range(len(command)): self.command_list[index].insert(iteration, command.pop(-1)) - def remove_command_iteration(self, index: Optional[int] = None, iteration: Optional[int] = None): + def remove_command_iteration( + self, index: Optional[int] = None, iteration: Optional[int] = None + ): """Remove a command iteration from a command's iteration list. Parameters @@ -195,7 +219,12 @@ def move_command_iteration_by_index(self, index: int, old_iter: int, new_iter: i new_iter : int The new iteration index to move the iteration to """ - if old_iter >= 0 and old_iter <= len(self.command_list[index]) - 1 and new_iter >= 0 and new_iter <= len(self.command_list[index]) - 1: + if ( + old_iter >= 0 + and old_iter <= len(self.command_list[index]) - 1 + and new_iter >= 0 + and new_iter <= len(self.command_list[index]) - 1 + ): if old_iter != new_iter: self.command_list[index].insert(new_iter, self.command_list[index].pop(old_iter)) else: @@ -234,7 +263,7 @@ def verify_command_list(self) -> Tuple[bool, str]: # Important Note!: So far, this doesn't consider composite commands since they can hide away other commands which may break the entire recipe # One way is to unwrap the composite command, but keep in mind since composite commands behave like commands, this means a composite command # can have composite commands. Therefore, composite commands can be arbitrarily deep and can potentially be recursive, causing an infinite loop - + # iteration lists must have commands of same class? Not necessarily. Currently not enforced loop_start_location = [] loop_end_location = [] @@ -249,10 +278,13 @@ def verify_command_list(self) -> Tuple[bool, str]: return False, "There can only be one loop start" if len(loop_end_location) > 1: return False, "There can only be one loop end" - + if len(loop_start_location) != len(loop_end_location): - return False, "There must either be no loop start/end or exactly one of each" - + return ( + False, + "There must either be no loop start/end or exactly one of each", + ) + if len(loop_start_location) > 0: if len(self.command_list[loop_start_location[0][0]]) > 1: return False, "Loop start command can not have additional iterations" @@ -298,7 +330,7 @@ def verify(self) -> Tuple[bool, str]: is_valid, message = self.verify_num_iterations() if not is_valid: return (is_valid, message) - return True, "All checks passed" + return True, "All checks passed" def update_device_by_name(self): """Update the device dict that stores each device/receiver by it's name.""" @@ -315,15 +347,17 @@ def get_unlooped_command_list(self) -> List[Command]: List[Command] The unlooped command list """ - unlooped_list= [] + unlooped_list = [] command_generator = self.yield_next_command() for command in command_generator: if not isinstance(command, Command): break unlooped_list.append(command) - return unlooped_list + return unlooped_list - def yield_next_command(self) -> Generator[Union[Command, Tuple[bool, str]], None, None]: + def yield_next_command( + self, + ) -> Generator[Union[Command, Tuple[bool, str]], None, None]: """A generator that yields each command sequentially, accounting for loops. Yields @@ -347,7 +381,7 @@ def yield_next_command(self) -> Generator[Union[Command, Tuple[bool, str]], None index = 0 iteration = 0 loop_start_index = None - if self.num_iterations == 'ALL': + if self.num_iterations == "ALL": max_iterations = self.get_max_iteration() else: max_iterations = self.num_iterations @@ -359,7 +393,7 @@ def yield_next_command(self) -> Generator[Union[Command, Tuple[bool, str]], None command = self.command_list[index][-1] else: command = self.command_list[index][iteration] - + if isinstance(command, LoopStartCommand): loop_start_index = index index += 1 @@ -401,7 +435,7 @@ def save_to_yaml(self, filename: str): """ filename = self.recipe_directory + filename data_list = [self.device_list, self.command_list, self.num_iterations] - with open(filename, 'w') as file: + with open(filename, "w") as file: yaml.dump(data_list, file, default_flow_style=False, sort_keys=False) def load_from_yaml(self, filename: str): @@ -433,7 +467,7 @@ def get_command_names(self, unloop: bool = False) -> List[str]: Returns ------- List[str] - The list of command names + The list of command names """ name_list = [] if unloop: @@ -459,27 +493,46 @@ def get_command_names_descriptions(self, unloop: bool = False) -> List[List[str] Returns ------- List[List[str]] - The list of [command names, command descriptions] + The list of [command names, command descriptions] """ name_desc_list = [] if unloop: for command in self.yield_next_command(): - name_desc_list.append(["Name: " + command.name, "Description: " + command.description]) + name_desc_list.append( + ["Name: " + command.name, "Description: " + command.description] + ) else: for command in self.command_list: for iter_ndx, iter_command in enumerate(command): if iter_ndx == 0: - name_desc_list.append(["Name: " + iter_command.name, "Description: " + iter_command.description]) + name_desc_list.append( + [ + "Name: " + iter_command.name, + "Description: " + iter_command.description, + ] + ) else: - name_desc_list.append([" IterIndex" + str(iter_ndx) + ": " + "Name: " + iter_command.name, - " IterIndex" + str(iter_ndx) + ": " + "Description: " + iter_command.description]) + name_desc_list.append( + [ + " IterIndex" + + str(iter_ndx) + + ": " + + "Name: " + + iter_command.name, + " IterIndex" + + str(iter_ndx) + + ": " + + "Description: " + + iter_command.description, + ] + ) return name_desc_list def print_command_names(self): """Print the command names with any loop.""" for name in self.get_command_names(unloop=False): print(name) - + def print_unlooped_command_names(self): """Print the command names unlooped.""" for name in self.get_command_names(unloop=True): @@ -496,7 +549,7 @@ def print_unlooped_command_names_descriptions(self): for name_desc in self.get_command_names_descriptions(unloop=True): print(name_desc[0]) print(name_desc[1]) - + def get_device_names_classes(self) -> List[List[str]]: name_cls_list = [] for device in self.device_list: @@ -531,3 +584,111 @@ def remove_all_loop_commands(self): # if inner broke, then break outer loop to start over # https://stackoverflow.com/questions/189645/how-to-break-out-of-multiple-loops break + + def get_clean_device_list(self): + """Returns a list for use with the dashboard.""" + util = self._get_util_module() + device_list = self.get_device_names_classes().copy() + device_list_ret = [] + for index, device in enumerate(device_list): + device_list_temp = [] + device_list_temp.append(device_list[index][1]) + device_list_temp.append(util.device_to_dict(self.device_list[index])) + # device_list[index].append(util.device_to_dict(self.device_list[index])) + device_list_ret.append(device_list_temp) + return device_list_ret + + def get_recipe(self): + """Returns a list for use with the dashboard.""" + util = self._get_util_module() + devices = [] + commands = [] + execution_options = self.execution_options + for i, device in enumerate(self.device_list): + devices.append({str(device.__class__.__name__): {"args": device.get_init_args()}}) + for i, command in enumerate(self.command_list): + arg_params = {} + util_arg_params = util.devices_ref_redundancy[ + str(command[0]._receiver.__class__.__name__) + ]["commands"][str(command[0].__class__.__name__)]["args"] + for arg in util_arg_params: + if arg == "receiver": + arg_params["receiver_name"] = command[0]._params["receiver_name"] + else: + arg_params[arg] = command[0]._params[arg] + commands.append( + { + str(command[0].__class__.__name__): { + "args": arg_params, + "delay": command[0]._params["delay"], + "device": str(command[0]._receiver.__class__.__name__), + } + } + ) + return [devices, commands, execution_options] + + def load_from_dict(self, recipe_dict): + """Loads a recipe from a dictionary.""" + util = self._get_util_module() + self.device_list = [] + self.command_list = [] + for device in recipe_dict["devices"]: + for device_type, device_params in device.items(): + self.add_device( + util.devices_ref_redundancy[device_type]["obj"](**device_params["args"]) + ) + self.update_device_by_name() + for command in recipe_dict["commands"]: + for command_type, command_params in command.items(): + rec_name = command_params["args"]["receiver_name"] + del command_params["args"]["receiver_name"] + self.add_command( + util.devices_ref_redundancy[command_params["device"]]["commands"][command_type][ + "obj" + ](receiver=self.device_by_name[rec_name], **command_params["args"]) + ) + self.execution_options = recipe_dict["execution_options"] + + def add_device_from_dict(self, device_type, device_dict): + util = self._get_util_module() + if not hasattr(self, 'document'): + self.document = { + '_id': ObjectId(), + 'recipe_dict': { + 'devices': [], + 'commands': [], + 'execution_options': [] + } + } + self.add_device(util.devices_ref_redundancy[device_type]["obj"](**device_dict)) + self.update_device_by_name() + + def add_command_from_dict(self, device_type, command_type, command_dict): + util = self._get_util_module() + if device_type == "UtilityCommands": + self.add_command( + util.devices_ref_redundancy[device_type]["commands"][command_type]["obj"]( + **command_dict + ) + ) + return + command_dict_receiver = command_dict["receiver"] + command_dict_delay = command_dict["delay"] + del command_dict["receiver"] + del command_dict["delay"] + self.add_command( + util.devices_ref_redundancy[device_type]["commands"][command_type]["obj"]( + receiver=self.device_by_name[command_dict_receiver], + delay=command_dict_delay, + **command_dict + ) + ) + + def clear_recipe(self): + """Clears the recipe.""" + self.device_list = [] + self.db_device_list = [] + self.command_list = [] + self.db_command_list = [] + self.num_iterations = "ALL" + self.device_by_name = {} diff --git a/devices/__init__.py b/aamp_app/commands/__init__.py similarity index 100% rename from devices/__init__.py rename to aamp_app/commands/__init__.py diff --git a/aamp_app/commands/apis_commands.py b/aamp_app/commands/apis_commands.py new file mode 100644 index 0000000..d583e90 --- /dev/null +++ b/aamp_app/commands/apis_commands.py @@ -0,0 +1,311 @@ +from .command import Command, CommandResult +from devices.apis import APIS + + +class APISParentCommand(Command): + """Parent class for all APIS commands.""" + + receiver_cls = APIS + + def __init__(self, receiver: APIS, **kwargs): + super().__init__(receiver, **kwargs) + + +class APISConnect(APISParentCommand): + """Open the serial port and run the APIS READY/RESET handshake.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.connect()) + + +class APISInitialize(APISParentCommand): + """Initialize APIS by resetting it into the ARMED state.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class APISDeinitialize(APISParentCommand): + """Deinitialize APIS by sending ESTOP and closing the serial port.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize()) + + +class APISReset(APISParentCommand): + """Send RESET to arm APIS.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.reset()) + + +class APISEmergencyStop(APISParentCommand): + """Send ESTOP to latch and detach the APIS servos.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.emergency_stop()) + + +class APISHome(APISParentCommand): + """Move both APIS stages to 0 degrees.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.home()) + + +class APISRotatePolarizer(APISParentCommand): + """Rotate the APIS polarizer stage to a target stage angle in degrees.""" + + def __init__(self, receiver: APIS, angle_deg: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["angle_deg"] = angle_deg + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.rotate_polarizer(self._params["angle_deg"])) + + +class APISRotateSample(APISParentCommand): + """Rotate the APIS sample stage to a target stage angle in degrees.""" + + def __init__(self, receiver: APIS, angle_deg: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["angle_deg"] = angle_deg + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.rotate_sample(self._params["angle_deg"])) + + +class APISSetPolarizerBaseline(APISParentCommand): + """Set APIS XPL angle and derive the reachable orthogonal PPL angle.""" + + def __init__( + self, + receiver: APIS, + xpl_angle_deg: float, + persist: bool = False, + source: str = "manual", + **kwargs + ): + super().__init__(receiver, **kwargs) + self._params["xpl_angle_deg"] = xpl_angle_deg + self._params["persist"] = persist + self._params["source"] = source + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.set_polarizer_baseline( + self._params["xpl_angle_deg"], + persist=self._params["persist"], + source=self._params["source"], + ) + ) + + +class APISLoadPolarizerBaseline(APISParentCommand): + """Load the persisted APIS XPL/PPL polarizer baseline.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.load_polarizer_baseline()) + + +class APISSavePolarizerBaseline(APISParentCommand): + """Persist the current APIS XPL/PPL polarizer baseline.""" + + def __init__(self, receiver: APIS, source: str = "manual", **kwargs): + super().__init__(receiver, **kwargs) + self._params["source"] = source + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.save_polarizer_baseline(source=self._params["source"]) + ) + + +class APISRotateXPL(APISParentCommand): + """Rotate the APIS polarizer to the configured XPL baseline angle.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.rotate_xpl()) + + +class APISRotatePPL(APISParentCommand): + """Rotate the APIS polarizer to the configured PPL baseline angle.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.rotate_ppl()) + + +class APISGetState(APISParentCommand): + """Return the cached APIS state.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_state()) + + +class APISCaptureRaw16(APISParentCommand): + """Capture a RAW16 TIFF from the APIS Ximea camera.""" + + def __init__( + self, + receiver: APIS, + filename: str = None, + directory: str = None, + exposure_time: int = None, + gain: float = 0.0, + **kwargs + ): + super().__init__(receiver, **kwargs) + self._params["filename"] = filename + self._params["directory"] = directory + self._params["exposure_time"] = exposure_time + self._params["gain"] = gain + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.capture_raw16( + filename=self._params["filename"], + directory=self._params["directory"], + exposure_time=self._params["exposure_time"], + gain=self._params["gain"], + ) + ) + + +class APISCaptureRgb(APISParentCommand): + """Capture an APIS-style RGB preview TIFF from the APIS Ximea camera.""" + + def __init__( + self, + receiver: APIS, + filename: str = None, + directory: str = None, + exposure_time: int = None, + gain: float = 0.0, + **kwargs + ): + super().__init__(receiver, **kwargs) + self._params["filename"] = filename + self._params["directory"] = directory + self._params["exposure_time"] = exposure_time + self._params["gain"] = gain + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.capture_rgb( + filename=self._params["filename"], + directory=self._params["directory"], + exposure_time=self._params["exposure_time"], + gain=self._params["gain"], + ) + ) + + +class APISConvertRaw16ToRgb(APISParentCommand): + """Convert a saved RAW16 TIFF into an APIS-style RGB preview TIFF.""" + + def __init__( + self, + receiver: APIS, + raw16_path: str, + rgb_path: str = None, + **kwargs + ): + super().__init__(receiver, **kwargs) + self._params["raw16_path"] = raw16_path + self._params["rgb_path"] = rgb_path + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.convert_raw16_to_rgb( + raw16_path=self._params["raw16_path"], + rgb_path=self._params["rgb_path"], + ) + ) + + +class APISRunImagingSequence(APISParentCommand): + """Run the current APIS XPL/PPL RAW16 imaging sequence with metadata logging.""" + + def __init__( + self, + receiver: APIS, + sample_id: str, + directory: str = None, + sample_angles=None, + xpl_exposure_time: int = APIS.XIMEA_DEFAULT_XPL_EXPOSURE_US, + ppl_exposure_time: int = APIS.XIMEA_DEFAULT_PPL_EXPOSURE_US, + do_xpl: bool = True, + do_ppl: bool = True, + xpl_polarizer_angle: float = None, + ppl_polarizer_angle: float = None, + gain: float = 0.0, + **kwargs + ): + super().__init__(receiver, **kwargs) + self._params["sample_id"] = sample_id + self._params["directory"] = directory + self._params["sample_angles"] = sample_angles + self._params["xpl_exposure_time"] = xpl_exposure_time + self._params["ppl_exposure_time"] = ppl_exposure_time + self._params["do_xpl"] = do_xpl + self._params["do_ppl"] = do_ppl + self._params["xpl_polarizer_angle"] = xpl_polarizer_angle + self._params["ppl_polarizer_angle"] = ppl_polarizer_angle + self._params["gain"] = gain + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.run_imaging_sequence( + sample_id=self._params["sample_id"], + directory=self._params["directory"], + sample_angles=self._params["sample_angles"], + xpl_exposure_time=self._params["xpl_exposure_time"], + ppl_exposure_time=self._params["ppl_exposure_time"], + do_xpl=self._params["do_xpl"], + do_ppl=self._params["do_ppl"], + xpl_polarizer_angle=self._params["xpl_polarizer_angle"], + ppl_polarizer_angle=self._params["ppl_polarizer_angle"], + gain=self._params["gain"], + ) + ) + + +class APISRunPolarizerCalibration(APISParentCommand): + """Run a RAW16 polarizer scan and update APIS XPL/PPL baseline angles.""" + + def __init__( + self, + receiver: APIS, + sample_id: str = "polarizer_calibration", + directory: str = None, + exposure_time: int = APIS.XIMEA_DEFAULT_POLARIZER_CALIBRATION_EXPOSURE_US, + polarizer_angles=None, + sample_angle: float = 0.0, + fine_radius_deg: int = APIS.POLARIZER_CALIBRATION_FINE_RADIUS_DEG, + fine_step_deg: int = APIS.POLARIZER_CALIBRATION_FINE_STEP_DEG, + gain: float = 0.0, + **kwargs + ): + super().__init__(receiver, **kwargs) + self._params["sample_id"] = sample_id + self._params["directory"] = directory + self._params["exposure_time"] = exposure_time + self._params["polarizer_angles"] = polarizer_angles + self._params["sample_angle"] = sample_angle + self._params["fine_radius_deg"] = fine_radius_deg + self._params["fine_step_deg"] = fine_step_deg + self._params["gain"] = gain + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.run_polarizer_calibration( + sample_id=self._params["sample_id"], + directory=self._params["directory"], + exposure_time=self._params["exposure_time"], + polarizer_angles=self._params["polarizer_angles"], + sample_angle=self._params["sample_angle"], + fine_radius_deg=self._params["fine_radius_deg"], + fine_step_deg=self._params["fine_step_deg"], + gain=self._params["gain"], + ) + ) diff --git a/commands/command.py b/aamp_app/commands/command.py similarity index 88% rename from commands/command.py rename to aamp_app/commands/command.py index 9a917cd..3e67e38 100644 --- a/commands/command.py +++ b/aamp_app/commands/command.py @@ -5,28 +5,28 @@ from devices.device import Device - # TODO # logging from within the composite command execution? # The adding of args into params dict can be done with locals() + class Command(ABC): """The Command abstract base class which acts as an interface for other objects that use commands.""" - receiver_cls = None # or Device? + receiver_cls = None # or Device? @abstractmethod def __init__(self, receiver: Device, delay: float = 0.0): # Child classes will still have 'receiver' in their signature (separate from **kwargs) because I want the IDE to hint at the specific receiver class - # delay will be passed up through **kwargs as it is an secondary parameter for all commands + # delay will be passed up through **kwargs as it is an secondary parameter for all commands self._receiver = receiver self._params = {} - self._params['receiver_name'] = receiver.name - self._params['delay'] = delay + self._params["receiver_name"] = receiver.name + self._params["delay"] = delay # self._was_successful = None - # self._result_message = None + # self._result_message = None - # this could also just be None, instead of a CommandResult with None attributes + # this could also just be None, instead of a CommandResult with None attributes # is there ever a time where the result would be accessed before the command is executed? # it might be better to just have it as a CommandResult with None's since whatever accessing it # is expecting _result to be a CommandResult object anyways @@ -51,14 +51,14 @@ def name(self) -> str: """ self._name = type(self).__name__ for key, value in self._params.items(): - if not key == 'delay': + if not key == "delay": self._name += " " + key + "=" + str(value) # I want delay to always be last when displayed and only if its not zero - if isinstance(self._params['delay'], str): - self._name += " " + 'delay=' + str(self._params['delay']) + if isinstance(self._params["delay"], str): + self._name += " " + "delay=" + str(self._params["delay"]) else: - if self._params['delay'] > 0.0: - self._name += " " + 'delay=' + str(self._params['delay']) + if self._params["delay"] > 0.0: + self._name += " " + "delay=" + str(self._params["delay"]) return self._name @property @@ -101,22 +101,35 @@ def result(self): # """ # return self._result_message -# store info like name and description in here too? or leave separate? Leave separate, semantically i think it makes sense + def get_init_args(self) -> dict: + """Get the command's initialization arguments. + + Returns + ------- + dict + Returns a dictionary of the command's initialization arguments. + """ + return self._params + + +# store info like name and description in here too? or leave separate? Leave separate, semantically i think it makes sense # to retrive non-result info from the command instead of the commandresult object, potentially avoid conflicting info too # Mainly adding to future proof in case more info needs to be retrieved after execution -class CommandResult(): +class CommandResult: """Object which stores whether a command's execution was successful and other relevant information""" - def __init__(self, was_successful: Optional[bool] = None, message: Optional[str] = None): + def __init__( + self, was_successful: Optional[bool] = None, message: Optional[str] = None + ): # I considered whether to have a reset/update function instead and have init call it, this function could be used outside to reset/update the result attributes # The alternative is to just use the contructor to create a new reset object. I decided just using the constructor is better. - # Although it creates a new object in memory, it ensures that whenever a command executes if it uses the constructor then the results - # will be fully reset and not hold any old info if it is executed multiples times. In other cases, it may hold old info like for + # Although it creates a new object in memory, it ensures that whenever a command executes if it uses the constructor then the results + # will be fully reset and not hold any old info if it is executed multiples times. In other cases, it may hold old info like for # 1) explicitly tuple unpacking in the result attributes (old version) or 2) using an update function (which may not update all attrs) - + self._was_successful = was_successful self._message = message - + @property def was_successful(self) -> Optional[bool]: """Whether the command's execution was successful or not. @@ -137,20 +150,20 @@ def message(self) -> Optional[str]: Optional[str] Returns a string describing the result of the command's execution with details if it failed. """ - return self._message - + return self._message + # A composite command contains a command list but it behaves like a regular command to any other object using it. # This means a composite command can contain a composite command and it can have many levels arbitrarily deep # This also means it can potentially be recursive causing an infinite loop # This also means that the contents of a composite command (and all potential levels of composite commands it has) should ideally be checked # To make sure it does not cause problems for the overall recipe -# For now, CompositeCommands do not implement checks against adding other composite commands to itself as it can be useful to have at least 1-2 levels +# For now, CompositeCommands do not implement checks against adding other composite commands to itself as it can be useful to have at least 1-2 levels class CompositeCommand(Command): """A composite command which contains multiple commands but can act like a single command that executes all contained commands sequentially.""" # receiver_cls = None - + def __init__(self, delay: float = 0.0): # super().__init__(**kwargs) # self._name = "CompositeCommand" @@ -159,9 +172,9 @@ def __init__(self, delay: float = 0.0): # self._receiver = None self._params = {} # self._params['receiver_name'] = 'None' - self._params['delay'] = delay + self._params["delay"] = delay # self._was_successful = None - # self._result_message = None + # self._result_message = None self._result = CommandResult() @property @@ -201,7 +214,7 @@ def add_command(self, command: Command, index: Optional[int] = None): """ if index is None: self._command_list.append(command) - else: + else: self._command_list.insert(index, command) def remove_command(self, index: Optional[int] = None): @@ -218,10 +231,9 @@ def remove_command(self, index: Optional[int] = None): def execute(self) -> None: """Executes each command in the command list sequentially and returns early if a command's execution was not successful.""" - # LOGGING? + # LOGGING? for command in self._command_list: - - delay = command._params['delay'] + delay = command._params["delay"] if isinstance(delay, float) or isinstance(delay, int): if delay > 0.0: time.sleep(delay) @@ -233,19 +245,19 @@ def execute(self) -> None: # self._result._message = command.result_message self._result = command.result if not command.was_successful: - return + return - # store the success of the last command + # store the success of the last command # or write a new message specific to the composite command # self._result_message = "Successfully executed composite command " + type(self).__name__ -# for the receiver and concretecommand classes, I figured there were two ways of writing the code. Keep the receiver methods low level and employ logic -# in the commands, or put all low level methods, logic, and subsequent high level methods in the receiver and have the command simply call the +# for the receiver and concretecommand classes, I figured there were two ways of writing the code. Keep the receiver methods low level and employ logic +# in the commands, or put all low level methods, logic, and subsequent high level methods in the receiver and have the command simply call the # high level methods with minimal logic if at all. I decided the latter mainly because some devices/receivers will have many different commands # and I want to keep all the command classes streamlined and minimal. I also felt that having the logic and higher level methods separate from the lower level methods # would make it more difficult to write and maintain due to switching back and forth between the modules. -# If there is a need to separate low level and high level methods, then in my opinion it should take place between the receiver class and another class it extends/inherits +# If there is a need to separate low level and high level methods, then in my opinion it should take place between the receiver class and another class it extends/inherits # from or the actual device firmware. # nearly all attributes in Command and its child classes are protected because the parameters are not meant to be changed after construction. @@ -254,4 +266,4 @@ def execute(self) -> None: # This can be fixed by implementing the arg dictionary mentioned below and using the name and description getters to update the value by iterating the dict # This parallels the way CompositeCommands have names that update to reflect its command list when the name getter is used -# consider a command rank system to be able to check that some commands must come before/after others, e.g. initialize? \ No newline at end of file +# consider a command rank system to be able to check that some commands must come before/after others, e.g. initialize? diff --git a/commands/dummy_composite_commands.py b/aamp_app/commands/dummy_composite_commands.py similarity index 100% rename from commands/dummy_composite_commands.py rename to aamp_app/commands/dummy_composite_commands.py diff --git a/commands/dummy_heater_commands.py b/aamp_app/commands/dummy_heater_commands.py similarity index 100% rename from commands/dummy_heater_commands.py rename to aamp_app/commands/dummy_heater_commands.py diff --git a/commands/dummy_meter_commands.py b/aamp_app/commands/dummy_meter_commands.py similarity index 100% rename from commands/dummy_meter_commands.py rename to aamp_app/commands/dummy_meter_commands.py diff --git a/commands/dummy_motor_commands.py b/aamp_app/commands/dummy_motor_commands.py similarity index 97% rename from commands/dummy_motor_commands.py rename to aamp_app/commands/dummy_motor_commands.py index ff34a9c..53c3b8e 100644 --- a/commands/dummy_motor_commands.py +++ b/aamp_app/commands/dummy_motor_commands.py @@ -74,6 +74,8 @@ def __init__(self, receiver: DummyMotor, speed: float, position: float, **kwargs self.add_command(DummyMotorSetSpeed(receiver, speed)) self.add_command(DummyMotorMoveAbsolute(receiver, position)) self.add_command(DummyMotorSetSpeed(receiver, original_speed)) + self._params['speed'] = speed + self._params["position"] = position class DummyMotorMultiMoveAbsolute(CompositeCommand): """Move a list of motors to a list of position at a particular speed""" diff --git a/aamp_app/commands/festo_solenoid_valve_commands.py b/aamp_app/commands/festo_solenoid_valve_commands.py new file mode 100644 index 0000000..aeed3c7 --- /dev/null +++ b/aamp_app/commands/festo_solenoid_valve_commands.py @@ -0,0 +1,80 @@ +from typing import List +from devices.festo_solenoid_valve import FestoSolenoidValve +from .command import Command, CommandResult + + +class FestoParentCommand(Command): + """Parent class for all Festo Solenoid Valve commands.""" + + receiver_cls = FestoSolenoidValve + + def __init__(self, receiver: FestoSolenoidValve, **kwargs): + super().__init__(receiver, **kwargs) + + +class FestoConnect(Command): + """Open the serial port to the festo valve""" + + receiver_cls = FestoSolenoidValve + + def __init__(self, receiver: FestoSolenoidValve, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_serial()) + + +class FestoInitialize(FestoParentCommand): + """Initialize the solenoid valve""" + + def __init__(self, receiver: FestoSolenoidValve, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class FestoDeinitialize(FestoParentCommand): + """Deinitialize the solenoid valve""" + + def __init__(self, receiver: FestoSolenoidValve, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize()) + + +class FestoValveOpen(FestoParentCommand): + """Open the solenoid valve and keep open""" + + def __init__(self, receiver: FestoSolenoidValve, valve_num: int, **kwargs): + super().__init__(receiver, **kwargs) + self._params["valve_num"] = valve_num + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.valve_open(self._params["valve_num"]) + ) + + +class FestoValveClosed(FestoParentCommand): + """Close the solenoid valve and keep closed""" + + def __init__(self, receiver: FestoSolenoidValve, valve_num: int, **kwargs): + super().__init__(receiver, **kwargs) + self._params["valve_num"] = valve_num + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.valve_closed(self._params["valve_num"]) + ) + + +class FestoCloseAll(FestoParentCommand): + """Close all solenoid valves""" + + def __init__(self, receiver: FestoSolenoidValve, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.close_all()) diff --git a/commands/generic_commands.py b/aamp_app/commands/generic_commands.py similarity index 100% rename from commands/generic_commands.py rename to aamp_app/commands/generic_commands.py diff --git a/commands/heating_stage_commands.py b/aamp_app/commands/heating_stage_commands.py similarity index 70% rename from commands/heating_stage_commands.py rename to aamp_app/commands/heating_stage_commands.py index d0b84cc..d9db496 100644 --- a/commands/heating_stage_commands.py +++ b/aamp_app/commands/heating_stage_commands.py @@ -58,3 +58,35 @@ def __init__(self, receiver: HeatingStage, temperature: float, **kwargs): def execute(self) -> None: self._result = CommandResult(*self._receiver.set_settemp(self._params['temperature'])) + + +class HeatingStageWaitForTemperature(HeatingStageParentCommand): + """Wait until the heating stage reaches the target temperature within a tolerance.""" + + def __init__( + self, + receiver: HeatingStage, + target: float, + tolerance: float = 1.0, + timeout: float = 600.0, + poll_interval: float = 1.0, + hold_duration: float = 30.0, + **kwargs + ): + super().__init__(receiver, **kwargs) + self._params['target'] = target + self._params['tolerance'] = tolerance + self._params['timeout'] = timeout + self._params['poll_interval'] = poll_interval + self._params['hold_duration'] = hold_duration + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.wait_for_temperature( + self._params['target'], + self._params['tolerance'], + self._params['timeout'], + self._params['poll_interval'], + self._params['hold_duration'], + ) + ) diff --git a/aamp_app/commands/keithley_2450_commands.py b/aamp_app/commands/keithley_2450_commands.py new file mode 100644 index 0000000..8db552e --- /dev/null +++ b/aamp_app/commands/keithley_2450_commands.py @@ -0,0 +1,123 @@ +from typing import List + +from devices.device import Device +from .command import Command, CommandResult, CompositeCommand +from devices.keithley_2450 import Keithley2450 + +class KeithleyParentCommand(Command): + """Parent class for all Keithley2450 commands.""" + receiver_cls = Keithley2450 + + def __init__(self, receiver: Keithley2450, **kwargs): + super().__init__(receiver, **kwargs) + +class Keithley2450Initialize(KeithleyParentCommand): + """Initialize the SMU by resetting it""" + + def __init__(self, receiver: Keithley2450, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + +class Keithley2450Deinitialize(KeithleyParentCommand): + """Deinitialize the SMU""" + + #TODO: create command to deinitialize keithley2450 if necessary + + def __init__(self, reciever: Keithley2450, reset_init_flag: bool = True, **kwargs): + super().__init__(reciever, **kwargs) + self._params['reset_init_flag'] = reset_init_flag + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize(self._params['reset_init_flag'])) + + +class KeithleyWait(KeithleyParentCommand): + """Wait for all pending operations to finish""" + + def __init__(self, receiver: Keithley2450, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.wait()) + +class KeithleyWriteCommand(KeithleyParentCommand): + """Write arbitrary SCPI ASCII command""" + + def __init__(self, receiver: Keithley2450, command: str, **kwargs): + super().__init__(receiver, **kwargs) + self._params['command'] = command + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.write_command(self._params['command'])) + +class KeithleySetTerminal(KeithleyParentCommand): + """Set the terminal position of the SMU""" + + def __init__(self, receiver: Keithley2450, position: str = 'front', **kwargs): + super().__init__(receiver, **kwargs) + self._params['position'] = position + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.terminal_pos(self._params['position'])) + +class KeithleyErrorCheck(KeithleyParentCommand): + """Check for errors that occur during measurement""" + + def __init__(self, receiver: Keithley2450, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.error_check()) + +class KeithleyClearBuffer(KeithleyParentCommand): + """Clear the data storage buffer within the Keithley2450""" + def __init__(self, receiver: Keithley2450, buffer: str = "defbuffer1", **kwargs): + super().__init__(receiver, **kwargs) + self._params['buffer'] = buffer + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.clear_buffer(self._params['buffer'])) + +class KeithleyIVCharacteristic(KeithleyParentCommand): + """I-V linear sweep sourcing voltage and measuring current""" + + def __init__(self, receiver: Keithley2450, ilimit: float, vmin: float, vmax: float, delay: float, steps: int = 60, **kwargs): + super().__init__(receiver, **kwargs) + self._params['ilimit'] = ilimit + self._params['vmin'] = vmin + self._params['vmax'] = vmax + self._params['delay'] = delay + self._params['steps'] = steps + + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.IV_characteristic(self._params['ilimit'], self._params['vmin'], + self._params['vmax'], self._params['steps'], self._params['delay'])) + + +class KeithleyFourPoint(KeithleyParentCommand): + """Four collinear point sheet resistance measurement""" + + def __init__(self, receiver: Keithley2450, test_curr: float, vlimit: float, curr_reversal: bool = False, **kwargs): + super().__init__(receiver, **kwargs) + self._params['test_curr'] = test_curr + self._params['vlimit'] = vlimit + self._params['curr_reversal'] = curr_reversal + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.four_point(self._params['test_curr'], self._params['vlimit'], self._params['curr_reversal'])) + +class KeithleyGetData(KeithleyParentCommand): + """Retrieve data""" + + def __init__(self, receiver: Keithley2450, filename: str = None, four_point: bool = False, **kwargs): + super().__init__(receiver, **kwargs) + self._params['filename'] = filename + self._params['four_point'] = four_point + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_data(self._params['filename'], self._params['four_point'])) + + diff --git a/commands/kinova_arm_commands.py b/aamp_app/commands/kinova_arm_commands.py similarity index 100% rename from commands/kinova_arm_commands.py rename to aamp_app/commands/kinova_arm_commands.py diff --git a/aamp_app/commands/linear_stage_150_commands.py b/aamp_app/commands/linear_stage_150_commands.py new file mode 100644 index 0000000..c78c6b9 --- /dev/null +++ b/aamp_app/commands/linear_stage_150_commands.py @@ -0,0 +1,78 @@ +from devices.device import Device +from .command import Command, CommandResult +from devices.linear_stage_150 import LinearStage150 + + +class LinearStage150ParentCommand(Command): + """Parent class for all LinearStage150 commands.""" + receiver_cls = LinearStage150 + + def __init__(self, receiver: LinearStage150, **kwargs): + super().__init__(receiver, **kwargs) + +class LinearStage150Connect(LinearStage150ParentCommand): + """Open a serial port for the linear stage.""" + + def __init__(self, receiver: LinearStage150, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_serial()) + +class LinearStage150Initialize(LinearStage150ParentCommand): + """Initialize the linear stage by homing it.""" + + def __init__(self, receiver: LinearStage150, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + +class LinearStage150Deinitialize(LinearStage150ParentCommand): + """Deinitialize the linear stage.""" + + def __init__(self, receiver: LinearStage150, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize()) + +class LinearStage150EnableMotor(LinearStage150ParentCommand): + """Enable the linear stage motor.""" + + def __init__(self, receiver: LinearStage150, **kwargs): + super().__init__(receiver, **kwargs) + self._params['state'] = True + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_enabled_state(self._params['state'])) + +class LinearStage150DisableMotor(LinearStage150ParentCommand): + """Disable the linear stage motor.""" + + def __init__(self, receiver: LinearStage150, **kwargs): + super().__init__(receiver, **kwargs) + self._params['state'] = False + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_enabled_state(self._params['state'])) + +class LinearStage150MoveAbsolute(LinearStage150ParentCommand): + """Move the linear stage to an absolute position.""" + + def __init__(self, receiver: LinearStage150, position: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params['position'] = position + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_absolute(self._params['position'])) + +class LinearStage150MoveRelative(LinearStage150ParentCommand): + """Move the linear stage by a relative distance.""" + + def __init__(self, receiver: LinearStage150, distance: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params['distance'] = distance + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_relative(self._params['distance'])) \ No newline at end of file diff --git a/aamp_app/commands/mfc_commands.py b/aamp_app/commands/mfc_commands.py new file mode 100644 index 0000000..1f7b532 --- /dev/null +++ b/aamp_app/commands/mfc_commands.py @@ -0,0 +1,92 @@ +from typing import Tuple, List +import asyncio +from devices.mfc import MassFlowController +from .command import Command, CommandResult + + +class MFCParentCommand(Command): + """Parent class for MKS instruments mass flow controller""" + + receiver_cls = MassFlowController + + def __init__(self, receiver: MassFlowController, **kwargs): + super().__init__(receiver, **kwargs) + + +class MFCInitialize(MFCParentCommand): + """Initialize the mass flow controller""" + + def __init__(self, receiver: MassFlowController, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class MFCDeinitialize(MFCParentCommand): + """Deinitialize the mass flow controller""" + + def __init__(self, receiver: MassFlowController, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult( + asyncio.run(self._receiver.deinitialize()), "Deinitialized the mfc" + ) + # self._result = CommandResult(*self._receiver.deinitialize()) + + +class MFCGetData(MFCParentCommand): + """Return data on mfc operation""" + + def __init__(self, receiver: MassFlowController, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + # asyncio.run(self._receiver.get()) + self._result = CommandResult(asyncio.run(self._receiver.get()), "Received Data") + # self._result = CommandResult(True, "got data") + + +class MFCSetGas(MFCParentCommand): + """Set the type of gas for operation""" + + """Gas instance must first be created within web browser""" + + def __init__(self, receiver: MassFlowController, gas: str, **kwargs): + super().__init__(receiver, **kwargs) + self._params["gas"] = gas + + def execute(self) -> None: + self._result = CommandResult( + asyncio.run(self._receiver.set_gas(self._params["gas"])), "Set the gas" + ) + # self._result = CommandResult(*self._receiver.set_gas(self._params['gas'])) + + +class MFCSet(MFCParentCommand): + """Set the desired flowrate in sccm""" + + def __init__(self, receiver: MassFlowController, setpoint: int, **kwargs): + super().__init__(receiver, **kwargs) + self._params["setpoint"] = setpoint + + def execute(self) -> None: + self._result = CommandResult( + asyncio.run(self._receiver.set(self._params["setpoint"])), + "Set the flowrate", + ) + # self._result = CommandResult(*self._receiver.set(self._params['setpoint'])) + + +class MFCOpen(MFCParentCommand): + """Open the mfc, set the flowrate to the maximum for the set gas""" + + def __init__(self, receiver: MassFlowController, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult( + asyncio.run(self._receiver.open()), "Opened the mfc, maximum flowrate" + ) + # self._result = CommandResult(*self._receiver.open()) diff --git a/aamp_app/commands/mts50_z8_commands.py b/aamp_app/commands/mts50_z8_commands.py new file mode 100644 index 0000000..ce6f74a --- /dev/null +++ b/aamp_app/commands/mts50_z8_commands.py @@ -0,0 +1,86 @@ +from devices.device import Device +from .command import Command, CommandResult +from devices.mts50_z8 import MTS50_Z8 + + +class MTS50_Z8ParentCommand(Command): + """Parent class for all MTS50_Z8 commands.""" + + receiver_cls = MTS50_Z8 + + def __init__(self, receiver: MTS50_Z8, **kwargs): + super().__init__(receiver, **kwargs) + + +class MTS50_Z8Connect(MTS50_Z8ParentCommand): + """Open a serial port for the stage.""" + + def __init__(self, receiver: MTS50_Z8, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_serial()) + + +class MTS50_Z8Initialize(MTS50_Z8ParentCommand): + """Initialize the stage by homing it.""" + + def __init__(self, receiver: MTS50_Z8, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class MTS50_Z8Deinitialize(MTS50_Z8ParentCommand): + """Deinitialize the stage.""" + + def __init__(self, receiver: MTS50_Z8, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize()) + + +class MTS50_Z8EnableMotor(MTS50_Z8ParentCommand): + """Enable the stage motor.""" + + def __init__(self, receiver: MTS50_Z8, **kwargs): + super().__init__(receiver, **kwargs) + self._params["state"] = True + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_enabled_state(self._params["state"])) + + +class MTS50_Z8DisableMotor(MTS50_Z8ParentCommand): + """Disable the stage motor.""" + + def __init__(self, receiver: MTS50_Z8, **kwargs): + super().__init__(receiver, **kwargs) + self._params["state"] = False + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_enabled_state(self._params["state"])) + + +class MTS50_Z8MoveAbsolute(MTS50_Z8ParentCommand): + """Move the stage to an absolute position.""" + + def __init__(self, receiver: MTS50_Z8, position: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["position"] = position + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_absolute(self._params["position"])) + + +class MTS50_Z8MoveRelative(MTS50_Z8ParentCommand): + """Move the stage by a relative distance.""" + + def __init__(self, receiver: MTS50_Z8, distance: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["distance"] = distance + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_relative(self._params["distance"])) diff --git a/commands/multi_stepper_commands.py b/aamp_app/commands/multi_stepper_commands.py similarity index 100% rename from commands/multi_stepper_commands.py rename to aamp_app/commands/multi_stepper_commands.py diff --git a/aamp_app/commands/newport_94043a_solar_sim_commands.py b/aamp_app/commands/newport_94043a_solar_sim_commands.py new file mode 100644 index 0000000..2a373b2 --- /dev/null +++ b/aamp_app/commands/newport_94043a_solar_sim_commands.py @@ -0,0 +1,134 @@ +from .command import Command, CommandResult +from devices.newport_94043a_solar_sim import Newport94043ASolarSim + + +class Newport94043ASolarSimParentCommand(Command): + """Parent class for all Newport94043ASolarSim commands controlled through the 69920 power supply.""" + + receiver_cls = Newport94043ASolarSim + + def __init__(self, receiver: Newport94043ASolarSim, **kwargs): + super().__init__(receiver, **kwargs) + + +class Newport94043ASolarSimConnect(Newport94043ASolarSimParentCommand): + """Open a serial port for the 94043A solar simulator via the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_serial(delay=0.5)) + + +class Newport94043ASolarSimInitialize(Newport94043ASolarSimParentCommand): + """Initialize the 94043A solar simulator through the 69920 power supply and set power mode.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class Newport94043ASolarSimDeinitialize(Newport94043ASolarSimParentCommand): + """Deinitialize the 94043A solar simulator interface.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize()) + + +class Newport94043ASolarSimIdentify(Newport94043ASolarSimParentCommand): + """Query the 69920 power supply model identifier used by the 94043A solar simulator.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.identify()) + + +class Newport94043ASolarSimStatusByte(Newport94043ASolarSimParentCommand): + """Query the 69920 status byte.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.status_byte()) + + +class Newport94043ASolarSimEventStatus(Newport94043ASolarSimParentCommand): + """Query the 69920 event status register.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.event_status_register()) + + +class Newport94043ASolarSimLampStart(Newport94043ASolarSimParentCommand): + """Start the lamp through the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.lamp_start()) + + +class Newport94043ASolarSimLampStop(Newport94043ASolarSimParentCommand): + """Stop the lamp through the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.lamp_stop()) + + +class Newport94043ASolarSimSetPowerMode(Newport94043ASolarSimParentCommand): + """Set the 94043A solar simulator control path to power mode through the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_power_mode()) + + +class Newport94043ASolarSimGetAmps(Newport94043ASolarSimParentCommand): + """Read the displayed lamp current from the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_amps()) + + +class Newport94043ASolarSimGetVolts(Newport94043ASolarSimParentCommand): + """Read the displayed lamp voltage from the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_volts()) + + +class Newport94043ASolarSimGetWatts(Newport94043ASolarSimParentCommand): + """Read the displayed lamp power from the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_watts()) + + +class Newport94043ASolarSimGetLampHours(Newport94043ASolarSimParentCommand): + """Read accumulated lamp hours from the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_lamp_hours()) + + +class Newport94043ASolarSimGetPowerPreset(Newport94043ASolarSimParentCommand): + """Read the configured power preset from the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_power_preset()) + + +class Newport94043ASolarSimGetCurrentLimit(Newport94043ASolarSimParentCommand): + """Read the configured current limit from the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_current_limit()) + + +class Newport94043ASolarSimGetPowerLimit(Newport94043ASolarSimParentCommand): + """Read the configured power limit from the 69920 power supply.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_power_limit()) + + +class Newport94043ASolarSimSetPowerPreset(Newport94043ASolarSimParentCommand): + """Set the power preset in watts through the 69920 power supply.""" + + def __init__(self, receiver: Newport94043ASolarSim, watts: int, **kwargs): + super().__init__(receiver, **kwargs) + self._params["watts"] = watts + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_power_preset(self._params["watts"])) diff --git a/commands/newport_esp301_commands.py b/aamp_app/commands/newport_esp301_commands.py similarity index 100% rename from commands/newport_esp301_commands.py rename to aamp_app/commands/newport_esp301_commands.py diff --git a/aamp_app/commands/oxygen_sensor_commands.py b/aamp_app/commands/oxygen_sensor_commands.py new file mode 100644 index 0000000..d3131fe --- /dev/null +++ b/aamp_app/commands/oxygen_sensor_commands.py @@ -0,0 +1,54 @@ +from typing import Tuple, List +from devices.oxygen_sensor import OxygenSensor +from .command import Command, CommandResult + + +class OxygenParentCommand(Command): + """Parent class for DFRobot Oxygen Sensor Commands""" + + receiver_cls = OxygenSensor + + def __init__(self, receiver: OxygenSensor, **kwargs): + super().__init__(receiver, **kwargs) + + +class OxygenConnect(Command): + """Open the serial port to the oxygen sensor""" + + receiver_cls = OxygenSensor + + def __init__(self, receiver: OxygenSensor, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_serial()) + + +class OxygenInitialize(OxygenParentCommand): + """Initialize the oxygen sensor""" + + def __init__(self, receiver: OxygenSensor, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class OxygenDeinitialize(OxygenParentCommand): + """Deinitialize the oxygen sensor""" + + def __init__(self, receiver: OxygenSensor, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize()) + + +class OxygenGetOxygen(OxygenParentCommand): + """Read average oxygen data""" + + def __init__(self, receiver: OxygenSensor, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_oxygen()) diff --git a/aamp_app/commands/p4pp_commands.py b/aamp_app/commands/p4pp_commands.py new file mode 100644 index 0000000..0a15029 --- /dev/null +++ b/aamp_app/commands/p4pp_commands.py @@ -0,0 +1,152 @@ +from .command import Command, CommandResult +from devices.p4pp import P4PP + + +class P4PPParentCommand(Command): + """Parent class for all P4PP commands.""" + + receiver_cls = P4PP + + def __init__(self, receiver: P4PP, **kwargs): + super().__init__(receiver, **kwargs) + + +class P4PPConnect(P4PPParentCommand): + """Open the serial port for the P4PP controller.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_serial()) + + +class P4PPInitialize(P4PPParentCommand): + """Initialize the P4PP controller by syncing its current position.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class P4PPDeinitialize(P4PPParentCommand): + """Deinitialize the P4PP controller.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize()) + + +class P4PPRefreshPosition(P4PPParentCommand): + """Refresh the cached linear and rotational positions from firmware.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.refresh_position()) + + +class P4PPHomeLinear(P4PPParentCommand): + """Home the P4PP linear axis.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.home_linear()) + + +class P4PPHomeRotational(P4PPParentCommand): + """Home the P4PP rotational axis.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.home_rotational()) + + +class P4PPHomeAll(P4PPParentCommand): + """Home both the P4PP linear and rotational axes.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.home_all()) + + +class P4PPMoveLinearAbsolute(P4PPParentCommand): + """Move the P4PP linear axis to an absolute position in mm.""" + + def __init__(self, receiver: P4PP, position_mm: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["position_mm"] = position_mm + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_linear_mm(self._params["position_mm"], relative=False)) + + +class P4PPMoveLinearRelative(P4PPParentCommand): + """Move the P4PP linear axis by a relative distance in mm.""" + + def __init__(self, receiver: P4PP, distance_mm: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["distance_mm"] = distance_mm + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_linear_mm(self._params["distance_mm"], relative=True)) + + +class P4PPMoveRotationalAbsolute(P4PPParentCommand): + """Move the P4PP rotational axis to an absolute position in degrees.""" + + def __init__(self, receiver: P4PP, position_deg: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["position_deg"] = position_deg + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_rotational_deg(self._params["position_deg"], relative=False)) + + +class P4PPMoveRotationalRelative(P4PPParentCommand): + """Move the P4PP rotational axis by a relative distance in degrees.""" + + def __init__(self, receiver: P4PP, distance_deg: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["distance_deg"] = distance_deg + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_rotational_deg(self._params["distance_deg"], relative=True)) + + +class P4PPMeasure(P4PPParentCommand): + """Trigger a P4PP measurement. Multi-cycle mode uses firmware averaging.""" + + def __init__(self, receiver: P4PP, cycles: int = 1, **kwargs): + super().__init__(receiver, **kwargs) + self._params["cycles"] = cycles + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.measure(self._params["cycles"])) + + +class P4PPSetMeasurementResistor(P4PPParentCommand): + """Select the P4PP measurement resistor configuration: 681 or 68.1 ohm.""" + + def __init__(self, receiver: P4PP, resistor_ohms: float = 681.0, **kwargs): + super().__init__(receiver, **kwargs) + self._params["resistor_ohms"] = resistor_ohms + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_measurement_resistor(self._params["resistor_ohms"])) + + +class P4PPSaveMeasurementCsv(P4PPParentCommand): + """Append the latest P4PP measurement result to a CSV file.""" + + def __init__( + self, + receiver: P4PP, + sample_id: str = None, + csv_path: str = None, + notes: str = None, + **kwargs + ): + super().__init__(receiver, **kwargs) + self._params["sample_id"] = sample_id + self._params["csv_path"] = csv_path + self._params["notes"] = notes + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.save_measurement_csv( + sample_id=self._params["sample_id"], + csv_path=self._params["csv_path"], + notes=self._params["notes"], + ) + ) diff --git a/commands/psd6_syringe_pump_commands.py b/aamp_app/commands/psd6_syringe_pump_commands.py similarity index 100% rename from commands/psd6_syringe_pump_commands.py rename to aamp_app/commands/psd6_syringe_pump_commands.py diff --git a/aamp_app/commands/sciencetech_uhe_nl_solar_sim_commands.py b/aamp_app/commands/sciencetech_uhe_nl_solar_sim_commands.py new file mode 100644 index 0000000..b20328a --- /dev/null +++ b/aamp_app/commands/sciencetech_uhe_nl_solar_sim_commands.py @@ -0,0 +1,104 @@ +from .command import Command, CommandResult +from devices.sciencetech_uhe_nl_solar_sim import SciencetechUHENLSolarSim + + +class SciencetechUHENLSolarSimParentCommand(Command): + receiver_cls = SciencetechUHENLSolarSim + + def __init__(self, receiver: SciencetechUHENLSolarSim, **kwargs): + super().__init__(receiver, **kwargs) + + +class SciencetechUHENLSolarSimConnect(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.connect()) + + +class SciencetechUHENLSolarSimInitialize(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class SciencetechUHENLSolarSimDeinitialize(SciencetechUHENLSolarSimParentCommand): + def __init__(self, receiver: SciencetechUHENLSolarSim, reset_init_flag: bool = True, close_serial: bool = False, **kwargs): + super().__init__(receiver, **kwargs) + self._params["reset_init_flag"] = reset_init_flag + self._params["close_serial"] = close_serial + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.deinitialize( + reset_init_flag=self._params["reset_init_flag"], + close_serial=self._params["close_serial"], + ) + ) + + +class SciencetechUHENLSolarSimCloseShutter(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.close_shutter()) + + +class SciencetechUHENLSolarSimOpenShutter(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.open_shutter()) + + +class SciencetechUHENLSolarSimEnableCooling(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.enable_cooling()) + + +class SciencetechUHENLSolarSimDisableCooling(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.disable_cooling()) + + +class SciencetechUHENLSolarSimEnableArcLamp(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.enable_arc_lamp()) + + +class SciencetechUHENLSolarSimDisableArcLamp(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.disable_arc_lamp()) + + +class SciencetechUHENLSolarSimOpenAttenuator(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.open_attenuator()) + + +class SciencetechUHENLSolarSimSetAttenuator(SciencetechUHENLSolarSimParentCommand): + def __init__(self, receiver: SciencetechUHENLSolarSim, percent: int = 100, **kwargs): + super().__init__(receiver, **kwargs) + self._params["percent"] = percent + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_attenuator(self._params["percent"])) + + +class SciencetechUHENLSolarSimSetCurrent(SciencetechUHENLSolarSimParentCommand): + def __init__(self, receiver: SciencetechUHENLSolarSim, percent: float = 85.0, **kwargs): + super().__init__(receiver, **kwargs) + self._params["percent"] = percent + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_current(self._params["percent"])) + + +class SciencetechUHENLSolarSimGetStatus(SciencetechUHENLSolarSimParentCommand): + def execute(self) -> None: + was_successful, message = self._receiver.get_status() + if was_successful and isinstance(message, list): + message = " | ".join(message) + self._result = CommandResult(was_successful, message) + + +class SciencetechUHENLSolarSimGetFeedback(SciencetechUHENLSolarSimParentCommand): + def __init__(self, receiver: SciencetechUHENLSolarSim, feedback_type: str, **kwargs): + super().__init__(receiver, **kwargs) + self._params["feedback_type"] = feedback_type + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_feedback(self._params["feedback_type"])) diff --git a/aamp_app/commands/sht85_sensor_commands.py b/aamp_app/commands/sht85_sensor_commands.py new file mode 100644 index 0000000..c9921d2 --- /dev/null +++ b/aamp_app/commands/sht85_sensor_commands.py @@ -0,0 +1,64 @@ +from typing import Tuple, List +from devices.sht85_sensor import SHT85HumidityTempSensor +from .command import Command, CommandResult + + +class SHT85ParentCommand(Command): + """Parent class for SHT85 humidity and temperature sensor""" + + receiver_cls = SHT85HumidityTempSensor + + def __init__(self, receiver: SHT85HumidityTempSensor, **kwargs): + super().__init__(receiver, **kwargs) + + +class SHT85Connect(Command): + """Open the serial port to the SHT85 sensor.""" + + receiver_cls = SHT85HumidityTempSensor + + def __init__(self, receiver: SHT85HumidityTempSensor, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_serial()) + + +class SHT85Initialize(SHT85ParentCommand): + """Initialize the humidity and temperature sensor""" + + def __init__(self, receiver: SHT85HumidityTempSensor, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class SHT85Deinitialize(SHT85ParentCommand): + """Deinitialize the humidity and temperature sensor""" + + def __init__(self, receiver: SHT85HumidityTempSensor, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize()) + + +class SHT85GetHumidity(SHT85ParentCommand): + """Read humidity data""" + + def __init__(self, receiver: SHT85HumidityTempSensor, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_humidity()) + + +class SHT85GetTemp(SHT85ParentCommand): + """Read temperature data""" + + def __init__(self, receiver: SHT85HumidityTempSensor, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_temp()) diff --git a/aamp_app/commands/sonicator_commands.py b/aamp_app/commands/sonicator_commands.py new file mode 100644 index 0000000..6abe48c --- /dev/null +++ b/aamp_app/commands/sonicator_commands.py @@ -0,0 +1,65 @@ +from .command import Command, CommandResult +from devices.sonicator import Sonicator + + +class SonicatorParentCommand(Command): + receiver_cls = Sonicator + + def __init__(self, receiver: Sonicator, **kwargs): + super().__init__(receiver, **kwargs) + + +class SonicatorConnect(SonicatorParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.connect()) + + +class SonicatorInitialize(SonicatorParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class SonicatorDeinitialize(SonicatorParentCommand): + def __init__( + self, + receiver: Sonicator, + reset_init_flag: bool = True, + close_serial: bool = False, + **kwargs, + ): + super().__init__(receiver, **kwargs) + self._params["reset_init_flag"] = reset_init_flag + self._params["close_serial"] = close_serial + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.deinitialize( + reset_init_flag=self._params["reset_init_flag"], + close_serial=self._params["close_serial"], + ) + ) + + +class SonicatorGetStatus(SonicatorParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_status()) + + +class SonicatorProbePowerConnection(SonicatorParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.probe_power_connection()) + + +class SonicatorStartSonicating(SonicatorParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_sonicating()) + + +class SonicatorStopSonicating(SonicatorParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.stop_sonicating()) + + +class SonicatorPressButton(SonicatorParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.press_button()) diff --git a/aamp_app/commands/stellarnet_spectrometer_commands.py b/aamp_app/commands/stellarnet_spectrometer_commands.py new file mode 100644 index 0000000..60a89db --- /dev/null +++ b/aamp_app/commands/stellarnet_spectrometer_commands.py @@ -0,0 +1,278 @@ +from typing import List, Optional, Tuple, Union + +from .command import Command, CommandResult +from devices.stellarnet_spectrometer import StellarNetSpectrometer + +DetectorSetting = Union[int, List[int], Tuple[int, ...]] +OptionalDetectorSetting = Optional[DetectorSetting] + +class SpectrometerParentCommand(Command): + """Parent class for all StellarNet Spectrometer commands.""" + receiver_cls = StellarNetSpectrometer + + def __init__(self, receiver: StellarNetSpectrometer, **kwargs): + super().__init__(receiver, **kwargs) + +class SpectrometerInitialize(SpectrometerParentCommand): + """Initialize spectrometer by verifying connection and getting each spectrometer object and wavelength array.""" + + def __init__(self, receiver: StellarNetSpectrometer, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + +class SpectrometerDeinitialize(SpectrometerParentCommand): + """Deinitialize the spectrometer, currently does nothing except optionally change init flag.""" + + def __init__(self, receiver: StellarNetSpectrometer, reset_init_flag: bool = True, **kwargs): + super().__init__(receiver, **kwargs) + self._params['reset_init_flag'] = reset_init_flag + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize(self._params['reset_init_flag'])) + +class SpectrometerUpdateDark(SpectrometerParentCommand): + """Update the stored dark spectra for all spectrometers.""" + + def __init__( + self, + receiver: StellarNetSpectrometer, + integration_times: OptionalDetectorSetting = None, + scans_to_avg: DetectorSetting = (3, 3), + smoothings: DetectorSetting = (0, 0), + xtimings: DetectorSetting = (1, 1), + **kwargs): + super().__init__(receiver, **kwargs) + self._params['integration_times'] = integration_times + self._params['scans_to_avg'] = scans_to_avg + self._params['smoothings'] = smoothings + self._params['xtimings'] = xtimings + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.update_all_dark_spectra( + self._params['integration_times'], + self._params['scans_to_avg'], + self._params['smoothings'], + self._params['xtimings'])) + +class SpectrometerUpdateBlank(SpectrometerParentCommand): + """Update the stored blank spectra for all spectrometers.""" + + def __init__( + self, + receiver: StellarNetSpectrometer, + integration_times: OptionalDetectorSetting = None, + scans_to_avg: DetectorSetting = (3, 3), + smoothings: DetectorSetting = (0, 0), + xtimings: DetectorSetting = (1, 1), + **kwargs): + super().__init__(receiver, **kwargs) + self._params['integration_times'] = integration_times + self._params['scans_to_avg'] = scans_to_avg + self._params['smoothings'] = smoothings + self._params['xtimings'] = xtimings + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.update_all_blank_spectra( + self._params['integration_times'], + self._params['scans_to_avg'], + self._params['smoothings'], + self._params['xtimings'])) + +class SpectrometerAdjDefIntegrationTime(SpectrometerParentCommand): + def __init__( + self, + receiver: StellarNetSpectrometer, + scans_to_avg: DetectorSetting = (3, 3), + smoothings: DetectorSetting = (0, 0), + xtimings: DetectorSetting = (1, 1), + target_max_count: int = 52000, + tolerance: int = 2000, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['scans_to_avg'] = scans_to_avg + self._params['smoothings'] = smoothings + self._params['xtimings'] = xtimings + self._params['target_max_count'] = target_max_count + self._params['tolerance'] = tolerance + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.adjust_default_integration_time( + self._params['scans_to_avg'], + self._params['smoothings'], + self._params['xtimings'], + self._params['target_max_count'], + self._params['tolerance'])) + +class SpectrometerGetAbsorbance(SpectrometerParentCommand): + """Calculate and update the stored absorbance spectra, merge spectra, and optionally save to file. No filename = timestamped filename.""" + + def __init__( + self, + receiver: StellarNetSpectrometer, + save_to_file: bool = True, + filename: Optional[str] = None, + integration_times: OptionalDetectorSetting = None, + scans_to_avg: DetectorSetting = (3, 3), + smoothings: DetectorSetting = (0, 0), + xtimings: DetectorSetting = (1, 1), + **kwargs): + super().__init__(receiver, **kwargs) + self._params['save_to_file'] = save_to_file + self._params['filename'] = filename + self._params['integration_times'] = integration_times + self._params['scans_to_avg'] = scans_to_avg + self._params['smoothings'] = smoothings + self._params['xtimings'] = xtimings + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_all_absorbance( + self._params['save_to_file'], + self._params['filename'], + self._params['integration_times'], + self._params['scans_to_avg'], + self._params['smoothings'], + self._params['xtimings'])) + +class SpectrometerGetAbsorbancebyname(SpectrometerParentCommand): + def __init__( + self, + receiver: StellarNetSpectrometer, + sample_name: Optional[str] = None, + save_to_file: bool = True, + repeat_measure: bool = False, + integration_times: OptionalDetectorSetting = None, + scans_to_avg: DetectorSetting = (3, 3), + smoothings: DetectorSetting = (0, 0), + xtimings: DetectorSetting = (1, 1), + absorbance_threshold: float = 0.003, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['sample_name'] = sample_name + self._params['save_to_file'] = save_to_file + self._params['repeat_measure'] = repeat_measure + self._params['integration_times'] = integration_times + self._params['scans_to_avg'] = scans_to_avg + self._params['smoothings'] = smoothings + self._params['xtimings'] = xtimings + self._params['absorbance_threshold'] = absorbance_threshold + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_all_absorbance_byname( + self._params['sample_name'], + self._params['save_to_file'], + self._params['repeat_measure'], + self._params['integration_times'], + self._params['scans_to_avg'], + self._params['smoothings'], + self._params['xtimings'], + self._params['absorbance_threshold'])) + +class SpectrometerGetPhotoncountsbyname(SpectrometerParentCommand): + def __init__( + self, + receiver: StellarNetSpectrometer, + sample_name: Optional[str] = None, + save_to_file: bool = True, + repeat_measure: bool = False, + integration_times: OptionalDetectorSetting = None, + scans_to_avg: DetectorSetting = (3, 3), + smoothings: DetectorSetting = (0, 0), + xtimings: DetectorSetting = (1, 1), + absorbance_threshold: float = 0.003, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['sample_name'] = sample_name + self._params['save_to_file'] = save_to_file + self._params['repeat_measure'] = repeat_measure + self._params['integration_times'] = integration_times + self._params['scans_to_avg'] = scans_to_avg + self._params['smoothings'] = smoothings + self._params['xtimings'] = xtimings + self._params['absorbance_threshold'] = absorbance_threshold + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_all_counts_byname( + self._params['sample_name'], + self._params['save_to_file'], + self._params['repeat_measure'], + self._params['integration_times'], + self._params['scans_to_avg'], + self._params['smoothings'], + self._params['xtimings'], + self._params['absorbance_threshold'])) + +class SpectrometerGetSpecDecay(SpectrometerParentCommand): + def __init__( + self, + receiver: StellarNetSpectrometer, + sample_name: str, + save_to_file: bool = False, + range_start: float = 290.0, + range_end: float = 800.0, + irradiance_file: str = "reference/am15g_spectrum.csv", + Wvlgth_col_name: str = "wavelength_nm", + Irrad_col_name: str = "irradiance_w_m2_nm", + decay_threshold: float = 0.01, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['sample_name'] = sample_name + self._params['save_to_file'] = save_to_file + self._params['range_start'] = range_start + self._params['range_end'] = range_end + self._params['irradiance_file'] = irradiance_file + self._params['Wvlgth_col_name'] = Wvlgth_col_name + self._params['Irrad_col_name'] = Irrad_col_name + self._params['decay_threshold'] = decay_threshold + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_spec_decay( + self._params['sample_name'], + self._params['save_to_file'], + self._params['range_start'], + self._params['range_end'], + self._params['irradiance_file'], + self._params['Wvlgth_col_name'], + self._params['Irrad_col_name'], + self._params['decay_threshold'])) + +class SpectrometerPlotSpecDecaySummary(SpectrometerParentCommand): + def __init__( + self, + receiver: StellarNetSpectrometer, + sample_name: str, + range_start: float = 290.0, + range_end: float = 800.0, + save_to_file: bool = True, + output_filename: Optional[str] = None, + figure_dpi: int = 180, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['sample_name'] = sample_name + self._params['range_start'] = range_start + self._params['range_end'] = range_end + self._params['save_to_file'] = save_to_file + self._params['output_filename'] = output_filename + self._params['figure_dpi'] = figure_dpi + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.plot_spec_decay_summary( + self._params['sample_name'], + self._params['range_start'], + self._params['range_end'], + self._params['save_to_file'], + self._params['output_filename'], + self._params['figure_dpi'])) + +class SpectrometerShutterIn(SpectrometerParentCommand): + pass + +class SpectrometerShutterOut(SpectrometerParentCommand): + pass + +class SpectrometerLampOn(SpectrometerParentCommand): + pass + +class SpectrometerLampOff(SpectrometerParentCommand): + pass diff --git a/aamp_app/commands/substrate_dispenser_commands.py b/aamp_app/commands/substrate_dispenser_commands.py new file mode 100644 index 0000000..59bde6b --- /dev/null +++ b/aamp_app/commands/substrate_dispenser_commands.py @@ -0,0 +1,63 @@ +from .command import Command, CommandResult +from devices.substrate_dispenser import SubstrateDispenser + + +class SubstrateDispenserParentCommand(Command): + receiver_cls = SubstrateDispenser + + def __init__(self, receiver: SubstrateDispenser, **kwargs): + super().__init__(receiver, **kwargs) + + +class SubstrateDispenserConnect(SubstrateDispenserParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.connect()) + + +class SubstrateDispenserInitialize(SubstrateDispenserParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class SubstrateDispenserDeinitialize(SubstrateDispenserParentCommand): + def __init__( + self, + receiver: SubstrateDispenser, + reset_init_flag: bool = True, + close_serial: bool = False, + **kwargs): + super().__init__(receiver, **kwargs) + self._params["reset_init_flag"] = reset_init_flag + self._params["close_serial"] = close_serial + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize( + self._params["reset_init_flag"], + self._params["close_serial"], + )) + + +class SubstrateDispenserHome(SubstrateDispenserParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.home()) + + +class SubstrateDispenserMoveToPosition(SubstrateDispenserParentCommand): + def __init__( + self, + receiver: SubstrateDispenser, + position_mm: float, + speed_mm_per_s: float = 20.0, + move_timeout: float = None, + **kwargs): + super().__init__(receiver, **kwargs) + self._params["position_mm"] = position_mm + self._params["speed_mm_per_s"] = speed_mm_per_s + self._params["move_timeout"] = move_timeout + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_to_position( + self._params["position_mm"], + self._params["speed_mm_per_s"], + self._params["move_timeout"], + )) diff --git a/aamp_app/commands/substrate_hotel_commands.py b/aamp_app/commands/substrate_hotel_commands.py new file mode 100644 index 0000000..ee59d96 --- /dev/null +++ b/aamp_app/commands/substrate_hotel_commands.py @@ -0,0 +1,63 @@ +from .command import Command, CommandResult +from devices.substrate_hotel import SubstrateHotel + + +class SubstrateHotelParentCommand(Command): + receiver_cls = SubstrateHotel + + def __init__(self, receiver: SubstrateHotel, **kwargs): + super().__init__(receiver, **kwargs) + + +class SubstrateHotelConnect(SubstrateHotelParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.connect()) + + +class SubstrateHotelInitialize(SubstrateHotelParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class SubstrateHotelDeinitialize(SubstrateHotelParentCommand): + def __init__( + self, + receiver: SubstrateHotel, + reset_init_flag: bool = True, + close_serial: bool = False, + **kwargs): + super().__init__(receiver, **kwargs) + self._params["reset_init_flag"] = reset_init_flag + self._params["close_serial"] = close_serial + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize( + self._params["reset_init_flag"], + self._params["close_serial"], + )) + + +class SubstrateHotelHome(SubstrateHotelParentCommand): + def execute(self) -> None: + self._result = CommandResult(*self._receiver.home()) + + +class SubstrateHotelMoveToPosition(SubstrateHotelParentCommand): + def __init__( + self, + receiver: SubstrateHotel, + position_mm: float, + speed_mm_per_s: float = 20.0, + move_timeout: float = None, + **kwargs): + super().__init__(receiver, **kwargs) + self._params["position_mm"] = position_mm + self._params["speed_mm_per_s"] = speed_mm_per_s + self._params["move_timeout"] = move_timeout + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_to_position( + self._params["position_mm"], + self._params["speed_mm_per_s"], + self._params["move_timeout"], + )) diff --git a/commands/utility_commands.py b/aamp_app/commands/utility_commands.py similarity index 100% rename from commands/utility_commands.py rename to aamp_app/commands/utility_commands.py diff --git a/aamp_app/commands/ximea_camera_commands.py b/aamp_app/commands/ximea_camera_commands.py new file mode 100644 index 0000000..6c79a45 --- /dev/null +++ b/aamp_app/commands/ximea_camera_commands.py @@ -0,0 +1,223 @@ +from typing import Optional + +from .command import Command, CommandResult +from devices.ximea_camera import XimeaCamera + + +class XimeaCameraParentCommand(Command): + """Parent class for all XimeaCamera commands.""" + receiver_cls = XimeaCamera + + def __init__(self, receiver: XimeaCamera, **kwargs): + super().__init__(receiver, **kwargs) + + +class XimeaCameraInitialize(XimeaCameraParentCommand): + """Initialize camera by opening communication with camera and optionally set defaults.""" + + def __init__(self, receiver: XimeaCamera, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class XimeaCameraDeinitialize(XimeaCameraParentCommand): + """Deinitialize camera by stopping any acquisition and closing communication with camera.""" + + def __init__(self, receiver: XimeaCamera, reset_init_flag: bool = True, **kwargs): + super().__init__(receiver, **kwargs) + self._params['reset_init_flag'] = reset_init_flag + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize(self._params['reset_init_flag'])) + +class XimeaCameraUpdateBackground(XimeaCameraParentCommand): + def __init__( + self, + receiver: XimeaCamera, + save_to_file: bool = True, + filename: str = None, + exposure_time: Optional[int] = None, + gain: Optional[float] = None, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['save_to_file'] = save_to_file + self._params['filename'] = filename + self._params['exposure_time'] = exposure_time + self._params['gain'] = gain + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.update_background(self._params['save_to_file'], self._params['filename'], + self._params['exposure_time'], self._params['gain'])) + + +class XimeaCameraUpdateCompRef(XimeaCameraParentCommand): + def __init__( + self, + receiver: XimeaCamera, + save_to_file: bool = True, + filename: str = None, + exposure_time: Optional[int] = None, + gain: Optional[float] = None, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['save_to_file'] = save_to_file + self._params['filename'] = filename + self._params['exposure_time'] = exposure_time + self._params['gain'] = gain + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.update_completeness_ref(self._params['save_to_file'], self._params['filename'], + self._params['exposure_time'], self._params['gain'])) + + +class XimeaCameraCheckComp(XimeaCameraParentCommand): + def __init__( + self, + receiver: XimeaCamera, + save_to_file: bool = True, + filename: str = None, + exposure_time: Optional[int] = None, + gain: Optional[float] = None, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['save_to_file'] = save_to_file + self._params['filename'] = filename + self._params['exposure_time'] = exposure_time + self._params['gain'] = gain + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.check_completeness(self._params['save_to_file'], self._params['filename'], + self._params['exposure_time'], self._params['gain'])) + + +class XimeaCameraUpdateVertBound(XimeaCameraParentCommand): + def __init__( + self, + receiver: XimeaCamera, + exposure_time: Optional[int] = None, + gain: Optional[float] = None, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['exposure_time'] = exposure_time + self._params['gain'] = gain + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.update_vertical_bound(self._params['exposure_time'], self._params['gain'])) + + +class XimeaCameraGetImage(XimeaCameraParentCommand): + """Get image and save to file, display image, or both, check uniform in certain range. No filename = timestamped filename. No exposure or gain = use defaults.""" + + def __init__( + self, + receiver: XimeaCamera, + save_to_file: bool = True, + filename: str = None, + check_uniform: bool = True, + y_upper: int = 100, + y_length: int = 700, + x_upper: int = None, + x_length: int = None, + exposure_time: Optional[int] = None, + gain: Optional[float] = None, + show_pop_up: bool = False, + **kwargs): + super().__init__(receiver, **kwargs) + self._params['save_to_file'] = save_to_file + self._params['filename'] = filename + self._params['check_uniform'] = check_uniform + self._params['y_upper'] = y_upper + self._params['y_length'] = y_length + self._params['x_upper'] = x_upper + self._params['x_length'] = x_length + self._params['exposure_time'] = exposure_time + self._params['gain'] = gain + self._params['show_pop_up'] = show_pop_up + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.get_image(self._params['save_to_file'], self._params['filename'], + self._params['check_uniform'], self._params['y_upper'], + self._params['y_length'], self._params['x_upper'], + self._params['x_length'], + self._params['exposure_time'], self._params['gain'], + self._params['show_pop_up'])) + + +class XimeaCameraSetDefaultExposure(XimeaCameraParentCommand): + """Set the default exposure time to use if no exposure time is passed when getting image.""" + + def __init__(self, receiver: XimeaCamera, exposure_time: int, **kwargs): + super().__init__(receiver, **kwargs) + self._params['exposure_time'] = exposure_time + + def execute(self) -> None: + self._receiver.default_exposure_time = self._params['exposure_time'] + if self._receiver.default_exposure_time == self._params['exposure_time']: + self._result = CommandResult(True, "Default exposure time successfully set to " + str( + self._params['exposure_time'])) + else: + self._result = CommandResult(False, "Failed to set the default exposure time to " + str( + self._params['exposure_time']) + ". The default exposure time must be > 0.") + + +class XimeaCameraSetDefaultGain(XimeaCameraParentCommand): + """Set the default gain to use if no gain is passed when getting image.""" + + def __init__(self, receiver: XimeaCamera, gain: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params['gain'] = gain + + def execute(self) -> None: + self._receiver.default_gain = self._params['gain'] + if self._receiver.default_gain == self._params['gain']: + self._result = CommandResult(True, "Default gain successfully set to " + str(self._params['gain'])) + else: + # unsure what the upper limit is at the moment + self._result = CommandResult(False, "Failed to set the default gain to " + str( + self._params['gain']) + ". The default gain must be >= 0 and has an upper limit.") + + +class XimeaCameraUpdateWhiteBal(XimeaCameraParentCommand): + """Update the white balance coefficients using the current camera feed.""" + + def __init__(self, receiver: XimeaCamera, exposure_time: int = None, gain: Optional[float] = None, **kwargs): + super().__init__(receiver, **kwargs) + self._params['exposure_time'] = exposure_time + self._params['gain'] = gain + + def execute(self) -> None: + self._result = CommandResult( + *self._receiver.update_white_balance(self._params['exposure_time'], self._params['gain'])) + + +class XimeaCameraSetManualWhiteBal(XimeaCameraParentCommand): + """Set any of the red, green, and blue white balance coefficients manually.""" + + def __init__(self, receiver: XimeaCamera, wb_kr: Optional[float] = None, wb_kg: Optional[float] = None, + wb_kb: Optional[float] = None, **kwargs): + super().__init__(receiver, **kwargs) + self._params['wb_kr'] = wb_kr + self._params['wb_kg'] = wb_kg + self._params['wb_kb'] = wb_kb + + def execute(self) -> None: + self._result = CommandResult( + self._receiver.set_white_balance_manually(self._params['wb_kr'], self._params['wb_kg'], + self._params['wb_kb'])) + + +class XimeaCameraResetWhiteBal(XimeaCameraParentCommand): + """Reset the red, green, and blue white balance coefficients to defaults.""" + + def __init__(self, receiver: XimeaCamera, **kwargs): + super().__init__(receiver, **kwargs) + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.reset_white_balance_rgb_coeffs()) + diff --git a/aamp_app/commands/z812_commands.py b/aamp_app/commands/z812_commands.py new file mode 100644 index 0000000..9f88295 --- /dev/null +++ b/aamp_app/commands/z812_commands.py @@ -0,0 +1,76 @@ +from .command import Command, CommandResult +from devices.z812 import Z812 + + +class Z812ParentCommand(Command): + """Parent class for all Z812 commands.""" + + receiver_cls = Z812 + + def __init__(self, receiver: Z812, **kwargs): + super().__init__(receiver, **kwargs) + + +class Z812Connect(Z812ParentCommand): + """Open a serial port for the Z812 stage.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.start_serial()) + + +class Z812Initialize(Z812ParentCommand): + """Initialize the Z812 stage by homing it.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.initialize()) + + +class Z812Deinitialize(Z812ParentCommand): + """Deinitialize the Z812 stage.""" + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.deinitialize()) + + +class Z812EnableMotor(Z812ParentCommand): + """Enable the Z812 motor.""" + + def __init__(self, receiver: Z812, **kwargs): + super().__init__(receiver, **kwargs) + self._params["state"] = True + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_enabled_state(self._params["state"])) + + +class Z812DisableMotor(Z812ParentCommand): + """Disable the Z812 motor.""" + + def __init__(self, receiver: Z812, **kwargs): + super().__init__(receiver, **kwargs) + self._params["state"] = False + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.set_enabled_state(self._params["state"])) + + +class Z812MoveAbsolute(Z812ParentCommand): + """Move the Z812 stage to an absolute position in mm.""" + + def __init__(self, receiver: Z812, position: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["position"] = position + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_absolute(self._params["position"])) + + +class Z812MoveRelative(Z812ParentCommand): + """Move the Z812 stage by a relative distance in mm.""" + + def __init__(self, receiver: Z812, distance: float, **kwargs): + super().__init__(receiver, **kwargs) + self._params["distance"] = distance + + def execute(self) -> None: + self._result = CommandResult(*self._receiver.move_relative(self._params["distance"])) diff --git a/aamp_app/console_interceptor.py b/aamp_app/console_interceptor.py new file mode 100644 index 0000000..358a910 --- /dev/null +++ b/aamp_app/console_interceptor.py @@ -0,0 +1,59 @@ +import sys +import logging + +class ConsoleInterceptor: + def __init__(self): + self.stdout = sys.stdout + self.stderr = sys.stderr + self.intercepted_messages = [] + + def start_interception(self): + sys.stdout = self.InterceptedStream(self.stdout, self._handle_message) + sys.stderr = self.InterceptedStream(self.stderr, self._handle_message) + self._configure_logger() + + def stop_interception(self): + sys.stdout = self.stdout + sys.stderr = self.stderr + + def get_intercepted_messages(self): + return self.intercepted_messages + + def _handle_message(self, message): + # Store the intercepted message for later use + self.intercepted_messages.append(message) + + def _configure_logger(self): + logger = logging.getLogger() + logger.addHandler(self._get_logging_handler()) + logger.addFilter(self._get_logging_filter()) + + def _get_logging_handler(self): + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + return handler + + def _get_logging_filter(self): + return self.InterceptedMessageFilter(self.intercepted_messages) + + class InterceptedStream: + def __init__(self, stream, handler): + self.stream = stream + self.handler = handler + + def write(self, message): + self.handler(message) + self.stream.write(message) + + def flush(self): + self.stream.flush() + + class InterceptedMessageFilter(logging.Filter): + def __init__(self, intercepted_messages): + super().__init__() + self.intercepted_messages = intercepted_messages + + def filter(self, record): + message = record.getMessage() + return message not in self.intercepted_messages + diff --git a/aamp_app/db/gridfs_local.py b/aamp_app/db/gridfs_local.py new file mode 100644 index 0000000..cdaed04 --- /dev/null +++ b/aamp_app/db/gridfs_local.py @@ -0,0 +1,41 @@ +from mongodb_helper import MongoDBHelper +from gridfs import GridFS +from bson import ObjectId +import os + +def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + +load_env() + +mongo_uri = os.environ.get("MONGO_URI") +mongo_db_name = os.environ.get("MONGO_DB_NAME") + +mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, +) + +db = mongo.db + +fs = GridFS(db, collection="recipes") + +# Open and store the CSV file using GridFS +with open("data.csv", "rb") as file: + file_id = fs.put(file, filename="data.csv") + # create ObjectId(file_id) in document to point to csv + +# Retrieve the CSV file from GridFS +gridfs_file = fs.find_one({"_id": ObjectId("64c2d215255f257ee66d30e9")}) + +# Read the CSV data from the file +csv_data = gridfs_file.read() + +# Print the CSV data +print(csv_data) +# print(csv_data.decode()) diff --git a/aamp_app/db/validation/devices.py b/aamp_app/db/validation/devices.py new file mode 100644 index 0000000..a79c88c --- /dev/null +++ b/aamp_app/db/validation/devices.py @@ -0,0 +1,402 @@ +from mongodb_helper import MongoDBHelper +from pymongo.errors import CollectionInvalid +import os + +def init_collection(): + def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + load_env() + + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + + mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, + ) + + db = mongo.db + + device_dict = { + "device": { + "electrode_etl": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + "etl": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + "active_layer": { + "donor": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + "acceptor": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + }, + "htl": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + "electrode_htl": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + }, + "result": { + "jv_curve": ["object_id", "null"], + "t80": ["float", "null"], + }, + } + + device_dict_schema = { + "bsonType": "object", + "title": "Device Object Validation", + "required": ["device", "result"], + "properties": { + "device": { + "bsonType": "object", + "title": "Device Validation", + "required": ["electrode_etl", "active_layer", "electrode_htl"], + "properties": { + "electrode_etl": { + "bsonType": "object", + "title": "Electrode ETL Validation", + "required": ["material"], + "properties": { + "material": { + "bsonType": "object", + "title": "Material Validation", + "required": ["metadata"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Validation", + "required": [ + "solvent", + "concentration", + "printing_speed", + "printing_temperature", + "additive", + ], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'temperature' must be a double and is required", + }, + "additive": { + "bsonType": "object", + "title": "Additive Validation", + "required": ["molecule", "concentration"], + "properties": { + "molecule": { + "bsonType": "string", + "description": "'molecule' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "active_layer": { + "bsonType": "object", + "title": "Active Layer Validation", + "required": ["donor", "acceptor"], + "properties": { + "donor": { + "bsonType": "object", + "title": "Donor Validation", + "required": ["material"], + "properties": { + "material": { + "bsonType": "object", + "title": "Material Validation", + "required": ["metadata"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Validation", + "required": [ + "solvent", + "concentration", + "printing_speed", + "printing_temperature", + "additive", + ], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'temperature' must be a double and is required", + }, + "additive": { + "bsonType": "object", + "title": "Additive Validation", + "required": [ + "molecule", + "concentration", + ], + "properties": { + "molecule": { + "bsonType": "string", + "description": "'molecule' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "acceptor": { + "bsonType": "object", + "title": "Acceptor Validation", + "required": ["material"], + "properties": { + "material": { + "bsonType": "object", + "title": "Material Validation", + "required": ["metadata"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Validation", + "required": [ + "solvent", + "concentration", + "printing_speed", + "printing_temperature", + "additive", + ], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'temperature' must be a double and is required", + }, + "additive": { + "bsonType": "object", + "title": "Additive Validation", + "required": [ + "molecule", + "concentration", + ], + "properties": { + "molecule": { + "bsonType": "string", + "description": "'molecule' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "electrode_htl": { + "bsonType": "object", + "title": "Electrode HTL Validation", + "required": ["material"], + "properties": { + "material": { + "bsonType": "object", + "title": "Material Validation", + "required": ["metadata"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Validation", + "required": [ + "solvent", + "concentration", + "printing_speed", + "printing_temperature", + "additive", + ], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'temperature' must be a double and is required", + }, + "additive": { + "bsonType": "object", + "title": "Additive Validation", + "required": ["molecule", "concentration"], + "properties": { + "molecule": { + "bsonType": "string", + "description": "'molecule' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "result": { + "bsonType": "object", + "title": "Result Object Validation", + "required": ["jv_curve", "t80"], + "properties": { + "jv_curve": { + "bsonType": ["objectId", "null"], + "description": "'jv_curve' must be an objectId and is required", + }, + "t80": { + "bsonType": ["double", "null"], + "description": "'t80' must be a double and is required", + }, + }, + }, + }, + } + + + collection_name = "devices" + collection_options = {"validator": {"$jsonSchema": device_dict_schema}} + try: + db.create_collection(collection_name, **collection_options) + except CollectionInvalid: + print("devices collection already exists") diff --git a/aamp_app/db/validation/films.py b/aamp_app/db/validation/films.py new file mode 100644 index 0000000..f4b88da --- /dev/null +++ b/aamp_app/db/validation/films.py @@ -0,0 +1,94 @@ +from mongodb_helper import MongoDBHelper +from pymongo.errors import CollectionInvalid +import os + +def init_collection(): + def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + load_env() + + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + + mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, + ) + + db = mongo.db + + + film_dict = { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + }, + "result": { + "uv_vis": ["object_id", "null"], + "t80": ["float", "null"], + }, + } + + + film_dict_schema = { + "bsonType": "object", + "title": "Film Object Validation", + "required": ["metadata", "result"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Object Validation", + "required": ["solvent", "concentration", "printing_speed", "printing_temperature"], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'printing_speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'printing_temperature' must be a double and is required", + }, + }, + }, + "result": { + "bsonType": "object", + "title": "Result Object Validation", + "required": ["uv_vis", "t80"], + "properties": { + "uv_vis": { + "bsonType": ["objectId", "null"], + "description": "'uv_vis' must be an objectId and is required", + }, + "t80": { + "bsonType": ["double", "null"], + "description": "'t80' must be a double and is required", + }, + }, + }, + }, + } + + + + collection_name = "film" + collection_options = {"validator": {"$jsonSchema": film_dict_schema}} + try: + db.create_collection(collection_name, **collection_options) + except CollectionInvalid: + print("film collection already exists") \ No newline at end of file diff --git a/aamp_app/db/validation/recipes.py b/aamp_app/db/validation/recipes.py new file mode 100644 index 0000000..1161b42 --- /dev/null +++ b/aamp_app/db/validation/recipes.py @@ -0,0 +1,99 @@ +from mongodb_helper import MongoDBHelper +from pymongo.errors import CollectionInvalid +import os + +def init_collection(): + def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + load_env() + + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + + mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, + ) + + db = mongo.db + + + film_dict = { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + }, + "result": { + "uv_vis": ["object_id", "null"], + "t80": ["float", "null"], + }, + } + + + recipe_dict_schema = { + "bsonType": "object", + "title": "Recipe Object Validation", + "required": ["file_name", "recipe_dict", "dash_friendly", "executions"], + "properties": { + "file_name": { + "bsonType": "string", + "description": "'file_name' must be a string and is required" + }, + "recipe_dict": { + "bsonType": "object", + "title": "Recipe Dictionary Validation", + "required": ["devices", "commands", "execution_options"], + "properties": { + "devices": { + "bsonType": "array", + "description": "'devices' must be an array and is required" + }, + "commands": { + "bsonType": "array", + "description": "'commands' must be an array and is required" + }, + "execution_options": { + "bsonType": "object", + "required": ["output_files", "default_execution_record_name"], + "properties": { + "output_files": { + "bsonType": "array", + "description": "'output_files' must be an array" + }, + "default_execution_record_name": { + "bsonType": "string", + "description": "'default_execution_record_name' must be a string" + } + } + } + } + }, + "dash_friendly": { + "bsonType": "bool", + "description": "'dash_friendly' must be a boolean and is required" + }, + "executions": { + "bsonType": "array", + "description": "'executions' must be an array and is required" + } + } + } + + + + collection_name = "recipes" + # keep validationLevel as "moderate" to allow for existing documents to be validated + # keep validationAction as "warning" so we can update the schema as we learn more about devices + collection_options = {"validator": {"$jsonSchema": recipe_dict_schema}, "validationLevel": "moderate", "validationAction": "warn"} + try: + db.create_collection(collection_name, **collection_options) + except CollectionInvalid: + print("recipes collection already exists") \ No newline at end of file diff --git a/aamp_app/db/validation/solutions.py b/aamp_app/db/validation/solutions.py new file mode 100644 index 0000000..b862852 --- /dev/null +++ b/aamp_app/db/validation/solutions.py @@ -0,0 +1,85 @@ +from mongodb_helper import MongoDBHelper +from pymongo.errors import CollectionInvalid +import os + +def init_collection(): + def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + load_env() + + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + + mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, + ) + + db = mongo.db + + + solution_dict = { + "metadata": { + "solvent": "string", + "concentration": 0.3, + }, + "result": { + "uv_vis": None, + "t80": 0.3, + }, + } + + + + json_schema = { + "bsonType": "object", + "title": "Solution Object Validation", + "required": ["metadata", "result"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Object Validation", + "required": ["solvent", "concentration"], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + "result": { + "bsonType": "object", + "title": "Result Object Validation", + "required": ["uv_vis", "t80"], + "properties": { + "uv_vis": { + "bsonType": ["objectId", "null"], + "description": "'uv_vis' must be an objectId and is required", + }, + "t80": { + "bsonType": ["double", "null"], + "description": "'t80' must be a double and is required", + }, + }, + }, + }, + } + + + + collection_name = "solutions" + collection_options = {"validator": {"$jsonSchema": json_schema}} + try: + db.create_collection(collection_name, **collection_options) + except CollectionInvalid: + print("solutions collection already exists") \ No newline at end of file diff --git a/devices/.gitignore b/aamp_app/devices/.gitignore similarity index 100% rename from devices/.gitignore rename to aamp_app/devices/.gitignore diff --git a/devices/README.md b/aamp_app/devices/README.md similarity index 100% rename from devices/README.md rename to aamp_app/devices/README.md diff --git a/aamp_app/devices/__init__.py b/aamp_app/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aamp_app/devices/apis.py b/aamp_app/devices/apis.py new file mode 100644 index 0000000..b9c1f3f --- /dev/null +++ b/aamp_app/devices/apis.py @@ -0,0 +1,1082 @@ +import csv +import json +import os +import time +from decimal import Decimal, getcontext +from typing import Optional, Tuple + +import cv2 +import numpy as np + +from .device import SerialDevice, check_initialized, check_serial +from .ximea_camera import XimeaCamera + + +class APIS(SerialDevice): + """ + AAMP wrapper for the APIS Arduino controller. + + Python-side behavior is based on the public code in + https://github.com/changhwang/APIS . + Firmware and hardware details should be referenced from + https://github.com/polyprintillinois/APIS . + """ + + SERIAL_BAUDRATE = 9600 + SERIAL_TIMEOUT_S = 0.5 + CONNECTION_WAIT_S = 2.0 + COMMAND_DELAY_S = 0.05 + SETTLING_TIME_S = 1.5 + MAX_RETRIES = 3 + + SERVO_MIN_ANGLE = 0 + SERVO_MAX_ANGLE = 180 + + POLARIZER_STAGE_TO_SERVO_RATIO = 1.059 + SAMPLE_STAGE_TO_SERVO_RATIO = 1.059 + POLARIZER_STAGE_DIRECTION = 1 + SAMPLE_STAGE_DIRECTION = 1 + POLARIZER_SERVO_ZERO_DEG = 0 + SAMPLE_SERVO_ZERO_DEG = 0 + POLARIZER_STAGE_MIN_ANGLE = 0.0 + SAMPLE_STAGE_MIN_ANGLE = 0.0 + POLARIZER_STAGE_MAX_ANGLE = (SERVO_MAX_ANGLE - POLARIZER_SERVO_ZERO_DEG) / POLARIZER_STAGE_TO_SERVO_RATIO + SAMPLE_STAGE_MAX_ANGLE = (SERVO_MAX_ANGLE - SAMPLE_SERVO_ZERO_DEG) / SAMPLE_STAGE_TO_SERVO_RATIO + + CMD_POLARIZER = 10 + CMD_SAMPLE = 11 + CMD_HOME = 96 + CMD_RESET = 98 + CMD_ESTOP = 99 + + STATE_LATCHED = "LATCHED" + STATE_ARMED = "ARMED" + + XIMEA_DEFAULT_PPL_EXPOSURE_US = 18000 + XIMEA_DEFAULT_XPL_EXPOSURE_US = 400000 + XIMEA_DEFAULT_POLARIZER_CALIBRATION_EXPOSURE_US = 200000 + XIMEA_DEFAULT_NORMAL_EXPOSURE_US = XIMEA_DEFAULT_PPL_EXPOSURE_US + XIMEA_DEFAULT_CROSSPOL_EXPOSURE_US = XIMEA_DEFAULT_XPL_EXPOSURE_US + + POLARIZER_PPL_ANGLE_DEG = 30 + POLARIZER_XPL_ANGLE_DEG = 120 + POLARIZER_CALIBRATION_FINE_RADIUS_DEG = 10 + POLARIZER_CALIBRATION_FINE_STEP_DEG = 1 + + CAMERA_PREVIEW_WB_GAINS = (1.40, 1.00, 1.20) + CAMERA_PREVIEW_GAMMA = 1.0 / 2.2 + + def __init__( + self, + name: str, + port: str, + baudrate: int = SERIAL_BAUDRATE, + timeout: Optional[float] = SERIAL_TIMEOUT_S, + connection_wait_s: float = CONNECTION_WAIT_S, + settling_time_s: float = SETTLING_TIME_S, + command_delay_s: float = COMMAND_DELAY_S, + max_retries: int = MAX_RETRIES, + polarizer_stage_to_servo_ratio: float = POLARIZER_STAGE_TO_SERVO_RATIO, + sample_stage_to_servo_ratio: float = SAMPLE_STAGE_TO_SERVO_RATIO, + polarizer_stage_direction: int = POLARIZER_STAGE_DIRECTION, + sample_stage_direction: int = SAMPLE_STAGE_DIRECTION, + polarizer_servo_zero_deg: int = POLARIZER_SERVO_ZERO_DEG, + sample_servo_zero_deg: int = SAMPLE_SERVO_ZERO_DEG, + use_camera: bool = True, + camera_save_directory: str = "data/imaging/", + camera_bayer_pattern: str = XimeaCamera.DEFAULT_RAW_BAYER_PATTERN, + camera_raw_max_value: float = XimeaCamera.DEFAULT_RAW_MAX_VALUE, + polarizer_baseline_path: Optional[str] = None, + ): + super().__init__(name, port, baudrate, timeout) + self._connection_wait_s = connection_wait_s + self._settling_time_s = settling_time_s + self._command_delay_s = command_delay_s + self._max_retries = max_retries + self._polarizer_stage_to_servo_ratio = polarizer_stage_to_servo_ratio + self._sample_stage_to_servo_ratio = sample_stage_to_servo_ratio + self._polarizer_stage_direction = polarizer_stage_direction + self._sample_stage_direction = sample_stage_direction + self._polarizer_servo_zero_deg = polarizer_servo_zero_deg + self._sample_servo_zero_deg = sample_servo_zero_deg + self._use_camera = use_camera + self._camera_save_directory = camera_save_directory + self._camera_bayer_pattern = camera_bayer_pattern + self._camera_raw_max_value = camera_raw_max_value + self._polarizer_baseline_path = polarizer_baseline_path + + self._state = self.STATE_LATCHED + self._is_connected = False + self._polarizer_angle = 0.0 + self._sample_angle = 0.0 + self._polarizer_xpl_angle_deg = self.POLARIZER_XPL_ANGLE_DEG + self._polarizer_ppl_angle_deg = self.derive_ppl_angle_from_xpl(self._polarizer_xpl_angle_deg) + self.load_polarizer_baseline() + self.last_calibration_info = {} + self.last_run_info = {} + self.camera = XimeaCamera(name=f"{name}_camera") if use_camera else None + if self.camera is not None: + self.camera.save_directory = camera_save_directory + + def get_init_args(self) -> dict: + return { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "connection_wait_s": self._connection_wait_s, + "settling_time_s": self._settling_time_s, + "command_delay_s": self._command_delay_s, + "max_retries": self._max_retries, + "polarizer_stage_to_servo_ratio": self._polarizer_stage_to_servo_ratio, + "sample_stage_to_servo_ratio": self._sample_stage_to_servo_ratio, + "polarizer_stage_direction": self._polarizer_stage_direction, + "sample_stage_direction": self._sample_stage_direction, + "polarizer_servo_zero_deg": self._polarizer_servo_zero_deg, + "sample_servo_zero_deg": self._sample_servo_zero_deg, + "use_camera": self._use_camera, + "camera_save_directory": self._camera_save_directory, + "camera_bayer_pattern": self._camera_bayer_pattern, + "camera_raw_max_value": self._camera_raw_max_value, + "polarizer_baseline_path": self._polarizer_baseline_path, + } + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._connection_wait_s = args_dict["connection_wait_s"] + self._settling_time_s = args_dict["settling_time_s"] + self._command_delay_s = args_dict["command_delay_s"] + self._max_retries = args_dict["max_retries"] + self._polarizer_stage_to_servo_ratio = args_dict["polarizer_stage_to_servo_ratio"] + self._sample_stage_to_servo_ratio = args_dict["sample_stage_to_servo_ratio"] + self._polarizer_stage_direction = args_dict["polarizer_stage_direction"] + self._sample_stage_direction = args_dict["sample_stage_direction"] + self._polarizer_servo_zero_deg = args_dict["polarizer_servo_zero_deg"] + self._sample_servo_zero_deg = args_dict["sample_servo_zero_deg"] + self._use_camera = args_dict["use_camera"] + self._camera_save_directory = args_dict["camera_save_directory"] + self._camera_bayer_pattern = args_dict["camera_bayer_pattern"] + self._camera_raw_max_value = args_dict["camera_raw_max_value"] + self._polarizer_baseline_path = args_dict.get("polarizer_baseline_path") + self.load_polarizer_baseline() + self.camera = XimeaCamera(name=f"{self._name}_camera") if self._use_camera else None + if self.camera is not None: + self.camera.save_directory = self._camera_save_directory + + @property + def state(self) -> str: + return self._state + + @property + def polarizer_angle(self) -> float: + return self._polarizer_angle + + @property + def sample_angle(self) -> float: + return self._sample_angle + + @property + def xpl_angle(self) -> float: + return self._polarizer_xpl_angle_deg + + @property + def ppl_angle(self) -> float: + return self._polarizer_ppl_angle_deg + + def connect(self) -> Tuple[bool, str]: + was_successful, response = self.start_serial(delay=2.0) + if not was_successful: + self._is_connected = False + return (was_successful, response) + + self.ser.reset_input_buffer() + self.ser.reset_output_buffer() + + start_t = time.time() + while (time.time() - start_t) < self._connection_wait_s: + if self.ser.in_waiting: + line = self.ser.readline().decode("utf-8", errors="ignore").strip() + if line == "READY": + self._is_connected = True + self._state = self.STATE_LATCHED + return (True, "APIS connected. Arduino boot message READY received; state is LATCHED.") + time.sleep(0.1) + + was_successful, response = self._send_raw_command(self.CMD_RESET, 0) + if was_successful and "OK RESET" in response: + self._is_connected = True + self._state = self.STATE_ARMED + return (True, "APIS connected without READY handshake; force RESET succeeded and system is ARMED.") + + self._is_connected = False + return (False, "APIS connection failed. No READY received and RESET fallback failed: " + response) + + @check_serial + def initialize(self) -> Tuple[bool, str]: + if not self._is_connected: + return (False, "APIS is not connected. Run connect first.") + was_successful, response = self.reset() + if not was_successful: + self._is_initialized = False + return (was_successful, response) + if self.camera is not None: + was_successful, response = self.camera.initialize(set_defaults=True) + if not was_successful: + self._is_initialized = False + return (was_successful, response) + self.camera.save_directory = self._camera_save_directory + self._is_initialized = True + return ( + True, + "Successfully initialized APIS and armed the controller. " + + "For firmware and hardware details, see https://github.com/polyprintillinois/APIS .", + ) + + def deinitialize(self) -> Tuple[bool, str]: + if self.camera is not None and self.camera.is_initialized: + self.camera.deinitialize(reset_init_flag=True) + if self.ser.is_open: + self.emergency_stop() + self.ser.close() + self._is_initialized = False + self._is_connected = False + self._state = self.STATE_LATCHED + return (True, "Successfully deinitialized APIS, sent ESTOP, and closed the serial port.") + + def _format_command(self, pp: int, aaa: int) -> str: + return f"{int(pp):02d}{int(aaa):03d}" + + def _send_raw_command(self, pp: int, aaa: int) -> Tuple[bool, str]: + if not self.ser or not self.ser.is_open: + return (False, "ERR NO_CONNECTION") + + cmd_str = self._format_command(pp, aaa) + attempt = 0 + while attempt < self._max_retries: + try: + self.ser.reset_input_buffer() + self.ser.write((cmd_str + "\n").encode("utf-8")) + self.ser.flush() + response = self.ser.readline().decode("utf-8", errors="ignore").strip() + if not response: + attempt += 1 + time.sleep(0.2 * (2 ** max(0, attempt - 1))) + continue + time.sleep(self._command_delay_s) + return (True, response) + except Exception as exc: + return (False, "ERR EXCEPTION " + str(exc)) + + return (False, "ERR TIMEOUT") + + def _send_command(self, pp: int, aaa: int) -> Tuple[bool, str]: + was_successful, response = self._send_raw_command(pp, aaa) + if not was_successful: + return (False, response) + if not response.startswith("OK"): + return (False, response) + return (True, response) + + @check_initialized + @check_serial + def emergency_stop(self) -> Tuple[bool, str]: + was_successful, response = self._send_command(self.CMD_ESTOP, 0) + if was_successful: + self._state = self.STATE_LATCHED + return (was_successful, response) + + @check_serial + def reset(self) -> Tuple[bool, str]: + was_successful, response = self._send_command(self.CMD_RESET, 0) + if was_successful: + self._state = self.STATE_ARMED + return (was_successful, response) + + def _stage_to_servo_angle( + self, + stage_angle: float, + ratio: float, + direction: int, + zero_offset: int, + axis_name: str, + ) -> Tuple[bool, int]: + servo_angle = zero_offset + (direction * stage_angle * ratio) + servo_cmd = int(round(servo_angle)) + if servo_cmd < self.SERVO_MIN_ANGLE or servo_cmd > self.SERVO_MAX_ANGLE: + return ( + False, + f"{axis_name} angle {stage_angle:.2f} deg maps to servo command {servo_cmd}, outside {self.SERVO_MIN_ANGLE}-{self.SERVO_MAX_ANGLE}.", + ) + return (True, servo_cmd) + + @check_initialized + @check_serial + def home(self) -> Tuple[bool, str]: + was_successful, response = self.rotate_polarizer(0.0) + if not was_successful: + return (was_successful, response) + return self.rotate_sample(0.0) + + @check_initialized + @check_serial + def rotate_polarizer(self, angle_deg: float) -> Tuple[bool, str]: + if angle_deg < self.POLARIZER_STAGE_MIN_ANGLE or angle_deg > self.POLARIZER_STAGE_MAX_ANGLE: + return ( + False, + f"Polarizer angle {angle_deg:.2f} deg is outside the allowed stage range 0-{self.POLARIZER_STAGE_MAX_ANGLE:.2f} deg.", + ) + was_successful, servo_cmd = self._stage_to_servo_angle( + angle_deg, + self._polarizer_stage_to_servo_ratio, + self._polarizer_stage_direction, + self._polarizer_servo_zero_deg, + "Polarizer", + ) + if not was_successful: + return (False, servo_cmd) + was_successful, response = self._send_command(self.CMD_POLARIZER, servo_cmd) + if was_successful: + self._polarizer_angle = angle_deg + time.sleep(self._settling_time_s) + return (was_successful, response) + + @check_initialized + @check_serial + def rotate_sample(self, angle_deg: float) -> Tuple[bool, str]: + if angle_deg < self.SAMPLE_STAGE_MIN_ANGLE or angle_deg > self.SAMPLE_STAGE_MAX_ANGLE: + return ( + False, + f"Sample angle {angle_deg:.2f} deg is outside the allowed stage range 0-{self.SAMPLE_STAGE_MAX_ANGLE:.2f} deg.", + ) + was_successful, servo_cmd = self._stage_to_servo_angle( + angle_deg, + self._sample_stage_to_servo_ratio, + self._sample_stage_direction, + self._sample_servo_zero_deg, + "Sample", + ) + if not was_successful: + return (False, servo_cmd) + was_successful, response = self._send_command(self.CMD_SAMPLE, servo_cmd) + if was_successful: + self._sample_angle = angle_deg + time.sleep(self._settling_time_s) + return (was_successful, response) + + @check_initialized + @check_serial + def get_state(self) -> Tuple[bool, str]: + return (True, self._state) + + @check_initialized + def get_polarizer_angle(self) -> Tuple[bool, float]: + return (True, self._polarizer_angle) + + @check_initialized + def get_sample_angle(self) -> Tuple[bool, float]: + return (True, self._sample_angle) + + @staticmethod + def choose_orthogonal_polarizer_angle( + xpl_angle: float, + min_angle: float = POLARIZER_STAGE_MIN_ANGLE, + max_angle: float = POLARIZER_STAGE_MAX_ANGLE, + prefer_positive: bool = True, + ) -> Tuple[int, int]: + xpl_angle = int(round(xpl_angle)) + min_angle = int(min_angle) + max_angle = int(max_angle) + offsets = (90, -90) if prefer_positive else (-90, 90) + for offset in offsets: + candidate = xpl_angle + offset + if min_angle <= candidate <= max_angle: + return (candidate, offset) + raise ValueError( + f"No valid PPL angle for XPL={xpl_angle} deg within range {min_angle}-{max_angle}." + ) + + @classmethod + def derive_ppl_angle_from_xpl(cls, xpl_angle: float, prefer_positive: bool = True) -> int: + ppl_angle, _ = cls.choose_orthogonal_polarizer_angle( + xpl_angle, + cls.POLARIZER_STAGE_MIN_ANGLE, + cls.POLARIZER_STAGE_MAX_ANGLE, + prefer_positive=prefer_positive, + ) + return ppl_angle + + @staticmethod + def build_angle_window( + center_angle: float, + radius: int, + min_angle: float, + max_angle: float, + step: int = 1, + ) -> list: + if step <= 0: + raise ValueError("Angle window step must be positive.") + start = max(int(min_angle), int(round(center_angle)) - int(radius)) + end = min(int(max_angle), int(round(center_angle)) + int(radius)) + return list(range(start, end + 1, int(step))) + + @staticmethod + def parse_angle_spec(angle_spec, min_angle: float, max_angle: float) -> Tuple[list, str]: + if angle_spec is None: + return ([], "Angles are empty.") + if isinstance(angle_spec, str): + raw = angle_spec.strip() + if not raw: + return ([], "Angles are empty.") + parts = [p for p in raw.replace(",", " ").split() if p] + else: + parts = list(angle_spec) + + angles = [] + for part in parts: + token = str(part).strip() + if ":" in token: + nums = token.split(":") + if len(nums) not in (2, 3): + return ([], f"Invalid range token: '{token}'") + try: + start = int(nums[0]) + end = int(nums[1]) + step = int(nums[2]) if len(nums) == 3 else 1 + except ValueError: + return ([], f"Invalid range token: '{token}'") + if step == 0: + return ([], f"Step cannot be 0 in '{token}'") + if start < min_angle or start > max_angle or end < min_angle or end > max_angle: + return ([], f"Range out of bounds ({min_angle}-{max_angle}): '{token}'") + stop = end + 1 if step > 0 else end - 1 + angles.extend(list(range(start, stop, step))) + else: + try: + value = int(float(token)) + except ValueError: + return ([], f"Invalid angle token: '{token}'") + if value < min_angle or value > max_angle: + return ([], f"Angle out of bounds ({min_angle}-{max_angle}): '{token}'") + angles.append(value) + + if not angles: + return ([], "No valid angles found.") + return (angles, "") + + def _get_polarizer_baseline_path(self) -> str: + return self._polarizer_baseline_path or os.path.join("data", "polarizer_baseline.json") + + def load_polarizer_baseline(self) -> Tuple[bool, str]: + baseline_path = self._get_polarizer_baseline_path() + if not os.path.isfile(baseline_path): + return ( + True, + f"Using default APIS polarizer baseline: XPL={self._polarizer_xpl_angle_deg} deg, " + f"PPL={self._polarizer_ppl_angle_deg} deg.", + ) + + try: + with open(baseline_path, "r", encoding="utf-8") as file_obj: + baseline = json.load(file_obj) + xpl_angle_deg = baseline["xpl_angle_deg"] + except Exception as exc: + return (False, f"Failed to load APIS polarizer baseline from {baseline_path}: {exc}") + + was_successful, response = self.set_polarizer_baseline( + xpl_angle_deg, + persist=False, + source="saved baseline", + ) + if not was_successful: + return (False, response) + return (True, f"Loaded APIS polarizer baseline from {baseline_path}. {response}") + + def save_polarizer_baseline(self, source: str = "manual") -> Tuple[bool, str]: + baseline_path = self._get_polarizer_baseline_path() + payload = { + "saved_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "source": source, + "xpl_angle_deg": int(self._polarizer_xpl_angle_deg), + "ppl_angle_deg": int(self._polarizer_ppl_angle_deg), + "ppl_offset_deg": int(self._polarizer_ppl_angle_deg - self._polarizer_xpl_angle_deg), + } + try: + os.makedirs(os.path.dirname(baseline_path) or ".", exist_ok=True) + with open(baseline_path, "w", encoding="utf-8") as file_obj: + json.dump(payload, file_obj, indent=2) + except Exception as exc: + return (False, f"Failed to save APIS polarizer baseline to {baseline_path}: {exc}") + return (True, f"Saved APIS polarizer baseline to {baseline_path}.") + + def set_polarizer_baseline( + self, + xpl_angle_deg: float, + persist: bool = False, + source: str = "manual", + ) -> Tuple[bool, str]: + if xpl_angle_deg < self.POLARIZER_STAGE_MIN_ANGLE or xpl_angle_deg > self.POLARIZER_STAGE_MAX_ANGLE: + return ( + False, + f"XPL angle {xpl_angle_deg:.2f} deg is outside the allowed polarizer range " + f"{self.POLARIZER_STAGE_MIN_ANGLE}-{self.POLARIZER_STAGE_MAX_ANGLE:.2f} deg.", + ) + try: + ppl_angle, offset = self.choose_orthogonal_polarizer_angle( + xpl_angle_deg, + self.POLARIZER_STAGE_MIN_ANGLE, + self.POLARIZER_STAGE_MAX_ANGLE, + ) + except ValueError as exc: + return (False, str(exc)) + + self._polarizer_xpl_angle_deg = int(round(xpl_angle_deg)) + self._polarizer_ppl_angle_deg = int(ppl_angle) + persist_msg = "" + if persist: + was_successful, persist_msg = self.save_polarizer_baseline(source=source) + if not was_successful: + return (False, persist_msg) + persist_msg = " " + persist_msg + return ( + True, + f"APIS polarizer baseline set: XPL={self._polarizer_xpl_angle_deg} deg, " + f"PPL=XPL{offset:+d} -> {self._polarizer_ppl_angle_deg} deg.{persist_msg}", + ) + + @check_initialized + @check_serial + def rotate_xpl(self) -> Tuple[bool, str]: + return self.rotate_polarizer(self._polarizer_xpl_angle_deg) + + @check_initialized + @check_serial + def rotate_ppl(self) -> Tuple[bool, str]: + return self.rotate_polarizer(self._polarizer_ppl_angle_deg) + + @staticmethod + def format_speed(speed: float) -> str: + getcontext().prec = 50 + speed_dec = Decimal(str(speed)) + if speed_dec == 0: + return "0" + if speed_dec >= 1 and speed_dec == speed_dec.to_integral_value(): + return f"{int(speed_dec)}" + integer_part = "".join(map(str, speed_dec.as_tuple().digits)) + exponent = speed_dec.as_tuple().exponent + return f"{integer_part}E{exponent}" + + @classmethod + def build_sample_basename( + cls, + round_num, + sample_num, + polymer: str, + solvent: str, + concentration: int, + speed: float, + temperature: int, + gap: int, + volume: int, + ) -> str: + speed_str = cls.format_speed(speed) + return ( + f"R{round_num}S{sample_num}_{polymer}_{solvent}_{concentration}mgml_" + f"{speed_str}mms_{temperature}C_{gap}um_{volume}ul" + ) + + @staticmethod + def resolve_mode_directory(root_save_dir: str, polymer: str, mode: str) -> str: + mode_dir = os.path.join(root_save_dir, polymer, mode) + os.makedirs(mode_dir, exist_ok=True) + return mode_dir + + @staticmethod + def build_mode_filename(base_sample_name: str, mode: str, angle_deg: float) -> str: + return f"{base_sample_name}_{mode}_{int(angle_deg)}deg" + + @staticmethod + def _append_calibration_log(log_path: str, row: dict) -> None: + os.makedirs(os.path.dirname(log_path), exist_ok=True) + fieldnames = [ + "timestamp", + "mode", + "exposure_us", + "gain", + "polarizer_angle", + "sample_angle", + "signal_mean", + "filepath", + "arduino_response", + "attempt_count", + ] + file_exists = os.path.isfile(log_path) + with open(log_path, mode="a", newline="", encoding="utf-8") as file_obj: + writer = csv.DictWriter(file_obj, fieldnames=fieldnames) + if not file_exists: + writer.writeheader() + writer.writerow({key: row.get(key, "") for key in fieldnames}) + + @staticmethod + def _write_json(filepath: str, data: dict) -> None: + os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True) + with open(filepath, "w", encoding="utf-8") as file_obj: + json.dump(data, file_obj, indent=2) + + def _capture_imaging_phase( + self, + *, + mode_name: str, + display_name: str, + sample_angles, + exposure_time: int, + polarizer_angle: float, + output_dir: str, + sample_id: str, + log_path: str, + metadata: dict, + gain: float, + ) -> Tuple[bool, str]: + os.makedirs(output_dir, exist_ok=True) + + was_successful, response = self.rotate_polarizer(polarizer_angle) + if not was_successful: + return (False, f"Failed to move Polarizer to {polarizer_angle}: {response}") + + for angle in sample_angles: + was_successful, response = self.rotate_sample(angle) + if not was_successful and mode_name == "xpl": + was_successful, response = self.rotate_sample(angle) + if not was_successful: + return (False, f"Failed to move Sample to {angle}: {response}") + + filename = f"{sample_id}_{mode_name}_{int(angle):03d}" + image_path = os.path.join(output_dir, filename + ".tif") + was_successful, response = self.camera.capture_raw16( + save_to_file=True, + filename=filename, + directory=output_dir, + exposure_time=exposure_time, + gain=gain, + ) + if not was_successful: + return (False, response) + + timestamp = time.strftime("%Y-%m-%dT%H:%M:%S") + self._append_calibration_log( + log_path, + { + "timestamp": timestamp, + "mode": mode_name, + "exposure_us": exposure_time, + "gain": gain, + "polarizer_angle": polarizer_angle, + "sample_angle": angle, + "filepath": image_path, + "arduino_response": "OK", + "attempt_count": 1, + }, + ) + metadata["images"].append( + { + "filename": os.path.basename(image_path), + "mode": mode_name, + "display_name": display_name, + "polarizer_angle_deg": polarizer_angle, + "sample_angle_deg": angle, + "exposure_us": exposure_time, + "filepath": image_path, + "timestamp": timestamp, + } + ) + + return (True, f"{display_name} capture complete.") + + @check_initialized + @check_serial + def run_imaging_sequence( + self, + sample_id: str, + directory: Optional[str] = None, + sample_angles=None, + xpl_exposure_time: int = XIMEA_DEFAULT_XPL_EXPOSURE_US, + ppl_exposure_time: int = XIMEA_DEFAULT_PPL_EXPOSURE_US, + do_xpl: bool = True, + do_ppl: bool = True, + xpl_polarizer_angle: Optional[float] = None, + ppl_polarizer_angle: Optional[float] = None, + gain: float = 0.0, + ) -> Tuple[bool, str]: + if self.camera is None: + return (False, "APIS camera support is disabled for this device instance.") + if not (do_xpl or do_ppl): + return (False, "No modes enabled for APIS imaging sequence.") + if sample_angles is None: + sample_angles = [90, 60, 45, 30, 0] + sample_angles, angle_error = self.parse_angle_spec( + sample_angles, + self.SAMPLE_STAGE_MIN_ANGLE, + self.SAMPLE_STAGE_MAX_ANGLE, + ) + if not sample_angles: + return (False, angle_error) + + xpl_angle = self._polarizer_xpl_angle_deg if xpl_polarizer_angle is None else xpl_polarizer_angle + ppl_angle = self._polarizer_ppl_angle_deg if ppl_polarizer_angle is None else ppl_polarizer_angle + save_root = directory or self._camera_save_directory + sample_root = os.path.join(save_root, sample_id) + log_path = os.path.join(sample_root, f"{sample_id}_log.csv") + metadata_path = os.path.join(sample_root, f"{sample_id}_metadata.json") + metadata = { + "sample_id": sample_id, + "sequence_started_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "sequence_completed": False, + "error_message": "", + "save_root": save_root, + "mode_angles_deg": { + "xpl": xpl_angle if do_xpl else None, + "ppl": ppl_angle if do_ppl else None, + }, + "images": [], + } + run_info = { + "saved_frame_count": 0, + "sequence_completed": False, + "body_error": "", + "metadata_path": metadata_path, + } + + try: + was_successful, response = self.reset() + if not was_successful: + raise RuntimeError(response) + + if do_xpl: + was_successful, response = self._capture_imaging_phase( + mode_name="xpl", + display_name="XPL", + sample_angles=sample_angles, + exposure_time=xpl_exposure_time, + polarizer_angle=xpl_angle, + output_dir=os.path.join(sample_root, "xpl"), + sample_id=sample_id, + log_path=log_path, + metadata=metadata, + gain=gain, + ) + if not was_successful: + raise RuntimeError(response) + + if do_ppl: + was_successful, response = self._capture_imaging_phase( + mode_name="ppl", + display_name="PPL", + sample_angles=sample_angles, + exposure_time=ppl_exposure_time, + polarizer_angle=ppl_angle, + output_dir=os.path.join(sample_root, "ppl"), + sample_id=sample_id, + log_path=log_path, + metadata=metadata, + gain=gain, + ) + if not was_successful: + raise RuntimeError(response) + + self.home() + metadata["sequence_completed"] = True + run_info["sequence_completed"] = True + except Exception as exc: + metadata["error_message"] = str(exc) + run_info["body_error"] = str(exc) + try: + self.emergency_stop() + except Exception: + pass + finally: + run_info["saved_frame_count"] = len(metadata["images"]) + metadata["saved_frame_count"] = len(metadata["images"]) + try: + self._write_json(metadata_path, metadata) + except Exception as exc: + run_info["body_error"] = run_info["body_error"] or str(exc) + metadata["error_message"] = metadata["error_message"] or str(exc) + self.last_run_info = run_info + + if run_info["body_error"]: + return (False, "APIS imaging sequence failed: " + run_info["body_error"]) + return ( + True, + f"APIS imaging sequence complete. Saved {run_info['saved_frame_count']} frame(s). " + f"Metadata: {metadata_path}", + ) + + def _capture_polarizer_scan( + self, + *, + sample_id: str, + exposure_time: int, + gain: float, + sample_angle: float, + polarizer_angles, + scan_phase: str, + output_dir: str, + log_path: str, + ) -> Tuple[bool, object]: + results = [] + for angle in polarizer_angles: + was_successful, response = self.rotate_polarizer(float(angle)) + if not was_successful: + return (False, response) + + filename = f"{sample_id}_polarizer_cal_{scan_phase}_{int(angle):03d}" + image_path = os.path.join(output_dir, filename + ".tif") + was_successful, response = self.camera.capture_raw16( + save_to_file=True, + filename=filename, + directory=output_dir, + exposure_time=exposure_time, + gain=gain, + ) + if not was_successful: + return (False, response) + + image_data = cv2.imread(image_path, cv2.IMREAD_UNCHANGED) + if image_data is None: + return (False, f"Failed to read calibration image: {image_path}") + signal_mean = float(np.asarray(image_data, dtype=np.float32).mean()) + row = { + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "mode": "polarizer_calibration", + "exposure_us": exposure_time, + "gain": gain, + "polarizer_angle": angle, + "sample_angle": sample_angle, + "signal_mean": signal_mean, + "filepath": image_path, + "arduino_response": scan_phase.upper(), + "attempt_count": 1, + } + self._append_calibration_log(log_path, row) + results.append( + { + "filename": os.path.basename(image_path), + "scan_phase": scan_phase, + "polarizer_angle_deg": int(angle), + "sample_angle_deg": sample_angle, + "exposure_us": exposure_time, + "mean_signal": signal_mean, + "filepath": image_path, + "timestamp": row["timestamp"], + } + ) + return (True, results) + + @check_initialized + @check_serial + def run_polarizer_calibration( + self, + sample_id: str = "polarizer_calibration", + directory: Optional[str] = None, + exposure_time: int = XIMEA_DEFAULT_POLARIZER_CALIBRATION_EXPOSURE_US, + polarizer_angles=None, + sample_angle: float = 0.0, + fine_radius_deg: int = POLARIZER_CALIBRATION_FINE_RADIUS_DEG, + fine_step_deg: int = POLARIZER_CALIBRATION_FINE_STEP_DEG, + gain: float = 0.0, + ) -> Tuple[bool, str]: + if self.camera is None: + return (False, "APIS camera support is disabled for this device instance.") + + if polarizer_angles is None: + polarizer_angles = f"{int(self.POLARIZER_STAGE_MIN_ANGLE)}:{int(self.POLARIZER_STAGE_MAX_ANGLE)}:5" + polarizer_angles, angle_error = self.parse_angle_spec( + polarizer_angles, + self.POLARIZER_STAGE_MIN_ANGLE, + self.POLARIZER_STAGE_MAX_ANGLE, + ) + if not polarizer_angles: + return (False, angle_error) + + sample_root = os.path.join(directory or self._camera_save_directory, sample_id) + calibration_dir = os.path.join(sample_root, "polarizer_calibration") + log_path = os.path.join(sample_root, f"{sample_id}_log.csv") + metadata_path = os.path.join(sample_root, f"{sample_id}_polarizer_calibration.json") + os.makedirs(calibration_dir, exist_ok=True) + + metadata = { + "sample_id": sample_id, + "capture_type": "polarizer_calibration", + "calibration_sample_angle_deg": sample_angle, + "coarse_scan_angles_deg": list(polarizer_angles), + "fine_radius_deg": fine_radius_deg, + "fine_step_deg": fine_step_deg, + "exposure_us": exposure_time, + "gain": gain, + "images": [], + "scan_results": [], + "sequence_completed": False, + "error_message": "", + } + + try: + was_successful, response = self.reset() + if not was_successful: + raise RuntimeError(response) + + was_successful, response = self.rotate_sample(sample_angle) + if not was_successful: + raise RuntimeError(response) + + was_successful, coarse_results = self._capture_polarizer_scan( + sample_id=sample_id, + exposure_time=exposure_time, + gain=gain, + sample_angle=sample_angle, + polarizer_angles=polarizer_angles, + scan_phase="coarse", + output_dir=calibration_dir, + log_path=log_path, + ) + if not was_successful: + raise RuntimeError(coarse_results) + + metadata["scan_results"].extend(coarse_results) + metadata["images"].extend(coarse_results) + coarse_xpl = min( + coarse_results, + key=lambda item: (item["mean_signal"], item["polarizer_angle_deg"]), + ) + fine_angles = self.build_angle_window( + coarse_xpl["polarizer_angle_deg"], + fine_radius_deg, + self.POLARIZER_STAGE_MIN_ANGLE, + self.POLARIZER_STAGE_MAX_ANGLE, + fine_step_deg, + ) + metadata["coarse_darkest_angle_deg"] = coarse_xpl["polarizer_angle_deg"] + metadata["coarse_darkest_signal_mean"] = coarse_xpl["mean_signal"] + metadata["fine_scan_angles_deg"] = fine_angles + + was_successful, fine_results = self._capture_polarizer_scan( + sample_id=sample_id, + exposure_time=exposure_time, + gain=gain, + sample_angle=sample_angle, + polarizer_angles=fine_angles, + scan_phase="fine", + output_dir=calibration_dir, + log_path=log_path, + ) + if not was_successful: + raise RuntimeError(fine_results) + + metadata["scan_results"].extend(fine_results) + metadata["images"].extend(fine_results) + xpl_candidate = min( + fine_results, + key=lambda item: (item["mean_signal"], item["polarizer_angle_deg"]), + ) + was_successful, response = self.set_polarizer_baseline( + xpl_candidate["polarizer_angle_deg"], + persist=True, + source="calibration baseline", + ) + if not was_successful: + raise RuntimeError(response) + + _, ppl_offset = self.choose_orthogonal_polarizer_angle(self._polarizer_xpl_angle_deg) + metadata["recommended_xpl_angle_deg"] = self._polarizer_xpl_angle_deg + metadata["recommended_ppl_angle_deg"] = self._polarizer_ppl_angle_deg + metadata["recommended_ppl_offset_deg"] = ppl_offset + metadata["xpl_signal_mean"] = xpl_candidate["mean_signal"] + metadata["sequence_completed"] = True + + self.home() + self.last_calibration_info = metadata + with open(metadata_path, "w", encoding="utf-8") as file_obj: + json.dump(metadata, file_obj, indent=2) + + return ( + True, + "Polarizer calibration complete. " + f"XPL={self._polarizer_xpl_angle_deg} deg, " + f"PPL={self._polarizer_ppl_angle_deg} deg. Metadata: {metadata_path}", + ) + except Exception as exc: + metadata["error_message"] = str(exc) + self.last_calibration_info = metadata + try: + with open(metadata_path, "w", encoding="utf-8") as file_obj: + json.dump(metadata, file_obj, indent=2) + except Exception: + pass + try: + self.emergency_stop() + except Exception: + pass + return (False, "Polarizer calibration failed: " + str(exc)) + + def _resolve_capture_directory(self, directory: Optional[str], mode_subdir: Optional[str] = None) -> str: + base_dir = directory or self._camera_save_directory + if mode_subdir: + base_dir = os.path.join(base_dir, mode_subdir) + os.makedirs(base_dir, exist_ok=True) + return base_dir + + @check_initialized + def capture_raw16( + self, + filename: Optional[str] = None, + directory: Optional[str] = None, + exposure_time: Optional[int] = None, + gain: float = 0.0, + ) -> Tuple[bool, str]: + if self.camera is None: + return (False, "APIS camera support is disabled for this device instance.") + return self.camera.capture_raw16( + save_to_file=True, + filename=filename, + directory=self._resolve_capture_directory(directory, "raw16"), + exposure_time=exposure_time, + gain=gain, + ) + + @check_initialized + def capture_rgb( + self, + filename: Optional[str] = None, + directory: Optional[str] = None, + exposure_time: Optional[int] = None, + gain: float = 0.0, + ) -> Tuple[bool, str]: + if self.camera is None: + return (False, "APIS camera support is disabled for this device instance.") + return self.camera.capture_rgb_no_correction( + save_to_file=True, + filename=filename, + directory=self._resolve_capture_directory(directory, "rgb"), + exposure_time=exposure_time, + gain=gain, + bayer_pattern=self._camera_bayer_pattern, + raw_max_value=self._camera_raw_max_value, + wb_gains=self.CAMERA_PREVIEW_WB_GAINS, + gamma=self.CAMERA_PREVIEW_GAMMA, + ) + + @check_initialized + def convert_raw16_to_rgb( + self, + raw16_path: str, + rgb_path: Optional[str] = None, + ) -> Tuple[bool, str]: + return XimeaCamera.convert_saved_raw16_to_rgb( + raw16_path=raw16_path, + rgb_path=rgb_path, + bayer_pattern=self._camera_bayer_pattern, + raw_max_value=self._camera_raw_max_value, + wb_gains=self.CAMERA_PREVIEW_WB_GAINS, + gamma=self.CAMERA_PREVIEW_GAMMA, + ) diff --git a/devices/device.py b/aamp_app/devices/device.py similarity index 91% rename from devices/device.py rename to aamp_app/devices/device.py index 38865c7..443b071 100644 --- a/devices/device.py +++ b/aamp_app/devices/device.py @@ -6,6 +6,7 @@ import serial except ImportError: pass +import json # Decorator to check if is initialized, optional custom message on fail @@ -94,6 +95,29 @@ def deinitialize(self) -> Tuple[bool, str]: """ pass + @abstractmethod # uncomment and implement method in all devices + def get_init_args(self) -> dict: + """The get_args abstract method that all devices should implement. Method should return a dict with only the arguments needed to initialize the device. + + Returns + ------- + dict + Returns a dict containing the arguments needed to initialize the device. + """ + pass + + @abstractmethod # uncomment and implement method in all devices + def update_init_args(self, args_dict: dict): + """The update_init_args abstract method that all devices should implement. Method should update the arguments needed to initialize the device. + + Parameters + ---------- + + args_dict : dict + A dict containing the arguments needed to initialize the device. + """ + pass + class SerialDevice(Device): """A Device that uses serial communication.""" @@ -264,12 +288,12 @@ def get_response(self, response_timeout: float = 1.0) -> Tuple[bool, str]: response_result = (response_result.decode('ascii') + temp_result.decode('ascii')).encode('ascii') if retry_count == partial_retries and "\\n" not in str(response_result): - return (False, "Timed out. Partial message received for potential control char or str " + control_char + ".") + return (False, "Timed out. Partial message received.") response_result = response_result.strip().decode('ascii') if response_result == "": - return (False, "Timed out. Did not receive any response for control char or str " + control_char + ".") + return (False, "Timed out. Did not receive any response.") return (True, response_result) @@ -291,4 +315,9 @@ def parse_equal_sign(text: str) -> Tuple[bool, str]: last_token = text.split("=")[-1] return (True, last_token.strip()) else: - return (False, "") \ No newline at end of file + return (False, "") + + +class MiscDeviceClass(): + def exists(): + return True diff --git a/devices/dummy_heater.py b/aamp_app/devices/dummy_heater.py similarity index 91% rename from devices/dummy_heater.py rename to aamp_app/devices/dummy_heater.py index e93b79d..c904001 100644 --- a/devices/dummy_heater.py +++ b/aamp_app/devices/dummy_heater.py @@ -17,6 +17,18 @@ def __init__(self, name: str, heat_rate: float = 20.0): self._temperature = random.uniform(self.min_temperature, self.max_temperature) self._hardware_interval = 0.05 + def get_init_args(self) -> dict: + args_dict = { + "name": self._name, + "heat_rate": self._heat_rate, + } + return args_dict + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._heat_rate = args_dict["heat_rate"] + + @property def temperature(self) -> float: return self._temperature diff --git a/devices/dummy_meter.py b/aamp_app/devices/dummy_meter.py similarity index 76% rename from devices/dummy_meter.py rename to aamp_app/devices/dummy_meter.py index 129ef67..bfb2387 100644 --- a/devices/dummy_meter.py +++ b/aamp_app/devices/dummy_meter.py @@ -44,6 +44,42 @@ def __init__( self.b2 = b2 self.noise_width = noise_width + def __init__( + self, + name: str, + heater: dict, + motor: dict, + a1: List[float], + b1: List[float], + a2: List[float], + b2: List[float], + noise_width: float = 0.0 + ): + super().__init__(name) + # All arguments except 'name' are only for emulation purposes + self.heater = DummyHeater(**heater) + self.motor = DummyMotor(**motor) + self.x1_range = (self.heater.min_temperature, self.heater.max_temperature) + self.x2_range = (self.motor.motor.min_speed, self.motor.motor.max_speed) + self.a1 = a1 + self.b1 = b1 + self.a2 = a2 + self.b2 = b2 + self.noise_width = noise_width + + def get_args(self) -> dict: + args_dict = { + "name": self._name, + "heater": self.heater.get_args(), + "motor": self.motor.get_args(), + "a1": self.a1, + "b1": self.b1, + "a2": self.a2, + "b2": self.b2, + "noise_width": self.noise_width, + } + return args_dict + def initialize(self): self._is_initialized = True return (True, "Initialized DummyMeter") diff --git a/devices/dummy_motor.py b/aamp_app/devices/dummy_motor.py similarity index 57% rename from devices/dummy_motor.py rename to aamp_app/devices/dummy_motor.py index 432187e..acb44d8 100644 --- a/devices/dummy_motor.py +++ b/aamp_app/devices/dummy_motor.py @@ -12,20 +12,43 @@ def __init__(self, name: str, speed: float = 20.0): # Using composition instead of multiple inheritance self.motor = DummyMotorSource(speed) + def get_init_args(self) -> dict: + args_dict = { + "name": self._name, + "speed": self.motor._speed, + } + return args_dict + + def update_init_args(self, args_dict: dict): + self.motor._speed = args_dict["speed"] + self._name = args_dict["name"] + @property def position(self) -> float: return self.motor.position - + + def get_position(self) -> float: + return self.motor.position + @property def speed(self) -> float: return self.motor.speed + def get_speed(self) -> float: + return self.motor.speed + def initialize(self) -> Tuple[bool, str]: self.motor.home_motor() while self.motor._position != 0.0: - print("DummyMotor " + self.name + " position: " + str(self.motor._position), end='\r') + print( + "DummyMotor " + self.name + " position: " + str(self.motor._position), + end="\r", + ) time.sleep(0.1) - print("DummyMotor " + self.name + " position: " + str(self.motor._position), end='\r') + print( + "DummyMotor " + self.name + " position: " + str(self.motor._position), + end="\r", + ) self._is_initialized = True return (True, "Initialized DummyMotor by homing and setting position to zero") @@ -39,9 +62,16 @@ def set_speed(self, speed: float): self.motor.speed = speed # if out of range, then the setter did nothing if self.motor.speed == speed: - return (True, "DummyMotor speed was successfully set to " + str(self.motor.speed)) + return ( + True, + "DummyMotor speed was successfully set to " + str(self.motor.speed), + ) else: - return (False, "DummyMotor speed was not set. Speed is currently " + str(self.motor.speed)) + return ( + False, + "DummyMotor speed was not set. Speed is currently " + + str(self.motor.speed), + ) @check_initialized def move_absolute(self, position: float) -> Tuple[bool, str]: @@ -53,9 +83,15 @@ def move_absolute(self, position: float) -> Tuple[bool, str]: self.motor.move_absolute(position) while self.motor.position != position: - print("DummyMotor " + self.name + " position: " + str(self.motor.position), end='\r') - time.sleep(.1) - print("DummyMotor " + self.name + " position: " + str(self.motor.position), end='\r') + print( + "DummyMotor " + self.name + " position: " + str(self.motor.position), + end="\r", + ) + time.sleep(0.1) + print( + "DummyMotor " + self.name + " position: " + str(self.motor.position), + end="\r", + ) return (True, "DummyMotor has reached position " + str(position)) @@ -71,16 +107,26 @@ def move_relative(self, distance: float) -> Tuple[bool, str]: self.motor.move_relative(distance) while self.motor.position != position: - print("DummyMotor " + self.name + " position: " + str(self.motor.position), end='\r') - time.sleep(.1) - print("DummyMotor " + self.name + " position: " + str(self.motor.position), end='\r') - - return (True, "DummyMotor has moved by " + str(distance) + " and reached position " + str(position)) + print( + "DummyMotor " + self.name + " position: " + str(self.motor.position), + end="\r", + ) + time.sleep(0.1) + print( + "DummyMotor " + self.name + " position: " + str(self.motor.position), + end="\r", + ) + + return ( + True, + "DummyMotor has moved by " + + str(distance) + + " and reached position " + + str(position), + ) def is_valid_position(self, position) -> bool: if position >= self.motor.min_position and position <= self.motor.max_position: return True else: return False - - diff --git a/devices/dummy_motor_source.py b/aamp_app/devices/dummy_motor_source.py similarity index 95% rename from devices/dummy_motor_source.py rename to aamp_app/devices/dummy_motor_source.py index 0aac591..6e0caea 100644 --- a/devices/dummy_motor_source.py +++ b/aamp_app/devices/dummy_motor_source.py @@ -1,7 +1,8 @@ import threading import random +from devices.device import MiscDeviceClass -class DummyMotorSource(): +class DummyMotorSource(MiscDeviceClass): def __init__(self, speed: float = 20.0): self._speed = speed diff --git a/aamp_app/devices/festo_solenoid_valve.py b/aamp_app/devices/festo_solenoid_valve.py new file mode 100644 index 0000000..9716f04 --- /dev/null +++ b/aamp_app/devices/festo_solenoid_valve.py @@ -0,0 +1,83 @@ +"""Requires Festo Solenoid Valve flashed to Arduino Board""" +"""Valve 1: Pin 12, Valve 2: Pin 8, Valve 3: Pin 4""" +from typing import Tuple, Optional +import time +import serial + +from .device import ArduinoSerialDevice, check_initialized, check_serial + + +class FestoSolenoidValve(ArduinoSerialDevice): + """Simple serial wrapper for an Arduino sketch that toggles Festo valve driver outputs. + + The companion sketch in ``to_implement/festo_solenoid_valve_multiple/Festo_Multiple.ino`` + maps valve 1/2/3 to Arduino pins 12/8/4 and expects single-byte commands: + A/B/C=open valve 1/2/3, D/E/F=close valve 1/2/3. + """ + + def __init__( + self, name: str, port: str, baudrate: int = 9600, timeout: float = 0.1 + ): + super().__init__(name, port, baudrate, timeout) + + def get_init_args(self) -> dict: + args_dict = { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout + } + return args_dict + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + + def initialize(self) -> Tuple[bool, str]: + self._is_initialized = True + return (True, "Solenoid valve initialized") + + def deinitialize(self) -> Tuple[bool, str]: + if self.ser.is_open: + self.ser.close() + self._is_initialized = False + return (True, "Solenoid valve deinitialized") + + @check_serial + @check_initialized + def valve_open(self, valve_num: int) -> Tuple[bool, str]: + if valve_num == 1: + self.ser.write(b"A") + elif valve_num == 2: + self.ser.write(b"B") + elif valve_num == 3: + self.ser.write(b"C") + else: + return (False, "Incorrect solenoid valve number") + return (True, "Solenoid valve is open") + + @check_serial + @check_initialized + def valve_closed(self, valve_num: int) -> Tuple[bool, str]: + if valve_num == 1: + self.ser.write(b"D") + elif valve_num == 2: + self.ser.write(b"E") + elif valve_num == 3: + self.ser.write(b"F") + else: + return (False, "Incorrect solenoid valve number") + return (True, "Solenoid valve is closed") + + @check_serial + @check_initialized + def close_all(self) -> Tuple[bool, str]: + self.ser.write(b"D") + time.sleep(0.25) + self.ser.write(b"E") + time.sleep(0.25) + self.ser.write(b"F") + time.sleep(0.25) + return (True, "Closed all solenoid valves") diff --git a/devices/heating_stage.py b/aamp_app/devices/heating_stage.py similarity index 61% rename from devices/heating_stage.py rename to aamp_app/devices/heating_stage.py index 4b51b10..cf6daa7 100644 --- a/devices/heating_stage.py +++ b/aamp_app/devices/heating_stage.py @@ -1,5 +1,7 @@ from typing import Optional, Tuple, Union import serial +import time +import re from .device import ArduinoSerialDevice, check_initialized, check_serial @@ -16,6 +18,23 @@ def __init__( super().__init__(name, port, baudrate, timeout) self._heating_timeout = heating_timeout + def get_init_args(self) -> dict: + args_dict = { + "name": self.name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "heating_timeout": self._heating_timeout, + } + return args_dict + + def update_init_args(self, args_dict: dict): + self.name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._heating_timeout = args_dict["heating_timeout"] + # no need to check serial as set_settemp and pid_on has these checks already def initialize(self) -> Tuple[bool, str]: self._is_initialized = True @@ -137,8 +156,76 @@ def temperature(self) -> Tuple[bool, Union[float, str]]: if not has_temperature: return (has_temperature, "Message from device did not contain temperature.") - - return (True, float(temperature_str)) + + match = re.search(r"-?\d+(?:\.\d+)?", temperature_str) + if match is None: + return (False, "Could not parse temperature from message: " + str(temperature_str)) + + return (True, float(match.group(0))) + + @check_serial + @check_initialized + def wait_for_temperature( + self, + target: float, + tolerance: float = 1.0, + timeout: Optional[float] = None, + poll_interval: float = 1.0, + hold_duration: float = 30.0, + ) -> Tuple[bool, str]: + if tolerance < 0: + return (False, "Tolerance must be non-negative.") + if poll_interval <= 0: + return (False, "Poll interval must be greater than zero.") + if hold_duration < 0: + return (False, "Hold duration must be non-negative.") + + if timeout is None: + timeout = self._heating_timeout + + deadline = time.time() + timeout + last_temp = None + stable_since = None + + while time.time() <= deadline: + was_successful, current_temp = self.temperature() + if not was_successful: + return (False, str(current_temp)) + + last_temp = current_temp + if abs(current_temp - target) <= tolerance: + if stable_since is None: + stable_since = time.time() + elif time.time() - stable_since >= hold_duration: + return ( + True, + "Heating stage held target temperature " + + str(target) + + " C within ±" + + str(tolerance) + + " C for " + + str(hold_duration) + + " s. Current temperature: " + + str(current_temp) + + " C.", + ) + else: + stable_since = None + + time.sleep(poll_interval) + + return ( + False, + "Heating stage failed to reach target temperature " + + str(target) + + " C and hold it within ±" + + str(tolerance) + + " C for " + + str(hold_duration) + + " s within timeout. Last measured temperature: " + + str(last_temp) + + " C.", + ) diff --git a/aamp_app/devices/keithley_2450.py b/aamp_app/devices/keithley_2450.py new file mode 100644 index 0000000..38a42ba --- /dev/null +++ b/aamp_app/devices/keithley_2450.py @@ -0,0 +1,189 @@ +"""Requires download of NI-VISA with equivalent bitness""" +import pyvisa +from time import sleep +from datetime import datetime +import pandas as pd +from typing import Optional, Tuple +from .device import Device, check_initialized + + + +class Keithley2450(Device): + save_directory = 'data/keithley_2450/' + + def __init__(self, name: str, ID: str, query_delay: int): + super().__init__(name) + self.ID = ID + self.query_delay = query_delay + self.keithley = pyvisa.ResourceManager().open_resource(self.ID) + print(self.keithley.query('*IDN?')) + sleep(self.query_delay) + + def get_init_args(self) -> dict: + args_dict = { + "name": self.name, + "ID": self.ID, + "query_delay": self.query_delay, + } + return args_dict + + def update_init_args(self, args_dict: dict): + self.name = args_dict["name"] + self.ID = args_dict["ID"] + self.query_delay = args_dict["query_delay"] + + @property #TODO: Check if necessary + def terminal_pos(self) -> str: + return self.position + + def initialize(self) -> Tuple[bool, str]: + self.keithley.write('*RST') + self._is_initialized = True + return (True, "Initialized Keithley2450 by performing device reset") + + def deinitialize(self) -> Tuple[bool, str]: + #TODO: Keithley deinitialization steps + self.keithley.close() + self._is_initialized = False + return(True, "Deinitialized Keithley2450") + + @check_initialized + def wait(self): + self.keithley.query('*OPC?') + if self.keithley.query('*ESR?') != 1: + self.keithley.write('*CLS') + print("Wait Completed, Standard Event Status Register cleared") + + #TODO: Test wait function below + """ + def wait(self): + self.keithley.write('*OPC?') + while True: + try: + self.keithley.read() + break + except pyvisa.errors.VisaIOError: + continue + """ + + @check_initialized + def write_command(self, command: str): + self.keithley.write(command) + + @check_initialized + def terminal_pos(self, position: str = 'front'): + """Set terminal positions to front or rear""" + if position == "rear": + self.keithley.write('ROUT:TERM REAR') + self.wait() + elif position == "front": + self.keithley.write('ROUT:TERM FRONT') + self.wait() + else: + raise Exception(f"Expected 'rear' or 'front', found {position}") + @check_initialized + def error_check(self): + """Check for errors raised by Keithley2450""" + self.keithley.write('SYSTem:ERRor:COUNt?') + num_errors = int(self.keithley.read()) + if num_errors != 0: + errors = [] + for i in range(num_errors): + self.keithley.write('SYSTem:ERRor:NEXT?') + errors.append(self.device.read()) + errors = ''.join(errors) + raise Warning(f"An error has occurred:\n {errors}") + + @check_initialized + def clear_buffer(self, buffer: str = 'defbuffer1') -> Tuple[bool, str]: + """Clear the data storage buffer within the Keithley2450""" + self.keithley.write(f':TRACe:CLEar "{buffer}"') + return (True, f'Cleared buffer {buffer}') + + + @check_initialized + def IV_characteristic(self, ilimit: float, vmin: float, vmax: float, delay: float, steps: int = 60): + """Sourcing voltage and measuring current with linear sweep""" + if ilimit <= 1e-9 and ilimit >= 1.05: + raise Exception(f"Expected source current limit between 1nA and 1.05A") + + if vmin < -210 or vmin > 210: + raise Exception(f"Voltage minimum out of range -210V to 210V") + + if vmax < -210 or vmin > 210: + raise Exception(f"Voltage maximum out of range -210V to 210V") + if vmax < vmin: + raise Exception(f"Voltage minimum is greater than voltage maximum") + + if delay < 50e-6: + raise Exception(f"Delay value too small, must be greater than 50 µs") + + self.keithley.write('*RST') + self.keithley.write('SENS:CURR:RANG:AUTO ON') + self.keithley.write('SYST:RSEN ON') + self.keithley.write(f"SOURce:VOLT:ILIMit {ilimit}") + self.keithley.write(f"SOURce:SWE:VOLT:LIN {vmin}, {vmax}, {steps}, {delay}") + self.keithley.write('INIT') + self.keithley.write('*WAI') + + @check_initialized + def four_point(self, test_curr: float, vlimit: float, curr_reversal: bool = False) -> float: + """Measure resistance through four-point collinear probe method""" + + self.keithley.write('*RST') + self.keithley.write('SENS:VOLT:RANG:AUTO ON') + self.keithley.write('SYST:RSEN ON') + self.keithley.write(f"SOURce:CURR:VLIMit {vlimit}") + self.keithley.write(f"SOURce:CURR {test_curr}") + self.keithley.write(':OUTP ON') + self.keithley.write('*WAI') + sleep(2) + res1 = self.keithley.query(':READ?') + self.keithley.write(':OUTP OFF') + four_point_data = res1 + if curr_reversal: + self.keithley.write(f"SOURce:CURR:VLIMit {vlimit}") + self.keithley.write(f"SOURce:CURR {-1*test_curr}") + self.keithley.write(':OUTP ON') + self.keithley.write('*WAI') + sleep(2) + res2 = self.keithley.query('READ?') + self.keithley.write(':OUTP OFF') + four_point_data = (res1 + res2) / 2 + return four_point_data + + + @check_initialized + def get_data(self, filename: str = None, four_point: bool = False): + """Retrieve data from buffer and save locally""" + + if not four_point: + self.keithley.write(':TRACe:ACTual:END?') + end_index = int(self.keithley.read()) + self.keithley.write(f':TRACe:DATA? 1, {end_index}, "defbuffer1", RELative, SOURce, READing') + results = self.keithley.read() + results = results.split(',') + results = list(map(float, results)) + data = { + 'time' : results[::3], + 'source' : results[1::3], + 'reading' : results[2::3] + } + else: + self.keithley.write(':TRACe:ACTual:END?') + end_index = int(self.keithley.read()) + self.keithley.write(f':TRACe:DATA? 1, {end_index}, "defbuffer1"') + data = self.keithley.read() + + if filename is None: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = timestamp + + dataframe = pd.DataFrame(data) + + fullfilename = self.save_directory + filename + '.csv' + dataframe.to_csv(fullfilename, mode = 'w', index = False) + + return (True, "Saved data to at " + fullfilename) + + diff --git a/devices/kinova_arm.py b/aamp_app/devices/kinova_arm.py similarity index 90% rename from devices/kinova_arm.py rename to aamp_app/devices/kinova_arm.py index 96608a7..0b7d5fe 100644 --- a/devices/kinova_arm.py +++ b/aamp_app/devices/kinova_arm.py @@ -26,6 +26,7 @@ class KinovaArm(Device): pose_dict_file = 'robot_arm_poses.yaml' + safe_action_names = ("Home", "Above Fork Pickup") def __init__( self, @@ -53,6 +54,25 @@ def __init__( self._transport = TCPTransport() self._router = RouterClient(self._transport, RouterClient.basicErrorCallback) + def get_init_args(self) -> dict: + args_dict = { + "name": self.name, + "ip": self._ip, + "username": self._username, + "password": self._password, + "action_timeout": self._action_timeout, + "proportional_gain": self._proportional_gain + } + return args_dict + + def update_init_args(self, args_dict: dict): + self.name = args_dict["name"] + self._ip = args_dict["ip"] + self._username = args_dict["username"] + self._password = args_dict["password"] + self._action_timeout = args_dict["action_timeout"] + self._proportional_gain = args_dict["proportional_gain"] + def connect(self) -> Tuple[bool, str]: self._transport.connect(self._ip, self._port) @@ -99,40 +119,16 @@ def deinitialize(self) -> Tuple[bool, str]: def home(self) -> Tuple[bool, str]: - # Make sure the arm is in Single Level Servoing mode - base_servo_mode = Base_pb2.ServoingModeInformation() - base_servo_mode.servoing_mode = Base_pb2.SINGLE_LEVEL_SERVOING - self._base.SetServoingMode(base_servo_mode) - - # Move arm to ready position - # print("Moving the arm to a safe position") - action_type = Base_pb2.RequestedActionType() - action_type.action_type = Base_pb2.REACH_JOINT_ANGLES - action_list = self._base.ReadAllActions(action_type) - action_handle = None - - for action in action_list.action_list: - # if action.name == "Home": - if action.name == 'Above Fork Pickup': - action_handle = action.handle + for action_name in self.safe_action_names: + was_successful, message = self.execute_action(action_name) + if was_successful: + return (True, message) - if action_handle is None: - return (False, "Can't reach safe position") - - e = threading.Event() - notification_handle = self._base.OnNotificationActionTopic( - self.check_for_end_or_abort(e), - Base_pb2.NotificationOptions() - ) + if not message.startswith("Did not find action of name "): + return (False, message) - self._base.ExecuteActionFromReference(action_handle) - finished = e.wait(self._action_timeout) - self._base.Unsubscribe(notification_handle) - - if finished: - return (True, "Safe position reached") - else: - return (False, "Timeout on action notification wait") + action_name_str = ", ".join(self.safe_action_names) + return (False, "Can't reach safe position. None of these saved actions were found: " + action_name_str) def execute_action(self, action_name: str): # Make sure the arm is in Single Level Servoing mode @@ -371,4 +367,4 @@ def check(notification, e = e): if notification.action_event == Base_pb2.ACTION_END \ or notification.action_event == Base_pb2.ACTION_ABORT: e.set() - return check \ No newline at end of file + return check diff --git a/aamp_app/devices/linear_stage_150.py b/aamp_app/devices/linear_stage_150.py new file mode 100644 index 0000000..84f4963 --- /dev/null +++ b/aamp_app/devices/linear_stage_150.py @@ -0,0 +1,215 @@ +from typing import Optional, Tuple +from struct import pack, unpack +import time +from .device import SerialDevice, check_serial, check_initialized + + +class LinearStage150(SerialDevice): + DEVICE_UNIT_SCALE = 409600 + MIN_POSITION_MM = 0.0 + MAX_POSITION_MM = 150.0 + HOME_TIMEOUT_S = 120.0 + MOVE_TIMEOUT_S = 120.0 + + def __init__( + self, + name: str, + port: str = "COM6", + baudrate: int = 115200, + timeout: float | None = 0.1, + destination: int = 0x50, + source: int = 0x01, + channel: int = 1, + ): + super().__init__(name, port, baudrate, timeout) + self._destination = destination + self._source = source + self._channel = channel + self._is_enabled = False + + def _wait_for_message(self, expected_header: bytes, timeout_s: float) -> Tuple[bool, str]: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self.ser.read(2) + if response == expected_header: + return (True, "") + if response == b"": + continue + return (False, "Response timed out while waiting for stage controller message.") + + def _validate_position(self, position: float) -> Tuple[bool, str]: + if position < self.MIN_POSITION_MM or position > self.MAX_POSITION_MM: + return ( + False, + "Position " + + str(position) + + " is out of range. Expected " + + str(self.MIN_POSITION_MM) + + " to " + + str(self.MAX_POSITION_MM) + + " mm.", + ) + return (True, "") + + def get_init_args(self) -> dict: + args_dict = { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "destination": self._destination, + "source": self._source, + "channel": self._channel, + } + return args_dict + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._destination = args_dict["destination"] + self._source = args_dict["source"] + self._channel = args_dict["channel"] + + @check_serial + def initialize(self) -> Tuple[bool, str]: + self._is_initialized = False + + was_enabled, message = self.set_enabled_state(True) + if not was_enabled: + return (was_enabled, message) + + # Home Stage; MGMSG_MOT_MOVE_HOME + self.ser.write( + pack(" Tuple[bool, str]: + if self.ser.is_open: + self.set_enabled_state(False) + self._is_initialized = False + return (True, "Successfully deinitialized LTS150.") + + @check_serial + def get_enabled_state(self) -> bool: + self.ser.write( + pack(" Tuple[bool, str]: + if state: + self.ser.write( + pack(" float: + self._position = 0.0 + # MGMSG_MOT_GET_POSCOUNTER + self.ser.write( + pack(" Tuple[bool, str]: + is_valid_position, message = self._validate_position(position) + if not is_valid_position: + return (False, message) + + dUnitpos = int(self.DEVICE_UNIT_SCALE * position) + self.ser.write( + pack( + " Tuple[bool, str]: + target_position = self.get_position() + distance + is_valid_position, message = self._validate_position(target_position) + if not is_valid_position: + return (False, message) + + dUnitpos = int(self.DEVICE_UNIT_SCALE * distance) + self.ser.write( + pack( + " dict: + args_dict = { + "name": self._name, + "ip": self._ip, + } + return args_dict + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._ip = args_dict["ip"] + + def initialize(self) -> Tuple[bool, str]: + self._is_initialized = True + return (True, "Initialized mass flow controller") + + async def deinitialize(self) -> Tuple[bool, str]: + async with FlowController(self._ip) as fc: + await fc.disconnect() + self._is_initialized = False + return (True, "Deinitialized mass flow controller") + + @check_initialized + async def get(self) -> Tuple[bool, dict]: + async with FlowController(self._ip) as fc: + info = await fc.get() + return (True, info) + + @check_initialized + async def set_gas(self, gas: str) -> Tuple[bool, str]: + """Gas instance must first be created within web browser interface""" + async with FlowController(self._ip) as fc: + await fc.set_gas(gas) + return (True, f"Set MFC gas to {gas}") + + @check_initialized + async def set(self, setpoint) -> Tuple[bool, str]: + async with FlowController(self._ip) as fc: + await fc.set(setpoint) + return (True, f"Set MFC flowrate to {setpoint} sccm") + + async def open(self) -> Tuple[bool, str]: + async with FlowController(self._ip) as fc: + await fc.open() + return (True, "Set the MFC flowrate to its maximum") diff --git a/aamp_app/devices/mts50_z8.py b/aamp_app/devices/mts50_z8.py new file mode 100644 index 0000000..f3dc2c8 --- /dev/null +++ b/aamp_app/devices/mts50_z8.py @@ -0,0 +1,168 @@ +from typing import Optional, Tuple +from struct import pack, unpack +from .device import SerialDevice, check_serial, check_initialized +import time + + +# uses the kdc101 motor controller +class MTS50_Z8(SerialDevice): + def __init__( + self, + name: str, + port: str = "'COM6'", + baudrate: int = 115200, + timeout: float | None = 1, + destination: int = 0x50, + source: int = 0x01, + channel: int = 1, + ): + super().__init__(name, port, baudrate, timeout) + self._destination = destination + self._source = source + self._channel = channel + + def get_init_args(self) -> dict: + args_dict = { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "destination": self._destination, + "source": self._source, + "channel": self._channel, + } + return args_dict + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._destination = args_dict["destination"] + self._source = args_dict["source"] + self._channel = args_dict["channel"] + + @check_serial + def initialize(self) -> Tuple[bool, str]: + self._is_initialized = False + + # Home Stage; MGMSG_MOT_MOVE_HOME + self.ser.write(pack(" Tuple[bool, str]: + # i dont think this is needed: deinitialize + # if reset_init_flag: //used in other devices + self._is_initialized = False + return (True, "Successfully deinitialized MTS50_Z8.") + # return super().deinitialize() + + @check_serial + # @check_initialized + def get_enabled_state(self) -> bool: + self._is_enabled = False + + # TODO: mts50-z8 get enabled state, MGMSG_MOD_GET_CHANENABLESTATE + # self.ser.write(pack(' Tuple[bool, str]: + if state: + self.ser.write(pack(" Tuple[bool, str]: + if position > 150: + return (False, "Position " + str(position) + " is out of range.") + + Device_Unit_SF = 409600 + dUnitpos = int(Device_Unit_SF * position) + self.ser.write( + pack( + " Tuple[bool, str]: + if distance + self.get_position() > 150: + return ( + False, + "Position " + str(distance + self.get_position()) + " is out of range.", + ) + + Device_Unit_SF = 409600 + dUnitpos = int(Device_Unit_SF * distance) + self.ser.write( + pack( + " dict: + args_dict = { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "stepper_list": self._stepper_list, + "move_timeout": self._move_timeout + } + return args_dict + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._stepper_list = args_dict["stepper_list"] + self._move_timeout = args_dict["move_timeout"] + def initialize(self): for stepper in self._stepper_list: was_homed, comment = self.home(stepper) diff --git a/aamp_app/devices/newport_94043a_solar_sim.py b/aamp_app/devices/newport_94043a_solar_sim.py new file mode 100644 index 0000000..d8f5da0 --- /dev/null +++ b/aamp_app/devices/newport_94043a_solar_sim.py @@ -0,0 +1,233 @@ +from typing import Optional, Tuple + +from .device import SerialDevice, check_serial, check_initialized + + +class Newport94043ASolarSim(SerialDevice): + DEFAULT_POWER_WATTS = 400 + MAX_POWER_WATTS = 450 + MODE_TIMEOUT_S = 3.0 + POWER_PRESET_TIMEOUT_S = 3.0 + LAMP_START_TIMEOUT_S = 10.0 + LAMP_STOP_TIMEOUT_S = 10.0 + + def __init__( + self, + name: str, + port: str, + baudrate: int = 9600, + timeout: Optional[float] = 1.0, + line_terminator: str = "\r", + lamp_hours_warning_threshold: float = 1000.0, + default_power_watts: int = DEFAULT_POWER_WATTS, + max_power_watts: int = MAX_POWER_WATTS, + ): + super().__init__(name, port, baudrate, timeout) + self._line_terminator = line_terminator + self._lamp_hours_warning_threshold = lamp_hours_warning_threshold + self._default_power_watts = default_power_watts + self._max_power_watts = max_power_watts + + def get_init_args(self) -> dict: + return { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "line_terminator": self._line_terminator, + "lamp_hours_warning_threshold": self._lamp_hours_warning_threshold, + "default_power_watts": self._default_power_watts, + "max_power_watts": self._max_power_watts, + } + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._line_terminator = args_dict["line_terminator"] + self._lamp_hours_warning_threshold = args_dict["lamp_hours_warning_threshold"] + self._default_power_watts = args_dict["default_power_watts"] + self._max_power_watts = args_dict["max_power_watts"] + + def _send(self, command: str) -> None: + self.ser.write((command + self._line_terminator).encode("ascii")) + + def _readline(self) -> Tuple[bool, str]: + response = self.ser.readline() + if response == b"": + return (False, "Response timed out.") + return (True, response.decode("ascii", errors="replace").strip()) + + def _query(self, command: str) -> Tuple[bool, str]: + self.ser.reset_input_buffer() + self._send(command) + return self._readline() + + def _query_with_timeout(self, command: str, timeout_s: float) -> Tuple[bool, str]: + original_timeout = self.ser.timeout + self.ser.timeout = timeout_s + try: + return self._query(command) + finally: + self.ser.timeout = original_timeout + + @staticmethod + def _parse_esr(response: str) -> Tuple[bool, str]: + if not response.startswith("ESR"): + return (False, "Unexpected response: " + response) + hex_value = response[3:] + try: + esr_value = int(hex_value, 16) + except ValueError: + return (False, "Could not parse ESR response: " + response) + + has_error = bool(esr_value & 0b00111100) + if has_error: + return (False, "69920 returned error status " + response) + return (True, response) + + @check_serial + def initialize(self) -> Tuple[bool, str]: + was_successful, response = self._set_mode(power_mode=True) + if not was_successful: + self._is_initialized = False + return (was_successful, response) + + was_successful, response = self._set_power_preset(self._default_power_watts) + if not was_successful: + self._is_initialized = False + return (was_successful, response) + + was_successful, lamp_hours_response = self._get_lamp_hours() + if not was_successful: + self._is_initialized = False + return (was_successful, lamp_hours_response) + + self._is_initialized = True + lamp_hours = self.parse_lamp_hours(lamp_hours_response) + message = ( + "Successfully initialized 94043A solar simulator through 69920 power supply in power mode. " + f"Default power preset set to {self._default_power_watts} W. Lamp hours: {lamp_hours:.0f} h." + ) + if lamp_hours >= self._lamp_hours_warning_threshold: + message += " Lamp has exceeded the replacement threshold. Replace the lamp and reset 69920 lamp hours from the front panel." + return (True, message) + + def deinitialize(self) -> Tuple[bool, str]: + self._is_initialized = False + return (True, "Successfully deinitialized 69920.") + + @check_serial + def identify(self) -> Tuple[bool, str]: + return self._query("IDN?") + + @check_serial + def status_byte(self) -> Tuple[bool, str]: + return self._query("STB?") + + @check_serial + def event_status_register(self) -> Tuple[bool, str]: + return self._query("ESR?") + + @check_initialized + @check_serial + def lamp_start(self) -> Tuple[bool, str]: + was_successful, response = self._query_with_timeout("START", self.LAMP_START_TIMEOUT_S) + if not was_successful: + return (was_successful, response) + return self._parse_esr(response) + + @check_initialized + @check_serial + def lamp_stop(self) -> Tuple[bool, str]: + was_successful, response = self._query_with_timeout("STOP", self.LAMP_STOP_TIMEOUT_S) + if not was_successful: + return (was_successful, response) + return self._parse_esr(response) + + @check_initialized + @check_serial + def set_mode(self, power_mode: bool = True) -> Tuple[bool, str]: + return self._set_mode(power_mode) + + @check_serial + def _set_mode(self, power_mode: bool = True) -> Tuple[bool, str]: + mode_value = 0 if power_mode else 1 + was_successful, response = self._query_with_timeout(f"MODE={mode_value}", self.MODE_TIMEOUT_S) + if not was_successful: + return (was_successful, response) + return self._parse_esr(response) + + def set_power_mode(self) -> Tuple[bool, str]: + return self.set_mode(power_mode=True) + + @check_initialized + @check_serial + def get_amps(self) -> Tuple[bool, str]: + return self._query("AMPS?") + + @check_initialized + @check_serial + def get_volts(self) -> Tuple[bool, str]: + return self._query("VOLTS?") + + @check_initialized + @check_serial + def get_watts(self) -> Tuple[bool, str]: + return self._query("WATTS?") + + @check_initialized + @check_serial + def get_lamp_hours(self) -> Tuple[bool, str]: + return self._get_lamp_hours() + + @check_serial + def _get_lamp_hours(self) -> Tuple[bool, str]: + return self._query("LAMP HRS?") + + @staticmethod + def parse_lamp_hours(response: str) -> float: + cleaned = response.strip() + return float(cleaned) + + @check_initialized + @check_serial + def get_current_limit(self) -> Tuple[bool, str]: + return self._query("A-LIM?") + + @check_initialized + @check_serial + def get_power_limit(self) -> Tuple[bool, str]: + return self._query("P-LIM?") + + @check_initialized + @check_serial + def get_power_preset(self) -> Tuple[bool, str]: + return self._query("P-PRESET?") + + @check_initialized + @check_serial + def set_power_preset(self, watts: int) -> Tuple[bool, str]: + return self._set_power_preset(watts) + + @check_serial + def _set_power_preset(self, watts: int) -> Tuple[bool, str]: + if watts < 0: + return (False, "Power preset must be non-negative.") + if watts > self._max_power_watts: + return ( + False, + "Power preset " + + str(watts) + + " W exceeds the configured safety limit of " + + str(self._max_power_watts) + + " W for the 94043A solar simulator.", + ) + was_successful, response = self._query_with_timeout( + f"P-PRESET={int(watts)}", + self.POWER_PRESET_TIMEOUT_S, + ) + if not was_successful: + return (was_successful, response) + return self._parse_esr(response) diff --git a/devices/newport_esp301.py b/aamp_app/devices/newport_esp301.py similarity index 70% rename from devices/newport_esp301.py rename to aamp_app/devices/newport_esp301.py index 6ed63b3..0cf6248 100644 --- a/devices/newport_esp301.py +++ b/aamp_app/devices/newport_esp301.py @@ -1,5 +1,5 @@ import time -from typing import Optional, Tuple, Union +from typing import Dict, Optional, Tuple, Union import functools from .device import SerialDevice, check_initialized, check_serial @@ -20,6 +20,28 @@ def wrapper(self, *args, **kwargs): return wrapper class NewportESP301(SerialDevice): + UNIT_CODE_BY_NAME = { + "encoder_count": 0, + "motor_step": 1, + "mm": 2, + "micrometer": 3, + "inch": 4, + "milli_inch": 5, + "micro_inch": 6, + "deg": 7, + "gradian": 8, + "radian": 9, + "milliradian": 10, + "microradian": 11, + } + + DEFAULT_AXIS_CONFIG = { + "motion_type": "linear", + "units": "mm", + "home_mode": "OR4", + "zero_position": 0.0, + } + def __init__( self, name: str, @@ -28,7 +50,8 @@ def __init__( timeout: Optional[float] = 1.0, axis_list: Tuple[int, ...] = (1,), default_speed: float = 20.0, - poll_interval: float = 0.1): + poll_interval: float = 0.1, + axis_configs: Optional[Dict[int, Dict[str, Union[str, float]]]] = None): super().__init__(name, port, baudrate, timeout) self._axis_list = axis_list @@ -37,6 +60,30 @@ def __init__( self._poll_interval = poll_interval self._max_speed = 200.0 # make list # self._max_speed_list = max_speed_list + self._axis_configs = self._normalize_axis_configs(axis_configs) + + def get_init_args(self) -> dict: + args_dict = { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "axis_list": self._axis_list, + "default_speed": self._default_speed, + "poll_interval": self._poll_interval, + "axis_configs": self._axis_configs, + } + return args_dict + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._axis_list = args_dict["axis_list"] + self._default_speed = args_dict["default_speed"] + self._poll_interval = args_dict["poll_interval"] + self._axis_configs = self._normalize_axis_configs(args_dict.get("axis_configs")) @property def default_speed(self) -> float: @@ -47,6 +94,51 @@ def default_speed(self, speed: float): if speed > 0.0 and speed < self._max_speed: self._default_speed = speed + def _normalize_axis_configs( + self, + axis_configs: Optional[Dict[int, Dict[str, Union[str, float]]]] + ) -> Dict[int, Dict[str, Union[str, float]]]: + normalized_configs: Dict[int, Dict[str, Union[str, float]]] = {} + + for axis in self._axis_list: + config = dict(NewportESP301.DEFAULT_AXIS_CONFIG) + if axis_configs and axis in axis_configs: + config.update(axis_configs[axis]) + + motion_type = str(config.get("motion_type", "linear")).lower() + units = config.get("units") + if units is None: + units = "deg" if motion_type == "rotary" else "mm" + else: + units = str(units).lower() + + config["motion_type"] = motion_type + config["units"] = units + config["home_mode"] = str(config.get("home_mode", "OR4")).upper() + config["zero_position"] = float(config.get("zero_position", 0.0)) + config["default_speed"] = float(config.get("default_speed", self._default_speed)) + config["max_speed"] = float(config.get("max_speed", self._max_speed)) + normalized_configs[axis] = config + + return normalized_configs + + def _get_axis_config(self, axis_number: int) -> Dict[str, Union[str, float]]: + if axis_number not in self._axis_configs: + self._axis_configs = self._normalize_axis_configs(self._axis_configs) + return self._axis_configs[axis_number] + + def _get_unit_code(self, axis_number: int) -> int: + units = str(self._get_axis_config(axis_number)["units"]).lower() + if units not in NewportESP301.UNIT_CODE_BY_NAME: + raise ValueError(f"Unsupported ESP301 unit '{units}' for axis {axis_number}") + return NewportESP301.UNIT_CODE_BY_NAME[units] + + def _get_axis_default_speed(self, axis_number: int) -> float: + return float(self._get_axis_config(axis_number)["default_speed"]) + + def _get_axis_max_speed(self, axis_number: int) -> float: + return float(self._get_axis_config(axis_number)["max_speed"]) + # check_error already has serial check # easier to just set is_intialized False at the very beginning # do for all receivers @@ -66,8 +158,22 @@ def initialize(self) -> Tuple[bool, str]: if not was_turned_on: self._is_initialized = False return (was_turned_on, message) - # set units to mm, homing value to 0, set max speed, set current speed - command = str(axis) + "SN2;" + str(axis) + "SH0;" + str(axis) + "VU" + str(self._max_speed) + ";" + str(axis) + "VA" + str(self.default_speed) + "\r" + + try: + unit_code = self._get_unit_code(axis) + except ValueError as exc: + self._is_initialized = False + return (False, str(exc)) + + axis_max_speed = self._get_axis_max_speed(axis) + axis_default_speed = self._get_axis_default_speed(axis) + command = ( + str(axis) + "SN" + str(unit_code) + + ";" + str(axis) + "SH0" + + ";" + str(axis) + "VU" + str(axis_max_speed) + + ";" + str(axis) + "VA" + str(axis_default_speed) + + "\r" + ) self.ser.write(command.encode('ascii')) # Make sure initialization of settings was successful @@ -83,7 +189,7 @@ def initialize(self) -> Tuple[bool, str]: return (was_homed, message) self._is_initialized = True - return (True, "Successfully initialized axes by setting units to mm, settings max/current speeds, and homing. Current position set to zero.") + return (True, "Successfully initialized ESP301 axes with axis-specific units, speed settings, and homing.") # move_speed_absolute already has serial check def deinitialize(self, reset_init_flag: bool = True) -> Tuple[bool, str]: @@ -91,7 +197,8 @@ def deinitialize(self, reset_init_flag: bool = True) -> Tuple[bool, str]: # return (False, "Serial port " + self._port + " is not open. ") for axis in self._axis_list: - was_zeroed, message = self.move_speed_absolute(0.0, speed=None, axis_number=axis) + zero_position = float(self._get_axis_config(axis)["zero_position"]) + was_zeroed, message = self.move_speed_absolute(axis, zero_position, speed=None) if not was_zeroed: return (was_zeroed, message) @@ -102,11 +209,13 @@ def deinitialize(self, reset_init_flag: bool = True) -> Tuple[bool, str]: # make a home_all function @check_serial + @check_axis_num def home(self, axis_number: int) -> Tuple[bool, str]: # if not self.ser.is_open: # return (False, "Serial port " + self._port + " is not open. ") - command = str(axis_number) + "OR4\r" + home_mode = str(self._get_axis_config(axis_number)["home_mode"]) + command = str(axis_number) + home_mode + "\r" self.ser.write(command.encode('ascii')) while self.is_any_moving(): @@ -118,7 +227,17 @@ def home(self, axis_number: int) -> Tuple[bool, str]: if not was_successful: return (was_successful, message) else: - return (True, "Successfully homed axes " + str(axis_number)) + axis_config = self._get_axis_config(axis_number) + return ( + True, + "Successfully homed axis " + + str(axis_number) + + " using " + + home_mode + + " in " + + str(axis_config["units"]) + + "." + ) # Consider a decorator for checks? @check_serial @@ -138,7 +257,7 @@ def move_speed_absolute(self, axis_number: int = 1, position: Optional[float] = return (False, "Position was not specified") if speed is None: - speed = self._default_speed + speed = self._get_axis_default_speed(axis_number) command = str(axis_number) + "VA" + str(speed) +"\r" self.ser.write(command.encode('ascii')) @@ -180,7 +299,7 @@ def move_speed_relative(self, axis_number: int = 1, distance: Optional[float] = return (False, "Distance was not specified") if speed is None: - speed = self._default_speed + speed = self._get_axis_default_speed(axis_number) command = str(axis_number) + "VA" + str(speed) +"\r" self.ser.write(command.encode('ascii')) diff --git a/aamp_app/devices/oxygen_sensor.py b/aamp_app/devices/oxygen_sensor.py new file mode 100644 index 0000000..68dda70 --- /dev/null +++ b/aamp_app/devices/oxygen_sensor.py @@ -0,0 +1,33 @@ +from .device import ArduinoSerialDevice, check_initialized, check_serial +import serial +from time import sleep +from typing import Optional, Tuple + + +class OxygenSensor(ArduinoSerialDevice): + def __init__( + self, name: str, port: str, baudrate: int = 9600, timeout: Optional[float] = 1.0 + ): + super().__init__(name, port, baudrate, timeout) + self.ser.bytesize = serial.EIGHTBITS + self.ser.parity = serial.PARITY_NONE + + def initialize(self) -> Tuple[bool, str]: + self.ser.setDTR(False) + self.ser.flushInput() + self.ser.setDTR(True) + self._is_initialized = True + return (True, "Initialized oxygen sensor") + + def deinitialize(self) -> Tuple[bool, str]: + self.ser.close() + self._is_initialized = False + return (True, "Deinitialized oxygen sensor") + + @check_serial + @check_initialized + def get_oxygen(self) -> Tuple[bool, float, str]: + self.ser.write(b"O") + sleep(0.75) + oxygen = float(self.ser.readline().strip().decode()) + return (True, f"Oxygen concentration is {oxygen} %vol") diff --git a/aamp_app/devices/p4pp.py b/aamp_app/devices/p4pp.py new file mode 100644 index 0000000..a1f2c93 --- /dev/null +++ b/aamp_app/devices/p4pp.py @@ -0,0 +1,541 @@ +import re +import csv +import os +import time +from collections import deque +from typing import Optional, Tuple + +from .device import SerialDevice, check_initialized, check_serial + + +class P4PP(SerialDevice): + """ + AAMP wrapper for the P4PP controller. + + Python-side behavior is based on the public driver in + https://github.com/changhwang/P4PP . + Firmware and hardware details should be referenced from + https://github.com/polyprintillinois/P4PP . + """ + + POS_PATTERN = re.compile(r"^POS LIN:\s*(-?\d+)\s+ROT:\s*(-?\d+)$") + RS_PATTERN = re.compile(r"Raw R_sheet:\s*(-?\d+(?:\.\d+)?)") + CYCLE_PATTERN = re.compile(r"^CYCLE:(\d+)\s+Rs:(-?\d+(?:\.\d+)?)$") + AVG_STD_PATTERN = re.compile(r"^AVG:(-?\d+(?:\.\d+)?)\s+STD:(-?\d+(?:\.\d+)?)$") + LIN_TARGET_PATTERN = re.compile(r"^OK LIN_TARGET:\s*(-?\d+)$") + ROT_TARGET_PATTERN = re.compile(r"^OK ROT_TARGET:\s*(-?\d+)$") + + LIN_STEPS_PER_MM = 200.0 + ROT_STEPS_PER_DEG = 4.444444 + LIN_MIN_STEPS = 0 + LIN_MAX_STEPS = 10000 + ROT_MIN_STEPS = 0 + ROT_MAX_STEPS = 1250 + + DEFAULT_STARTUP_DELAY_S = 2.0 + DEFAULT_COMMAND_TIMEOUT_S = 30.0 + DEFAULT_MOTION_TIMEOUT_S = 60.0 + DEFAULT_HOME_TIMEOUT_S = 60.0 + DEFAULT_MEASURE_TIMEOUT_S = 30.0 + DEFAULT_POLL_INTERVAL_S = 0.2 + DEFAULT_ROTATION_SAFETY_LINEAR_MM = 45.0 + DEFAULT_MEASUREMENT_RESISTOR_OHMS = 681.0 + DEFAULT_SAVE_DIRECTORY = "data/resistance/" + + RESPONSE_OK_MEASURE_COMPLETE = "OK MEASURE_COMPLETE" + RESPONSE_OK_HOMING_LIN_COMPLETE = "OK HOMING_LIN_COMPLETE" + RESPONSE_OK_HOMING_ROT_COMPLETE = "OK HOMING_ROT_COMPLETE" + RESPONSE_ERR_PREFIX = "ERR " + RESPONSE_ERROR_PREFIX = "ERROR:" + + COMMAND_MEASURE = "MEASURE" + COMMAND_MEASURE_N = "MEASURE_N" + COMMAND_MOVE_LIN = "MOVE_LIN" + COMMAND_MOVE_ROT = "MOVE_ROT" + COMMAND_HOME_LIN = "HOME_LIN" + COMMAND_HOME_ROT = "HOME_ROT" + COMMAND_GET_POS = "GET_POS" + COMMAND_STATUS = "STATUS" + + def __init__( + self, + name: str, + port: str, + baudrate: int = 115200, + timeout: Optional[float] = 0.2, + startup_delay: float = DEFAULT_STARTUP_DELAY_S, + command_timeout: float = DEFAULT_COMMAND_TIMEOUT_S, + motion_timeout: float = DEFAULT_MOTION_TIMEOUT_S, + home_timeout: float = DEFAULT_HOME_TIMEOUT_S, + measure_timeout: float = DEFAULT_MEASURE_TIMEOUT_S, + poll_interval: float = DEFAULT_POLL_INTERVAL_S, + rotation_safety_linear_mm: float = DEFAULT_ROTATION_SAFETY_LINEAR_MM, + measurement_resistor_ohms: float = DEFAULT_MEASUREMENT_RESISTOR_OHMS, + save_directory: str = DEFAULT_SAVE_DIRECTORY, + ): + super().__init__(name, port, baudrate, timeout) + self._startup_delay = startup_delay + self._command_timeout = command_timeout + self._motion_timeout = motion_timeout + self._home_timeout = home_timeout + self._measure_timeout = measure_timeout + self._poll_interval = poll_interval + self._rotation_safety_linear_mm = rotation_safety_linear_mm + self._measurement_resistor_ohms = measurement_resistor_ohms + self._save_directory = save_directory + + self._has_homed_linear = False + self._has_homed_rotational = False + self._linear_steps = 0 + self._rotational_steps = 0 + self._target_linear_steps = None + self._target_rotational_steps = None + self._latest_result = None + self._latest_std = None + self._latest_raw_result = None + self._cycle_results = [] + self._recent_lines = deque(maxlen=200) + + def get_init_args(self) -> dict: + return { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "startup_delay": self._startup_delay, + "command_timeout": self._command_timeout, + "motion_timeout": self._motion_timeout, + "home_timeout": self._home_timeout, + "measure_timeout": self._measure_timeout, + "poll_interval": self._poll_interval, + "rotation_safety_linear_mm": self._rotation_safety_linear_mm, + "measurement_resistor_ohms": self._measurement_resistor_ohms, + "save_directory": self._save_directory, + } + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._startup_delay = args_dict["startup_delay"] + self._command_timeout = args_dict["command_timeout"] + self._motion_timeout = args_dict["motion_timeout"] + self._home_timeout = args_dict["home_timeout"] + self._measure_timeout = args_dict["measure_timeout"] + self._poll_interval = args_dict["poll_interval"] + self._rotation_safety_linear_mm = args_dict["rotation_safety_linear_mm"] + self._measurement_resistor_ohms = args_dict["measurement_resistor_ohms"] + self._save_directory = args_dict["save_directory"] + + @property + def has_homed_linear(self) -> bool: + return self._has_homed_linear + + @property + def has_homed_rotational(self) -> bool: + return self._has_homed_rotational + + @property + def latest_result(self): + return self._latest_result + + @property + def latest_std(self): + return self._latest_std + + @property + def latest_raw_result(self): + return self._latest_raw_result + + @property + def cycle_results(self): + return list(self._cycle_results) + + @staticmethod + def lin_steps_to_mm(steps: int) -> float: + return steps / P4PP.LIN_STEPS_PER_MM + + @staticmethod + def rot_steps_to_deg(steps: int) -> float: + return steps / P4PP.ROT_STEPS_PER_DEG + + @staticmethod + def mm_to_lin_steps(mm: float) -> int: + return int(round(mm * P4PP.LIN_STEPS_PER_MM)) + + @staticmethod + def deg_to_rot_steps(deg: float) -> int: + return int(round(deg * P4PP.ROT_STEPS_PER_DEG)) + + def start_serial(self, delay: Optional[float] = None) -> Tuple[bool, str]: + if delay is None: + delay = self._startup_delay + return super().start_serial(delay=delay) + + def _send(self, command: str) -> None: + self.ser.write((command.strip() + "\n").encode("utf-8")) + + def _readline(self) -> Tuple[bool, str]: + response = self.ser.readline() + if response == b"": + return (False, "") + return (True, response.decode("utf-8", errors="ignore").strip()) + + def _clear_serial_buffer(self) -> None: + if self.ser.is_open: + self.ser.reset_input_buffer() + + def _handle_line(self, line: str) -> Tuple[bool, Optional[str]]: + if not line: + return (True, None) + + self._recent_lines.append(line) + + if line.startswith(self.RESPONSE_ERR_PREFIX) or line.startswith(self.RESPONSE_ERROR_PREFIX): + return (False, line) + + if line == self.RESPONSE_OK_HOMING_LIN_COMPLETE: + self._has_homed_linear = True + self._linear_steps = 0 + self._target_linear_steps = None + return (True, line) + + if line == self.RESPONSE_OK_HOMING_ROT_COMPLETE: + self._has_homed_rotational = True + self._rotational_steps = 0 + self._target_rotational_steps = None + return (True, line) + + rs_match = self.RS_PATTERN.search(line) + if rs_match: + self._latest_raw_result = float(rs_match.group(1)) + self._latest_result = self._latest_raw_result + return (True, line) + + cycle_match = self.CYCLE_PATTERN.match(line) + if cycle_match: + self._cycle_results.append(float(cycle_match.group(2))) + return (True, line) + + avg_std_match = self.AVG_STD_PATTERN.match(line) + if avg_std_match: + self._latest_raw_result = float(avg_std_match.group(1)) + self._latest_result = self._latest_raw_result + self._latest_std = float(avg_std_match.group(2)) + return (True, line) + + pos_match = self.POS_PATTERN.match(line) + if pos_match: + self._linear_steps = int(pos_match.group(1)) + self._rotational_steps = int(pos_match.group(2)) + return (True, line) + + lin_target_match = self.LIN_TARGET_PATTERN.match(line) + if lin_target_match: + self._target_linear_steps = int(lin_target_match.group(1)) + return (True, line) + + rot_target_match = self.ROT_TARGET_PATTERN.match(line) + if rot_target_match: + self._target_rotational_steps = int(rot_target_match.group(1)) + return (True, line) + + return (True, line) + + def _wait_for_condition( + self, + predicate, + timeout_s: float, + poll_position: bool = False, + ) -> Tuple[bool, str]: + deadline = time.monotonic() + timeout_s + last_poll_at = 0.0 + while time.monotonic() < deadline: + now = time.monotonic() + if poll_position and (now - last_poll_at) >= self._poll_interval: + self._send(self.COMMAND_GET_POS) + last_poll_at = now + + was_successful, line = self._readline() + if not was_successful: + continue + + was_successful, error_message = self._handle_line(line) + if not was_successful: + return (False, error_message) + + if predicate(line): + return (True, line) + + return (False, "Timed out waiting for P4PP response.") + + def _rotation_is_safe(self) -> Tuple[bool, str]: + linear_mm = self.lin_steps_to_mm(self._linear_steps) + if linear_mm >= self._rotation_safety_linear_mm: + return ( + False, + f"P4PP rotation blocked: linear axis is at {linear_mm:.3f} mm, which is at or above the safety limit of {self._rotation_safety_linear_mm:.3f} mm. Retract the probe first.", + ) + return (True, "") + + @staticmethod + def _normalize_measurement_resistor_ohms(resistor_ohms: float) -> Tuple[bool, float]: + if abs(float(resistor_ohms) - 681.0) < 1e-6: + return (True, 681.0) + if abs(float(resistor_ohms) - 68.1) < 1e-6: + return (True, 68.1) + return (False, float(resistor_ohms)) + + def get_measurement_resistor_info(self) -> dict: + if abs(self._measurement_resistor_ohms - 68.1) < 1e-6: + return {"R_set": 68.1, "label": "68.1 ohm", "range": "<= 10 kOhm/sq"} + return {"R_set": 681.0, "label": "681 ohm", "range": "1 kOhm/sq - 100 kOhm/sq"} + + @staticmethod + def _ensure_csv_directory(csv_path: str) -> None: + directory = os.path.dirname(csv_path) + if directory: + os.makedirs(directory, exist_ok=True) + + def build_measurement_csv_path(self, filename: str = "p4pp_measurements.csv", directory: Optional[str] = None) -> str: + if directory is None: + directory = self._save_directory + return os.path.join(directory, filename) + + @check_initialized + @check_serial + def set_measurement_resistor(self, resistor_ohms: float) -> Tuple[bool, str]: + was_successful, normalized = self._normalize_measurement_resistor_ohms(resistor_ohms) + if not was_successful: + return (False, "P4PP measurement resistor must be either 681 or 68.1 ohm.") + self._measurement_resistor_ohms = normalized + info = self.get_measurement_resistor_info() + return (True, f"P4PP measurement resistor set to {info['label']} ({info['range']}).") + + @check_serial + def initialize(self) -> Tuple[bool, str]: + was_successful, response = self.refresh_position() + if not was_successful: + self._is_initialized = False + return (was_successful, response) + self._is_initialized = True + return ( + True, + "Successfully initialized P4PP. " + + "For firmware and hardware details, see https://github.com/polyprintillinois/P4PP .", + ) + + def deinitialize(self) -> Tuple[bool, str]: + self._is_initialized = False + return (True, "Successfully deinitialized P4PP.") + + @check_serial + def refresh_position(self) -> Tuple[bool, str]: + self._clear_serial_buffer() + self._send(self.COMMAND_GET_POS) + was_successful, response = self._wait_for_condition( + lambda line: bool(self.POS_PATTERN.match(line)), + self._command_timeout, + poll_position=False, + ) + if not was_successful: + return (was_successful, response) + return ( + True, + "Linear position: " + + f"{self.lin_steps_to_mm(self._linear_steps):.3f} mm, rotational position: {self.rot_steps_to_deg(self._rotational_steps):.3f} deg.", + ) + + @check_initialized + @check_serial + def home_linear(self) -> Tuple[bool, str]: + self._clear_serial_buffer() + self._send(self.COMMAND_HOME_LIN) + was_successful, response = self._wait_for_condition( + lambda line: line == self.RESPONSE_OK_HOMING_LIN_COMPLETE, + self._home_timeout, + poll_position=True, + ) + if not was_successful: + return (was_successful, response) + return (True, "Successfully homed P4PP linear axis.") + + @check_initialized + @check_serial + def home_rotational(self) -> Tuple[bool, str]: + was_successful, message = self._rotation_is_safe() + if not was_successful: + return (was_successful, message) + self._clear_serial_buffer() + self._send(self.COMMAND_HOME_ROT) + was_successful, response = self._wait_for_condition( + lambda line: line == self.RESPONSE_OK_HOMING_ROT_COMPLETE, + self._home_timeout, + poll_position=True, + ) + if not was_successful: + return (was_successful, response) + return (True, "Successfully homed P4PP rotational axis.") + + @check_initialized + @check_serial + def home_all(self) -> Tuple[bool, str]: + was_successful, response = self.home_linear() + if not was_successful: + return (was_successful, response) + return self.home_rotational() + + @check_initialized + @check_serial + def move_linear_mm(self, position_mm: float, relative: bool = False) -> Tuple[bool, str]: + if not self._has_homed_linear: + return (False, "P4PP linear axis must be homed before moving.") + + target_steps = self.mm_to_lin_steps(position_mm) + final_target = self._linear_steps + target_steps if relative else target_steps + if final_target < self.LIN_MIN_STEPS or final_target > self.LIN_MAX_STEPS: + return ( + False, + f"P4PP linear target {self.lin_steps_to_mm(final_target):.3f} mm is outside the allowed range.", + ) + + self._clear_serial_buffer() + self._target_linear_steps = final_target + self._send(f"{self.COMMAND_MOVE_LIN} {final_target}") + was_successful, response = self._wait_for_condition( + lambda line: self._linear_steps == final_target, + self._motion_timeout, + poll_position=True, + ) + if not was_successful: + return (was_successful, response) + return ( + True, + f"Successfully moved P4PP linear axis to {self.lin_steps_to_mm(final_target):.3f} mm.", + ) + + @check_initialized + @check_serial + def move_rotational_deg(self, position_deg: float, relative: bool = False) -> Tuple[bool, str]: + if not self._has_homed_rotational: + return (False, "P4PP rotational axis must be homed before moving.") + was_successful, message = self._rotation_is_safe() + if not was_successful: + return (was_successful, message) + + target_steps = self.deg_to_rot_steps(position_deg) + final_target = self._rotational_steps + target_steps if relative else target_steps + if final_target < self.ROT_MIN_STEPS or final_target > self.ROT_MAX_STEPS: + return ( + False, + f"P4PP rotational target {self.rot_steps_to_deg(final_target):.3f} deg is outside the allowed range.", + ) + + self._clear_serial_buffer() + self._target_rotational_steps = final_target + self._send(f"{self.COMMAND_MOVE_ROT} {final_target}") + was_successful, response = self._wait_for_condition( + lambda line: self._rotational_steps == final_target, + self._motion_timeout, + poll_position=True, + ) + if not was_successful: + return (was_successful, response) + return ( + True, + f"Successfully moved P4PP rotational axis to {self.rot_steps_to_deg(final_target):.3f} deg.", + ) + + @check_initialized + @check_serial + def measure(self, cycles: int = 1) -> Tuple[bool, str]: + if cycles < 1: + return (False, "Measurement cycles must be at least 1.") + + self._latest_result = None + self._latest_std = None + self._latest_raw_result = None + self._cycle_results = [] + + self._clear_serial_buffer() + if cycles == 1: + self._send(self.COMMAND_MEASURE) + else: + self._send(f"{self.COMMAND_MEASURE_N} {int(cycles)}") + + was_successful, response = self._wait_for_condition( + lambda line: line == self.RESPONSE_OK_MEASURE_COMPLETE, + self._measure_timeout, + poll_position=False, + ) + if not was_successful: + return (was_successful, response) + + if self._latest_result is None: + info = self.get_measurement_resistor_info() + return (True, f"P4PP measurement completed with {info['label']}, but no parsed R_sheet value was received.") + + info = self.get_measurement_resistor_info() + message = f"P4PP measurement complete. R_sheet={self._latest_result}" + if self._latest_std is not None: + message += f", std={self._latest_std}" + if cycles > 1: + message += f", cycles={cycles}" + message += f", R_set={info['label']}" + return (True, message) + + @check_initialized + def save_measurement_csv( + self, + sample_id: Optional[str] = None, + csv_path: Optional[str] = None, + notes: Optional[str] = None, + ) -> Tuple[bool, str]: + if self._latest_result is None: + return (False, "No P4PP measurement result is available to save.") + + if csv_path is None: + csv_path = self.build_measurement_csv_path() + + self._ensure_csv_directory(csv_path) + file_exists = os.path.isfile(csv_path) + info = self.get_measurement_resistor_info() + row = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "sample_id": sample_id or "", + "linear_position_mm": self.lin_steps_to_mm(self._linear_steps), + "rotational_position_deg": self.rot_steps_to_deg(self._rotational_steps), + "cycles": len(self._cycle_results) if self._cycle_results else 1, + "r_set_ohms": info["R_set"], + "r_sheet": self._latest_result, + "r_sheet_std": self._latest_std if self._latest_std is not None else "", + "raw_r_sheet": self._latest_raw_result if self._latest_raw_result is not None else "", + "notes": notes or "", + } + fieldnames = list(row.keys()) + try: + with open(csv_path, "a", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + if not file_exists: + writer.writeheader() + writer.writerow(row) + except Exception as exc: + return (False, "Failed to save P4PP measurement CSV: " + str(exc)) + + return (True, "Successfully saved P4PP measurement to " + csv_path) + + @check_initialized + @check_serial + def get_linear_position_mm(self) -> Tuple[bool, float]: + return (True, self.lin_steps_to_mm(self._linear_steps)) + + @check_initialized + @check_serial + def get_rotational_position_deg(self) -> Tuple[bool, float]: + return (True, self.rot_steps_to_deg(self._rotational_steps)) + + def drain_recent_lines(self): + lines = list(self._recent_lines) + self._recent_lines.clear() + return lines diff --git a/devices/psd6_syringe_pump.py b/aamp_app/devices/psd6_syringe_pump.py similarity index 91% rename from devices/psd6_syringe_pump.py rename to aamp_app/devices/psd6_syringe_pump.py index 5302f44..8729bc7 100644 --- a/devices/psd6_syringe_pump.py +++ b/aamp_app/devices/psd6_syringe_pump.py @@ -63,6 +63,31 @@ def __init__( # self._min_position = 0 # self._max_position = 6000 + def get_init_args(self) -> dict: + args_dict = { + 'name': self.name, + 'port': self.port, + 'baudrate': self.baudrate, + 'timeout': self.timeout, + 'stroke_volume': self._stroke_volume, + 'stroke_steps': self._stroke_steps, + 'default_flowrate': self._default_flowrate, + 'port_dead_volumes': self._port_dead_volumes, + 'poll_interval': self._poll_interval + } + return args_dict + + def update_init_args(self, args_dict: dict): + self.name = args_dict['name'] + self.port = args_dict['port'] + self.baudrate = args_dict['baudrate'] + self.timeout = args_dict['timeout'] + self._stroke_volume = args_dict['stroke_volume'] + self._stroke_steps = args_dict['stroke_steps'] + self._default_flowrate = args_dict['default_flowrate'] + self._port_dead_volumes = args_dict['port_dead_volumes'] + self._poll_interval = args_dict['poll_interval'] + @property def default_flowrate(self) -> float: return self._default_flowrate diff --git a/aamp_app/devices/sciencetech_uhe_nl_solar_sim.py b/aamp_app/devices/sciencetech_uhe_nl_solar_sim.py new file mode 100644 index 0000000..0de21ad --- /dev/null +++ b/aamp_app/devices/sciencetech_uhe_nl_solar_sim.py @@ -0,0 +1,398 @@ +import re +import time +from typing import Optional, Tuple + +from .device import SerialDevice, check_initialized, check_serial + + +class SciencetechUHENLSolarSim(SerialDevice): + """ + RS-232 wrapper for the Sciencetech UHE-NL solar simulator power control path. + + The UHE-NL operating instructions confirm that RS-232 computer control is available, + while the detailed command strings are inferred from the existing lab draft in + `to_implement/sciencetech_lamp.py`. + """ + + STATUS_LINE_MAP = { + "current": 3, + "voltage": 4, + "power": 5, + "po": 6, + "cool": 7, + "lamp": 8, + "starts": 9, + "runtime": 10, + "output": 11, + "hours": 12, + "lamp minutes": 13, + "shutter": 14, + "attenuator": 15, + "stabilization": 16, + } + STATUS_LABEL_MAP = { + "current": ("CURRENT", "I"), + "voltage": ("VOLTAGE", "V"), + "power": ("POWER", "P"), + "po": ("PO",), + "cool": ("COOL", "COOLING", "FANS"), + "lamp": ("LAMP",), + "starts": ("STARTS",), + "runtime": ("RUNTIME", "RUN TIME", "TIME"), + "output": ("OUTPUT",), + "hours": ("HOURS",), + "lamp minutes": ("MINUTES", "LAMP MINUTES"), + "shutter": ("SHUTTER",), + "attenuator": ("ATTENUATOR", "TRANSMISSION"), + "stabilization": ("STABILIZATION",), + } + + def __init__( + self, + name: str, + port: str, + baudrate: int = 9600, + timeout: Optional[float] = 2.0, + line_terminator: str = "\r", + connect_delay_s: float = 3.0, + command_delay_s: float = 8.0, + status_timeout_s: float = 8.0, + default_current_percent: float = 85.0, + default_attenuator_percent: int = 100, + debug_io: bool = False, + ): + super().__init__(name, port, baudrate, timeout) + self._line_terminator = line_terminator + self._connect_delay_s = connect_delay_s + self._command_delay_s = command_delay_s + self._status_timeout_s = status_timeout_s + self._default_current_percent = default_current_percent + self._default_attenuator_percent = default_attenuator_percent + self._debug_io = debug_io + self._requested_output_percent = default_current_percent + + def get_init_args(self) -> dict: + return { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "line_terminator": self._line_terminator, + "connect_delay_s": self._connect_delay_s, + "command_delay_s": self._command_delay_s, + "status_timeout_s": self._status_timeout_s, + "default_current_percent": self._default_current_percent, + "default_attenuator_percent": self._default_attenuator_percent, + "debug_io": self._debug_io, + } + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._line_terminator = args_dict["line_terminator"] + self._connect_delay_s = args_dict["connect_delay_s"] + self._command_delay_s = args_dict["command_delay_s"] + self._status_timeout_s = args_dict["status_timeout_s"] + self._default_current_percent = args_dict["default_current_percent"] + self._default_attenuator_percent = args_dict["default_attenuator_percent"] + self._debug_io = args_dict["debug_io"] + self._requested_output_percent = self._default_current_percent + + def _debug(self, message: str): + if self._debug_io: + print(f"[{self._name} debug] {message}") + + def connect(self) -> Tuple[bool, str]: + return self.start_serial(delay=self._connect_delay_s) + + @check_serial + def initialize(self) -> Tuple[bool, str]: + was_successful, message = self.get_feedback("lamp") + if not was_successful: + self._is_initialized = False + return (False, message) + lamp_state = self._extract_last_number(message) + if lamp_state is not None and int(lamp_state) == 1: + self._is_initialized = False + return (False, "Lamp is currently on. Turn the lamp off before initialize.") + + was_successful, message = self.enable_cooling() + if not was_successful: + self._is_initialized = False + return (False, message) + + if self._default_attenuator_percent >= 100: + was_successful, message = self.open_attenuator() + else: + was_successful, message = self.set_attenuator(self._default_attenuator_percent) + if not was_successful: + self._is_initialized = False + return (False, message) + + was_successful, message = self.set_current(self._default_current_percent) + if not was_successful: + self._is_initialized = False + return (False, message) + + self._is_initialized = True + return ( + True, + "Successfully initialized the UHE-NL solar simulator control path. " + + f"Cooling on, attenuator set to {self._default_attenuator_percent}%, " + + f"output setpoint set to {self._default_current_percent:.1f}%.", + ) + + def deinitialize(self, reset_init_flag: bool = True, close_serial: bool = False) -> Tuple[bool, str]: + lamp_message = None + if self.ser.is_open: + was_successful, message = self.get_feedback("lamp") + if not was_successful: + return (False, message) + lamp_state = self._extract_last_number(message) + if lamp_state is not None and int(lamp_state) == 1: + was_successful, lamp_message = self.disable_arc_lamp() + if not was_successful: + return (False, lamp_message) + if close_serial and self.ser.is_open: + self.ser.close() + if reset_init_flag: + self._is_initialized = False + message = "Successfully deinitialized the UHE-NL solar simulator interface." + if lamp_message is not None: + message += " Arc lamp was turned off. Cooling was left on for post-shutdown cooldown." + return (True, message) + + @check_serial + def _send_command(self, command: str): + self._debug(f"TX -> {command!r}") + self.ser.reset_input_buffer() + self.ser.reset_output_buffer() + self.ser.write((command + self._line_terminator).encode("ascii")) + self.ser.flush() + time.sleep(self._command_delay_s) + + @staticmethod + def _extract_last_number(text: str) -> Optional[float]: + matches = re.findall(r"-?\d+(?:\.\d+)?", text) + if not matches: + return None + return float(matches[-1]) + + @check_serial + def get_status(self) -> Tuple[bool, list]: + self._debug("TX -> 'FS'") + self.ser.reset_input_buffer() + self.ser.reset_output_buffer() + self.ser.write(("FS" + self._line_terminator).encode("ascii")) + self.ser.flush() + + answer = [] + start_t = time.time() + while (time.time() - start_t) < self._status_timeout_s: + line = self.ser.readline().decode("ascii", errors="ignore").strip() + if not line: + continue + self._debug(f"RX <- {line!r}") + if line == "END": + return (True, answer) + answer.append(line) + + return (False, "Timed out while waiting for full status response.") + + @check_serial + def get_feedback(self, feedback_type: str) -> Tuple[bool, str]: + type_lower = feedback_type.lower() + if type_lower not in self.STATUS_LINE_MAP: + return (False, "Invalid feedback type") + + was_successful, response = self.get_status() + if not was_successful: + return (False, response) + + prefixes = self.STATUS_LABEL_MAP.get(type_lower, ()) + for line in response: + line_upper = line.upper() + for prefix in prefixes: + normalized_prefix = prefix.upper() + if line_upper.startswith(normalized_prefix + "=") or line_upper.startswith(normalized_prefix + ":"): + return (True, line) + + line_index = self.STATUS_LINE_MAP[type_lower] + if len(response) <= line_index: + return (False, "Status response did not contain expected feedback lines.") + + return (True, response[line_index]) + + def _verify_binary_feedback(self, feedback_type: str, expected: int, label: str, state_text: str, action_verb: str): + was_successful, message = self.get_feedback(feedback_type) + if not was_successful: + return (False, message) + + value = self._extract_last_number(message) + if value is None: + return (False, f"Failed to parse {label} feedback: {message}") + if int(value) == expected: + return (True, f"Successfully {action_verb} {label.lower()}.") + return (False, f"{label} did not reach the {state_text} state after command. Feedback: {message}") + + @check_serial + def close_shutter(self) -> Tuple[bool, str]: + was_successful, message = self.get_feedback("shutter") + if not was_successful: + return (False, message) + value = self._extract_last_number(message) + if value is not None and int(value) == 1: + return (True, "Shutter is closed.") + + self._send_command("S1") + return self._verify_binary_feedback("shutter", 1, "Shutter", "closed", "close") + + @check_serial + def open_shutter(self) -> Tuple[bool, str]: + was_successful, message = self.get_feedback("shutter") + if not was_successful: + return (False, message) + value = self._extract_last_number(message) + if value is not None and int(value) == 0: + return (True, "Shutter is open.") + + self._send_command("S0") + return self._verify_binary_feedback("shutter", 0, "Shutter", "open", "open") + + @check_serial + def enable_cooling(self) -> Tuple[bool, str]: + was_successful, message = self.get_feedback("cool") + if not was_successful: + return (False, message) + value = self._extract_last_number(message) + if value is not None and int(value) == 1: + return (True, "Cooling is enabled.") + + self._send_command("C1") + return self._verify_binary_feedback("cool", 1, "Cooling", "enabled", "enable") + + @check_serial + def disable_cooling(self) -> Tuple[bool, str]: + was_successful, message = self.get_feedback("cool") + if not was_successful: + return (False, message) + value = self._extract_last_number(message) + if value is not None and int(value) == 0: + return (True, "Cooling is disabled.") + + self._send_command("C0") + return self._verify_binary_feedback("cool", 0, "Cooling", "disabled", "disable") + + @check_serial + def enable_arc_lamp(self) -> Tuple[bool, str]: + was_successful, message = self.get_feedback("cool") + if not was_successful: + return (False, message) + cool_state = self._extract_last_number(message) + if cool_state is None: + return (False, f"Failed to parse cooling feedback: {message}") + if int(cool_state) != 1: + return (False, "Cooling must be on before enabling the arc lamp.") + + was_successful, message = self.get_feedback("lamp") + if not was_successful: + return (False, message) + value = self._extract_last_number(message) + if value is not None and int(value) == 1: + return (True, "Arc lamp is enabled.") + + self._send_command("L1") + return self._verify_binary_feedback("lamp", 1, "Arc lamp", "enabled", "enable") + + @check_serial + def disable_arc_lamp(self) -> Tuple[bool, str]: + was_successful, message = self.get_feedback("lamp") + if not was_successful: + return (False, message) + value = self._extract_last_number(message) + if value is not None and int(value) == 0: + return (True, "Arc lamp is disabled.") + + self._send_command("L0") + return self._verify_binary_feedback("lamp", 0, "Arc lamp", "disabled", "disable") + + @check_serial + def open_attenuator(self) -> Tuple[bool, str]: + self._send_command("A1xxxx") + was_successful, message = self.get_feedback("attenuator") + if not was_successful: + return (False, message) + + percent_read = self._extract_last_number(message) + if percent_read is None: + return (False, f"Failed to parse attenuator feedback: {message}") + if int(round(percent_read)) == 100: + return (True, "Successfully opened attenuator to 100%.") + return (False, f"Failed to fully open attenuator. Feedback: {message}") + + @check_serial + def set_attenuator(self, percent: int) -> Tuple[bool, str]: + if percent < 0 or percent > 100: + return (False, "Invalid attenuator percentage") + + self._send_command(f"A={int(percent):03d}x") + was_successful, message = self.get_feedback("attenuator") + if not was_successful: + return (False, message) + + percent_read = self._extract_last_number(message) + if percent_read is None: + return (False, f"Failed to parse attenuator feedback: {message}") + if int(round(percent_read)) == int(percent): + return (True, f"Successfully set attenuator transmission to {percent}%.") + return (False, f"Failed to set attenuator transmission to {percent}%. Feedback: {message}") + + @check_serial + def set_current(self, percent: float) -> Tuple[bool, str]: + if percent < 0 or percent > 100: + return (False, "Invalid current percentage") + + scaled = int(round(percent * 10)) + self._requested_output_percent = percent + self._send_command(f"P={scaled:04d}") + + was_successful, lamp_feedback = self.get_feedback("lamp") + if not was_successful: + return (False, lamp_feedback) + lamp_state = self._extract_last_number(lamp_feedback) + + was_successful, message = self.get_feedback("output") + if not was_successful: + return (False, message) + + percent_read = self._extract_last_number(message) + if percent_read is None: + return (False, f"Failed to parse output current feedback: {message}") + percent_read = percent_read / 10.0 if percent_read > 100 else percent_read + + if lamp_state is not None and int(lamp_state) == 0: + if abs(percent_read - percent) <= 0.2: + return (True, f"Successfully set output current setpoint to {percent:.1f}%.") + self._debug( + "Lamp is off; controller is reporting OUTPUT while lamp is off as " + + f"{message!r}. Treating the setpoint command as accepted." + ) + return ( + True, + f"Sent output current setpoint command for {percent:.1f}% while lamp was off. " + + f"Controller feedback remained {message}.", + ) + + if abs(percent_read - percent) <= 0.2: + return (True, f"Successfully set output current setpoint to {percent:.1f}%.") + was_successful, status = self.get_status() + if was_successful: + status_text = " | ".join(status) + return ( + False, + f"Failed to set output current setpoint to {percent:.1f}%. " + + f"Feedback: {message}. Full status: {status_text}", + ) + return (False, f"Failed to set output current setpoint to {percent:.1f}%. Feedback: {message}") diff --git a/aamp_app/devices/sht85_sensor.py b/aamp_app/devices/sht85_sensor.py new file mode 100644 index 0000000..eddf4f1 --- /dev/null +++ b/aamp_app/devices/sht85_sensor.py @@ -0,0 +1,60 @@ +from .device import ArduinoSerialDevice, SerialDevice, check_initialized, check_serial +import serial +from time import sleep +from typing import Optional, Tuple + + +class SHT85HumidityTempSensor(ArduinoSerialDevice): + """Humidity and Temperature Sensor""" + + def __init__( + self, name: str, port: str, baudrate: int = 9600, timeout: Optional[float] = 1.0 + ): + super().__init__(name, port, baudrate, timeout) + # self.ser = serial.Serial() + # self.ser.bytesize = serial.EIGHTBITS + # self.ser.parity = serial.PARITY_NONE + + def get_init_args(self) -> dict: + args_dict = { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + } + return args_dict + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + + def initialize(self) -> Tuple[bool, str]: + # self.ser.setDTR(False) + # self.ser.flushInput() + # self.ser.setDTR(True) + # self.ser.open() + self._is_initialized = True + return (True, "Initialized humidity and temperature sensor") + + def deinitialize(self) -> Tuple[bool, str]: + self.ser.close() + self._is_initialized = False + return (True, "Deinitialized humidity and temperature sensor") + + @check_serial + @check_initialized + def get_humidity(self) -> Tuple[bool, float, str]: + self.ser.write(b"H") + sleep(0.75) + humidity = float(self.ser.readline().strip().decode()) + return (True, f"The relative humidity is {humidity}%") + + @check_serial + @check_initialized + def get_temp(self) -> Tuple[bool, float, str]: + self.ser.write(b"T") + sleep(0.75) + temp = float(self.ser.readline().strip().decode()) + return (True, f"The temperature is {temp} degrees C") diff --git a/aamp_app/devices/sonicator.py b/aamp_app/devices/sonicator.py new file mode 100644 index 0000000..e9e2739 --- /dev/null +++ b/aamp_app/devices/sonicator.py @@ -0,0 +1,244 @@ +import time +from typing import Optional, Tuple + +from .device import ArduinoSerialDevice, check_initialized, check_serial + + +class Sonicator(ArduinoSerialDevice): + """ + Arduino Uno R3 wrapper for a sonicator front-panel button and status LED interface. + + The expected wiring follows the older lab draft: + - Uno `5V` to sonicator interface board `5V` + - Uno `GND` to sonicator interface board `GND` + - Uno `D7` as the sonicator button-drive output + - Uno `D8` as the sonication-status input + + Serial protocol: + - `>status` + - `>button` + - `>power` + - `>turnon` + - `>turnoff` + """ + + RESPONSE_MESSAGES = { + "SIW": "Sonicator is sonicating.", + "SNW": "Sonicator is idle.", + "BIP": "Pressed the sonicator button.", + "PIO": "Sonicator power connection is present.", + "PNO": "Sonicator power connection is not present.", + "SAN": "Sonicator is already sonicating.", + "STN": "Successfully started sonication.", + "SAF": "Sonicator is already idle.", + "STF": "Successfully stopped sonication.", + "INV": "Invalid command.", + "ERR": "Controller reported a state-transition error.", + } + + def __init__( + self, + name: str, + port: str, + baudrate: int = 9600, + timeout: Optional[float] = 0.5, + connect_delay_s: float = 3.0, + response_timeout_s: float = 3.0, + power_probe_timeout_s: float = 5.0, + line_terminator: str = "\n", + command_prefix: str = ">", + debug_io: bool = False, + ): + super().__init__(name, port, baudrate, timeout) + self._connect_delay_s = connect_delay_s + self._response_timeout_s = response_timeout_s + self._power_probe_timeout_s = power_probe_timeout_s + self._line_terminator = line_terminator + self._command_prefix = command_prefix + self._debug_io = debug_io + + def get_init_args(self) -> dict: + return { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "connect_delay_s": self._connect_delay_s, + "response_timeout_s": self._response_timeout_s, + "power_probe_timeout_s": self._power_probe_timeout_s, + "line_terminator": self._line_terminator, + "command_prefix": self._command_prefix, + "debug_io": self._debug_io, + } + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._connect_delay_s = args_dict["connect_delay_s"] + self._response_timeout_s = args_dict["response_timeout_s"] + self._power_probe_timeout_s = args_dict["power_probe_timeout_s"] + self._line_terminator = args_dict["line_terminator"] + self._command_prefix = args_dict["command_prefix"] + self._debug_io = args_dict["debug_io"] + + def _debug(self, message: str): + if self._debug_io: + print(f"[{self._name} debug] {message}") + + def connect(self) -> Tuple[bool, str]: + return self.start_serial(delay=self._connect_delay_s) + + @check_serial + def initialize(self) -> Tuple[bool, str]: + was_successful, status_code = self._request_code("status", self._response_timeout_s) + if not was_successful: + self._is_initialized = False + return (False, status_code) + + init_message = "Successfully initialized sonicator control path. Sonicator is idle." + if status_code == "SIW": + was_successful, stop_message = self._stop_sonicating_internal() + if not was_successful: + self._is_initialized = False + return (False, stop_message) + init_message = ( + "Successfully initialized sonicator control path. " + + "Sonication was stopped during initialize." + ) + elif status_code != "SNW": + self._is_initialized = False + return (False, self._unexpected_code_message("status", status_code)) + + self._is_initialized = True + return ( + True, + init_message + " Power connection was not explicitly probed.", + ) + + def deinitialize(self, reset_init_flag: bool = True, close_serial: bool = False) -> Tuple[bool, str]: + stop_message = None + if self.ser.is_open: + was_successful, status_code = self._request_code("status", self._response_timeout_s) + if not was_successful: + return (False, status_code) + if status_code == "SIW": + was_successful, stop_message = self._stop_sonicating_internal() + if not was_successful: + return (False, stop_message) + elif status_code != "SNW": + return (False, self._unexpected_code_message("status", status_code)) + + if close_serial and self.ser.is_open: + self.ser.close() + if reset_init_flag: + self._is_initialized = False + + message = "Successfully deinitialized the sonicator interface." + if stop_message is not None: + message += " Sonication was stopped during deinitialize." + return (True, message) + + @check_serial + def probe_power_connection(self) -> Tuple[bool, str]: + was_successful, status_code = self._request_code("status", self._response_timeout_s) + if not was_successful: + return (False, status_code) + if status_code == "SIW": + return ( + True, + "Sonicator power connection is present. Status indicates sonication is active, so the intrusive power probe was skipped.", + ) + if status_code != "SNW": + return (False, self._unexpected_code_message("status", status_code)) + + was_successful, response_code = self._request_code("power", self._power_probe_timeout_s) + if not was_successful: + return (False, response_code) + if response_code == "PIO": + return (True, self.RESPONSE_MESSAGES[response_code]) + if response_code == "PNO": + return (False, self.RESPONSE_MESSAGES[response_code]) + return (False, self._unexpected_code_message("power", response_code)) + + @check_serial + def get_status(self) -> Tuple[bool, str]: + was_successful, status_code = self._request_code("status", self._response_timeout_s) + if not was_successful: + return (False, status_code) + if status_code in ("SIW", "SNW"): + return (True, self.RESPONSE_MESSAGES[status_code]) + return (False, self._unexpected_code_message("status", status_code)) + + @check_serial + @check_initialized + def start_sonicating(self) -> Tuple[bool, str]: + was_successful, response_code = self._request_code("turnon", self._response_timeout_s) + if not was_successful: + return (False, response_code) + if response_code in ("SAN", "STN"): + return (True, self.RESPONSE_MESSAGES[response_code]) + return (False, self._unexpected_code_message("turnon", response_code)) + + @check_serial + @check_initialized + def stop_sonicating(self) -> Tuple[bool, str]: + return self._stop_sonicating_internal() + + @check_serial + @check_initialized + def press_button(self) -> Tuple[bool, str]: + was_successful, response_code = self._request_code("button", self._response_timeout_s) + if not was_successful: + return (False, response_code) + if response_code == "BIP": + return (True, self.RESPONSE_MESSAGES[response_code]) + return (False, self._unexpected_code_message("button", response_code)) + + def _stop_sonicating_internal(self) -> Tuple[bool, str]: + was_successful, response_code = self._request_code("turnoff", self._response_timeout_s) + if not was_successful: + return (False, response_code) + if response_code in ("SAF", "STF"): + return (True, self.RESPONSE_MESSAGES[response_code]) + return (False, self._unexpected_code_message("turnoff", response_code)) + + def _unexpected_code_message(self, command_keyword: str, response_code: str) -> str: + if response_code in self.RESPONSE_MESSAGES: + return ( + f"Unexpected response for '{command_keyword}': " + + f"{response_code} ({self.RESPONSE_MESSAGES[response_code]})" + ) + return f"Received unknown response code for '{command_keyword}': {response_code}" + + @check_serial + def _request_code(self, command_keyword: str, response_timeout_s: float) -> Tuple[bool, str]: + command = f"{self._command_prefix}{command_keyword}{self._line_terminator}" + self.ser.reset_input_buffer() + self.ser.reset_output_buffer() + self._debug(f"TX -> {command!r}") + self.ser.write(command.encode("ascii")) + self.ser.flush() + return self._read_response_code(response_timeout_s) + + def _read_response_code(self, response_timeout_s: float) -> Tuple[bool, str]: + deadline = time.time() + response_timeout_s + chunks = [] + + while time.time() < deadline: + piece = self.ser.readline() + if not piece: + continue + chunks.append(piece) + if b"\n" in piece or b"\r" in piece: + break + + if not chunks: + return (False, "Timed out waiting for sonicator response.") + + response = b"".join(chunks).decode("ascii", errors="ignore").strip() + self._debug(f"RX <- {response!r}") + if response == "": + return (False, "Received an empty sonicator response.") + return (True, response) diff --git a/aamp_app/devices/stellarnet_spectrometer.py b/aamp_app/devices/stellarnet_spectrometer.py new file mode 100644 index 0000000..0ef8512 --- /dev/null +++ b/aamp_app/devices/stellarnet_spectrometer.py @@ -0,0 +1,1662 @@ +from typing import Union, Tuple, Dict, List, Optional +from datetime import datetime +import os +import re +import time + +import numpy as np +import pandas as pd +from scipy.interpolate import interp1d +from scipy.optimize import minimize +try: + import stellarnet_driver3 as sn + _driver_import_error = None +except Exception as exc: + sn = None + _driver_import_error = str(exc) + +from .device import Device, check_initialized + +# TODO +# convert to serial device parent when arduino added +# add shutter and lamp control +# check slicing as second index is not included in slice +# type hinting of ndarrays? +# type hint the static methods? + +class StellarNetSpectrometer(Device): + SUPPORTED_SPEC_KEYS = ("UV-Vis", "NIR") + save_directory = 'data/spectroscopy/' + MERGE_UV_START = 210.0 + MERGE_NIR_END = 1700.0 + MERGE_OVERLAP_START = 900.0 + MERGE_OVERLAP_END = 1030.0 + MERGE_WINDOW_NM = 10.0 + MERGE_RAW_ABS_TOLERANCE = 0.01 + MERGE_SIGNAL_MIN_P95 = 0.02 + MERGE_SCALE_MIN = 0.5 + MERGE_SCALE_MAX = 2.0 + MERGE_SCALE_FIT_MEDIAN_TOLERANCE = 0.02 + MERGE_FIXED_CROSSOVER_NM = 900.0 + + def __init__( + self, + name: str, + spec_keys: Union[str, List[str], Tuple[str, ...]] = ("UV-Vis",), + save_directory: str = save_directory, + default_integration_time: Optional[Union[int, List[int], Tuple[int, ...]]] = None): + super().__init__(name) + self.spectrometer_dict = {} + self.wavelength_dict = {} + self.dark_spectra_dict = {} + self.blank_spectra_dict = {} + self.absorbance_dict = {} + self.photoncounts_dict = {} + self.merged_absorbance = None + self.num_spectrometers = 0 + self.spec_keys = self._normalize_spec_keys(spec_keys) + self.save_directory = save_directory + self.default_integration_time = self._normalize_detector_setting( + default_integration_time, + "default_integration_time", + default_value=100, + ) + + def get_init_args(self) -> dict: + return { + "name": self._name, + "spec_keys": self.spec_keys, + "save_directory": self.save_directory, + "default_integration_time": self.default_integration_time, + } + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self.spec_keys = self._normalize_spec_keys(args_dict["spec_keys"]) + self.save_directory = args_dict["save_directory"] + self.default_integration_time = self._normalize_detector_setting( + args_dict.get("default_integration_time"), + "default_integration_time", + default_value=100, + ) + + @classmethod + def _normalize_spec_keys( + cls, + spec_keys: Union[str, List[str], Tuple[str, ...], None], + ) -> Tuple[str, ...]: + if spec_keys is None: + spec_keys = ("UV-Vis",) + elif isinstance(spec_keys, str): + spec_keys = (spec_keys,) + + normalized = [] + for spec_key in spec_keys: + cleaned_key = str(spec_key).strip().rstrip(",") + if cleaned_key not in cls.SUPPORTED_SPEC_KEYS: + raise ValueError( + "Unsupported spectrometer key: " + + cleaned_key + + ". Supported keys: " + + ", ".join(cls.SUPPORTED_SPEC_KEYS) + ) + if cleaned_key not in normalized: + normalized.append(cleaned_key) + + if not normalized: + raise ValueError("At least one spectrometer key must be selected.") + return tuple(normalized) + + def _normalize_detector_setting( + self, + values: Optional[Union[int, float, List[Union[int, float]], Tuple[Union[int, float], ...]]], + value_name: str, + default_value: int, + ) -> Tuple[int, ...]: + if values is None: + return tuple(default_value for _ in self.spec_keys) + + if isinstance(values, (int, float, np.integer, np.floating)): + normalized_values = [values] * len(self.spec_keys) + elif isinstance(values, str): + normalized_values = [values] * len(self.spec_keys) + else: + normalized_values = list(values) + if not normalized_values: + raise ValueError(value_name + " cannot be empty.") + if len(normalized_values) == 1: + normalized_values = normalized_values * len(self.spec_keys) + elif len(normalized_values) < len(self.spec_keys): + raise ValueError( + value_name + + " must provide at least " + + str(len(self.spec_keys)) + + " values for spec_keys " + + str(list(self.spec_keys)) + + "." + ) + elif len(normalized_values) > len(self.spec_keys): + normalized_values = normalized_values[:len(self.spec_keys)] + + return tuple(int(value) for value in normalized_values) + + def _normalize_measurement_settings( + self, + integration_times: Optional[Union[int, List[int], Tuple[int, ...]]] = None, + scans_to_avg: Union[int, List[int], Tuple[int, ...]] = (3, 3), + smoothings: Union[int, List[int], Tuple[int, ...]] = (0, 0), + xtimings: Union[int, List[int], Tuple[int, ...]] = (1, 1), + ) -> Tuple[Tuple[int, ...], Tuple[int, ...], Tuple[int, ...], Tuple[int, ...]]: + if integration_times is None: + integration_times = self.default_integration_time + return ( + self._normalize_detector_setting(integration_times, "integration_times", default_value=100), + self._normalize_detector_setting(scans_to_avg, "scans_to_avg", default_value=3), + self._normalize_detector_setting(smoothings, "smoothings", default_value=0), + self._normalize_detector_setting(xtimings, "xtimings", default_value=1), + ) + + def find_file(self, dir_path: str, substr: str, substr2: str) -> Optional[str]: + if not os.path.isdir(dir_path): + return None + + for _root, _dirs, files in os.walk(dir_path): + for fname in files: + if substr in fname and substr2 in fname: + return fname + return None + + def _measurement_qc_log_path(self, sample_name: str) -> str: + return os.path.join(self.save_directory, sample_name + "_measurement_qc_log.csv") + + def _append_measurement_qc_log(self, sample_name: str, qc_rows: List[dict]) -> None: + if not qc_rows: + return + os.makedirs(self.save_directory, exist_ok=True) + fullpath = self._measurement_qc_log_path(sample_name) + df = pd.DataFrame(qc_rows) + write_header = not os.path.exists(fullpath) + df.to_csv(fullpath, mode='a', index=False, header=write_header) + + @staticmethod + def _linear_interpolate( + x_source: np.ndarray, + y_source: np.ndarray, + x_new: np.ndarray, + ) -> np.ndarray: + if x_source.size < 2: + return np.full_like(x_new, np.nan, dtype=float) + source_order = np.argsort(x_source) + xs = x_source[source_order] + ys = y_source[source_order] + result = np.interp(x_new, xs, ys, left=np.nan, right=np.nan) + return result + + @staticmethod + def _trapz(x_values: np.ndarray, y_values: np.ndarray) -> float: + if x_values.size < 2 or y_values.size < 2: + return 0.0 + return float(np.trapezoid(y_values, x_values)) + + @staticmethod + def _compute_decay_metrics_with_spacing( + wavelength_values: np.ndarray, + reference_absorbance: np.ndarray, + current_absorbance: np.ndarray, + decay_threshold: float, + ) -> Tuple[float, float, float, float]: + valid_mask = ( + np.isfinite(wavelength_values) + & np.isfinite(reference_absorbance) + & np.isfinite(current_absorbance) + & (reference_absorbance > decay_threshold) + ) + if np.count_nonzero(valid_mask) < 2: + return (0.0, 0.0, 0.0, 0.0) + + x_valid = wavelength_values[valid_mask] + reference_valid = np.maximum(reference_absorbance[valid_mask], 0.0) + current_valid = np.nan_to_num(current_absorbance[valid_mask], nan=0.0, posinf=0.0, neginf=0.0) + signed_delta = current_valid - reference_valid + + norm = StellarNetSpectrometer._trapz(x_valid, reference_valid) + if norm <= 0: + return (0.0, 0.0, 0.0, 0.0) + + magnitude = np.abs(signed_delta) + positive = np.maximum(signed_delta, 0.0) + negative_abs = np.maximum(-signed_delta, 0.0) + + decay_mag = StellarNetSpectrometer._trapz(x_valid, magnitude) / norm + decay_signed = StellarNetSpectrometer._trapz(x_valid, signed_delta) / norm + decay_positive = StellarNetSpectrometer._trapz(x_valid, positive) / norm + decay_negative_abs = StellarNetSpectrometer._trapz(x_valid, negative_abs) / norm + return ( + float(decay_mag), + float(decay_signed), + float(decay_positive), + float(decay_negative_abs), + ) + + @staticmethod + def _parse_elapsed_seconds_from_columns(columns: List[str]) -> np.ndarray: + elapsed_seconds = [] + for column_name in columns: + match = re.search(r'(-?\d+(?:\.\d+)?)', str(column_name)) + if match is None: + elapsed_seconds.append(float('nan')) + else: + elapsed_seconds.append(float(match.group(1))) + return np.asarray(elapsed_seconds, dtype=float) + + @staticmethod + def _interpolate_crossing_time( + times: np.ndarray, + values: np.ndarray, + target: float, + ) -> Optional[float]: + valid = np.isfinite(times) & np.isfinite(values) + if np.count_nonzero(valid) < 2: + return None + times_valid = times[valid] + values_valid = values[valid] + order = np.argsort(times_valid) + times_valid = times_valid[order] + values_valid = values_valid[order] + + for idx in range(len(times_valid) - 1): + t0, t1 = times_valid[idx], times_valid[idx + 1] + v0, v1 = values_valid[idx], values_valid[idx + 1] + if v0 == target: + return float(t0) + if v1 == target: + return float(t1) + if (v0 - target) * (v1 - target) > 0: + continue + if v1 == v0: + return float(t0) + ratio = (target - v0) / (v1 - v0) + return float(t0 + ratio * (t1 - t0)) + return None + + def _load_irradiance_reference( + self, + irradiance_file: str, + wavelength_col_name: str, + irradiance_col_name: str, + ) -> Tuple[bool, Union[Tuple[np.ndarray, np.ndarray], str]]: + irradiance_path = os.path.join(self.save_directory, irradiance_file) + if not os.path.exists(irradiance_path): + return (False, "Irradiance table is missing: " + irradiance_path) + + last_error = None + for read_kwargs in ({}, {"sep": "\t"}): + try: + df_irrad = pd.read_csv(irradiance_path, **read_kwargs) + except Exception as exc: + last_error = str(exc) + continue + + wavelength_candidates = [wavelength_col_name, "wavelength_nm", "Wvlgth nm"] + irradiance_candidates = [ + irradiance_col_name, + "irradiance_w_m2_nm", + "irradiance(trapezoid : W/m2)", + ] + + wavelength_column = next((col for col in wavelength_candidates if col in df_irrad.columns), None) + irradiance_column = next((col for col in irradiance_candidates if col in df_irrad.columns), None) + if wavelength_column is None or irradiance_column is None: + continue + + wavelength = pd.to_numeric(df_irrad[wavelength_column], errors="coerce").to_numpy(dtype=float) + irradiance = pd.to_numeric(df_irrad[irradiance_column], errors="coerce").to_numpy(dtype=float) + valid = np.isfinite(wavelength) & np.isfinite(irradiance) + wavelength = wavelength[valid] + irradiance = irradiance[valid] + if wavelength.size < 2: + continue + + order = np.argsort(wavelength) + wavelength = wavelength[order] + irradiance = np.maximum(irradiance[order], 0.0) + return (True, (wavelength, irradiance)) + + error_message = last_error if last_error is not None else "Unsupported irradiance table format." + return (False, error_message) + + @staticmethod + def num_specs_connected() -> int: + if sn is None: + return 0 + num_connected = 0 + start_wav = [-1.] + while True: + try: + spec, wav = sn.array_get_spec(num_connected) + start_wav.append(wav[0].item()) + if start_wav[num_connected+1] == start_wav[num_connected]: + break + else: + num_connected += 1 + except: + break + return num_connected + + def initialize(self) -> Tuple[bool, str]: + #lamp on + #shutter in/out + #any other Arduino initialization + if sn is None: + self._is_initialized = False + return ( + False, + "stellarnet_driver3 is unavailable. " + + ("Driver import error: " + _driver_import_error + " " if _driver_import_error else "") + + "Add the vendor driver file and Python USB dependency referenced in README before using StellarNetSpectrometer.", + ) + + connected_spectrometers = self.num_specs_connected() + if connected_spectrometers == 0: + self._is_initialized = False + return (False, "There are no spectrometers connected") + + detected_spectrometer_dict = {} + detected_wavelength_dict = {} + for ndx in range(connected_spectrometers): + spectrometer, wavelengths = sn.array_get_spec(ndx) + if wavelengths[0] < 200.0: + # UV-VIS + detected_spectrometer_dict['UV-Vis'] = spectrometer + detected_wavelength_dict['UV-Vis'] = wavelengths + else: + # NIR + detected_spectrometer_dict['NIR'] = spectrometer + detected_wavelength_dict['NIR'] = wavelengths + # For additional spectrometers add to the if else ladder + + missing_spec_keys = [ + spec_key for spec_key in self.spec_keys if spec_key not in detected_spectrometer_dict + ] + if not missing_spec_keys: + self.spectrometer_dict = { + spec_key: detected_spectrometer_dict[spec_key] for spec_key in self.spec_keys + } + self.wavelength_dict = { + spec_key: detected_wavelength_dict[spec_key] for spec_key in self.spec_keys + } + self.num_spectrometers = len(self.spec_keys) + self._is_initialized = True + return ( + True, + "Successfully initialized requested spectrometers: " + + str(list(self.spec_keys)) + + ". Detected connected spectrometers: " + + str(list(detected_spectrometer_dict.keys())) + ) + + self._is_initialized = False + self.spectrometer_dict = detected_spectrometer_dict + self.wavelength_dict = detected_wavelength_dict + self.num_spectrometers = len(detected_spectrometer_dict) + return ( + False, + "Not all declared spectrometers were initialized. Missing: " + + str(missing_spec_keys) + + ". Detected connected spectrometers: " + + str(list(detected_spectrometer_dict.keys())) + ) + + def deinitialize(self, reset_init_flag: bool = True) -> Tuple[bool, str]: + # turn off lamp? + # move shutter the position so that initialize can be ready to move the shutter correct position when starting the instrument + if reset_init_flag: + self._is_initialized = False + + return (True, "Pass, nothing to deinitialize for now.") + + @check_initialized + def check_max_count( + self, + spec_key: str, + integration_time: int = 100, + scans_to_avg: int = 3, + smoothing: int = 0, + xtiming: int = 1) -> Tuple[bool, Union[float, str]]: + + if spec_key not in self.spectrometer_dict.keys(): + return (False, spec_key + " spectrometer is not found") + + self.spectrometer_dict[spec_key]['device'].set_config( + int_time=integration_time, + scans_to_avg=scans_to_avg, + x_smooth=smoothing, + x_timing=xtiming) + spectrum_array = sn.array_spectrum(self.spectrometer_dict[spec_key], self.wavelength_dict[spec_key]) + max_count = float(np.amax(spectrum_array[:, 1], axis=0)) + return (True, max_count) + + @check_initialized + def adjust_default_integration_time( + self, + scans_to_avg: Union[int, List[int], Tuple[int, ...]] = (3, 3), + smoothings: Union[int, List[int], Tuple[int, ...]] = (0, 0), + xtimings: Union[int, List[int], Tuple[int, ...]] = (1, 1), + target_max_count: int = 52000, + tolerance: int = 2000, + max_iterations: int = 8) -> Tuple[bool, str]: + + if self.num_spectrometers != len(self.spec_keys): + return (False, "Spectrometers are not all connected") + + scans_to_avg = self._normalize_detector_setting(scans_to_avg, "scans_to_avg", default_value=3) + smoothings = self._normalize_detector_setting(smoothings, "smoothings", default_value=0) + xtimings = self._normalize_detector_setting(xtimings, "xtimings", default_value=1) + integration_time_testing = list(self.default_integration_time) + for ndx, spec_key in enumerate(self.spec_keys): + for _ in range(max_iterations): + result, max_count = self.check_max_count( + spec_key, + integration_time=integration_time_testing[ndx], + scans_to_avg=scans_to_avg[ndx], + smoothing=smoothings[ndx], + xtiming=xtimings[ndx], + ) + if not result: + return (False, max_count) + + if max_count <= 0: + break + if abs(max_count - target_max_count) <= tolerance: + break + + next_integration_time = int(np.ceil(integration_time_testing[ndx] * target_max_count / max_count)) + next_integration_time = max(1, next_integration_time) + if next_integration_time == integration_time_testing[ndx]: + break + + integration_time_testing[ndx] = next_integration_time + time.sleep(0.5) + + self.default_integration_time = tuple(integration_time_testing) + return (True, "Successfully adjusted default integration time to " + str(self.default_integration_time)) + + @staticmethod + def _compute_absorbance(dark_spec, blank_spec, sam_spec): + sam_dark_diff = sam_spec - dark_spec + blank_dark_diff = blank_spec - dark_spec + absorbance = np.zeros_like(sam_dark_diff, dtype=float) + absorbance[(blank_dark_diff > 0) & (sam_dark_diff <= 0)] = 5.0 + valid_index = (blank_dark_diff > 0) & (sam_dark_diff > 0) + absorbance[valid_index] = -np.log10(sam_dark_diff[valid_index] / blank_dark_diff[valid_index]) + absorbance = np.nan_to_num(absorbance, nan=0.0, posinf=5.0, neginf=0.0) + absorbance[absorbance < 0] = 0 + absorbance[absorbance > 5] = 5 + return absorbance + + def _merge_absorbance_byname_onedetector( + self, + save_to_file: bool, + filename: str, + sample_name: str, + comment_list: List[str]) -> Tuple[bool, str]: + + uv_array = self.absorbance_dict[self.spec_keys[0]].copy() + uv_array = self.truncate_ends_by_wavelength(uv_array, 210.0, 1700.0) + merged_array = uv_array[uv_array[:, 0].argsort()] + self.merged_absorbance = merged_array + + if save_to_file: + os.makedirs(self.save_directory, exist_ok=True) + fname = self.find_file(self.save_directory, sample_name, 'merged.csv') + if fname is None: + data = pd.DataFrame() + data['Wavelength'] = np.squeeze(self.merged_absorbance[:, 0].copy()) + copy_abs = self.merged_absorbance[:, 1].copy() + copy_abs[copy_abs < 0] = 0 + data['0'] = np.squeeze(copy_abs) + fullfilename = os.path.join(self.save_directory, filename + '_merged.csv') + else: + fullfilename = os.path.join(self.save_directory, fname) + timestamp_match = re.search(r'_(\d+)_', fname) + time_interval = 0 + if timestamp_match is not None: + time_zero = datetime.strptime(timestamp_match.group(1), '%Y%m%d%H%M%S') + time_interval = int((datetime.now() - time_zero).total_seconds()) + df_old = pd.read_csv(fullfilename, comment='#') + df_new = pd.DataFrame() + df_new['Wavelength'] = np.squeeze(self.merged_absorbance[:, 0].copy()) + copy_abs = self.merged_absorbance[:, 1].copy() + copy_abs[copy_abs < 0] = 0 + df_new[str(time_interval)] = np.squeeze(copy_abs) + data = df_old.drop(columns='Index', errors='ignore').merge(df_new, how='inner', on='Wavelength') + + comment_list = list(comment_list) + comment_list.append("# To merge, NIR data is scaled first then shifted\n") + comment_list.append("# scale = none\n") + comment_list.append("# shift = none\n") + comment_list.append("# First column is Wavelength, later columns are absorbance at elapsed time in seconds\n") + with open(fullfilename, 'w') as file: + file.writelines(comment_list) + data.to_csv(fullfilename, mode='a', index_label='Index') + + return (True, "Successfully merged absorbance spectra for single detector") + + @check_initialized + def get_spectrum_counts( + self, + spec_key: str, + integration_time: int = 100, + scans_to_avg: int = 3, + smoothing: int = 0, + xtiming: int = 1) -> Tuple[bool, str]: + + # if not self._is_initialized: + # return (False, "Spectrometer system not initialized") + + if spec_key in self.spectrometer_dict.keys(): + self.spectrometer_dict[spec_key]['device'].set_config( + int_time=integration_time, + scans_to_avg=scans_to_avg, + x_smooth=smoothing, + x_timing=xtiming) + + spectrum_array = sn.array_spectrum(self.spectrometer_dict[spec_key], self.wavelength_dict[spec_key]) + + max_count = np.amax(spectrum_array[:,1], axis=0) + print("Max count: " + str(max_count)) + if max_count > 65500: + return (False, spec_key + " detector is saturated, lower integration time") + return (True, spectrum_array) + else: + return (False, spec_key + " spectrometer is not found" ) + + def get_all_spectra_counts( + self, + integration_times: Optional[Union[int, List[int], Tuple[int, ...]]] = None, + scans_to_avg: Union[int, List[int], Tuple[int, ...]] = (3, 3), + smoothings: Union[int, List[int], Tuple[int, ...]] = (0, 0), + xtimings: Union[int, List[int], Tuple[int, ...]] = (1, 1)) -> Tuple[bool, str]: + + # Modify the variable 'spec_keys' if you don't intend to use all spectrometers + if self.num_spectrometers != len(self.spec_keys): + return (False, "Spectrometers are not all connected") + + integration_times, scans_to_avg, smoothings, xtimings = self._normalize_measurement_settings( + integration_times, + scans_to_avg, + smoothings, + xtimings, + ) + spectrum_array_dict = {} + # print(spec_keys) + # using self.spec_keys to ensure that the order within the parameter tuple matches the order of the declared spec_keys + for ndx, spec_key in enumerate(self.spec_keys): + # this function should already check if the spec_key is valid + result, spectrum_array = self.get_spectrum_counts( + spec_key, + integration_time=integration_times[ndx], + scans_to_avg=scans_to_avg[ndx], + smoothing=smoothings[ndx], + xtiming=xtimings[ndx]) + if not result: + return result, spectrum_array + + spectrum_array_dict[spec_key] = spectrum_array + + return (True, spectrum_array_dict) + + + def update_all_dark_spectra( + self, + integration_times: Optional[Union[int, List[int], Tuple[int, ...]]] = None, + scans_to_avg: Union[int, List[int], Tuple[int, ...]] = (3, 3), + smoothings: Union[int, List[int], Tuple[int, ...]] = (0, 0), + xtimings: Union[int, List[int], Tuple[int, ...]] = (1, 1)) -> Tuple[bool, str]: + + result, spectrum_array_dict = self.get_all_spectra_counts( + integration_times, + scans_to_avg, + smoothings, + xtimings) + + if not result: + return result, spectrum_array_dict + + for key, value in spectrum_array_dict.items(): + self.dark_spectra_dict[key] = value + + return (True, "All dark spectra stored") + + def update_all_blank_spectra( + self, + integration_times: Optional[Union[int, List[int], Tuple[int, ...]]] = None, + scans_to_avg: Union[int, List[int], Tuple[int, ...]] = (3, 3), + smoothings: Union[int, List[int], Tuple[int, ...]] = (0, 0), + xtimings: Union[int, List[int], Tuple[int, ...]] = (1, 1)) -> Tuple[bool, str]: + + result, spectrum_array_dict = self.get_all_spectra_counts( + integration_times, + scans_to_avg, + smoothings, + xtimings) + + if not result: + return result, spectrum_array_dict + + for key, value in spectrum_array_dict.items(): + self.blank_spectra_dict[key] = value + + return (True, "All blank spectra stored") + + def get_all_absorbance( + self, + save_to_file: bool = False, + filename: Optional[str] = None, + integration_times: Optional[Union[int, List[int], Tuple[int, ...]]] = None, + scans_to_avg: Union[int, List[int], Tuple[int, ...]] = (3, 3), + smoothings: Union[int, List[int], Tuple[int, ...]] = (0, 0), + xtimings: Union[int, List[int], Tuple[int, ...]] = (1, 1)) -> Tuple[bool, str]: + + integration_times, scans_to_avg, smoothings, xtimings = self._normalize_measurement_settings( + integration_times, + scans_to_avg, + smoothings, + xtimings, + ) + + # get the sample spectra + result, spectrum_array_dict = self.get_all_spectra_counts( + integration_times, + scans_to_avg, + smoothings, + xtimings) + if save_to_file and filename is None: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = timestamp + + + # check if failed + if not result: + return result, spectrum_array_dict + + # check that dark/blank spectra exists for each initialized spectrometer + for key in self.spectrometer_dict.keys(): + if not key in self.blank_spectra_dict: + return (False, "Blank spectra for " + key + " is missing") + if not key in self.dark_spectra_dict: + return (False, "Dark spectra for " + key + " is missing") + + # Calculate absorbance for each spectra, save to self + # Absorbance is -log10((Isample - Idark)/(Iblank - Idark)) + for ndx, spec_key in enumerate(self.spec_keys): + wavelength = self.wavelength_dict[spec_key].copy() + dark_spec = self.dark_spectra_dict[spec_key][:,1].copy() + blank_spec = self.blank_spectra_dict[spec_key][:,1].copy() + sam_spec = spectrum_array_dict[spec_key][:,1].copy() + + absorbance = np.expand_dims(self._compute_absorbance(dark_spec, blank_spec, sam_spec), axis=1) + absorbance_array = np.hstack((wavelength, absorbance)) + + self.absorbance_dict[spec_key] = absorbance_array + + # save all data related to absorbance calculation to a file per spectrometer + if save_to_file: + os.makedirs(self.save_directory, exist_ok=True) + data = pd.DataFrame() + data[spec_key + ' Wavelength'] = np.squeeze(wavelength) + data[spec_key + ' Dark Counts'] = np.squeeze(dark_spec) + data[spec_key + ' Blank Counts'] = np.squeeze(blank_spec) + data[spec_key + ' Sample Counts'] = np.squeeze(sam_spec) + data[spec_key + ' Absorbance'] = np.squeeze(absorbance) + + comment = ["# spec_key = " + spec_key + "\n", + "# integration_time = " + str(integration_times[ndx]) + "\n", + "# scans_to_avg = " + str(scans_to_avg[ndx]) + "\n", + "# smoothing = " + str(smoothings[ndx]) + "\n", + "# xtiming = " + str(xtimings[ndx]) + "\n"] + + fullfilename = self.save_directory + filename + '_' + spec_key + '.csv' + with open(fullfilename, 'w') as file: + file.writelines(comment) + data.to_csv(fullfilename, mode='a', index_label='Index') + + if save_to_file: + all_comments = ["# spec_keys = " + str(self.spec_keys) + "\n" + "# integration_times = " + str(integration_times) + "\n", + "# scans_to_avg = " + str(scans_to_avg) + "\n", + "# smoothings = " + str(smoothings) + "\n", + "# xtimings = " + str(xtimings) + "\n"] + else: + all_comments = ['#\n',] + # Merge the absorbance spectra and optionally save to file + if len(self.spec_keys) == 1: + result, message = self._merge_absorbance_byname_onedetector(save_to_file, filename, filename, all_comments) + elif len(self.spec_keys) > 1: + result, message = self.merge_absorbance(save_to_file, filename, all_comments) + + if not result: + return result, message + + if save_to_file: + return (True, "All absorbance spectra stored to instance and saved to file: " + self.save_directory + filename) + else: + return (True, "All absorbance spectra stored to instance but not saved to file") + + def get_all_absorbance_byname( + self, + sample_name: str, + save_to_file: bool = False, + repeat_measure: bool = False, + integration_times: Optional[Union[int, List[int], Tuple[int, ...]]] = None, + scans_to_avg: Union[int, List[int], Tuple[int, ...]] = (3, 3), + smoothings: Union[int, List[int], Tuple[int, ...]] = (0, 0), + xtimings: Union[int, List[int], Tuple[int, ...]] = (1, 1), + absorbance_threshold: float = 0.003) -> Tuple[bool, str]: + + integration_times, scans_to_avg, smoothings, xtimings = self._normalize_measurement_settings( + integration_times, + scans_to_avg, + smoothings, + xtimings, + ) + + filename = sample_name + '_' + datetime.now().strftime('%Y%m%d%H%M%S') + for attempt in range(3): + warning_by_spec = {} + result, spectrum_array_dict = self.get_all_spectra_counts( + integration_times, + scans_to_avg, + smoothings, + xtimings) + if not result: + return result, spectrum_array_dict + + existing_files = {} + if save_to_file and repeat_measure: + for spec_key in self.spec_keys: + existing_files[spec_key] = self.find_file(self.save_directory, sample_name, spec_key) + + for key in self.spectrometer_dict.keys(): + if key not in self.blank_spectra_dict: + return (False, "Blank spectra for " + key + " is missing") + if key not in self.dark_spectra_dict: + return (False, "Dark spectra for " + key + " is missing") + + retry_needed = False + prepared_rows = {} + + for ndx, spec_key in enumerate(self.spec_keys): + wavelength = self.wavelength_dict[spec_key].copy() + dark_spec = self.dark_spectra_dict[spec_key][:, 1].copy() + blank_spec = self.blank_spectra_dict[spec_key][:, 1].copy() + sam_spec = spectrum_array_dict[spec_key][:, 1].copy() + absorbance = self._compute_absorbance(dark_spec, blank_spec, sam_spec) + absorbance_array = np.hstack((wavelength, np.expand_dims(absorbance, axis=1))) + self.absorbance_dict[spec_key] = absorbance_array + + fname = existing_files.get(spec_key) if repeat_measure else None + qc_record = { + "timestamp": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "spec_key": spec_key, + "measurement_valid": 1, + "relative_diff": "", + "previous_mean": "", + "current_mean": "", + "previous_sum": "", + "current_sum": "", + "valid_points": "", + "threshold": absorbance_threshold, + "attempt_used": attempt + 1, + "elapsed_seconds": 0, + "reason": "first_measurement" if fname is None else "comparison_not_needed", + "data_file": "", + } + if fname is not None: + fullfilename = os.path.join(self.save_directory, fname) + df_old = pd.read_csv(fullfilename, comment='#') + last_column_name = df_old.columns[-1] + df_cur = pd.DataFrame() + df_cur[spec_key + ' Wavelength'] = np.squeeze(wavelength) + df_cur[spec_key + ' current_absorbance'] = np.squeeze(absorbance) + data = df_old.drop(columns='Index', errors='ignore').merge( + df_cur, + how='inner', + on=spec_key + ' Wavelength') + + current_absorbance = data[spec_key + ' current_absorbance'].to_numpy() + last_absorbance = data[last_column_name].to_numpy() + wavelength_list = data.iloc[:, 0].to_numpy() + + if last_absorbance.shape == current_absorbance.shape: + valid_index = ( + (last_absorbance > 0) + & (last_absorbance < 5) + & (current_absorbance > 0) + & (current_absorbance < 5) + & (wavelength_list > 285) + & (wavelength_list < 800) + ) + if np.any(valid_index): + previous_sum = float(np.sum(np.abs(last_absorbance[valid_index]))) + current_sum = float(np.sum(np.abs(current_absorbance[valid_index]))) + previous_mean = float(np.mean(last_absorbance[valid_index])) + current_mean = float(np.mean(current_absorbance[valid_index])) + absorbance_diff = ( + np.sum(np.abs(last_absorbance[valid_index] - current_absorbance[valid_index])) + / previous_sum + ) + diff_message = ( + f"{spec_key}: relative_diff={absorbance_diff:.6f}, " + f"previous_mean={previous_mean:.6f}, current_mean={current_mean:.6f}, " + f"previous_sum={previous_sum:.6f}, current_sum={current_sum:.6f}, " + f"valid_points={int(np.count_nonzero(valid_index))}, attempt={attempt + 1}" + ) + timestamp_match = re.search(r'_(\d+)_', fname) + time_interval = 0 + if timestamp_match is not None: + time_zero = datetime.strptime(timestamp_match.group(1), '%Y%m%d%H%M%S') + time_interval = int((datetime.now() - time_zero).total_seconds()) + qc_record.update({ + "measurement_valid": int(absorbance_diff <= absorbance_threshold), + "relative_diff": absorbance_diff, + "previous_mean": previous_mean, + "current_mean": current_mean, + "previous_sum": previous_sum, + "current_sum": current_sum, + "valid_points": int(np.count_nonzero(valid_index)), + "elapsed_seconds": time_interval, + "reason": "within_threshold" if absorbance_diff <= absorbance_threshold else "difference_exceeded_threshold", + }) + if absorbance_diff > absorbance_threshold: + warning_by_spec[spec_key] = diff_message + if attempt < 2: + retry_needed = True + break + else: + warning_by_spec.pop(spec_key, None) + else: + qc_record.update({ + "measurement_valid": 1, + "reason": "no_overlap_for_comparison", + }) + + prepared_rows[spec_key] = { + "wavelength": wavelength, + "dark_spec": dark_spec, + "blank_spec": blank_spec, + "sam_spec": sam_spec, + "absorbance": absorbance, + "existing_file": fname, + "qc_record": qc_record, + } + + if retry_needed: + continue + + if save_to_file: + os.makedirs(self.save_directory, exist_ok=True) + qc_rows = [] + for ndx, spec_key in enumerate(self.spec_keys): + row = prepared_rows[spec_key] + wavelength = row["wavelength"] + dark_spec = row["dark_spec"] + blank_spec = row["blank_spec"] + sam_spec = row["sam_spec"] + absorbance = row["absorbance"] + fname = row["existing_file"] + qc_record = row["qc_record"] + + comment = [ + "# spec_key = " + spec_key + "\n", + "# integration_time = " + str(integration_times[ndx]) + "\n", + "# scans_to_avg = " + str(scans_to_avg[ndx]) + "\n", + "# smoothing = " + str(smoothings[ndx]) + "\n", + "# xtiming = " + str(xtimings[ndx]) + "\n", + ] + + if fname is None: + data = pd.DataFrame() + data[spec_key + ' Wavelength'] = np.squeeze(wavelength) + data[spec_key + ' Dark Counts'] = np.squeeze(dark_spec) + data[spec_key + ' Blank Counts'] = np.squeeze(blank_spec) + data[spec_key + ' Sample Counts at time 0'] = np.squeeze(sam_spec) + data[spec_key + ' Absorbance at time 0'] = np.squeeze(absorbance) + fullfilename = os.path.join(self.save_directory, filename + '_' + spec_key + '.csv') + qc_record["elapsed_seconds"] = 0 + else: + fullfilename = os.path.join(self.save_directory, fname) + timestamp_match = re.search(r'_(\d+)_', fname) + time_interval = 0 + if timestamp_match is not None: + time_zero = datetime.strptime(timestamp_match.group(1), '%Y%m%d%H%M%S') + time_interval = int((datetime.now() - time_zero).total_seconds()) + + df_old = pd.read_csv(fullfilename, comment='#') + df_new = pd.DataFrame() + df_new[spec_key + ' Wavelength'] = np.squeeze(wavelength) + df_new[spec_key + ' Sample Counts at time ' + str(time_interval)] = np.squeeze(sam_spec) + df_new[spec_key + ' Absorbance at time ' + str(time_interval)] = np.squeeze(absorbance) + data = df_old.drop(columns='Index', errors='ignore').merge( + df_new, + how='inner', + on=spec_key + ' Wavelength') + qc_record["elapsed_seconds"] = time_interval + + with open(fullfilename, 'w') as file: + file.writelines(comment) + data.to_csv(fullfilename, mode='a', index_label='Index') + qc_record["data_file"] = os.path.basename(fullfilename) + qc_rows.append(qc_record) + + self._append_measurement_qc_log(sample_name, qc_rows) + + break + + if save_to_file: + all_comments = [ + "# spec_keys = " + str(self.spec_keys) + "\n", + "# integration_times = " + str(integration_times) + "\n", + "# scans_to_avg = " + str(scans_to_avg) + "\n", + "# smoothings = " + str(smoothings) + "\n", + "# xtimings = " + str(xtimings) + "\n", + ] + else: + all_comments = ['#\n'] + + if len(self.spec_keys) == 1: + result, message = self._merge_absorbance_byname_onedetector(save_to_file, filename, sample_name, all_comments) + else: + result, message = self.merge_absorbance(save_to_file, filename, all_comments) + + if not result: + return result, message + + if save_to_file: + if warning_by_spec: + warning_summary = " | ".join( + "WARNING " + warning_message for warning_message in warning_by_spec.values() + ) + return ( + True, + "All absorbance spectra stored to instance and saved to file: " + + self.save_directory + + filename + + ". QC log updated: " + + self._measurement_qc_log_path(sample_name) + + ". " + + warning_summary + ) + return ( + True, + "All absorbance spectra stored to instance and saved to file: " + + self.save_directory + + filename + + ". QC log updated: " + + self._measurement_qc_log_path(sample_name) + ) + return (True, "All absorbance spectra stored to instance but not saved to file") + + def get_all_counts_byname( + self, + sample_name: str, + save_to_file: bool = False, + repeat_measure: bool = False, + integration_times: Optional[Union[int, List[int], Tuple[int, ...]]] = None, + scans_to_avg: Union[int, List[int], Tuple[int, ...]] = (3, 3), + smoothings: Union[int, List[int], Tuple[int, ...]] = (0, 0), + xtimings: Union[int, List[int], Tuple[int, ...]] = (1, 1), + absorbance_threshold: float = 0.003) -> Tuple[bool, str]: + + del absorbance_threshold + integration_times, scans_to_avg, smoothings, xtimings = self._normalize_measurement_settings( + integration_times, + scans_to_avg, + smoothings, + xtimings, + ) + + result, spectrum_array_dict = self.get_all_spectra_counts( + integration_times, + scans_to_avg, + smoothings, + xtimings) + if not result: + return result, spectrum_array_dict + + existing_files = {} + if save_to_file and repeat_measure: + for spec_key in self.spec_keys: + existing_files[spec_key] = self.find_file( + self.save_directory, + sample_name, + spec_key + '_photoncounts') + + filename = sample_name + '_' + datetime.now().strftime('%Y%m%d%H%M%S') + + for ndx, spec_key in enumerate(self.spec_keys): + wavelength = self.wavelength_dict[spec_key].copy() + photoncounts = spectrum_array_dict[spec_key][:, 1].copy() + photoncounts = np.nan_to_num(photoncounts, nan=0.0, posinf=0.0, neginf=0.0) + photoncounts[photoncounts < 0] = 0 + self.photoncounts_dict[spec_key] = np.hstack((wavelength, np.expand_dims(photoncounts, axis=1))) + + if save_to_file: + os.makedirs(self.save_directory, exist_ok=True) + comment = [ + "# spec_key = " + spec_key + "\n", + "# integration_time = " + str(integration_times[ndx]) + "\n", + "# scans_to_avg = " + str(scans_to_avg[ndx]) + "\n", + "# smoothing = " + str(smoothings[ndx]) + "\n", + "# xtiming = " + str(xtimings[ndx]) + "\n", + ] + fname = existing_files.get(spec_key) if repeat_measure else None + if fname is None: + data = pd.DataFrame() + data[spec_key + ' Wavelength'] = np.squeeze(wavelength) + data[spec_key + ' Photon Counts at time 0'] = np.squeeze(photoncounts) + fullfilename = os.path.join(self.save_directory, filename + '_' + spec_key + '_photoncounts.csv') + else: + fullfilename = os.path.join(self.save_directory, fname) + timestamp_match = re.search(r'_(\d+)_', fname) + time_interval = 0 + if timestamp_match is not None: + time_zero = datetime.strptime(timestamp_match.group(1), '%Y%m%d%H%M%S') + time_interval = int((datetime.now() - time_zero).total_seconds()) + df_old = pd.read_csv(fullfilename, comment='#') + df_new = pd.DataFrame() + df_new[spec_key + ' Wavelength'] = np.squeeze(wavelength) + df_new[spec_key + ' Photon Counts at time ' + str(time_interval)] = np.squeeze(photoncounts) + data = df_old.drop(columns='Index', errors='ignore').merge( + df_new, + how='inner', + on=spec_key + ' Wavelength') + + with open(fullfilename, 'w') as file: + file.writelines(comment) + data.to_csv(fullfilename, mode='a', index_label='Index') + + if save_to_file: + return (True, "Photon counts saved to file: " + self.save_directory + filename) + return (True, "Photon counts stored to instance but not saved to file") + + def get_spec_decay( + self, + sample_name: str, + save_to_file: bool = False, + range_start: float = 290.0, + range_end: float = 800.0, + irradiance_file: str = "reference/am15g_spectrum.csv", + Wvlgth_col_name: str = "wavelength_nm", + Irrad_col_name: str = "irradiance_w_m2_nm", + decay_threshold: float = 0.01) -> Tuple[bool, str]: + + fname = self.find_file(self.save_directory, sample_name, 'merged.csv') + if fname is None: + return (False, "Absorbance of " + sample_name + " is missing") + + fullfilename = os.path.join(self.save_directory, fname) + df_old = pd.read_csv(fullfilename, comment='#') + if len(df_old.columns) <= 3: + return (False, "Only original absorbance recorded") + + wavelength_list = pd.to_numeric(df_old.iloc[:, 1], errors='coerce').to_numpy(dtype=float) + data_mask = np.logical_and(wavelength_list >= range_start, wavelength_list <= range_end) + if np.count_nonzero(data_mask) < 2: + return (False, "Insufficient wavelength points in the selected spectral decay range.") + + absorbance_df = df_old.iloc[:, 2:].apply(pd.to_numeric, errors='coerce') + decayed_absorbance = absorbance_df.to_numpy(dtype=float)[data_mask] + original_absorbance = absorbance_df.iloc[:, 0].to_numpy(dtype=float)[data_mask] + wavelength_list_inrange = wavelength_list[data_mask] + time_columns = [str(column_name) for column_name in df_old.columns[2:]] + elapsed_seconds = self._parse_elapsed_seconds_from_columns(time_columns) + elapsed_hours = elapsed_seconds / 3600.0 + + result, irradiance_data = self._load_irradiance_reference( + irradiance_file, + Wvlgth_col_name, + Irrad_col_name) + if not result: + return (False, str(irradiance_data)) + wavelength_list_irr, irradiance_reference = irradiance_data + if range_start < np.min(wavelength_list_irr) or range_end > np.max(wavelength_list_irr): + return (False, "Incompatible range with irradiance table") + + irradiance_interp = self._linear_interpolate(wavelength_list_irr, irradiance_reference, wavelength_list_inrange) + irradiance_interp = np.nan_to_num(irradiance_interp, nan=0.0, posinf=0.0, neginf=0.0) + total_irradiance = self._trapz(wavelength_list_inrange, irradiance_interp) + + overlap_percent = np.zeros(decayed_absorbance.shape[1], dtype=float) + for idx in range(decayed_absorbance.shape[1]): + absorbed_fraction = 1.0 - np.power(10.0, -decayed_absorbance[:, idx]) + absorbed_fraction = np.clip(np.nan_to_num(absorbed_fraction, nan=0.0, posinf=1.0, neginf=0.0), 0.0, 1.0) + absorbed_weighted = irradiance_interp * absorbed_fraction + absorbed_irradiance = self._trapz(wavelength_list_inrange, absorbed_weighted) + overlap_percent[idx] = ( + absorbed_irradiance / total_irradiance * 100.0 if total_irradiance > 0 else 0.0 + ) + + baseline_overlap = overlap_percent[0] + if baseline_overlap > 0: + retention_percent = overlap_percent / baseline_overlap * 100.0 + else: + retention_percent = np.zeros_like(overlap_percent) + overlap_delta_vs_t0 = overlap_percent - baseline_overlap + overlap_abs_change_vs_t0 = np.abs(retention_percent - 100.0) + + decay_mag = np.zeros(decayed_absorbance.shape[1], dtype=float) + decay_signed = np.zeros(decayed_absorbance.shape[1], dtype=float) + decay_positive = np.zeros(decayed_absorbance.shape[1], dtype=float) + decay_negative_abs = np.zeros(decayed_absorbance.shape[1], dtype=float) + + for idx in range(decayed_absorbance.shape[1]): + ( + decay_mag[idx], + decay_signed[idx], + decay_positive[idx], + decay_negative_abs[idx], + ) = self._compute_decay_metrics_with_spacing( + wavelength_list_inrange, + original_absorbance, + decayed_absorbance[:, idx], + decay_threshold, + ) + + t80_h = self._interpolate_crossing_time(elapsed_hours, decay_mag, 0.20) + + df_specdecay = pd.DataFrame() + df_specdecay['time_s'] = elapsed_seconds + df_specdecay['time_h'] = elapsed_hours + df_specdecay['spectral_overlap_percent'] = overlap_percent + df_specdecay['spectral_overlap_delta_vs_t0_percent'] = overlap_delta_vs_t0 + df_specdecay['retention_vs_t0_percent'] = retention_percent + df_specdecay['spectral_overlap_abs_change_vs_t0_percent'] = overlap_abs_change_vs_t0 + df_specdecay['decay_index_mag'] = decay_mag + df_specdecay['decay_index_signed'] = decay_signed + df_specdecay['decay_index_positive'] = decay_positive + df_specdecay['decay_index_negative_abs'] = decay_negative_abs + df_specdecay['t80_h'] = t80_h + + if save_to_file: + new_filename = os.path.join(self.save_directory, sample_name + "_specdecay.csv") + comment = [ + "# range start from (wavelength) " + str(range_start) + " (nm)\n", + "# end at = " + str(range_end) + "\n", + "# irradiance_file = " + str(irradiance_file) + "\n", + "# decay_threshold = " + str(decay_threshold) + "\n", + "# methodology = UVVis_Converter style overlap/interpolate/trapz and decay index summary with wavelength-spacing weighting\n", + ] + with open(new_filename, 'w') as file: + file.writelines(comment) + df_specdecay.to_csv(new_filename, mode='a', index_label='Index') + return (True, "Spectral decay saved to file: " + new_filename) + return (True, "Spectral decay calculated but not saved to file") + + def plot_spec_decay_summary( + self, + sample_name: str, + range_start: float = 290.0, + range_end: float = 800.0, + save_to_file: bool = True, + output_filename: Optional[str] = None, + figure_dpi: int = 180) -> Tuple[bool, str]: + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + except Exception as exc: + return (False, "matplotlib is required to plot spectral decay summaries: " + str(exc)) + + specdecay_path = os.path.join(self.save_directory, sample_name + "_specdecay.csv") + if not os.path.exists(specdecay_path): + fname = self.find_file(self.save_directory, sample_name, 'specdecay.csv') + if fname is None: + return (False, "Spectral decay of " + sample_name + " is missing") + specdecay_path = os.path.join(self.save_directory, fname) + + df_specdecay = pd.read_csv(specdecay_path, comment='#') + if df_specdecay.empty: + return (False, "Spectral decay file is empty: " + specdecay_path) + + required_columns = [ + "time_h", + "spectral_overlap_percent", + "retention_vs_t0_percent", + "decay_index_mag", + "decay_index_signed", + "decay_index_positive", + "decay_index_negative_abs", + ] + missing_columns = [column for column in required_columns if column not in df_specdecay.columns] + if missing_columns: + return (False, "Spectral decay file is missing columns: " + ", ".join(missing_columns)) + + time_h = pd.to_numeric(df_specdecay["time_h"], errors="coerce").to_numpy(dtype=float) + overlap_percent = pd.to_numeric( + df_specdecay["spectral_overlap_percent"], errors="coerce").to_numpy(dtype=float) + retention_percent = pd.to_numeric( + df_specdecay["retention_vs_t0_percent"], errors="coerce").to_numpy(dtype=float) + decay_mag = pd.to_numeric(df_specdecay["decay_index_mag"], errors="coerce").to_numpy(dtype=float) + decay_signed = pd.to_numeric(df_specdecay["decay_index_signed"], errors="coerce").to_numpy(dtype=float) + decay_positive = pd.to_numeric(df_specdecay["decay_index_positive"], errors="coerce").to_numpy(dtype=float) + decay_negative_abs = pd.to_numeric( + df_specdecay["decay_index_negative_abs"], errors="coerce").to_numpy(dtype=float) + + t80_h = None + if "t80_h" in df_specdecay.columns: + t80_values = pd.to_numeric(df_specdecay["t80_h"], errors="coerce").to_numpy(dtype=float) + finite_t80 = t80_values[np.isfinite(t80_values)] + if finite_t80.size > 0: + t80_h = float(finite_t80[0]) + + merged_path = None + merged_name = self.find_file(self.save_directory, sample_name, 'merged.csv') + if merged_name is not None: + merged_path = os.path.join(self.save_directory, merged_name) + + qc_path = self._measurement_qc_log_path(sample_name) + df_qc = pd.read_csv(qc_path) if os.path.exists(qc_path) else None + + fig, axes = plt.subplots(2, 2, figsize=(13, 9), constrained_layout=True) + fig.suptitle(sample_name) + + ax_overlap = axes[0, 0] + ax_overlap.plot(time_h, overlap_percent, color="#1f77b4", linewidth=1.8, label="Overlap [%]") + ax_overlap.set_xlabel("Time [h]") + ax_overlap.set_ylabel("Spectral Overlap [%]", color="#1f77b4") + ax_overlap.tick_params(axis='y', labelcolor="#1f77b4") + ax_overlap.grid(alpha=0.25) + ax_retention = ax_overlap.twinx() + ax_retention.plot(time_h, retention_percent, color="#ff7f0e", linewidth=1.5, label="Retention [%]") + ax_retention.set_ylabel("Retention vs t0 [%]", color="#ff7f0e") + ax_retention.tick_params(axis='y', labelcolor="#ff7f0e") + if t80_h is not None and np.isfinite(t80_h): + ax_overlap.axvline(t80_h, color="black", linestyle="--", linewidth=1.0, alpha=0.7) + ax_overlap.text( + t80_h, + np.nanmax(overlap_percent) if np.any(np.isfinite(overlap_percent)) else 0.0, + f"t80={t80_h:.2f} h", + fontsize=8, + ha="left", + va="bottom", + ) + ax_overlap.set_title("Overlap / Retention") + + ax_decay = axes[0, 1] + ax_decay.plot(time_h, decay_mag, color="#d62728", linewidth=1.8, label="Decay magnitude") + ax_decay.plot(time_h, decay_positive, color="#2ca02c", linewidth=1.2, label="Positive") + ax_decay.plot(time_h, decay_negative_abs, color="#9467bd", linewidth=1.2, label="Negative abs") + ax_decay.plot(time_h, decay_signed, color="#7f7f7f", linewidth=1.0, linestyle="--", label="Signed") + if t80_h is not None and np.isfinite(t80_h): + ax_decay.axvline(t80_h, color="black", linestyle="--", linewidth=1.0, alpha=0.7) + ax_decay.set_xlabel("Time [h]") + ax_decay.set_ylabel("Decay Index") + ax_decay.set_title("Decay Indices") + ax_decay.grid(alpha=0.25) + ax_decay.legend(loc="best", fontsize=8) + + ax_abs = axes[1, 0] + if merged_path is not None and os.path.exists(merged_path): + df_merged = pd.read_csv(merged_path, comment='#') + wavelength = pd.to_numeric(df_merged.iloc[:, 1], errors="coerce").to_numpy(dtype=float) + absorbance_df = df_merged.iloc[:, 2:].apply(pd.to_numeric, errors='coerce') + mask = np.logical_and(wavelength >= range_start, wavelength <= range_end) + if np.count_nonzero(mask) >= 2 and absorbance_df.shape[1] >= 1: + selected_indices = sorted(set([0, absorbance_df.shape[1] // 2, absorbance_df.shape[1] - 1])) + colors = ["#1f77b4", "#ff7f0e", "#d62728"] + for color, idx in zip(colors, selected_indices): + label = f"t={absorbance_df.columns[idx]} s" + ax_abs.plot( + wavelength[mask], + absorbance_df.iloc[:, idx].to_numpy(dtype=float)[mask], + linewidth=1.1, + color=color, + label=label, + ) + ax_abs.legend(loc="best", fontsize=8) + else: + ax_abs.text(0.5, 0.5, "Merged absorbance range unavailable", ha="center", va="center") + else: + ax_abs.text(0.5, 0.5, "Merged absorbance file not found", ha="center", va="center") + ax_abs.set_xlabel("Wavelength [nm]") + ax_abs.set_ylabel("Absorbance") + ax_abs.set_title(f"Absorbance Snapshots ({range_start:.0f}-{range_end:.0f} nm)") + ax_abs.grid(alpha=0.25) + + ax_qc = axes[1, 1] + if df_qc is not None and not df_qc.empty: + qc_time_h = pd.to_numeric(df_qc.get("elapsed_seconds", np.nan), errors="coerce").to_numpy(dtype=float) / 3600.0 + measurement_valid = pd.to_numeric(df_qc.get("measurement_valid", np.nan), errors="coerce").to_numpy(dtype=float) + colors = np.where(measurement_valid > 0.5, "#2ca02c", "#d62728") + ax_qc.scatter(qc_time_h, measurement_valid, c=colors, s=22, label="measurement_valid") + ax_qc.set_ylim(-0.1, 1.1) + ax_qc.set_yticks([0, 1]) + ax_qc.set_xlabel("Time [h]") + ax_qc.set_ylabel("Measurement Valid") + ax_qc.grid(alpha=0.25) + valid_count = int(np.nansum(measurement_valid > 0.5)) + ax_qc.set_title(f"QC Summary ({valid_count}/{len(measurement_valid)} valid)") + + if "relative_diff" in df_qc.columns: + relative_diff = pd.to_numeric(df_qc["relative_diff"], errors="coerce").to_numpy(dtype=float) + qc_threshold = pd.to_numeric(df_qc.get("threshold", np.nan), errors="coerce").to_numpy(dtype=float) + ax_qc_diff = ax_qc.twinx() + ax_qc_diff.plot(qc_time_h, relative_diff, color="#ff7f0e", linewidth=1.3, alpha=0.9, label="relative_diff") + finite_threshold = qc_threshold[np.isfinite(qc_threshold)] + if finite_threshold.size > 0: + ax_qc_diff.axhline( + float(finite_threshold[0]), + color="#ff7f0e", + linestyle="--", + linewidth=1.0, + alpha=0.7, + ) + ax_qc_diff.set_ylabel("Relative Diff", color="#ff7f0e") + ax_qc_diff.tick_params(axis='y', labelcolor="#ff7f0e") + else: + ax_qc.text(0.5, 0.5, "QC log not found", ha="center", va="center") + ax_qc.set_title("QC Summary") + ax_qc.set_xticks([]) + ax_qc.set_yticks([]) + + if output_filename is None: + figure_path = os.path.join(self.save_directory, sample_name + "_specdecay_summary.png") + else: + figure_path = output_filename if os.path.isabs(output_filename) else os.path.join(self.save_directory, output_filename) + + if save_to_file: + fig.savefig(figure_path, dpi=figure_dpi, bbox_inches='tight') + plt.close(fig) + return (True, "Spectral decay summary figure saved to file: " + figure_path) + + plt.close(fig) + return (True, "Spectral decay summary figure created but not saved to file") + + # hard coded for UV-Vis and NIR + # what happens if only 1 spectrometer is connected/being used? + def merge_absorbance(self, save_to_file: bool, filename: str, comment_list: List[str]) -> Tuple[bool, str]: + result, merge_output = self.merge_uv_nir_absorbance_arrays( + self.absorbance_dict['UV-Vis'], + self.absorbance_dict['NIR'], + ) + if not result: + return (False, merge_output) + + self.merged_absorbance = merge_output["merged_array"] + metadata = merge_output["metadata"] + + if save_to_file: + os.makedirs(self.save_directory, exist_ok=True) + data = pd.DataFrame() + data['Wavelength'] = np.squeeze(self.merged_absorbance[:, 0].copy()) + data['Absorbance'] = np.squeeze(self.merged_absorbance[:, 1].copy()) + + fullfilename = os.path.join(self.save_directory, filename + '_merged.csv') + comment_list = list(comment_list) + comment_list.append("# merge_method = qc_stitch_scale_only\n") + comment_list.append("# merge_mode = " + str(metadata["merge_mode"]) + "\n") + comment_list.append("# merge_reason = " + str(metadata["merge_reason"]) + "\n") + comment_list.append("# crossover_nm = " + str(metadata["crossover_nm"]) + "\n") + comment_list.append("# scale = " + str(metadata["scale"]) + "\n") + comment_list.append("# shift = " + str(metadata["shift"]) + "\n") + comment_list.append("# overlap_range_nm = " + str((metadata["overlap_start_nm"], metadata["overlap_end_nm"])) + "\n") + comment_list.append("# uv_overlap_p95 = " + str(metadata["uv_overlap_p95"]) + "\n") + comment_list.append("# best_raw_window_diff = " + str(metadata["best_raw_window_diff"]) + "\n") + comment_list.append("# scale_only = " + str(metadata["scale_only"]) + "\n") + comment_list.append("# scaled_median_diff = " + str(metadata["scaled_median_diff"]) + "\n") + comment_list.append("# negative_values_clipped = " + str(metadata["negative_values_clipped"]) + "\n") + with open(fullfilename, 'w') as file: + file.writelines(comment_list) + data.to_csv(fullfilename, mode='a', index_label='Index') + + return ( + True, + "Successfully merged UV-Vis and NIR absorbance spectra using " + + str(metadata["merge_mode"]) + ) + + @staticmethod + def _best_window_median_diff( + wavelengths, + y1, + y2, + window_nm: float, + min_points: int = 5) -> Tuple[float, float]: + best_diff = float('inf') + best_wavelength = float('nan') + for wavelength in wavelengths: + window_index = np.abs(wavelengths - wavelength) <= window_nm + if np.count_nonzero(window_index) < min_points: + continue + median_diff = float(np.median(np.abs(y1[window_index] - y2[window_index]))) + if median_diff < best_diff: + best_diff = median_diff + best_wavelength = float(wavelength) + return best_diff, best_wavelength + + @staticmethod + def _sanitize_absorbance_array(array) -> np.ndarray: + clean_array = np.asarray(array, dtype=float) + if clean_array.ndim != 2 or clean_array.shape[1] < 2: + raise ValueError("Absorbance array must have at least two columns.") + clean_array = clean_array[:, :2] + valid_index = np.isfinite(clean_array[:, 0]) & np.isfinite(clean_array[:, 1]) + clean_array = clean_array[valid_index] + return clean_array[clean_array[:, 0].argsort()] + + @staticmethod + def merge_uv_nir_absorbance_arrays(uv_array, nir_array) -> Tuple[bool, Union[dict, str]]: + try: + uv_array = StellarNetSpectrometer._sanitize_absorbance_array(uv_array) + nir_array = StellarNetSpectrometer._sanitize_absorbance_array(nir_array) + except ValueError as exc: + return (False, str(exc)) + + overlap_start = StellarNetSpectrometer.MERGE_OVERLAP_START + overlap_end = StellarNetSpectrometer.MERGE_OVERLAP_END + overlap_index = (nir_array[:, 0] >= overlap_start) & (nir_array[:, 0] <= overlap_end) + overlap_wavelength = nir_array[overlap_index, 0].copy() + nir_overlap = nir_array[overlap_index, 1].copy() + uv_overlap = StellarNetSpectrometer._linear_interpolate( + uv_array[:, 0], + uv_array[:, 1], + overlap_wavelength, + ) + + valid_overlap = np.isfinite(overlap_wavelength) & np.isfinite(uv_overlap) & np.isfinite(nir_overlap) + overlap_wavelength = overlap_wavelength[valid_overlap] + uv_overlap = uv_overlap[valid_overlap] + nir_overlap = nir_overlap[valid_overlap] + + metadata = { + "merge_mode": "fixed_stitch_invalid_overlap", + "merge_reason": "not enough valid overlap points", + "overlap_start_nm": overlap_start, + "overlap_end_nm": overlap_end, + "window_nm": StellarNetSpectrometer.MERGE_WINDOW_NM, + "raw_abs_tolerance": StellarNetSpectrometer.MERGE_RAW_ABS_TOLERANCE, + "signal_min_p95": StellarNetSpectrometer.MERGE_SIGNAL_MIN_P95, + "scale_min": StellarNetSpectrometer.MERGE_SCALE_MIN, + "scale_max": StellarNetSpectrometer.MERGE_SCALE_MAX, + "scale_fit_median_tolerance": StellarNetSpectrometer.MERGE_SCALE_FIT_MEDIAN_TOLERANCE, + "uv_overlap_p95": float('nan'), + "uv_overlap_median": float('nan'), + "nir_overlap_p95": float('nan'), + "best_raw_window_diff": float('inf'), + "best_raw_window_nm": float('nan'), + "scale_only": float('nan'), + "scaled_median_diff": float('nan'), + "best_scaled_window_diff": float('inf'), + "best_scaled_window_nm": float('nan'), + "scale": 1.0, + "shift": 0.0, + "crossover_nm": StellarNetSpectrometer.MERGE_FIXED_CROSSOVER_NM, + "negative_values_clipped": 0, + } + + if overlap_wavelength.size >= 10: + uv_p95 = float(np.percentile(uv_overlap, 95)) + nir_p95 = float(np.percentile(nir_overlap, 95)) + raw_diff, raw_nm = StellarNetSpectrometer._best_window_median_diff( + overlap_wavelength, + uv_overlap, + nir_overlap, + StellarNetSpectrometer.MERGE_WINDOW_NM, + ) + denom = float(np.sum(nir_overlap * nir_overlap)) + scale_only = float(np.sum(uv_overlap * nir_overlap) / denom) if denom > 0 else float('nan') + scaled_overlap = nir_overlap * scale_only if np.isfinite(scale_only) else np.full_like(nir_overlap, np.nan) + scaled_median_diff = ( + float(np.median(np.abs(uv_overlap - scaled_overlap))) + if np.all(np.isfinite(scaled_overlap)) + else float('nan') + ) + scaled_window_diff, scaled_nm = StellarNetSpectrometer._best_window_median_diff( + overlap_wavelength, + uv_overlap, + scaled_overlap, + StellarNetSpectrometer.MERGE_WINDOW_NM, + ) + + metadata.update({ + "uv_overlap_p95": uv_p95, + "uv_overlap_median": float(np.median(uv_overlap)), + "nir_overlap_p95": nir_p95, + "best_raw_window_diff": raw_diff, + "best_raw_window_nm": raw_nm, + "scale_only": scale_only, + "scaled_median_diff": scaled_median_diff, + "best_scaled_window_diff": scaled_window_diff, + "best_scaled_window_nm": scaled_nm, + }) + + if uv_p95 < StellarNetSpectrometer.MERGE_SIGNAL_MIN_P95: + metadata.update({ + "merge_mode": "fixed_stitch_low_overlap_signal", + "merge_reason": "UV overlap signal is too low for reliable fitting", + "crossover_nm": StellarNetSpectrometer.MERGE_FIXED_CROSSOVER_NM, + "scale": 1.0, + "shift": 0.0, + }) + elif raw_diff <= StellarNetSpectrometer.MERGE_RAW_ABS_TOLERANCE: + metadata.update({ + "merge_mode": "raw_stitch", + "merge_reason": "raw UV and NIR spectra match within window tolerance", + "crossover_nm": raw_nm if np.isfinite(raw_nm) else StellarNetSpectrometer.MERGE_FIXED_CROSSOVER_NM, + "scale": 1.0, + "shift": 0.0, + }) + elif ( + np.isfinite(scale_only) + and StellarNetSpectrometer.MERGE_SCALE_MIN <= scale_only <= StellarNetSpectrometer.MERGE_SCALE_MAX + and scaled_median_diff <= StellarNetSpectrometer.MERGE_SCALE_FIT_MEDIAN_TOLERANCE): + metadata.update({ + "merge_mode": "scale_only_stitch", + "merge_reason": "raw spectra do not meet tolerance; scale-only fit is within bounds", + "crossover_nm": scaled_nm if np.isfinite(scaled_nm) else StellarNetSpectrometer.MERGE_FIXED_CROSSOVER_NM, + "scale": scale_only, + "shift": 0.0, + }) + else: + metadata.update({ + "merge_mode": "fixed_stitch_unreliable_fit", + "merge_reason": "raw match and scale-only fit did not pass QC", + "crossover_nm": StellarNetSpectrometer.MERGE_FIXED_CROSSOVER_NM, + "scale": 1.0, + "shift": 0.0, + }) + + uv_part = StellarNetSpectrometer.truncate_ends_by_wavelength( + uv_array, + StellarNetSpectrometer.MERGE_UV_START, + float(metadata["crossover_nm"]), + ) + nir_adjusted = nir_array.copy() + nir_adjusted[:, 1] = StellarNetSpectrometer.scale_shift_data( + [float(metadata["scale"]), float(metadata["shift"])], + nir_adjusted[:, 1], + ) + nir_part = nir_adjusted[ + np.logical_and( + nir_adjusted[:, 0] > float(metadata["crossover_nm"]), + nir_adjusted[:, 0] < StellarNetSpectrometer.MERGE_NIR_END, + ) + ] + + if uv_part.size == 0 or nir_part.size == 0: + return (False, "Unable to stitch UV-Vis and NIR arrays with crossover " + str(metadata["crossover_nm"])) + + merged_array = np.vstack((uv_part, nir_part)) + merged_array = merged_array[merged_array[:, 0].argsort()] + negative_index = merged_array[:, 1] < 0 + metadata["negative_values_clipped"] = int(np.count_nonzero(negative_index)) + merged_array[negative_index, 1] = 0.0 + + return (True, {"merged_array": merged_array, "metadata": metadata}) + + # y and return are ndarrays + @staticmethod + def scale_shift_data(params: List[float], y): + scale = params[0] + shift = params[1] + return y * scale + shift + # return (y + shift) * scale + + @staticmethod + def truncate_ends_by_wavelength(array, start: float, end: float): + # expects array to be two columns with first being wavelength and second being the data of interest (counts, absorbance, etc.) + return array[np.logical_and(array[:,0]>start, array[:,0] dict: + return { + "name": self._name, + "port": self._port, + "max_position_mm": self._max_position_mm, + "baudrate": self._baudrate, + "timeout": self._timeout, + "connect_delay_s": self._connect_delay_s, + "ready_timeout_s": self._ready_timeout_s, + "home_timeout_s": self._home_timeout_s, + "move_timeout_s": self._move_timeout_s, + } + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._max_position_mm = float(args_dict["max_position_mm"]) + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._connect_delay_s = float(args_dict.get("connect_delay_s", 5.0)) + self._ready_timeout_s = float(args_dict.get("ready_timeout_s", 5.0)) + self._home_timeout_s = float(args_dict.get("home_timeout_s", 30.0)) + self._move_timeout_s = float(args_dict.get("move_timeout_s", 30.0)) + + def connect(self) -> Tuple[bool, str]: + return self.start_serial(delay=self._connect_delay_s) + + @check_serial + def initialize(self) -> Tuple[bool, str]: + ready_success, ready_message = self.get_response(response_timeout=self._ready_timeout_s) + if not ready_success or "Ready" not in ready_message: + self._is_initialized = False + return (False, "Arduino did not send ready signal. Response: " + str(ready_message)) + + success, message = self.home() + if not success: + self._is_initialized = False + return (False, "Homing failed: " + str(message)) + + self._is_initialized = True + return (True, "Device initialized and homed.") + + @check_serial + def deinitialize( + self, + reset_init_flag: bool = True, + close_serial: bool = False) -> Tuple[bool, str]: + if reset_init_flag: + self._is_initialized = False + if close_serial and self.ser.is_open: + self.ser.close() + return (True, "Device deinitialized.") + + @check_serial + def home(self) -> Tuple[bool, str]: + self.ser.write(b'H\n') + return self.check_ack_succ(succ_timeout=self._home_timeout_s) + + @check_serial + @check_initialized + def move_to_position( + self, + position_mm: float, + speed_mm_per_s: float, + move_timeout: Optional[float] = None) -> Tuple[bool, str]: + if not (0.0 <= float(position_mm) <= self._max_position_mm): + return ( + False, + f"Command error: position {position_mm} mm is out of range (0-{self._max_position_mm} mm).", + ) + + command = f"M{float(position_mm)},{float(speed_mm_per_s)}\n".encode("ascii") + self.ser.write(command) + + ack_success, ack_message = self.check_response( + self.char_ACK, + self.char_delimiter, + response_timeout=2.0, + ) + if not ack_success: + return (False, "Did not receive ACK for move command: " + str(ack_message)) + + timeout_s = self._move_timeout_s if move_timeout is None else float(move_timeout) + succ_success, succ_message = self.check_response( + self.char_SUCC, + self.char_delimiter, + response_timeout=timeout_s, + ) + if not succ_success: + return (False, "Move did not complete successfully: " + str(succ_message)) + + return (True, succ_message) diff --git a/aamp_app/devices/utility_device.py b/aamp_app/devices/utility_device.py new file mode 100644 index 0000000..823e2cf --- /dev/null +++ b/aamp_app/devices/utility_device.py @@ -0,0 +1,11 @@ +from .device import Device + +class UtilityCommands(Device): + def __init__(self, name: str): + super().__init__(name) + + def get_init_args(self) -> dict: + return {"name": self._name} + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] \ No newline at end of file diff --git a/aamp_app/devices/ximea_camera.py b/aamp_app/devices/ximea_camera.py new file mode 100644 index 0000000..6c57aab --- /dev/null +++ b/aamp_app/devices/ximea_camera.py @@ -0,0 +1,702 @@ +from datetime import datetime +import os +from typing import Optional, Tuple, List + +from PIL import Image +from ximea import xiapi + +from .device import Device, check_initialized + +import numpy as np +import cv2 + + +# Unsure how cooperative xiapi.Camera is so did not use multiple inheritance +# will need to figure out the method resolution order if using multiple inheritance +class XimeaCamera(Device): + save_directory = 'data/imaging/' + DEFAULT_RAW_BAYER_PATTERN = "GBRG" + DEFAULT_RAW_MAX_VALUE = 1023.0 + + def __init__(self, name: str): + super().__init__(name) + self.cam = xiapi.Camera() + # defaults, changeable for each instance + # could use dictionary but lose IDE hinting + # could consider a namedtuple to keep descriptive context of each param while still being able to use an iterative + # for now, individual params + self._default_imgdataformat = 'XI_RGB24' + # self._default_imgdataformat = 'XI_MONO8' + self._default_exposure_time = 50000 + self._default_gain = 0.0 + # default wb coeffs below technically constants, leaving uncaptilaized + + self._default_wb_kr = 1.531 + self._default_wb_kg = 1.0 + self._default_wb_kb = 1.305 + + self.background = None + self.completeness_ref = None + + self.boundaryx_0 = 350 + self.boundaryx_1 = 1000 + # self.set_default_params() # done in initalize because cam is not yet open here + + def get_init_args(self) -> dict: + return { + "name": self._name, + } + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + + # no setter for imgdataformat at the moment + @property + def default_imgdataformat(self) -> str: + return self._default_imgdataformat + + @property + def default_exposure_time(self) -> int: + return self._default_exposure_time + + @default_exposure_time.setter + def default_exposure_time(self, exposure_time: int): + if exposure_time > 0: + self._default_exposure_time = int(exposure_time) + + @property + def default_gain(self) -> float: + return self._default_gain + + @default_gain.setter + def default_gain(self, gain: float): + # there is an upper limit for this but not 100% sure what it is + if gain >= 0.0: + self._default_gain = gain + + # no setter for default wb coeffs + @property + def default_wb_kr(self) -> float: + return self._default_wb_kr + + @property + def default_wb_kg(self) -> float: + return self._default_wb_kg + + @property + def default_wb_kb(self) -> float: + return self._default_wb_kb + + def set_default_params(self): + self.cam.set_imgdataformat(self._default_imgdataformat) + self.cam.set_exposure(self._default_exposure_time) + self.cam.set_gain(self._default_gain) + self.cam.set_wb_kr(self._default_wb_kr) + self.cam.set_wb_kg(self._default_wb_kg) + self.cam.set_wb_kb(self._default_wb_kb) + + def initialize(self, set_defaults: bool = True) -> Tuple[bool, str]: + try: + self.cam.open_device() + # set defaults if flag is True. This should be True the very first time in order to set the default params, but not enforced + if set_defaults: + self.set_default_params() + print(self.cam.get_exposure_minimum()) + print(self.cam.get_exposure_maximum()) + print(self.cam.get_exposure_increment()) + print("Gain min:", self.cam.get_gain_minimum()) + print("Gain max:", self.cam.get_gain_maximum()) + print("Gain increment:", self.cam.get_gain_increment()) + print("Auto white balance?", self.cam.is_auto_wb()) + self.cam.disable_aeag() + # self.cam.enable_auto_wb() + # print() + self._is_initialized = True + except xiapi.Xi_error as inst: + self._is_initialized = False + return (False, "Failed to connect and initialize: " + str(inst)) + + if set_defaults: + return (True, "Successfully initialized camera by opening communications and setting defaults.") + else: + return (True, "Successfully intitialized camera by opening communications.") + + def deinitialize(self, reset_init_flag: bool = True) -> Tuple[bool, str]: + try: + self.cam.stop_acquisition() + self.cam.close_device() + except xiapi.Xi_error as inst: + return (False, "Failed to deinitialize the camera: " + str(inst)) + + if reset_init_flag: + self._is_initialized = False + + return (True, "Successfully deinitialized camera, communication closed.") + + def _normalize_output_path(self, directory: Optional[str], filename: Optional[str], extension: str) -> str: + if filename is None: + filename = datetime.now().strftime('%Y%m%d_%H%M%S') + if directory is None: + directory = self.save_directory + os.makedirs(directory, exist_ok=True) + if not extension.startswith("."): + extension = "." + extension + return os.path.join(directory, filename + extension) + + def _capture_numpy( + self, + imgdataformat: str, + exposure_time: Optional[int] = None, + gain: Optional[float] = None, + invert_rgb_order: bool = False, + ): + if exposure_time is None: + exposure_time = self._default_exposure_time + if gain is None: + gain = self._default_gain + + try: + self.cam.set_imgdataformat(imgdataformat) + self.cam.set_exposure(exposure_time) + self.cam.set_gain(gain) + img = xiapi.Image() + self.cam.start_acquisition() + self.cam.get_image(img) + self.cam.stop_acquisition() + except xiapi.Xi_error as inst: + raise RuntimeError("Error while getting image: " + str(inst)) from inst + + return img.get_image_data_numpy(invert_rgb_order=invert_rgb_order) + + @staticmethod + def raw16_to_rgb_array( + raw16: np.ndarray, + bayer_pattern: str = DEFAULT_RAW_BAYER_PATTERN, + raw_max_value: float = DEFAULT_RAW_MAX_VALUE, + wb_gains: Optional[Tuple[float, float, float]] = None, + gamma: float = 1.0, + ) -> np.ndarray: + # Match the APIS RAW16 preview conversion pipeline: + # demosaic with OpenCV's BGR code, apply fixed WB gains, then gamma. + pattern_map = { + "GBRG": cv2.COLOR_BAYER_GB2BGR, + "RGGB": cv2.COLOR_BAYER_RG2BGR, + "BGGR": cv2.COLOR_BAYER_BG2BGR, + "GRBG": cv2.COLOR_BAYER_GR2BGR, + } + pattern_key = bayer_pattern.upper() + if pattern_key not in pattern_map: + raise ValueError("Unsupported Bayer pattern: " + str(bayer_pattern)) + if raw16.ndim != 2 or raw16.dtype != np.uint16: + raise ValueError("raw16_to_rgb_array expects a 2D uint16 Bayer image.") + + rgb16 = cv2.cvtColor(raw16, pattern_map[pattern_key]) + scale = float(raw_max_value) if raw_max_value and raw_max_value > 0 else float(np.max(rgb16) or 1.0) + rgb = rgb16.astype(np.float32) / scale + + if wb_gains is not None: + rgb[..., 0] *= wb_gains[0] + rgb[..., 1] *= wb_gains[1] + rgb[..., 2] *= wb_gains[2] + + rgb = np.clip(rgb, 0.0, 1.0) + if gamma and gamma > 0: + rgb = np.power(rgb, gamma) + + rgb8 = np.clip(np.round(rgb * 255.0), 0, 255).astype(np.uint8) + return rgb8 + + @check_initialized + def capture_raw16( + self, + save_to_file: bool = True, + filename: str = None, + directory: str = None, + exposure_time: Optional[int] = None, + gain: float = 0.0, + ) -> Tuple[bool, str]: + try: + raw16 = self._capture_numpy("XI_RAW16", exposure_time=exposure_time, gain=gain, invert_rgb_order=False) + except RuntimeError as inst: + return (False, str(inst)) + + if save_to_file: + fullfilename = self._normalize_output_path(directory, filename, ".tif") + try: + if not cv2.imwrite(fullfilename, raw16): + return (False, "Failed to save RAW16 image to " + fullfilename) + except Exception as inst: + return (False, "Failed to save RAW16 image: " + str(inst)) + return (True, "Successfully saved RAW16 image to " + fullfilename) + + return (True, "Successfully captured RAW16 image without saving.") + + @check_initialized + def capture_rgb_no_correction( + self, + save_to_file: bool = True, + filename: str = None, + directory: str = None, + exposure_time: Optional[int] = None, + gain: float = 0.0, + bayer_pattern: str = DEFAULT_RAW_BAYER_PATTERN, + raw_max_value: float = DEFAULT_RAW_MAX_VALUE, + wb_gains: Optional[Tuple[float, float, float]] = None, + gamma: float = 1.0, + ) -> Tuple[bool, str]: + try: + raw16 = self._capture_numpy("XI_RAW16", exposure_time=exposure_time, gain=gain, invert_rgb_order=False) + rgb8 = self.raw16_to_rgb_array( + raw16, + bayer_pattern=bayer_pattern, + raw_max_value=raw_max_value, + wb_gains=wb_gains, + gamma=gamma, + ) + except Exception as inst: + return (False, str(inst)) + + if save_to_file: + fullfilename = self._normalize_output_path(directory, filename, ".tif") + try: + if not cv2.imwrite(fullfilename, cv2.cvtColor(rgb8, cv2.COLOR_RGB2BGR)): + return (False, "Failed to save RGB image to " + fullfilename) + except Exception as inst: + return (False, "Failed to save RGB image: " + str(inst)) + return (True, "Successfully saved RGB image to " + fullfilename) + + return (True, "Successfully captured RGB image without saving.") + + @staticmethod + def convert_saved_raw16_to_rgb( + raw16_path: str, + rgb_path: Optional[str] = None, + bayer_pattern: str = DEFAULT_RAW_BAYER_PATTERN, + raw_max_value: float = DEFAULT_RAW_MAX_VALUE, + wb_gains: Optional[Tuple[float, float, float]] = None, + gamma: float = 1.0, + ) -> Tuple[bool, str]: + if rgb_path is None: + root, ext = os.path.splitext(raw16_path) + rgb_path = root + "_rgb" + (ext if ext else ".tif") + + raw16 = cv2.imread(raw16_path, cv2.IMREAD_UNCHANGED) + if raw16 is None: + return (False, "Failed to read RAW16 image from " + raw16_path) + + try: + rgb8 = XimeaCamera.raw16_to_rgb_array( + raw16, + bayer_pattern=bayer_pattern, + raw_max_value=raw_max_value, + wb_gains=wb_gains, + gamma=gamma, + ) + os.makedirs(os.path.dirname(rgb_path) or ".", exist_ok=True) + if not cv2.imwrite(rgb_path, cv2.cvtColor(rgb8, cv2.COLOR_RGB2BGR)): + return (False, "Failed to save RGB image to " + rgb_path) + except Exception as inst: + return (False, "Failed to convert RAW16 to RGB: " + str(inst)) + + return (True, "Successfully converted RAW16 image to RGB: " + rgb_path) + + @check_initialized + def update_background( + self, + save_to_file: bool = True, + filename: str = None, + exposure_time: Optional[int] = None, + gain: Optional[float] = None) -> Tuple[bool, str]: + if exposure_time is None: + exposure_time = self._default_exposure_time + if gain is None: + gain = self._default_gain + + try: + self.cam.set_exposure(exposure_time) + self.cam.set_gain(gain) + + img = xiapi.Image() + self.cam.start_acquisition() + self.cam.get_image(img) + self.cam.stop_acquisition() + except xiapi.Xi_error as inst: + return (False, "Error while getting image: " + str(inst)) + + data = img.get_image_data_numpy(invert_rgb_order=True) + self.background = data + img = Image.fromarray(self.background, 'RGB') + + if save_to_file: + + if filename is None: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = timestamp + fullfilename = self.save_directory + filename + img.save(fullfilename + '.bmp') + + return (True, "Successfully update background") + + # @check_initialized + # def is_uniform( + # self, + # save_to_file: bool = True, + # exposure_time: Optional[int] = None, + # gain: Optional[float] = None) -> float: + # if exposure_time is None: + # exposure_time = self._default_exposure_time + # if gain is None: + # gain = self._default_gain + + # try: + # self.cam.set_exposure(exposure_time) + # self.cam.set_gain(gain) + # img = xiapi.Image() + # self.cam.start_acquisition() + # self.cam.get_image(img) + # self.cam.stop_acquisition() + # except xiapi.Xi_error as inst: + # return (False, "Error while getting image: " + str(inst)) + # data = img.get_image_data_numpy(invert_rgb_order=True) + + # # define a square area, check uniform + # x_upper = 680 + # y_upper = 540 + # length = 500 + # checking_area = data[y_upper : y_upper+length, x_upper: x_upper + length, :] + # std = 0 + # for i in range (3): + # avg = np.average(checking_area[:,:,i]) + # sqaure_diff = (checking_area[:,:,i] - avg) ** 2 + # std += np.sqrt(np.average(sqaure_diff)) + # score = 1 - std / (127.5 * 3) + + # checking_area = data[y_upper : y_upper+length, x_upper: x_upper + length, :] + # img_focus = Image.fromarray(checking_area, 'RGB') + + # if save_to_file: + + # if filename is None: + # timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + # filename = timestamp + # fullfilename = self.save_directory + filename + # img_focus.save(fullfilename + '_focus' + '.bmp') + + # return score + + @check_initialized + def get_image( + self, + save_to_file: bool = True, + filename: str = None, + check_uniform: bool = True, + y_upper: int = 20, + y_length: int = 1000, + x_upper: int = None, + x_length: int = None, + exposure_time: Optional[int] = None, + gain: Optional[float] = None, + show_pop_up: bool = False) -> Tuple[bool, str]: + # if not self._is_initialized: + # return (False, "Camera is not initialized") + + + + if x_upper is None: + x_upper = self.boundaryx_0 + if x_length is None: + x_length = self.boundaryx_1 - self.boundaryx_0 + print('x_upper:', x_upper) + print('x_length:', x_length) + if exposure_time is None: + exposure_time = self._default_exposure_time + if gain is None: + gain = self._default_gain + + try: + self.cam.set_exposure(exposure_time) + self.cam.set_gain(gain) + img = xiapi.Image() + self.cam.start_acquisition() + # exposure time is in microsec, timeout is in millisec, timeout is set to double the exposure time + # self.cam.get_image(img, timeout=(int(exposure_time / 1000 * 2))) + self.cam.get_image(img) + # data_raw = img.get_image_data_raw() + # data_0 = list(data_raw) + # print("first 10 pixels: " + str(data_0[:10])) + self.cam.stop_acquisition() + except xiapi.Xi_error as inst: + return (False, "Error while getting image: " + str(inst)) + + # print("Current image format:", self.cam.get_imgdataformat()) + + data = img.get_image_data_numpy(invert_rgb_order=True) + img = Image.fromarray(data, 'RGB') + + # print("data:",data.dtype) + print("shape: ", data.shape) + # print("first 10 value:", data[0:10,0,:]) + + if check_uniform: + if self.background is None: + checking_area = data[y_upper: y_upper + y_length, x_upper: x_upper + x_length, :] + else: + corrected_image = cv2.subtract(self.background, data) + print("shape of corrected_image", corrected_image.shape) + checking_area = corrected_image[y_upper: y_upper + y_length, x_upper: x_upper + x_length] + checking_area[checking_area < 0] = 0 + std = 0 + for i in range(3): + std += (np.std(checking_area[:, :, i])) ** 2 + score = 1 - np.sqrt(std) / (220.836) + img_focus = Image.fromarray(checking_area, 'RGB') + print("uniformity score is ", score) + + if save_to_file: + + if filename is None: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = timestamp + fullfilename = self.save_directory + filename + img.save(fullfilename + '.bmp') + + settings = ["image data format = " + self.cam.get_imgdataformat() + "\n", + "exposure (us) = " + str(self.cam.get_exposure()) + "\n", + "gain = " + str(self.cam.get_gain()) + "\n", + "wb_kr = " + str(self.cam.get_wb_kr()) + "\n", + "wb_kg = " + str(self.cam.get_wb_kg()) + "\n", + "wb_kb = " + str(self.cam.get_wb_kb()) + "\n"] + with open(fullfilename + '.txt', 'w') as file: + file.writelines(settings) + + if check_uniform: + img_focus.save(fullfilename + '_checkunifrom' + '.bmp') + settings = ["This sample is " + str(score * 100) + "% uniform, (1 - std / 127.5) " + "\n", + "checking area:" "\n", + "x from " + str(x_upper) + " to " + str(x_upper + x_length) + "\n", + "y from " + str(y_upper) + " to " + str(y_upper + y_length) + "\n"] + with open(fullfilename + '_checkunifrom' + '.txt', 'w') as file: + file.writelines(settings) + + return (True, "Successfully saved image and settings to " + str(fullfilename) + ".bmp") + + if show_pop_up: + img.show() + + return (True, "Successfully took image but did not save.") + + @check_initialized + def update_vertical_bound( + self, + exposure_time: Optional[int] = None, + gain: Optional[float] = None) -> Tuple[bool, str]: + if self.background is None: + return (False, "background image is required, call update_background first") + if exposure_time is None: + exposure_time = self._default_exposure_time + if gain is None: + gain = self._default_gain + + try: + self.cam.set_exposure(exposure_time) + self.cam.set_gain(gain) + + img = xiapi.Image() + self.cam.start_acquisition() + # exposure time is in microsec, timeout is in millisec, timeout is set to double the exposure time + self.cam.get_image(img) + self.cam.stop_acquisition() + except xiapi.Xi_error as inst: + return (False, "Error while getting image: " + str(inst)) + + data = img.get_image_data_numpy(invert_rgb_order=True) + gray_scale_ref = cv2.subtract(cv2.cvtColor(self.background, cv2.COLOR_RGB2GRAY), + cv2.cvtColor(data, cv2.COLOR_RGB2GRAY)) + gray_scale_ref[gray_scale_ref < 5] = 0 + gray_scale_ref[gray_scale_ref >= 5] = 100 + boundary = np.abs(gray_scale_ref[:, 1:] - gray_scale_ref[:, :-1]) + img = Image.fromarray(boundary, 'L') + img.save(self.save_directory + 'z_bbs' + '.bmp') + print('boundary.shape', boundary.shape) + boundary = boundary.astype(np.int32) + boundary = np.sum(boundary, axis=0) + + # remainder = len(boundary) % 10 + # if remainder != 0: + # padding = 10 - remainder + # boundary_pad = np.pad(boundary, (0,padding), "constant") + # else: + # boundary_pad = boundary + # boundary_reshape = (boundary_pad.reshape(-1,10)).sum(axis=1) + + boundary_x = np.argsort(boundary) + print('boundary_x:', boundary_x[-10:]) + print(boundary.argmin(), boundary.argmax(), boundary.min(), boundary.max()) + print(boundary[boundary_x[-10:]]) + + self.boundaryx_0 = boundary_x[-1] + boundaryx_1_idx = -1 + while (np.abs(boundary_x[boundaryx_1_idx] - self.boundaryx_0) < 600): + boundaryx_1_idx -= 1 + self.boundaryx_1 = boundary_x[boundaryx_1_idx] + + if self.boundaryx_1 < self.boundaryx_0: + temp = self.boundaryx_1 + self.boundaryx_1 = self.boundaryx_0 + self.boundaryx_0 = temp + print('self.boundaryx_0:', self.boundaryx_0) + print('self.boundaryx_1:', self.boundaryx_1) + self.boundaryx_0 += 60 + self.boundaryx_1 -= 60 + + return (True, "Successfully update vertical boundary") + + @check_initialized + def update_completeness_ref( + self, + save_to_file: bool = True, + filename: str = None, + exposure_time: Optional[int] = None, + gain: Optional[float] = None) -> Tuple[bool, str]: + if self.background is None: + return (False, "background image is required, call update_background first") + if exposure_time is None: + exposure_time = self._default_exposure_time + if gain is None: + gain = self._default_gain + + try: + self.cam.set_exposure(exposure_time) + self.cam.set_gain(gain) + + img = xiapi.Image() + self.cam.start_acquisition() + # exposure time is in microsec, timeout is in millisec, timeout is set to double the exposure time + self.cam.get_image(img) + self.cam.stop_acquisition() + except xiapi.Xi_error as inst: + return (False, "Error while getting image: " + str(inst)) + + data = img.get_image_data_numpy(invert_rgb_order=True) + gray_scale_ref = cv2.subtract(cv2.cvtColor(self.background, cv2.COLOR_RGB2GRAY), + cv2.cvtColor(data, cv2.COLOR_RGB2GRAY)) + gray_scale_ref[gray_scale_ref < 5] = 0 + gray_scale_ref[gray_scale_ref >= 5] = 100 + self.completeness_ref = gray_scale_ref + img = Image.fromarray(self.completeness_ref, 'L') + + if save_to_file: + + if filename is None: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = timestamp + fullfilename = self.save_directory + filename + img.save(fullfilename + '.bmp') + + return (True, "Successfully update completeness reference") + + @check_initialized + def check_completeness( + self, + save_to_file: bool = True, + filename: str = None, + exposure_time: Optional[int] = None, + gain: Optional[float] = None) -> Tuple[bool, str]: + if self.completeness_ref is None: + return (False, "completeness_ref image is required, call update_completeness_ref first") + if exposure_time is None: + exposure_time = self._default_exposure_time + if gain is None: + gain = self._default_gain + + try: + self.cam.set_exposure(exposure_time) + self.cam.set_gain(gain) + + img = xiapi.Image() + self.cam.start_acquisition() + # exposure time is in microsec, timeout is in millisec, timeout is set to double the exposure time + self.cam.get_image(img) + self.cam.stop_acquisition() + except xiapi.Xi_error as inst: + return (False, "Error while getting image: " + str(inst)) + + data = img.get_image_data_numpy(invert_rgb_order=True) + gray_scale_image = cv2.subtract(cv2.cvtColor(self.background, cv2.COLOR_RGB2GRAY), + cv2.cvtColor(data, cv2.COLOR_RGB2GRAY)) + gray_scale_image[gray_scale_image < 5] = 0 + gray_scale_image[gray_scale_image >= 5] = 100 + similarity = 1 - np.sum(np.abs(gray_scale_image - self.completeness_ref)) / ( + np.sum(np.ones_like(gray_scale_image)) * 100) + img = Image.fromarray(gray_scale_image, 'L') + + if save_to_file: + + if filename is None: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = timestamp + fullfilename = self.save_directory + filename + img.save(fullfilename + '.bmp') + + return (True, "This sample's shape is " + str(similarity * 100) + "% similar to the reference.") + + @check_initialized + def update_white_balance(self, exposure_time: int = None, gain: Optional[float] = None) -> Tuple[bool, str]: + # if not self._is_initialized: + # return (False, "Camera is not initialized") + + try: + self.cam.set_manual_wb(1) + except xiapi.Xi_error as inst: + return (False, "Failed to set white balance: " + str(inst)) + + was_successful, result_message = self.get_image(save_to_file=False, filename=None, exposure_time=exposure_time, + gain=gain) + + if not was_successful: + return (was_successful, result_message) + + return (True, "Successfully updated white balance coefficients with image: wb_kr, wb_kg, wb_kb = " + str( + self.get_white_balance_rgb_coeffs())) + + @check_initialized # is this necessary + def set_white_balance_manually(self, wb_kr: Optional[float] = None, wb_kg: Optional[float] = None, + wb_kb: Optional[float] = None) -> Tuple[bool, str]: + # if not self._is_initialized: + # return (False, "Camera is not initialized") + + if wb_kr is None and wb_kg is None and wb_kb is None: + return (True, + "No white balance coefficients were changed. Coefficients are currently: wb_kr, wb_kg, wb_kb = " + str( + self.get_white_balance_rgb_coeffs())) + try: + if wb_kr is not None: + self.cam.set_wb_kr(wb_kr) + if wb_kg is not None: + self.cam.set_wb_kg(wb_kg) + if wb_kb is not None: + self.cam.set_wb_kb(wb_kb) + except: + return (False, + "Error in setting white balance coefficients. Coefficients are currently: wb_kr, wb_kg, wb_kb = " + str( + self.get_white_balance_rgb_coeffs())) + + return (True, "Successfully set white balance coefficients manually: wb_kr, wb_kg, wb_kb = " + str( + self.get_white_balance_rgb_coeffs())) + + @check_initialized # is this necessary + def reset_white_balance_rgb_coeffs(self) -> Tuple[bool, str]: + self.cam.set_wb_kr(self._default_wb_kr) + self.cam.set_wb_kg(self._default_wb_kg) + self.cam.set_wb_kb(self._default_wb_kb) + return (True, + "Successfully reset white balance coefficients to defaults. Coefficients are currently: wb_kr, wb_kg, wb_kb = " + str( + self.get_white_balance_rgb_coeffs())) + + @check_initialized # is this necessary + def get_white_balance_rgb_coeffs(self) -> List[float]: + wb = [] + wb.append(self.cam.get_wb_kr()) + wb.append(self.cam.get_wb_kg()) + wb.append(self.cam.get_wb_kb()) + return wb diff --git a/aamp_app/devices/z812.py b/aamp_app/devices/z812.py new file mode 100644 index 0000000..37aecd9 --- /dev/null +++ b/aamp_app/devices/z812.py @@ -0,0 +1,182 @@ +from typing import Optional, Tuple +from struct import pack, unpack +import time + +from .device import SerialDevice, check_serial, check_initialized + + +class Z812(SerialDevice): + DEVICE_UNIT_SCALE = 34555 + MIN_POSITION_MM = 0.0 + MAX_POSITION_MM = 12.0 + HOME_TIMEOUT_S = 120.0 + MOVE_TIMEOUT_S = 120.0 + + def __init__( + self, + name: str, + port: str = "COM6", + baudrate: int = 115200, + timeout: Optional[float] = 0.1, + destination: int = 0x50, + source: int = 0x01, + channel: int = 1, + ): + super().__init__(name, port, baudrate, timeout) + self._destination = destination + self._source = source + self._channel = channel + self._is_enabled = False + + def _wait_for_message(self, expected_header: bytes, timeout_s: float) -> Tuple[bool, str]: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self.ser.read(2) + if response == expected_header: + return (True, "") + if response == b"": + continue + return (False, "Response timed out while waiting for Z812 controller message.") + + def _validate_position(self, position: float) -> Tuple[bool, str]: + if position < self.MIN_POSITION_MM or position > self.MAX_POSITION_MM: + return ( + False, + "Position " + + str(position) + + " is out of range. Expected " + + str(self.MIN_POSITION_MM) + + " to " + + str(self.MAX_POSITION_MM) + + " mm.", + ) + return (True, "") + + def get_init_args(self) -> dict: + return { + "name": self._name, + "port": self._port, + "baudrate": self._baudrate, + "timeout": self._timeout, + "destination": self._destination, + "source": self._source, + "channel": self._channel, + } + + def update_init_args(self, args_dict: dict): + self._name = args_dict["name"] + self._port = args_dict["port"] + self._baudrate = args_dict["baudrate"] + self._timeout = args_dict["timeout"] + self._destination = args_dict["destination"] + self._source = args_dict["source"] + self._channel = args_dict["channel"] + + @check_serial + def initialize(self) -> Tuple[bool, str]: + self._is_initialized = False + + was_enabled, message = self.set_enabled_state(True) + if not was_enabled: + return (was_enabled, message) + + self.ser.write(pack(" Tuple[bool, str]: + if self.ser.is_open: + self.set_enabled_state(False) + self._is_initialized = False + return (True, "Successfully deinitialized Z812.") + + @check_serial + def get_enabled_state(self) -> bool: + self.ser.write(pack(" Tuple[bool, str]: + if state: + self.ser.write(pack(" float: + self.ser.write(pack(" Tuple[bool, str]: + is_valid_position, message = self._validate_position(position) + if not is_valid_position: + return (False, message) + + dunit_pos = int(self.DEVICE_UNIT_SCALE * position) + self.ser.write( + pack( + " Tuple[bool, str]: + target_position = self.get_position() + distance + is_valid_position, message = self._validate_position(target_position) + if not is_valid_position: + return (False, message) + + dunit_pos = int(self.DEVICE_UNIT_SCALE * distance) + self.ser.write( + pack( + " None: + if file_path.exists(): + with open(file_path, "r", encoding="utf-8") as file: + for line in file: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + +def get_mongo() -> MongoDBHelper: + load_env() + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + if not mongo_uri or not mongo_db_name: + raise RuntimeError("MONGO_URI and MONGO_DB_NAME must be set in .env.") + return MongoDBHelper(mongo_uri, mongo_db_name) + + +with open(TEMPLATE_PATH, "r", encoding="utf-8") as file: + CH_APIS_RECIPE_TEMPLATE = Template(file.read()) + + +app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) + +app.layout = html.Div( + [ + html.Div( + [ + html.H1( + [ + "CH APIS Imaging Builder Draft", + html.Span( + " ?", + id="ch-apis-builder-help", + style={ + "cursor": "pointer", + "color": "gray", + "fontWeight": "bold", + "fontSize": "0.7em", + "marginLeft": "10px", + }, + ), + ], + style={"display": "inline-block"}, + ), + dbc.Tooltip( + "Standalone draft for APIS imaging recipe generation. " + "Select a campaign and it will list all sets that do not yet have APIS imaging records in the images collection. " + "Generated recipes perform MongoDB lookup at execution time using campaign_name, batch_no, and sample_no.", + target="ch-apis-builder-help", + placement="right", + style={"maxWidth": "420px"}, + ), + ] + ), + dbc.Alert( + id="ch-apis-alert", + color="success", + is_open=False, + fade=True, + className="mb-3", + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + html.H5("Select Campaign"), + dcc.Dropdown( + id="ch-apis-campaign-dropdown", + options=[], + placeholder="Select a campaign...", + ), + ], + width=8, + ), + dbc.Col( + [ + dbc.Button( + "Generate Pending APIS Recipes", + id="ch-apis-generate-button", + color="primary", + className="mt-4", + ), + ], + width=2, + ), + dbc.Col( + [ + dbc.Button( + "Run 0 recipes", + id="ch-apis-run-button", + n_clicks=0, + className="btn btn-success mt-4", + ), + ], + width=2, + ), + ], + className="mb-3", + ), + html.Div( + [ + html.H4("Pending Parameter Sets"), + html.Div(id="ch-apis-sets"), + ], + className="mb-3", + ), + html.Div(id="ch-apis-output", className="mb-3"), + html.Div(id="ch-apis-run-log", className="mb-3"), + dcc.Store(id="ch-apis-parameter-sets"), + dcc.Store(id="ch-apis-generated-scripts"), + ], + className="container", + ), + ], + className="container", +) + + +@callback( + Output("ch-apis-campaign-dropdown", "options"), + Input("ch-apis-campaign-dropdown", "search_value"), +) +def update_campaign_options(search_value): + mongo = get_mongo() + try: + campaigns = list(mongo.db.campaigns.find({}, {"campaign_name": 1})) + options = [{"label": doc["campaign_name"], "value": doc["campaign_name"]} for doc in campaigns] + if search_value: + options = [opt for opt in options if search_value.lower() in opt["label"].lower()] + return options + finally: + mongo.close_connection() + + +@callback( + Output("ch-apis-sets", "children"), + Output("ch-apis-parameter-sets", "data"), + Output("ch-apis-alert", "children"), + Output("ch-apis-alert", "is_open"), + Output("ch-apis-alert", "color"), + Input("ch-apis-campaign-dropdown", "value"), +) +def display_pending_campaign_sets(selected_campaign): + if not selected_campaign: + return html.Div("Select a campaign to view pending APIS imaging sets."), [], "", False, "success" + + mongo = get_mongo() + try: + campaign = mongo.db.campaigns.find_one({"campaign_name": selected_campaign}) + if not campaign: + return html.Div(f"Campaign '{selected_campaign}' was not found."), [], "Campaign not found.", True, "danger" + + sets = list( + mongo.db.sets.find({"campaign_id": campaign["_id"]}).sort( + [("batch_no", 1), ("sample_no", 1)] + ) + ) + if not sets: + return html.Div(f"No parameter sets found for campaign '{selected_campaign}'."), [], "No parameter sets found.", True, "warning" + + executed_set_ids = set( + mongo.db.images.distinct( + "set_id", + { + "measurement_type": "apis_imaging", + "campaign_id": campaign["_id"], + }, + ) + ) + pending_sets = [doc for doc in sets if doc["_id"] not in executed_set_ids] + + if not pending_sets: + return ( + html.Div(f"All parameter sets for campaign '{selected_campaign}' already have APIS imaging records."), + [], + "No pending APIS imaging sets remain for this campaign.", + True, + "info", + ) + + table_rows = [] + for doc in pending_sets: + table_rows.append( + { + "set_id": str(doc["_id"]), + "batch_no": doc.get("batch_no"), + "sample_no": doc.get("sample_no"), + "polymer_name": doc.get("polymer_name"), + "solvent": doc.get("solvent"), + "concentration": doc.get("concentration"), + "motor_speed": doc.get("motor_speed"), + "temperature": doc.get("temperature"), + "printing_gap": doc.get("printing_gap"), + "precursor_volume": doc.get("precursor_volume"), + } + ) + + table = dash_table.DataTable( + id="ch-apis-sets-table", + data=table_rows, + columns=[ + {"name": "Batch No", "id": "batch_no"}, + {"name": "Sample No", "id": "sample_no"}, + {"name": "Polymer", "id": "polymer_name"}, + {"name": "Solvent", "id": "solvent"}, + {"name": "Conc.", "id": "concentration"}, + {"name": "Speed", "id": "motor_speed"}, + {"name": "Temp", "id": "temperature"}, + {"name": "Gap", "id": "printing_gap"}, + {"name": "Volume", "id": "precursor_volume"}, + ], + selected_rows=list(range(len(table_rows))), + row_selectable="multi", + page_size=12, + style_table={"overflowX": "auto"}, + style_cell={"textAlign": "left"}, + ) + msg = f"Loaded {len(table_rows)} pending APIS imaging sets for campaign '{selected_campaign}'." + return table, table_rows, msg, True, "success" + finally: + mongo.close_connection() + + +@callback( + Output("ch-apis-run-button", "children"), + Input("ch-apis-parameter-sets", "data"), + Input("ch-apis-sets-table", "selected_rows"), + prevent_initial_call=False, +) +def update_run_button_label(parameter_sets, selected_rows): + if not parameter_sets: + return "Run 0 recipes" + num_recipes = len(selected_rows) if selected_rows else 0 + return f"Run {num_recipes} recipe{'s' if num_recipes != 1 else ''}" + + +@callback( + Output("ch-apis-output", "children"), + Output("ch-apis-generated-scripts", "data"), + Input("ch-apis-generate-button", "n_clicks"), + State("ch-apis-sets-table", "selected_rows"), + State("ch-apis-parameter-sets", "data"), + State("ch-apis-campaign-dropdown", "value"), + prevent_initial_call=True, +) +def generate_pending_apis_recipes(n_clicks, selected_rows, parameter_sets, selected_campaign): + if not selected_campaign: + return html.Div("Select a campaign first.", style={"color": "red"}), [] + if not parameter_sets: + return html.Div("No pending parameter sets are available.", style={"color": "red"}), [] + if not selected_rows: + return html.Div("Select at least one parameter set.", style={"color": "red"}), [] + + generated_scripts = [] + for idx in selected_rows: + params = parameter_sets[idx] + sub_dict = { + "campaign_name": selected_campaign, + "batch_no": params["batch_no"], + "sample_no": params["sample_no"], + } + script = CH_APIS_RECIPE_TEMPLATE.safe_substitute(sub_dict) + generated_scripts.append( + { + "batch_no": params["batch_no"], + "sample_no": params["sample_no"], + "script": script, + } + ) + + return ( + html.Div( + [ + html.H4("Generated APIS Imaging Recipes"), + html.Ul( + [ + html.Li( + [ + html.P(f"Batch {entry['batch_no']} Sample {entry['sample_no']}"), + html.Pre( + entry["script"], + style={ + "height": "420px", + "overflowY": "auto", + "backgroundColor": "#f8f9fa", + "padding": "10px", + "border": "1px solid #dee2e6", + }, + ), + ] + ) + for entry in generated_scripts + ] + ), + ] + ), + generated_scripts, + ) + + +@callback( + Output("ch-apis-run-log", "children"), + Input("ch-apis-run-button", "n_clicks"), + State("ch-apis-generated-scripts", "data"), + prevent_initial_call=True, +) +def run_generated_apis_recipes(n_clicks, generated_scripts): + if not generated_scripts: + return html.Div("No generated recipes to run.", style={"color": "red"}) + + log_components = [] + interceptor = ConsoleInterceptor() + + try: + for entry in generated_scripts: + code = entry["script"] + log_components.append(html.H5(f"Running Batch {entry['batch_no']} Sample {entry['sample_no']}")) + + interceptor.start_interception() + try: + exec(code) + status = "Success" + alert_color = "success" + except Exception as exc: + status = f"Error: {str(exc)}" + alert_color = "danger" + finally: + interceptor.stop_interception() + + output = interceptor.get_intercepted_messages() + log_components.extend( + [ + dbc.Alert(status, color=alert_color), + html.Pre( + f"Output:\n{''.join(output)}", + style={ + "backgroundColor": "#f8f9fa", + "padding": "10px", + "border": "1px solid #dee2e6", + "maxHeight": "300px", + "overflowY": "auto", + }, + ), + html.Hr(), + ] + ) + interceptor.intercepted_messages = [] + + return log_components + finally: + del interceptor + + +if __name__ == "__main__": + app.run(debug=True, port=8051) diff --git a/mongodb_helper.py b/aamp_app/mongodb_helper.py similarity index 96% rename from mongodb_helper.py rename to aamp_app/mongodb_helper.py index 84b012b..d0aa3ba 100644 --- a/mongodb_helper.py +++ b/aamp_app/mongodb_helper.py @@ -59,9 +59,11 @@ def insert_yaml_file(self, collection, file_path): str: The inserted document ID. """ with open(file_path, 'r') as file: - yaml_data = yaml.safe_load(file) + # yaml_data = yaml.load(file, Loader=yaml.Loader) + yaml_data = file.read() + doc = {'yaml_data': yaml_data, 'file_name': file_path} - return str(self.db[collection].insert_one(yaml_data).inserted_id) + return str(self.db[collection].insert_one(doc).inserted_id) def update_yaml_file(self, collection, file_id, updated_data): """ diff --git a/aamp_app/pages/bayesian-optimization.py b/aamp_app/pages/bayesian-optimization.py new file mode 100644 index 0000000..39aae49 --- /dev/null +++ b/aamp_app/pages/bayesian-optimization.py @@ -0,0 +1,337 @@ +from dash import html, dcc, dash_table, callback, Input, Output, State +import dash_bootstrap_components as dbc +import dash +import io +import contextlib +from pymongo import MongoClient +import gridfs +import os +import signal + +dash.register_page( + __name__, + path="/bayesian-optimization", + name="Optimization", + title="Optimization" +) + +def emergency_stop(): + os.kill(os.getpid(), signal.SIGINT) + +client = MongoClient('mongodb://aamp-dev:dcb22e50d7f310850e748c22e6278e4b@aamp-mongodb-0.aamp.mmli2.ncsa.illinois.edu/aamp?replicaSet=aamp-mongodb&authSource=admin&tlsAllowInvalidCertificates=true&tls=true') +db = client['aamp-dev'] +fs = gridfs.GridFS(db) + +# def plot_optimization_results(optimizer, objective): +# """Create selective plots of the optimization results (1, 3, and 4 only).""" + +# import matplotlib.pyplot as plt +# import numpy as np +# import base64 +# # import io + +# history = optimizer.get_optimization_history() +# train_X, train_Y = optimizer.get_training_data() +# true_params, _ = objective.get_optimal_parameters() +# true_score = objective.evaluate_at_optimal() + +# # Create a 1x3 subplot layout for the three selected plots +# fig, axes = plt.subplots(1, 3, figsize=(21, 6)) # Wider layout + +# ### 1. Optimization Progress (axes[0]) +# iterations = [h['iteration'] for h in history] +# best_values = [h['best_value'] for h in history] + +# axes[0].plot(iterations, best_values, 'b-o', linewidth=2, markersize=6) +# axes[0].axhline(y=true_score, color='r', linestyle='--', alpha=0.7, +# label=f'True optimum: {true_score:.3f}') +# axes[0].set_xlabel('Iteration') +# axes[0].set_ylabel('Best Observed Value') +# axes[0].set_title('Optimization Progress') +# axes[0].legend() +# axes[0].grid(True, alpha=0.3) + +# ### 3. Parameter Space Exploration (axes[1]) +# scatter = axes[1].scatter(train_X[:, 0], train_X[:, 1], c=train_Y.squeeze(), +# cmap='viridis', alpha=0.6, s=50) +# axes[1].scatter(optimizer.best_parameters[0], optimizer.best_parameters[1], +# c='red', s=200, marker='*', label='Best found', +# edgecolor='black', linewidth=2) +# axes[1].scatter(true_params[0], true_params[1], c='orange', s=200, marker='*', +# label='True optimum', edgecolor='black', linewidth=2) +# axes[1].set_xlabel('Concentration') +# axes[1].set_ylabel('Print Speed (mm/s)') +# axes[1].set_title('Parameter Space Exploration') +# axes[1].legend() +# plt.colorbar(scatter, ax=axes[1], label='Objective Value') + +# ### 4. Gap Size vs Volume (axes[2]) +# scatter2 = axes[2].scatter(train_X[:, 2], train_X[:, 3], c=train_Y.squeeze(), +# cmap='viridis', alpha=0.6, s=50) +# axes[2].scatter(optimizer.best_parameters[2], optimizer.best_parameters[3], +# c='red', s=200, marker='*', label='Best found', +# edgecolor='black', linewidth=2) +# axes[2].scatter(true_params[2], true_params[3], c='orange', s=200, marker='*', +# label='True optimum', edgecolor='black', linewidth=2) +# axes[2].set_xlabel('Gap Size (mm)') +# axes[2].set_ylabel('Volume (μL)') +# axes[2].set_title('🔍 Gap Size vs Volume') +# axes[2].legend() +# plt.colorbar(scatter2, ax=axes[2], label='Objective Value') + +# plt.tight_layout() +# plt.show() + +# buf = io.BytesIO() +# fig.savefig(buf, format="png", bbox_inches='tight') +# buf.seek(0) +# encoded_image = base64.b64encode(buf.read()).decode("utf-8") +# buf.close() +# plt.close(fig) + +# return encoded_image + +layout = html.Div( + [ + html.Div(id="bo-emergency-stop-output", style={"display": "none"}), + html.Div([ + html.H1([ + "Optimization", + html.Span( + " ?", + id="recipe-builder-help", + style={ + "cursor": "pointer", + "color": "gray", + "fontWeight": "bold", + "fontSize": "0.7em", + "marginLeft": "10px" + } + ), + ], style={"display": "inline-block"}), + dbc.Tooltip( + "This page uses parameter sets generated in the sampler to create recipe templates to run on the devices. Selecting a campaign brings up all the parameter sets associated with it, and uses the solution map to determine each set's corresponding solution position. Generating recipes uses string template matching to fill in the templates with the set information and lets the user preview the results. You can also run all of the recipes sequentially.", + target="recipe-builder-help", + placement="right", + style={"maxWidth": "350px"} + ), + ]), + dbc.Alert( + id="recipe-alert", + color="success", + is_open=False, + fade=True, + className="mb-3", + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + html.H5("Select Campaign"), + dcc.Dropdown( + id="recipe-builder-campaign-dropdown", + options=[], + placeholder="Select a campaign..." + ), + ], + width=8, + ), + ], + className="mb-3", + ), + ], + className="container", + ), + # html.Div([ + # html.H4("Campaign Progress"), + # dash_table.DataTable( + # id="recipe-builder-campaign-progress", + # columns=[ + # {"name": "Set ID", "id": "set_id"}, + # {"name": "Status", "id": "status"}, + # {"name": "Timestamp", "id": "timestamp"}, + # {"name": "Solution Pos", "id": "solution_pos"}, + # ], + # data=[], # Filled in via callback when a campaign is selected + # style_table={"overflowX": "auto"}, + # style_cell={"textAlign": "center"}, + # style_header={"backgroundColor": "#f8f9fa", "fontWeight": "bold"}, + # ) + # ], className="mb-4"), + + html.Hr(), + html.H4("Optimizer Parameter Generation"), + + html.Div(id="bo-hyperparam-fields"), + + dbc.Row( + [ + dbc.Col([html.H5("Select Parameters to Include")], width=5), + ], + className="mb-2", + ), + + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dcc.Dropdown( + id="bo-params-dropdown", + options=["Concentration", "Print Speed", "Gap Size", "Volume"], + multi=True, + value=["Concentration", "Print Speed", "Gap Size", "Volume"], + ), + ], + width=6, + ), + ], + className="mb-3", + ), + ], + id="bo-hyperparam-fields", + ), + + dbc.Row([ + dbc.Col([ + html.Label("Batch Size"), + dbc.Input(id="bo-batch-size", type="number", value=8, min=1) + ], width=4), + + dbc.Col([ + html.Label("Target Objective (Required)"), + dbc.Input( + id="bo-target-objective", + type="number", + min=0, + placeholder="e.g., 0.95" + ) + ], width=4), + ], className="mb-3"), + + dbc.ButtonGroup( + [ + dbc.Button("Generate and Save Optimizer Parameters", id="bo-generate-btn", color="primary", className="mb-3"), + dbc.Button("EMERGENCY STOP", id="bo-emergency-stop-btn", color="danger", className="mb-3") + ], + ), + html.Pre(id="optimizer-output1", style={"whiteSpace": "pre-wrap", "border": "1px solid #ccc", "padding": "10px"}), + + ], + className="container", +) + +@callback( + Output("bo-emergency-stop-output", "children"), + Input("bo-emergency-stop-btn", "n_clicks"), + prevent_initial_call=True +) +def emergency_stop_callback(n_clicks): + emergency_stop() + return [] + +@callback( + Output("optimizer-output1", "children"), + Input("bo-generate-btn", "n_clicks"), + State("recipe-builder-campaign-dropdown", "value"), + State("bo-batch-size", "value"), + State("bo-target-objective", "value"), + prevent_initial_call=True +) +def generate_optimizer_parameters(n_clicks, camp, bs, target_objective): + import torch + from Image_Processing.constrained_bo_ver2 import ConstrainedBayesianOptimizer + + f = io.StringIO() + # plot_images = [] + with contextlib.redirect_stdout(f): + # Define search space bounds + if n_clicks > 0: + collection = db['campaigns'] + entry = collection.find_one({"campaign_name": camp}) + grid_out = fs.find_one({"campaign_id": entry['_id'], "filename": "PProDOT_CB_Campaign_parameters.csv"}) + data = grid_out.read() + # bounds = torch.tensor([ + # [min(entry['concentration_range']), max(entry['concentration_range'])], # concentration + # [min(entry['motor_speed']), max(entry['motor_speed'])], # print_speed + # [min(entry['printing_gap']), max(entry['printing_gap'])], # gap_size + # [min(entry['precursor_volume']), max(entry['precursor_volume'])] # volume + # ]).T + bounds = torch.tensor([ + [0.01, 20.0], # Speed + [25.0, 107.0], # Temperature + [50, 200], # Gap + [5, 15] # Volume + ]).T + + discrete_points = [ + torch.tensor([0.1, 0.5, 0.7, 1.0]), # Speed + torch.tensor(list(range(25, 108))), # Temperature + torch.tensor(list(range(50, 201))), # Gap + torch.tensor(list(range(5, 16))) # Volume + ] + + # Create optimizer and objective function + optimizer = ConstrainedBayesianOptimizer( + bounds=bounds, + csv_data=data, + round_num=0, + batch_size=bs, + discrete_or_not=[False, True, True, True], + discrete_points=discrete_points + ) + print("\n\n") + candidates, metadata = optimizer.suggest() + + collection = db['sets'] + for i in range(bs): + set_doc = { + "campaign_id": entry['_id'], + "batch_no": optimizer.round_num, + "sample_no": i + 1, + "motor_speed": candidates[i][0].item(), + "temperature": candidates[i][1].item(), + "printing_gap": candidates[i][2].item(), + "precursor_volume": candidates[i][3].item(), + "ei_log": metadata["ei_log"][i], + "ei_raw": metadata["ei_raw"][i], + "p_valid": metadata["p_valid"][i], + "score_constrained": metadata["score_constrained"][i], + "source": metadata["source"][i], + "candidate_rank": metadata["candidate_rank"][i], + "mu_obj": metadata["mu_obj"][i], + "sigma_obj": metadata["sigma_obj"][i], + "mu_valid_raw": metadata["mu_valid_raw"][i], + } + collection.insert_one(set_doc) + updated_df = optimizer.save_candidates_to_csv(candidates, metadata) + fs.delete(grid_out._id) + csv_bytes = updated_df.to_csv(index=False).encode('utf-8') + fs.put(csv_bytes, filename="PProDOT_CB_Campaign_parameters.csv", campaign_id=entry['_id']) + # for i, fig in enumerate(ff): + # experiment_id = entry['_id'] + # buf = io.BytesIO() + # fig.savefig(buf, format="png") + # buf.seek(0) + # file_id = fs.put(buf.getvalue(), filename=f"sample_plot_{i}.png", metadata={"experiment_id": experiment_id}) + # plots_collection = db['optimizer_plots'] + # plot_doc = { + # "name": f"sample_matplotlib_plot_{i}", + # "experiment_id": experiment_id, + # "file_id": file_id + # } + # plots_collection.insert_one(plot_doc) + # plot = plot_optimization_results(optimizer, objective) + # plot_images.append(plot) + # plot_images.append("plot") + + log_text = f.getvalue() + + # Create image components from the base64-encoded plot images + # image_components = [html.Img(src=f"data:image/png;base64,{img}", style={"width": "100%"}) for img in plot_images] + + # Return the log text and plot images as components + return log_text \ No newline at end of file diff --git a/aamp_app/pages/data.py b/aamp_app/pages/data.py new file mode 100644 index 0000000..376fd23 --- /dev/null +++ b/aamp_app/pages/data.py @@ -0,0 +1,19 @@ +from dash import Dash, html, dcc, dash_table +import dash_bootstrap_components as dbc +import dash + + +dash.register_page(__name__, path="/data", title="Recipe Document", name="Recipe Document") + +layout = html.Div( + [ + html.H1("View Recipe Document"), + dbc.Button("Load data", id="load-data-button", n_clicks=0, className="mb-3"), + # dcc.Textarea( + # id="data-output", readOnly=True, style={"width": "100%", "height": 0} + # ), + # html.Div(id="data-output-div"), + html.Div(id="data-output-div2"), + ], + className="container", +) diff --git a/aamp_app/pages/database.py b/aamp_app/pages/database.py new file mode 100644 index 0000000..c3cbfed --- /dev/null +++ b/aamp_app/pages/database.py @@ -0,0 +1,57 @@ +from dash import Dash, html, dcc, dash_table, callback, Input, Output, State +import dash_bootstrap_components as dbc +import dash + +dash.register_page( + __name__, path="/database", title="Database Browser", name="Database Browser" +) + +layout = html.Div( + [ + html.H1("Database Browser", className="mb-3"), + dcc.Interval(id="interval-database", interval=500000, n_intervals=0), + dbc.Row( + [ + dbc.Col( + [ + dcc.Dropdown( + id="database-db-dropdown", + options=[], + value=None, + className="mb-3", + placeholder="Select a database", + ), + dbc.Col([], id="database-db-data"), + ] + ), + dbc.Col( + [ + dcc.Dropdown( + id="database-collection-dropdown", + options=[], + value=None, + className="mb-3", + disabled=True, + placeholder="Select a collection", + ), + dbc.Col([], id="database-collection-schema"), + ] + ), + dbc.Col( + [ + dcc.Dropdown( + id="database-document-dropdown", + options=[], + value=None, + className="mb-3", + disabled=True, + placeholder="Select a document", + ), + dbc.Col([], id="database-document-viewer"), + ] + ), + ] + ), + ], + className="container", +) diff --git a/aamp_app/pages/execute-recipe.py b/aamp_app/pages/execute-recipe.py new file mode 100644 index 0000000..0055896 --- /dev/null +++ b/aamp_app/pages/execute-recipe.py @@ -0,0 +1,168 @@ +from dash import Dash, html, dcc, dash_table, Input, Output, callback, State +import dash_bootstrap_components as dbc +import dash +import logging + + +dash.register_page(__name__, path="/execute-recipe", name="Execute Recipe", title="Execute Recipe") + +layout = html.Div( + [ + html.H1("Execute Recipe"), + dbc.ButtonGroup( + [ + dbc.Button("Execute", id="execute-button", n_clicks=0), + dbc.Button("Clear Log", id="reset-button", n_clicks=0), + dbc.Button("Emergency Stop", id="stop-button", n_clicks=0, color="danger"), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Input( + id="execute-recipe-upload-name", + placeholder="Name this execution", + type="text", + ), + ] + ) + ], + className="mb-3", + ), + # html.Div( + # [ + # dbc.Switch( + # id='show-log-switch', + # label='Show log', + # value = False, + # style={'display': 'none'} + # ) + # ], + # className="d-flex align-items-center mt-3", + # ), + html.Div(id="execute-recipe-output", className="mt-3", style={"display": "none"}), + dcc.Interval(id="update-interval", interval=500, n_intervals=0), + dcc.Interval(id="interval1", interval=50, n_intervals=0), + html.Div(id="hidden-div", style={"display": "none"}), + dbc.Row( + [ + dbc.Col( + [ + html.H4("Recipe Data"), + dbc.Textarea( + id="execute-recipe-upload-document", + className="log-container mb-3", + readOnly=True, + style={"height": "200px"}, + ), + ] + ), + ] + ), + html.H4("Log"), + html.Div( + id="console-out2", + className="log-container mb-3", + style={ + "height": "500px", + "overflow-y": "scroll", + "padding": "10px", + "border": "2px solid", + }, + ), + dbc.Row( + [ + dbc.Col( + [ + html.H4("Notes"), + dbc.Textarea( + id="execute-recipe-upload-notes", + className="log-container mb-3", + style={"height": "200px"}, + ), + ] + ), + dbc.Col( + [ + html.H4("Upload Files"), + dcc.Upload( + id="execute-recipe-upload-files", + children=html.Div( + [ + "Drag and Drop or ", + html.A( + "Select Files", + style={ + "color": "blue", + "textDecoration": "underline", + }, + ), + " or ", + html.A( + "Replace Selected", + id="execute-recipe-clear-files", + style={ + "color": "blue", + "textDecoration": "underline", + }, + ), + html.Div(id="execute-recipe-upload-files-names"), + ] + ), + style={ + "width": "100%", + "height": "200px", + "lineHeight": "60px", + "borderWidth": "1px", + "borderStyle": "dashed", + "borderRadius": "5px", + "textAlign": "center", + }, + multiple=True, + ), + ] + ), + ] + ), + dbc.Button( + "Save and Upload Data", + id="execute-recipe-upload-data-button", + className="mb-3", + color="primary", + ), + html.Div(id="execute-recipe-upload-data-output"), + ], + className="container", +) + + +@callback( + Output("execute-recipe-upload-files-names", "children"), + Input("execute-recipe-upload-files", "filename"), + State("url", "pathname"), + prevent_initial_call=True, +) +def add_filenames_to_upload_box(filenames, url): + if str(url) == "/execute-recipe": + if filenames is None or filenames == []: + return "" + toRet = "" + for i, filename in enumerate(filenames): + if i < len(filenames) - 1: + toRet += filename + ", " + else: + toRet += filename + return toRet + + +@callback( + Output("execute-recipe-upload-files", "filename"), + Input("execute-recipe-clear-files", "n_clicks"), + State("url", "pathname"), + prevent_initial_call=True, +) +def clear_selected_files(n, url): + if str(url) == "/execute-recipe": + return None diff --git a/aamp_app/pages/home.py b/aamp_app/pages/home.py new file mode 100644 index 0000000..1d557e4 --- /dev/null +++ b/aamp_app/pages/home.py @@ -0,0 +1,204 @@ +from dash import Dash, html, dcc, dash_table, Input, Output, callback +import dash_bootstrap_components as dbc +import dash + +dash.register_page(__name__, "/") + +# layout = html.Div( +# [ +# html.H1("Home"), +# dcc.Input(id="filename-input", type="text", placeholder="Enter filename name"), +# dbc.Button("Load", id="filename-input-button", n_clicks=0), +# html.Div(id="home-output"), +# dbc.Button("Refresh List", id="home-refresh-list-button", n_clicks=0), +# dash_table.DataTable( +# id="home-recipes-list-table", +# columns=[ +# {"name": "File Name", "id": "file_name"}, +# # {'name':'Posix', 'id':'posix_friendly'} +# ], +# data=[], +# style_table={"width": "300px"}, +# style_cell={"textAlign": "left"}, +# ), +# ] +# ) + +links = { + "Load Recipe": "/load-recipe", + "View/Edit Recipe": "/view-recipe", + "Edit Recipe Code": "/edit-recipe", + "Recipe Document": "/data", + "Execute Recipe": "/execute-recipe", + "Manual Control": "/manual-control", + "Database Browser": "/database-browser", + "Real Time Telemetry": "/real-time-telemetry", + # "Images": "/images", + "Sampler": "/sampler", + "Recipe Builder": "/recipe-builder", + "Solution Map": "/solution-map", + "Autonomous Run": "/bayesian-optimization", + "Manual Run": "/manual-run", + # "Options": "/options", +} + +layout = html.Div( + [ + html.H1("Home", className="mb-3"), + # dbc.Row( + # dbc.Col( + # dbc.Card( + # [ + # dbc.ListGroup( + # [ + # dbc.ListGroupItem( + # dbc.NavLink( + # link_name, href=link_path, external_link=True + # ) + # ) + # for link_name, link_path in links.items() + # ], + # flush=True, + # ), + # ] + # ), + # width=6, + # className="mx-auto mt-5", + # ) + # ), + dbc.Row( + [ + html.A( + dbc.Card( + dbc.CardBody( + dbc.NavLink(link_name, href=link_path, external_link=True) + ), + className="mb-3", + ), + href=link_path, + style={"width": "30%"}, + ) + for link_name, link_path in links.items() + ], + style={"justifyContent": "space-around"}, + ) + # dbc.Row( + # [ + # dbc.Col( + # dcc.Input( + # id="filename-input", + # type="text", + # placeholder="Enter file name", + # className="form-control", + # ), + # width=6, + # ), + # dbc.Col( + # dbc.Button( + # "Load", + # id="filename-input-button", + # n_clicks=0, + # color="primary", + # className="btn btn-primary", + # ), + # width=2, + # ), + # ], + # className="mb-3", + # ), + # dbc.Alert( + # id="home-load-file-alert", + # color="success", + # is_open=False, + # # fade=True, + # className="mb-3", + # ), + # dbc.Row( + # [ + # dbc.Col( + # dbc.Button( + # "Refresh List", + # id="home-refresh-list-button", + # n_clicks=0, + # color="secondary", + # className="btn btn-secondary mb-3", + # ), + # width=2, + # ) + # ] + # ), + # dbc.Row( + # [ + # dbc.Col( + # dash_table.DataTable( + # id="home-recipes-list-table", + # columns=[ + # {"name": "File Name", "id": "file_name"}, + # {"name": "Posix Compatible", "id": "posix_friendly"}, + # {"name": "Viewer Compatible", "id": "dash_friendly"}, + # {"name": "Python Code Available", "id": "python_code"}, + # ], + # data=[], + # style_table={"width": "100%"}, + # style_cell={"textAlign": "left"}, + # style_header={"fontWeight": "bold"}, + # page_current=0, + # page_size=10, + # style_data_conditional=[ + # { + # "if": { + # "column_id": "posix_friendly", + # "filter_query": "{posix_friendly} contains true", + # }, + # "backgroundColor": "#b7e8c4", + # "color": "black", + # }, + # { + # "if": { + # "column_id": "posix_friendly", + # "filter_query": "{posix_friendly} contains false", + # }, + # "backgroundColor": "#e8b7b7", + # "color": "black", + # }, + # { + # "if": { + # "column_id": "dash_friendly", + # "filter_query": "{dash_friendly} contains true", + # }, + # "backgroundColor": "#b7e8c4", + # "color": "black", + # }, + # { + # "if": { + # "column_id": "dash_friendly", + # "filter_query": "{dash_friendly} contains false", + # }, + # "backgroundColor": "#e8b7b7", + # "color": "black", + # }, + # { + # "if": { + # "column_id": "python_code", + # "filter_query": "{python_code} contains true", + # }, + # "backgroundColor": "#b7e8c4", + # "color": "black", + # }, + # { + # "if": { + # "column_id": "python_code", + # "filter_query": "{python_code} contains false", + # }, + # "backgroundColor": "#e8b7b7", + # "color": "black", + # }, + # ], + # ), + # width=10, + # ) + # ] + # ), + ], + className="container", +) diff --git a/aamp_app/pages/images.py b/aamp_app/pages/images.py new file mode 100644 index 0000000..15a2e70 --- /dev/null +++ b/aamp_app/pages/images.py @@ -0,0 +1,63 @@ +from dash import html, dcc, callback, Input, Output, State +import dash_bootstrap_components as dbc +import dash + +dash.register_page(__name__, path="/images", name="Images", title="Upload Images") + +layout = html.Div( + [ + html.H1("Upload Images to MongoDB"), + dbc.Alert( + id="images-alert", + color="success", + is_open=False, + fade=True, + className="mb-3", + ), + dbc.Row( + [ + dbc.Col(dbc.Input(id="sample-number", type="number", placeholder="Sample Number"), width=4), + dbc.Col(dbc.Input(id="motor-speed", type="number", placeholder="Motor Speed"), width=4), + dbc.Col(dbc.Input(id="temperature", type="number", placeholder="Temperature"), width=4), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col(dbc.Input(id="concentration", type="number", placeholder="Concentration"), width=4), + dbc.Col(dbc.Input(id="printing-gap", type="number", placeholder="Printing Gap"), width=4), + dbc.Col(dbc.Input(id="precursor-volume", type="number", placeholder="Precursor Volume"), width=4), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col(dbc.Input(id="solvent", type="text", placeholder="Solvent"), width=6), + ], + className="mb-3", + ), + dbc.Row( + [ + dcc.Upload( + id="image-upload", + children=html.Div(["Drag and Drop or ", html.A("Select an Image")]), + style={ + "width": "100%", + "height": "60px", + "lineHeight": "60px", + "borderWidth": "1px", + "borderStyle": "dashed", + "borderRadius": "5px", + "textAlign": "center", + "margin": "10px", + }, + multiple=False, + ), + ], + className="mb-3", + ), + dbc.Button("Upload Image", id="upload-image-button", color="primary"), + html.Div(id="upload-status", style={"marginTop": "20px"}), + ], + className="container", +) diff --git a/aamp_app/pages/load-recipe.py b/aamp_app/pages/load-recipe.py new file mode 100644 index 0000000..5cac21f --- /dev/null +++ b/aamp_app/pages/load-recipe.py @@ -0,0 +1,141 @@ +from dash import Dash, html, dcc, dash_table, Input, Output, callback +import dash_bootstrap_components as dbc +import dash + +dash.register_page(__name__, path="/load-recipe", title="Load Recipe", name="Load Recipe") + + +layout = html.Div( + [ + html.H1("Load Recipe"), + dbc.Row( + [ + dbc.Col( + dcc.Input( + id="filename-input", + type="text", + placeholder="Enter file name", + className="form-control", + ), + width=6, + ), + dbc.Col( + dbc.Button( + "Load", + id="filename-input-button", + n_clicks=0, + color="primary", + className="btn btn-primary", + ), + width=2, + ), + ], + className="mb-3", + ), + dbc.Alert( + id="home-load-file-alert", + color="success", + is_open=False, + # fade=True, + className="mb-3", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Button( + "Refresh List", + id="home-refresh-list-button", + n_clicks=0, + color="secondary", + className="btn btn-secondary mb-3", + ), + ], + width=2, + ), + dbc.Col( + [ + dbc.Button( + "Create New Recipe", + id="home-create-new-recipe-button", + n_clicks=0, + ) + ], + ), + ] + ), + dbc.Row( + [ + dbc.Col( + dash_table.DataTable( + id="home-recipes-list-table", + columns=[ + {"name": "Recipe Name", "id": "file_name"}, + {"name": "Posix Compatible", "id": "posix_friendly"}, + {"name": "Viewer Compatible", "id": "dash_friendly"}, + {"name": "Python Code Available", "id": "python_code"}, + ], + data=[], + style_table={"width": "100%"}, + style_cell={"textAlign": "left"}, + style_header={"fontWeight": "bold"}, + page_current=0, + page_size=10, + style_data_conditional=[ + { + "if": { + "column_id": "posix_friendly", + "filter_query": "{posix_friendly} contains true", + }, + "backgroundColor": "#b7e8c4", + "color": "black", + }, + { + "if": { + "column_id": "posix_friendly", + "filter_query": "{posix_friendly} contains false", + }, + "backgroundColor": "#e8b7b7", + "color": "black", + }, + { + "if": { + "column_id": "dash_friendly", + "filter_query": "{dash_friendly} contains true", + }, + "backgroundColor": "#b7e8c4", + "color": "black", + }, + { + "if": { + "column_id": "dash_friendly", + "filter_query": "{dash_friendly} contains false", + }, + "backgroundColor": "#e8b7b7", + "color": "black", + }, + { + "if": { + "column_id": "python_code", + "filter_query": "{python_code} contains true", + }, + "backgroundColor": "#b7e8c4", + "color": "black", + }, + { + "if": { + "column_id": "python_code", + "filter_query": "{python_code} contains false", + }, + "backgroundColor": "#e8b7b7", + "color": "black", + }, + ], + ), + width=10, + ) + ] + ), + ], + className="container", +) diff --git a/aamp_app/pages/manual-control.py b/aamp_app/pages/manual-control.py new file mode 100644 index 0000000..153ecb6 --- /dev/null +++ b/aamp_app/pages/manual-control.py @@ -0,0 +1,125 @@ +from dash import Dash, html, dcc, dash_table, Input, Output, callback +import dash_bootstrap_components as dbc +import dash + + +dash.register_page(__name__, path="/manual-control", title="Manual Control", name="Manual Control") + +layout = html.Div( + [ + html.H1("Manual Control", className="mb-3"), + dcc.Interval(id="interval-manual-control", interval=500000, n_intervals=0), + dbc.ButtonGroup( + [ + dbc.Button( + "Open Execute Window", + id="manual-control-open-execute-modal-button", + n_clicks=0, + disabled=True, + ), + dbc.Button("Clear", id="manual-control-clear-button", n_clicks=0, disabled=True), + ], + className="mb-3", + ), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Execute")), + dbc.ModalBody( + [ + dbc.Row( + [ + dbc.Col( + [ + dbc.Button( + "Execute", + id="manual-control-execute-button", + n_clicks=0, + style={"width": "100%"}, + ) + ], + width=2, + ), + dbc.Col( + [ + dbc.Alert( + "Alert", + id="manual-control-alert", + is_open=False, + duration=500, + ) + ] + ), + ] + ), + html.Div( + id="manual-control-execute-modal-body-code", + className="log-container", + style={ + "height": "160px", + "overflow-y": "scroll", + "padding": "10px", + "border": "2px solid", + "margin-bottom": "10px", + }, + ), + html.Div( + id="manual-control-execute-modal-body", + className="log-container", + style={ + "height": "300px", + "overflow-y": "scroll", + "padding": "10px", + "border": "2px solid", + }, + ), + ] + ), + ], + id="manual-control-execute-modal", + size="xl", + ), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("COM Port Information")), + dbc.ModalBody([html.Div(id="manual-control-serial-ports-info")]), + ], + id="manual-control-port-modal", + ), + dbc.Row( + [ + dbc.Col( + [ + dcc.Dropdown( + id="manual-control-device-dropdown", + options=[], + value=None, + className="mb-3", + ), + dbc.Col( + [ + dbc.NavLink( + id="manual-control-port-field", + style={"display": "none"}, + ), + ], + id="manual-control-device-form", + ), + ] + ), + dbc.Col( + [ + dcc.Dropdown( + id="manual-control-command-dropdown", + options=[], + value=None, + disabled=True, + className="mb-3", + ), + dbc.Col([], id="manual-control-command-form"), + ] + ), + ], + ), + ], + className="container", +) diff --git a/aamp_app/pages/manual-run.py b/aamp_app/pages/manual-run.py new file mode 100644 index 0000000..dcdb112 --- /dev/null +++ b/aamp_app/pages/manual-run.py @@ -0,0 +1,201 @@ +import torch +import pandas as pd +from pathlib import Path + +from dash import html, dcc, dash_table, callback, Input, Output, State +import dash_bootstrap_components as dbc +import dash +import io +import contextlib +from Image_Processing.image_processing_changhyun import * +from Image_Processing.constrained_bo_ver2 import ConstrainedBayesianOptimizer + +dash.register_page( + __name__, + path="/manual-run", + name="Manual Run", + title="Manual Run" +) + +layout = html.Div([ + html.Div([ + html.H2("Manual Bayesian Optimization Run"), + # Additional layout components can be added here + html.Div([ + dbc.Row([ + dbc.Col([ + html.H5("Upload Training Data"), + dcc.Upload( + id='manual-upload-data', + children=html.Div([ + 'Drag and Drop or ', + html.A('Select a CSV File') + ]), + style={ + 'width': '100%', + 'height': '60px', + 'lineHeight': '60px', + 'borderWidth': '1px', + 'borderStyle': 'dashed', + 'borderRadius': '5px', + 'textAlign': 'center', + 'margin': '10px' + }, + multiple=False, + accept='.csv' + ), + html.Div(id='manual-upload-data-output') + ], width=12) + ], className="mb-3"), + dbc.Button("Run Optimization", id="manual-run-optimization-btn", color="primary"), + html.Pre(id="manual-optimization-output", style={"whiteSpace": "pre-wrap", "border": "1px solid #ccc", "padding": "10px", "marginTop": "10px"}) + ]) + ]), + html.Div( + [ + html.H2("Image Processing"), + dbc.Input(id="data-path-input", placeholder="Enter input data path...", type="text"), + dbc.Button("Process Image", id="process-image-btn", color="primary", className="mt-2"), + html.Table(id="manual-image-processing-output", style={"whiteSpace": "pre-wrap", "border": "1px solid #ccc", "padding": "10px", "marginTop": "10px"}) + ] + ) +]) + +@callback( + Output('manual-optimization-output', 'children'), + Input('manual-run-optimization-btn', 'n_clicks'), + State('manual-upload-data', 'contents'), + State('manual-upload-data', 'filename') +) +def run_manual_optimization(n_clicks, contents, filename): + if n_clicks is None or contents is None: + return "No data uploaded yet." + + f = io.StringIO() + bounds = torch.tensor([ + [0.01, 20.0], # Speed + [25.0, 107.0], # Temperature + [50, 200], # Gap + [5, 15] # Volume + ]).T + + # Define discrete points for each parameter + discrete_points = [ + torch.tensor([0.1, 0.5, 0.7, 1.0]), # Speed (if discrete) + torch.tensor(list(range(25, 108))), # Temperature + torch.tensor(list(range(50, 201))), # Gap + torch.tensor(list(range(5, 16))) # Volume + ] + + csv_path = 'Round0/PProDOT_CB_Campaign_parameters.csv' + optimizer = ConstrainedBayesianOptimizer( + bounds=bounds, + csv_path=csv_path, + round_num=0, + batch_size=8, + discrete_or_not=[False, True, True, True], + discrete_points=discrete_points + ) + + # Suggest candidates + with contextlib.redirect_stdout(f): + candidates, metadata = optimizer.suggest() + print("\n" + "=" * 60) + print("CONSTRAINED BO CANDIDATES READY") + print("=" * 60) + log = f.getvalue() + return log + +@callback( + Output('manual-image-processing-output', 'children'), + Input('process-image-btn', 'n_clicks'), + State('data-path-input', 'value') +) +def img_proc(n_clicks, data_path): + if n_clicks is None: + return "Image processing not started yet." + + main_dirs = [ + Path("Image_Processing/Round0"), + # Path("data/Round0_1"), + # Path("data/Round0_2"), + # Path("data/Round0_3"), + # Path("data/Round1"), + # Path("data/Round2"), + # Path("data/Round3"), + # Path("data/Round4"), + # Path("data/Round5"), + # Path("data/PProDOT_2nd_dataset") + # Add desired directories + ] + + # Method 2: Automatically discover all Round* directories under data/ (comment out Method 1 first) + # data_base = Path("data") + # if data_base.exists(): + # main_dirs = [d for d in data_base.iterdir() if d.is_dir() and d.name.startswith("Round")] + # else: + # main_dirs = [] + + # Common settings + roi_x = 580 + roi_y = 400 + roi_width = 913 + roi_height = 415 + + # Thresholds + uvvis_abs_threshold = 0.1 + coverage_threshold = 0.1 + + # Common metadata (reference_dir and uvvis_dir are automatically set for each directory) + metadata_base = { + "roi_x": roi_x, + "roi_y": roi_y, + "roi_width": roi_width, + "roi_height": roi_height, + "uvvis_abs_threshold": uvvis_abs_threshold, + "coverage_threshold": coverage_threshold, + "coverage_k": 1.0, + "uniformity_std_sensitivity": 15.0, # K1: Sensitivity to Std difference + "uniformity_ent_sensitivity": 2.0, # K2: Sensitivity to Entropy difference + } + + # Process each directory + for main_dir in main_dirs: + if not main_dir.exists(): + print(f"Warning: Directory not found: {main_dir}, skipping...") + continue + + results = None + try: + results = process_single_directory( + main_dir=main_dir, + roi_x=roi_x, + roi_y=roi_y, + roi_width=roi_width, + roi_height=roi_height, + uvvis_abs_threshold=uvvis_abs_threshold, + coverage_threshold=coverage_threshold, + metadata_base=metadata_base, + ) + except Exception as e: + print(f"Error processing {main_dir}: {e}") + import traceback + traceback.print_exc() + continue + + print(f"\n{'='*60}") + print("All directories processed!") + print(f"{'='*60}") + + # Create DataFrame for display + df_results = pd.DataFrame(results) + # only need a few columns for display + display_columns = ["round#", "Sample #", "image_name", "uniformity_score"] + df_display = df_results[display_columns] + return dash_table.DataTable( + data=df_display.to_dict('records'), + columns=[{"name": i, "id": i} for i in df_display.columns], + page_size=10, + style_table={'overflowX': 'auto'}, + style_cell={'textAlign': 'left'}, + ) \ No newline at end of file diff --git a/aamp_app/pages/options.py b/aamp_app/pages/options.py new file mode 100644 index 0000000..1d8e6b6 --- /dev/null +++ b/aamp_app/pages/options.py @@ -0,0 +1,11 @@ +from dash import Dash, html, dcc, Input, Output, State +import dash_bootstrap_components as dbc +import dash + + +dash.register_page(__name__, path="/options", title="Options", name="Options") + +layout = html.Div( + [html.H1("Options", className="mb-3")], + className="container", +) diff --git a/aamp_app/pages/python-edit-recipe.py b/aamp_app/pages/python-edit-recipe.py new file mode 100644 index 0000000..92eaf3a --- /dev/null +++ b/aamp_app/pages/python-edit-recipe.py @@ -0,0 +1,120 @@ +from dash import Dash, html, dcc, dash_table, Input, Output, State, callback +import dash_bootstrap_components as dbc +import dash +import dash_ace + +dash.register_page(__name__, path="/edit-recipe", title="Edit Recipe", name="Edit Recipe") + +layout = html.Div( + [ + html.H1("Edit Recipe Code"), + html.Div( + [ + html.Div( + [ + dbc.Alert("Alert", id="ace-editor-alert", is_open=False, duration=500), + dbc.ButtonGroup( + [ + dbc.Button("Fill editor", id="refresh-button-ace", n_clicks=0), + dbc.Button("Add device", id="add-device-button-ace"), + dbc.Button("Add command", id="add-command-button-ace"), + dbc.Button( + "Execute and save yaml", + id="execute-and-save-button", + n_clicks=0, + ), + ], + className="mb-3", + ), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Add Device")), + dbc.ModalBody( + [ + dcc.Dropdown( + id="add-device-dropdown-ace", + options=[], + value=None, + ), + ] + ), + dbc.ModalFooter(dbc.Button("Add", id="add-device-editor-ace")), + ], + id="device-add-modal-ace", + keyboard=False, + backdrop="static", + ), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Add Command")), + dbc.ModalBody( + [ + dcc.Dropdown( + id="add-command-device-dropdown-ace", + options=[], + value=None, + ), + ] + ), + dbc.ModalBody( + [ + dcc.Dropdown( + id="add-command-command-dropdown-ace", + options=[], + value=None, + ), + ] + ), + dbc.ModalFooter(dbc.Button("Add", id="add-command-editor-ace")), + ], + id="command-add-modal-ace", + keyboard=False, + backdrop="static", + ), + dash_ace.DashAceEditor( + id="ace-recipe-editor", + mode="python", + enableBasicAutocompletion=True, + enableLiveAutocompletion=True, + theme="github", + wrapEnabled=True, + style={"width": "100%", "height": "550px"}, + cursorStart=333, + ), + ], + className="table-container", + ), + ], + className="tables-container", + ), + ], + className="container", +) + + +@callback( + Output("command-add-modal-ace", "is_open"), + [ + Input("add-command-editor-ace", "n_clicks"), + Input("add-command-button-ace", "n_clicks"), + ], + State("command-add-modal-ace", "is_open"), +) +def toggle_command_add_modal_ace(n1, n2, is_open): + if n1 or n2: + return not is_open + return is_open + + +@callback( + Output("device-add-modal-ace", "is_open"), + [ + Input("add-device-button-ace", "n_clicks"), + Input("add-device-editor-ace", "n_clicks"), + ], + [State("device-add-modal-ace", "is_open")], +) +def toggle_device_add_modal_ace(n1, n2, is_open): + if n1 or n2: + return not is_open + return is_open diff --git a/aamp_app/pages/real-time-telemetry.py b/aamp_app/pages/real-time-telemetry.py new file mode 100644 index 0000000..f013aae --- /dev/null +++ b/aamp_app/pages/real-time-telemetry.py @@ -0,0 +1,35 @@ +from dash import Dash, html, dcc, Input, Output, callback +import dash_bootstrap_components as dbc +import dash +from util import devices_ref_redundancy + +dash.register_page( + __name__, + path="/real-time-telemetry", + name="Real Time Telemetry", + title="Real Time Telemetry", +) + + +def check_telemetry(device): + if "telemetry" in list( + devices_ref_redundancy[device].keys() + ) and "default_obj" in list(devices_ref_redundancy[device].keys()): + return True + else: + return False + + +layout = html.Div( + [ + html.H1("Real Time Telemetry", className="mb-3"), + dcc.Dropdown( + options=list(filter(check_telemetry, list(devices_ref_redundancy.keys()))), + id="real-time-telemetry-device-dropdown", + className="mb-3", + ), + dcc.Interval(id="interval-real-time-telemetry", interval=500, n_intervals=0), + html.Div(id="real-time-telemetry-div"), + ], + className="container", +) diff --git a/aamp_app/pages/recipe-builder.py b/aamp_app/pages/recipe-builder.py new file mode 100644 index 0000000..6349c3f --- /dev/null +++ b/aamp_app/pages/recipe-builder.py @@ -0,0 +1,110 @@ +from dash import Dash, html, dcc, Input, Output, State, callback +import dash_bootstrap_components as dbc +import dash + +dash.register_page(__name__, path="/recipe-builder", name="Recipe Builder", title="Recipe Builder") + +layout = html.Div( + [ + html.Div([ + html.H1([ + "Recipe Builder", + html.Span( + " ?", + id="recipe-builder-help", + style={ + "cursor": "pointer", + "color": "gray", + "fontWeight": "bold", + "fontSize": "0.7em", + "marginLeft": "10px" + } + ), + ], style={"display": "inline-block"}), + dbc.Tooltip( + "This page uses parameter sets generated in the sampler to create recipe templates to run on the devices. Selecting a campaign brings up all the parameter sets associated with it, and uses the solution map to determine each set's corresponding solution position. Generating recipes uses string template matching to fill in the templates with the set information and lets the user preview the results. You can also run all of the recipes sequentially.", + target="recipe-builder-help", + placement="right", + style={"maxWidth": "350px"} + ), + ]), + dbc.Alert( + id="recipe-alert", + color="success", + is_open=False, + fade=True, + className="mb-3", + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + html.H5("Select Campaign"), + dcc.Dropdown( + id="recipe-builder-campaign-dropdown", + options=[], + placeholder="Select a campaign..." + ), + ], + width=8, + ), + dbc.Col( + [ + html.H5("Enter Solution Position"), + dcc.Input( + id="recipe-builder-solution-position", + type="text", + placeholder="A1-D5" + ), + ], + width=4, + ), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Button( + "Generate Selected Recipes", + id="recipe-builder-generate-button", + color="primary", + className="mb-3", + ), + ], + width=3, + ), + dbc.Col( + [ + dbc.Button( + "Run 0 recipes", + id="recipe-builder-run-button", + n_clicks=0, + className="btn btn-success mt-2" + ), + ], + width=2, + ), + ], + className="mb-3", + ), + html.Div( + [ + html.H4("Parameter Sets for Selected Campaign"), + html.Div(id="recipe-builder-sets") + ], + className="mb-3" + ), + html.Div(id="recipe-builder-output", className="mb-3"), + dcc.Store(id='recipe-builder-polymer-name'), + dcc.Store(id='recipe-builder-parameter-sets'), + dcc.Store(id="recipe-builder-generated-scripts"), + ], + className="container", + ) +], +className="container", +) \ No newline at end of file diff --git a/aamp_app/pages/sampler.py b/aamp_app/pages/sampler.py new file mode 100644 index 0000000..14d2a09 --- /dev/null +++ b/aamp_app/pages/sampler.py @@ -0,0 +1,557 @@ +from dash import Dash, html, dcc, dash_table, callback, Input, Output, State +import dash_bootstrap_components as dbc +import dash + +dash.register_page(__name__, path="/sampler", name="Sampler", title="Sampler") + +SOLV_NAMES = ["CF", "CB", "CB9:A1", "CB8:A2", "CB7:A3", "1,4-Dichlorobenzene", "1,2,4-Trihlorobenzene", "o-xylene", "m-xylene", "p-xylene", "mesitylene", "toluene", "1-Chloronaphthalene", "anisole", "Tetrahydrofuran", "decane"] +CONCEN_D = [2, 5, 10, 15, 20] +PRINT_GAP_D = [50, 100] +PREC_VOL_D = [6, 9, 12] +MOTOR_SPEEDS_D = [0.01, 0.0355, 0.126, 0.4472, 1.587, 5.635, 20] +SPEED_C = (0.01, 20.0) +PREC_VOL_C = (6.0, 12.0) +CONCEN_C = (1, 5) + +TEMP_CHOICES_D = { + "CF": [25, 41.3], + "CB": [25, 47.3, 62.9, 87.6, 107.4], + "CB9:A1": [25, 47.3, 62.9, 87.6, 107.4], + "CB8:A2": [25, 47.3, 62.9, 87.6, 107.4], + "CB7:A3": [25, 47.3, 62.9, 87.6, 107.4], + "1,4-Dichlorobenzene": [25, 47.3, 62.9, 87.6, 107.4, 135], + "1,2,4-Trihlorobenzene": [25, 47.3, 62.9, 87.6, 107.4, 135], + "o-xylene": [25, 47.3, 62.9, 87.6, 107.4, 119.6], + "m-xylene": [25, 47.3, 62.9, 87.6, 107.4, 114.6], + "p-xylene": [25, 47.3, 62.9, 87.6, 107.4, 113.8], + "mesitylene": [25, 47.3, 62.9, 87.6, 107.4, 135], + "toluene": [25, 47.3, 55.1, 62.9, 75.1, 87.7], + "1-Chloronaphthalene": [25, 47.3, 62.9, 87.6, 107.4, 135], + "anisole": [25, 47.3, 62.9, 87.6, 107.4, 129.1], + "Tetrahydrofuran": [25, 30.1, 35.4, 40, 45.7], + "decane": [25, 47.3, 62.9, 87.6, 107.4, 135], +} + +TEMP_CHOICES_C = { + "CF": (25, 41.3), + "CB": (25, 107.4), + "CB9:A1": (25, 107.4), + "CB8:A2": (25, 107.4), + "CB7:A3": (25, 107.4), + "1,4-Dichlorobenzene": (25, 135), + "1,2,4-Trihlorobenzene": (25, 135), + "o-xylene": (25, 119.6), + "m-xylene": (25, 114.6), + "p-xylene": (25, 113.8), + "mesitylene": (25, 135), + "toluene": (25, 87.7), + "1-Chloronaphthalene": (25, 135), + "anisole": (25, 129.1), + "Tetrahydrofuran": (25, 45.7), + "decane": (25, 135), +} + +layout = html.Div( + [ + html.Datalist( + id="smiles-suggestions", + children=[ + html.Option(value="{O=C(OCC(CCCC)[*]CCCCC)C1=C(C2=CC=CS2)SC(C(S3)=CC(C(OCC(CCCCCC)CCCC)=O)=C3C4=CC=[*]S4)=C1}"), + html.Option(value="{COCCOCCOCCOC1=C(C2=C(OCCOCCOCCOC)C=C(S2)[*])SC(C3=CC4=C(S3)C=C(S4)[*])=C1}") + ] + ), + html.Datalist( + id="polymer-suggestions", + children=[ + html.Option(value="PDCBT"), + html.Option(value="P(g42T-TT)") + ] + ), + html.Datalist( + id="mw-suggestions", + children=[ + html.Option(value="60000"), + html.Option(value="40000") + ] + ), + html.Datalist( + id="pdi-suggestions", + children=[ + html.Option(value="2.5") + ] + ), + html.Div([ + html.H1([ + "Sampler", + html.Span( + " ?", + id="sampler-help", + style={ + "cursor": "pointer", + "color": "gray", + "fontWeight": "bold", + "fontSize": "0.7em", + "marginLeft": "10px" + } + ), + ], style={"display": "inline-block"}), + dbc.Tooltip( + "This page allows the user to generate starting parameter sets for a campaign, covering as much as the parameter space as possible so the optimizer can narrow down on particular runs that where successful. You can enter campaign-specific information, and discrete or continuous parameter values for the sampler to explore, and it will generate PCA and uMAP visualizations, the generated parameter values, and their min-max normalized value for the optimizer. This data can then be saved to the database and used in other pages of the app, namely the recipe builder, to start a campaign.", + target="sampler-help", + placement="right", + style={"maxWidth": "350px"} + ), + ]), + dbc.Alert( + id="sampler-alert", + color="success", + is_open=False, + fade=True, + className="mb-3", + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + html.H5("Campaign Name"), + dbc.Input(id="sampler-campaign-name", type="text", placeholder="Enter campaign name"), + ], + width=2, + ), + dbc.Col( + [ + html.H5("Polymer Name"), + dbc.Input(id="sampler-polymer-name", type="text", placeholder="Enter polymer name", list="polymer-suggestions"), + ], + width=3, + ), + dbc.Col( + [ + html.H5("SMILES String"), + dbc.Input(id="sampler-smiles-string", type="text", placeholder="Enter SMILES string", list="smiles-suggestions"), + ], + width=3, + ), + dbc.Col( + [ + html.H5("Number-Averaged Molecular Weight (Mn)"), + dbc.Input(id="sampler-mw", type="number", placeholder="Enter Mn", min=0, list="mw-suggestions"), + ], + width=2, + ), + dbc.Col( + [ + html.H5("Polydispersity Index (PDI)"), + dbc.Input(id="sampler-pdi", type="number", placeholder="Enter PDI", min=1, step=0.01, list="pdi-suggestions"), + ], + width=2, + ), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col( + [ + html.H5("Upload Polymer Image"), + dcc.Upload( + id="sampler-polymer-image", + children=html.Div([ + 'Drag and Drop or ', + html.A('Select an Image') + ]), + style={ + 'width': '100%', + 'height': '60px', + 'lineHeight': '60px', + 'borderWidth': '1px', + 'borderStyle': 'dashed', + 'borderRadius': '5px', + 'textAlign': 'center', + 'margin': '10px 0' + }, + multiple=False, + accept='image/*' + ), + html.Div(id="sampler-polymer-image-preview"), + ], + width=12, + ), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col( + [ + html.H5("Upload GPC Data"), + dcc.Upload( + id="gpc-data-upload", + children=html.Div([ + 'Drag and Drop or ', + html.A('Select a CSV or Excel File') + ]), + style={ + 'width': '100%', + 'height': '60px', + 'lineHeight': '60px', + 'borderWidth': '1px', + 'borderStyle': 'dashed', + 'borderRadius': '5px', + 'textAlign': 'center', + 'margin': '10px 0' + }, + multiple=False, + accept='.csv, .xlsx, .xls' + ), + html.Div(id="gpc-data-output") + ], + width=12, + ), + ], + className="mb-3", + ), + dcc.Store(id="gpc-data-store", storage_type="memory"), + dbc.Row( + [ + dbc.Col( + [ + html.H5("Solvent"), + dcc.Dropdown( + id="sampler-solvent-dropdown", + options=[{"label": solv, "value": solv} for solv in SOLV_NAMES], + multi=True, + ), + ], + width=6, + ), + dbc.Col( + html.Div( + [ + html.H5("Temperature"), + html.Div(id="sampler-temperature-options", children=[]), + ], + id="temperature-container", + style={'display': 'none'} + ), + width=6, + ), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col([html.H5("Concentration Range")], width=2), + dbc.Col( + [ + dbc.Switch( + id={"type": "toggle", "param": "concentration"}, + label="Continuous", + value=False, + ), + ], + width=2, + ), + ], + className="mb-2", + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dcc.Dropdown( + id={"type": "sampler-dropdown", "id": "concentration"}, + options=[{"label": str(concen), "value": concen} for concen in CONCEN_D], + multi=True, + value=CONCEN_D, + ), + ], + width=4, + ), + dbc.Col( + dbc.InputGroup([ + dbc.Input(id={"type": "custom-input", "id": "concentration"}, type="number", placeholder="Custom concentration"), + dbc.Button("Add", id={"type": "add-custom-button", "id": "concentration"}, size="sm"), + ]), + width=4, + ) + ], + className="mb-3", + ), + ], + id={"type": "discrete-container", "param": "concentration"}, + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dbc.InputGroup( + [ + dbc.Input(id="concentration-min", type="number", placeholder="Min", value=CONCEN_C[0]), + dbc.Input(id="concentration-max", type="number", placeholder="Max", value=CONCEN_C[1]), + ] + ), + ], + width=4, + ), + ], + className="mb-3", + ), + ], + id={"type": "continuous-container", "param": "concentration"}, + style={"display": "none"}, + ), + dbc.Row( + [ + dbc.Col([html.H5("Printing Gap")], width=2), + dbc.Col( + [ + dbc.Switch( + id={"type": "toggle", "param": "printing-gap"}, + label="Continuous", + value=False, + ), + ], + width=2, + ), + ], + className="mb-2", + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dcc.Dropdown( + id={"type": "sampler-dropdown", "id": "printing-gap"}, + options=[{"label": str(gap), "value": gap} for gap in PRINT_GAP_D], + multi=True, + value=PRINT_GAP_D, + ), + ], + width=4, + ), + dbc.Col( + dbc.InputGroup([ + dbc.Input(id={"type": "custom-input", "id": "printing-gap"}, type="number", placeholder="Custom printing gap"), + dbc.Button("Add", id={"type": "add-custom-button", "id": "printing-gap"}, size="sm"), + ]), + width=4, + ) + ], + className="mb-3", + ), + ], + id={"type": "discrete-container", "param": "printing-gap"}, + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dbc.InputGroup( + [ + dbc.Input(id="printing-gap-min", type="number", placeholder="Min", value=PRINT_GAP_D[0]), + dbc.Input(id="printing-gap-max", type="number", placeholder="Max", value=PRINT_GAP_D[-1]), + ] + ), + ], + width=4, + ), + ], + className="mb-3", + ), + ], + id={"type": "continuous-container", "param": "printing-gap"}, + style={"display": "none"}, + ), + dbc.Row( + [ + dbc.Col([html.H5("Precursor Volume")], width=2), + dbc.Col( + [ + dbc.Switch( + id={"type": "toggle", "param": "precursor-volume"}, + label="Continuous", + value=False, + ), + ], + width=2, + ), + ], + className="mb-2", + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dcc.Dropdown( + id={"type": "sampler-dropdown", "id": "precursor-volume"}, + options=[{"label": str(vol), "value": vol} for vol in PREC_VOL_D], + multi=True, + value=PREC_VOL_D, + ), + ], + width=4, + ), + dbc.Col( + dbc.InputGroup([ + dbc.Input(id={"type": "custom-input", "id": "precursor-volume"}, type="number", placeholder="Custom precursor volume"), + dbc.Button("Add", id={"type": "add-custom-button", "id": "precursor-volume"}, size="sm"), + ]), + width=4, + ) + ], + className="mb-3", + ), + ], + id={"type": "discrete-container", "param": "precursor-volume"}, + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dbc.InputGroup( + [ + dbc.Input(id="precursor-volume-min", type="number", placeholder="Min", value=PREC_VOL_C[0]), + dbc.Input(id="precursor-volume-max", type="number", placeholder="Max", value=PREC_VOL_C[1]), + ] + ), + ], + width=4, + ), + ], + className="mb-3", + ), + ], + id={"type": "continuous-container", "param": "precursor-volume"}, + style={"display": "none"}, + ), + dbc.Row( + [ + dbc.Col([html.H5("Motor Speed Range")], width=2), + dbc.Col( + [ + dbc.Switch( + id={"type": "toggle", "param": "motor-speed"}, + label="Continuous", + value=False, + ), + ], + width=2, + ), + ], + className="mb-2", + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dcc.Dropdown( + id={"type": "sampler-dropdown", "id": "motor-speed"}, + options=[{"label": str(speed), "value": speed} for speed in MOTOR_SPEEDS_D], + multi=True, + value=MOTOR_SPEEDS_D, + ), + ], + width=4, + ), + dbc.Col( + dbc.InputGroup([ + dbc.Input(id={"type": "custom-input", "id": "motor-speed"}, type="number", placeholder="Custom motor speed"), + dbc.Button("Add", id={"type": "add-custom-button", "id": "motor-speed"}, size="sm"), + ]), + width=4, + ) + ], + className="mb-3", + ), + ], + id={"type": "discrete-container", "param": "motor-speed"}, + ), + html.Div( + [ + dbc.Row( + [ + dbc.Col( + [ + dbc.InputGroup( + [ + dbc.Input(id="motor-speed-min", type="number", placeholder="Min", value=SPEED_C[0]), + dbc.Input(id="motor-speed-max", type="number", placeholder="Max", value=SPEED_C[1]), + ] + ), + ], + width=4, + ), + ], + className="mb-3", + ), + ], + id={"type": "continuous-container", "param": "motor-speed"}, + style={"display": "none"}, + ), + dbc.Row( + [ + dbc.Col( + [ + html.H5("Sampling Method"), + dcc.Dropdown( + id="sampler-method-dropdown", + options=[ + {"label": "Sobol Sequence", "value": "sobol"}, + {"label": "Random Sampling", "value": "random"}, + ], + value="sobol", + ), + ], + width=6, + ), + dbc.Col( + [ + html.H5("Number of Samples per Solvent"), + dbc.Input(id="sampler-num-samples", type="number", value=5, min=1), + ], + width=6, + ), + ], + className="mb-3", + ), + + dbc.Button( + "Generate Parameter Sets", + id="sampler-generate-button", + color="primary", + className="mb-3", + ), + + html.Div(id="sampler-results-table", className="mb-3"), + html.Div(id="sampler-results-plots", className="mb-3"), + + dbc.Button( + "Save Parameter Sets", + id="sampler-save-button", + color="success", + className="mb-3", + disabled=True, + ), + ], + className="container", + ), + ], + className="container", +) diff --git a/aamp_app/pages/solution-map.py b/aamp_app/pages/solution-map.py new file mode 100644 index 0000000..da1ab80 --- /dev/null +++ b/aamp_app/pages/solution-map.py @@ -0,0 +1,42 @@ +from dash import html, dcc +import dash +import dash_bootstrap_components as dbc + +dash.register_page( + __name__, + path="/solution-map", + name="Solution Map", + title="Solution Map" +) + +layout = html.Div([ + html.Div([ + html.H1([ + "Solution Map", + html.Span( + " ?", + id="solution-map-help", + style={ + "cursor": "pointer", + "color": "gray", + "fontWeight": "bold", + "fontSize": "0.7em", + "marginLeft": "10px" + } + ), + ], style={"display": "inline-block"}), + dbc.Tooltip( + "This page allows you to view and edit the solution map. Each nested object in the JSON represents a cell with a polymer, solvent, and concentration. You can update the map and save changes to the database. Use this to manage which solutions are available in each position (A1-D5). When the recipe builder generates recipe templates, it uses the information from each parameter set and this solution map to decide where to move the handler arm.", + target="solution-map-help", + placement="right", + style={"maxWidth": "350px"} + ), + ]), + dbc.Alert(id="solution-map-alert", is_open=False, color="danger", className="mb-3"), + dcc.Textarea( + id="solution-map-json", + style={"width": "100%", "height": "400px", "fontFamily": "monospace"}, + spellCheck=False, + ), + dbc.Button("Save", id="solution-map-save", color="primary", className="mt-2"), +], className="container") diff --git a/aamp_app/pages/view-recipe.py b/aamp_app/pages/view-recipe.py new file mode 100644 index 0000000..53e7fc0 --- /dev/null +++ b/aamp_app/pages/view-recipe.py @@ -0,0 +1,304 @@ +from dash import Dash, html, dcc, dash_table, callback, Input, Output, State +import dash_bootstrap_components as dbc +import dash + +dash.register_page(__name__, path="/view-recipe", name="View Recipe", title="View Recipe") + +layout = html.Div( + [ + html.H1("View Recipe"), + html.Div( + [ + dbc.Alert( + id="view-recipe-alert", + color="success", + is_open=False, + fade=True, + className="mb-3", + ), + html.Div( + [ + html.H2("Devices"), + dbc.ButtonGroup( + [ + dbc.Button("Refresh", id="refresh-button1", n_clicks=0), + dbc.Button("Add device", id="add-device-button"), + dbc.Button("Edit", id="edit-device-button"), + dbc.Button("Delete", id="delete-device-button"), + ], + className="mb-3", + ), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Editor"), close_button=False), + dbc.ModalBody( + [ + dcc.Textarea( + id="device-json-editor", + style={ + "width": "100%", + "height": "200px", + "fontFamily": "monospace", + "backgroundColor": "#f5f5f5", + "border": "1px solid #ccc", + "padding": "10px", + "color": "#333", + }, + ), + html.Div( + id="edit-device-error", + style={"color": "red"}, + ), + html.Div( + id="edit-device-serial-ports-info", + ), + ] + ), + dbc.ModalFooter(dbc.Button("Save", id="save-device-editor")), + ], + id="device-editor-modal", + keyboard=False, + backdrop="static", + ), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Add Device")), + dbc.ModalBody( + [ + dcc.Dropdown( + id="add-device-dropdown", + options=[], + value=None, + className="mb-2", + ), + dcc.Textarea( + id="add-device-json-editor", + style={ + "width": "100%", + "height": "200px", + "fontFamily": "monospace", + "backgroundColor": "#f5f5f5", + "border": "1px solid #ccc", + "padding": "10px", + "color": "#333", + }, + ), + html.Div( + id="add-device-error", + style={"color": "red", "display": "none"}, + ), + html.Div( + id="add-device-serial-ports-info", + ), + ] + ), + dbc.ModalFooter(dbc.Button("Add", id="add-device-editor")), + ], + id="device-add-modal", + keyboard=False, + backdrop="static", + ), + html.Div( + children=[dash_table.DataTable(id="devices-table")], + id="devices-table-div", + ), + ], + className="table-container mb-3", + ), + html.Div( + [ + html.H2("Commands"), + dbc.ButtonGroup( + [ + dbc.Button("Refresh", id="refresh-button2", n_clicks=0), + dbc.Button("Add command", id="add-command-open-modal-button"), + dbc.Button("Edit", id="edit-command-button"), + dbc.Button("Delete", id="delete-command-button"), + ], + className="mb-3", + ), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Editor"), close_button=False), + dbc.ModalBody( + [ + dcc.Textarea( + id="command-json-editor", + style={ + "width": "100%", + "height": "200px", + "fontFamily": "monospace", + "backgroundColor": "#f5f5f5", + "border": "1px solid #ccc", + "padding": "10px", + "color": "#333", + }, + ), + html.Div( + id="edit-command-error", + style={"color": "red"}, + ), + ] + ), + dbc.ModalFooter(dbc.Button("Save", id="save-command-editor")), + ], + id="command-editor-modal", + keyboard=False, + backdrop="static", + ), + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Add Command")), + dbc.ModalBody( + [ + dcc.Dropdown( + id="view-recipe-add-command-device-dropdown", + options=[], + value=None, + ), + dcc.Dropdown( + id="view-recipe-add-command-command-dropdown", + options=[], + value=None, + className="mb-2", + ), + dcc.Textarea( + id="view-recipe-add-command-json-editor", + style={ + "width": "100%", + "height": "200px", + "fontFamily": "monospace", + "backgroundColor": "#f5f5f5", + "border": "1px solid #ccc", + "padding": "10px", + "color": "#333", + }, + ), + html.Div( + id="add-command-error", + style={"color": "red"}, + ), + ] + ), + dbc.ModalFooter( + dbc.Button("Add", id="view-recipe-add-command-editor") + ), + ], + id="view-recipe-command-add-modal", + keyboard=False, + backdrop="static", + ), + html.Div( + children=[dash_table.DataTable(id="commands-table")], + id="commands-table-div", + ), + # dbc.Accordion( + # [ + # dbc.AccordionItem( + # "item1", title="Item 1", item_id="item1" + # ) + # ], + # id="commands-accordion", + # # start_collapsed=True, + # style={"display": "none"}, + # ), + ], + className="table-container mb-3", + ), + html.Div( + [ + html.H2("Execution Options"), + dbc.Row( + [ + dbc.Row( + [ + dbc.Col( + [ + html.H5("Default Execution Record Name"), + dbc.Col( + [ + dbc.Input( + id="view-recipe-execution-options-default-execution-record-name", + ) + ] + ), + ] + ), + dbc.Col( + [ + html.H5("Output Files"), + dbc.Textarea( + id="view-recipe-execution-options-output-files", + placeholder="Enter one filename with extension per line", + ), + ] + ), + ], + className="mb-3", + ), + ] + ), + dbc.Button( + "Save Options", + id="view-recipe-execution-options-save-button", + n_clicks=0, + className="mb-3", + ), + dbc.Label( + "Execution Options Saved", + id="view-recipe-execution-options-saved-label", + style={"display": "none"}, + ), + ], + className="table-container mb-5", + ) + # html.Div( + # [ + # html.H2("Command Iterations"), + # html.Button("Refresh", id="refresh-button3", n_clicks=0), + # html.Div(id="table-container3"), + # ], + # className="table-container", + # ), + ], + className="tables-container", + ), + ], + className="container", +) + + +@callback( + Output("device-editor-modal", "is_open"), + [Input("edit-device-button", "n_clicks"), Input("save-device-editor", "n_clicks")], + [State("device-editor-modal", "is_open")], +) +def toggle_device_editor_modal(n1, n2, is_open): + if n1 or n2: + return not is_open + return is_open + + +@callback( + Output("device-add-modal", "is_open"), + [Input("add-device-button", "n_clicks"), Input("add-device-editor", "n_clicks")], + [State("device-add-modal", "is_open")], +) +def toggle_device_add_modal(n1, n2, is_open): + if n1 or n2: + return not is_open + return is_open + + +@callback( + Output("command-editor-modal", "is_open"), + [ + Input("edit-command-button", "n_clicks"), + Input("save-command-editor", "n_clicks"), + ], + [State("command-editor-modal", "is_open")], +) +def toggle_command_editor_modal(n1, n2, is_open): + if n1 or n2: + return not is_open + return is_open diff --git a/aamp_app/solution_map.json b/aamp_app/solution_map.json new file mode 100644 index 0000000..8dd664a --- /dev/null +++ b/aamp_app/solution_map.json @@ -0,0 +1,122 @@ +{ + "A1": { + "polymer": "P3MEEMT", + "solvent": "CB", + "concentration": 40, + "status": "occupied" + }, + "A2": { + "polymer": "P3MEEMT", + "solvent": "CB", + "concentration": 30, + "status": "occupied" + }, + "A3": { + "polymer": "P3MEEMT", + "solvent": "CB", + "concentration": 20, + "status": "occupied" + }, + "A4": { + "polymer": "P3MEEMT", + "solvent": "CB", + "concentration": 10, + "status": "occupied" + }, + "A5": { + "polymer": "Chang", + "solvent": "CF", + "concentration": 2, + "status": "occupied" + }, + "B1": { + "polymer": "P3MEEMT", + "solvent": "CB", + "concentration": 15, + "status": "occupied" + }, + "B2": { + "polymer": "Chang", + "solvent": "CF", + "concentration": 10, + "status": "occupied" + }, + "B3": { + "polymer": "Chang", + "solvent": "CF", + "concentration": 15, + "status": "occupied" + }, + "B4": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "B5": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "C1": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "C2": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "C3": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "C4": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "C5": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "D1": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "D2": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "D3": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "D4": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + }, + "D5": { + "polymer": null, + "solvent": null, + "concentration": null, + "status": "empty" + } +} \ No newline at end of file diff --git a/aamp_app/util.py b/aamp_app/util.py new file mode 100644 index 0000000..5353d71 --- /dev/null +++ b/aamp_app/util.py @@ -0,0 +1,3555 @@ +from commands.command import Command +from commands.utility_commands import LoopStartCommand, LoopEndCommand +from devices.heating_stage import HeatingStage +from devices.apis import APIS +from devices.sciencetech_uhe_nl_solar_sim import SciencetechUHENLSolarSim +from devices.stellarnet_spectrometer import StellarNetSpectrometer +from devices.multi_stepper import MultiStepper +from devices.newport_esp301 import NewportESP301 +from devices.newport_94043a_solar_sim import Newport94043ASolarSim +from devices.festo_solenoid_valve import FestoSolenoidValve +from devices.ximea_camera import XimeaCamera +from devices.dummy_heater import DummyHeater +from devices.dummy_motor import DummyMotor +from devices.linear_stage_150 import LinearStage150 +from devices.mts50_z8 import MTS50_Z8 +from devices.p4pp import P4PP +from devices.sonicator import Sonicator +from devices.substrate_hotel import SubstrateHotel +from devices.substrate_dispenser import SubstrateDispenser +from devices.z812 import Z812 +from devices.keithley_2450 import Keithley2450 +from devices.mfc import MassFlowController +from devices.oxygen_sensor import OxygenSensor +from devices.sht85_sensor import SHT85HumidityTempSensor +from devices.device import Device, MiscDeviceClass +from devices.utility_device import UtilityCommands +from devices.psd6_syringe_pump import PSD6SyringePump +from devices.ximea_camera import XimeaCamera + +from commands.linear_stage_150_commands import * +from commands.apis_commands import * +from commands.sciencetech_uhe_nl_solar_sim_commands import * +from commands.stellarnet_spectrometer_commands import * +from commands.mts50_z8_commands import * +from commands.p4pp_commands import * +from commands.z812_commands import * +from commands.dummy_heater_commands import * +from commands.dummy_motor_commands import * +from commands.dummy_meter_commands import * +from commands.keithley_2450_commands import * +from commands.festo_solenoid_valve_commands import * +from commands.ximea_camera_commands import * +from commands.utility_commands import * +from commands.heating_stage_commands import * +from commands.multi_stepper_commands import * +from commands.mfc_commands import * +from commands.sonicator_commands import * +from commands.substrate_hotel_commands import * +from commands.substrate_dispenser_commands import * +from commands.sht85_sensor_commands import * +from commands.newport_esp301_commands import * +from commands.newport_94043a_solar_sim_commands import * +from commands.utility_commands import * +from commands.psd6_syringe_pump_commands import * +from commands.ximea_camera_commands import * + +import json +import numpy as np +from typing import Tuple, Union + + +named_devices = { + "PrintingStage": HeatingStage, + "AnnealingStage": HeatingStage, + "MultiStepper1": MultiStepper, + "PrinterMotorX": NewportESP301, + "StellarNetSpectrometer": StellarNetSpectrometer, + "Sonicator": Sonicator, + "SubstrateHotel": SubstrateHotel, + "SubstrateDispenser": SubstrateDispenser, + "SampleCamera": XimeaCamera, + "DummyHeater1": DummyHeater, + "DummyHeater2": DummyHeater, + "DummyMotor": DummyMotor, + "DummyMotor1": DummyMotor, + "DummyMotor2": DummyMotor, +} +command_directory = "commands/" +approved_devices = list(named_devices.keys()) + +# device_init_args = { +# "DummyHeater": ["name", "heat_rate"], +# "DummyMotor": ["name", "speed"], +# } + + +def dict_to_device(device: Device, type: str): + device_cls = named_devices[type] + arg_dict = device.get_init_args() + + # for attr in device_init_args[type]: + # arg_dict[attr] = dict["_"+attr] + + # print(arg_dict) + return device_cls(**arg_dict) + + +def str_to_device(device_str: str): + print(device_str) + return eval(device_str) + + +def device_to_dict(device: Device): + return device.get_init_args() + + +def evaluate(eval_str): + return eval(eval_str) + + +class Encoder(json.JSONEncoder): + def default(self, obj): + if ( + isinstance(obj, Device) + or isinstance(obj, Command) + or isinstance(obj, MiscDeviceClass) + ): + return obj.__dict__ + elif isinstance(obj, np.ndarray): + return obj.tolist() + return super().default(obj) + + +heating_stage_ref = { + "obj": HeatingStage, + "serial": True, + "serial_sequence": ["HeatingStageConnect", "HeatingStageInitialize"], + "import_device": "from devices.heating_stage import HeatingStage", + "import_commands": "from commands.heating_stage_commands import *", + "init": { + "default_code": "HeatingStage(name='Stage', port='', baudrate=115200, timeout=0.1, heating_timeout=600.0)", + "obj_name": "HeatingStage", + "args": { + "name": { + "default": "Stage", + "type": str, + "notes": "Name of the device", + }, + "port": {"default": "COM", "type": str, "notes": "Port"}, + "baudrate": { + "default": 115200, + "type": int, + "notes": "Baudrate", + }, + "timeout": { + "default": 0.1, + "type": float, + "notes": "Timeout", + }, + "heating_timeout": { + "default": 600.0, + "type": float, + "notes": "Heating timeout", + }, + }, + }, + "commands": { + "HeatingStageConnect": { + "default_code": "HeatingStageConnect(receiver= '')", + "args": { + "receiver": { + "default": "Stage", + "type": str, + "notes": "Name of the device", + } + }, + "obj": HeatingStageConnect, + }, + "HeatingStageInitialize": { + "default_code": "HeatingStageInitialize(receiver= '')", + "args": { + "receiver": { + "default": "Stage", + "type": str, + "notes": "Name of the device", + } + }, + "obj": HeatingStageInitialize, + }, + "HeatingStageDeinitialize": { + "default_code": "HeatingStageDeinitialize(receiver= '')", + "args": { + "receiver": { + "default": "Stage", + "type": str, + "notes": "Name of the device", + } + }, + "obj": HeatingStageDeinitialize, + }, + "HeatingStageSetTemp": { + "default_code": "HeatingStageSetTemp(receiver= '', temperature= 0.0)", + "args": { + "receiver": { + "default": "Stage", + "type": str, + "notes": "Name of the device", + }, + "temperature": { + "default": 0.0, + "type": float, + "notes": "Temperature", + }, + }, + "obj": HeatingStageSetTemp, + }, + "HeatingStageSetSetPoint": { + "default_code": "HeatingStageSetSetPoint(receiver= '', temperature= 0.0)", + "args": { + "receiver": { + "default": "Stage", + "type": str, + "notes": "Name of the device", + }, + "temperature": { + "default": 0.0, + "type": float, + "notes": "Temperature", + }, + }, + "obj": HeatingStageSetSetPoint, + }, + "HeatingStageWaitForTemperature": { + "default_code": "HeatingStageWaitForTemperature(receiver= '', target= 25.0, tolerance= 1.0, timeout= 600.0, poll_interval= 1.0, hold_duration= 30.0)", + "args": { + "receiver": { + "default": "Stage", + "type": str, + "notes": "Name of the device", + }, + "target": { + "default": 25.0, + "type": float, + "notes": "Target temperature in C", + }, + "tolerance": { + "default": 1.0, + "type": float, + "notes": "Allowed deviation in C", + }, + "timeout": { + "default": 600.0, + "type": float, + "notes": "Maximum wait time in seconds", + }, + "poll_interval": { + "default": 1.0, + "type": float, + "notes": "Polling interval in seconds", + }, + "hold_duration": { + "default": 30.0, + "type": float, + "notes": "Time in seconds that temperature must stay within tolerance", + }, + }, + "obj": HeatingStageWaitForTemperature, + }, + }, +} + + +devices_ref_redundancy = { + "UtilityCommands": { + "obj": UtilityCommands, + "serial": False, + "import_device": "from devices.utility_commands import UtilityCommands", + "import_commands": "from commands.utility_commands import *", + "init": { + "default_code": "# Utility Commands used", + "obj_name": "UtilityCommands", + "args": {}, + }, + "commands": { + "LoopStartCommand": { + "default_code": "LoopStartCommand()", + "args": {}, + "obj": LoopStartCommand, + }, + "LoopEndCommand": { + "default_code": "LoopEndCommand()", + "args": {}, + "obj": LoopEndCommand, + }, + "DelayPauseCommand": { + "default_code": "DelayPauseCommand(delay=0.0)", + "args": { + "delay": { + "default": 0.0, + "type": float, + "notes": "Delay in seconds", + } + }, + "obj": DelayPauseCommand, + }, + "NotifySlackCommand": { + "default_code": "NotifySlackCommand(message='Hello World')", + "args": { + "message": { + "default": "Hello World", + "type": str, + "notes": "Message to send to slack", + } + }, + "obj": NotifySlackCommand, + }, + "LogUserMessageCommand": { + "default_code": "LogUserMessageCommand(message='Hello World')", + "args": { + "message": { + "default": "Hello World", + "type": str, + "notes": "Message to log", + } + }, + "obj": LogUserMessageCommand, + }, + }, + }, + "FestoSolenoidValve": { + "obj": FestoSolenoidValve, + "serial": True, + "serial_sequence": ["FestoConnect", "FestoInitialize"], + "import_device": "from devices.festo_solenoid_valve import FestoSolenoidValve", + "import_commands": "from commands.festo_solenoid_valve_commands import *", + "init": { + "default_code": "FestoSolenoidValve(name='FestoSolenoidValve', port='COM5', baudrate=9600, timeout=0.1)", + "obj_name": "FestoSolenoidValve", + "args": { + "name": { + "default": "FestoSolenoidValve", + "type": str, + "notes": "Name of the device.", + }, + # "numchannel": { + # "default": 1, + # "type": int, + # "notes": "", + # }, + "port": { + "default": "COM5", + "type": str, + "notes": "Port", + }, + "baudrate": { + "default": 9600, + "type": int, + "notes": "Baudrate", + }, + "timeout": { + "default": 0.1, + "type": float, + "notes": "Timeout", + }, + }, + }, + "commands": { + "FestoConnect": { + "default_code": "FestoConnect(receiver= '')", + "args": { + "receiver": { + "default": "FestoSolenoidValve", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": FestoConnect, + }, + "FestoInitialize": { + "default_code": "FestoInitialize(receiver= '')", + "args": { + "receiver": { + "default": "FestoSolenoidValve", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": FestoInitialize, + }, + "FestoDeinitialize": { + "default_code": "FestoDeinitialize(receiver= '')", + "args": { + "receiver": { + "default": "FestoSolenoidValve", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": FestoDeinitialize, + }, + "FestoValveOpen": { + "default_code": "FestoValveOpen(receiver= '', valve_num=1)", + "args": { + "receiver": { + "default": "FestoSolenoidValve", + "type": str, + "notes": "Name of the device", + }, + "valve_num": { + "default": 1, + "type": int, + "notes": "Valve number to open. Arduino sketch maps 1/2/3 to pins 12/8/4.", + }, + }, + "obj": FestoValveOpen, + }, + "FestoValveClosed": { + "default_code": "FestoValveClosed(receiver= '', valve_num=1)", + "args": { + "receiver": { + "default": "FestoSolenoidValve", + "type": str, + "notes": "Name of the device", + }, + "valve_num": { + "default": 1, + "type": int, + "notes": "Valve number to close. Arduino sketch maps 1/2/3 to pins 12/8/4.", + }, + }, + "obj": FestoValveClosed, + }, + "FestoCloseAll": { + "default_code": "FestoCloseAll(receiver= '')", + "args": { + "receiver": { + "default": "FestoSolenoidValve", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": FestoCloseAll, + } + # "FestoOpenTimed": { + # "default_code": "FestoOpenTimed(receiver= '', time=0)", + # "args": { + # "receiver": { + # "default": "FestoSolenoidValve", + # "type": str, + # "notes": "Name of the device", + # }, + # "time": { + # "default": 0, + # "type": int, + # "notes": "Time to keep the valve open", + # }, + # }, + # "obj": FestoOpenTimed, + # }, + }, + }, + "LinearStage150": { + "obj": LinearStage150, + "default_obj": LinearStage150( + name="LinearStage150", + port="COM5", + baudrate=115200, + timeout=0.1, + destination=0x50, + source=0x01, + channel=1, + ), + "serial": True, + "serial_sequence": ["LinearStage150Connect", "LinearStage150Initialize"], + "import_device": "from devices.linear_stage_150 import LinearStage150", + "import_commands": "from commands.linear_stage_150_commands import *", + "telemetry": { + "parameters": { + "position": { + "function_name": "get_position", + "data_type": "float", + "units": "mm", + } + }, + "options": {"custom_init_args": ["port"]}, + }, + "init": { + "default_code": "LinearStage150(name='LinearStage150', port='', baudrate=115200, timeout=0.1, destination=0x50, source=0x01, channel=1)", + "obj_name": "LinearStage150", + "args": { + "name": { + "default": "LinearStage150", + "type": str, + "notes": "Name of the device.", + }, + "port": {"default": "COM", "type": str, "notes": "Port"}, + "baudrate": { + "default": 115200, + "type": int, + "notes": "Baudrate", + }, + "timeout": { + "default": 0.1, + "type": float, + "notes": "Timeout", + }, + "destination": { + "default": 0x50, + "type": int, + "notes": "", + }, + "source": { + "default": 0x01, + "type": int, + "notes": "", + }, + "channel": { + "default": 1, + "type": int, + "notes": "", + }, + }, + }, + "commands": { + "LinearStage150Connect": { + "default_code": "LinearStage150Connect(receiver= '')", + "args": { + "receiver": { + "default": "LinearStage150", + "type": str, + "notes": "", + } + }, + "obj": LinearStage150Connect, + }, + "LinearStage150Initialize": { + "default_code": "LinearStage150Initialize(receiver= '')", + "args": { + "receiver": { + "default": "LinearStage150", + "type": str, + "notes": "", + } + }, + "obj": LinearStage150Initialize, + }, + "LinearStage150Deinitialize": { + "default_code": "LinearStage150Deinitialize(receiver= '')", + "args": { + "receiver": { + "default": "LinearStage150", + "type": str, + "notes": "", + } + }, + "obj": LinearStage150Deinitialize, + }, + "LinearStage150EnableMotor": { + "default_code": "LinearStage150EnableMotor(receiver= '')", + "args": { + "receiver": { + "default": "LinearStage150", + "type": str, + "notes": "", + } + }, + "obj": LinearStage150EnableMotor, + }, + "LinearStage150DisableMotor": { + "default_code": "LinearStage150DisableMotor(receiver= '')", + "args": { + "receiver": { + "default": "LinearStage150", + "type": str, + "notes": "", + } + }, + "obj": LinearStage150DisableMotor, + }, + "LinearStage150MoveAbsolute": { + "default_code": "LinearStage150MoveAbsolute(receiver= '', position= 0)", + "args": { + "receiver": { + "default": "LinearStage150", + "type": str, + "notes": "", + }, + "position": { + "default": 0.0, + "type": float, + "notes": "Absolute position in mm.", + }, + }, + "obj": LinearStage150MoveAbsolute, + }, + "LinearStage150MoveRelative": { + "default_code": "LinearStage150MoveRelative(receiver= '', distance= 0)", + "args": { + "receiver": { + "default": "LinearStage150", + "type": str, + "notes": "", + }, + "distance": { + "default": 0.0, + "type": float, + "notes": "Relative distance in mm.", + }, + }, + "obj": LinearStage150MoveRelative, + }, + }, + }, + "MTS50_Z8": { + "obj": MTS50_Z8, + "serial": True, + "serial_sequence": ["MTS50_Z8Connect", "MTS50_Z8EnableMotor"], + "import_device": "from devices.mts50_z8 import MTS50_Z8", + "import_commands": "from commands.mts50_z8_commands import *", + "init": { + "default_code": "MTS50_Z8(name='MTS50_Z8', port='', baudrate=115200, timeout=0.1, destination=0x50, source=0x01, channel=1)", + "obj_name": "MTS50_Z8", + "args": { + "name": { + "default": "MTS50_Z8", + "type": str, + "notes": "Name of the device.", + }, + "port": {"default": "COM", "type": str, "notes": "Port"}, + "baudrate": { + "default": 115200, + "type": int, + "notes": "Baudrate", + }, + "timeout": { + "default": 0.1, + "type": float, + "notes": "Timeout", + }, + "destination": { + "default": 0x50, + "type": int, + "notes": "", + }, + "source": { + "default": 0x01, + "type": int, + "notes": "", + }, + "channel": { + "default": 1, + "type": int, + "notes": "", + }, + }, + }, + "commands": { + "MTS50_Z8Connect": { + "default_code": "MTS50_Z8Connect(receiver= '')", + "args": { + "receiver": { + "default": "MTS50_Z8", + "type": str, + "notes": "", + } + }, + "obj": MTS50_Z8Connect, + }, + "MTS50_Z8Initialize": { + "default_code": "MTS50_Z8Initialize(receiver= '')", + "args": { + "receiver": { + "default": "MTS50_Z8", + "type": str, + "notes": "", + } + }, + "obj": MTS50_Z8Initialize, + }, + "MTS50_Z8Deinitialize": { + "default_code": "MTS50_Z8Deinitialize(receiver= '')", + "args": { + "receiver": { + "default": "MTS50_Z8", + "type": str, + "notes": "", + } + }, + "obj": MTS50_Z8Deinitialize, + }, + "MTS50_Z8EnableMotor": { + "default_code": "MTS50_Z8EnableMotor(receiver= '')", + "args": { + "receiver": { + "default": "MTS50_Z8", + "type": str, + "notes": "", + } + }, + "obj": MTS50_Z8EnableMotor, + }, + "MTS50_Z8DisableMotor": { + "default_code": "MTS50_Z8DisableMotor(receiver= '')", + "args": { + "receiver": { + "default": "MTS50_Z8", + "type": str, + "notes": "", + } + }, + "obj": MTS50_Z8DisableMotor, + }, + "MTS50_Z8MoveAbsolute": { + "default_code": "MTS50_Z8MoveAbsolute(receiver= '', position= 0)", + "args": { + "receiver": { + "default": "MTS50_Z8", + "type": str, + "notes": "", + }, + "position": { + "default": 0, + "type": int, + "notes": "", + }, + }, + "obj": MTS50_Z8MoveAbsolute, + }, + "MTS50_Z8MoveRelative": { + "default_code": "MTS50_Z8MoveRelative(receiver= '', distance= 0)", + "args": { + "receiver": { + "default": "MTS50_Z8", + "type": str, + "notes": "", + }, + "distance": { + "default": 0, + "type": int, + "notes": "", + }, + }, + "obj": MTS50_Z8MoveRelative, + }, + }, + }, + "Z812": { + "obj": Z812, + "serial": True, + "serial_sequence": ["Z812Connect", "Z812Initialize"], + "import_device": "from devices.z812 import Z812", + "import_commands": "from commands.z812_commands import *", + "telemetry": { + "parameters": { + "position": { + "function_name": "get_position", + "data_type": "float", + "units": "mm", + } + }, + "options": {"custom_init_args": ["port"]}, + }, + "init": { + "default_code": "Z812(name='Z812', port='', baudrate=115200, timeout=0.1, destination=0x50, source=0x01, channel=1)", + "obj_name": "Z812", + "args": { + "name": { + "default": "Z812", + "type": str, + "notes": "Name of the device.", + }, + "port": {"default": "COM", "type": str, "notes": "Port"}, + "baudrate": { + "default": 115200, + "type": int, + "notes": "Baudrate", + }, + "timeout": { + "default": 0.1, + "type": float, + "notes": "Timeout", + }, + "destination": { + "default": 0x50, + "type": int, + "notes": "", + }, + "source": { + "default": 0x01, + "type": int, + "notes": "", + }, + "channel": { + "default": 1, + "type": int, + "notes": "", + }, + }, + }, + "commands": { + "Z812Connect": { + "default_code": "Z812Connect(receiver= '')", + "args": { + "receiver": { + "default": "Z812", + "type": str, + "notes": "", + } + }, + "obj": Z812Connect, + }, + "Z812Initialize": { + "default_code": "Z812Initialize(receiver= '')", + "args": { + "receiver": { + "default": "Z812", + "type": str, + "notes": "", + } + }, + "obj": Z812Initialize, + }, + "Z812Deinitialize": { + "default_code": "Z812Deinitialize(receiver= '')", + "args": { + "receiver": { + "default": "Z812", + "type": str, + "notes": "", + } + }, + "obj": Z812Deinitialize, + }, + "Z812EnableMotor": { + "default_code": "Z812EnableMotor(receiver= '')", + "args": { + "receiver": { + "default": "Z812", + "type": str, + "notes": "", + } + }, + "obj": Z812EnableMotor, + }, + "Z812DisableMotor": { + "default_code": "Z812DisableMotor(receiver= '')", + "args": { + "receiver": { + "default": "Z812", + "type": str, + "notes": "", + } + }, + "obj": Z812DisableMotor, + }, + "Z812MoveAbsolute": { + "default_code": "Z812MoveAbsolute(receiver= '', position= 0.0)", + "args": { + "receiver": { + "default": "Z812", + "type": str, + "notes": "", + }, + "position": { + "default": 0.0, + "type": float, + "notes": "Absolute position in mm.", + }, + }, + "obj": Z812MoveAbsolute, + }, + "Z812MoveRelative": { + "default_code": "Z812MoveRelative(receiver= '', distance= 0.0)", + "args": { + "receiver": { + "default": "Z812", + "type": str, + "notes": "", + }, + "distance": { + "default": 0.0, + "type": float, + "notes": "Relative distance in mm.", + }, + }, + "obj": Z812MoveRelative, + }, + }, + }, + "APIS": { + "obj": APIS, + "serial": True, + "serial_sequence": ["APISConnect", "APISInitialize"], + "import_device": "from devices.apis import APIS", + "import_commands": "from commands.apis_commands import *", + "telemetry": { + "parameters": { + "polarizer_angle": { + "function_name": "get_polarizer_angle", + "data_type": "float", + "units": "deg", + }, + "sample_angle": { + "function_name": "get_sample_angle", + "data_type": "float", + "units": "deg", + }, + }, + "options": {"custom_init_args": ["port"]}, + }, + "init": { + "default_code": "APIS(name='APIS', port='', baudrate=9600, timeout=0.5, connection_wait_s=2.0, settling_time_s=1.5, command_delay_s=0.05, max_retries=3, polarizer_stage_to_servo_ratio=1.059, sample_stage_to_servo_ratio=1.059, polarizer_stage_direction=1, sample_stage_direction=1, polarizer_servo_zero_deg=0, sample_servo_zero_deg=0, use_camera=True, camera_save_directory='data/imaging/', camera_bayer_pattern='GBRG', camera_raw_max_value=1023.0, polarizer_baseline_path=None)", + "obj_name": "APIS", + "args": { + "name": { + "default": "APIS", + "type": str, + "notes": "Name of the device.", + }, + "port": { + "default": "COM", + "type": str, + "notes": "Arduino serial port for APIS.", + }, + "baudrate": { + "default": 9600, + "type": int, + "notes": "APIS serial baudrate.", + }, + "timeout": { + "default": 0.5, + "type": float, + "notes": "Serial readline timeout in seconds.", + }, + "connection_wait_s": { + "default": 2.0, + "type": float, + "notes": "Time to wait for READY during connect.", + }, + "settling_time_s": { + "default": 1.5, + "type": float, + "notes": "Post-move settling time before command completion.", + }, + "command_delay_s": { + "default": 0.05, + "type": float, + "notes": "Delay between serial command transactions.", + }, + "max_retries": { + "default": 3, + "type": int, + "notes": "Retry count for serial timeouts.", + }, + "polarizer_stage_to_servo_ratio": { + "default": 1.059, + "type": float, + "notes": "Polarizer stage-to-servo calibration ratio.", + }, + "sample_stage_to_servo_ratio": { + "default": 1.059, + "type": float, + "notes": "Sample stage-to-servo calibration ratio.", + }, + "polarizer_stage_direction": { + "default": 1, + "type": int, + "notes": "Polarizer rotation sign.", + }, + "sample_stage_direction": { + "default": 1, + "type": int, + "notes": "Sample rotation sign.", + }, + "polarizer_servo_zero_deg": { + "default": 0, + "type": int, + "notes": "Polarizer servo zero offset.", + }, + "sample_servo_zero_deg": { + "default": 0, + "type": int, + "notes": "Sample servo zero offset.", + }, + "use_camera": { + "default": True, + "type": bool, + "notes": "Initialize and use the integrated Ximea camera.", + }, + "camera_save_directory": { + "default": "data/imaging/", + "type": str, + "notes": "Default save directory for APIS image outputs.", + }, + "camera_bayer_pattern": { + "default": "GBRG", + "type": str, + "notes": "Bayer pattern used when converting RAW16 data to RGB.", + }, + "camera_raw_max_value": { + "default": 1023.0, + "type": float, + "notes": "Linear scaling maximum used for RAW16 to RGB conversion.", + }, + "polarizer_baseline_path": { + "default": None, + "type": str, + "notes": "Optional path for persisted XPL/PPL polarizer baseline JSON.", + }, + }, + }, + "commands": { + "APISConnect": { + "default_code": "APISConnect(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISConnect, + }, + "APISInitialize": { + "default_code": "APISInitialize(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISInitialize, + }, + "APISDeinitialize": { + "default_code": "APISDeinitialize(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISDeinitialize, + }, + "APISReset": { + "default_code": "APISReset(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISReset, + }, + "APISEmergencyStop": { + "default_code": "APISEmergencyStop(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISEmergencyStop, + }, + "APISHome": { + "default_code": "APISHome(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISHome, + }, + "APISRotatePolarizer": { + "default_code": "APISRotatePolarizer(receiver= '', angle_deg= 0.0)", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""}, + "angle_deg": { + "default": 0.0, + "type": float, + "notes": "Target polarizer stage angle in degrees.", + }, + }, + "obj": APISRotatePolarizer, + }, + "APISRotateSample": { + "default_code": "APISRotateSample(receiver= '', angle_deg= 0.0)", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""}, + "angle_deg": { + "default": 0.0, + "type": float, + "notes": "Target sample stage angle in degrees.", + }, + }, + "obj": APISRotateSample, + }, + "APISSetPolarizerBaseline": { + "default_code": "APISSetPolarizerBaseline(receiver= '', xpl_angle_deg= 120.0, persist=False, source='manual')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""}, + "xpl_angle_deg": { + "default": 120.0, + "type": float, + "notes": "XPL polarizer angle; PPL is derived as the reachable orthogonal angle.", + }, + "persist": { + "default": False, + "type": bool, + "notes": "Save the resulting XPL/PPL baseline to JSON.", + }, + "source": { + "default": "manual", + "type": str, + "notes": "Source label stored in the baseline JSON when persist is enabled.", + }, + }, + "obj": APISSetPolarizerBaseline, + }, + "APISLoadPolarizerBaseline": { + "default_code": "APISLoadPolarizerBaseline(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISLoadPolarizerBaseline, + }, + "APISSavePolarizerBaseline": { + "default_code": "APISSavePolarizerBaseline(receiver= '', source='manual')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""}, + "source": {"default": "manual", "type": str, "notes": "Source label stored in baseline JSON."}, + }, + "obj": APISSavePolarizerBaseline, + }, + "APISRotateXPL": { + "default_code": "APISRotateXPL(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISRotateXPL, + }, + "APISRotatePPL": { + "default_code": "APISRotatePPL(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISRotatePPL, + }, + "APISGetState": { + "default_code": "APISGetState(receiver= '')", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""} + }, + "obj": APISGetState, + }, + "APISCaptureRaw16": { + "default_code": "APISCaptureRaw16(receiver= '', filename=None, directory=None, exposure_time=None, gain=0.0)", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""}, + "filename": {"default": None, "type": str, "notes": "Output filename without extension."}, + "directory": {"default": None, "type": str, "notes": "Optional override save directory."}, + "exposure_time": {"default": None, "type": int, "notes": "Optional camera exposure in microseconds."}, + "gain": {"default": 0.0, "type": float, "notes": "Camera gain in dB. Default is 0."}, + }, + "obj": APISCaptureRaw16, + }, + "APISCaptureRgb": { + "default_code": "APISCaptureRgb(receiver= '', filename=None, directory=None, exposure_time=None, gain=0.0)", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""}, + "filename": {"default": None, "type": str, "notes": "Output filename without extension."}, + "directory": {"default": None, "type": str, "notes": "Optional override save directory."}, + "exposure_time": {"default": None, "type": int, "notes": "Optional camera exposure in microseconds."}, + "gain": {"default": 0.0, "type": float, "notes": "Camera gain in dB. Default is 0."}, + }, + "obj": APISCaptureRgb, + }, + "APISConvertRaw16ToRgb": { + "default_code": "APISConvertRaw16ToRgb(receiver= '', raw16_path='', rgb_path=None)", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""}, + "raw16_path": {"default": "", "type": str, "notes": "Path to the saved RAW16 TIFF file."}, + "rgb_path": {"default": None, "type": str, "notes": "Optional output path for the converted RGB TIFF."}, + }, + "obj": APISConvertRaw16ToRgb, + }, + "APISRunImagingSequence": { + "default_code": "APISRunImagingSequence(receiver= '', sample_id='sample', directory=None, sample_angles=None, xpl_exposure_time=400000, ppl_exposure_time=18000, do_xpl=True, do_ppl=True, xpl_polarizer_angle=None, ppl_polarizer_angle=None, gain=0.0)", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""}, + "sample_id": {"default": "sample", "type": str, "notes": "Sample ID used for output folder and filenames."}, + "directory": {"default": None, "type": str, "notes": "Optional output root directory."}, + "sample_angles": {"default": None, "type": list, "notes": "Sample angles, or None for APIS default sequence."}, + "xpl_exposure_time": {"default": 400000, "type": int, "notes": "XPL exposure in microseconds."}, + "ppl_exposure_time": {"default": 18000, "type": int, "notes": "PPL exposure in microseconds."}, + "do_xpl": {"default": True, "type": bool, "notes": "Capture XPL mode."}, + "do_ppl": {"default": True, "type": bool, "notes": "Capture PPL mode."}, + "xpl_polarizer_angle": {"default": None, "type": float, "notes": "Optional XPL polarizer angle override."}, + "ppl_polarizer_angle": {"default": None, "type": float, "notes": "Optional PPL polarizer angle override."}, + "gain": {"default": 0.0, "type": float, "notes": "Camera gain in dB."}, + }, + "obj": APISRunImagingSequence, + }, + "APISRunPolarizerCalibration": { + "default_code": "APISRunPolarizerCalibration(receiver= '', sample_id='polarizer_calibration', directory=None, exposure_time=200000, polarizer_angles=None, sample_angle=0.0, fine_radius_deg=10, fine_step_deg=1, gain=0.0)", + "args": { + "receiver": {"default": "APIS", "type": str, "notes": ""}, + "sample_id": {"default": "polarizer_calibration", "type": str, "notes": "Calibration sample ID used in output filenames."}, + "directory": {"default": None, "type": str, "notes": "Optional calibration output root."}, + "exposure_time": {"default": 200000, "type": int, "notes": "Calibration exposure in microseconds."}, + "polarizer_angles": {"default": None, "type": list, "notes": "Coarse scan angles, or None for 0:max:5."}, + "sample_angle": {"default": 0.0, "type": float, "notes": "Sample stage angle during calibration."}, + "fine_radius_deg": {"default": 10, "type": int, "notes": "Fine scan radius around the darkest coarse angle."}, + "fine_step_deg": {"default": 1, "type": int, "notes": "Fine scan angle step."}, + "gain": {"default": 0.0, "type": float, "notes": "Camera gain in dB."}, + }, + "obj": APISRunPolarizerCalibration, + }, + }, + }, + "P4PP": { + "obj": P4PP, + "serial": True, + "serial_sequence": ["P4PPConnect", "P4PPInitialize"], + "import_device": "from devices.p4pp import P4PP", + "import_commands": "from commands.p4pp_commands import *", + "telemetry": { + "parameters": { + "linear_position_mm": { + "function_name": "get_linear_position_mm", + "data_type": "float", + "units": "mm", + }, + "rotational_position_deg": { + "function_name": "get_rotational_position_deg", + "data_type": "float", + "units": "deg", + }, + }, + "options": {"custom_init_args": ["port"]}, + }, + "init": { + "default_code": "P4PP(name='P4PP', port='', baudrate=115200, timeout=0.2, startup_delay=2.0, command_timeout=30.0, motion_timeout=60.0, home_timeout=60.0, measure_timeout=30.0, poll_interval=0.2, rotation_safety_linear_mm=45.0, measurement_resistor_ohms=681.0, save_directory='data/resistance/')", + "obj_name": "P4PP", + "args": { + "name": { + "default": "P4PP", + "type": str, + "notes": "Name of the device.", + }, + "port": { + "default": "COM", + "type": str, + "notes": "Arduino serial port for the P4PP controller.", + }, + "baudrate": { + "default": 115200, + "type": int, + "notes": "P4PP serial baudrate.", + }, + "timeout": { + "default": 0.2, + "type": float, + "notes": "Serial readline timeout in seconds.", + }, + "startup_delay": { + "default": 2.0, + "type": float, + "notes": "Delay after opening serial to allow Arduino reset.", + }, + "command_timeout": { + "default": 30.0, + "type": float, + "notes": "Timeout for position refresh and other short commands.", + }, + "motion_timeout": { + "default": 60.0, + "type": float, + "notes": "Timeout for motion commands.", + }, + "home_timeout": { + "default": 60.0, + "type": float, + "notes": "Timeout for homing commands.", + }, + "measure_timeout": { + "default": 30.0, + "type": float, + "notes": "Timeout for MEASURE or MEASURE_N commands.", + }, + "poll_interval": { + "default": 0.2, + "type": float, + "notes": "Position polling interval while motion or homing is active.", + }, + "rotation_safety_linear_mm": { + "default": 45.0, + "type": float, + "notes": "Block rotational homing and moves when linear position is at or above this value in mm.", + }, + "measurement_resistor_ohms": { + "default": 681.0, + "type": float, + "notes": "Measurement resistor selection. Allowed values are 681 and 68.1 ohm.", + }, + "save_directory": { + "default": "data/resistance/", + "type": str, + "notes": "Default directory for saved resistance CSV files.", + }, + }, + }, + "commands": { + "P4PPConnect": { + "default_code": "P4PPConnect(receiver= '')", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + } + }, + "obj": P4PPConnect, + }, + "P4PPInitialize": { + "default_code": "P4PPInitialize(receiver= '')", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + } + }, + "obj": P4PPInitialize, + }, + "P4PPDeinitialize": { + "default_code": "P4PPDeinitialize(receiver= '')", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + } + }, + "obj": P4PPDeinitialize, + }, + "P4PPRefreshPosition": { + "default_code": "P4PPRefreshPosition(receiver= '')", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + } + }, + "obj": P4PPRefreshPosition, + }, + "P4PPHomeLinear": { + "default_code": "P4PPHomeLinear(receiver= '')", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + } + }, + "obj": P4PPHomeLinear, + }, + "P4PPHomeRotational": { + "default_code": "P4PPHomeRotational(receiver= '')", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + } + }, + "obj": P4PPHomeRotational, + }, + "P4PPHomeAll": { + "default_code": "P4PPHomeAll(receiver= '')", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + } + }, + "obj": P4PPHomeAll, + }, + "P4PPMoveLinearAbsolute": { + "default_code": "P4PPMoveLinearAbsolute(receiver= '', position_mm= 0.0)", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + }, + "position_mm": { + "default": 0.0, + "type": float, + "notes": "Absolute linear position in mm.", + }, + }, + "obj": P4PPMoveLinearAbsolute, + }, + "P4PPMoveLinearRelative": { + "default_code": "P4PPMoveLinearRelative(receiver= '', distance_mm= 0.0)", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + }, + "distance_mm": { + "default": 0.0, + "type": float, + "notes": "Relative linear distance in mm.", + }, + }, + "obj": P4PPMoveLinearRelative, + }, + "P4PPMoveRotationalAbsolute": { + "default_code": "P4PPMoveRotationalAbsolute(receiver= '', position_deg= 0.0)", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + }, + "position_deg": { + "default": 0.0, + "type": float, + "notes": "Absolute rotational position in degrees.", + }, + }, + "obj": P4PPMoveRotationalAbsolute, + }, + "P4PPMoveRotationalRelative": { + "default_code": "P4PPMoveRotationalRelative(receiver= '', distance_deg= 0.0)", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + }, + "distance_deg": { + "default": 0.0, + "type": float, + "notes": "Relative rotational distance in degrees.", + }, + }, + "obj": P4PPMoveRotationalRelative, + }, + "P4PPMeasure": { + "default_code": "P4PPMeasure(receiver= '', cycles= 20)", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + }, + "cycles": { + "default": 20, + "type": int, + "notes": "Number of measurement cycles. Uses firmware averaging when greater than 1.", + }, + }, + "obj": P4PPMeasure, + }, + "P4PPSetMeasurementResistor": { + "default_code": "P4PPSetMeasurementResistor(receiver= '', resistor_ohms= 681.0)", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + }, + "resistor_ohms": { + "default": 681.0, + "type": float, + "notes": "Measurement resistor selection. Use 681 or 68.1 ohm.", + }, + }, + "obj": P4PPSetMeasurementResistor, + }, + "P4PPSaveMeasurementCsv": { + "default_code": "P4PPSaveMeasurementCsv(receiver= '', sample_id=None, csv_path=None, notes=None)", + "args": { + "receiver": { + "default": "P4PP", + "type": str, + "notes": "", + }, + "sample_id": { + "default": None, + "type": str, + "notes": "Optional sample identifier for the CSV row.", + }, + "csv_path": { + "default": None, + "type": str, + "notes": "Optional override path. Defaults to data/resistance/p4pp_measurements.csv.", + }, + "notes": { + "default": None, + "type": str, + "notes": "Optional note stored with the measurement row.", + }, + }, + "obj": P4PPSaveMeasurementCsv, + }, + }, + }, + "Keithley2450": { + "obj": Keithley2450, + "serial": True, + "serial_sequence": ["Keithley2450Initialize"], + "import_device": "from devices.keithley_2450 import Keithley2450", + "import_commands": "from commands.keithley_2450_commands import *", + "init": { + "default_code": "Keithley2450(name='Keithley2450', ID='', query_delay=0)", + "obj_name": "Keithley2450", + "args": { + "name": { + "default": "Keithley2450", + "type": str, + "notes": "Name of the device", + }, + "ID": { + "default": "", + "type": str, + "notes": "ID of the device", + }, + "query_delay": { + "default": 0, + "type": int, + "notes": "Delay between queries", + }, + }, + }, + "commands": { + "Keithley2450Initialize": { + "default_code": "Keithley2450Initialize(receiver= '')", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + } + }, + "obj": Keithley2450Initialize, + }, + "Keithley2450Deinitialize": { + "default_code": "Keithley2450Deinitialize(receiver= '')", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + } + }, + "obj": Keithley2450Deinitialize, + }, + "KeithleyWait": { + "default_code": "KeithleyWait(receiver= '')", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + } + }, + "obj": KeithleyWait, + }, + "KeithleyWriteCommand": { + "default_code": "KeithleyWriteCommand(receiver= '', command= '')", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + }, + "command": { + "default": "", + "type": str, + "notes": "", + }, + }, + "obj": KeithleyWriteCommand, + }, + "KeithleySetTerminal": { + "default_code": "KeithleySetTerminal(receiver= '', position= 'front')", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + }, + "position": { + "default": "front", + "type": str, + "notes": "", + }, + }, + "obj": KeithleySetTerminal, + }, + "KeithleyErrorCheck": { + "default_code": "KeithleyErrorCheck(receiver= '')", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + } + }, + "obj": KeithleyErrorCheck, + }, + "KeithleyClearBuffer": { + "default_code": "KeithleyClearBuffer(receiver= '', buffer='defbuffer1')", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + }, + "buffer": { + "default": "defbuffer1", + "type": str, + "notes": "", + }, + }, + "obj": KeithleyClearBuffer, + }, + "KeithleyIVCharacteristic": { + "default_code": "KeithleyIVCharacteristic(receiver= '', ilimit=0.0, vmin=0.0, vmax=0.0, delay=0.0, steps=60)", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + }, + "ilimit": { + "default": 0.0, + "type": float, + "notes": "", + }, + "vmin": { + "default": 0.0, + "type": float, + "notes": "", + }, + "vmax": { + "default": 0.0, + "type": float, + "notes": "", + }, + "delay": { + "default": 0.0, + "type": float, + "notes": "", + }, + "steps": { + "default": 60, + "type": int, + "notes": "", + }, + }, + "obj": KeithleyIVCharacteristic, + }, + "KeithleyFourPoint": { + "default_code": "KeithleyFourPoint(receiver= '', test_curr=0.0, vlimit=0.0, curr_reversal = False)", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + }, + "test_curr": { + "default": 0.0, + "type": float, + "notes": "", + }, + "vlimit": { + "default": 0.0, + "type": float, + "notes": "", + }, + "curr_reversal": { + "default": False, + "type": bool, + "notes": "", + }, + }, + "obj": KeithleyFourPoint, + }, + "KeithleyGetData": { + "default_code": "KeithleyGetData(receiver= '', filename=None, four_point= False)", + "args": { + "receiver": { + "default": "Keithley2450", + "type": str, + "notes": "", + }, + "filename": { + "default": None, + "type": str, + "notes": "", + }, + "four_point": { + "default": False, + "type": bool, + "notes": "", + }, + }, + "obj": KeithleyGetData, + }, + }, + }, + "PrintingStage": heating_stage_ref, + "AnnealingStage": heating_stage_ref, + "MultiStepper": { + "obj": MultiStepper, + "serial": True, + "serial_sequence": ["MultiStepperConnect", "MultiStepperInitialize"], + "import_device": "from devices.multi_stepper import MultiStepper", + "import_commands": "from commands.multi_stepper_commands import *", + "init": { + "default_code": "MultiStepper(name='MultiStepper', port='', baudrate=115200, timeout=0.1, destination=0x50, source=0x01, channel=1)", + "obj_name": "MultiStepper", + "args": { + "name": { + "default": "MultiStepper", + "type": str, + "notes": "Name of the device", + }, + "port": { + "default": "COM", + "type": str, + "notes": "Port of the device", + }, + "baudrate": { + "default": 115200, + "type": int, + "notes": "Baudrate of the device", + }, + "timeout": { + "default": 1.0, + "type": float, + "notes": "Timeout of the device", + }, + "stepper_list": { + "default": (1,), + "type": Tuple[int, ...], + "notes": "List of stepper numbers", + }, + "move_timeout": { + "default": 30.0, + "type": float, + "notes": "Timeout for move commands", + }, + }, + }, + "commands": { + "MultiStepperConnect": { + "default_code": "MultiStepperConnect(receiver= '')", + "args": { + "receiver": { + "default": "MultiStepper", + "type": str, + "notes": "Name of the device", + } + }, + "obj": MultiStepperConnect, + }, + "MultiStepperInitialize": { + "default_code": "MultiStepperInitialize(receiver= '')", + "args": { + "receiver": { + "default": "MultiStepper", + "type": str, + "notes": "Name of the device", + } + }, + "obj": MultiStepperInitialize, + }, + "MultiStepperDeinitialize": { + "default_code": "MultiStepperDeinitialize(receiver= '')", + "args": { + "receiver": { + "default": "MultiStepper", + "type": str, + "notes": "Name of the device", + } + }, + "obj": MultiStepperDeinitialize, + }, + "MultiStepperMoveAbsolute": { + "default_code": "MultiStepperMoveAbsolute(receiver= '', stepper_number= 0, position= 0)", + "args": { + "receiver": { + "default": "MultiStepper", + "type": str, + "notes": "Name of the device", + }, + "stepper_number": { + "default": 0, + "type": int, + "notes": "Number of the stepper", + }, + "position": { + "default": 0.0, + "type": float, + "notes": "Position to move to", + }, + }, + "obj": MultiStepperMoveAbsolute, + }, + "MultiStepperMoveRelative": { + "default_code": "MultiStepperMoveRelative(receiver= '', stepper_number= 0, distance= 0)", + "args": { + "receiver": { + "default": "MultiStepper", + "type": str, + "notes": "Name of the device", + }, + "stepper_number": { + "default": 0, + "type": int, + "notes": "Number of the stepper", + }, + "distance": { + "default": 0.0, + "type": float, + "notes": "Distance to move", + }, + }, + "obj": MultiStepperMoveRelative, + }, + }, + }, + "SHT85HumidityTempSensor": { + "obj": SHT85HumidityTempSensor, + "serial": True, + "serial_sequence": ["SHT85Connect", "SHT85Initialize"], + "import_device": "from devices.sht85_sensor import SHT85HumidityTempSensor", + "import_commands": "from commands.sht85_sensor_commands import *", + "init": { + "default_code": "SHT85HumidityTempSensor(name='SHT85HumidityTempSensor', port='', baudrate=9600, timeout=1.0)", + "obj_name": "SHT85HumidityTempSensor", + "args": { + "name": { + "default": "SHT85HumidityTempSensor", + "type": str, + "notes": "Name of the device", + }, + "port": { + "default": "COM", + "type": str, + "notes": "Port of the device", + }, + "baudrate": { + "default": 9600, + "type": int, + "notes": "Baudrate of the device", + }, + "timeout": { + "default": 1.0, + "type": float, + "notes": "Timeout of the device", + }, + }, + }, + "commands": { + "SHT85Connect": { + "default_code": "SHT85Connect(receiver= '')", + "args": { + "receiver": { + "default": "SHT85HumidityTempSensor", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": SHT85Connect, + }, + "SHT85Initialize": { + "default_code": "SHT85Initialize(receiver= '')", + "args": { + "receiver": { + "default": "SHT85HumidityTempSensor", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": SHT85Initialize, + }, + "SHT85Deinitialize": { + "default_code": "SHT85Deinitialize(receiver= '')", + "args": { + "receiver": { + "default": "SHT85HumidityTempSensor", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": SHT85Deinitialize, + }, + "SHT85GetHumidity": { + "default_code": "SHT85GetHumidity(receiver= '')", + "args": { + "receiver": { + "default": "SHT85HumidityTempSensor", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": SHT85GetHumidity, + }, + "SHT85GetTemp": { + "default_code": "SHT85GetTemp(receiver= '')", + "args": { + "receiver": { + "default": "SHT85HumidityTempSensor", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": SHT85GetTemp, + }, + }, + }, + "Sonicator": { + "obj": Sonicator, + "serial": True, + "serial_sequence": ["SonicatorConnect", "SonicatorInitialize"], + "import_device": "from devices.sonicator import Sonicator", + "import_commands": "from commands.sonicator_commands import *", + "init": { + "default_code": "Sonicator(name='Sonicator', port='', baudrate=9600, timeout=0.5, connect_delay_s=3.0, response_timeout_s=3.0, power_probe_timeout_s=5.0, line_terminator='\\n', command_prefix='>', debug_io=False)", + "obj_name": "Sonicator", + "args": { + "name": { + "default": "Sonicator", + "type": str, + "notes": "Name of the device", + }, + "port": { + "default": "COM", + "type": str, + "notes": "Port of the Arduino Uno R3 wrapper", + }, + "baudrate": { + "default": 9600, + "type": int, + "notes": "Baudrate of the device", + }, + "timeout": { + "default": 0.5, + "type": float, + "notes": "Serial readline timeout in seconds.", + }, + "connect_delay_s": { + "default": 3.0, + "type": float, + "notes": "Wait time after opening the serial port.", + }, + "response_timeout_s": { + "default": 3.0, + "type": float, + "notes": "Timeout for status, start, stop, and button commands.", + }, + "power_probe_timeout_s": { + "default": 5.0, + "type": float, + "notes": "Timeout for the intrusive power-probe command.", + }, + "line_terminator": { + "default": "\\n", + "type": str, + "notes": "Line terminator sent after each command keyword.", + }, + "command_prefix": { + "default": ">", + "type": str, + "notes": "Command header character expected by the Arduino sketch.", + }, + "debug_io": { + "default": False, + "type": bool, + "notes": "Print raw TX/RX serial traffic for debugging.", + }, + }, + }, + "commands": { + "SonicatorConnect": { + "default_code": "SonicatorConnect(receiver= '')", + "args": { + "receiver": {"default": "Sonicator", "type": str, "notes": "Name of the device"} + }, + "obj": SonicatorConnect, + }, + "SonicatorInitialize": { + "default_code": "SonicatorInitialize(receiver= '')", + "args": { + "receiver": {"default": "Sonicator", "type": str, "notes": "Name of the device"} + }, + "obj": SonicatorInitialize, + }, + "SonicatorDeinitialize": { + "default_code": "SonicatorDeinitialize(receiver= '', reset_init_flag=True, close_serial=False)", + "args": { + "receiver": {"default": "Sonicator", "type": str, "notes": "Name of the device"}, + "reset_init_flag": {"default": True, "type": bool, "notes": "Reset the initialized flag."}, + "close_serial": {"default": False, "type": bool, "notes": "Close the serial port after deinitialize."}, + }, + "obj": SonicatorDeinitialize, + }, + "SonicatorGetStatus": { + "default_code": "SonicatorGetStatus(receiver= '')", + "args": { + "receiver": {"default": "Sonicator", "type": str, "notes": "Name of the device"} + }, + "obj": SonicatorGetStatus, + }, + "SonicatorStartSonicating": { + "default_code": "SonicatorStartSonicating(receiver= '')", + "args": { + "receiver": {"default": "Sonicator", "type": str, "notes": "Name of the device"} + }, + "obj": SonicatorStartSonicating, + }, + "SonicatorStopSonicating": { + "default_code": "SonicatorStopSonicating(receiver= '')", + "args": { + "receiver": {"default": "Sonicator", "type": str, "notes": "Name of the device"} + }, + "obj": SonicatorStopSonicating, + }, + "SonicatorPressButton": { + "default_code": "SonicatorPressButton(receiver= '')", + "args": { + "receiver": {"default": "Sonicator", "type": str, "notes": "Name of the device"} + }, + "obj": SonicatorPressButton, + }, + "SonicatorProbePowerConnection": { + "default_code": "SonicatorProbePowerConnection(receiver= '')", + "args": { + "receiver": {"default": "Sonicator", "type": str, "notes": "Name of the device"} + }, + "obj": SonicatorProbePowerConnection, + }, + }, + }, + "SubstrateHotel": { + "obj": SubstrateHotel, + "serial": True, + "serial_sequence": ["SubstrateHotelConnect", "SubstrateHotelInitialize"], + "import_device": "from devices.substrate_hotel import SubstrateHotel", + "import_commands": "from commands.substrate_hotel_commands import *", + "init": { + "default_code": "SubstrateHotel(name='SubstrateHotel', port='', baudrate=9600, timeout=1.0, connect_delay_s=5.0, ready_timeout_s=5.0, home_timeout_s=300.0, move_timeout_s=300.0)", + "obj_name": "SubstrateHotel", + "args": { + "name": {"default": "SubstrateHotel", "type": str, "notes": "Name of the device"}, + "port": {"default": "COM", "type": str, "notes": "Arduino serial port"}, + "baudrate": {"default": 9600, "type": int, "notes": "Baudrate of the Arduino controller"}, + "timeout": {"default": 1.0, "type": float, "notes": "Serial readline timeout in seconds"}, + "connect_delay_s": {"default": 5.0, "type": float, "notes": "Delay after opening the serial port to allow Arduino reset"}, + "ready_timeout_s": {"default": 5.0, "type": float, "notes": "Timeout while waiting for the Arduino Ready banner"}, + "home_timeout_s": {"default": 300.0, "type": float, "notes": "Timeout for the homing operation"}, + "move_timeout_s": {"default": 300.0, "type": float, "notes": "Default timeout for absolute moves"}, + }, + }, + "commands": { + "SubstrateHotelConnect": { + "default_code": "SubstrateHotelConnect(receiver= '')", + "args": {"receiver": {"default": "SubstrateHotel", "type": str, "notes": "Name of the device"}}, + "obj": SubstrateHotelConnect, + }, + "SubstrateHotelInitialize": { + "default_code": "SubstrateHotelInitialize(receiver= '')", + "args": {"receiver": {"default": "SubstrateHotel", "type": str, "notes": "Name of the device"}}, + "obj": SubstrateHotelInitialize, + }, + "SubstrateHotelDeinitialize": { + "default_code": "SubstrateHotelDeinitialize(receiver= '', reset_init_flag=True, close_serial=False)", + "args": { + "receiver": {"default": "SubstrateHotel", "type": str, "notes": "Name of the device"}, + "reset_init_flag": {"default": True, "type": bool, "notes": "Reset the initialized flag."}, + "close_serial": {"default": False, "type": bool, "notes": "Close the serial port after deinitialize."}, + }, + "obj": SubstrateHotelDeinitialize, + }, + "SubstrateHotelHome": { + "default_code": "SubstrateHotelHome(receiver= '')", + "args": {"receiver": {"default": "SubstrateHotel", "type": str, "notes": "Name of the device"}}, + "obj": SubstrateHotelHome, + }, + "SubstrateHotelMoveToPosition": { + "default_code": "SubstrateHotelMoveToPosition(receiver= '', position_mm=0.0, speed_mm_per_s=20.0, move_timeout=None)", + "args": { + "receiver": {"default": "SubstrateHotel", "type": str, "notes": "Name of the device"}, + "position_mm": {"default": 0.0, "type": float, "notes": "Absolute target position in mm (0-430)."}, + "speed_mm_per_s": {"default": 20.0, "type": float, "notes": "Requested move speed in mm/s."}, + "move_timeout": {"default": None, "type": float, "notes": "Optional override timeout for this move."}, + }, + "obj": SubstrateHotelMoveToPosition, + }, + }, + }, + "SubstrateDispenser": { + "obj": SubstrateDispenser, + "serial": True, + "serial_sequence": ["SubstrateDispenserConnect", "SubstrateDispenserInitialize"], + "import_device": "from devices.substrate_dispenser import SubstrateDispenser", + "import_commands": "from commands.substrate_dispenser_commands import *", + "init": { + "default_code": "SubstrateDispenser(name='SubstrateDispenser', port='', baudrate=9600, timeout=1.0, connect_delay_s=5.0, ready_timeout_s=5.0, home_timeout_s=30.0, move_timeout_s=30.0)", + "obj_name": "SubstrateDispenser", + "args": { + "name": {"default": "SubstrateDispenser", "type": str, "notes": "Name of the device"}, + "port": {"default": "COM", "type": str, "notes": "Arduino serial port"}, + "baudrate": {"default": 9600, "type": int, "notes": "Baudrate of the Arduino controller"}, + "timeout": {"default": 1.0, "type": float, "notes": "Serial readline timeout in seconds"}, + "connect_delay_s": {"default": 5.0, "type": float, "notes": "Delay after opening the serial port to allow Arduino reset"}, + "ready_timeout_s": {"default": 5.0, "type": float, "notes": "Timeout while waiting for the Arduino Ready banner"}, + "home_timeout_s": {"default": 30.0, "type": float, "notes": "Timeout for the homing operation"}, + "move_timeout_s": {"default": 30.0, "type": float, "notes": "Default timeout for absolute moves"}, + }, + }, + "commands": { + "SubstrateDispenserConnect": { + "default_code": "SubstrateDispenserConnect(receiver= '')", + "args": {"receiver": {"default": "SubstrateDispenser", "type": str, "notes": "Name of the device"}}, + "obj": SubstrateDispenserConnect, + }, + "SubstrateDispenserInitialize": { + "default_code": "SubstrateDispenserInitialize(receiver= '')", + "args": {"receiver": {"default": "SubstrateDispenser", "type": str, "notes": "Name of the device"}}, + "obj": SubstrateDispenserInitialize, + }, + "SubstrateDispenserDeinitialize": { + "default_code": "SubstrateDispenserDeinitialize(receiver= '', reset_init_flag=True, close_serial=False)", + "args": { + "receiver": {"default": "SubstrateDispenser", "type": str, "notes": "Name of the device"}, + "reset_init_flag": {"default": True, "type": bool, "notes": "Reset the initialized flag."}, + "close_serial": {"default": False, "type": bool, "notes": "Close the serial port after deinitialize."}, + }, + "obj": SubstrateDispenserDeinitialize, + }, + "SubstrateDispenserHome": { + "default_code": "SubstrateDispenserHome(receiver= '')", + "args": {"receiver": {"default": "SubstrateDispenser", "type": str, "notes": "Name of the device"}}, + "obj": SubstrateDispenserHome, + }, + "SubstrateDispenserMoveToPosition": { + "default_code": "SubstrateDispenserMoveToPosition(receiver= '', position_mm=0.0, speed_mm_per_s=20.0, move_timeout=None)", + "args": { + "receiver": {"default": "SubstrateDispenser", "type": str, "notes": "Name of the device"}, + "position_mm": {"default": 0.0, "type": float, "notes": "Absolute target position in mm (0-45)."}, + "speed_mm_per_s": {"default": 20.0, "type": float, "notes": "Requested move speed in mm/s."}, + "move_timeout": {"default": None, "type": float, "notes": "Optional override timeout for this move."}, + }, + "obj": SubstrateDispenserMoveToPosition, + }, + }, + }, + "MassFlowController": { + "obj": MassFlowController, + "serial": True, + "serial_sequence": ["MassFlowControllerInitialize"], + "import_device": "from devices.mfc import MassFlowController", + "import_commands": "from commands.mfc_commands import *", + "init": { + "default_code": "MassFlowController(name='MassFlowController', ip='192.168.2.155')", + "obj_name": "MassFlowController", + "args": { + "name": { + "default": "MassFlowController", + "type": str, + "notes": "Name of the device", + }, + "ip": { + "default": "192.168.2.155", + "type": str, + "notes": "IP of the device", + }, + }, + }, + "commands": { + "MFCInitialize": { + "default_code": "MFCInitialize(receiver= '')", + "args": { + "receiver": { + "default": "MassFlowController", + "type": str, + "notes": "Name of the device", + } + }, + "obj": MFCInitialize, + }, + "MFCDeinitialize": { + "default_code": "MFCDeinitialize(receiver= '')", + "args": { + "receiver": { + "default": "MassFlowController", + "type": str, + "notes": "Name of the device", + }, + "obj": MFCDeinitialize, + }, + }, + "MFCGetData": { + "default_code": "MFCGetData(receiver= '')", + "args": { + "receiver": { + "default": "MassFlowController", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": MFCGetData, + }, + "MFCSetGas": { + "default_code": "MFCSetGas(receiver= '', gas= 'N2')", + "args": { + "receiver": { + "default": "MassFlowController", + "type": str, + "notes": "Name of the device", + }, + "gas": { + "default": "N2", + "type": str, + "notes": "Gas to set", + }, + }, + "obj": MFCSetGas, + }, + "MFCSet": { + "default_code": "MFCSet(receiver= '', setpoint= 0.0)", + "args": { + "receiver": { + "default": "MassFlowController", + "type": str, + "notes": "Name of the device", + }, + "setpoint": { + "default": 0.0, + "type": float, + "notes": "Setpoint to set", + }, + }, + "obj": MFCSet, + }, + "MFCOpen": { + "default_code": "MFCOpen(receiver= '')", + "args": { + "receiver": { + "default": "MassFlowController", + "type": str, + "notes": "Name of the device", + }, + }, + "obj": MFCOpen, + }, + }, + }, + "NewportESP301": { + "obj": NewportESP301, + "serial": True, + "serial_sequence": ["NewportESP301Connect", "NewportESP301Initialize"], + "import_device": "from devices.newport_esp301 import NewportESP301", + "import_commands": "from commands.newport_esp301_commands import *", + "init": { + "default_code": "NewportESP301(name='NewportESP301', port='', baudrate=921600, timeout=1.0, axis_list=(1, 2, 3), default_speed=10.0, poll_interval=0.1, axis_configs={1: {'stage_model': 'ILS100CC', 'motion_type': 'linear', 'units': 'mm', 'home_mode': 'OR4', 'zero_position': 0.0, 'default_speed': 10.0}, 2: {'stage_model': 'UTS100PP', 'motion_type': 'linear', 'units': 'mm', 'home_mode': 'OR4', 'zero_position': 0.0, 'default_speed': 10.0}, 3: {'stage_model': 'PR50PP', 'motion_type': 'rotary', 'units': 'deg', 'home_mode': 'OR1', 'zero_position': 0.0, 'default_speed': 10.0}})", + "obj_name": "NewportESP301", + "args": { + "name": { + "default": "NewportESP301", + "type": str, + "notes": "Name of the device", + }, + "port": { + "default": "COM", + "type": str, + "notes": "Port of the device", + }, + "baudrate": { + "default": 921600, + "type": int, + "notes": "Baudrate of the device", + }, + "timeout": { + "default": 1.0, + "type": float, + "notes": "Timeout of the device", + }, + "axis_list": { + "default": (1, 2, 3), + "type": Tuple[int, ...], + "notes": "List of axis numbers", + }, + "default_speed": { + "default": 10.0, + "type": float, + "notes": "Default speed of the device", + }, + "poll_interval": { + "default": 0.1, + "type": float, + "notes": "Poll interval of the device", + }, + "axis_configs": { + "default": { + 1: {"stage_model": "ILS100CC", "motion_type": "linear", "units": "mm", "home_mode": "OR4", "zero_position": 0.0, "default_speed": 10.0}, + 2: {"stage_model": "UTS100PP", "motion_type": "linear", "units": "mm", "home_mode": "OR4", "zero_position": 0.0, "default_speed": 10.0}, + 3: {"stage_model": "PR50PP", "motion_type": "rotary", "units": "deg", "home_mode": "OR1", "zero_position": 0.0, "default_speed": 10.0}, + }, + "type": dict, + "notes": "Axis-specific configuration keyed by axis number. Recommended ESP301-3N setup: axes 1 and 2 linear in mm with OR4, axis 3 rotary in deg with OR1.", + }, + }, + }, + "commands": { + "NewportESP301Connect": { + "default_code": "NewportESP301Connect(receiver= '')", + "args": { + "receiver": { + "default": "NewportESP301", + "type": str, + "notes": "Name of the device", + } + }, + "obj": NewportESP301Connect, + }, + "NewportESP301Initialize": { + "default_code": "NewportESP301Initialize(receiver= '')", + "args": { + "receiver": { + "default": "NewportESP301", + "type": str, + "notes": "Name of the device", + } + }, + "obj": NewportESP301Initialize, + }, + "NewportESP301Deinitialize": { + "default_code": "NewportESP301Deinitialize(receiver= '')", + "args": { + "receiver": { + "default": "NewportESP301", + "type": str, + "notes": "Name of the device", + } + }, + "obj": NewportESP301Deinitialize, + }, + "NewportESP301MoveSpeedAbsolute": { + "default_code": "NewportESP301MoveSpeedAbsolute(receiver= '', axis_number= 1, position= 0, speed= 20.0)", + "args": { + "receiver": { + "default": "NewportESP301", + "type": str, + "notes": "Name of the device", + }, + "axis_number": { + "default": 1, + "type": int, + "notes": "Axis number", + }, + "position": { + "default": 0.0, + "type": float, + "notes": "Position to move to", + }, + "speed": { + "default": 20.0, + "type": float, + "notes": "Speed to move at", + }, + }, + "obj": NewportESP301MoveSpeedAbsolute, + }, + "NewportESP301MoveSpeedRelative": { + "default_code": "NewportESP301MoveSpeedRelative(receiver= '', axis_number= 1, distance= 0, speed= 20.0)", + "args": { + "receiver": { + "default": "NewportESP301", + "type": str, + "notes": "Name of the device", + }, + "axis_number": { + "default": 1, + "type": int, + "notes": "Axis number", + }, + "distance": { + "default": 0.0, + "type": float, + "notes": "Distance to move", + }, + "speed": { + "default": 20.0, + "type": float, + "notes": "Speed to move at", + }, + }, + "obj": NewportESP301MoveSpeedRelative, + }, + }, + }, + "Newport94043ASolarSim": { + "obj": Newport94043ASolarSim, + "serial": True, + "serial_sequence": ["Newport94043ASolarSimConnect", "Newport94043ASolarSimInitialize"], + "import_device": "from devices.newport_94043a_solar_sim import Newport94043ASolarSim", + "import_commands": "from commands.newport_94043a_solar_sim_commands import *", + "telemetry": { + "parameters": { + "watts": { + "function_name": "get_watts", + "data_type": "str", + "units": "W", + }, + "amps": { + "function_name": "get_amps", + "data_type": "str", + "units": "A", + }, + "volts": { + "function_name": "get_volts", + "data_type": "str", + "units": "V", + }, + }, + "options": {"custom_init_args": ["port"]}, + }, + "init": { + "default_code": "Newport94043ASolarSim(name='Newport94043ASolarSim', port='', baudrate=9600, timeout=1.0, line_terminator='\\r', lamp_hours_warning_threshold=1000.0, default_power_watts=400, max_power_watts=450)", + "obj_name": "Newport94043ASolarSim", + "args": { + "name": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + }, + "port": { + "default": "COM", + "type": str, + "notes": "COM port via RS-232 to USB adapter", + }, + "baudrate": { + "default": 9600, + "type": int, + "notes": "69920 RS-232 baudrate", + }, + "timeout": { + "default": 1.0, + "type": float, + "notes": "Serial timeout in seconds", + }, + "line_terminator": { + "default": "\r", + "type": str, + "notes": "Command terminator for RS-232", + }, + "lamp_hours_warning_threshold": { + "default": 1000.0, + "type": float, + "notes": "Warn during initialize when lamp hours exceed this threshold.", + }, + "default_power_watts": { + "default": 400, + "type": int, + "notes": "Default watts preset applied during initialize.", + }, + "max_power_watts": { + "default": 450, + "type": int, + "notes": "Safety ceiling for watts preset values.", + }, + }, + }, + "commands": { + "Newport94043ASolarSimConnect": { + "default_code": "Newport94043ASolarSimConnect(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimConnect, + }, + "Newport94043ASolarSimInitialize": { + "default_code": "Newport94043ASolarSimInitialize(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimInitialize, + }, + "Newport94043ASolarSimDeinitialize": { + "default_code": "Newport94043ASolarSimDeinitialize(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimDeinitialize, + }, + "Newport94043ASolarSimIdentify": { + "default_code": "Newport94043ASolarSimIdentify(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimIdentify, + }, + "Newport94043ASolarSimStatusByte": { + "default_code": "Newport94043ASolarSimStatusByte(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimStatusByte, + }, + "Newport94043ASolarSimEventStatus": { + "default_code": "Newport94043ASolarSimEventStatus(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimEventStatus, + }, + "Newport94043ASolarSimLampStart": { + "default_code": "Newport94043ASolarSimLampStart(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimLampStart, + }, + "Newport94043ASolarSimLampStop": { + "default_code": "Newport94043ASolarSimLampStop(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimLampStop, + }, + "Newport94043ASolarSimSetPowerMode": { + "default_code": "Newport94043ASolarSimSetPowerMode(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimSetPowerMode, + }, + "Newport94043ASolarSimGetAmps": { + "default_code": "Newport94043ASolarSimGetAmps(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimGetAmps, + }, + "Newport94043ASolarSimGetVolts": { + "default_code": "Newport94043ASolarSimGetVolts(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimGetVolts, + }, + "Newport94043ASolarSimGetWatts": { + "default_code": "Newport94043ASolarSimGetWatts(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimGetWatts, + }, + "Newport94043ASolarSimGetLampHours": { + "default_code": "Newport94043ASolarSimGetLampHours(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimGetLampHours, + }, + "Newport94043ASolarSimGetPowerPreset": { + "default_code": "Newport94043ASolarSimGetPowerPreset(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimGetPowerPreset, + }, + "Newport94043ASolarSimGetCurrentLimit": { + "default_code": "Newport94043ASolarSimGetCurrentLimit(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimGetCurrentLimit, + }, + "Newport94043ASolarSimGetPowerLimit": { + "default_code": "Newport94043ASolarSimGetPowerLimit(receiver= '')", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + } + }, + "obj": Newport94043ASolarSimGetPowerLimit, + }, + "Newport94043ASolarSimSetPowerPreset": { + "default_code": "Newport94043ASolarSimSetPowerPreset(receiver= '', watts= 400)", + "args": { + "receiver": { + "default": "Newport94043ASolarSim", + "type": str, + "notes": "Name of the device", + }, + "watts": { + "default": 400, + "type": int, + "notes": "Power preset in watts. Values above 450 W are rejected.", + }, + }, + "obj": Newport94043ASolarSimSetPowerPreset, + }, + }, + }, + "SciencetechUHENLSolarSim": { + "obj": SciencetechUHENLSolarSim, + "serial": True, + "serial_sequence": ["SciencetechUHENLSolarSimConnect", "SciencetechUHENLSolarSimInitialize"], + "import_device": "from devices.sciencetech_uhe_nl_solar_sim import SciencetechUHENLSolarSim", + "import_commands": "from commands.sciencetech_uhe_nl_solar_sim_commands import *", + "init": { + "default_code": "SciencetechUHENLSolarSim(name='SciencetechUHENLSolarSim', port='', baudrate=9600, timeout=2.0, line_terminator='\\r', connect_delay_s=3.0, command_delay_s=8.0, status_timeout_s=8.0, default_current_percent=85.0, default_attenuator_percent=100, debug_io=False)", + "obj_name": "SciencetechUHENLSolarSim", + "args": { + "name": { + "default": "SciencetechUHENLSolarSim", + "type": str, + "notes": "Name of the device", + }, + "port": { + "default": "COM", + "type": str, + "notes": "COM port via the UHE-NL RS-232 to USB cable", + }, + "baudrate": { + "default": 9600, + "type": int, + "notes": "Serial baudrate", + }, + "timeout": { + "default": 2.0, + "type": float, + "notes": "Serial timeout in seconds", + }, + "line_terminator": { + "default": "\r", + "type": str, + "notes": "Command line terminator", + }, + "connect_delay_s": { + "default": 3.0, + "type": float, + "notes": "Delay after opening the serial port", + }, + "command_delay_s": { + "default": 8.0, + "type": float, + "notes": "Delay after each control command before polling feedback", + }, + "status_timeout_s": { + "default": 8.0, + "type": float, + "notes": "Timeout while reading the multiline status response", + }, + "default_current_percent": { + "default": 85.0, + "type": float, + "notes": "Output percentage setpoint applied during initialize after cooling is confirmed on", + }, + "default_attenuator_percent": { + "default": 100, + "type": int, + "notes": "Attenuator transmission percentage applied during initialize", + }, + "debug_io": { + "default": False, + "type": bool, + "notes": "Print raw TX/RX serial traffic for debugging", + }, + }, + }, + "commands": { + "SciencetechUHENLSolarSimConnect": { + "default_code": "SciencetechUHENLSolarSimConnect(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimConnect, + }, + "SciencetechUHENLSolarSimInitialize": { + "default_code": "SciencetechUHENLSolarSimInitialize(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimInitialize, + }, + "SciencetechUHENLSolarSimDeinitialize": { + "default_code": "SciencetechUHENLSolarSimDeinitialize(receiver= '', reset_init_flag=True, close_serial=False)", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"}, + "reset_init_flag": {"default": True, "type": bool, "notes": "Reset initialized flag"}, + "close_serial": {"default": False, "type": bool, "notes": "Close the serial port"}, + }, + "obj": SciencetechUHENLSolarSimDeinitialize, + }, + "SciencetechUHENLSolarSimCloseShutter": { + "default_code": "SciencetechUHENLSolarSimCloseShutter(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimCloseShutter, + }, + "SciencetechUHENLSolarSimOpenShutter": { + "default_code": "SciencetechUHENLSolarSimOpenShutter(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimOpenShutter, + }, + "SciencetechUHENLSolarSimEnableCooling": { + "default_code": "SciencetechUHENLSolarSimEnableCooling(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimEnableCooling, + }, + "SciencetechUHENLSolarSimDisableCooling": { + "default_code": "SciencetechUHENLSolarSimDisableCooling(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimDisableCooling, + }, + "SciencetechUHENLSolarSimEnableArcLamp": { + "default_code": "SciencetechUHENLSolarSimEnableArcLamp(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimEnableArcLamp, + }, + "SciencetechUHENLSolarSimDisableArcLamp": { + "default_code": "SciencetechUHENLSolarSimDisableArcLamp(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimDisableArcLamp, + }, + "SciencetechUHENLSolarSimOpenAttenuator": { + "default_code": "SciencetechUHENLSolarSimOpenAttenuator(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimOpenAttenuator, + }, + "SciencetechUHENLSolarSimSetAttenuator": { + "default_code": "SciencetechUHENLSolarSimSetAttenuator(receiver= '', percent=100)", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"}, + "percent": {"default": 100, "type": int, "notes": "Attenuator transmission percentage"}, + }, + "obj": SciencetechUHENLSolarSimSetAttenuator, + }, + "SciencetechUHENLSolarSimSetCurrent": { + "default_code": "SciencetechUHENLSolarSimSetCurrent(receiver= '', percent=85.0)", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"}, + "percent": {"default": 85.0, "type": float, "notes": "Lamp output percentage setpoint"}, + }, + "obj": SciencetechUHENLSolarSimSetCurrent, + }, + "SciencetechUHENLSolarSimGetStatus": { + "default_code": "SciencetechUHENLSolarSimGetStatus(receiver= '')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"} + }, + "obj": SciencetechUHENLSolarSimGetStatus, + }, + "SciencetechUHENLSolarSimGetFeedback": { + "default_code": "SciencetechUHENLSolarSimGetFeedback(receiver= '', feedback_type='lamp')", + "args": { + "receiver": {"default": "SciencetechUHENLSolarSim", "type": str, "notes": "Name of the device"}, + "feedback_type": {"default": "lamp", "type": str, "notes": "Feedback key such as lamp, cool, shutter, attenuator, output, current, voltage, power, hours"}, + }, + "obj": SciencetechUHENLSolarSimGetFeedback, + }, + }, + }, + "StellarNetSpectrometer": { + "obj": StellarNetSpectrometer, + "serial": False, + "serial_sequence": ["SpectrometerInitialize"], + "import_device": "from devices.stellarnet_spectrometer import StellarNetSpectrometer", + "import_commands": "from commands.stellarnet_spectrometer_commands import *", + "init": { + "default_code": "StellarNetSpectrometer(name='StellarNetSpectrometer', spec_keys=['UV-Vis'], save_directory='data/spectroscopy/', default_integration_time=(100,))", + "obj_name": "StellarNetSpectrometer", + "args": { + "name": { + "default": "StellarNetSpectrometer", + "type": str, + "notes": "Name of the device", + }, + "spec_keys": { + "default": ["UV-Vis"], + "type": list, + "notes": "Declared spectrometer keys. Common values are ['UV-Vis'] or ['UV-Vis', 'NIR'].", + }, + "save_directory": { + "default": "data/spectroscopy/", + "type": str, + "notes": "Directory used when absorbance CSV files are saved.", + }, + "default_integration_time": { + "default": (100,), + "type": tuple, + "notes": "Default integration time in ms. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order.", + }, + }, + }, + "commands": { + "SpectrometerInitialize": { + "default_code": "SpectrometerInitialize(receiver= '')", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"} + }, + "obj": SpectrometerInitialize, + }, + "SpectrometerDeinitialize": { + "default_code": "SpectrometerDeinitialize(receiver= '', reset_init_flag=True)", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"}, + "reset_init_flag": {"default": True, "type": bool, "notes": "Reset the initialized flag."}, + }, + "obj": SpectrometerDeinitialize, + }, + "SpectrometerUpdateDark": { + "default_code": "SpectrometerUpdateDark(receiver= '', integration_times=(100,), scans_to_avg=(3,), smoothings=(0,), xtimings=(1,))", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"}, + "integration_times": {"default": (100,), "type": tuple, "notes": "Integration times in ms. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "scans_to_avg": {"default": (3,), "type": tuple, "notes": "Scans-to-average values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "smoothings": {"default": (0,), "type": tuple, "notes": "Smoothing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "xtimings": {"default": (1,), "type": tuple, "notes": "X timing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + }, + "obj": SpectrometerUpdateDark, + }, + "SpectrometerUpdateBlank": { + "default_code": "SpectrometerUpdateBlank(receiver= '', integration_times=(100,), scans_to_avg=(3,), smoothings=(0,), xtimings=(1,))", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"}, + "integration_times": {"default": (100,), "type": tuple, "notes": "Integration times in ms. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "scans_to_avg": {"default": (3,), "type": tuple, "notes": "Scans-to-average values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "smoothings": {"default": (0,), "type": tuple, "notes": "Smoothing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "xtimings": {"default": (1,), "type": tuple, "notes": "X timing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + }, + "obj": SpectrometerUpdateBlank, + }, + "SpectrometerAdjDefIntegrationTime": { + "default_code": "SpectrometerAdjDefIntegrationTime(receiver= '', scans_to_avg=(3,), smoothings=(0,), xtimings=(1,), target_max_count=52000, tolerance=2000)", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"}, + "scans_to_avg": {"default": (3,), "type": tuple, "notes": "Scans-to-average values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "smoothings": {"default": (0,), "type": tuple, "notes": "Smoothing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "xtimings": {"default": (1,), "type": tuple, "notes": "X timing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "target_max_count": {"default": 52000, "type": int, "notes": "Target detector max count used to tune the default integration time."}, + "tolerance": {"default": 2000, "type": int, "notes": "Allowed deviation from the target max count."}, + }, + "obj": SpectrometerAdjDefIntegrationTime, + }, + "SpectrometerGetAbsorbance": { + "default_code": "SpectrometerGetAbsorbance(receiver= '', save_to_file=True, filename=None, integration_times=(100,), scans_to_avg=(3,), smoothings=(0,), xtimings=(1,))", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"}, + "save_to_file": {"default": True, "type": bool, "notes": "Save absorbance CSV output to the spectrometer save directory."}, + "filename": {"default": None, "type": str, "notes": "Optional output filename prefix. Uses a timestamp when omitted."}, + "integration_times": {"default": (100,), "type": tuple, "notes": "Integration times in ms. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "scans_to_avg": {"default": (3,), "type": tuple, "notes": "Scans-to-average values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "smoothings": {"default": (0,), "type": tuple, "notes": "Smoothing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "xtimings": {"default": (1,), "type": tuple, "notes": "X timing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + }, + "obj": SpectrometerGetAbsorbance, + }, + "SpectrometerGetAbsorbancebyname": { + "default_code": "SpectrometerGetAbsorbancebyname(receiver= '', sample_name='sample', save_to_file=True, repeat_measure=False, integration_times=None, scans_to_avg=(3,), smoothings=(0,), xtimings=(1,), absorbance_threshold=0.003)", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"}, + "sample_name": {"default": "sample", "type": str, "notes": "Sample name used to build absorbance filenames."}, + "save_to_file": {"default": True, "type": bool, "notes": "Save absorbance CSV output to the spectrometer save directory."}, + "repeat_measure": {"default": False, "type": bool, "notes": "Retained for compatibility with the older workflow."}, + "integration_times": {"default": None, "type": tuple, "notes": "Optional integration times in ms. Uses the device default when omitted; otherwise a single value applies to all selected spectrometers or tuple/list values follow spec_keys order."}, + "scans_to_avg": {"default": (3,), "type": tuple, "notes": "Scans-to-average values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "smoothings": {"default": (0,), "type": tuple, "notes": "Smoothing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "xtimings": {"default": (1,), "type": tuple, "notes": "X timing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "absorbance_threshold": {"default": 0.003, "type": float, "notes": "Compatibility placeholder for the older repeat-measure workflow."}, + }, + "obj": SpectrometerGetAbsorbancebyname, + }, + "SpectrometerGetPhotoncountsbyname": { + "default_code": "SpectrometerGetPhotoncountsbyname(receiver= '', sample_name='sample', save_to_file=True, repeat_measure=False, integration_times=None, scans_to_avg=(3,), smoothings=(0,), xtimings=(1,), absorbance_threshold=0.003)", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"}, + "sample_name": {"default": "sample", "type": str, "notes": "Sample name used to build photon count filenames."}, + "save_to_file": {"default": True, "type": bool, "notes": "Save photon count CSV output to the spectrometer save directory."}, + "repeat_measure": {"default": False, "type": bool, "notes": "Retained for compatibility with the older workflow."}, + "integration_times": {"default": None, "type": tuple, "notes": "Optional integration times in ms. Uses the device default when omitted; otherwise a single value applies to all selected spectrometers or tuple/list values follow spec_keys order."}, + "scans_to_avg": {"default": (3,), "type": tuple, "notes": "Scans-to-average values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "smoothings": {"default": (0,), "type": tuple, "notes": "Smoothing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "xtimings": {"default": (1,), "type": tuple, "notes": "X timing values. A single value applies to all selected spectrometers; tuple/list values follow spec_keys order."}, + "absorbance_threshold": {"default": 0.003, "type": float, "notes": "Compatibility placeholder for the older repeat-measure workflow."}, + }, + "obj": SpectrometerGetPhotoncountsbyname, + }, + "SpectrometerGetSpecDecay": { + "default_code": "SpectrometerGetSpecDecay(receiver= '', sample_name='sample', save_to_file=True, range_start=290.0, range_end=800.0, irradiance_file='reference/am15g_spectrum.csv', Wvlgth_col_name='wavelength_nm', Irrad_col_name='irradiance_w_m2_nm', decay_threshold=0.01)", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"}, + "sample_name": {"default": "sample", "type": str, "notes": "Sample name used to find the saved merged absorbance file."}, + "save_to_file": {"default": True, "type": bool, "notes": "Save the spectral decay CSV to the spectrometer save directory."}, + "range_start": {"default": 290.0, "type": float, "notes": "Start wavelength for spectral decay calculation."}, + "range_end": {"default": 800.0, "type": float, "notes": "End wavelength for spectral decay calculation."}, + "irradiance_file": {"default": "reference/am15g_spectrum.csv", "type": str, "notes": "Reference irradiance CSV stored relative to the spectrometer save directory."}, + "Wvlgth_col_name": {"default": "wavelength_nm", "type": str, "notes": "Irradiance-table wavelength column name."}, + "Irrad_col_name": {"default": "irradiance_w_m2_nm", "type": str, "notes": "Irradiance-table irradiance column name."}, + "decay_threshold": {"default": 0.01, "type": float, "notes": "Reference absorbance threshold used when computing decay indices."}, + }, + "obj": SpectrometerGetSpecDecay, + }, + "SpectrometerPlotSpecDecaySummary": { + "default_code": "SpectrometerPlotSpecDecaySummary(receiver= '', sample_name='sample', range_start=290.0, range_end=800.0, save_to_file=True, output_filename=None, figure_dpi=180)", + "args": { + "receiver": {"default": "StellarNetSpectrometer", "type": str, "notes": "Name of the device"}, + "sample_name": {"default": "sample", "type": str, "notes": "Sample name used to find the saved specdecay and QC files."}, + "range_start": {"default": 290.0, "type": float, "notes": "Start wavelength used for the absorbance snapshot panel."}, + "range_end": {"default": 800.0, "type": float, "notes": "End wavelength used for the absorbance snapshot panel."}, + "save_to_file": {"default": True, "type": bool, "notes": "Save the summary figure as PNG in the spectrometer save directory."}, + "output_filename": {"default": None, "type": str, "notes": "Optional output PNG filename. Relative paths are resolved against the spectrometer save directory."}, + "figure_dpi": {"default": 180, "type": int, "notes": "PNG export resolution."}, + }, + "obj": SpectrometerPlotSpecDecaySummary, + }, + }, + }, + "DummyMotor": { + "obj": DummyMotor, + "default_obj": DummyMotor(name="DummyMotor", speed=20.0), + "serial": True, + "serial_sequence": ["DummyMotorInitialize"], + "import_device": "from devices.dummy_motor import DummyMotor", + "import_commands": "from commands.dummy_motor_commands import *", + "telemetry": { + "parameters": { + "position": { + "function_name": "get_position", + "data_type": "float", + "units": "mm", + }, + "speed": { + "function_name": "get_speed", + "data_type": "float", + "units": "mm/s", + }, + }, + "options": {"custom_init_args": []}, + }, + "init": { + "default_code": "DummyMotor(name='DummyMotor', speed=20.0)", + "obj_name": "DummyMotor", + "args": { + "name": { + "default": "DummyMotor", + "type": str, + "notes": "Name of the device.", + }, + "speed": { + "default": 20.0, + "type": float, + "notes": "Speed of the motor.", + }, + }, + }, + "commands": { + "DummyMotorInitialize": { + "default_code": "DummyMotorInitialize(receiver= '')", + "args": { + "receiver": { + "default": "DummyMotor", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": DummyMotorInitialize, + }, + "DummyMotorDeinitialize": { + "default_code": "DummyMotorDeinitialize(receiver= '')", + "args": { + "receiver": { + "default": "DummyMotor", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": DummyMotorDeinitialize, + }, + "DummyMotorSetSpeed": { + "default_code": "DummyMotorSetSpeed(receiver= '', speed= 0.0)", + "args": { + "receiver": { + "default": "DummyMotor", + "type": str, + "notes": "Name of the device.", + }, + "speed": { + "default": 0.0, + "type": float, + "notes": "Speed of the motor.", + }, + }, + "obj": DummyMotorSetSpeed, + }, + "DummyMotorMoveAbsolute": { + "default_code": "DummyMotorMoveAbsolute(receiver= '', position= 0)", + "args": { + "receiver": { + "default": "DummyMotor", + "type": str, + "notes": "Name of the device.", + }, + "position": { + "default": 0, + "type": float, + "notes": "Position to move to.", + }, + }, + "obj": DummyMotorMoveAbsolute, + }, + "DummyMotorMoveRelative": { + "default_code": "DummyMotorMoveRelative(receiver= '', distance= 0)", + "args": { + "receiver": { + "default": "DummyMotor", + "type": str, + "notes": "Name of the device.", + }, + "distance": { + "default": 0, + "type": float, + "notes": "Distance to move.", + }, + }, + "obj": DummyMotorMoveRelative, + }, + "DummyMotorMoveSpeedAbsolute": { + "default_code": "DummyMotorMoveSpeedAbsolute(receiver= '', position= 0.0, speed= 0.0)", + "args": { + "receiver": { + "default": "DummyMotor", + "type": str, + "notes": "Name of the device.", + }, + "position": { + "default": 0.0, + "type": float, + "notes": "Position to move to.", + }, + "speed": { + "default": 0.0, + "type": float, + "notes": "Speed of the motor.", + }, + }, + "obj": DummyMotorMoveSpeedAbsolute, + }, + }, + }, + # "Spectrometer": {"obj": StellarNetSpectrometer}, + "DummyHeater": { + "obj": DummyHeater, + "serial": True, + "serial_sequence": ["DummyHeaterInitialize"], + "import_device": "from devices.dummy_heater import DummyHeater", + "import_commands": "from commands.dummy_heater_commands import *", + "init": { + "default_code": "DummyHeater(name='DummyHeater', heat_rate=20.0)", + "obj_name": "DummyHeater", + "args": { + "name": { + "default": "DummyHeater", + "type": str, + "notes": "Name of the device.", + }, + "heat_rate": { + "default": 20.0, + "type": float, + "notes": "Heat rate of the heater.", + }, + }, + }, + "commands": { + "DummyHeaterInitialize": { + "default_code": "DummyHeaterInitialize(receiver= '')", + "args": { + "receiver": { + "default": "DummyHeater", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": DummyHeaterInitialize, + }, + "DummyHeaterDeinitialize": { + "default_code": "DummyHeaterDeinitialize(receiver= '')", + "args": { + "receiver": { + "default": "DummyHeater", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": DummyHeaterDeinitialize, + }, + "DummyHeaterSetHeatRate": { + "default_code": "DummyHeaterSetHeatRate(receiver= '', heat_rate= 0.0)", + "args": { + "receiver": { + "default": "DummyHeater", + "type": str, + "notes": "Name of the device.", + }, + "heat_rate": { + "default": 0.0, + "type": float, + "notes": "Heat rate of the heater.", + }, + }, + "obj": DummyHeaterSetHeatRate, + }, + "DummyHeaterSetTemp": { + "default_code": "DummyHeaterSetTemp(receiver= '', temperature= 0.0)", + "args": { + "receiver": { + "default": "DummyHeater", + "type": str, + "notes": "Name of the device.", + }, + "temperature": { + "default": 0.0, + "type": float, + "notes": "Temperature to set the heater to.", + }, + }, + "obj": DummyHeaterSetTemp, + }, + }, + }, + "PSD6SyringePump": { + "obj": PSD6SyringePump, + "serial": True, + "serial_sequence": ["PSD6SyringePumpInitialize"], + "import_device": "from devices.psd6_syringe_pump import PSD6SyringePump", + "import_commands": "from commands.psd6_syringe_pump_commands import *", + "init": { + "default_code": "PSD6SyringePump(name='PSD6SyringePump', port='COM5', baudrate=9600, timeout=10.0)", + "obj_name": "PSD6SyringePump", + "args": { + "name": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "port": { + "default": "COM5", + "type": str, + "notes": "Port", + }, + "baudrate": { + "default": 9600, + "type": int, + "notes": "Baudrate", + }, + "timeout": { + "default": 10.0, + "type": float, + "notes": "Timeout", + }, + }, + }, + "commands": { + "PSD6SyringePumpConnect": { + "default_code": "PSD6SyringePumpConnect(receiver= '')", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": PSD6SyringePumpConnect, + }, + "PSD6SyringePumpInitialize": { + "default_code": "PSD6SyringePumpInitialize(receiver= '')", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": PSD6SyringePumpInitialize, + }, + "PSD6SyringePumpMoveValve": { + "default_code": "PSD6SyringePumpMoveValve(receiver= '', valve_num=0)", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "valve_num": { + "default": 0, + "type": int, + "notes": "Valve number.", + }, + }, + "obj": PSD6SyringePumpMoveValve, + }, + "PSD6SyringePumpMoveAbsolute": { + "default_code": "PSD6SyringePumpMoveAbsolute(receiver= '', volume=0.0, valve_num= 0, flowrate=0.0)", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "volume": { + "default": 0.0, + "type": float, + "notes": "Volume.", + }, + "valve_num": { + "default": 0, + "type": int, + "notes": "Valve number.", + }, + "flowrate": { + "default": 0.0, + "type": float, + "notes": "Flowrate.", + }, + }, + "obj": PSD6SyringePumpMoveAbsolute, + }, + "PSD6SyringePumpInfuse": { + "default_code": "PSD6SyringePumpInfuse(receiver= '', volume=0.0, valve_num= 0, flowrate=0.0)", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "volume": { + "default": 0.0, + "type": float, + "notes": "Volume.", + }, + "valve_num": { + "default": 0, + "type": int, + "notes": "Valve number.", + }, + "flowrate": { + "default": 0.0, + "type": float, + "notes": "Flowrate.", + }, + }, + "obj": PSD6SyringePumpInfuse, + }, + "PSD6SyringePumpWithdraw": { + "default_code": "PSD6SyringePumpWithdraw(receiver= '', volume=0.0, valve_num= 0, flowrate=0.0)", + "args": { + "receiver": { + "default": "PSD6SyringePump", + "type": str, + "notes": "Name of the device.", + }, + "volume": { + "default": 0.0, + "type": float, + "notes": "Volume.", + }, + "valve_num": { + "default": 0, + "type": int, + "notes": "Valve number.", + }, + "flowrate": { + "default": 0.0, + "type": float, + "notes": "Flowrate.", + }, + }, + "obj": PSD6SyringePumpWithdraw, + }, + }, + }, + "XimeaCamera": { + "obj": XimeaCamera, + "serial": True, + "serial_sequence": ["XimeaCameraInitialize"], + "import_device": "from devices.ximea_camera import XimeaCamera", + "import_commands": "from commands.ximea_camera_commands import *", + "init": { + "default_code": "XimeaCamera(name='XimeaCamera')", + "obj_name": "XimeaCamera", + "args": { + "name": { + "default": "XimeaCamera", + "type": str, + "notes": "Name of the device.", + }, + }, + }, + "commands": { + "XimeaCameraInitialize": { + "default_code": "XimeaCameraInitialize(receiver= '')", + "args": { + "receiver": { + "default": "XimeaCamera", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": XimeaCameraInitialize, + }, + "XimeaCameraDeinitialize": { + "default_code": "XimeaCameraDeinitialize(receiver= '', reset_init_flag=True)", + "args": { + "receiver": { + "default": "XimeaCamera", + "type": str, + "notes": "Name of the device.", + }, + "reset_init_flag": { + "default": True, + "type": str, + "notes": "Reset init flag.", + } + }, + "obj": XimeaCameraDeinitialize, + }, + "XimeaCameraGetImage": { + "default_code": "XimeaCameraGetImage(receiver= '', save_to_file=True, filename=None, exposure_time=None, gain=None, show_pop_up=False)", + "args": { + "receiver": { + "default": "XimeaCamera", + "type": str, + "notes": "Name of the device.", + }, + "save_to_file": { + "default": True, + "type": bool, + "notes": "Whether the image should be saved to file or not.", + }, + "filename": { + "default": None, + "type": str, + "notes": "Filename to save the image to.", + }, + "exposure_time": { + "default": None, + "type": int, + "notes": "Exposure time of the camera.", + }, + "gain": { + "default": None, + "type": float, + "notes": "Gain of the camera.", + }, + "show_pop_up": { + "default": False, + "type": bool, + "notes": "Whether to show a pop up of the image.", + }, + }, + "obj": XimeaCameraGetImage, + }, + "XimeaCameraSetDefaultExposure": { + "default_code": "XimeaCameraSetDefaultExposure(receiver= '', exposure_time=None)", + "args": { + "receiver": { + "default": "XimeaCamera", + "type": str, + "notes": "Name of the device.", + }, + "exposure_time": { + "default": None, + "type": int, + "notes": "Updated exposure time.", + }, + }, + "obj": XimeaCameraSetDefaultExposure, + }, + "XimeaCameraSetDefaultGain": { + "default_code": "XimeaCameraSetDefaultGain(receiver= '', gain=None)", + "args": { + "receiver": { + "default": "XimeaCamera", + "type": str, + "notes": "Name of the device.", + }, + "gain": { + "default": None, + "type": float, + "notes": "Updated gain.", + }, + }, + "obj": XimeaCameraSetDefaultGain, + }, + "XimeaCameraUpdateWhiteBal": { + "default_code": "XimeaCameraUpdateWhiteBal(receiver= '', exposure_time=None, gain=None)", + "args": { + "receiver": { + "default": "XimeaCamera", + "type": str, + "notes": "Name of the device.", + }, + "exposure_time": { + "default": None, + "type": int, + "notes": "Updated exposure time.", + }, + "gain": { + "default": None, + "type": float, + "notes": "Updated gain.", + }, + }, + "obj": XimeaCameraUpdateWhiteBal, + }, + "XimeaCameraSetManualWhiteBal": { + "default_code": "XimeaCameraSetManualWhiteBal(receiver= '', wb_kr=None, wb_kg=None, wb_kb=None)", + "args": { + "receiver": { + "default": "XimeaCamera", + "type": str, + "notes": "Name of the device.", + }, + "wb_kr": { + "default": None, + "type": float, + "notes": "White balance red.", + }, + "wb_kg": { + "default": None, + "type": float, + "notes": "White balance green.", + }, + "wb_kb": { + "default": None, + "type": float, + "notes": "White balance blue.", + }, + }, + "obj": XimeaCameraSetManualWhiteBal, + }, + "XimeaCameraResetWhiteBal": { + "default_code": "XimeaCameraResetWhiteBal(receiver= '')", + "args": { + "receiver": { + "default": "XimeaCamera", + "type": str, + "notes": "Name of the device.", + } + }, + "obj": XimeaCameraResetWhiteBal, + }, + }, + }, +} + + +# devices_ref = { +# "PrintingStage": heating_stage_ref, +# "AnnealingStage": heating_stage_ref, +# "MultiStepper": { +# "obj": MultiStepper, +# "import_device": "from devices.multi_stepper import MultiStepper", +# "import_commands": "from commands.multi_stepper_commands import *", +# "init": "MultiStepper(name='MultiStepper', port='', baudrate=115200, timeout=0.1, destination=0x50, source=0x01, channel=1)", +# "commands": { +# "MultiStepperConnect": "MultiStepperConnect(receiver= '')", +# "MultiStepperInitialize": "MultiStepperInitialize(receiver= '')", +# "MultiStepperDeinitialize": "MultiStepperDeinitialize(receiver= '')", +# "MultiStepperMoveAbsolute": "MultiStepperMoveAbsolute(receiver= '', stepper_number= 0, position= 0)", +# "MultiStepperMoveRelative": "MultiStepperMoveRelative(receiver= '', stepper_number= 0, distance= 0)", +# }, +# }, +# "PrinterMotorX": {"obj": NewportESP301}, +# # "Spectrometer": {"obj": StellarNetSpectrometer}, +# "XimeaCamera": {"obj": XimeaCamera}, +# "DummyHeater": {"obj": DummyHeater}, +# "DummyMotor": {"obj": DummyMotor}, +# "LinearStage150": { +# "obj": LinearStage150, +# "import_device": "from devices.linear_stage_150 import LinearStage150", +# "import_commands": "from commands.linear_stage_150_commands import *", +# "init": "LinearStage150(name='LinearStage150', port='', baudrate=115200, timeout=0.1, destination=0x50, source=0x01, channel=1)", +# "commands": { +# "LinearStage150Connect": "LinearStage150Connect(receiver= '')", +# "LinearStage150Initialize": "LinearStage150Initialize(receiver= '')", +# "LinearStage150Deinitialize": "LinearStage150Deinitialize(receiver= '')", +# "LinearStage150EnableMotor": "LinearStage150EnableMotor(receiver= '')", +# "LinearStage150DisableMotor": "LinearStage150DisableMotor(receiver= '')", +# "LinearStage150MoveAbsolute": "LinearStage150MoveAbsolute(receiver= '', position= 0)", +# "LinearStage150MoveRelative": "LinearStage150MoveRelative(receiver= '', distance= 0)", +# }, +# }, +# } diff --git a/commands/stellarnet_spectrometer_commands.py b/commands/stellarnet_spectrometer_commands.py deleted file mode 100644 index 2355b65..0000000 --- a/commands/stellarnet_spectrometer_commands.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import Tuple, Optional - -from .command import Command, CommandResult -from devices.stellarnet_spectrometer import StellarNetSpectrometer - -class SpectrometerParentCommand(Command): - """Parent class for all StellarNet Spectrometer commands.""" - receiver_cls = StellarNetSpectrometer - - def __init__(self, receiver: StellarNetSpectrometer, **kwargs): - super().__init__(receiver, **kwargs) - -class SpectrometerInitialize(SpectrometerParentCommand): - """Initialize spectrometer by verifying connection and getting each spectrometer object and wavelength array.""" - - def __init__(self, receiver: StellarNetSpectrometer, **kwargs): - super().__init__(receiver, **kwargs) - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.initialize()) - -class SpectrometerDeinitialize(SpectrometerParentCommand): - """Deinitialize the spectrometer, currently does nothing except optionally change init flag.""" - - def __init__(self, receiver: StellarNetSpectrometer, reset_init_flag: bool = True, **kwargs): - super().__init__(receiver, **kwargs) - self._params['reset_init_flag'] = reset_init_flag - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.deinitialize(self._params['reset_init_flag'])) - -class SpectrometerUpdateDark(SpectrometerParentCommand): - """Update the stored dark spectra for all spectrometers.""" - - def __init__( - self, - receiver: StellarNetSpectrometer, - integration_times: Tuple[int, ...] = (100, 100), - scans_to_avg: Tuple[int, ...] = (3, 3), - smoothings: Tuple[int, ...] = (0, 0), - **kwargs): - super().__init__(receiver, **kwargs) - self._params['integration_times'] = integration_times - self._params['scans_to_avg'] = scans_to_avg - self._params['smoothings'] = smoothings - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.update_all_dark_spectra(self._params['integration_times'], self._params['scans_to_avg'], self._params['smoothings'])) - -class SpectrometerUpdateBlank(SpectrometerParentCommand): - """Update the stored blank spectra for all spectrometers.""" - - def __init__( - self, - receiver: StellarNetSpectrometer, - integration_times: Tuple[int, ...] = (100, 100), - scans_to_avg: Tuple[int, ...] = (3, 3), - smoothings: Tuple[int, ...] = (0, 0), - **kwargs): - super().__init__(receiver, **kwargs) - self._params['integration_times'] = integration_times - self._params['scans_to_avg'] = scans_to_avg - self._params['smoothings'] = smoothings - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.update_all_blank_spectra(self._params['integration_times'], self._params['scans_to_avg'], self._params['smoothings'])) - -class SpectrometerGetAbsorbance(SpectrometerParentCommand): - """Calculate and update the stored absorbance spectra, merge spectra, and optionally save to file. No filename = timestamped filename.""" - - def __init__( - self, - receiver: StellarNetSpectrometer, - save_to_file: bool = True, - filename: Optional[str] = None, - integration_times: Tuple[int, ...] = (100, 100), - scans_to_avg: Tuple[int, ...] = (3, 3), - smoothings: Tuple[int, ...] = (0, 0), - **kwargs): - super().__init__(receiver, **kwargs) - self._params['save_to_file'] = save_to_file - self._params['filename'] = filename - self._params['integration_times'] = integration_times - self._params['scans_to_avg'] = scans_to_avg - self._params['smoothings'] = smoothings - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.get_all_absorbance(self._params['save_to_file'], self._params['filename'], self._params['integration_times'], self._params['scans_to_avg'], self._params['smoothings'])) - -class SpectrometerShutterIn(SpectrometerParentCommand): - pass - -class SpectrometerShutterOut(SpectrometerParentCommand): - pass - -class SpectrometerLampOn(SpectrometerParentCommand): - pass - -class SpectrometerLampOff(SpectrometerParentCommand): - pass \ No newline at end of file diff --git a/commands/ximea_camera_commands.py b/commands/ximea_camera_commands.py deleted file mode 100644 index 02e92f6..0000000 --- a/commands/ximea_camera_commands.py +++ /dev/null @@ -1,115 +0,0 @@ -from typing import Optional - -from .command import Command, CommandResult -from devices.ximea_camera import XimeaCamera - - -class XimeaCameraParentCommand(Command): - """Parent class for all XimeaCamera commands.""" - receiver_cls = XimeaCamera - - def __init__(self, receiver: XimeaCamera, **kwargs): - super().__init__(receiver, **kwargs) - -class XimeaCameraInitialize(XimeaCameraParentCommand): - """Initialize camera by opening communication with camera and optionally set defaults.""" - - def __init__(self, receiver: XimeaCamera, **kwargs): - super().__init__(receiver, **kwargs) - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.initialize()) - -class XimeaCameraDeinitialize(XimeaCameraParentCommand): - """Deinitialize camera by stopping any acquisition and closing communication with camera.""" - - def __init__(self, receiver: XimeaCamera, reset_init_flag: bool = True, **kwargs): - super().__init__(receiver, **kwargs) - self._params['reset_init_flag'] = reset_init_flag - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.deinitialize(self._params['reset_init_flag'])) - -class XimeaCameraGetImage(XimeaCameraParentCommand): - """Get image and save to file, display image, or both. No filename = timestamped filename. No exposure or gain = use defaults.""" - - def __init__( - self, - receiver: XimeaCamera, - save_to_file: bool = True, - filename: str = None, - exposure_time: Optional[int] = None, - gain: Optional[float] = None, - show_pop_up: bool = False, - **kwargs): - super().__init__(receiver, **kwargs) - self._params['save_to_file'] = save_to_file - self._params['filename'] = filename - self._params['exposure_time'] = exposure_time - self._params['gain'] = gain - self._params['show_pop_up'] = show_pop_up - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.get_image(self._params['save_to_file'], self._params['filename'], self._params['exposure_time'], self._params['gain'], self._params['show_pop_up'])) - -class XimeaCameraSetDefaultExposure(XimeaCameraParentCommand): - """Set the default exposure time to use if no exposure time is passed when getting image.""" - - def __init__(self, receiver: XimeaCamera, exposure_time: int, **kwargs): - super().__init__(receiver, **kwargs) - self._params['exposure_time'] = exposure_time - - def execute(self) -> None: - self._receiver.default_exposure_time = self._params['exposure_time'] - if self._receiver.default_exposure_time == self._params['exposure_time']: - self._result = CommandResult(True, "Default exposure time successfully set to " + str(self._params['exposure_time'])) - else: - self._result = CommandResult(False, "Failed to set the default exposure time to " + str(self._params['exposure_time']) + ". The default exposure time must be > 0.") - -class XimeaCameraSetDefaultGain(XimeaCameraParentCommand): - """Set the default gain to use if no gain is passed when getting image.""" - - def __init__(self, receiver: XimeaCamera, gain: float, **kwargs): - super().__init__(receiver, **kwargs) - self._params['gain'] = gain - - def execute(self) -> None: - self._receiver.default_gain = self._params['gain'] - if self._receiver.default_gain == self._params['gain']: - self._result = CommandResult(True, "Default gain successfully set to " + str(self._params['gain'])) - else: - # unsure what the upper limit is at the moment - self._result = CommandResult(False, "Failed to set the default gain to " + str(self._params['gain']) + ". The default gain must be >= 0 and has an upper limit.") - -class XimeaCameraUpdateWhiteBal(XimeaCameraParentCommand): - """Update the white balance coefficients using the current camera feed.""" - - def __init__(self, receiver: XimeaCamera, exposure_time: int = None, gain: Optional[float] = None, **kwargs): - super().__init__(receiver, **kwargs) - self._params['exposure_time'] = exposure_time - self._params['gain'] = gain - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.update_white_balance(self._params['exposure_time'], self._params['gain'])) - -class XimeaCameraSetManualWhiteBal(XimeaCameraParentCommand): - """Set any of the red, green, and blue white balance coefficients manually.""" - - def __init__(self, receiver: XimeaCamera, wb_kr: Optional[float] = None, wb_kg: Optional[float] = None, wb_kb: Optional[float] = None, **kwargs): - super().__init__(receiver, **kwargs) - self._params['wb_kr'] = wb_kr - self._params['wb_kg'] = wb_kg - self._params['wb_kb'] = wb_kb - - def execute(self) -> None: - self._result = CommandResult(self._receiver.set_white_balance_manually(self._params['wb_kr'], self._params['wb_kg'], self._params['wb_kb'])) - -class XimeaCameraResetWhiteBal(XimeaCameraParentCommand): - """Reset the red, green, and blue white balance coefficients to defaults.""" - - def __init__(self, receiver: XimeaCamera, **kwargs): - super().__init__(receiver, **kwargs) - - def execute(self) -> None: - self._result = CommandResult(*self._receiver.reset_white_balance_rgb_coeffs()) - diff --git a/db/gridfs_local.py b/db/gridfs_local.py new file mode 100644 index 0000000..cdaed04 --- /dev/null +++ b/db/gridfs_local.py @@ -0,0 +1,41 @@ +from mongodb_helper import MongoDBHelper +from gridfs import GridFS +from bson import ObjectId +import os + +def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + +load_env() + +mongo_uri = os.environ.get("MONGO_URI") +mongo_db_name = os.environ.get("MONGO_DB_NAME") + +mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, +) + +db = mongo.db + +fs = GridFS(db, collection="recipes") + +# Open and store the CSV file using GridFS +with open("data.csv", "rb") as file: + file_id = fs.put(file, filename="data.csv") + # create ObjectId(file_id) in document to point to csv + +# Retrieve the CSV file from GridFS +gridfs_file = fs.find_one({"_id": ObjectId("64c2d215255f257ee66d30e9")}) + +# Read the CSV data from the file +csv_data = gridfs_file.read() + +# Print the CSV data +print(csv_data) +# print(csv_data.decode()) diff --git a/db/validation/devices.py b/db/validation/devices.py new file mode 100644 index 0000000..b204603 --- /dev/null +++ b/db/validation/devices.py @@ -0,0 +1,402 @@ +from mongodb_helper import MongoDBHelper +from pymongo.errors import CollectionInvalid +import os + +def init_collection(): + def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + load_env() + + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + + mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, + ) + + db = mongo.db + + device_dict = { + "device": { + "electrode_etl": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + "etl": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + "active_layer": { + "donor": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + "acceptor": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + }, + "htl": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + "electrode_htl": { + "material": { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + "additive": { + "molecule": "string", + "concentration": "float", + }, + } + } + }, + }, + "result": { + "jv_curve": ["object_id", "null"], + "t80": ["float", "null"], + }, + } + + device_dict_schema = { + "bsonType": "object", + "title": "Device Object Validation", + "required": ["device", "result"], + "properties": { + "device": { + "bsonType": "object", + "title": "Device Validation", + "required": ["electrode_etl", "active_layer", "electrode_htl"], + "properties": { + "electrode_etl": { + "bsonType": "object", + "title": "Electrode ETL Validation", + "required": ["material"], + "properties": { + "material": { + "bsonType": "object", + "title": "Material Validation", + "required": ["metadata"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Validation", + "required": [ + "solvent", + "concentration", + "printing_speed", + "printing_temperature", + "additive", + ], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'temperature' must be a double and is required", + }, + "additive": { + "bsonType": "object", + "title": "Additive Validation", + "required": ["molecule", "concentration"], + "properties": { + "molecule": { + "bsonType": "string", + "description": "'molecule' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "active_layer": { + "bsonType": "object", + "title": "Active Layer Validation", + "required": ["donor", "acceptor"], + "properties": { + "donor": { + "bsonType": "object", + "title": "Donor Validation", + "required": ["material"], + "properties": { + "material": { + "bsonType": "object", + "title": "Material Validation", + "required": ["metadata"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Validation", + "required": [ + "solvent", + "concentration", + "printing_speed", + "printing_temperature", + "additive", + ], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'temperature' must be a double and is required", + }, + "additive": { + "bsonType": "object", + "title": "Additive Validation", + "required": [ + "molecule", + "concentration", + ], + "properties": { + "molecule": { + "bsonType": "string", + "description": "'molecule' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "acceptor": { + "bsonType": "object", + "title": "Acceptor Validation", + "required": ["material"], + "properties": { + "material": { + "bsonType": "object", + "title": "Material Validation", + "required": ["metadata"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Validation", + "required": [ + "solvent", + "concentration", + "printing_speed", + "printing_temperature", + "additive", + ], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'temperature' must be a double and is required", + }, + "additive": { + "bsonType": "object", + "title": "Additive Validation", + "required": [ + "molecule", + "concentration", + ], + "properties": { + "molecule": { + "bsonType": "string", + "description": "'molecule' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "electrode_htl": { + "bsonType": "object", + "title": "Electrode HTL Validation", + "required": ["material"], + "properties": { + "material": { + "bsonType": "object", + "title": "Material Validation", + "required": ["metadata"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Validation", + "required": [ + "solvent", + "concentration", + "printing_speed", + "printing_temperature", + "additive", + ], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'temperature' must be a double and is required", + }, + "additive": { + "bsonType": "object", + "title": "Additive Validation", + "required": ["molecule", "concentration"], + "properties": { + "molecule": { + "bsonType": "string", + "description": "'molecule' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "result": { + "bsonType": "object", + "title": "Result Object Validation", + "required": ["jv_curve", "t80"], + "properties": { + "jv_curve": { + "bsonType": ["objectId", "null"], + "description": "'jv_curve' must be an objectId and is required", + }, + "t80": { + "bsonType": ["double", "null"], + "description": "'t80' must be a double and is required", + }, + }, + }, + }, + } + + + collection_name = "devices" + collection_options = {"validator": {"$jsonSchema": device_dict_schema}} + try: + db.create_collection(collection_name, **collection_options) + except CollectionInvalid: + print("Error with creating solutions collection") diff --git a/db/validation/films.py b/db/validation/films.py new file mode 100644 index 0000000..6897c2b --- /dev/null +++ b/db/validation/films.py @@ -0,0 +1,94 @@ +from mongodb_helper import MongoDBHelper +from pymongo.errors import CollectionInvalid +import os + +def init_collection(): + def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + load_env() + + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + + mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, + ) + + db = mongo.db + + + film_dict = { + "metadata": { + "solvent": "string", + "concentration": "float", + "printing_speed": "float", + "printing_temperature": "float", + }, + "result": { + "uv_vis": ["object_id", "null"], + "t80": ["float", "null"], + }, + } + + + film_dict_schema = { + "bsonType": "object", + "title": "Film Object Validation", + "required": ["metadata", "result"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Object Validation", + "required": ["solvent", "concentration", "printing_speed", "printing_temperature"], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + "printing_speed": { + "bsonType": "double", + "description": "'printing_speed' must be a double and is required", + }, + "printing_temperature": { + "bsonType": "double", + "description": "'printing_temperature' must be a double and is required", + }, + }, + }, + "result": { + "bsonType": "object", + "title": "Result Object Validation", + "required": ["uv_vis", "t80"], + "properties": { + "uv_vis": { + "bsonType": ["objectId", "null"], + "description": "'uv_vis' must be an objectId and is required", + }, + "t80": { + "bsonType": ["double", "null"], + "description": "'t80' must be a double and is required", + }, + }, + }, + }, + } + + + + collection_name = "film" + collection_options = {"validator": {"$jsonSchema": film_dict_schema}} + try: + db.create_collection(collection_name, **collection_options) + except CollectionInvalid: + print("Error with creating solutions collection") \ No newline at end of file diff --git a/db/validation/solutions.py b/db/validation/solutions.py new file mode 100644 index 0000000..c2944c4 --- /dev/null +++ b/db/validation/solutions.py @@ -0,0 +1,86 @@ +from mongodb_helper import MongoDBHelper +from pymongo.errors import CollectionInvalid +import os + +def init_collection(): + def load_env(file_path=".env"): + if os.path.exists(file_path): + with open(file_path, "r") as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + load_env() + + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + + mongo = MongoDBHelper( + mongo_uri, + mongo_db_name, + ) + + db = mongo.db + + + solution_dict = { + "metadata": { + "solvent": "string", + "concentration": 0.3, + }, + "result": { + "uv_vis": None, + "t80": 0.3, + }, + } + + + + json_schema = { + "bsonType": "object", + "title": "Solution Object Validation", + "required": ["metadata", "result"], + "properties": { + "metadata": { + "bsonType": "object", + "title": "Metadata Object Validation", + "required": ["solvent", "concentration"], + "properties": { + "solvent": { + "bsonType": "string", + "description": "'solvent' must be a string and is required", + }, + "concentration": { + "bsonType": "double", + "description": "'concentration' must be a double and is required", + }, + }, + }, + "result": { + "bsonType": "object", + "title": "Result Object Validation", + "required": ["uv_vis", "t80"], + "properties": { + "uv_vis": { + "bsonType": ["objectId", "null"], + "description": "'uv_vis' must be an objectId and is required", + }, + "t80": { + "bsonType": ["double", "null"], + "description": "'t80' must be a double and is required", + }, + }, + }, + }, + } + + + + collection_name = "solutions" + collection_options = {"validator": {"$jsonSchema": json_schema}} + try: + db.create_collection(collection_name, **collection_options) + except CollectionInvalid: + print("Error with creating solutions collection") + quit() diff --git a/device_ports.md b/device_ports.md new file mode 100644 index 0000000..3897853 --- /dev/null +++ b/device_ports.md @@ -0,0 +1,37 @@ +# Device Ports + +This file tracks the current local serial port assignments and related connection settings for lab hardware used in this repository. + +## Notes + +- Update this file when a device moves to a new port. +- If a port changes, update the related example, recipe, or web app configuration as needed. +- These values are machine-specific and may differ across lab PCs. + +## Current Assignments + +| Device Name | Model / Controller | Vendor | Port | Baudrate | Notes | Related Files | +| --- | --- | --- | --- | --- | --- | --- | +| `esp301_3n` | `ESP301-3N` with `axis1=ILS100CC`, `axis2=UTS100PP`, `axis3=PR50PP` | Newport | `COM6` | `921600` | Axis homing config: `OR4 / OR4 / OR1`; axis 3 is also used for the UV-Vis film degradation chamber workflow | `examples/example_esp301_3n.py`, `examples/example_uvvis_film_absorbance_degradation.py`, `recipes/recipe_sample.py`, `aamp_app/util.py` | +| `heater` | `HeatingStage` Arduino controller | Custom | `COM16` | `115200` | Smoke test updated locally; supports non-blocking setpoint command and explicit wait-for-temperature hold check | `examples/example_heating_stage.py`, `aamp_app/util.py`, `aamp_app/devices/heating_stage.py` | +| `linear_stage_150` | `LTS150` / `LinearStage150` | Thorlabs | `COM15` | `115200` | Smoke test updated locally to use `COM15`; initial absolute move tested at `100 mm` | `examples/example_linear_stage_150.py`, `aamp_app/util.py` | +| `z812` | `Z812` with `KDC101` | Thorlabs | `COM12` | `115200` | Smoke test passed locally with absolute move `8 mm` and relative move `3 mm` | `examples/example_z812.py`, `aamp_app/util.py`, `aamp_app/devices/z812.py` | +| `newport_94043a_solar_sim` | `94043A` solar simulator via `69920` power supply | Newport | `COM11` | `9600` | RS-232 via USB adapter; default control path is power mode; initialize applies `400 W` preset and software blocks presets above `450 W`; lamp replacement warning starts at `1000 h` | `examples/example_94043a_solar_sim.py`, `aamp_app/util.py`, `aamp_app/devices/newport_94043a_solar_sim.py` | +| `p4pp` | `P4PP` Arduino controller | PolyPrint Illinois | `COM19` | `115200` | Rotation is blocked when linear position is `>= 45.0 mm`; explicit measurement resistor selection supported with default `681 ohm`; measurement default cycles set to `20` | `examples/example_p4pp.py`, `aamp_app/util.py`, `aamp_app/devices/p4pp.py` | +| `psd6_25ml` | `PSD/6` syringe pump, 25 mL syringe, 6-port distribution valve | Hamilton | `COM17` | `9600` | Port map: `1=sonicator reservoir`, `2=IPA`, `3=acetone`, `4=toluene`, `5=empty/air`, `6=waste`; smoke test uses safe idle valve `5`, air purge `5->2`, and IPA prime `2->6` | `examples/example_psd6_syringe_pump.py`, `examples/example_psd6_cleaning_solvent_test.py`, `aamp_app/devices/psd6_syringe_pump.py`, `aamp_app/commands/psd6_syringe_pump_commands.py` | +| `psd6_1ml_liquid_handler` | `PSD/6` syringe pump, 1 mL syringe, 6-port distribution valve | Hamilton | `COM14` | `9600` | Same valve mapping as the PSD/6 pump except port `1=liquid handler`; port map: `1=liquid handler`, `2=IPA`, `5=empty/air`, `6=waste`; printing recipe registers this pump for liquid-handler dispensing | `examples/example_psd6_1ml_suction_test.py`, `examples/example_printing_recipe.py`, `aamp_app/devices/psd6_syringe_pump.py`, `aamp_app/commands/psd6_syringe_pump_commands.py` | +| `apis` | `APIS` Arduino controller + Ximea camera | PolyPrint Illinois | `COM8` | `9600` | Composite device for stage control and imaging; current XPL/PPL defaults are `120 deg` / `30 deg`, XPL exposure is `400000 us`, persisted baseline defaults to `data/polarizer_baseline.json`, and core APIS capture is exposed through `APISRunImagingSequence` / `APISRunPolarizerCalibration`; default image root is `data/imaging/` | `examples/example_apis.py`, `aamp_app/util.py`, `aamp_app/devices/apis.py`, `aamp_app/commands/apis_commands.py` | +| `festo_valve` | `MHJ10-S-2,5-QS-1/4-MF-U` x2 via Arduino relay/driver wrapper | Festo + Custom | `COM9` | `9600` | Current local wiring uses Arduino `D8 -> valve_num=2` and `D4 -> valve_num=3`; companion sketch is `to_implement/festo_solenoid_valve_multiple/Festo_Multiple.ino`; valve supply is external `24 V DC`, `3-wire`, default closed/monostable | `examples/example_festo_solenoid_valve.py`, `aamp_app/devices/festo_solenoid_valve.py`, `aamp_app/commands/festo_solenoid_valve_commands.py`, `aamp_app/util.py`, `to_implement/festo_solenoid_valve_multiple/Festo_Multiple.ino` | +| `uhe_nl` | `UHE-NL 7 Sun` solar simulator power control | Sciencetech | `COM20` | `9600` | `initialize` requires lamp OFF and enables cooling first; lamp enable is blocked unless cooling feedback is on; `deinitialize` turns lamp off but leaves cooling on for cooldown | `examples/example_sciencetech_uhe_nl_solar_sim.py`, `aamp_app/util.py`, `aamp_app/devices/sciencetech_uhe_nl_solar_sim.py` | +| `stellarnet_uv_vis` | `StellarNet UV-Vis spectrometer` | StellarNet | `USB` | `n/a` | Vendor Python driver required in the environment; current local examples cover manual dark/blank/sample acquisition and ESP301-driven film degradation loops; spectral decay now uses `reference/am15g_spectrum.csv` | `examples/example_stellarnet_spectrometer.py`, `examples/example_uvvis_film_absorbance_degradation.py`, `aamp_app/devices/stellarnet_spectrometer.py` | +| `sonicator` | `Arduino Uno R3` wrapper for sonicator front-panel button/status lines | Custom | `COM13` | `9600` | Expected wiring is `5V`, `GND`, `D7` button drive, and `D8` status sense; Python smoke test passed locally; explicit power probe is intrusive and not used during initialize | `examples/example_sonicator.py`, `aamp_app/devices/sonicator.py`, `aamp_app/util.py`, `firmware/sonicator/sonicator_uno_r3/sonicator_uno_r3.ino` | +| `substrate_hotel` | `Arduino linear stage` for substrate hotel | Custom | `COM10` | `9600` | Current local assignment; protocol expects `Ready`, `H`, and `M{position},{speed}`; current Python guard range is `0-430 mm`; default homing and move timeouts are `300 s` | `examples/example_substrate_hotel.py`, `aamp_app/devices/substrate_hotel.py`, `aamp_app/util.py` | +| `substrate_dispenser` | `Arduino linear stage` for substrate dispenser | Custom | `COM7` | `9600` | Current local assignment; protocol expects `Ready`, `H`, and `M{position},{speed}`; current Python guard range is `0-45 mm` | `examples/example_substrate_dispenser.py`, `aamp_app/devices/substrate_dispenser.py`, `aamp_app/util.py` | + +## Storage Notes + +- `APIS` image outputs default to `data/imaging/` +- `P4PP` measurement CSV outputs default to `data/resistance/p4pp_measurements.csv` +- `StellarNetSpectrometer` outputs default to `data/spectroscopy/` +- StellarNet spectral-decay reference data is stored in `data/spectroscopy/reference/am15g_spectrum.csv` +- StellarNet repeated absorbance QC is appended to `sample_name_measurement_qc_log.csv` in `data/spectroscopy/` diff --git a/devices/stellarnet_spectrometer.py b/devices/stellarnet_spectrometer.py deleted file mode 100644 index 3b8e3b0..0000000 --- a/devices/stellarnet_spectrometer.py +++ /dev/null @@ -1,391 +0,0 @@ -from typing import Union, Tuple, Dict, List, Optional -from datetime import datetime - -import numpy as np -import pandas as pd -from scipy.interpolate import interp1d -from scipy.optimize import minimize -import stellarnet_driver3 as sn - -from .device import Device, check_initialized - -# TODO -# convert to serial device parent when arduino added -# add shutter and lamp control -# check slicing as second index is not included in slice -# type hinting of ndarrays? -# type hint the static methods? - -class StellarNetSpectrometer(Device): - save_directory = 'data/spectroscopy/' - - def __init__(self, name: str, spec_keys: List[str] = ['UV-Vis', 'NIR']): - super().__init__(name) - self.spectrometer_dict = {} - self.wavelength_dict = {} - self.dark_spectra_dict = {} - self.blank_spectra_dict = {} - self.absorbance_dict = {} - self.merged_absorbance = None - self.num_spectrometers = 0 - self.spec_keys = spec_keys - - @staticmethod - def num_specs_connected() -> int: - num_connected = 0 - start_wav = [-1.] - while True: - try: - spec, wav = sn.array_get_spec(num_connected) - start_wav.append(wav[0].item()) - if start_wav[num_connected+1] == start_wav[num_connected]: - break - else: - num_connected += 1 - except: - break - return num_connected - - def initialize(self) -> Tuple[bool, str]: - #lamp on - #shutter in/out - #any other Arduino initialization - - self.num_spectrometers = self.num_specs_connected() - if self.num_spectrometers == 0: - self._is_initialized = False - return (False, "There are no spectrometers connected") - - for ndx in range(self.num_spectrometers): - spectrometer, wavelengths = sn.array_get_spec(ndx) - if wavelengths[0] < 200.0: - # UV-VIS - self.spectrometer_dict['UV-Vis'] = spectrometer - self.wavelength_dict['UV-Vis'] = wavelengths - else: - # NIR - self.spectrometer_dict['NIR'] = spectrometer - self.wavelength_dict['NIR'] = wavelengths - # For additional spectrometers add to the if else ladder - - # Check that we were able to initialize all spectrometers that were declared during construction - if set(self.spec_keys) == set(self.spectrometer_dict.keys()): - self._is_initialized = True - return (True, "Successfully initialized " + str(self.num_spectrometers) + " spectrometers: " + str(list(self.spectrometer_dict.keys()))) - else: - self._is_initialized = False - return (False, "Not all declared spectrometers were initialized. Only initialized: " + str(list(self.spectrometer_dict.keys()))) - - def deinitialize(self, reset_init_flag: bool = True) -> Tuple[bool, str]: - # turn off lamp? - # move shutter the position so that initialize can be ready to move the shutter correct position when starting the instrument - if reset_init_flag: - self._is_initialized = False - - return (True, "Pass, nothing to deinitialize for now.") - - @check_initialized - def get_spectrum_counts( - self, - spec_key: str, - integration_time: int = 100, - scans_to_avg: int = 3, - smoothing: int = 0, - xtiming: int = 1) -> Tuple[bool, str]: - - # if not self._is_initialized: - # return (False, "Spectrometer system not initialized") - - if spec_key in self.spectrometer_dict.keys(): - self.spectrometer_dict[spec_key]['device'].set_config( - int_time=integration_time, - scans_to_avg=scans_to_avg, - x_smooth=smoothing, - x_timing=xtiming) - - spectrum_array = sn.array_spectrum(self.spectrometer_dict[spec_key], self.wavelength_dict[spec_key]) - - max_count = np.amax(spectrum_array[:,1], axis=0) - print("Max count: " + str(max_count)) - if max_count > 65500: - return (False, spec_key + " detector is saturated, lower integration time") - return (True, spectrum_array) - else: - return (False, spec_key + " spectrometer is not found" ) - - def get_all_spectra_counts( - self, - integration_times: Tuple[int, ...] = (100, 100), - scans_to_avg: Tuple[int, ...] = (3, 3), - smoothings: Tuple[int, ...] = (0, 0), - xtimings: Tuple[int, ...] = (1, 1)) -> Tuple[bool, str]: - - # Modify the variable 'spec_keys' if you don't intend to use all spectrometers - if self.num_spectrometers != len(self.spec_keys): - return (False, "Spectrometers are not all connected") - - spectrum_array_dict = {} - # print(spec_keys) - # using self.spec_keys to ensure that the order within the parameter tuple matches the order of the declared spec_keys - for ndx, spec_key in enumerate(self.spec_keys): - # this function should already check if the spec_key is valid - result, spectrum_array = self.get_spectrum_counts( - spec_key, - integration_time=integration_times[ndx], - scans_to_avg=scans_to_avg[ndx], - smoothing=smoothings[ndx], - xtiming=xtimings[ndx]) - if not result: - return result, spectrum_array - - spectrum_array_dict[spec_key] = spectrum_array - - return (True, spectrum_array_dict) - - - def update_all_dark_spectra( - self, - integration_times: Tuple[int, ...] = (100, 100), - scans_to_avg: Tuple[int, ...] = (3, 3), - smoothings: Tuple[int, ...] = (0, 0), - xtimings: Tuple[int, ...] = (1, 1)) -> Tuple[bool, str]: - - result, spectrum_array_dict = self.get_all_spectra_counts( - integration_times, - scans_to_avg, - smoothings, - xtimings) - - if not result: - return result, spectrum_array_dict - - for key, value in spectrum_array_dict.items(): - self.dark_spectra_dict[key] = value - - return (True, "All dark spectra stored") - - def update_all_blank_spectra( - self, - integration_times: Tuple[int, ...] = (100, 100), - scans_to_avg: Tuple[int, ...] = (3, 3), - smoothings: Tuple[int, ...] = (0, 0), - xtimings: Tuple[int, ...] = (1, 1)) -> Tuple[bool, str]: - - result, spectrum_array_dict = self.get_all_spectra_counts( - integration_times, - scans_to_avg, - smoothings, - xtimings) - - if not result: - return result, spectrum_array_dict - - for key, value in spectrum_array_dict.items(): - self.blank_spectra_dict[key] = value - - return (True, "All blank spectra stored") - - def get_all_absorbance( - self, - save_to_file: bool = False, - filename: Optional[str] = None, - integration_times: Tuple[int, ...] = (100, 100), - scans_to_avg: Tuple[int, ...] = (3, 3), - smoothings: Tuple[int, ...] = (0, 0), - xtimings: Tuple[int, ...] = (1, 1)) -> Tuple[bool, str]: - - # get the sample spectra - result, spectrum_array_dict = self.get_all_spectra_counts( - integration_times, - scans_to_avg, - smoothings, - xtimings) - if save_to_file and filename is None: - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = timestamp - - - # check if failed - if not result: - return result, spectrum_array_dict - - # check that dark/blank spectra exists for each initialized spectrometer - for key in self.spectrometer_dict.keys(): - if not key in self.blank_spectra_dict: - return (False, "Blank spectra for " + key + " is missing") - if not key in self.dark_spectra_dict: - return (False, "Dark spectra for " + key + " is missing") - - # Calculate absorbance for each spectra, save to self - # Absorbance is -log10((Isample - Idark)/(Iblank - Idark)) - for ndx, spec_key in enumerate(self.spec_keys): - wavelength = self.wavelength_dict[spec_key].copy() - dark_spec = self.dark_spectra_dict[spec_key][:,1].copy() - blank_spec = self.blank_spectra_dict[spec_key][:,1].copy() - sam_spec = spectrum_array_dict[spec_key][:,1].copy() - - absorbance = np.expand_dims(-np.log10((sam_spec - dark_spec) / (blank_spec - dark_spec)), axis=1) - absorbance_array = np.hstack((wavelength, absorbance)) - - self.absorbance_dict[spec_key] = absorbance_array - - # save all data related to absorbance calculation to a file per spectrometer - if save_to_file: - data = pd.DataFrame() - data[spec_key + ' Wavelength'] = np.squeeze(wavelength) - data[spec_key + ' Dark Counts'] = np.squeeze(dark_spec) - data[spec_key + ' Blank Counts'] = np.squeeze(blank_spec) - data[spec_key + ' Sample Counts'] = np.squeeze(sam_spec) - data[spec_key + ' Absorbance'] = np.squeeze(absorbance) - - comment = ["# spec_key = " + spec_key + "\n", - "# integration_time = " + str(integration_times[ndx]) + "\n", - "# scans_to_avg = " + str(scans_to_avg[ndx]) + "\n", - "# smoothing = " + str(smoothings[ndx]) + "\n", - "# xtiming = " + str(xtimings[ndx]) + "\n"] - - fullfilename = self.save_directory + filename + '_' + spec_key + '.csv' - with open(fullfilename, 'w') as file: - file.writelines(comment) - data.to_csv(fullfilename, mode='a', index_label='Index') - - if save_to_file: - all_comments = ["# spec_keys = " + str(self.spec_keys) + "\n" - "# integration_times = " + str(integration_times) + "\n", - "# scans_to_avg = " + str(scans_to_avg) + "\n", - "# smoothings = " + str(smoothings) + "\n", - "# xtimings = " + str(xtimings) + "\n"] - else: - all_comments = ['#\n',] - # Merge the absorbance spectra and optionally save to file - # What happens if only 1 spectrometer is connected/being used? - if len(self.spec_keys) > 1: - result, message = self.merge_absorbance(save_to_file, filename, all_comments) - - if not result: - return result, message - - if save_to_file: - return (True, "All absorbance spectra stored to instance and saved to file: " + self.save_directory + filename) - else: - return (True, "All absorbance spectra stored to instance but not saved to file") - - # hard coded for UV-Vis and NIR - # what happens if only 1 spectrometer is connected/being used? - def merge_absorbance(self, save_to_file: bool, filename: str, comment_list: List[str]) -> Tuple[bool, str]: - uv_wavelength = np.squeeze(self.wavelength_dict['UV-Vis'].copy()) - nir_wavelength = np.squeeze(self.wavelength_dict['NIR'].copy()) - uv_absorbance = np.squeeze(self.absorbance_dict['UV-Vis'][:,1].copy()) - nir_absorbance = np.squeeze(self.absorbance_dict['NIR'][:,1].copy()) - - # start and end of overlapping regions - WAVE_START = 900.0 - WAVE_END = 1030.0 - - merge_result = minimize(self.merge_error, [1, 0], args=(uv_wavelength, uv_absorbance, nir_wavelength, nir_absorbance, WAVE_START, WAVE_END), method='BFGS') - - if not merge_result: - return (False, "Failed to merge UV-Vis and NIR absorbance spectra: " + merge_result.message) - else: - scale = merge_result.x[0] - shift = merge_result.x[1] - - # uv is not modified, nir is adjusted to match uv - uv_array = self.absorbance_dict['UV-Vis'].copy() - nir_wavelength = self.wavelength_dict['NIR'].copy() - - nir_absorbance = np.expand_dims(self.scale_shift_data([scale, shift], self.absorbance_dict['NIR'][:,1].copy()), axis=1) - nir_array = np.hstack((nir_wavelength, nir_absorbance)) - - UV_START = 210.0 - UV_END = 1030.0 - NIR_START = 900.0 - NIR_END = 1700.0 - - uv_array = self.truncate_ends_by_wavelength(uv_array, UV_START, UV_END) - nir_array = self.truncate_ends_by_wavelength(nir_array, NIR_START, NIR_END) - - merged_array = np.vstack((uv_array, nir_array)) - merged_array = merged_array[merged_array[:,0].argsort()] - self.merged_absorbance = merged_array - - if save_to_file: - data = pd.DataFrame() - data['Wavelength'] = np.squeeze(self.merged_absorbance[:,0].copy()) - data['Absorbance'] = np.squeeze(self.merged_absorbance[:,1].copy()) - - fullfilename = self.save_directory + filename + '_merged.csv' - comment_list.append("# To merge, NIR data is scaled first then shifted\n") - comment_list.append("# scale = " + str(scale) + "\n") - comment_list.append("# shift = " + str(shift) + "\n") - with open(fullfilename, 'w') as file: - file.writelines(comment_list) - data.to_csv(fullfilename, mode='a', index_label='Index') - - return (True, "Successfully merged UV-Vis and NIR absorbance spectra: " + merge_result.message) - - # y and return are ndarrays - @staticmethod - def scale_shift_data(params: List[float], y): - scale = params[0] - shift = params[1] - return y * scale + shift - # return (y + shift) * scale - - @staticmethod - def truncate_ends_by_wavelength(array, start: float, end: float): - # expects array to be two columns with first being wavelength and second being the data of interest (counts, absorbance, etc.) - return array[np.logical_and(array[:,0]>start, array[:,0] str: - return self._default_imgdataformat - - @property - def default_exposure_time(self) -> int: - return self._default_exposure_time - - @default_exposure_time.setter - def default_exposure_time(self, exposure_time: int): - if exposure_time > 0: - self._default_exposure_time = int(exposure_time) - - @property - def default_gain(self) -> float: - return self._default_gain - - @default_gain.setter - def default_gain(self, gain: float): - # there is an upper limit for this but not 100% sure what it is - if gain >= 0.0: - self._default_gain = gain - - # no setter for default wb coeffs - @property - def default_wb_kr(self) -> float: - return self._default_wb_kr - - @property - def default_wb_kg(self) -> float: - return self._default_wb_kg - - @property - def default_wb_kb(self) -> float: - return self._default_wb_kb - - def set_default_params(self): - self.cam.set_imgdataformat(self._default_imgdataformat) - self.cam.set_exposure(self._default_exposure_time) - self.cam.set_gain(self._default_gain) - self.cam.set_wb_kr(self._default_wb_kr) - self.cam.set_wb_kg(self._default_wb_kg) - self.cam.set_wb_kb(self._default_wb_kb) - - def initialize(self, set_defaults: bool = True) -> Tuple[bool, str]: - try: - self.cam.open_device() - # set defaults if flag is True. This should be True the very first time in order to set the default params, but not enforced - if set_defaults: - self.set_default_params() - self._is_initialized = True - except xiapi.Xi_error as inst: - self._is_initialized = False - return (False, "Failed to connect and initialize: " + str(inst)) - - if set_defaults: - return (True, "Successfully initialized camera by opening communications and setting defaults.") - else: - return (True, "Successfully intitialized camera by opening communications.") - - def deinitialize(self, reset_init_flag: bool = True) -> Tuple[bool, str]: - try: - self.cam.stop_acquisition() - self.cam.close_device() - except xiapi.Xi_error as inst: - return (False, "Failed to deinitialize the camera: " + str(inst)) - - if reset_init_flag: - self._is_initialized = False - - return (True, "Successfully deinitialized camera, communication closed.") - - @check_initialized - def get_image( - self, - save_to_file: bool = True, - filename: str = None, - exposure_time: Optional[int] = None, - gain: Optional[float] = None, - show_pop_up: bool = False) -> Tuple[bool, str]: - # if not self._is_initialized: - # return (False, "Camera is not initialized") - - if exposure_time is None: - exposure_time = self._default_exposure_time - if gain is None: - gain = self._default_gain - - try: - self.cam.set_exposure(exposure_time) - self.cam.set_gain(gain) - - img = xiapi.Image() - self.cam.start_acquisition() - # exposure time is in microsec, timeout is in millisec, timeout is set to double the exposure time - self.cam.get_image(img, timeout=(int(exposure_time / 1000 * 2))) - self.cam.stop_acquisition() - except xiapi.Xi_error as inst: - return (False, "Error while getting image: " + str(inst)) - - data = img.get_image_data_numpy(invert_rgb_order=True) - img = Image.fromarray(data, 'RGB') - - if save_to_file: - - if filename is None: - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = timestamp - fullfilename = self.save_directory + filename - img.save(fullfilename + '.bmp') - - settings = ["image data format = " + self.cam.get_imgdataformat() + "\n", - "exposure (us) = " + str(self.cam.get_exposure()) + "\n", - "gain = " + str(self.cam.get_gain()) + "\n", - "wb_kr = " + str(self.cam.get_wb_kr()) + "\n", - "wb_kg = " + str(self.cam.get_wb_kg()) + "\n", - "wb_kb = " + str(self.cam.get_wb_kb()) + "\n"] - with open(fullfilename + '.txt', 'w') as file: - file.writelines(settings) - - return (True, "Successfully saved image and settings to " + str(fullfilename) + ".bmp") - - if show_pop_up: - img.show() - - return (True, "Successfully took image but did not save.") - - @check_initialized - def update_white_balance(self, exposure_time: int = None, gain: Optional[float] = None) -> Tuple[bool, str]: - # if not self._is_initialized: - # return (False, "Camera is not initialized") - - try: - self.cam.set_manual_wb(1) - except xiapi.Xi_error as inst: - return (False, "Failed to set white balance: " + str(inst)) - - was_successful, result_message = self.get_image(save_to_file=False, filename=None, exposure_time=exposure_time, gain=gain) - - if not was_successful: - return (was_successful, result_message) - - return (True, "Successfully updated white balance coefficients with image: wb_kr, wb_kg, wb_kb = " + str(self.get_white_balance_rgb_coeffs())) - - @check_initialized # is this necessary - def set_white_balance_manually(self, wb_kr: Optional[float] = None, wb_kg: Optional[float] = None, wb_kb: Optional[float] = None) -> Tuple[bool, str]: - # if not self._is_initialized: - # return (False, "Camera is not initialized") - - if wb_kr is None and wb_kg is None and wb_kb is None: - return (True, "No white balance coefficients were changed. Coefficients are currently: wb_kr, wb_kg, wb_kb = " + str(self.get_white_balance_rgb_coeffs())) - try: - if wb_kr is not None: - self.cam.set_wb_kr(wb_kr) - if wb_kg is not None: - self.cam.set_wb_kg(wb_kg) - if wb_kb is not None: - self.cam.set_wb_kb(wb_kb) - except: - return (False, "Error in setting white balance coefficients. Coefficients are currently: wb_kr, wb_kg, wb_kb = " + str(self.get_white_balance_rgb_coeffs())) - - return (True, "Successfully set white balance coefficients manually: wb_kr, wb_kg, wb_kb = " + str(self.get_white_balance_rgb_coeffs())) - - @check_initialized # is this necessary - def reset_white_balance_rgb_coeffs(self) -> Tuple[bool, str]: - self.cam.set_wb_kr(self._default_wb_kr) - self.cam.set_wb_kg(self._default_wb_kg) - self.cam.set_wb_kb(self._default_wb_kb) - return (True, "Successfully reset white balance coefficients to defaults. Coefficients are currently: wb_kr, wb_kg, wb_kb = " + str(self.get_white_balance_rgb_coeffs())) - - @check_initialized # is this necessary - def get_white_balance_rgb_coeffs(self) -> List[float]: - wb = [] - wb.append(self.cam.get_wb_kr()) - wb.append(self.cam.get_wb_kg()) - wb.append(self.cam.get_wb_kb()) - return wb - - - diff --git a/examples/.gitignore b/examples/.gitignore index c887976..bb33d5e 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -7,6 +7,27 @@ !example4.py !example5.py !example6.py +!example_esp301_3n.py +!example_linear_stage_150.py +!example_heating_stage.py +!example_z812.py +!example_94043a_solar_sim.py +!example_p4pp.py +!example_apis.py +!example_apis_from_mongodb.py +!example_film_characterization.py +!example_psd6_syringe_pump.py +!example_psd6_1ml_suction_test.py +!example_psd6_cleaning_solvent_test.py +!example_stellarnet_spectrometer.py +!example_uvvis_film_absorbance_degradation.py +!example_sciencetech_uhe_nl_solar_sim.py +!example_festo_solenoid_valve.py +!example_p4pp_rotation_sweep.py +!example_printing_recipe.py +!example_sonicator.py +!example_substrate_hotel.py +!example_substrate_dispenser.py !Figure_1.png !Figure_2.png -!Figure_3.png \ No newline at end of file +!Figure_3.png diff --git a/examples/example_94043a_solar_sim.py b/examples/example_94043a_solar_sim.py new file mode 100644 index 0000000..047cab9 --- /dev/null +++ b/examples/example_94043a_solar_sim.py @@ -0,0 +1,73 @@ +# Newport 94043A solar simulator smoke test via 69920 power supply +# run from root using 'python -m examples.example_94043a_solar_sim' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_sequence import CommandSequence +from command_invoker import CommandInvoker +from devices.newport_94043a_solar_sim import Newport94043ASolarSim +from commands.newport_94043a_solar_sim_commands import * + + +POWER_SUPPLY_PORT = "COM11" +POWER_PRESET_WATTS = 400 + + +def main() -> None: + seq = CommandSequence() + + power_supply = Newport94043ASolarSim( + name="newport_94043a_solar_sim", + port=POWER_SUPPLY_PORT, + baudrate=9600, + timeout=1.0, + default_power_watts=400, + max_power_watts=450, + ) + seq.add_device(power_supply) + + seq.add_command(Newport94043ASolarSimConnect(power_supply)) + seq.add_command(Newport94043ASolarSimIdentify(power_supply)) + seq.add_command(Newport94043ASolarSimInitialize(power_supply)) + seq.add_command(Newport94043ASolarSimStatusByte(power_supply)) + seq.add_command(Newport94043ASolarSimEventStatus(power_supply)) + seq.add_command(Newport94043ASolarSimGetLampHours(power_supply)) + seq.add_command(Newport94043ASolarSimGetWatts(power_supply)) + seq.add_command(Newport94043ASolarSimGetCurrentLimit(power_supply)) + seq.add_command(Newport94043ASolarSimGetPowerLimit(power_supply)) + seq.add_command(Newport94043ASolarSimSetPowerPreset(power_supply, watts=POWER_PRESET_WATTS)) + seq.add_command(Newport94043ASolarSimGetPowerPreset(power_supply)) + seq.add_command(Newport94043ASolarSimLampStart(power_supply)) + seq.add_command(Newport94043ASolarSimGetWatts(power_supply)) + seq.add_command(Newport94043ASolarSimSetPowerPreset(power_supply, watts=420)) + seq.add_command(Newport94043ASolarSimGetPowerPreset(power_supply)) + seq.add_command(Newport94043ASolarSimGetWatts(power_supply)) + seq.add_command(Newport94043ASolarSimSetPowerPreset(power_supply, watts=380)) + seq.add_command(Newport94043ASolarSimGetPowerPreset(power_supply)) + seq.add_command(Newport94043ASolarSimGetWatts(power_supply)) + seq.add_command(Newport94043ASolarSimLampStop(power_supply)) + + seq.add_command(Newport94043ASolarSimDeinitialize(power_supply)) + + log_file = "logs/example_94043a_solar_sim.log" + invoker = CommandInvoker(seq, log_to_file=True, log_filename=log_file, alert_slack=False) + + seq.print_command_names() + print("\nThis smoke test communicates with the 94043A solar simulator through the 69920 power supply in power mode.") + print("Sequence: initialize at 400 W, lamp start, set 420 W, set 380 W, lamp stop.") + print("Software blocks presets above 450 W.") + userinput = input("\ntype 'y' to continue, type anything else to quit: ").strip().lower() + if userinput == "y": + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/examples/example_apis.py b/examples/example_apis.py new file mode 100644 index 0000000..ab0214b --- /dev/null +++ b/examples/example_apis.py @@ -0,0 +1,175 @@ +# APIS imaging workflow example +# run from root using 'python -m examples.example_apis' + +import os +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence +from devices.apis import APIS +from commands.apis_commands import * + + +APIS_PORT = "COM8" +ROOT_SAVE_DIR = os.path.join("data", "imaging", "demo_campaign") +XPL_EXPOSURE_US = 50000 +PPL_EXPOSURE_US = 18000 +SAMPLE_ANGLES_DEG = [90, 60, 45, 30, 0] + + +def add_mode_capture_commands(seq, apis, mode_name, polarizer_angle, sample_angles, exposure_time, base_name, save_dir): + seq.add_command(APISRotatePolarizer(apis, angle_deg=polarizer_angle)) + for angle in sample_angles: + seq.add_command(APISRotateSample(apis, angle_deg=angle)) + filename = apis.build_mode_filename(base_name, mode_name, angle) + raw16_path = os.path.join(save_dir, "raw16", filename + ".tif") + rgb_path = os.path.join(save_dir, "rgb", filename + "_rgb.tif") + seq.add_command( + APISCaptureRaw16( + apis, + filename=filename, + directory=save_dir, + exposure_time=exposure_time, + gain=0.0, + ) + ) + seq.add_command( + APISConvertRaw16ToRgb( + apis, + raw16_path=raw16_path, + rgb_path=rgb_path, + ) + ) + + +def main() -> None: + print("=== APIS Imaging Workflow ===") + print("Enter parameter values using the MongoDB `sets` field names.") + + batch_input = input("Enter batch_no (for example: 1): ").strip() + sample_input = input("Enter sample_no (for example: 1): ").strip() + + try: + batch_no = int(batch_input) + except ValueError: + batch_no = batch_input + + try: + sample_no = int(sample_input) + except ValueError: + sample_no = sample_input + + polymer_name = input("Enter polymer_name (for example: PProDOT): ").strip() + solvent = input("Enter solvent (for example: CB): ").strip() + concentration = int(input("Enter concentration (mg/ml): ").strip()) + motor_speed = float(input("Enter motor_speed (mm/s): ").strip()) + temperature = int(input("Enter temperature (C): ").strip()) + printing_gap = int(input("Enter printing_gap (um): ").strip()) + precursor_volume = int(input("Enter precursor_volume (ul): ").strip()) + + params = { + "campaign_name": "demo_campaign", + "batch_no": batch_no, + "sample_no": sample_no, + "polymer_name": polymer_name, + "temperature": temperature, + "motor_speed": motor_speed, + "printing_gap": printing_gap, + "solvent": solvent, + "concentration": concentration, + "precursor_volume": precursor_volume, + } + + print("\n=== Sample Parameters ===") + for key, value in params.items(): + print(f"{key}: {value}") + + base_sample_name = APIS.build_sample_basename( + round_num=params["batch_no"], + sample_num=params["sample_no"], + polymer=params["polymer_name"], + solvent=params["solvent"], + concentration=params["concentration"], + speed=params["motor_speed"], + temperature=params["temperature"], + gap=params["printing_gap"], + volume=params["precursor_volume"], + ) + print(f"\nGenerated filename prefix: {base_sample_name}") + print("Mode folders will use `xpl/` and `ppl/`.") + print("Each capture will save RAW16 first, then convert that file to RGB.") + + confirm = input("\nProceed with imaging using these parameters? (y/n): ").strip().lower() + if confirm != "y": + print("Imaging cancelled.") + return + + print("\nInitializing hardware...") + apis = APIS( + name="apis", + port=APIS_PORT, + baudrate=9600, + timeout=0.5, + connection_wait_s=2.0, + settling_time_s=1.5, + command_delay_s=0.05, + max_retries=3, + use_camera=True, + camera_save_directory=ROOT_SAVE_DIR, + camera_bayer_pattern="GBRG", + camera_raw_max_value=1023.0, + ) + xpl_dir = apis.resolve_mode_directory(ROOT_SAVE_DIR, params["polymer_name"], "xpl") + ppl_dir = apis.resolve_mode_directory(ROOT_SAVE_DIR, params["polymer_name"], "ppl") + + seq = CommandSequence() + seq.add_device(apis) + + seq.add_command(APISConnect(apis)) + seq.add_command(APISInitialize(apis)) + seq.add_command(APISHome(apis)) + + print(f"\nStarting XPL capture ({len(SAMPLE_ANGLES_DEG)} angles)") + add_mode_capture_commands( + seq=seq, + apis=apis, + mode_name="xpl", + polarizer_angle=90.0, + sample_angles=SAMPLE_ANGLES_DEG, + exposure_time=XPL_EXPOSURE_US, + base_name=base_sample_name, + save_dir=xpl_dir, + ) + + print(f"Starting PPL capture ({len(SAMPLE_ANGLES_DEG)} angles)") + add_mode_capture_commands( + seq=seq, + apis=apis, + mode_name="ppl", + polarizer_angle=0.0, + sample_angles=SAMPLE_ANGLES_DEG, + exposure_time=PPL_EXPOSURE_US, + base_name=base_sample_name, + save_dir=ppl_dir, + ) + + seq.add_command(APISRotatePolarizer(apis, angle_deg=0.0)) + seq.add_command(APISRotateSample(apis, angle_deg=0.0)) + seq.add_command(APISDeinitialize(apis)) + + print("\nRunning imaging sequence...") + invoker = CommandInvoker(seq, False) + result = invoker.invoke_commands() + print(f"\nImaging finished. Result: {result}") + + +if __name__ == "__main__": + main() diff --git a/examples/example_apis_from_mongodb.py b/examples/example_apis_from_mongodb.py new file mode 100644 index 0000000..108978e --- /dev/null +++ b/examples/example_apis_from_mongodb.py @@ -0,0 +1,350 @@ +# APIS imaging workflow example backed by MongoDB parameter sets. +# Run from repo root using: +# python -m examples.example_apis_from_mongodb + +import os +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Tuple + +from gridfs import GridFS + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence +from commands.apis_commands import * +from devices.apis import APIS +from mongodb_helper import MongoDBHelper + + +APIS_PORT = "COM8" +ROOT_SAVE_DIR = os.path.join("data", "imaging") +XPL_EXPOSURE_US = 50000 +PPL_EXPOSURE_US = 18000 +SAMPLE_ANGLES_DEG = [90, 60, 45, 30, 0] + + +def load_env(file_path: Path = ROOT_DIR / ".env") -> None: + if file_path.exists(): + with open(file_path, "r", encoding="utf-8") as file: + for line in file: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + +def sanitize_path_component(value: str) -> str: + sanitized = re.sub(r'[<>:"/\\|?*]+', "_", value.strip()) + return sanitized or "unnamed_campaign" + + +def format_display_value(value): + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + +def get_mongo_helper() -> MongoDBHelper: + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + if not mongo_uri or not mongo_db_name: + raise RuntimeError("MONGO_URI and MONGO_DB_NAME must be set in .env to use this example.") + return MongoDBHelper(mongo_uri, mongo_db_name) + + +def fetch_parameter_set( + mongo: MongoDBHelper, + campaign_name: str, + batch_no: int, + sample_no: int, +) -> Tuple[dict, dict]: + campaign_doc = mongo.db["campaigns"].find_one({"campaign_name": campaign_name}) + if campaign_doc is None: + raise LookupError(f"Campaign '{campaign_name}' was not found in the campaigns collection.") + + set_doc = mongo.db["sets"].find_one( + { + "campaign_id": campaign_doc["_id"], + "batch_no": batch_no, + "sample_no": sample_no, + } + ) + if set_doc is None: + available_samples = sorted( + mongo.db["sets"].distinct( + "sample_no", + { + "campaign_id": campaign_doc["_id"], + "batch_no": batch_no, + }, + ) + ) + if available_samples: + raise LookupError( + f"No parameter set found for campaign='{campaign_name}', batch_no={batch_no}, " + f"sample_no={sample_no}. Available sample_no values for that round: {available_samples}" + ) + available_rounds = sorted( + mongo.db["sets"].distinct( + "batch_no", + {"campaign_id": campaign_doc["_id"]}, + ) + ) + raise LookupError( + f"No parameter set found for campaign='{campaign_name}', batch_no={batch_no}, " + f"sample_no={sample_no}. Available rounds(batch_no): {available_rounds}" + ) + return campaign_doc, set_doc + + +def build_set_params(batch_no: int, sample_no: int, set_doc: dict) -> Dict[str, object]: + return { + "campaign_name": format_display_value(set_doc.get("campaign_name", "")), + "batch_no": batch_no, + "sample_no": sample_no, + "polymer_name": format_display_value(set_doc["polymer_name"]), + "temperature": format_display_value(set_doc["temperature"]), + "motor_speed": float(set_doc["motor_speed"]), + "printing_gap": format_display_value(set_doc["printing_gap"]), + "solvent": format_display_value(set_doc["solvent"]), + "concentration": format_display_value(set_doc["concentration"]), + "precursor_volume": format_display_value(set_doc["precursor_volume"]), + } + + +def add_mode_capture_commands( + seq: CommandSequence, + apis: APIS, + mode_name: str, + polarizer_angle: float, + sample_angles: List[float], + exposure_time: int, + base_name: str, + save_dir: str, + capture_records: List[dict], +) -> None: + seq.add_command(APISRotatePolarizer(apis, angle_deg=polarizer_angle)) + for angle in sample_angles: + seq.add_command(APISRotateSample(apis, angle_deg=angle)) + filename = apis.build_mode_filename(base_name, mode_name, angle) + raw16_path = os.path.join(save_dir, "raw16", filename + ".tif") + rgb_path = os.path.join(save_dir, "rgb", filename + "_rgb.tif") + capture_records.append( + { + "mode": mode_name, + "image_kind": "raw16", + "sample_angle_deg": angle, + "local_path": raw16_path, + } + ) + capture_records.append( + { + "mode": mode_name, + "image_kind": "rgb", + "sample_angle_deg": angle, + "local_path": rgb_path, + } + ) + seq.add_command( + APISCaptureRaw16( + apis, + filename=filename, + directory=save_dir, + exposure_time=exposure_time, + gain=0.0, + ) + ) + seq.add_command( + APISConvertRaw16ToRgb( + apis, + raw16_path=raw16_path, + rgb_path=rgb_path, + ) + ) + + +def upload_capture_records_to_mongodb( + mongo: MongoDBHelper, + campaign_doc: dict, + set_doc: dict, + capture_records: List[dict], +) -> Tuple[int, int]: + fs = GridFS(mongo.db) + uploaded_count = 0 + skipped_count = 0 + for record in capture_records: + local_path = record["local_path"] + if not os.path.isfile(local_path): + skipped_count += 1 + continue + + with open(local_path, "rb") as file_obj: + file_id = fs.put(file_obj, filename=os.path.basename(local_path)) + + metadata = { + "campaign_id": campaign_doc["_id"], + "campaign_name": campaign_doc["campaign_name"], + "set_id": set_doc["_id"], + "batch_no": set_doc.get("batch_no"), + "sample_no": set_doc.get("sample_no"), + "polymer_name": set_doc.get("polymer_name"), + "solvent": set_doc.get("solvent"), + "concentration": set_doc.get("concentration"), + "motor_speed": set_doc.get("motor_speed"), + "temperature": set_doc.get("temperature"), + "printing_gap": set_doc.get("printing_gap"), + "precursor_volume": set_doc.get("precursor_volume"), + "mode": record["mode"], + "image_kind": record["image_kind"], + "sample_angle_deg": record["sample_angle_deg"], + "file_id": file_id, + "filename": os.path.basename(local_path), + "relative_local_path": os.path.relpath(local_path, ROOT_DIR), + "measurement_type": "apis_imaging", + "uploaded_at": datetime.now(timezone.utc), + "source": "example_apis_from_mongodb", + } + mongo.db["images"].insert_one(metadata) + uploaded_count += 1 + + return uploaded_count, skipped_count + + +def main() -> None: + print("=== APIS Imaging From MongoDB Parameter Sets ===") + print("This example resolves sample parameters from MongoDB using campaign name, round number, and sample number.") + print("Round number is matched to MongoDB field `batch_no`.") + print("Sample number is matched to MongoDB field `sample_no`.") + + load_env() + mongo = get_mongo_helper() + + campaign_name = input("Enter campaign name: ").strip() + batch_no = int(input("Enter batch_no: ").strip()) + sample_no = int(input("Enter sample_no: ").strip()) + + try: + campaign_doc, set_doc = fetch_parameter_set(mongo, campaign_name, batch_no, sample_no) + except LookupError as exc: + print(f"\nParameter lookup failed: {exc}") + mongo.close_connection() + return + + params = build_set_params(batch_no, sample_no, set_doc) + + print("\n=== Resolved Sample Parameters ===") + for key, value in params.items(): + print(f"{key}: {value}") + + base_sample_name = APIS.build_sample_basename( + round_num=params["batch_no"], + sample_num=params["sample_no"], + polymer=params["polymer_name"], + solvent=params["solvent"], + concentration=params["concentration"], + speed=params["motor_speed"], + temperature=params["temperature"], + gap=params["printing_gap"], + volume=params["precursor_volume"], + ) + root_save_dir = os.path.join(ROOT_SAVE_DIR, sanitize_path_component(campaign_name)) + print(f"\nGenerated filename prefix: {base_sample_name}") + print(f"Local save root: {root_save_dir}") + print("Mode folders will use `xpl/` and `ppl/`.") + print("Each capture will save RAW16 first, then convert that file to RGB.") + + upload_after_capture = ( + input("\nUpload captured files to MongoDB GridFS after local imaging? (y/n): ").strip().lower() == "y" + ) + confirm = input("Proceed with imaging using these parameters? (y/n): ").strip().lower() + if confirm != "y": + print("Imaging cancelled.") + mongo.close_connection() + return + + print("\nInitializing hardware...") + apis = APIS( + name="apis", + port=APIS_PORT, + baudrate=9600, + timeout=0.5, + connection_wait_s=2.0, + settling_time_s=1.5, + command_delay_s=0.05, + max_retries=3, + use_camera=True, + camera_save_directory=root_save_dir, + camera_bayer_pattern="GBRG", + camera_raw_max_value=1023.0, + ) + xpl_dir = apis.resolve_mode_directory(root_save_dir, params["polymer_name"], "xpl") + ppl_dir = apis.resolve_mode_directory(root_save_dir, params["polymer_name"], "ppl") + + capture_records: List[dict] = [] + seq = CommandSequence() + seq.add_device(apis) + + seq.add_command(APISConnect(apis)) + seq.add_command(APISInitialize(apis)) + seq.add_command(APISHome(apis)) + + add_mode_capture_commands( + seq=seq, + apis=apis, + mode_name="xpl", + polarizer_angle=90.0, + sample_angles=SAMPLE_ANGLES_DEG, + exposure_time=XPL_EXPOSURE_US, + base_name=base_sample_name, + save_dir=xpl_dir, + capture_records=capture_records, + ) + add_mode_capture_commands( + seq=seq, + apis=apis, + mode_name="ppl", + polarizer_angle=0.0, + sample_angles=SAMPLE_ANGLES_DEG, + exposure_time=PPL_EXPOSURE_US, + base_name=base_sample_name, + save_dir=ppl_dir, + capture_records=capture_records, + ) + + seq.add_command(APISRotatePolarizer(apis, angle_deg=0.0)) + seq.add_command(APISRotateSample(apis, angle_deg=0.0)) + seq.add_command(APISDeinitialize(apis)) + + print("\nRunning imaging sequence...") + invoker = CommandInvoker(seq, False) + result = invoker.invoke_commands() + print(f"\nImaging finished. Result: {result}") + + if result and upload_after_capture: + uploaded_count, skipped_count = upload_capture_records_to_mongodb( + mongo=mongo, + campaign_doc=campaign_doc, + set_doc=set_doc, + capture_records=capture_records, + ) + print( + f"Uploaded {uploaded_count} files to GridFS and inserted matching metadata into `images`." + ) + if skipped_count: + print(f"Skipped {skipped_count} expected files because they were not found on disk.") + elif upload_after_capture: + print("Skipping MongoDB upload because imaging did not complete successfully.") + + mongo.close_connection() + + +if __name__ == "__main__": + main() diff --git a/examples/example_esp301_3n.py b/examples/example_esp301_3n.py new file mode 100644 index 0000000..3ec6544 --- /dev/null +++ b/examples/example_esp301_3n.py @@ -0,0 +1,88 @@ +# ESP301-3N smoke test +# run from root using 'python -m examples.example_esp301_3n' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_sequence import CommandSequence +from command_invoker import CommandInvoker +from devices.newport_esp301 import NewportESP301 +from commands.newport_esp301_commands import * + + +ESP301_PORT = "COM6" + +AXIS_CONFIGS = { + 1: { + "stage_model": "ILS100CC", + "motion_type": "linear", + "units": "mm", + "home_mode": "OR4", + "zero_position": 0.0, + "default_speed": 10.0, + }, + 2: { + "stage_model": "UTS100PP", + "motion_type": "linear", + "units": "mm", + "home_mode": "OR4", + "zero_position": 0.0, + "default_speed": 10.0, + }, + 3: { + "stage_model": "PR50PP", + "motion_type": "rotary", + "units": "deg", + "home_mode": "OR1", + "zero_position": 0.0, + "default_speed": 10.0, + }, +} + + +def main() -> None: + seq = CommandSequence() + + esp301 = NewportESP301( + name="esp301_3n", + port=ESP301_PORT, + axis_list=(1, 2, 3), + default_speed=10.0, + poll_interval=0.1, + axis_configs=AXIS_CONFIGS, + ) + seq.add_device(esp301) + + seq.add_command(NewportESP301Connect(esp301)) + seq.add_command(NewportESP301Initialize(esp301)) + + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=20.0, speed=5.0)) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=5.0, speed=5.0)) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=20.0, speed=5.0)) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=5.0, speed=5.0)) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=3, position=45.0, speed=10.0)) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=3, position=120.0, speed=10.0)) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=3, position=15.0, speed=10.0)) + + # Re-run initialization at the end to confirm all three axes can home again. + seq.add_command(NewportESP301Initialize(esp301)) + + log_file = "logs/example_esp301_3n.log" + invoker = CommandInvoker(seq, log_to_file=True, log_filename=log_file, alert_slack=False) + + seq.print_command_names() + print("\nBefore running, place axes 1 and 2 away from the negative limit and make sure axis 3 has clearance for a full home search.") + userinput = input("\ntype 'y' to continue, type anything else to quit: ").strip().lower() + if userinput == "y": + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/examples/example_festo_solenoid_valve.py b/examples/example_festo_solenoid_valve.py new file mode 100644 index 0000000..8dae248 --- /dev/null +++ b/examples/example_festo_solenoid_valve.py @@ -0,0 +1,82 @@ +# Festo solenoid valve smoke test +# run from repo root with: +# python -m examples.example_festo_solenoid_valve + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence +from devices.festo_solenoid_valve import FestoSolenoidValve +from commands.festo_solenoid_valve_commands import ( + FestoCloseAll, + FestoConnect, + FestoDeinitialize, + FestoInitialize, + FestoValveClosed, + FestoValveOpen, +) + +DEFAULT_PORT = "COM9" +PIN8_VALVE_NUM = 2 +PIN4_VALVE_NUM = 3 + + +def main() -> None: + port = input( + f"Enter the Arduino COM port for the Festo valve controller [{DEFAULT_PORT}]: " + ).strip() + if not port: + port = DEFAULT_PORT + + confirm = input( + f"This test will use {port}, then close both valves, open valve_num={PIN8_VALVE_NUM} (pin 8), close it, open valve_num={PIN4_VALVE_NUM} (pin 4), close it, then deinitialize. Type 'y' to continue: " + ).strip().lower() + if confirm != "y": + print("Smoke test cancelled.") + return + + valve = FestoSolenoidValve( + name="festo_valve", + port=port, + baudrate=9600, + timeout=0.5, + ) + + seq = CommandSequence() + seq.add_device(valve) + seq.add_command(FestoConnect(valve)) + seq.add_command(FestoInitialize(valve)) + seq.add_command(FestoCloseAll(valve)) + seq.add_command(FestoValveOpen(valve, valve_num=PIN8_VALVE_NUM, delay=5.0)) + seq.add_command(FestoValveClosed(valve, valve_num=PIN8_VALVE_NUM, delay=60)) + # seq.add_command(FestoValveOpen(valve, valve_num=PIN4_VALVE_NUM, delay=45.0)) + # seq.add_command(FestoValveClosed(valve, valve_num=PIN4_VALVE_NUM, delay=15)) + seq.add_command(FestoCloseAll(valve)) + seq.add_command(FestoDeinitialize(valve)) + + print("\nAssumptions:") + print("- Arduino sketch: to_implement/festo_solenoid_valve_multiple/Festo_Multiple.ino") + print("- With the current sketch, valve_num=2 uses Arduino pin D8 and valve_num=3 uses D4.") + print("- valve_num=1 is still mapped to D12 unless you remap the sketch.") + print("- The Arduino pin must drive a MOSFET/relay/driver board, not the valve coil directly.") + print("- The valve coil power must come from an external supply with flyback protection.") + + invoker = CommandInvoker( + seq, + log_to_file=True, + log_filename="logs/example_festo_solenoid_valve.log", + alert_slack=False, + ) + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/examples/example_film_characterization.py b/examples/example_film_characterization.py new file mode 100644 index 0000000..a040127 --- /dev/null +++ b/examples/example_film_characterization.py @@ -0,0 +1,202 @@ +# Film characterization example combining Kinova handling, APIS rotation, and P4PP probing. +# run from root using 'python -m examples.example_film_characterization' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence +from devices.apis import APIS +from devices.kinova_arm import KinovaArm +from devices.p4pp import P4PP +from commands.apis_commands import * +from commands.kinova_arm_commands import * +from commands.p4pp_commands import * + + +APIS_PORT = "COM8" +P4PP_PORT = "COM19" +SAMPLE_ANGLES_DEG = [0, 45, 90] +P4PP_TOUCHDOWN_MM = 47.0 +P4PP_ROTATION_CLEARANCE_MM = 40.0 +P4PP_ROTATION_STEP_DEG = 45.0 +P4PP_CONTACT_DELAY_S = 5.0 +P4PP_MEASUREMENT_RESISTOR_OHMS = 681.0 + + +def add_legacy_apis_rotation_commands( + seq: CommandSequence, + apis: APIS, + sample_angles_deg, + sample_delay_s: float = 0.5, +) -> None: + # Keep the same logical flow as the old polarizer example: + # polarizer at 0 deg, rotate the sample through each angle, + # then polarizer at 90 deg and repeat. + seq.add_command(APISRotatePolarizer(apis, angle_deg=0.0)) + for angle in sample_angles_deg: + seq.add_command(APISRotateSample(apis, angle_deg=float(angle), delay=sample_delay_s)) + + seq.add_command(APISRotatePolarizer(apis, angle_deg=90.0)) + for angle in sample_angles_deg: + seq.add_command(APISRotateSample(apis, angle_deg=float(angle), delay=sample_delay_s)) + + seq.add_command(APISRotatePolarizer(apis, angle_deg=0.0)) + seq.add_command(APISRotateSample(apis, angle_deg=0.0)) + + +def add_p4pp_characterization_commands(seq: CommandSequence, p4pp: P4PP) -> None: + # Measure at 0 deg, 45 deg, then 90 deg using 45 deg relative steps. + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=P4PP_TOUCHDOWN_MM)) + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=P4PP_ROTATION_CLEARANCE_MM, delay=P4PP_CONTACT_DELAY_S)) + + seq.add_command(P4PPMoveRotationalRelative(p4pp, distance_deg=P4PP_ROTATION_STEP_DEG)) + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=P4PP_TOUCHDOWN_MM)) + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=P4PP_ROTATION_CLEARANCE_MM, delay=P4PP_CONTACT_DELAY_S)) + + seq.add_command(P4PPMoveRotationalRelative(p4pp, distance_deg=P4PP_ROTATION_STEP_DEG)) + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=P4PP_TOUCHDOWN_MM)) + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=P4PP_ROTATION_CLEARANCE_MM, delay=P4PP_CONTACT_DELAY_S)) + seq.add_command(P4PPHomeAll(p4pp)) + + + +def main() -> None: + seq = CommandSequence() + + arm = KinovaArm("arm") + apis = APIS( + name="apis", + port=APIS_PORT, + baudrate=9600, + timeout=0.5, + connection_wait_s=2.0, + settling_time_s=1.5, + command_delay_s=0.05, + max_retries=3, + use_camera=False, + ) + p4pp = P4PP( + name="p4pp", + port=P4PP_PORT, + baudrate=115200, + timeout=0.2, + startup_delay=2.0, + command_timeout=10.0, + motion_timeout=60.0, + home_timeout=60.0, + measure_timeout=30.0, + poll_interval=0.2, + rotation_safety_linear_mm=45.0, + measurement_resistor_ohms=P4PP_MEASUREMENT_RESISTOR_OHMS, + save_directory="data/resistance/", + ) + + seq.add_device(arm) + seq.add_device(apis) + seq.add_device(p4pp) + + seq.add_command(KinovaArmConnect(arm)) + seq.add_command(APISConnect(apis)) + seq.add_command(P4PPConnect(p4pp)) + + seq.add_command(APISInitialize(apis)) + seq.add_command(APISHome(apis)) + seq.add_command(P4PPInitialize(p4pp)) + seq.add_command(P4PPSetMeasurementResistor(p4pp, resistor_ohms=P4PP_MEASUREMENT_RESISTOR_OHMS)) + seq.add_command(P4PPHomeAll(p4pp)) + seq.add_command(KinovaArmInitialize(arm)) + + seq.add_command(KinovaArmExecuteAction(arm, action_name="Home")) + seq.add_command(KinovaArmOpenGripper(arm)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_Up_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_Down_Angles")) + seq.add_command(KinovaArmCloseGripper(arm)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_Up_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Ready_Angles")) + + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Down_Out_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Down_In_Angles", delay=5)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Up_In_Angles", delay=5)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_High_In_Angles", delay="P")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Ready_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="UV_Intermediate_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Intermediate_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Up_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Down_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Down_Out_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Intermediate_Angles")) + + # Image sequence + add_legacy_apis_rotation_commands( + seq=seq, + apis=apis, + sample_angles_deg=SAMPLE_ANGLES_DEG, + ) + + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Down_Out_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Down_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Up_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Intermediate_Angles")) + + seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_Intermediate_Angles", delay=5)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_Up_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_Down_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_Down_Out_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_Intermediate_Angles")) + + + # Resistance sequence + # add_p4pp_characterization_commands(seq=seq, p4pp=p4pp) + + # seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_Down_Out_Angles", delay=5)) + # seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_Down_Angles")) + # seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_Up_Angles")) + # seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_High_Angles")) + # seq.add_command(KinovaArmExecuteAction(arm, action_name="Resistance_Intermediate_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Image_Intermediate_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="UV_Intermediate_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_Up_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_Down_Angles")) + seq.add_command(KinovaArmOpenGripper(arm)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_Up_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Sub_Handler_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Home")) + + + # Resistance sequence + add_p4pp_characterization_commands(seq=seq, p4pp=p4pp) + + + seq.add_command(APISDeinitialize(apis)) + seq.add_command(P4PPDeinitialize(p4pp)) + + log_file = "logs/example_film_characterization.log" + invoker = CommandInvoker(seq, log_to_file=True, log_filename=log_file, alert_slack=False) + + print("This example combines Kinova handling, APIS rotation, and P4PP characterization.") + print("APIS camera support is disabled here; the APIS portion rotates the film before the P4PP probing sequence.") + print("The P4PP portion probes at 0 deg, 45 deg, and 90 deg using 47 mm contact and 40 mm rotation clearance.") + seq.print_command_names() + userinput = input("\ntype 'y' to continue, type anything else to quit: ").strip().lower() + if userinput == "y": + result = invoker.invoke_commands() + print(result) + + +if __name__ == "__main__": + main() diff --git a/examples/example_heating_stage.py b/examples/example_heating_stage.py new file mode 100644 index 0000000..ca7e36f --- /dev/null +++ b/examples/example_heating_stage.py @@ -0,0 +1,50 @@ +# HeatingStage smoke test +# run from root using 'python -m examples.example_heating_stage' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_sequence import CommandSequence +from command_invoker import CommandInvoker +from devices.heating_stage import HeatingStage +from commands.heating_stage_commands import * + + +HEATER_PORT = "COM16" +TARGET_TEMPERATURE_C = 40.0 +ROOM_TEMPERATURE_C = 25.0 + + +def main() -> None: + seq = CommandSequence() + + heater = HeatingStage("heater", HEATER_PORT, 115200, timeout=0.1, heating_timeout=1200.0) + seq.add_device(heater) + + seq.add_command(HeatingStageConnect(heater)) + seq.add_command(HeatingStageInitialize(heater)) + seq.add_command(HeatingStageSetSetPoint(heater, TARGET_TEMPERATURE_C)) + # Wait until the target temperature is held within ±1 C for 30 seconds. + seq.add_command(HeatingStageWaitForTemperature(heater, target=TARGET_TEMPERATURE_C, tolerance=1.0, timeout=1200.0, poll_interval=1.0, hold_duration=30.0)) + seq.add_command(HeatingStageSetSetPoint(heater, ROOM_TEMPERATURE_C)) + seq.add_command(HeatingStageDeinitialize(heater)) + + log_file = "logs/example_heating_stage.log" + invoker = CommandInvoker(seq, log_to_file=True, log_filename=log_file, alert_slack=False) + + seq.print_command_names() + print("\nThis smoke test changes the setpoint to the target temperature, then returns the setpoint to room temperature.") + userinput = input("\ntype 'y' to continue, type anything else to quit: ").strip().lower() + if userinput == "y": + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/examples/example_linear_stage_150.py b/examples/example_linear_stage_150.py new file mode 100644 index 0000000..26e73e5 --- /dev/null +++ b/examples/example_linear_stage_150.py @@ -0,0 +1,54 @@ +# LinearStage150 smoke test +# run from root using 'python -m examples.example_linear_stage_150' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_sequence import CommandSequence +from command_invoker import CommandInvoker +from devices.linear_stage_150 import LinearStage150 +from commands.linear_stage_150_commands import * + + +STAGE_PORT = "COM15" + + +def main() -> None: + seq = CommandSequence() + + stage = LinearStage150( + name="linear_stage_150", + port=STAGE_PORT, + baudrate=115200, + timeout=0.1, + destination=0x50, + source=0x01, + channel=1, + ) + seq.add_device(stage) + + seq.add_command(LinearStage150Connect(stage)) + seq.add_command(LinearStage150Initialize(stage)) + seq.add_command(LinearStage150MoveAbsolute(stage, position=100.0)) + seq.add_command(LinearStage150MoveRelative(stage, distance=5.0)) + seq.add_command(LinearStage150MoveAbsolute(stage, position=0.0)) + seq.add_command(LinearStage150Deinitialize(stage)) + + log_file = "logs/example_linear_stage_150.log" + invoker = CommandInvoker(seq, log_to_file=True, log_filename=log_file, alert_slack=False) + + seq.print_command_names() + userinput = input("\ntype 'y' to continue, type anything else to quit: ").strip().lower() + if userinput == "y": + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/examples/example_p4pp.py b/examples/example_p4pp.py new file mode 100644 index 0000000..fd7d944 --- /dev/null +++ b/examples/example_p4pp.py @@ -0,0 +1,70 @@ +# P4PP smoke test +# run from root using 'python -m examples.example_p4pp' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_sequence import CommandSequence +from command_invoker import CommandInvoker +from devices.p4pp import P4PP +from commands.p4pp_commands import * + + +P4PP_PORT = "COM19" + + +def main() -> None: + seq = CommandSequence() + + p4pp = P4PP( + name="p4pp", + port=P4PP_PORT, + baudrate=115200, + timeout=0.2, + startup_delay=2.0, + command_timeout=10.0, + motion_timeout=60.0, + home_timeout=60.0, + measure_timeout=30.0, + poll_interval=0.2, + rotation_safety_linear_mm=45.0, + measurement_resistor_ohms=681.0, + save_directory="data/resistance/", + ) + seq.add_device(p4pp) + + seq.add_command(P4PPConnect(p4pp)) + seq.add_command(P4PPInitialize(p4pp)) + seq.add_command(P4PPSetMeasurementResistor(p4pp, resistor_ohms=681.0)) + seq.add_command(P4PPHomeAll(p4pp)) + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=5.0)) + seq.add_command(P4PPMoveRotationalAbsolute(p4pp, position_deg=30.0)) + seq.add_command(P4PPRefreshPosition(p4pp)) + seq.add_command(P4PPMoveRotationalAbsolute(p4pp, position_deg=0.0)) + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=0.0)) + # Uncomment when the probe/sample path is ready for measurement. + # seq.add_command(P4PPMeasure(p4pp, cycles=20)) + # seq.add_command(P4PPSaveMeasurementCsv(p4pp, sample_id="demo_sample")) + seq.add_command(P4PPDeinitialize(p4pp)) + + log_file = "logs/example_p4pp.log" + invoker = CommandInvoker(seq, log_to_file=True, log_filename=log_file, alert_slack=False) + + print("This smoke test uses the Python-side P4PP integration.") + print("Measurement resistor selection is explicit. Default is 681 ohm.") + print("Firmware and hardware details should be referenced from https://github.com/polyprintillinois/P4PP .") + seq.print_command_names() + userinput = input("\ntype 'y' to continue, type anything else to quit: ").strip().lower() + if userinput == "y": + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/examples/example_p4pp_rotation_sweep.py b/examples/example_p4pp_rotation_sweep.py new file mode 100644 index 0000000..a740187 --- /dev/null +++ b/examples/example_p4pp_rotation_sweep.py @@ -0,0 +1,164 @@ +# P4PP rotation sweep measurement +# run from root using 'python -m examples.example_p4pp_rotation_sweep' + +import csv +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_sequence import CommandSequence +from command_invoker import CommandInvoker +from commands.command import CommandResult +from commands.p4pp_commands import ( + P4PPConnect, + P4PPDeinitialize, + P4PPHomeAll, + P4PPInitialize, + P4PPMeasure, + P4PPMoveLinearAbsolute, + P4PPMoveRotationalAbsolute, + P4PPParentCommand, + P4PPRefreshPosition, + P4PPSaveMeasurementCsv, + P4PPSetMeasurementResistor, +) +from devices.p4pp import P4PP + + +P4PP_PORT = "COM19" +SAMPLE_NAME = "sample_name" +SAVE_DIRECTORY = "data/resistance" +MEASUREMENT_CYCLES = 25 +MEASUREMENT_RESISTOR_OHMS = 681.0 +RETRACT_LINEAR_MM = 40.0 +MEASURE_LINEAR_MM = 47.0 +ANGLE_START_DEG = 0 +ANGLE_STOP_DEG = 180 +ANGLE_STEP_DEG = 5 + + +class P4PPSaveRotationSweepSummary(P4PPParentCommand): + """Append the latest P4PP rotation sweep average/std result to a merged CSV.""" + + def __init__(self, receiver: P4PP, angle_deg: float, sample_id: str, csv_path: str, notes: str = "", **kwargs): + super().__init__(receiver, **kwargs) + self._params["angle_deg"] = angle_deg + self._params["sample_id"] = sample_id + self._params["csv_path"] = csv_path + self._params["notes"] = notes + + def execute(self) -> None: + if self._receiver.latest_result is None: + self._result = CommandResult(False, "No P4PP measurement result is available to summarize.") + return + + csv_path = self._params["csv_path"] + Path(csv_path).parent.mkdir(parents=True, exist_ok=True) + file_exists = Path(csv_path).is_file() + resistor_info = self._receiver.get_measurement_resistor_info() + _, linear_position_mm = self._receiver.get_linear_position_mm() + _, rotational_position_deg = self._receiver.get_rotational_position_deg() + cycle_results = self._receiver.cycle_results + + row = { + "sample_id": self._params["sample_id"], + "angle_deg": self._params["angle_deg"], + "linear_position_mm": linear_position_mm, + "rotational_position_deg": rotational_position_deg, + "cycles": len(cycle_results) if cycle_results else 1, + "r_set_ohms": resistor_info["R_set"], + "r_sheet_avg": self._receiver.latest_result, + "r_sheet_std": self._receiver.latest_std if self._receiver.latest_std is not None else "", + "notes": self._params["notes"], + } + + with open(csv_path, "a", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(row.keys())) + if not file_exists: + writer.writeheader() + writer.writerow(row) + + self._result = CommandResult(True, "Successfully saved P4PP rotation sweep summary to " + csv_path) + + +def main() -> None: + seq = CommandSequence() + + p4pp = P4PP( + name="p4pp", + port=P4PP_PORT, + baudrate=115200, + timeout=0.2, + startup_delay=2.0, + command_timeout=10.0, + motion_timeout=60.0, + home_timeout=60.0, + measure_timeout=60.0, + poll_interval=0.2, + rotation_safety_linear_mm=45.0, + measurement_resistor_ohms=MEASUREMENT_RESISTOR_OHMS, + save_directory=SAVE_DIRECTORY, + ) + seq.add_device(p4pp) + + sample_dir = Path(SAVE_DIRECTORY) / SAMPLE_NAME + measurement_csv_path = sample_dir / f"{SAMPLE_NAME}_p4pp_rotation_sweep_measurements.csv" + merged_csv_path = sample_dir / f"{SAMPLE_NAME}_p4pp_rotation_sweep_merged.csv" + angles_deg = list(range(ANGLE_START_DEG, ANGLE_STOP_DEG, ANGLE_STEP_DEG)) + + seq.add_command(P4PPConnect(p4pp)) + seq.add_command(P4PPInitialize(p4pp)) + seq.add_command(P4PPHomeAll(p4pp)) + seq.add_command(P4PPSetMeasurementResistor(p4pp, resistor_ohms=MEASUREMENT_RESISTOR_OHMS)) + + for angle_deg in angles_deg: + # Retract below the rotation safety limit before changing angle. + sample_id = f"{SAMPLE_NAME}_{angle_deg}deg" + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=RETRACT_LINEAR_MM)) + seq.add_command(P4PPMoveRotationalAbsolute(p4pp, position_deg=float(angle_deg))) + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=MEASURE_LINEAR_MM)) + seq.add_command(P4PPMeasure(p4pp, cycles=MEASUREMENT_CYCLES)) + seq.add_command( + P4PPSaveMeasurementCsv( + p4pp, + sample_id=sample_id, + csv_path=str(measurement_csv_path), + notes="P4PP 0:180:5 rotation sweep at 47 mm linear position", + ) + ) + seq.add_command( + P4PPSaveRotationSweepSummary( + p4pp, + angle_deg=float(angle_deg), + sample_id=sample_id, + csv_path=str(merged_csv_path), + notes="P4PP 0:180:5 rotation sweep avg/std summary", + ) + ) + + seq.add_command(P4PPMoveLinearAbsolute(p4pp, position_mm=RETRACT_LINEAR_MM)) + seq.add_command(P4PPRefreshPosition(p4pp)) + seq.add_command(P4PPDeinitialize(p4pp)) + + log_file = "logs/example_p4pp_rotation_sweep.log" + invoker = CommandInvoker(seq, log_to_file=True, log_filename=log_file, alert_slack=False) + + print("This P4PP example homes the axes, selects the 681 ohm resistor,") + print(f"then measures {len(angles_deg)} angles from 0 to 175 deg in 5 deg steps.") + print(f"Each angle is measured with {MEASUREMENT_CYCLES} cycles at {MEASURE_LINEAR_MM} mm.") + print(f"Detailed results will be appended to {measurement_csv_path}.") + print(f"Merged avg/std results will be appended to {merged_csv_path}.") + seq.print_command_names() + userinput = input("\ntype 'y' to continue, type anything else to quit: ").strip().lower() + if userinput == "y": + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/examples/example_printing_recipe.py b/examples/example_printing_recipe.py new file mode 100644 index 0000000..0f532f5 --- /dev/null +++ b/examples/example_printing_recipe.py @@ -0,0 +1,314 @@ +# Printing recipe example scaffold. +# run from root using 'python -m examples.example_printing_recipe' + +import sys +from pathlib import Path +from typing import Dict + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence +from commands.newport_esp301_commands import * +from commands.kinova_arm_commands import * +from commands.psd6_syringe_pump_commands import * +from commands.sonicator_commands import * +from devices.kinova_arm import KinovaArm +from devices.newport_esp301 import NewportESP301 +from devices.psd6_syringe_pump import PSD6SyringePump +from devices.sonicator import Sonicator +from recipes.user_recipes.example_mongodb_recipe_lookup import ( + DEFAULT_CAMPAIGN_NAME, + build_recipe_params, + fetch_campaign, + fetch_parameter_set, + get_mongo_helper, + list_batch_numbers, + list_sample_numbers, + load_env, + prompt_choice, + prompt_with_default, +) + + +ESP301_PORT = "COM6" +PSD6_1ML_PORT = "COM14" +PSD6_25ML_PORT = "COM17" +SONICATOR_PORT = "COM13" +PSD6_1ML_STROKE_VOLUME_UL = 1_000.0 +PSD6_25ML_STROKE_VOLUME_UL = 25_000.0 +PSD6_1ML_DEFAULT_FLOWRATE_UL_S = 50.0 +PSD6_25ML_DEFAULT_FLOWRATE_UL_S = 500.0 + +PSD6_PORT_LIQUID_HANDLER = 1 +PSD6_PORT_IPA = 2 +PSD6_PORT_AIR_EMPTY = 5 +PSD6_PORT_WASTE = 6 +PSD6_PORT_SAFE_IDLE = PSD6_PORT_AIR_EMPTY +PSD6_AIR_PURGE_FLOWRATE_UL_S = 5.0 +PSD6_LOAD_FLOWRATE_UL_S = 10.0 +PSD6_PURGE_FLOWRATE_UL_S = 10.0 +PSD6_AIR_PURGE_VOLUME_UL = 100.0 +PSD6_LOAD_VOLUME_UL = 1_000.0 +PSD6_FIRST_PURGE_VOLUME_UL = 700.0 +PSD6_RELOAD_VOLUME_UL = 300.0 +PSD6_SECOND_PURGE_VOLUME_UL = 500.0 +PSD6_CLEANING_FLOWRATE_UL_S = 1_000.0 +PSD6_CLEANING_VOLUME_UL = 15_000.0 +PSD6_CLEANING_RESERVOIR_DRAIN_REPEATS = 2 +PSD6_PORT_SONICATOR_RESERVOIR = 1 +PSD6_PORT_ACETONE = 3 +PSD6_PORT_TOLUENE = 4 +PSD6_CLEANING_SOLVENT_PORTS = ( + (PSD6_PORT_IPA, "IPA"), + (PSD6_PORT_ACETONE, "acetone"), + (PSD6_PORT_TOLUENE, "toluene"), +) +SONICATION_TIME_S = 30.0 + +ESP301_AXIS_CONFIGS = { + 1: { + "stage_model": "ILS100CC", + "motion_type": "linear", + "units": "mm", + "home_mode": "OR4", + "zero_position": 0.0, + "default_speed": 10.0, + }, + 2: { + "stage_model": "UTS100PP", + "motion_type": "linear", + "units": "mm", + "home_mode": "OR4", + "zero_position": 0.0, + "default_speed": 10.0, + }, +} + + +PRINTING_AXIS2_POSITION_AT_0_UM = 36.9 +PRINTING_AXIS2_MM_PER_UM_GAP = 0.001 + + +def printing_gap_to_axis2_position(printing_gap_um: float) -> float: + return PRINTING_AXIS2_POSITION_AT_0_UM - PRINTING_AXIS2_MM_PER_UM_GAP * printing_gap_um + + +def load_recipe_params() -> Dict[str, object]: + load_env() + mongo = get_mongo_helper() + + campaign_name = prompt_with_default("Campaign name", DEFAULT_CAMPAIGN_NAME) + if not campaign_name: + raise ValueError("Campaign name is required.") + + campaign_doc = fetch_campaign(mongo, campaign_name) + batch_no = prompt_choice("batch_no values", list_batch_numbers(mongo, campaign_doc)) + sample_no = prompt_choice("sample_no values", list_sample_numbers(mongo, campaign_doc, batch_no)) + + campaign_doc, set_doc = fetch_parameter_set(mongo, campaign_doc, batch_no, sample_no) + params = build_recipe_params(batch_no, sample_no, set_doc) + params["campaign_name"] = campaign_doc["campaign_name"] + return params + + +def build_sequence(params: Dict[str, object]) -> CommandSequence: + seq = CommandSequence() + printing_speed = float(params["motor_speed"]) + printing_gap_um = float(params["printing_gap"]) + precursor_volume_ul = float(params["precursor_volume"]) + printing_ready_axis2_position = printing_gap_to_axis2_position(printing_gap_um) + + esp301 = NewportESP301( + name="esp301", + port=ESP301_PORT, + axis_list=(1, 2), + default_speed=10.0, + poll_interval=0.1, + axis_configs=ESP301_AXIS_CONFIGS, + ) + arm = KinovaArm("arm") + sonicator = Sonicator( + name="sonicator", + port=SONICATOR_PORT, + baudrate=9600, + timeout=0.5, + connect_delay_s=3.0, + response_timeout_s=3.0, + power_probe_timeout_s=5.0, + line_terminator="\n", + command_prefix=">", + debug_io=False, + ) + liquid_handler_pump = PSD6SyringePump( + name="psd6_1ml_liquid_handler", + port=PSD6_1ML_PORT, + baudrate=9600, + timeout=10.0, + stroke_volume=PSD6_1ML_STROKE_VOLUME_UL, + stroke_steps=6000, + default_flowrate=PSD6_1ML_DEFAULT_FLOWRATE_UL_S, + port_dead_volumes=[0.0] * 6, + poll_interval=0.1, + ) + solvent_pump = PSD6SyringePump( + name="psd6_25ml_solvent", + port=PSD6_25ML_PORT, + baudrate=9600, + timeout=10.0, + stroke_volume=PSD6_25ML_STROKE_VOLUME_UL, + stroke_steps=6000, + default_flowrate=PSD6_25ML_DEFAULT_FLOWRATE_UL_S, + port_dead_volumes=[0.0] * 6, + poll_interval=0.1, + ) + + seq.add_device(esp301) + seq.add_device(arm) + seq.add_device(sonicator) + seq.add_device(liquid_handler_pump) + seq.add_device(solvent_pump) + + seq.add_command(NewportESP301Connect(esp301)) + seq.add_command(KinovaArmConnect(arm)) + seq.add_command(SonicatorConnect(sonicator)) + seq.add_command(PSD6SyringePumpConnect(liquid_handler_pump)) + seq.add_command(PSD6SyringePumpConnect(solvent_pump)) + seq.add_command(NewportESP301Initialize(esp301)) # initialize printer head (0,0) + seq.add_command(KinovaArmInitialize(arm)) + seq.add_command(SonicatorInitialize(sonicator)) + seq.add_command(PSD6SyringePumpInitialize(liquid_handler_pump)) + seq.add_command(PSD6SyringePumpInitialize(solvent_pump)) + seq.add_command(PSD6SyringePumpMoveAbsolute(liquid_handler_pump, volume=0.0, valve_num=PSD6_PORT_AIR_EMPTY, flowrate=PSD6_AIR_PURGE_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpWithdraw(liquid_handler_pump, volume=PSD6_AIR_PURGE_VOLUME_UL, valve_num=PSD6_PORT_AIR_EMPTY, flowrate=PSD6_AIR_PURGE_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpInfuse(liquid_handler_pump, volume=PSD6_AIR_PURGE_VOLUME_UL, valve_num=PSD6_PORT_LIQUID_HANDLER, flowrate=PSD6_AIR_PURGE_FLOWRATE_UL_S)) + for drain_idx in range(PSD6_CLEANING_RESERVOIR_DRAIN_REPEATS): + seq.add_command(PSD6SyringePumpMoveAbsolute(solvent_pump, volume=0.0, valve_num=PSD6_PORT_SONICATOR_RESERVOIR, flowrate=PSD6_CLEANING_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpWithdraw(solvent_pump, volume=PSD6_CLEANING_VOLUME_UL, valve_num=PSD6_PORT_SONICATOR_RESERVOIR, flowrate=PSD6_CLEANING_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpInfuse(solvent_pump, volume=PSD6_CLEANING_VOLUME_UL, valve_num=PSD6_PORT_WASTE, flowrate=PSD6_CLEANING_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpMoveValve(liquid_handler_pump, valve_num=PSD6_PORT_SAFE_IDLE)) + seq.add_command(PSD6SyringePumpMoveValve(solvent_pump, valve_num=PSD6_PORT_SAFE_IDLE)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Home")) + + ## move printer head to sonicator up position(79, 10) to not mess with the arm ## + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=10.0, speed=10.0)) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=79.0, speed=10.0)) # sonicator up position (79,10) + + + ## move arm to liquid dispenser ## + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Ready_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Liq_Handler_Up_Out_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Liq_Handler_Down_Out_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Liq_Handler_Down_Angles")) + seq.add_command(KinovaArmCloseGripper(arm)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Liq_Handler_Up_Angles", delay=1)) + + ## move liquid dispenser to designated solution position(solution map needs to be implemented) ## + seq.add_command(KinovaArmExecuteAction(arm, action_name="Solution_Hotel_Ready_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Solution_1_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Solution_1_Up_Angles", delay=5)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Solution_1_Down_Angles")) + + ## withdraw solution using PSD6 pump ## + ## liquid_handler_pump valve map: 1=liquid handler, 2=IPA, 5=air/empty, 6=waste ## + seq.add_command(PSD6SyringePumpMoveAbsolute(liquid_handler_pump, volume=0.0, valve_num=PSD6_PORT_LIQUID_HANDLER, flowrate=PSD6_LOAD_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpWithdraw(liquid_handler_pump, volume=PSD6_LOAD_VOLUME_UL, valve_num=PSD6_PORT_LIQUID_HANDLER, flowrate=PSD6_LOAD_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpInfuse(liquid_handler_pump, volume=PSD6_FIRST_PURGE_VOLUME_UL, valve_num=PSD6_PORT_LIQUID_HANDLER, flowrate=PSD6_PURGE_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpWithdraw(liquid_handler_pump, volume=PSD6_RELOAD_VOLUME_UL, valve_num=PSD6_PORT_LIQUID_HANDLER, flowrate=PSD6_LOAD_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpInfuse(liquid_handler_pump, volume=PSD6_SECOND_PURGE_VOLUME_UL, valve_num=PSD6_PORT_LIQUID_HANDLER, flowrate=PSD6_PURGE_FLOWRATE_UL_S)) + + ## move liquid dispenser to printing position ## + seq.add_command(KinovaArmExecuteAction(arm, action_name="Solution_1_Up_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Solution_Hotel_Ready_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Ready_Angles")) + + ## move arm to Print_Dis_Left_Down position ## + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Dis_Center_High_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Dis_Left_Up_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Dis_Left_Down_Angles", delay=5)) + + ## move printer head to printing position(4,35) ## + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=40.0, speed=10.0)) # lowering printer head (79,40) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=4.0, speed=10.0)) # printing up position (4,40) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=35.0, speed=10.0)) # printing down position (4,35) + + ## add precursor solution using PSD6 pump ## + seq.add_command(PSD6SyringePumpInfuse(liquid_handler_pump, volume=precursor_volume_ul, valve_num=PSD6_PORT_LIQUID_HANDLER, flowrate=PSD6_PURGE_FLOWRATE_UL_S)) + + ## move to meniscus position ## + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=printing_ready_axis2_position, speed=10.0, delay='P')) # printing ready position adjusted by printing_gap (4,printing height) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=2.0, speed=10.0)) # making meniscus stable (2,PH) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=5.0, speed=10.0)) # making meniscus stable (5,PH) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=3.0, speed=10.0)) # making meniscus stable (3,PH) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=20, speed=printing_speed, delay=3)) # printing speed from MongoDB motor_speed (20, PH) + + ## move arm back to Liq_Handler ## + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Ready_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Solution_Hotel_Ready_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Liq_Handler_Up_Angles", delay=1)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Liq_Handler_Down_Angles")) + seq.add_command(KinovaArmOpenGripper(arm)) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Liq_Handler_Down_Out_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Liq_Handler_Up_Out_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Print_Ready_Angles")) + seq.add_command(KinovaArmExecuteAction(arm, action_name="Home")) + + ## end printing and move printer head to sonicator ## + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=10, speed=10.0)) # head up (20,10) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=79, speed=10.0)) # sonicator up position (79,10) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=70, speed=10.0)) # sonicator down position (79,70) + + ## add cleaning solvnet, sonicate ## + for solvent_port, solvent_name in PSD6_CLEANING_SOLVENT_PORTS: + seq.add_command(PSD6SyringePumpMoveAbsolute(solvent_pump, volume=0.0, valve_num=solvent_port, flowrate=PSD6_CLEANING_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpWithdraw(solvent_pump, volume=PSD6_CLEANING_VOLUME_UL, valve_num=solvent_port, flowrate=PSD6_CLEANING_FLOWRATE_UL_S)) + seq.add_command(PSD6SyringePumpInfuse(solvent_pump, volume=PSD6_CLEANING_VOLUME_UL, valve_num=PSD6_PORT_SONICATOR_RESERVOIR, flowrate=PSD6_CLEANING_FLOWRATE_UL_S)) + seq.add_command(SonicatorStartSonicating(sonicator)) + seq.add_command(SonicatorStopSonicating(sonicator, delay=SONICATION_TIME_S)) + + ## mechanical cleaning ## + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=35, speed=10.0)) # head up (79,35) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=45, speed=10.0)) # mechanical cleaning up position (45,35) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=43, speed=10.0)) # mechanical cleaning down position (45,43) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=79, speed=10.0)) # mechanical cleaning through linear movement (79,43) + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=70, speed=10.0)) # sonicator down position (79,70) + seq.add_command(SonicatorStopSonicating(sonicator, delay=SONICATION_TIME_S)) # second sonication + + ## move to drying position (3,35) ## + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=10, speed=10.0)) # head up + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=1, position=3, speed=10.0)) # printing up position + seq.add_command(NewportESP301MoveSpeedAbsolute(esp301, axis_number=2, position=35, speed=10.0)) # printing down position + + return seq + + +def main() -> None: + params = load_recipe_params() + printing_ready_axis2_position = printing_gap_to_axis2_position(float(params["printing_gap"])) + + print("\nLoaded MongoDB parameter set:") + for key, value in params.items(): + print(f" {key}: {value}") + print(f" printing_ready_axis2_position: {printing_ready_axis2_position:.4f} mm") + + seq = build_sequence(params) + + log_file = "logs/example_printing_recipe.log" + invoker = CommandInvoker(seq, log_to_file=True, log_filename=log_file, alert_slack=False) + + print("\nPrinting recipe preview.") + print("Sequence will use MongoDB motor_speed for axis 1 printing move and printing_gap for axis 2 ready position.") + seq.print_command_names() + userinput = input("\ntype 'y' to continue, type anything else to quit: ").strip().lower() + if userinput == "y": + result = invoker.invoke_commands() + print(result) + + +if __name__ == "__main__": + main() diff --git a/examples/example_psd6_1ml_suction_test.py b/examples/example_psd6_1ml_suction_test.py new file mode 100644 index 0000000..3a9a1f6 --- /dev/null +++ b/examples/example_psd6_1ml_suction_test.py @@ -0,0 +1,255 @@ +# PSD/6 1 mL port 1 transfer check +# run from repo root with: +# python -m examples.example_psd6_1ml_suction_test + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from devices.psd6_syringe_pump import PSD6SyringePump + + +PORT = "COM14" +STROKE_VOLUME_UL = 1_000.0 +AIR_PURGE_FLOWRATE_UL_S = 5.0 +LOAD_FLOWRATE_UL_S = 10.0 +PURGE_FLOWRATE_UL_S = 10.0 +DISPENSE_FLOWRATE_UL_S = 10.0 +AIR_SOURCE_PORT = 5 +SOURCE_PORT = 1 +DEST_PORT = 6 +AIR_PURGE_VOLUME_UL = 100.0 +LOAD_VOLUME_UL = 1_000.0 +FIRST_PURGE_VOLUME_UL = 700.0 +RELOAD_VOLUME_UL = 300.0 +SECOND_PURGE_VOLUME_UL = 500.0 +EXPECTED_REMAINING_VOLUME_UL = ( + LOAD_VOLUME_UL - FIRST_PURGE_VOLUME_UL + RELOAD_VOLUME_UL - SECOND_PURGE_VOLUME_UL +) + + +def transfer_volume( + pump: PSD6SyringePump, + source_port: int, + dest_port: int, + volume_ul: float, + flowrate_ul_s: float, + label: str, +) -> bool: + ok, message = pump.withdraw_syringe_volume( + volume_ul, + valve_num=source_port, + flowrate=flowrate_ul_s, + ) + print(f"{label} | withdraw from valve {source_port}: {ok} | {message}", flush=True) + if not ok: + return False + + ok, message = pump.infuse_syringe_volume( + volume_ul, + valve_num=dest_port, + flowrate=flowrate_ul_s, + ) + print(f"{label} | infuse to valve {dest_port}: {ok} | {message}", flush=True) + return ok + + +def main() -> None: + print("Hamilton PSD/6 1 mL port 1 automatic loading check") + print(f"- Port: {PORT}") + print("- Syringe: 1 mL") + print(f"- Air source valve: {AIR_SOURCE_PORT}") + print(f"- Source valve: {SOURCE_PORT} / liquid handler") + print(f"- Destination valve: {DEST_PORT}") + print("- Purpose: air-purge port 1, wet/load the liquid-handler line, and finish with 100 uL nominally loaded.") + print(f"- Air purge flowrate: {AIR_PURGE_FLOWRATE_UL_S:.1f} uL/s") + print(f"- Load flowrate: {LOAD_FLOWRATE_UL_S:.1f} uL/s") + print(f"- Liquid-handler purge flowrate: {PURGE_FLOWRATE_UL_S:.1f} uL/s") + print(f"- Interactive dispense flowrate: {DISPENSE_FLOWRATE_UL_S:.1f} uL/s") + print(f"- Air purge: {AIR_PURGE_VOLUME_UL:.0f} uL from valve {AIR_SOURCE_PORT} to valve {SOURCE_PORT}") + print(f"- Load: withdraw {LOAD_VOLUME_UL:.0f} uL from valve {SOURCE_PORT}") + print(f"- Wetting purge: infuse {FIRST_PURGE_VOLUME_UL:.0f} uL back to valve {SOURCE_PORT}") + print(f"- Reload: withdraw {RELOAD_VOLUME_UL:.0f} uL from valve {SOURCE_PORT}") + print(f"- Final purge: infuse {SECOND_PURGE_VOLUME_UL:.0f} uL back to valve {SOURCE_PORT}") + print(f"- Expected final syringe load: {EXPECTED_REMAINING_VOLUME_UL:.0f} uL") + + confirm = input( + f"This will connect to {PORT}, initialize the pump, air-purge {AIR_SOURCE_PORT}->{SOURCE_PORT}, " + f"then run the automatic 1000/700/300/500 uL loading sequence. Type 'y' to continue: " + ).strip().lower() + if confirm != "y": + print("Transfer check cancelled.") + return + + pump = PSD6SyringePump( + name="psd6_1ml_com14", + port=PORT, + baudrate=9600, + timeout=10.0, + stroke_volume=STROKE_VOLUME_UL, + stroke_steps=6000, + default_flowrate=LOAD_FLOWRATE_UL_S, + port_dead_volumes=[0.0] * 6, + poll_interval=0.1, + ) + + try: + ok, message = pump.start_serial(delay=1.0) + print(f"connect: {ok} | {message}") + if not ok: + return + + ok, message = pump.initialize() + print(f"initialize: {ok} | {message}") + if not ok: + return + + ok, message = pump.move_syringe_absolute_volume( + 0.0, + valve_num=AIR_SOURCE_PORT, + flowrate=AIR_PURGE_FLOWRATE_UL_S, + ) + print(f"reset to 0 uL on valve {AIR_SOURCE_PORT}: {ok} | {message}") + if not ok: + return + + ok = transfer_volume( + pump, + source_port=AIR_SOURCE_PORT, + dest_port=SOURCE_PORT, + volume_ul=AIR_PURGE_VOLUME_UL, + flowrate_ul_s=AIR_PURGE_FLOWRATE_UL_S, + label=f"air purge {AIR_SOURCE_PORT}->{SOURCE_PORT}", + ) + if not ok: + return + + pause_input = input( + f"Air purge to valve {SOURCE_PORT} is complete. Check the liquid-handler line, then type 'y' to continue loading: " + ).strip().lower() + if pause_input != "y": + print("Loading check cancelled after air purge.") + return + + ok, message = pump.move_syringe_absolute_volume( + 0.0, + valve_num=SOURCE_PORT, + flowrate=LOAD_FLOWRATE_UL_S, + ) + print(f"reset to 0 uL on valve {SOURCE_PORT}: {ok} | {message}") + if not ok: + return + + ok, message = pump.withdraw_syringe_volume( + LOAD_VOLUME_UL, + valve_num=SOURCE_PORT, + flowrate=LOAD_FLOWRATE_UL_S, + ) + print(f"load from valve {SOURCE_PORT}: {ok} | {message}") + if not ok: + return + + remaining_loaded_ul = LOAD_VOLUME_UL + print(f"Nominal remaining syringe load: {remaining_loaded_ul:.0f} uL.") + + pause_input = input( + f"Check whether {LOAD_VOLUME_UL:.0f} uL loaded from valve {SOURCE_PORT}, then type 'y' to run the 700/300/500 uL wetting sequence: " + ).strip().lower() + if pause_input != "y": + print("Loading check stopped after full withdraw.") + return + + ok, message = pump.infuse_syringe_volume( + FIRST_PURGE_VOLUME_UL, + valve_num=SOURCE_PORT, + flowrate=PURGE_FLOWRATE_UL_S, + ) + print(f"wetting purge to valve {SOURCE_PORT}: {ok} | {message}") + if not ok: + return + remaining_loaded_ul -= FIRST_PURGE_VOLUME_UL + print(f"Nominal remaining syringe load: {remaining_loaded_ul:.0f} uL.") + + ok, message = pump.withdraw_syringe_volume( + RELOAD_VOLUME_UL, + valve_num=SOURCE_PORT, + flowrate=LOAD_FLOWRATE_UL_S, + ) + print(f"reload from valve {SOURCE_PORT}: {ok} | {message}") + if not ok: + return + remaining_loaded_ul += RELOAD_VOLUME_UL + print(f"Nominal remaining syringe load: {remaining_loaded_ul:.0f} uL.") + + ok, message = pump.infuse_syringe_volume( + SECOND_PURGE_VOLUME_UL, + valve_num=SOURCE_PORT, + flowrate=PURGE_FLOWRATE_UL_S, + ) + print(f"final purge to valve {SOURCE_PORT}: {ok} | {message}") + if not ok: + return + remaining_loaded_ul -= SECOND_PURGE_VOLUME_UL + print(f"Nominal remaining syringe load: {remaining_loaded_ul:.0f} uL.") + + print( + f"Interactive dispensing started. Enter a number in uL to dispense to valve {SOURCE_PORT}, or 'q' to stop." + ) + while remaining_loaded_ul > 0.0: + user_input = input( + f"[{remaining_loaded_ul:.0f} uL nominal remaining] dispense uL to valve {SOURCE_PORT}: " + ).strip().lower() + + if user_input in ("q", "quit", "exit"): + print("Interactive dispensing stopped by user.") + break + + try: + dispense_ul = float(user_input) + except ValueError: + print("Invalid input. Enter a number in uL, or 'q' to stop.") + continue + + if dispense_ul <= 0.0: + print("Dispense volume must be greater than 0 uL.") + continue + if dispense_ul > remaining_loaded_ul: + print( + f"Requested {dispense_ul:.0f} uL but only about {remaining_loaded_ul:.0f} uL remains." + ) + continue + + ok, message = pump.infuse_syringe_volume( + dispense_ul, + valve_num=SOURCE_PORT, + flowrate=DISPENSE_FLOWRATE_UL_S, + ) + print(f"dispense to valve {SOURCE_PORT}: {ok} | {message}") + if not ok: + return + + remaining_loaded_ul -= dispense_ul + print(f"Nominal remaining syringe load: {remaining_loaded_ul:.0f} uL.") + + if remaining_loaded_ul <= 0.0: + print("Syringe nominally empty.") + + valve_position = pump.valve_position() + print(f"reported valve position: {valve_position}") + print(f"Automatic loading check finished. Syringe nominally holds about {remaining_loaded_ul:.0f} uL.") + finally: + deinit_ok, deinit_message = pump.deinitialize(reset_init_flag=True) + print(f"deinitialize: {deinit_ok} | {deinit_message}") + if pump.ser.is_open: + pump.ser.close() + print("serial: closed") + + +if __name__ == "__main__": + main() diff --git a/examples/example_psd6_cleaning_solvent_test.py b/examples/example_psd6_cleaning_solvent_test.py new file mode 100644 index 0000000..3f847f7 --- /dev/null +++ b/examples/example_psd6_cleaning_solvent_test.py @@ -0,0 +1,176 @@ +# PSD/6 25 mL cleaning solvent test +# run from repo root with: +# python -m examples.example_psd6_cleaning_solvent_test + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from devices.psd6_syringe_pump import PSD6SyringePump + + +PORT = "COM17" +STROKE_VOLUME_UL = 25_000.0 +DEFAULT_FLOWRATE_UL_S = 1_000.0 +CLEANING_SOLVENT_TEST_VOLUME_UL = 15_000.0 +RESERVOIR_DRAIN_REPEATS = 3 + +PORT_SONICATOR_RESERVOIR = 1 +PORT_IPA = 2 +PORT_ACETONE = 3 +PORT_TOLUENE = 4 +PORT_AIR_EMPTY = 5 +PORT_WASTE = 6 +PORT_SAFE_IDLE = PORT_AIR_EMPTY + +CLEANING_SOLVENT_PORTS = [ + (PORT_IPA, "IPA"), + (PORT_ACETONE, "acetone"), + (PORT_TOLUENE, "toluene"), +] + + +def transfer_volume( + pump: PSD6SyringePump, + source_port: int, + dest_port: int, + volume_ul: float, + flowrate_ul_s: float, + label: str, +) -> bool: + ok, message = pump.withdraw_syringe_volume( + volume_ul, + valve_num=source_port, + flowrate=flowrate_ul_s, + ) + print(f"{label} | withdraw from valve {source_port}: {ok} | {message}", flush=True) + if not ok: + return False + + ok, message = pump.infuse_syringe_volume( + volume_ul, + valve_num=dest_port, + flowrate=flowrate_ul_s, + ) + print(f"{label} | infuse to valve {dest_port}: {ok} | {message}", flush=True) + return ok + + +def main() -> None: + print("Hamilton PSD/6 25 mL cleaning solvent test") + print(f"- Port: {PORT}") + print(f"- Reservoir valve: {PORT_SONICATOR_RESERVOIR} / sonicator reservoir") + print(f"- Waste valve: {PORT_WASTE}") + print( + f"- Reservoir drain: {RESERVOIR_DRAIN_REPEATS} transfers of " + f"{CLEANING_SOLVENT_TEST_VOLUME_UL / 1000.0:.1f} mL from valve {PORT_SONICATOR_RESERVOIR} to valve {PORT_WASTE}" + ) + print(f"- Cleaning solvent test volume: {CLEANING_SOLVENT_TEST_VOLUME_UL / 1000.0:.1f} mL") + print(f"- Flowrate: {DEFAULT_FLOWRATE_UL_S / 1000.0:.1f} mL/s") + print("- Known cleaning solvent ports:") + for solvent_port, solvent_name in CLEANING_SOLVENT_PORTS: + print(f" - valve {solvent_port}: {solvent_name}") + + confirm = input( + f"This will initialize COM17, drain valve {PORT_SONICATOR_RESERVOIR} to valve {PORT_WASTE} " + f"{RESERVOIR_DRAIN_REPEATS} times, then transfer {CLEANING_SOLVENT_TEST_VOLUME_UL / 1000.0:.1f} mL each from valves 2, 3, and 4 " + f"to valve {PORT_SONICATOR_RESERVOIR}. " + "Type 'y' to continue: " + ).strip().lower() + if confirm != "y": + print("Cleaning solvent test cancelled.") + return + + pump = PSD6SyringePump( + name="psd6_25ml_cleaning_solvent", + port=PORT, + baudrate=9600, + timeout=10.0, + stroke_volume=STROKE_VOLUME_UL, + stroke_steps=6000, + default_flowrate=DEFAULT_FLOWRATE_UL_S, + port_dead_volumes=[0.0] * 6, + poll_interval=0.1, + ) + + try: + ok, message = pump.start_serial(delay=1.0) + print(f"connect: {ok} | {message}", flush=True) + if not ok: + return + + ok, message = pump.initialize() + print(f"initialize: {ok} | {message}", flush=True) + if not ok: + return + + ok, message = pump.move_valve_position(PORT_SAFE_IDLE) + print(f"safe idle move to valve {PORT_SAFE_IDLE}: {ok} | {message}", flush=True) + if not ok: + return + + for drain_idx in range(RESERVOIR_DRAIN_REPEATS): + label = f"reservoir drain {PORT_SONICATOR_RESERVOIR}->{PORT_WASTE} {drain_idx + 1}/{RESERVOIR_DRAIN_REPEATS}" + ok, message = pump.move_syringe_absolute_volume( + 0.0, + valve_num=PORT_SONICATOR_RESERVOIR, + flowrate=DEFAULT_FLOWRATE_UL_S, + ) + print(f"{label} | reset to 0 uL on valve {PORT_SONICATOR_RESERVOIR}: {ok} | {message}", flush=True) + if not ok: + return + + ok = transfer_volume( + pump, + source_port=PORT_SONICATOR_RESERVOIR, + dest_port=PORT_WASTE, + volume_ul=CLEANING_SOLVENT_TEST_VOLUME_UL, + flowrate_ul_s=DEFAULT_FLOWRATE_UL_S, + label=label, + ) + if not ok: + return + + for solvent_port, solvent_name in CLEANING_SOLVENT_PORTS: + label = f"{solvent_name} {CLEANING_SOLVENT_TEST_VOLUME_UL / 1000.0:.1f} mL transfer {solvent_port}->{PORT_SONICATOR_RESERVOIR}" + ok, message = pump.move_syringe_absolute_volume( + 0.0, + valve_num=solvent_port, + flowrate=DEFAULT_FLOWRATE_UL_S, + ) + print(f"{label} | reset to 0 uL on valve {solvent_port}: {ok} | {message}", flush=True) + if not ok: + return + + ok = transfer_volume( + pump, + source_port=solvent_port, + dest_port=PORT_SONICATOR_RESERVOIR, + volume_ul=CLEANING_SOLVENT_TEST_VOLUME_UL, + flowrate_ul_s=DEFAULT_FLOWRATE_UL_S, + label=label, + ) + if not ok: + return + + valve_position = pump.valve_position() + print(f"reported valve position: {valve_position}", flush=True) + finally: + if pump.ser.is_open and pump._is_initialized: + idle_ok, idle_message = pump.move_valve_position(PORT_SAFE_IDLE) + print(f"pre-deinitialize safe idle move to valve {PORT_SAFE_IDLE}: {idle_ok} | {idle_message}", flush=True) + deinit_ok, deinit_message = pump.deinitialize(reset_init_flag=True) + print(f"deinitialize: {deinit_ok} | {deinit_message}", flush=True) + if pump.ser.is_open: + pump.ser.close() + print("serial: closed", flush=True) + + +if __name__ == "__main__": + main() diff --git a/examples/example_psd6_syringe_pump.py b/examples/example_psd6_syringe_pump.py new file mode 100644 index 0000000..8e41256 --- /dev/null +++ b/examples/example_psd6_syringe_pump.py @@ -0,0 +1,190 @@ +# PSD/6 syringe pump smoke test +# run from repo root with: +# python -m examples.example_psd6_syringe_pump + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from devices.psd6_syringe_pump import PSD6SyringePump + + +PORT = "COM17" +STROKE_VOLUME_UL = 25_000.0 +DEFAULT_FLOWRATE_UL_S = 1_000.0 +LARGE_TEST_VOLUME_UL = 24_000.0 +PRIME_TEST_VOLUME_UL = 3_000.0 +PORT_SONICATOR_RESERVOIR = 1 +PORT_IPA = 2 +PORT_AIR_EMPTY = 5 +PORT_WASTE = 6 +PORT_SAFE_IDLE = PORT_AIR_EMPTY + + +def transfer_volume( + pump: PSD6SyringePump, + source_port: int, + dest_port: int, + volume_ul: float, + flowrate_ul_s: float, + label: str, +) -> bool: + ok, message = pump.withdraw_syringe_volume( + volume_ul, + valve_num=source_port, + flowrate=flowrate_ul_s, + ) + print(f"{label} | withdraw from valve {source_port}: {ok} | {message}") + if not ok: + return False + + ok, message = pump.infuse_syringe_volume( + volume_ul, + valve_num=dest_port, + flowrate=flowrate_ul_s, + ) + print(f"{label} | infuse to valve {dest_port}: {ok} | {message}") + return ok + + +def main() -> None: + print("Hamilton PSD/6 smoke test") + print(f"- Port: {PORT}") + print("- Syringe: 25 mL") + print("- Valve: 9998-01, 6-port distribution valve (positions 1-6)") + print("- Tubing: 1/8 in downstream tubing is fine mechanically, but the Hamilton valve ports are still 1/4-28 fittings.") + print("- Port map:") + print(f" - {PORT_SONICATOR_RESERVOIR}: sonicator reservoir") + print(f" - {PORT_IPA}: IPA") + print(f" - {PORT_AIR_EMPTY}: empty / air") + print(f" - {PORT_WASTE}: waste") + print(f"- Large transfer volume is {LARGE_TEST_VOLUME_UL / 1000.0:.1f} mL.") + print(f"- Prime test volume is {PRIME_TEST_VOLUME_UL / 1000.0:.1f} mL.") + print( + f"- The pump initializes with /1ZR, which homes the syringe and returns the valve to position 1, then this example moves it to safe idle port {PORT_SAFE_IDLE}." + ) + + confirm = input( + "This will connect to COM17, move to safe idle 5, air-purge 5->2 three times at 24 mL, then prime 2->6 two times at 3 mL. Type 'y' to continue: " + ).strip().lower() + if confirm != "y": + print("Smoke test cancelled.") + return + + pump = PSD6SyringePump( + name="psd6_com17", + port=PORT, + baudrate=9600, + timeout=10.0, + stroke_volume=STROKE_VOLUME_UL, + stroke_steps=6000, + default_flowrate=DEFAULT_FLOWRATE_UL_S, + port_dead_volumes=[0.0] * 6, + poll_interval=0.1, + ) + + try: + ok, message = pump.start_serial(delay=1.0) + print(f"connect: {ok} | {message}") + if not ok: + return + + ok, message = pump.initialize() + print(f"initialize: {ok} | {message}") + if not ok: + return + + ok, message = pump.move_valve_position(PORT_SAFE_IDLE) + print(f"post-initialize safe idle move to valve {PORT_SAFE_IDLE}: {ok} | {message}") + if not ok: + return + + # Reservoir drain template: keep this block here so it can be copied into + # a recipe later when you want to empty the sonicator reservoir. + # for drain_idx in range(3): + # label = f"reservoir drain {drain_idx + 1}/3" + # ok, message = pump.move_syringe_absolute_volume( + # 0.0, + # valve_num=PORT_SONICATOR_RESERVOIR, + # flowrate=DEFAULT_FLOWRATE_UL_S, + # ) + # print(f"{label} | reset to 0.0 mL on valve {PORT_SONICATOR_RESERVOIR}: {ok} | {message}") + # if not ok: + # return + # + # ok = transfer_volume( + # pump, + # source_port=PORT_SONICATOR_RESERVOIR, + # dest_port=PORT_WASTE, + # volume_ul=LARGE_TEST_VOLUME_UL, + # flowrate_ul_s=DEFAULT_FLOWRATE_UL_S, + # label=label, + # ) + # if not ok: + # return + + for purge_idx in range(3): + label = f"air purge 5->2 {purge_idx + 1}/3" + ok, message = pump.move_syringe_absolute_volume( + 0.0, + valve_num=PORT_AIR_EMPTY, + flowrate=DEFAULT_FLOWRATE_UL_S, + ) + print(f"{label} | reset to 0.0 mL on valve {PORT_AIR_EMPTY}: {ok} | {message}") + if not ok: + return + + ok = transfer_volume( + pump, + source_port=PORT_AIR_EMPTY, + dest_port=PORT_IPA, + volume_ul=LARGE_TEST_VOLUME_UL, + flowrate_ul_s=DEFAULT_FLOWRATE_UL_S, + label=label, + ) + if not ok: + return + + for prime_idx in range(2): + label = f"IPA prime 2->6 {prime_idx + 1}/2" + ok, message = pump.move_syringe_absolute_volume( + 0.0, + valve_num=PORT_IPA, + flowrate=DEFAULT_FLOWRATE_UL_S, + ) + print(f"{label} | reset to 0.0 mL on valve {PORT_IPA}: {ok} | {message}") + if not ok: + return + + ok = transfer_volume( + pump, + source_port=PORT_IPA, + dest_port=PORT_WASTE, + volume_ul=PRIME_TEST_VOLUME_UL, + flowrate_ul_s=DEFAULT_FLOWRATE_UL_S, + label=label, + ) + if not ok: + return + + valve_position = pump.valve_position() + print(f"reported valve position: {valve_position}") + finally: + if pump.ser.is_open and pump._is_initialized: + idle_ok, idle_message = pump.move_valve_position(PORT_SAFE_IDLE) + print(f"pre-deinitialize safe idle move to valve {PORT_SAFE_IDLE}: {idle_ok} | {idle_message}") + deinit_ok, deinit_message = pump.deinitialize(reset_init_flag=True) + print(f"deinitialize: {deinit_ok} | {deinit_message}") + if pump.ser.is_open: + pump.ser.close() + print("serial: closed") + + +if __name__ == "__main__": + main() diff --git a/examples/example_sciencetech_uhe_nl_solar_sim.py b/examples/example_sciencetech_uhe_nl_solar_sim.py new file mode 100644 index 0000000..44e383e --- /dev/null +++ b/examples/example_sciencetech_uhe_nl_solar_sim.py @@ -0,0 +1,84 @@ +# Sciencetech UHE-NL solar simulator smoke test +# run from root using 'python -m examples.example_sciencetech_uhe_nl_solar_sim' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence +from devices.sciencetech_uhe_nl_solar_sim import SciencetechUHENLSolarSim +from commands.sciencetech_uhe_nl_solar_sim_commands import * + + +def main() -> None: + port = input("Enter the UHE-NL COM port (for example COM7): ").strip() + if not port: + print("No COM port provided. Exiting.") + return + + confirm = input( + "This test will ignite the lamp, move the shutter, and change the attenuator. Type 'y' to continue: " + ).strip().lower() + if confirm != "y": + print("Smoke test cancelled.") + return + + simulator = SciencetechUHENLSolarSim( + name="uhe_nl", + port=port, + baudrate=9600, + timeout=2.0, + connect_delay_s=3.0, + command_delay_s=8.0, + status_timeout_s=8.0, + default_current_percent=85.0, + default_attenuator_percent=100, + debug_io=False, + ) + + seq = CommandSequence() + seq.add_device(simulator) + seq.add_command(SciencetechUHENLSolarSimConnect(simulator)) + seq.add_command(SciencetechUHENLSolarSimInitialize(simulator)) + seq.add_command(SciencetechUHENLSolarSimGetStatus(simulator)) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="cool")) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="lamp")) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="shutter")) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="attenuator")) + seq.add_command(SciencetechUHENLSolarSimOpenShutter(simulator)) + seq.add_command(SciencetechUHENLSolarSimSetAttenuator(simulator, percent=35)) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="attenuator")) + seq.add_command(SciencetechUHENLSolarSimEnableArcLamp(simulator)) + seq.add_command(SciencetechUHENLSolarSimGetStatus(simulator)) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="lamp")) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="output")) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="current")) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="voltage")) + seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="power")) + # seq.add_command(SciencetechUHENLSolarSimOpenShutter(simulator)) + # seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="shutter")) + # seq.add_command(SciencetechUHENLSolarSimCloseShutter(simulator)) + # seq.add_command(SciencetechUHENLSolarSimOpenAttenuator(simulator)) + # seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="attenuator")) + # seq.add_command(SciencetechUHENLSolarSimDisableArcLamp(simulator)) + # seq.add_command(SciencetechUHENLSolarSimGetFeedback(simulator, feedback_type="lamp")) + # seq.add_command(SciencetechUHENLSolarSimDeinitialize(simulator, close_serial=True)) + + print("\nThis smoke test verifies the full UHE-NL control path.") + print("Sequence: initialize with lamp OFF, close shutter, set attenuator to 35%, ignite lamp,") + print("check output/current/voltage/power, open and close shutter, reopen attenuator to 100%,") + print("turn lamp OFF, then deinitialize while leaving cooling on for cooldown.") + + invoker = CommandInvoker(seq, False) + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/examples/example_sonicator.py b/examples/example_sonicator.py new file mode 100644 index 0000000..2e42f83 --- /dev/null +++ b/examples/example_sonicator.py @@ -0,0 +1,71 @@ +# Sonicator smoke test +# run from root using 'python -m examples.example_sonicator' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence +from devices.sonicator import Sonicator +from commands.sonicator_commands import * + + +def main() -> None: + port = input("Enter the Sonicator Arduino COM port (for example COM13): ").strip() + if not port: + print("No COM port provided. Exiting.") + return + + confirm = input( + "This test will stop sonication if it is already running, then start and stop it once. Type 'y' to continue: " + ).strip().lower() + if confirm != "y": + print("Smoke test cancelled.") + return + + sonicator = Sonicator( + name="sonicator", + port=port, + baudrate=9600, + timeout=0.5, + connect_delay_s=3.0, + response_timeout_s=3.0, + power_probe_timeout_s=5.0, + line_terminator="\n", + command_prefix=">", + debug_io=False, + ) + + seq = CommandSequence() + seq.add_device(sonicator) + seq.add_command(SonicatorConnect(sonicator)) + seq.add_command(SonicatorInitialize(sonicator)) + seq.add_command(SonicatorGetStatus(sonicator)) + seq.add_command(SonicatorStartSonicating(sonicator)) + seq.add_command(SonicatorGetStatus(sonicator, delay=2.0)) + seq.add_command(SonicatorStopSonicating(sonicator, delay=30.0)) + seq.add_command(SonicatorGetStatus(sonicator)) + seq.add_command(SonicatorDeinitialize(sonicator, close_serial=True)) + + print("\nThis smoke test assumes an Arduino Uno R3 wrapper wired to 5V, GND, D7, and D8.") + print("The explicit power-probe command is not part of this example because it is intrusive.") + print("Sequence: connect, initialize to idle, read status, start sonication, wait briefly, stop, read status, deinitialize.") + + invoker = CommandInvoker( + seq, + log_to_file=True, + log_filename="logs/example_sonicator.log", + alert_slack=False, + ) + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/examples/example_stellarnet_spectrometer.py b/examples/example_stellarnet_spectrometer.py new file mode 100644 index 0000000..6e72250 --- /dev/null +++ b/examples/example_stellarnet_spectrometer.py @@ -0,0 +1,153 @@ +# StellarNet UV-Vis calibration and absorbance example +# run from root using 'python -m examples.example_stellarnet_spectrometer' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from devices.stellarnet_spectrometer import StellarNetSpectrometer + + +SAVE_DIRECTORY = "data/spectroscopy/" +SPEC_KEYS = ["UV-Vis"] +DEFAULT_INTEGRATION_TIMES = (100,) +SCANS_TO_AVG = (100,) +SMOOTHINGS = (3,) +XTIMINGS = (3,) +TARGET_MAX_COUNT = 52000 +TARGET_TOLERANCE = 2000 + + +def prompt_continue(message: str) -> bool: + response = input(f"{message} Press ENTER to continue or type anything else to cancel: ").strip() + return response == "" + + +def main() -> None: + print("=== StellarNet UV-Vis Calibration Example ===") + print("This example initializes the UV-Vis spectrometer, adjusts the default integration time at the blank position,") + print("records dark and blank references, and then captures absorbance and photon counts by sample name.") + + spec = StellarNetSpectrometer( + name="spec", + spec_keys=SPEC_KEYS, + save_directory=SAVE_DIRECTORY, + default_integration_time=DEFAULT_INTEGRATION_TIMES, + ) + + ok, message = spec.initialize() + print(f"initialize -> {ok}, {message}") + if not ok: + return + + print(f"Connected spectrometers: {list(spec.spectrometer_dict.keys())}") + print(f"Save directory: {spec.save_directory}") + print(f"Initial default integration time: {spec.default_integration_time}") + + if not prompt_continue("Set the optical path to BLANK/reference for integration-time adjustment."): + spec.deinitialize() + print("Cancelled before integration-time adjustment.") + return + + ok, message = spec.adjust_default_integration_time( + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + target_max_count=TARGET_MAX_COUNT, + tolerance=TARGET_TOLERANCE, + ) + print(f"adjust_default_integration_time -> {ok}, {message}") + print(f"Updated default integration time: {spec.default_integration_time}") + if not ok: + spec.deinitialize() + return + + if not prompt_continue("Set the optical path to DARK."): + spec.deinitialize() + print("Cancelled before dark capture.") + return + + ok, message = spec.update_all_dark_spectra( + integration_times=spec.default_integration_time, + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + ) + print(f"update_dark -> {ok}, {message}") + if not ok: + spec.deinitialize() + return + + if not prompt_continue("Set the optical path to BLANK/reference."): + spec.deinitialize() + print("Cancelled before blank capture.") + return + + ok, message = spec.update_all_blank_spectra( + integration_times=spec.default_integration_time, + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + ) + print(f"update_blank -> {ok}, {message}") + if not ok: + spec.deinitialize() + return + + if not prompt_continue("Load the SAMPLE for absorbance acquisition."): + spec.deinitialize() + print("Cancelled before sample acquisition.") + return + + sample_name = input("Enter sample name for the saved absorbance file prefix: ").strip() + if not sample_name: + sample_name = "uvvis_sample" + + repeat_measure = input( + "Append this measurement to an existing sample time series if matching files are found? [Y/N]: " + ).strip().lower() == "y" + + ok, message = spec.get_all_absorbance_byname( + sample_name=sample_name, + save_to_file=True, + repeat_measure=repeat_measure, + integration_times=spec.default_integration_time, + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + ) + print(f"get_absorbance_byname -> {ok}, {message}") + + if ok: + save_counts = input("Save photon counts for the same sample name as well? [Y/N]: ").strip().lower() + if save_counts != "n": + ok_counts, message_counts = spec.get_all_counts_byname( + sample_name=sample_name, + save_to_file=True, + repeat_measure=repeat_measure, + integration_times=spec.default_integration_time, + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + ) + print(f"get_photoncounts_byname -> {ok_counts}, {message_counts}") + + calc_decay = input( + "Compute spectral decay now if repeated absorbance data and reference/am15g_spectrum.csv are available? [Y/N]: " + ).strip().lower() == "y" + if calc_decay: + ok_decay, message_decay = spec.get_spec_decay(sample_name=sample_name, save_to_file=True) + print(f"get_spec_decay -> {ok_decay}, {message_decay}") + + ok, message = spec.deinitialize() + print(f"deinitialize -> {ok}, {message}") + + +if __name__ == "__main__": + main() diff --git a/examples/example_substrate_dispenser.py b/examples/example_substrate_dispenser.py new file mode 100644 index 0000000..40ff64c --- /dev/null +++ b/examples/example_substrate_dispenser.py @@ -0,0 +1,36 @@ +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence + +from devices.substrate_dispenser import SubstrateDispenser +from commands.substrate_dispenser_commands import * + + +def main(): + port = input("Enter substrate dispenser COM port [example: COM7]: ").strip() or "COM7" + + dispenser = SubstrateDispenser("dispenser", port) + + seq = CommandSequence() + seq.add_device(dispenser) + seq.add_command(SubstrateDispenserConnect(dispenser)) + seq.add_command(SubstrateDispenserInitialize(dispenser)) + seq.add_command(SubstrateDispenserMoveToPosition(dispenser, position_mm=25.0, speed_mm_per_s=10.0, delay=2.0)) + seq.add_command(SubstrateDispenserHome(dispenser, delay=2.0)) + seq.add_command(SubstrateDispenserDeinitialize(dispenser, close_serial=True)) + + invoker = CommandInvoker(seq, log_to_file=False) + print(invoker.invoke_commands()) + + +if __name__ == "__main__": + main() diff --git a/examples/example_substrate_hotel.py b/examples/example_substrate_hotel.py new file mode 100644 index 0000000..0497263 --- /dev/null +++ b/examples/example_substrate_hotel.py @@ -0,0 +1,41 @@ +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence + +from devices.substrate_hotel import SubstrateHotel +from commands.substrate_hotel_commands import * + + +def main(): + port = input("Enter substrate hotel COM port [example: COM10]: ").strip() or "COM10" + + hotel = SubstrateHotel( + "hotel", + port, + home_timeout_s=300.0, + move_timeout_s=300.0, + ) + + seq = CommandSequence() + seq.add_device(hotel) + seq.add_command(SubstrateHotelConnect(hotel)) + seq.add_command(SubstrateHotelInitialize(hotel)) + seq.add_command(SubstrateHotelMoveToPosition(hotel, position_mm=405.0, speed_mm_per_s=10.0, delay=2.0)) + # seq.add_command(SubstrateHotelHome(hotel, delay=2.0)) + seq.add_command(SubstrateHotelDeinitialize(hotel, close_serial=True)) + + invoker = CommandInvoker(seq, log_to_file=False) + print(invoker.invoke_commands()) + + +if __name__ == "__main__": + main() diff --git a/examples/example_uvvis_film_absorbance_degradation.py b/examples/example_uvvis_film_absorbance_degradation.py new file mode 100644 index 0000000..f997861 --- /dev/null +++ b/examples/example_uvvis_film_absorbance_degradation.py @@ -0,0 +1,260 @@ +# UV-Vis film absorbance degradation example +# run from root using 'python -m examples.example_uvvis_film_absorbance_degradation' + +import sys +import time +from pathlib import Path +from typing import Dict, Iterable, Tuple + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from devices.newport_esp301 import NewportESP301 +from devices.stellarnet_spectrometer import StellarNetSpectrometer + + +ESP301_PORT = "COM6" +ESP301_AXIS_NUMBER = 3 +ESP301_SPEED = 20.0 + +ESP301_AXIS_CONFIGS = { + 3: { + "stage_model": "PR50PP", + "motion_type": "rotary", + "units": "deg", + "home_mode": "OR1", + "zero_position": 0.0, + "default_speed": ESP301_SPEED, + }, +} + +SAVE_DIRECTORY = "data/spectroscopy/" +SPEC_KEYS = ["UV-Vis"] +DEFAULT_INTEGRATION_TIMES = (100,) +SCANS_TO_AVG = (100,) +SMOOTHINGS = (3,) +XTIMINGS = (3,) +TARGET_MAX_COUNT = 52000 +TARGET_TOLERANCE = 2000 + +ACTIVE_SLOTS = (1, 2, 3, 4, 5, 6, 7, 8) +SAMPLE_NAMES: Dict[int, str] = { + 1: "sample_1", + 2: "sample_2", + 3: "sample_3", + 4: "sample_4", + 5: "sample_5", + 6: "sample_6", + 7: "sample_7", + 8: "sample_8", +} + +NUM_CYCLES = 1 +DWELL_BETWEEN_CYCLES_S = 0.0 +SAVE_PHOTON_COUNTS = True + + +def sample_angle_deg(slot: int) -> float: + return float((slot - 1) * 45.0) + + +def dark_angle_deg() -> float: + return 15.0 + + +def blank_angle_deg(slot: int) -> float: + return float(sample_angle_deg(slot) + 22.5) + + +def validate_slots(active_slots: Iterable[int], sample_names: Dict[int, str]) -> Tuple[bool, str]: + active_slots = tuple(active_slots) + if not active_slots: + return False, "ACTIVE_SLOTS is empty." + for slot in active_slots: + if slot < 1 or slot > 8: + return False, f"Slot {slot} is invalid. Valid slots are 1 through 8." + if slot not in sample_names or not str(sample_names[slot]).strip(): + return False, f"Slot {slot} is active but has no sample name." + return True, "Slot configuration is valid." + + +def move_axis3(esp301: NewportESP301, angle_deg: float) -> bool: + ok, message = esp301.move_speed_absolute( + axis_number=ESP301_AXIS_NUMBER, + position=angle_deg, + speed=ESP301_SPEED, + ) + print(f"move axis3 -> {ok}, {message}") + return ok + + +def prompt_continue(message: str) -> bool: + response = input(f"{message} Press ENTER to continue or type anything else to cancel: ").strip() + return response == "" + + +def main() -> None: + ok, message = validate_slots(ACTIVE_SLOTS, SAMPLE_NAMES) + if not ok: + print(message) + return + + print("=== UV-Vis Film Absorbance Degradation Example ===") + print("This example uses ESP301 axis 3 for rotary positioning and StellarNet UV-Vis for repeated absorbance acquisition.") + print("Workflow:") + print("1. Move to 22.5 deg for initial blank-based integration-time adjustment.") + print("2. Move to 15.0 deg for a one-time dark measurement.") + print("3. For each loop, measure blank before every sample and append absorbance by sample name.") + print("4. Photon counts can be saved alongside absorbance.") + print("") + print(f"Active slots: {ACTIVE_SLOTS}") + for slot in ACTIVE_SLOTS: + print( + f" slot {slot}: sample={SAMPLE_NAMES[slot]}, " + f"sample_angle={sample_angle_deg(slot):.1f} deg, " + f"blank_angle={blank_angle_deg(slot):.1f} deg" + ) + print(f"Dark angle: {dark_angle_deg():.1f} deg") + print(f"Configured loop count: {NUM_CYCLES}") + print(f"Dwell between cycles: {DWELL_BETWEEN_CYCLES_S} s") + print(f"Photon counts enabled: {SAVE_PHOTON_COUNTS}") + + if not prompt_continue("Confirm the chamber is clear and the spectrometer optical path is ready."): + print("Cancelled before initialization.") + return + + esp301 = NewportESP301( + name="uvvis_stage", + port=ESP301_PORT, + axis_list=(ESP301_AXIS_NUMBER,), + default_speed=ESP301_SPEED, + poll_interval=0.1, + axis_configs=ESP301_AXIS_CONFIGS, + ) + spec = StellarNetSpectrometer( + name="spec", + spec_keys=SPEC_KEYS, + save_directory=SAVE_DIRECTORY, + default_integration_time=DEFAULT_INTEGRATION_TIMES, + ) + + esp_initialized = False + spec_initialized = False + + try: + ok, message = esp301.connect() + print(f"esp301 connect -> {ok}, {message}") + if not ok: + return + + ok, message = esp301.initialize() + print(f"esp301 initialize -> {ok}, {message}") + if not ok: + return + esp_initialized = True + + ok, message = spec.initialize() + print(f"spectrometer initialize -> {ok}, {message}") + if not ok: + return + spec_initialized = True + + if not move_axis3(esp301, 22.5): + return + ok, message = spec.adjust_default_integration_time( + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + target_max_count=TARGET_MAX_COUNT, + tolerance=TARGET_TOLERANCE, + ) + print(f"adjust_default_integration_time -> {ok}, {message}") + print(f"default_integration_time -> {spec.default_integration_time}") + if not ok: + return + + if not move_axis3(esp301, dark_angle_deg()): + return + ok, message = spec.update_all_dark_spectra( + integration_times=spec.default_integration_time, + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + ) + print(f"update_dark -> {ok}, {message}") + if not ok: + return + + if NUM_CYCLES < 1: + print("NUM_CYCLES must be at least 1.") + return + + for cycle_index in range(NUM_CYCLES): + print("") + print(f"=== Begin cycle {cycle_index + 1} / {NUM_CYCLES} ===") + for slot in ACTIVE_SLOTS: + sample_name = SAMPLE_NAMES[slot] + current_blank_angle = blank_angle_deg(slot) + current_sample_angle = sample_angle_deg(slot) + + if not move_axis3(esp301, current_blank_angle): + return + ok, message = spec.update_all_blank_spectra( + integration_times=spec.default_integration_time, + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + ) + print(f"update_blank slot {slot} -> {ok}, {message}") + if not ok: + return + + if not move_axis3(esp301, current_sample_angle): + return + ok, message = spec.get_all_absorbance_byname( + sample_name=sample_name, + save_to_file=True, + repeat_measure=True, + integration_times=spec.default_integration_time, + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + ) + print(f"get_absorbance_byname slot {slot} -> {ok}, {message}") + if not ok: + return + + if SAVE_PHOTON_COUNTS: + ok, message = spec.get_all_counts_byname( + sample_name=sample_name, + save_to_file=True, + repeat_measure=True, + integration_times=spec.default_integration_time, + scans_to_avg=SCANS_TO_AVG, + smoothings=SMOOTHINGS, + xtimings=XTIMINGS, + ) + print(f"get_photoncounts_byname slot {slot} -> {ok}, {message}") + if not ok: + return + + print(f"=== End cycle {cycle_index + 1} / {NUM_CYCLES} ===") + if cycle_index < NUM_CYCLES - 1 and DWELL_BETWEEN_CYCLES_S > 0: + print(f"Sleeping for {DWELL_BETWEEN_CYCLES_S} s before next cycle.") + time.sleep(DWELL_BETWEEN_CYCLES_S) + + finally: + if spec_initialized: + ok, message = spec.deinitialize() + print(f"spectrometer deinitialize -> {ok}, {message}") + if esp_initialized: + ok, message = esp301.deinitialize() + print(f"esp301 deinitialize -> {ok}, {message}") + + +if __name__ == "__main__": + main() diff --git a/examples/example_z812.py b/examples/example_z812.py new file mode 100644 index 0000000..534b5fa --- /dev/null +++ b/examples/example_z812.py @@ -0,0 +1,54 @@ +# Z812 smoke test +# run from root using 'python -m examples.example_z812' + +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_sequence import CommandSequence +from command_invoker import CommandInvoker +from devices.z812 import Z812 +from commands.z812_commands import * + + +STAGE_PORT = "COM12" + + +def main() -> None: + seq = CommandSequence() + + stage = Z812( + name="z812", + port=STAGE_PORT, + baudrate=115200, + timeout=0.1, + destination=0x50, + source=0x01, + channel=1, + ) + seq.add_device(stage) + + seq.add_command(Z812Connect(stage)) + seq.add_command(Z812Initialize(stage)) + seq.add_command(Z812MoveAbsolute(stage, position=8.0)) + seq.add_command(Z812MoveRelative(stage, distance=3.0)) + seq.add_command(Z812MoveAbsolute(stage, position=0.0)) + seq.add_command(Z812Deinitialize(stage)) + + log_file = "logs/example_z812.log" + invoker = CommandInvoker(seq, log_to_file=True, log_filename=log_file, alert_slack=False) + + seq.print_command_names() + userinput = input("\ntype 'y' to continue, type anything else to quit: ").strip().lower() + if userinput == "y": + invoker.invoke_commands() + + +if __name__ == "__main__": + main() diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 0000000..3d6a673 --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,490 @@ +# Implementation Plan + +This file tracks device and command work for this branch. + +## Workflow + +Use this order for each device: + +1. Define scope and required actions +2. Implement device in `aamp_app/devices/` +3. Implement commands in `aamp_app/commands/` +4. Add or update an example in `examples/` +5. Run the example and confirm behavior +6. Check recipe or YAML compatibility if needed +7. Verify the device or commands appear correctly in the web app +8. Record follow-up issues + +## Status Legend + +- `planned` +- `in progress` +- `blocked` +- `done` + +## Branch Summary + +- Branch goal: add or update devices and commands, verify with examples, then confirm web app integration +- Owner: `TBD` +- Started: `2026-03-23` + +## Device Tracking + +### Device Template + +Copy this section for each device and fill it in as work starts. + +#### Device: `` + +- Status: `planned` +- Device file: `aamp_app/devices/.py` +- Command file: `aamp_app/commands/_commands.py` +- Example file: `examples/.py` +- Similar existing implementation: `TBD` +- Hardware or SDK dependency: `TBD` + +#### Scope + +- Add: +- Update: +- Not in scope: + +#### Required Actions + +- [ ] Device class implemented +- [ ] Initialization path checked +- [ ] Shutdown or cleanup path checked +- [ ] Core commands implemented +- [ ] Command metadata and params reviewed +- [ ] Example added or updated +- [ ] Example executed successfully +- [ ] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [ ] Notes recorded + +#### Validation + +- Example run result: +- Web app result: +- Known issues: + +#### Notes + +- + +## Active Work Items + +- [x] Device 1: `ESP301-3N` +- [x] Device 2: `Z812` +- [x] Device 3: `HeatingStage` +- [x] Device 4: `P4PP` +- [x] Device 5: `Sciencetech UHE-NL` +- [x] Device 6: `StellarNetSpectrometer` +- [x] Device 7: `Sonicator` +- [ ] Device 8: `SubstrateHotel` +- [ ] Device 9: `SubstrateDispenser` + +#### Device: `ESP301-3N` + +- Status: `in progress` +- Device file: `aamp_app/devices/newport_esp301.py` +- Command file: `aamp_app/commands/newport_esp301_commands.py` +- Example file: `examples/TBD` +- Similar existing implementation: `NewportESP301`, `LinearStage150`, `MTS50_Z8` +- Hardware or SDK dependency: `Newport ESP301-3N controller` + +#### Scope + +- Add: axis-type-aware initialization and homing behavior for `axis1=ILS100CC`, `axis2=UTS100PP`, `axis3=PR50PP` +- Update: existing `NewportESP301` device and related commands or metadata as needed +- Not in scope: unrelated non-ESP301 devices + +#### Required Actions + +- [ ] Confirm axis-specific home and initialization requirements +- [x] Device class updated +- [ ] Initialization path checked +- [ ] Shutdown or cleanup path checked +- [x] Core commands implemented or updated +- [x] Command metadata and params reviewed +- [ ] Example added or updated +- [ ] Example executed successfully +- [ ] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [ ] Notes recorded + +#### Validation + +- Example run result: +- Web app result: +- Known issues: web app visibility still not explicitly checked end-to-end + +#### Notes + +- Target hardware layout: `axis1=ILS100CC`, `axis2=UTS100PP`, `axis3=PR50PP` +- `NewportESP301` now supports `axis_configs` so each axis can declare `motion_type`, `units`, `home_mode`, `zero_position`, `default_speed`, and `max_speed` +- Validated homing policy: `OR4 / OR4 / OR1` +- Smoke test completed locally with axis-specific movement and re-home sequence + +#### Device: `Z812` + +- Status: `in progress` +- Device file: `aamp_app/devices/z812.py` +- Command file: `aamp_app/commands/z812_commands.py` +- Example file: `examples/example_z812.py` +- Similar existing implementation: `MTS50_Z8`, `LinearStage150` +- Hardware or SDK dependency: `Thorlabs KDC101 with Z812 actuator` + +#### Scope + +- Add: dedicated `Z812` device, commands, smoke test, and web app metadata +- Update: documentation and port tracking +- Not in scope: refactoring `MTS50_Z8` into a shared base class + +#### Required Actions + +- [x] Device class implemented +- [x] Initialization path checked +- [x] Shutdown or cleanup path checked +- [x] Core commands implemented +- [x] Command metadata and params reviewed +- [x] Example added or updated +- [x] Example executed successfully +- [ ] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [x] Notes recorded + +#### Validation + +- Example run result: smoke test passed locally using `COM12` +- Web app result: +- Known issues: none reported yet + +#### Notes + +- Current local port assignment: `COM12` +- Smoke test sequence used `8 mm` absolute move, `3 mm` relative move, and return to `0 mm` + +#### Device: `HeatingStage` + +- Status: `done` +- Device file: `aamp_app/devices/heating_stage.py` +- Command file: `aamp_app/commands/heating_stage_commands.py` +- Example file: `examples/example_heating_stage.py` +- Similar existing implementation: `HeatingStage` +- Hardware or SDK dependency: `Heating stage Arduino controller` + +#### Scope + +- Add: explicit wait-for-temperature command with hold-time criterion +- Update: example and documentation with current port assignment +- Not in scope: heater firmware changes + +#### Required Actions + +- [x] Device class implemented +- [x] Initialization path checked +- [x] Shutdown or cleanup path checked +- [x] Core commands implemented +- [x] Command metadata and params reviewed +- [x] Example added or updated +- [x] Example executed successfully +- [x] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [x] Notes recorded + +#### Validation + +- Example run result: smoke test executed locally on `COM16` +- Web app result: +- Known issues: none reported + +#### Notes + +- `HeatingStageSetSetPoint` is non-blocking +- `HeatingStageWaitForTemperature` now requires staying within tolerance for a hold duration before succeeding + +#### Device: `P4PP` + +- Status: `done` +- Device file: `aamp_app/devices/p4pp.py` +- Command file: `aamp_app/commands/p4pp_commands.py` +- Example file: `examples/example_p4pp.py` +- Similar existing implementation: external reference `changhwang/P4PP` +- Hardware or SDK dependency: `P4PP Arduino controller` + +#### Scope + +- Add: P4PP device, commands, smoke test, and web app metadata +- Update: safety and measurement configuration defaults +- Not in scope: firmware integration inside this repo + +#### Required Actions + +- [x] Device class implemented +- [x] Initialization path checked +- [x] Shutdown or cleanup path checked +- [x] Core commands implemented +- [x] Command metadata and params reviewed +- [x] Example added or updated +- [x] Example executed successfully +- [x] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [x] Notes recorded + +#### Validation + +- Example run result: smoke test executed locally on `COM19` +- Web app result: +- Known issues: none reported + +#### Notes + +- Firmware and hardware details should reference `https://github.com/polyprintillinois/P4PP` +- Python behavior was aligned to the public driver in `https://github.com/changhwang/P4PP` +- Rotation is blocked when linear position is `>= 45.0 mm` +- Measurement resistor selection is explicit: `681 ohm` default, `68.1 ohm` optional +- Default measurement cycles for command metadata set to `20` + +#### Device: `Sciencetech UHE-NL` + +- Status: `done` +- Device file: `aamp_app/devices/sciencetech_uhe_nl_solar_sim.py` +- Command file: `aamp_app/commands/sciencetech_uhe_nl_solar_sim_commands.py` +- Example file: `examples/example_sciencetech_uhe_nl_solar_sim.py` +- Similar existing implementation: `to_implement/sciencetech_lamp.py` +- Hardware or SDK dependency: `Sciencetech UHE-NL / LPC controller over RS-232` + +#### Scope + +- Add: UHE-NL power control device, commands, smoke test, and web app metadata +- Update: safety handling for cooling and lamp sequencing +- Not in scope: optical calibration workflow + +#### Required Actions + +- [x] Device class implemented +- [x] Initialization path checked +- [x] Shutdown or cleanup path checked +- [x] Core commands implemented +- [x] Command metadata and params reviewed +- [x] Example added or updated +- [x] Example executed successfully +- [x] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [x] Notes recorded + +#### Validation + +- Example run result: full smoke test executed locally on `COM4` +- Web app result: +- Known issues: controller reports `OUTPUT=0000` while the lamp is off even after a setpoint command; live output feedback becomes meaningful only after lamp ignition + +#### Notes + +- `initialize` requires the lamp to be off and enables cooling first +- `enable_arc_lamp` refuses to run unless cooling feedback is on +- `deinitialize` turns the lamp off when needed and intentionally leaves cooling on for cooldown + +#### Device: `StellarNetSpectrometer` + +- Status: `done` +- Device file: `aamp_app/devices/stellarnet_spectrometer.py` +- Command file: `aamp_app/commands/stellarnet_spectrometer_commands.py` +- Example file: `examples/example_stellarnet_spectrometer.py`, `examples/example_uvvis_film_absorbance_degradation.py` +- Similar existing implementation: existing StellarNet driver wrapper +- Hardware or SDK dependency: `stellarnet_driver3` and compatible `pyusb` + +#### Scope + +- Add: calibration/smoke test example for the UV-Vis spectrometer, by-name absorbance and photon-count acquisition, spectral-decay calculation, and an ESP301-driven UV-Vis film degradation example +- Update: driver import handling, initialization metadata, repeated-measure QC logging, and AM1.5 reference handling +- Not in scope: automated analysis filtering based on QC validity inside the decay calculation itself + +#### Required Actions + +- [x] Device class implemented +- [x] Initialization path checked +- [x] Shutdown or cleanup path checked +- [x] Core commands implemented +- [x] Command metadata and params reviewed +- [x] Example added or updated +- [x] Example executed successfully +- [x] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [x] Notes recorded + +#### Validation + +- Example run result: local initialization succeeded with spectrometer key `UV-Vis`; manual dark/blank/sample absorbance example executed locally; spectral decay recalculation was validated against legacy `specdecay_sample` data with very high trend agreement +- Web app result: +- Known issues: vendor driver must be installed in the active Python environment + +#### Notes + +- `adjust_default_integration_time()` is used at the blank position and keeps the default target near `52000` counts +- Negative absorbance is clamped to `0` and high absorbance is capped at `5`, matching the older UV-Vis workflow +- Repeated absorbance measurements now append even when QC differences exceed threshold; validity and comparison statistics are logged separately in `sample_name_measurement_qc_log.csv` +- Spectral decay now follows the `UVVis_Converter` method more closely: + - default spectral range `290-800 nm` + - default irradiance reference `data/spectroscopy/reference/am15g_spectrum.csv` + - spectral overlap computed by interpolating AM1.5 irradiance onto the measured wavelength grid and integrating with `trapz` +- Added `example_uvvis_film_absorbance_degradation.py` for ESP301 axis-3 sample rotation with: + - dark measured once at startup + - blank measured before every sample + - loop count and active slots configured at the top of the example + +#### Device: `Sonicator` + +- Status: `done` +- Device file: `aamp_app/devices/sonicator.py` +- Command file: `aamp_app/commands/sonicator_commands.py` +- Example file: `examples/example_sonicator.py` +- Similar existing implementation: `to_implement/sonicator/test6/test6.ino` +- Hardware or SDK dependency: `Arduino Uno R3 wrapper for sonicator front-panel button/status wiring` + +#### Scope + +- Add: Sonicator device, commands, example, and cleaned-up Uno firmware sketch +- Update: web-app metadata and documentation stubs +- Not in scope: redesigning the sonicator hardware interface board + +#### Required Actions + +- [x] Device class implemented +- [x] Initialization path checked +- [x] Shutdown or cleanup path checked +- [x] Core commands implemented +- [x] Command metadata and params reviewed +- [x] Example added or updated +- [x] Example executed successfully +- [ ] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [x] Notes recorded + +#### Validation + +- Example run result: Python smoke test executed locally on `COM13` +- Web app result: +- Known issues: `power` probing is intentionally treated as intrusive because it may toggle the front-panel button when the sonicator is idle + +#### Notes + +- Host-side protocol follows the final draft family in `to_implement/sonicator/test6/test6.ino` +- Command set: `>status`, `>button`, `>power`, `>turnon`, `>turnoff` +- Expected Uno R3 wiring is `5V`, `GND`, `D7` button drive, and `D8` status sense +- Added a cleaned-up firmware sketch at `firmware/sonicator/sonicator_uno_r3/sonicator_uno_r3.ino` + +#### Device: `SubstrateHotel` + +- Status: `in progress` +- Device file: `aamp_app/devices/substrate_hotel.py` +- Command file: `aamp_app/commands/substrate_hotel_commands.py` +- Example file: `examples/example_substrate_hotel.py` +- Similar existing implementation: `to_implement/substratehotel/substrate_hotel.py` +- Hardware or SDK dependency: `Arduino-based linear stage controller` + +#### Scope + +- Add: dedicated device, commands, example, and web-app metadata +- Update: current repo to use `Connect -> Initialize -> Move/Home -> Deinitialize` style +- Not in scope: Arduino firmware changes + +#### Required Actions + +- [x] Device class implemented +- [ ] Initialization path checked +- [ ] Shutdown or cleanup path checked +- [x] Core commands implemented +- [x] Command metadata and params reviewed +- [x] Example added or updated +- [ ] Example executed successfully +- [ ] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [x] Notes recorded + +#### Validation + +- Example run result: +- Web app result: +- Known issues: current repo path not hardware-smoke-tested yet + +#### Notes + +- Serial protocol inferred from old draft: Arduino emits `Ready`, homing uses `H`, and absolute motion uses `M{position},{speed}` +- Current Python-side position guard is `0-430 mm` +- Default homing and move timeouts are `300 s` + +#### Device: `SubstrateDispenser` + +- Status: `in progress` +- Device file: `aamp_app/devices/substrate_dispenser.py` +- Command file: `aamp_app/commands/substrate_dispenser_commands.py` +- Example file: `examples/example_substrate_dispenser.py` +- Similar existing implementation: `to_implement/substratehotel/substrate_dispenser.py` +- Hardware or SDK dependency: `Arduino-based linear stage controller` + +#### Scope + +- Add: dedicated device, commands, example, and web-app metadata +- Update: current repo to use `Connect -> Initialize -> Move/Home -> Deinitialize` style +- Not in scope: Arduino firmware changes + +#### Required Actions + +- [x] Device class implemented +- [ ] Initialization path checked +- [ ] Shutdown or cleanup path checked +- [x] Core commands implemented +- [x] Command metadata and params reviewed +- [x] Example added or updated +- [ ] Example executed successfully +- [ ] Logging behavior checked +- [ ] Recipe or YAML compatibility checked +- [ ] Web app visibility checked +- [ ] Manual control page checked if applicable +- [ ] Execute recipe flow checked if applicable +- [x] Notes recorded + +#### Validation + +- Example run result: +- Web app result: +- Known issues: current repo path not hardware-smoke-tested yet + +#### Notes + +- Serial protocol inferred from old draft: Arduino emits `Ready`, homing uses `H`, and absolute motion uses `M{position},{speed}` +- Current Python-side position guard is `0-45 mm` +- Default homing and move timeouts are `30 s` + +## Questions or Blockers + +- None yet diff --git a/recipe_tool.py b/recipe_tool.py index 25adac5..fc1dd2a 100644 --- a/recipe_tool.py +++ b/recipe_tool.py @@ -14,19 +14,23 @@ import questionary from colorama import init from colorama import Fore, Back, Style + init() -from command_sequence import CommandSequence -from command_invoker import CommandInvoker -from commands.command import Command -from commands.utility_commands import LoopStartCommand, LoopEndCommand -from devices.heating_stage import HeatingStage -from devices.multi_stepper import MultiStepper -from devices.newport_esp301 import NewportESP301 -from devices.stellarnet_spectrometer import StellarNetSpectrometer -from devices.ximea_camera import XimeaCamera -from devices.dummy_heater import DummyHeater -from devices.dummy_motor import DummyMotor +from aamp_app.command_sequence import CommandSequence +from aamp_app.command_invoker import CommandInvoker +from aamp_app.commands.command import Command +from aamp_app.commands.utility_commands import LoopStartCommand, LoopEndCommand +from aamp_app.devices.heating_stage import HeatingStage +from aamp_app.devices.multi_stepper import MultiStepper +from aamp_app.devices.newport_esp301 import NewportESP301 + +# from devices.stellarnet_spectrometer import StellarNetSpectrometer +# from devices.ximea_camera import XimeaCamera +from aamp_app.devices.dummy_heater import DummyHeater +from aamp_app.devices.dummy_motor import DummyMotor + +import aamp_app.util as util # TODO @@ -35,33 +39,25 @@ # type hinting # add compatibility with composite and utility commands (try to avoid coding speciific class dependencies) -#================ Constants ============================= -named_devices = { - "PrintingStage": HeatingStage, - "AnnealingStage": HeatingStage, - "MultiStepper1": MultiStepper, - "PrinterMotorX": NewportESP301, - "Spectrometer": StellarNetSpectrometer, - "SampleCamera": XimeaCamera, - "DummyHeater1": DummyHeater, - "DummyHeater2": DummyHeater, - "DummyMotor1": DummyMotor, - "DummyMotor2": DummyMotor, - } +# ================ Constants ============================= +named_devices = util.named_devices command_directory = "commands/" load_directory = "recipes/user_recipes/" save_directory = "recipes/user_recipes/" log_directory = "logs/" -#data directory? -custom_style = questionary.Style([("highlighted", "bold"),("pointer", "fg:#00ff00")]) +# data directory? +custom_style = questionary.Style([("highlighted", "bold"), ("pointer", "fg:#00ff00")]) seq = CommandSequence() + + # seq.load_from_yaml('test.yaml') def main(): print_intro() main_menu() + ################################################## # Main Menu ################################################## @@ -77,13 +73,16 @@ def main_menu(): "Execute Recipe": execute_recipe, "Manual Command Session": execute_manual, "Help": print_help, - "Quit": quit_program + "Quit": quit_program, } - prompt = questionary.select(main_menu_prompt, choices=list(main_menu_options.keys()), style=custom_style) + prompt = questionary.select( + main_menu_prompt, choices=list(main_menu_options.keys()), style=custom_style + ) response = prompt.ask() response_function = main_menu_options[response] response_function() - + + ################################################## # New/Save/Load Recipe Menu ################################################## @@ -95,13 +94,24 @@ def recipe_menu(): "New Recipe": clear_sequence, "Back to Main Menu": main_menu, } - prompt = questionary.select(recipe_menu_prompt, choices=list(recipe_menu_options.keys()), style=custom_style) + prompt = questionary.select( + recipe_menu_prompt, choices=list(recipe_menu_options.keys()), style=custom_style + ) response = prompt.ask() response_function = recipe_menu_options[response] response_function() + def clear_sequence(): - response = questionary.confirm("Create new recipe. Any unsaved data will be lost. Continue?", default=False, style=questionary.Style([("question", "fg:#ff0000"),])).ask() + response = questionary.confirm( + "Create new recipe. Any unsaved data will be lost. Continue?", + default=False, + style=questionary.Style( + [ + ("question", "fg:#ff0000"), + ] + ), + ).ask() if response: global seq seq = CommandSequence() @@ -109,14 +119,28 @@ def clear_sequence(): else: print(Fore.RED + "Did not create new recipe!") + def save_sequence(): - save_file = questionary.path("Enter the file you would like to save to or type 'quit':", default=save_directory, validate=lambda file: valid_save_file(file, save_directory), style=custom_style).ask() - + save_file = questionary.path( + "Enter the file you would like to save to or type 'quit':", + default=save_directory, + validate=lambda file: valid_save_file(file, save_directory), + style=custom_style, + ).ask() + if save_file == "quit": return - + if isfile(save_file): - response = questionary.confirm("File already exists. Overwrite file?", default=False, style=questionary.Style([("question", "fg:#ff0000"),])).ask() + response = questionary.confirm( + "File already exists. Overwrite file?", + default=False, + style=questionary.Style( + [ + ("question", "fg:#ff0000"), + ] + ), + ).ask() if response: seq.save_to_yaml(save_file) print(Fore.GREEN + "Recipe has been saved to '" + save_file + "'!") @@ -126,19 +150,25 @@ def save_sequence(): seq.save_to_yaml(save_file) print(Fore.GREEN + "Recipe has been saved to '" + save_file + "'!") + def load_sequence(): - prompt = questionary.path("Enter the recipe file you would like to load or type 'quit':", default=load_directory, validate=lambda file: valid_yml(file)) + prompt = questionary.path( + "Enter the recipe file you would like to load or type 'quit':", + default=load_directory, + validate=lambda file: valid_yml(file), + ) yaml_file = prompt.ask() if yaml_file.lower() == "quit": return - #Load the yml file data into the sequence + # Load the yml file data into the sequence global seq seq = CommandSequence() seq.load_from_yaml(yaml_file) print(Fore.GREEN + "Recipe from yaml file loaded!") + ################################################## # Display Menu ################################################## @@ -153,11 +183,16 @@ def display_menu(): "Display a Command's Iterations": display_command_iterations, "Back to Main Menu": main_menu, } - prompt = questionary.select(display_menu_prompt, choices=list(display_menu_options.keys()), style=custom_style) + prompt = questionary.select( + display_menu_prompt, + choices=list(display_menu_options.keys()), + style=custom_style, + ) response = prompt.ask() response_function = display_menu_options[response] response_function() + def display_device_menu(): if len(seq.device_list) == 0: print(Fore.RED + "There are currently no devices added to the recipe.") @@ -171,25 +206,40 @@ def display_device_menu(): else: display_device_properties(device_index) + def print_devices(): - device_names_classes = seq.get_device_names_classes() # [name, class name] of every device + device_names_classes = ( + seq.get_device_names_classes() + ) # [name, class name] of every device print("") print(Fore.WHITE + "List of Devices:") print(Fore.GREEN + " {:20.20s}".format("Name") + Fore.YELLOW + "Class") for name_class in device_names_classes: - print(Fore.GREEN + " {:20.20s}".format(name_class[0]) + Fore.YELLOW + name_class[1]) + print( + Fore.GREEN + + " {:20.20s}".format(name_class[0]) + + Fore.YELLOW + + name_class[1] + ) print("") + def display_device_properties(device_index: int): attr_dict = seq.device_list[device_index].__dict__ print("") - print(Fore.WHITE + "List of device properties for: " + Fore.GREEN + seq.device_list[device_index].name) + print( + Fore.WHITE + + "List of device properties for: " + + Fore.GREEN + + seq.device_list[device_index].name + ) for name, value in attr_dict.items(): print(Fore.WHITE + " {:20.20s}".format(name) + " = " + Fore.YELLOW + str(value)) print("") print(Fore.WHITE + "Press ENTER to continue") input() + def display_commands(): if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands added to the recipe.") @@ -197,12 +247,20 @@ def display_commands(): command_names = seq.get_command_names() print("") print(Fore.WHITE + "List of Commands:") - print(Fore.RED + "{:7s}".format("Index") + Fore.CYAN + "{:40s}".format("Command Class") + Fore.GREEN + Fore.WHITE + "Parameters") + print( + Fore.RED + + "{:7s}".format("Index") + + Fore.CYAN + + "{:40s}".format("Command Class") + + Fore.GREEN + + Fore.WHITE + + "Parameters" + ) index = 0 for name in command_names: name_parts = name.strip().split(" ") - - if 'IterIndex' in name_parts[0]: + + if "IterIndex" in name_parts[0]: print(Fore.RED + "{:7s}".format(" "), end="") print(Fore.WHITE + "{:12.12s}".format(name_parts.pop(0)), end="") print(Fore.CYAN + "{:28.28s}".format(name_parts.pop(0)), end="") @@ -216,10 +274,13 @@ def display_commands(): for param in name_parts: param_name = param.split("=")[0] param_value = param.split("=")[1] - print(Fore.WHITE + param_name + "="+ Fore.YELLOW + param_value, end=" ") + print( + Fore.WHITE + param_name + "=" + Fore.YELLOW + param_value, end=" " + ) print("") print("") + def display_commands_hide_iterations(): if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands added to the recipe.") @@ -227,22 +288,36 @@ def display_commands_hide_iterations(): command_names = seq.get_command_names() for ndx, name in enumerate(command_names): - if 'IterIndex' in name: - command_names[ndx-1] = "*" + command_names[ndx-1] + if "IterIndex" in name: + command_names[ndx - 1] = "*" + command_names[ndx - 1] for ndx, name in enumerate(command_names): - while 'IterIndex' in command_names[ndx]: + while "IterIndex" in command_names[ndx]: del command_names[ndx] print("") print(Fore.WHITE + "List of Commands:") - print(Fore.RED + "{:7s}".format("Index") + Fore.CYAN + "{:40s}".format("Command Class") + Fore.GREEN + Fore.WHITE + "Parameters") + print( + Fore.RED + + "{:7s}".format("Index") + + Fore.CYAN + + "{:40s}".format("Command Class") + + Fore.GREEN + + Fore.WHITE + + "Parameters" + ) index = 0 for name in command_names: name_parts = name.strip().split(" ") print(Fore.RED + "{:7s}".format(str(index)), end="") index += 1 - if '*' in name_parts[0]: - print(Fore.WHITE + "*" + Fore.CYAN + "{:39.39s}".format(name_parts.pop(0)[1:]), end="") + if "*" in name_parts[0]: + print( + Fore.WHITE + + "*" + + Fore.CYAN + + "{:39.39s}".format(name_parts.pop(0)[1:]), + end="", + ) else: print(Fore.CYAN + "{:40.40s}".format(name_parts.pop(0)), end="") if len(name_parts) == 0: @@ -251,10 +326,13 @@ def display_commands_hide_iterations(): for param in name_parts: param_name = param.split("=")[0] param_value = param.split("=")[1] - print(Fore.WHITE + param_name + "="+ Fore.YELLOW + param_value, end=" ") + print( + Fore.WHITE + param_name + "=" + Fore.YELLOW + param_value, end=" " + ) print("") print("") + def display_commands_unlooped(): if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands added to the recipe.") @@ -262,16 +340,27 @@ def display_commands_unlooped(): command_list = seq.get_unlooped_command_list() if len(command_list) == 0: - print(Fore.RED + "The command sequence and/or num_iterations is not valid, cannot unloop.") + print( + Fore.RED + + "The command sequence and/or num_iterations is not valid, cannot unloop." + ) return command_names = [] for command in command_list: command_names.append(command.name) - + print("") print(Fore.WHITE + "List of Commands:") - print(Fore.RED + "{:15s}".format("Unlooped Index") + Fore.CYAN + "{:40s}".format("Command Class") + Fore.GREEN + Fore.WHITE + "Parameters") + print( + Fore.RED + + "{:15s}".format("Unlooped Index") + + Fore.CYAN + + "{:40s}".format("Command Class") + + Fore.GREEN + + Fore.WHITE + + "Parameters" + ) index = 0 for name in command_names: name_parts = name.strip().split(" ") @@ -284,10 +373,13 @@ def display_commands_unlooped(): for param in name_parts: param_name = param.split("=")[0] param_value = param.split("=")[1] - print(Fore.WHITE + param_name + "="+ Fore.YELLOW + param_value, end=" ") + print( + Fore.WHITE + param_name + "=" + Fore.YELLOW + param_value, end=" " + ) print("") print("") + def display_command_iterations(): if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands added to the recipe.") @@ -302,7 +394,15 @@ def display_command_iterations(): print("") print(Fore.WHITE + "List of Command Iterations:") - print(Fore.RED + "{:16s}".format("Iteration Index") + Fore.CYAN + "{:40s}".format("Command Iteration Class") + Fore.GREEN + Fore.WHITE + "Parameters") + print( + Fore.RED + + "{:16s}".format("Iteration Index") + + Fore.CYAN + + "{:40s}".format("Command Iteration Class") + + Fore.GREEN + + Fore.WHITE + + "Parameters" + ) index = 0 for name in iteration_names: name_parts = name.strip().split(" ") @@ -315,10 +415,13 @@ def display_command_iterations(): for param in name_parts: param_name = param.split("=")[0] param_value = param.split("=")[1] - print(Fore.WHITE + param_name + "="+ Fore.YELLOW + param_value, end=" ") + print( + Fore.WHITE + param_name + "=" + Fore.YELLOW + param_value, end=" " + ) print("") print("") + ################################################## # Edit Device Menu ################################################## @@ -332,27 +435,40 @@ def device_menu(): "Display COM Port Info": print_com_port_info, "Back to Main Menu": main_menu, } - prompt = questionary.select(device_menu_prompt, choices=list(device_menu_options.keys()), style=custom_style) + prompt = questionary.select( + device_menu_prompt, + choices=list(device_menu_options.keys()), + style=custom_style, + ) response = prompt.ask() response_function = device_menu_options[response] response_function() + def add_device(): approved_devices = list(named_devices.keys()) approved_devices.append("Go back") - response = questionary.select("Which approved device would you like to add?", choices=approved_devices, style=custom_style).ask() - + response = questionary.select( + "Which approved device would you like to add?", + choices=approved_devices, + style=custom_style, + ).ask() + if response == "Go back": return if response in seq.device_by_name: - print(Fore.RED + "Device is already added. To edit it, you must remove it and re-add it.") + print( + Fore.RED + + "Device is already added. To edit it, you must remove it and re-add it." + ) return device_cls = named_devices[response] - arg_dict = prompt_signature_args(device_cls.__init__, ignored_args=['name']) - arg_dict['name'] = response + arg_dict = prompt_signature_args(device_cls.__init__, ignored_args=["name"]) + arg_dict["name"] = response seq.add_device(device_cls(**arg_dict)) print(Fore.GREEN + response + " was added to the device list") + def remove_device(): if len(seq.device_list) == 0: print(Fore.RED + "There are currently no devices added to the recipe.") @@ -361,23 +477,33 @@ def remove_device(): if device_index is None: return name = seq.device_list[device_index].name - response = questionary.confirm("Are you sure you want to delete " + name + "?", default=False, style=questionary.Style([("question", "fg:#ff0000"),])).ask() + response = questionary.confirm( + "Are you sure you want to delete " + name + "?", + default=False, + style=questionary.Style( + [ + ("question", "fg:#ff0000"), + ] + ), + ).ask() if response: seq.remove_device_by_index(device_index) print(Fore.GREEN + name + " was removed from the device list") else: print(Fore.RED + name + " was NOT removed from the device list") + def print_com_port_info(): if _has_serial: ports = serial.tools.list_ports.comports() - print('') + print("") for port, desc, hwid in sorted(ports): print("{}: {} [{}]".format(port, desc, hwid)) - print('') + print("") else: print(Fore.RED + "PySerial is not installed") + ################################################## # Edit Command Menu ################################################## @@ -393,14 +519,22 @@ def command_menu(): "Remove All Loops": remove_loops, "Back to Main Menu": main_menu, } - prompt = questionary.select(command_menu_prompt, choices=list(command_menu_options.keys()), style=custom_style) + prompt = questionary.select( + command_menu_prompt, + choices=list(command_menu_options.keys()), + style=custom_style, + ) response = prompt.ask() response_function = command_menu_options[response] response_function() + def add_command(): if len(seq.device_list) == 0: - print(Fore.RED + "There are currently no devices. Add a device to create commands for it.") + print( + Fore.RED + + "There are currently no devices. Add a device to create commands for it." + ) return device_index = select_device("Choose a device to create a command for:") @@ -408,23 +542,33 @@ def add_command(): return device = seq.device_list[device_index] - valid_command_dict = get_all_commands_classes_for_receiver(command_directory, device.__class__) + valid_command_dict = get_all_commands_classes_for_receiver( + command_directory, device.__class__ + ) valid_command_names_desc = [] for name, cls in valid_command_dict.items(): valid_command_names_desc.append("{:30.30s}".format(name) + "- " + cls.__doc__) valid_command_names_desc.append("Go back") - response = questionary.select("Choose the command to add:", choices=valid_command_names_desc, style=custom_style).ask() + response = questionary.select( + "Choose the command to add:", + choices=valid_command_names_desc, + style=custom_style, + ).ask() if response == "Go back": return command_class_name = response.split(" ")[0] command_class = valid_command_dict[command_class_name] - arg_dict = prompt_signature_args(command_class.__init__, ['receiver']) - arg_dict['receiver'] = device - arg_dict['delay'] = prompt_delay() + arg_dict = prompt_signature_args(command_class.__init__, ["receiver"]) + arg_dict["receiver"] = device + arg_dict["delay"] = prompt_delay() - append_insert = questionary.select("Append this command to the list or insert at a specific position?", choices=['Append','Insert'], style=custom_style).ask() + append_insert = questionary.select( + "Append this command to the list or insert at a specific position?", + choices=["Append", "Insert"], + style=custom_style, + ).ask() if append_insert == "Append": seq.add_command(command_class(**arg_dict)) else: @@ -433,19 +577,24 @@ def add_command(): return seq.add_command(command_class(**arg_dict), insert_index) + def remove_commands(): if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands.") return - del_indices = select_multiple_commands("Select the command(s) you would like to remove:") + del_indices = select_multiple_commands( + "Select the command(s) you would like to remove:" + ) if len(del_indices) == 0: print(Fore.RED + "No commands were deleted (Select commands with Space Bar)") return - + # Must sort in descending order otherwise removing earlier commands will shift later commands del_indices.sort(reverse=True) - response = questionary.confirm("Are you sure you want to delete the command(s)?", default=False).ask() + response = questionary.confirm( + "Are you sure you want to delete the command(s)?", default=False + ).ask() if response: for del_index in del_indices: seq.remove_command(del_index) @@ -453,7 +602,8 @@ def remove_commands(): else: print(Fore.RED + "No commands were deleted") return - + + def move_command(): if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands.") @@ -470,37 +620,52 @@ def move_command(): return seq.move_command_by_index(old_index, new_index) + def add_loop(): if seq.count_loop_commands() > 0: - print(Fore.RED + "Recipe already has loop commands. Remove them and re-add them.") + print( + Fore.RED + "Recipe already has loop commands. Remove them and re-add them." + ) return if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands.") return - loop_indices = select_multiple_commands("Select the FIRST and LAST commands of the loop section:", validator_func=valid_loop_count) + loop_indices = select_multiple_commands( + "Select the FIRST and LAST commands of the loop section:", + validator_func=valid_loop_count, + ) if len(loop_indices) == 0: print(Fore.RED + "Loop was NOT added to the recipe.") return - + loop_indices.sort() loop_start_index = loop_indices[0] loop_end_index = loop_indices[1] + 1 seq.add_loop_end(loop_end_index) seq.add_loop_start(loop_start_index) print(Fore.GREEN + "Loop has been added to recipe!") - + + def remove_loops(): - response = questionary.confirm("Are you sure you want to delete all loop commands?", default=False).ask() + response = questionary.confirm( + "Are you sure you want to delete all loop commands?", default=False + ).ask() if response: loop_command_count = seq.count_loop_commands() seq.remove_all_loop_commands() - print(Fore.GREEN + "Removed " + str(loop_command_count) + " loop commands. If these loop commands were erroneously part of an iteration then you should fix your recipe.") + print( + Fore.GREEN + + "Removed " + + str(loop_command_count) + + " loop commands. If these loop commands were erroneously part of an iteration then you should fix your recipe." + ) else: print(Fore.RED + "No loop commands were deleted") return + def get_all_command_classes(command_dir: str): # initialize lists module_names = [] @@ -511,9 +676,13 @@ def get_all_command_classes(command_dir: str): # check if each file is a "regular file" with extension ".py" and neglecting the "__init__.py" file # then add to module_names str list for file in listdir(command_dir): - if isfile(join(command_dir, file)) and file != "__init__.py" and file.split(".")[1] == "py": + if ( + isfile(join(command_dir, file)) + and file != "__init__.py" + and file.split(".")[1] == "py" + ): # get rid of the file extension and replace / with . - module_name = join(command_dir, file).split(".")[0].replace("/",".") + module_name = join(command_dir, file).split(".")[0].replace("/", ".") module_names.append(module_name) # import each module @@ -522,12 +691,13 @@ def get_all_command_classes(command_dir: str): module = importlib.import_module(module_name) for name, cls in inspect.getmembers(module, inspect.isclass): if issubclass(cls, Command) and cls is not Command and cls not in cls_list: - if not 'ParentCommand' in name: + if not "ParentCommand" in name: cls_list.append(cls) cls_name_list.append(name) cls_dict[name] = cls return cls_dict + def get_all_commands_classes_for_receiver(command_dir: str, receiver_class): cls_dict = get_all_command_classes(command_dir) @@ -537,12 +707,12 @@ def get_all_commands_classes_for_receiver(command_dir: str, receiver_class): valid_dict[name] = cls return valid_dict + ################################################## # Iteration Menu ################################################## def iteration_menu(): while True: - if isinstance(seq.num_iterations, str): # Because I want the quotes to appear if it is a string num_iter_str = "'" + seq.num_iterations + "'" @@ -555,14 +725,21 @@ def iteration_menu(): "Add Command Iteration": add_command_iteration, "Remove Command Iteration(s)": remove_command_iterations, "Move Command Iteration": move_command_iteration, - "Set Number of Loop Iterations (currently=" + num_iter_str + ")": set_num_iterations, + "Set Number of Loop Iterations (currently=" + + num_iter_str + + ")": set_num_iterations, "Back to Main Menu": main_menu, } - prompt = questionary.select(command_menu_prompt, choices=list(command_menu_options.keys()), style=custom_style) + prompt = questionary.select( + command_menu_prompt, + choices=list(command_menu_options.keys()), + style=custom_style, + ) response = prompt.ask() response_function = command_menu_options[response] response_function() + def add_command_iteration(): if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands.") @@ -571,9 +748,11 @@ def add_command_iteration(): command_index = select_command("Select the command to add an iteration for:") if command_index is None: return - + for iteration in seq.command_list[command_index]: - if isinstance(iteration, LoopStartCommand) or isinstance(iteration, LoopEndCommand): + if isinstance(iteration, LoopStartCommand) or isinstance( + iteration, LoopEndCommand + ): print(Fore.RED + "Cannot add iteration to a Loop Start/End Command") return @@ -581,18 +760,27 @@ def add_command_iteration(): device = first_command_iteration._receiver command_class = first_command_iteration.__class__ - arg_dict = prompt_signature_args(command_class.__init__, ['receiver']) - arg_dict['receiver'] = device - arg_dict['delay'] = prompt_delay() + arg_dict = prompt_signature_args(command_class.__init__, ["receiver"]) + arg_dict["receiver"] = device + arg_dict["delay"] = prompt_delay() - append_insert = questionary.select("Append this command to the iteration list or insert at a specific position?", choices=['Append','Insert'], style=custom_style).ask() + append_insert = questionary.select( + "Append this command to the iteration list or insert at a specific position?", + choices=["Append", "Insert"], + style=custom_style, + ).ask() if append_insert == "Append": seq.add_command_iteration(command_class(**arg_dict), index=command_index) else: - insert_index = select_command_iteration(command_index, "Select the position to insert the new command iteration:") + insert_index = select_command_iteration( + command_index, "Select the position to insert the new command iteration:" + ) if insert_index is None: return - seq.add_command_iteration(command_class(**arg_dict), index=command_index, iteration=insert_index) + seq.add_command_iteration( + command_class(**arg_dict), index=command_index, iteration=insert_index + ) + def remove_command_iterations(): if len(seq.command_list) == 0: @@ -604,21 +792,31 @@ def remove_command_iterations(): return if len(seq.command_list[command_index]) == 1: - print(Fore.RED + "Selected command only has 1 command iteration. To change it, insert a new command or command iteration in its place then delete the old one.") + print( + Fore.RED + + "Selected command only has 1 command iteration. To change it, insert a new command or command iteration in its place then delete the old one." + ) return - del_indices = select_multiple_command_iterations(command_index, "Select the command iterations(s) you would like to remove:") + del_indices = select_multiple_command_iterations( + command_index, "Select the command iterations(s) you would like to remove:" + ) if len(del_indices) == 0: - print(Fore.RED + "No command iterations were deleted (Select command iterations with Space Bar)") + print( + Fore.RED + + "No command iterations were deleted (Select command iterations with Space Bar)" + ) return - + if len(del_indices) == len(seq.command_list[command_index]): print(Fore.RED + "There must be at least one iteration remaining") return - + # Must sort in descending order otherwise removing earlier commands will shift later commands del_indices.sort(reverse=True) - response = questionary.confirm("Are you sure you want to delete the command iterations(s)?", default=False).ask() + response = questionary.confirm( + "Are you sure you want to delete the command iterations(s)?", default=False + ).ask() if response: for del_index in del_indices: seq.remove_command_iteration(command_index, del_index) @@ -627,6 +825,7 @@ def remove_command_iterations(): print(Fore.RED + "No command iterations were deleted") return + def move_command_iteration(): if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands.") @@ -640,23 +839,36 @@ def move_command_iteration(): print(Fore.RED + "There is currently only one command iteration.") return - old_index = select_command_iteration(command_index, "Select the command iteration you would like to move:") + old_index = select_command_iteration( + command_index, "Select the command iteration you would like to move:" + ) if old_index is None: return - new_index = select_command_iteration(command_index, "Select where to move the command iteration to:") + new_index = select_command_iteration( + command_index, "Select where to move the command iteration to:" + ) if new_index is None: return seq.move_command_iteration_by_index(command_index, old_index, new_index) + def set_num_iterations(): print("") is_valid = False while not is_valid: - print(Fore.WHITE + "The current number of iterations is: " + Fore.YELLOW + str(seq.num_iterations)) + print( + Fore.WHITE + + "The current number of iterations is: " + + Fore.YELLOW + + str(seq.num_iterations) + ) print("") - response = questionary.text("Enter the number of iterations to perform (minimum = 1, automatic = 'ALL'):", default="ALL").ask() - if response == 'ALL': - num_iterations = 'ALL' + response = questionary.text( + "Enter the number of iterations to perform (minimum = 1, automatic = 'ALL'):", + default="ALL", + ).ask() + if response == "ALL": + num_iterations = "ALL" is_valid = True else: try: @@ -664,11 +876,18 @@ def set_num_iterations(): if num_iterations >= 1: is_valid = True else: - print(Fore.RED + "Invalid value for num iterations. It must be an integer >= 1 or the string 'ALL'.") + print( + Fore.RED + + "Invalid value for num iterations. It must be an integer >= 1 or the string 'ALL'." + ) except ValueError: - print(Fore.RED + "Invalid value for num iterations. It must be an integer >= 1 or the string 'ALL'.") + print( + Fore.RED + + "Invalid value for num iterations. It must be an integer >= 1 or the string 'ALL'." + ) seq.num_iterations = num_iterations + ################################################## # Execute Recipe ################################################## @@ -676,43 +895,73 @@ def execute_recipe(): if len(seq.command_list) == 0: print(Fore.RED + "There are currently no commands.") return - - log_options = ["Log with default timestamped filename", "Log with specified filename", "No logging"] - response = questionary.select("Select logging option:", choices=log_options, default=log_options[0], style=custom_style).ask() - + + log_options = [ + "Log with default timestamped filename", + "Log with specified filename", + "No logging", + ] + response = questionary.select( + "Select logging option:", + choices=log_options, + default=log_options[0], + style=custom_style, + ).ask() + if response == log_options[0]: log_to_file = True log_filename = None print(Fore.GREEN + "Log filename will be displayed before and after execution!") elif response == log_options[1]: log_to_file = True - log_filename = questionary.path("Enter log file to create:", default=log_directory, validate=lambda file: valid_log_file(file, log_directory), style=custom_style).ask() + log_filename = questionary.path( + "Enter log file to create:", + default=log_directory, + validate=lambda file: valid_log_file(file, log_directory), + style=custom_style, + ).ask() print(Fore.GREEN + "Log messages will be saved to '" + log_filename + "'!") else: log_to_file = False log_filename = None print(Fore.YELLOW + "Messages will not be logged to file!") - alert_slack = questionary.confirm("Do you want to alert Slack if the recipe fails?", default=True).ask() + alert_slack = questionary.confirm( + "Do you want to alert Slack if the recipe fails?", default=True + ).ask() response = questionary.confirm("Are you ready to execute the recipe?").ask() if response: invoker = CommandInvoker(seq, log_to_file, log_filename, alert_slack) invocation_successful = invoker.invoke_commands() if invocation_successful: - print(Fore.CYAN + "Recipe execution complete." + Fore.GREEN + " Execution was successful.") + print( + Fore.CYAN + + "Recipe execution complete." + + Fore.GREEN + + " Execution was successful." + ) else: - print(Fore.CYAN + "Recipe execution complete." + Fore.RED + " Execution encountered errors.") + print( + Fore.CYAN + + "Recipe execution complete." + + Fore.RED + + " Execution encountered errors." + ) print("") + ################################################## # Execute Manual Commands ################################################## def execute_manual(): global seq if len(seq.device_list) == 0: - print(Fore.RED + "There are currently no devices. Add a device to execute commands for it.") + print( + Fore.RED + + "There are currently no devices. Add a device to execute commands for it." + ) return - + # Warning! Manual command execution can alter the state of your devices which may or may not be desired! # For example, it can cause a device to have its is_initialized flag set to True, which can be dumped to a .yaml file # This means loading the "recipe" later will start with an initialized device even if it may not be initialized in reality @@ -721,25 +970,36 @@ def execute_manual(): # For example, you may actually want to manually get the device to a certain state before executing the full recipe # Therefore, manual commands executed here have the option to operate on the same device/receiver objects as the recipe using a shallow copy # So use carefully! Try not to save to .yaml file after executing commands manually, instead reload this program to make changes then save - + # response = questionary.confirm("WARNING! Executing commands manually can alter the state of your devices which may be undesirable. Continue?", default=False, style=questionary.Style([("question", "fg:#ff0000"),])).ask() - print(Fore.RED + "WARNING! Executing commands manually can alter the state of your devices which may be undesirable.") - print(Fore.RED + "*BUG - Performing a deepcopy after previously performing a shallow copy results in an error.") + print( + Fore.RED + + "WARNING! Executing commands manually can alter the state of your devices which may be undesirable." + ) + print( + Fore.RED + + "*BUG - Performing a deepcopy after previously performing a shallow copy results in an error." + ) # response = questionary.confirm("Do you want to preserve the state of the devices after execution? (Recommend No!)", default=False, style=questionary.Style([("question", "fg:#ff0000"),])).ask()) copy_options = [ "No, DO NOT preserve the state of the devices (Recommended, performs a deepcopy and creates new device objects)", - "Yes, preserve the state of the devices (performs a shallow copy and references the original device objects)" + "Yes, preserve the state of the devices (performs a shallow copy and references the original device objects)", ] - prompt = questionary.select("Do you want to preserve the state of the devices after execution?", choices=copy_options, default=copy_options[0], style=custom_style) + prompt = questionary.select( + "Do you want to preserve the state of the devices after execution?", + choices=copy_options, + default=copy_options[0], + style=custom_style, + ) response = prompt.ask() - + if response == copy_options[0]: # Do not preserve the device state seq_manual = copy.deepcopy(seq) else: # Preserve the device state seq_manual = copy.copy(seq) - + seq_manual.command_list = [] seq_manual.num_iterations = 1 @@ -753,66 +1013,111 @@ def execute_manual(): device = seq_manual.device_list[device_index] - valid_command_dict = get_all_commands_classes_for_receiver(command_directory, device.__class__) + valid_command_dict = get_all_commands_classes_for_receiver( + command_directory, device.__class__ + ) valid_command_names_desc = [] for name, cls in valid_command_dict.items(): - valid_command_names_desc.append("{:30.30s}".format(name) + "- " + cls.__doc__) + valid_command_names_desc.append( + "{:30.30s}".format(name) + "- " + cls.__doc__ + ) valid_command_names_desc.append("Go back") # Choose command loop while True: - response = questionary.select("Choose the command to add:", choices=valid_command_names_desc, style=custom_style).ask() + response = questionary.select( + "Choose the command to add:", + choices=valid_command_names_desc, + style=custom_style, + ).ask() if response == "Go back": break command_class_name = response.split(" ")[0] command_class = valid_command_dict[command_class_name] - arg_dict = prompt_signature_args(command_class.__init__, ['receiver']) - arg_dict['receiver'] = device - arg_dict['delay'] = prompt_delay() - - response = questionary.confirm("Are you sure you want to execute this command?", default=True, style=questionary.Style([("question", "fg:#ff0000"),])).ask() + arg_dict = prompt_signature_args(command_class.__init__, ["receiver"]) + arg_dict["receiver"] = device + arg_dict["delay"] = prompt_delay() + + response = questionary.confirm( + "Are you sure you want to execute this command?", + default=True, + style=questionary.Style( + [ + ("question", "fg:#ff0000"), + ] + ), + ).ask() if not response: break seq_manual.command_list = [] seq_manual.add_command(command_class(**arg_dict)) - invoker_manual = CommandInvoker(seq_manual, log_to_file=False, log_filename=None, alert_slack=False) + invoker_manual = CommandInvoker( + seq_manual, log_to_file=False, log_filename=None, alert_slack=False + ) invocation_successful = invoker_manual.invoke_commands() if invocation_successful: - print(Fore.CYAN + "Manual command execution complete." + Fore.GREEN + " Execution was successful.") + print( + Fore.CYAN + + "Manual command execution complete." + + Fore.GREEN + + " Execution was successful." + ) else: - print(Fore.CYAN + "Manual command execution complete." + Fore.RED + " Execution encountered errors.") + print( + Fore.CYAN + + "Manual command execution complete." + + Fore.RED + + " Execution encountered errors." + ) print("") - ################################################## # Print Help ################################################## def print_help(): - print('') - print(Fore.GREEN + "="*10 + " How to use this tool? " +"="*140) + print("") + print(Fore.GREEN + "=" * 10 + " How to use this tool? " + "=" * 140) # print(rainbow('#?')*1 + Fore.CYAN + " How to use this tool " + rainbow('#?')*14) - print('') - print(Fore.WHITE + " 1) Add devices that will be needed for the recipe. You will be prompted for parameters (e.g. COM port, timeout, motor numbers, etc.)") - print(" 2) Add commands for any device that has been added. You will be prompted for parameters if any. (e.g. temperature, speed, position, etc.)") - print(" 3) After adding commands, you can designate a subsection of the recipe as a looped section using 'Add Loop'. The looped section will execute a specified number of iterations.") - print(" 4) For each command, you can add additional 'command iterations' which are additional commands that correspond to each loop iteration.") - print(" (If there are more loop iterations (at least 1) than command's 'command iterations', the latest command iteration will be used.)") - print(" 5) You can set the integer number of loop iterations in the command iteration menu. A string value of 'ALL' will automatically determine the largest command iteration length and use that.") + print("") + print( + Fore.WHITE + + " 1) Add devices that will be needed for the recipe. You will be prompted for parameters (e.g. COM port, timeout, motor numbers, etc.)" + ) + print( + " 2) Add commands for any device that has been added. You will be prompted for parameters if any. (e.g. temperature, speed, position, etc.)" + ) + print( + " 3) After adding commands, you can designate a subsection of the recipe as a looped section using 'Add Loop'. The looped section will execute a specified number of iterations." + ) + print( + " 4) For each command, you can add additional 'command iterations' which are additional commands that correspond to each loop iteration." + ) + print( + " (If there are more loop iterations (at least 1) than command's 'command iterations', the latest command iteration will be used.)" + ) + print( + " 5) You can set the integer number of loop iterations in the command iteration menu. A string value of 'ALL' will automatically determine the largest command iteration length and use that." + ) print(" 6) Frequently save you recipe to a .yaml file so it can be loaded later") - print(" 7) When ready to execute the recipe you will be prompted for logging options, slack alerts, and a final confirmation before execution.") - print(" 8) During execution the status will be updated live and logged to the screen and log file. After completion you will be returned to the main menu.") - print('') + print( + " 7) When ready to execute the recipe you will be prompted for logging options, slack alerts, and a final confirmation before execution." + ) + print( + " 8) During execution the status will be updated live and logged to the screen and log file. After completion you will be returned to the main menu." + ) + print("") # print(rainbow('#?')*17) - print(Fore.GREEN + "="*173) + print(Fore.GREEN + "=" * 173) # print(rainbow("#")*10) # print(rainbow("?")*10) # print(rainbow_bg()*10) print("") + ################################################## # Quit ################################################## @@ -820,125 +1125,158 @@ def quit_program(): print(Fore.RED + "User quit program") sys.exit() + ################################################## # Useful Functions ################################################## -def select_device(prompt_message, allow_backout = True): - device_names_classes = seq.get_device_names_classes() # [name, class name] of every device +def select_device(prompt_message, allow_backout=True): + device_names_classes = ( + seq.get_device_names_classes() + ) # [name, class name] of every device display_device_menu_options = [] for name_class in device_names_classes: - display_device_menu_options.append("Name: {:20.20s} Class: {}".format(name_class[0], name_class[1])) + display_device_menu_options.append( + "Name: {:20.20s} Class: {}".format(name_class[0], name_class[1]) + ) if allow_backout: display_device_menu_options.append("Go back") - prompt = questionary.select(prompt_message, choices=display_device_menu_options, style=custom_style) + prompt = questionary.select( + prompt_message, choices=display_device_menu_options, style=custom_style + ) response = prompt.ask() if response == "Go back": response = None else: response = display_device_menu_options.index(response) - return response # index of the device + return response # index of the device + -def select_command(prompt_message, allow_backout = True): +def select_command(prompt_message, allow_backout=True): command_names = seq.get_command_names() # Any command with additional iterations is marked with * for ndx, name in enumerate(command_names): - if 'IterIndex' in name: - command_names[ndx-1] = "*" + command_names[ndx-1] + if "IterIndex" in name: + command_names[ndx - 1] = "*" + command_names[ndx - 1] # Delete the iterations from the list for ndx, name in enumerate(command_names): - while 'IterIndex' in command_names[ndx]: + while "IterIndex" in command_names[ndx]: del command_names[ndx] # Format the string with spaces for ndx, name in enumerate(command_names): name_parts = name.strip().split(" ") - command_names[ndx] = "{:4s}".format(str(ndx)) + "{:30s}".format(name_parts.pop(0)) + command_names[ndx] = "{:4s}".format(str(ndx)) + "{:30s}".format( + name_parts.pop(0) + ) if len(name_parts) > 0: for param in name_parts: command_names[ndx] += " " + param if allow_backout: command_names.append("Go back") - prompt = questionary.select(prompt_message, choices=command_names, style=custom_style) + prompt = questionary.select( + prompt_message, choices=command_names, style=custom_style + ) response = prompt.ask() if response == "Go back": response = None else: response = command_names.index(response) - return response # index of the command + return response # index of the command + -def select_multiple_commands(prompt_message, validator_func = None): +def select_multiple_commands(prompt_message, validator_func=None): command_names = seq.get_command_names() # Any command with additional iterations is marked with * for ndx, name in enumerate(command_names): - if 'IterIndex' in name: - command_names[ndx-1] = "*" + command_names[ndx-1] + if "IterIndex" in name: + command_names[ndx - 1] = "*" + command_names[ndx - 1] # Delete the iterations from the list for ndx, name in enumerate(command_names): - while 'IterIndex' in command_names[ndx]: + while "IterIndex" in command_names[ndx]: del command_names[ndx] # Format the string with spaces for ndx, name in enumerate(command_names): name_parts = name.strip().split(" ") - command_names[ndx] = "{:4s}".format(str(ndx)) + "{:30s}".format(name_parts.pop(0)) + command_names[ndx] = "{:4s}".format(str(ndx)) + "{:30s}".format( + name_parts.pop(0) + ) if len(name_parts) > 0: for param in name_parts: command_names[ndx] += " " + param if validator_func is None: - prompt = questionary.checkbox(prompt_message, choices=command_names, style=custom_style) + prompt = questionary.checkbox( + prompt_message, choices=command_names, style=custom_style + ) else: - prompt = questionary.checkbox(prompt_message, choices=command_names, validate=lambda resp: validator_func(resp), style=custom_style) + prompt = questionary.checkbox( + prompt_message, + choices=command_names, + validate=lambda resp: validator_func(resp), + style=custom_style, + ) response_list = prompt.ask() index_list = [] for response in response_list: index_list.append(command_names.index(response)) return index_list -def select_command_iteration(command_index, prompt_message, allow_backout = True): + +def select_command_iteration(command_index, prompt_message, allow_backout=True): iteration_names = [] for iteration in seq.command_list[command_index]: iteration_names.append(iteration.name) - + for ndx, name in enumerate(iteration_names): name_parts = name.strip().split(" ") - iteration_names[ndx] = "{:4s}".format(str(ndx)) + "{:30s}".format(name_parts.pop(0)) + iteration_names[ndx] = "{:4s}".format(str(ndx)) + "{:30s}".format( + name_parts.pop(0) + ) if len(name_parts) > 0: for param in name_parts: iteration_names[ndx] += " " + param - + if allow_backout: iteration_names.append("Go back") - prompt = questionary.select(prompt_message, choices=iteration_names, style=custom_style) + prompt = questionary.select( + prompt_message, choices=iteration_names, style=custom_style + ) response = prompt.ask() if response == "Go back": response = None else: response = iteration_names.index(response) - return response # index of the command iteration + return response # index of the command iteration + def select_multiple_command_iterations(command_index, prompt_message): iteration_names = [] for iteration in seq.command_list[command_index]: iteration_names.append(iteration.name) - + for ndx, name in enumerate(iteration_names): name_parts = name.strip().split(" ") - iteration_names[ndx] = "{:4s}".format(str(ndx)) + "{:30s}".format(name_parts.pop(0)) + iteration_names[ndx] = "{:4s}".format(str(ndx)) + "{:30s}".format( + name_parts.pop(0) + ) if len(name_parts) > 0: for param in name_parts: iteration_names[ndx] += " " + param - prompt = questionary.checkbox(prompt_message, choices=iteration_names, style=custom_style) + prompt = questionary.checkbox( + prompt_message, choices=iteration_names, style=custom_style + ) response_list = prompt.ask() index_list = [] for response in response_list: index_list.append(iteration_names.index(response)) return index_list + def prompt_signature_args(func, ignored_args): # ignored args, list of strings for arg names # returns dictionary of args sig = inspect.signature(func) arg_dict = {} - ignored_args.extend(['self', 'kwargs', 'args']) + ignored_args.extend(["self", "kwargs", "args"]) for param in sig.parameters.values(): if not param.name in ignored_args: @@ -947,23 +1285,42 @@ def prompt_signature_args(func, ignored_args): default = "N/A" else: default = str(param.default) - print(Fore.WHITE + "Parameter: " + Fore.GREEN + param.name + Fore.WHITE + " type: " + Fore.YELLOW + str(param.annotation) + Fore.WHITE + " default: " + Fore.YELLOW + default) - + print( + Fore.WHITE + + "Parameter: " + + Fore.GREEN + + param.name + + Fore.WHITE + + " type: " + + Fore.YELLOW + + str(param.annotation) + + Fore.WHITE + + " default: " + + Fore.YELLOW + + default + ) + if default == "N/A": response = questionary.text("Enter value for the parameter").ask() else: - response = questionary.text("Enter value for the parameter", default=default).ask() + response = questionary.text( + "Enter value for the parameter", default=default + ).ask() arg_dict[param.name] = eval(response) return arg_dict + def prompt_delay(): # print(Fore.WHITE + "Parameter: " + Fore.GREEN + "delay" + Fore.WHITE + " type: " + Fore.YELLOW + "float" + Fore.WHITE + " default: " + Fore.YELLOW + "0.0") is_valid = False while not is_valid: - response = questionary.text("Enter delay (in seconds) before this command executes. (0.0 = no delay, P = Pause & wait for ENTER before execute):", default="0.0").ask() - if response == 'P' or response == 'PAUSE': - delay = 'PAUSE' + response = questionary.text( + "Enter delay (in seconds) before this command executes. (0.0 = no delay, P = Pause & wait for ENTER before execute):", + default="0.0", + ).ask() + if response == "P" or response == "PAUSE": + delay = "PAUSE" is_valid = True else: try: @@ -971,67 +1328,113 @@ def prompt_delay(): if delay >= 0.0: is_valid = True else: - print(Fore.RED + "Invalid value for delay. It must be an number >= 0.0 or the string 'P' or 'PAUSE'.") + print( + Fore.RED + + "Invalid value for delay. It must be an number >= 0.0 or the string 'P' or 'PAUSE'." + ) except ValueError: - print(Fore.RED + "Invalid value for delay. It must be an number >= 0.0 or the string 'P' or 'PAUSE'.") + print( + Fore.RED + + "Invalid value for delay. It must be an number >= 0.0 or the string 'P' or 'PAUSE'." + ) return delay + def print_eval_warning(): print(Fore.RED + "Your response will be evaluated directly with eval()!") - print(Fore.RED + "Examples: string = 'COM9', integer = 5, float = 5.0, list of ints = [1, 2, 3], tuple of ints = (1, 2, 3)") + print( + Fore.RED + + "Examples: string = 'COM9', integer = 5, float = 5.0, list of ints = [1, 2, 3], tuple of ints = (1, 2, 3)" + ) + def print_intro(): - print(Fore.GREEN + Style.BRIGHT + '='*50) - print(" "*15 + "Command Recipe Tool") - print(" "*13 + "8/12/2021 - Justin Kwok") - print('='*50) - print('') + print(Fore.GREEN + Style.BRIGHT + "=" * 50) + print(" " * 15 + "Command Recipe Tool") + print(" " * 13 + "8/12/2021 - Justin Kwok") + print("=" * 50) + print("") + def rainbow(char): - string = Fore.RED + char + Fore.YELLOW + char + Fore.GREEN + char + Fore.CYAN + char + Fore.BLUE + char + Fore.MAGENTA + char + string = ( + Fore.RED + + char + + Fore.YELLOW + + char + + Fore.GREEN + + char + + Fore.CYAN + + char + + Fore.BLUE + + char + + Fore.MAGENTA + + char + ) return string + def rainbow_bg(): char = " " - string = Back.RED + char + Back.YELLOW + char + Back.GREEN + char + Back.CYAN + char + Back.BLUE + char + Back.MAGENTA + char + string = ( + Back.RED + + char + + Back.YELLOW + + char + + Back.GREEN + + char + + Back.CYAN + + char + + Back.BLUE + + char + + Back.MAGENTA + + char + ) return string + ################################################## # Questionary validator functions ################################################## def valid_yml(file): if file.lower() == "quit": return True - if not isfile(file) or (file.split(".")[-1] != "yml" and file.split(".")[-1] != "yaml"): + if not isfile(file) or ( + file.split(".")[-1] != "yml" and file.split(".")[-1] != "yaml" + ): return "Enter a valid yml/yaml file" else: return True + def valid_loop_count(response_list): if len(response_list) == 0 or len(response_list) == 2: return True else: - return "You must select 2 options only or select none to exit" + return "You must select 2 options only or select none to exit" + def valid_save_file(file, save_directory): if file.lower() == "quit": return True if not save_directory in file: return "You must save in the directory: " + save_directory - if not '.yaml' in file: + if not ".yaml" in file: return "File must have a '.yaml' extension" else: return True + def valid_log_file(file, log_directory): if not log_directory in file: return "You must save log in the directory: " + log_directory - if not '.log' in file: + if not ".log" in file: return "File must have a '.log' extension" if isfile(file): return "You must create a new log file" else: return True + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/recipes/.gitignore b/recipes/.gitignore index 5398972..2e40254 100644 --- a/recipes/.gitignore +++ b/recipes/.gitignore @@ -7,4 +7,5 @@ !example4.yaml !example5.yaml !example6.yaml -!ui_recipe_example.yaml \ No newline at end of file +!ui_recipe_example.yaml +!recipe_sample.py \ No newline at end of file diff --git a/recipes/recipe_sample.py b/recipes/recipe_sample.py new file mode 100644 index 0000000..cd475eb --- /dev/null +++ b/recipes/recipe_sample.py @@ -0,0 +1,213 @@ +import time +import os +from command_invoker import CommandInvoker +from command_sequence import CommandSequence +from utilities.solution_map import SolutionMap +from commands.solution_map_commands import FindAndStoreSolutionPosition, GetSolutionWithStoredPosition + +from devices.ximea_camera import XimeaCamera +from devices.newport_esp301 import NewportESP301 +from devices.kinova_arm import KinovaArm +from devices.heating_stage import HeatingStage +from devices.polarizer_servo_motor import PolarizerServoMotor +from devices.psd6_syringe_pump import PSD6SyringePump + +from commands.ximea_camera_commands import * +from commands.newport_esp301_commands import * +from commands.kinova_arm_commands import * +from commands.heating_stage_commands import * +from commands.psd6_syringe_pump_commands import * +from commands.polarizer_servo_motor_commands import * + +def format_speed(speed): + if speed >= 1: + return f"{int(speed)}" + else: + # Convert to exponential notation + # Example: 0.2 -> 2E-1, 0.02 -> 2E-2 + exponent = 0 + normalized = speed + while normalized < 1: + normalized *= 10 + exponent -= 1 + return f"{int(normalized)}E{exponent}" + +# Sample Metadata +round_num = 0 # x +sample_num = $sample_no # y +polymer = "$polymer" +solvent = "$solvent" +concentration = $concentration # mg/ml +speed = $motor_speed # a: mm/s +speed_str = format_speed(speed) +temperature = $temperature # b: °C +gap = $printing_gap # c: um +volume = $precursor_volume # d: ul + +# configure devices +polarizer = PolarizerServoMotor('polarizer', 'COM22') +printer = NewportESP301( + 'printer', + 'COM6', + axis_list=(1, 2, 3), + default_speed=10.0, + axis_configs={ + 1: { + 'stage_model': 'ILS100CC', + 'motion_type': 'linear', + 'units': 'mm', + 'home_mode': 'OR4', + 'zero_position': 0.0, + 'default_speed': 10.0, + }, + 2: { + 'stage_model': 'UTS100PP', + 'motion_type': 'linear', + 'units': 'mm', + 'home_mode': 'OR4', + 'zero_position': 0.0, + 'default_speed': 10.0, + }, + 3: { + 'stage_model': 'PR50PP', + 'motion_type': 'rotary', + 'units': 'deg', + 'home_mode': 'OR1', + 'zero_position': 0.0, + 'default_speed': 10.0, + }, + }, +) +arm = KinovaArm('kinova') +heating_stage = HeatingStage('heating_stage', 'COM16',115200) +xi = XimeaCamera('xi') +pump1 = PSD6SyringePump('pump1', 'COM4') +pump2 = PSD6SyringePump('pump2', 'COM5') +solution_map = SolutionMap() + +# add devices +seq = CommandSequence() +seq.add_device(printer) +seq.add_device(arm) +seq.add_device(heating_stage) +seq.add_device(polarizer) +seq.add_device(xi) +seq.add_device(pump1) +seq.add_device(pump2) +seq.add_device(solution_map) + +# initialize commands +seq.add_command(NewportESP301Connect(printer)) +seq.add_command(NewportESP301Initialize(printer)) +seq.add_command(KinovaArmConnect(arm)) +seq.add_command(KinovaArmInitialize(arm)) +seq.add_command(HeatingStageConnect(heating_stage)) +seq.add_command(HeatingStageInitialize(heating_stage)) +seq.add_command(PolarizerConnect(polarizer)) +seq.add_command(PolarizerInitialize(polarizer)) +seq.add_command(PSD6SyringePumpConnect(pump1)) +seq.add_command(PSD6SyringePumpConnect(pump2)) +seq.add_command(PSD6SyringePumpInitialize(pump1)) +seq.add_command(PSD6SyringePumpInitialize(pump2)) +seq.add_command(XimeaCameraInitialize(xi)) + +# get substrate to the printing stage +seq.add_command(KinovaArmExecuteAction(arm, action_name='Home')) +seq.add_command(KinovaArmOpenGripper(arm)) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Sub_Handler_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Sub_Handler_Down')) +seq.add_command(KinovaArmCloseGripper(arm)) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Sub_Handler_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Substrate_Hotel_Down')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Substrate_1_Down')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Substrate_1_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Substrate_Hotel_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Sub_Handler_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Home')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Printer_Up_Out')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Printer_Up_In')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Printer_Down_In')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Printer_Down_Out')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Home')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Sub_Handler_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Sub_Handler_Down')) +seq.add_command(KinovaArmOpenGripper(arm)) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Sub_Handler_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Home')) + +# set printing temperature +seq.add_command(HeatingStageSetSetPoint(heating_stage, temperature)) + +# get solution + +# Sahas - commenting this out for now because the user can manually enter solution position in the recipe builder UI +# find_solution_cmd = FindAndStoreSolutionPosition(solution_map, polymer, solvent, concentration) +# seq.add_command(find_solution_cmd) + +seq.add_command(KinovaArmExecuteAction(arm, action_name='Liq_Handler_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Liq_Handler_Down')) +seq.add_command(KinovaArmCloseGripper(arm)) +seq.add_command(KinovaArmExecuteAction(arm, action_name='Liq_Handler_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='$solution_position' + '_Up')) +seq.add_command(KinovaArmExecuteAction(arm, action_name='$solution_position' + '_Down')) + +# Set Exposure Time +CROSSPOL_EXPOSURE = 50000 +NORMAL_EXPOSURE = 12000 + +# Set Save Path (data/imaging is excluded) +BASE_DIR = polymer # P42gTTT +CROSSPOL_DIR = os.path.join(BASE_DIR, 'crosspol') # P42gTTT/crosspol +NORMAL_DIR = os.path.join(BASE_DIR, 'normal') # P42gTTT/normal + +# Set Base Sample Name +base_sample_name = f"R{round_num}S{sample_num}_{polymer}_{solvent}_{concentration}mgml_{speed_str}mms_{temperature}C_{gap}um_{volume}ul" + + + +# Take Cross-pol Images (polarizer 0 degree) +seq.add_command(RotatePolarizerAbsolute(polarizer, angle=0)) + +sample_angles = [90, 60, 45, 30, 0] +for angle in sample_angles: + seq.add_command(RotateSampleAbsolute(polarizer, angle=angle)) + filename = os.path.join(CROSSPOL_DIR, f"{base_sample_name}_crosspol_{angle}deg") + seq.add_command( + XimeaCameraGetImage(xi, + filename=filename, + y_upper=10, + y_length=1000, + x_upper=500, + x_length=1000, + exposure_time=CROSSPOL_EXPOSURE, + check_uniform=False) + ) + +# Take Normal Images (polarizer 90 degree) +seq.add_command(RotatePolarizerAbsolute(polarizer, angle=90)) + +for angle in sample_angles: + seq.add_command(RotateSampleAbsolute(polarizer, angle=angle)) + filename = os.path.join(NORMAL_DIR, f"{base_sample_name}_normal_{angle}deg") + seq.add_command( + XimeaCameraGetImage(xi, + filename=filename, + y_upper=10, + y_length=1000, + x_upper=500, + x_length=1000, + exposure_time=NORMAL_EXPOSURE, + check_uniform=False) + ) + +# Return to initial position +seq.add_command(RotatePolarizerAbsolute(polarizer, angle=0)) +seq.add_command(RotateSampleAbsolute(polarizer, angle=0)) + +# Create necessary folders (include full path) +os.makedirs(os.path.join('data', 'imaging', CROSSPOL_DIR), exist_ok=True) +os.makedirs(os.path.join('data', 'imaging', NORMAL_DIR), exist_ok=True) + +invoker = CommandInvoker(seq, False) +res = invoker.invoke_commands() +print(res) diff --git a/recipes/user_recipes/.gitignore b/recipes/user_recipes/.gitignore index c96a04f..9d37706 100644 --- a/recipes/user_recipes/.gitignore +++ b/recipes/user_recipes/.gitignore @@ -1,2 +1,4 @@ * -!.gitignore \ No newline at end of file +!.gitignore +!ch_apis_imaging_from_mongodb.py +!example_mongodb_recipe_lookup.py diff --git a/recipes/user_recipes/ch_apis_imaging_from_mongodb.py b/recipes/user_recipes/ch_apis_imaging_from_mongodb.py new file mode 100644 index 0000000..4f97ea1 --- /dev/null +++ b/recipes/user_recipes/ch_apis_imaging_from_mongodb.py @@ -0,0 +1,245 @@ +import os +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +from gridfs import GridFS + +CWD = Path.cwd().resolve() +ROOT_DIR = CWD.parent if CWD.name == "aamp_app" else CWD +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_invoker import CommandInvoker +from command_sequence import CommandSequence +from commands.apis_commands import * +from devices.apis import APIS +from mongodb_helper import MongoDBHelper + + +CAMPAIGN_NAME = "${campaign_name}" +BATCH_NO = ${batch_no} +SAMPLE_NO = ${sample_no} +APIS_PORT = "COM8" +ROOT_SAVE_DIR = ROOT_DIR / "data" / "imaging" +XPL_EXPOSURE_US = 50000 +PPL_EXPOSURE_US = 18000 +SAMPLE_ANGLES_DEG = [90, 60, 45, 30, 0] + + +def load_env(file_path=ROOT_DIR / ".env"): + if file_path.exists(): + with open(file_path, "r", encoding="utf-8") as file: + for line in file: + if "=" in line and not line.strip().startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + +def sanitize_path_component(value: str) -> str: + sanitized = re.sub(r'[<>:"/\\\\|?*]+', "_", value.strip()) + return sanitized or "unnamed_campaign" + + +def get_mongo(): + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + if not mongo_uri or not mongo_db_name: + raise RuntimeError("MONGO_URI and MONGO_DB_NAME must be set in .env.") + return MongoDBHelper(mongo_uri, mongo_db_name) + + +def fetch_parameter_set(mongo, campaign_name, batch_no, sample_no): + campaign_doc = mongo.db["campaigns"].find_one({"campaign_name": campaign_name}) + if campaign_doc is None: + raise LookupError(f"Campaign '{campaign_name}' was not found.") + set_doc = mongo.db["sets"].find_one( + { + "campaign_id": campaign_doc["_id"], + "batch_no": batch_no, + "sample_no": sample_no, + } + ) + if set_doc is None: + raise LookupError( + f"No parameter set found for campaign='{campaign_name}', batch_no={batch_no}, sample_no={sample_no}." + ) + return campaign_doc, set_doc + + +def build_set_params(batch_no, sample_no, set_doc): + return { + "campaign_name": CAMPAIGN_NAME, + "batch_no": batch_no, + "sample_no": sample_no, + "polymer_name": set_doc["polymer_name"], + "temperature": set_doc["temperature"], + "motor_speed": float(set_doc["motor_speed"]), + "printing_gap": set_doc["printing_gap"], + "solvent": set_doc["solvent"], + "concentration": set_doc["concentration"], + "precursor_volume": set_doc["precursor_volume"], + } + + +def add_mode_capture_commands(seq, apis, mode_name, polarizer_angle, sample_angles, exposure_time, base_name, save_dir, capture_records): + seq.add_command(APISRotatePolarizer(apis, angle_deg=polarizer_angle)) + for angle in sample_angles: + seq.add_command(APISRotateSample(apis, angle_deg=angle)) + filename = apis.build_mode_filename(base_name, mode_name, angle) + raw16_path = os.path.join(save_dir, "raw16", filename + ".tif") + rgb_path = os.path.join(save_dir, "rgb", filename + "_rgb.tif") + capture_records.append( + { + "mode": mode_name, + "image_kind": "raw16", + "sample_angle_deg": angle, + "local_path": raw16_path, + } + ) + capture_records.append( + { + "mode": mode_name, + "image_kind": "rgb", + "sample_angle_deg": angle, + "local_path": rgb_path, + } + ) + seq.add_command( + APISCaptureRaw16( + apis, + filename=filename, + directory=save_dir, + exposure_time=exposure_time, + gain=0.0, + ) + ) + seq.add_command( + APISConvertRaw16ToRgb( + apis, + raw16_path=raw16_path, + rgb_path=rgb_path, + ) + ) + + +def upload_capture_records_to_mongodb(mongo, campaign_doc, set_doc, capture_records): + fs = GridFS(mongo.db) + uploaded_count = 0 + for record in capture_records: + local_path = record["local_path"] + if not os.path.isfile(local_path): + continue + with open(local_path, "rb") as file_obj: + file_id = fs.put(file_obj, filename=os.path.basename(local_path)) + metadata = { + "campaign_id": campaign_doc["_id"], + "campaign_name": campaign_doc["campaign_name"], + "set_id": set_doc["_id"], + "batch_no": set_doc["batch_no"], + "sample_no": set_doc["sample_no"], + "polymer_name": set_doc["polymer_name"], + "solvent": set_doc["solvent"], + "concentration": set_doc["concentration"], + "motor_speed": set_doc["motor_speed"], + "temperature": set_doc["temperature"], + "printing_gap": set_doc["printing_gap"], + "precursor_volume": set_doc["precursor_volume"], + "mode": record["mode"], + "image_kind": record["image_kind"], + "sample_angle_deg": record["sample_angle_deg"], + "file_id": file_id, + "filename": os.path.basename(local_path), + "relative_local_path": os.path.relpath(local_path, ROOT_DIR), + "measurement_type": "apis_imaging", + "source": "ch_apis_imaging_recipe", + "uploaded_at": datetime.now(timezone.utc), + } + mongo.db["images"].insert_one(metadata) + uploaded_count += 1 + return uploaded_count + + +load_env() +mongo = get_mongo() +campaign_doc, set_doc = fetch_parameter_set(mongo, CAMPAIGN_NAME, BATCH_NO, SAMPLE_NO) +params = build_set_params(BATCH_NO, SAMPLE_NO, set_doc) + +base_sample_name = APIS.build_sample_basename( + round_num=params["batch_no"], + sample_num=params["sample_no"], + polymer=params["polymer_name"], + solvent=params["solvent"], + concentration=params["concentration"], + speed=params["motor_speed"], + temperature=params["temperature"], + gap=params["printing_gap"], + volume=params["precursor_volume"], +) + +root_save_dir = os.path.join(str(ROOT_SAVE_DIR), sanitize_path_component(CAMPAIGN_NAME)) +apis = APIS( + name="apis", + port=APIS_PORT, + baudrate=9600, + timeout=0.5, + connection_wait_s=2.0, + settling_time_s=1.5, + command_delay_s=0.05, + max_retries=3, + use_camera=True, + camera_save_directory=root_save_dir, + camera_bayer_pattern="GBRG", + camera_raw_max_value=1023.0, +) + +xpl_dir = apis.resolve_mode_directory(root_save_dir, params["polymer_name"], "xpl") +ppl_dir = apis.resolve_mode_directory(root_save_dir, params["polymer_name"], "ppl") +capture_records = [] + +seq = CommandSequence() +seq.add_device(apis) +seq.add_command(APISConnect(apis)) +seq.add_command(APISInitialize(apis)) +seq.add_command(APISHome(apis)) + +add_mode_capture_commands( + seq=seq, + apis=apis, + mode_name="xpl", + polarizer_angle=90.0, + sample_angles=SAMPLE_ANGLES_DEG, + exposure_time=XPL_EXPOSURE_US, + base_name=base_sample_name, + save_dir=xpl_dir, + capture_records=capture_records, +) +add_mode_capture_commands( + seq=seq, + apis=apis, + mode_name="ppl", + polarizer_angle=0.0, + sample_angles=SAMPLE_ANGLES_DEG, + exposure_time=PPL_EXPOSURE_US, + base_name=base_sample_name, + save_dir=ppl_dir, + capture_records=capture_records, +) + +seq.add_command(APISRotatePolarizer(apis, angle_deg=0.0)) +seq.add_command(APISRotateSample(apis, angle_deg=0.0)) +seq.add_command(APISDeinitialize(apis)) + +invoker = CommandInvoker(seq, False) +result = invoker.invoke_commands() +print(f"APIS imaging finished for campaign={CAMPAIGN_NAME}, batch_no={BATCH_NO}, sample_no={SAMPLE_NO}. Result={result}") + +if result: + uploaded_count = upload_capture_records_to_mongodb(mongo, campaign_doc, set_doc, capture_records) + print(f"Uploaded {uploaded_count} APIS image files to GridFS and inserted metadata into images collection.") + +mongo.close_connection() diff --git a/recipes/user_recipes/example_mongodb_recipe_lookup.py b/recipes/user_recipes/example_mongodb_recipe_lookup.py new file mode 100644 index 0000000..78e0498 --- /dev/null +++ b/recipes/user_recipes/example_mongodb_recipe_lookup.py @@ -0,0 +1,226 @@ +"""Minimal recipe example that reads a parameter set from MongoDB. + +Run from the repository root: + python ./recipes/user_recipes/example_mongodb_recipe_lookup.py + +Expected .env entries: + MONGO_URI=mongodb://... + MONGO_DB_NAME=... +""" + +import os +import re +import sys +from pathlib import Path +from typing import Dict, List, Tuple + + +ROOT_DIR = Path(__file__).resolve().parents[2] +AAMP_APP_DIR = ROOT_DIR / "aamp_app" +for path in (ROOT_DIR, AAMP_APP_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from command_sequence import CommandSequence +from mongodb_helper import MongoDBHelper + + +DEFAULT_CAMPAIGN_NAME = "" + + +def load_env(file_paths=(ROOT_DIR / ".env", AAMP_APP_DIR / ".env")) -> None: + for file_path in file_paths: + if not file_path.exists(): + continue + with open(file_path, "r", encoding="utf-8") as file: + for line in file: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ[key.strip()] = value.strip().strip('"').strip("'") + + +def get_mongo_helper() -> MongoDBHelper: + mongo_uri = os.environ.get("MONGO_URI") + mongo_db_name = os.environ.get("MONGO_DB_NAME") + if not mongo_uri or not mongo_db_name: + raise RuntimeError("MONGO_URI and MONGO_DB_NAME must be set in .env.") + return MongoDBHelper(mongo_uri, mongo_db_name) + + +def fetch_campaign(mongo: MongoDBHelper, campaign_name: str) -> dict: + campaign_doc = mongo.db["campaigns"].find_one({"campaign_name": campaign_name}) + if campaign_doc is None: + available_campaigns = sorted(mongo.db["campaigns"].distinct("campaign_name")) + preview = available_campaigns[:20] + suffix = " ..." if len(available_campaigns) > len(preview) else "" + raise LookupError( + f"Campaign '{campaign_name}' was not found. Available campaigns: {preview}{suffix}" + ) + return campaign_doc + + +def list_batch_numbers(mongo: MongoDBHelper, campaign_doc: dict) -> List[int]: + return sorted(mongo.db["sets"].distinct("batch_no", {"campaign_id": campaign_doc["_id"]})) + + +def list_sample_numbers(mongo: MongoDBHelper, campaign_doc: dict, batch_no: int) -> List[int]: + return sorted( + mongo.db["sets"].distinct( + "sample_no", + { + "campaign_id": campaign_doc["_id"], + "batch_no": batch_no, + }, + ) + ) + + +def prompt_choice(prompt: str, options: List[int]) -> int: + if not options: + raise LookupError(f"No options available for {prompt}.") + + print(f"\nAvailable {prompt}: {options}") + default = options[0] + while True: + raw_value = input(f"Choose {prompt} [{default}]: ").strip() + value = default if not raw_value else int(raw_value) + if value in options: + return value + print(f"{value} is not available. Choose one of: {options}") + + +def fetch_parameter_set( + mongo: MongoDBHelper, + campaign_doc: dict, + batch_no: int, + sample_no: int, +) -> Tuple[dict, dict]: + set_doc = mongo.db["sets"].find_one( + { + "campaign_id": campaign_doc["_id"], + "batch_no": batch_no, + "sample_no": sample_no, + } + ) + if set_doc is None: + available_samples = sorted( + mongo.db["sets"].distinct( + "sample_no", + { + "campaign_id": campaign_doc["_id"], + "batch_no": batch_no, + }, + ) + ) + if available_samples: + raise LookupError( + f"No set found for campaign='{campaign_doc['campaign_name']}', batch_no={batch_no}, " + f"sample_no={sample_no}. Available sample_no values: {available_samples}" + ) + + available_batches = list_batch_numbers(mongo, campaign_doc) + raise LookupError( + f"No set found for campaign='{campaign_doc['campaign_name']}', batch_no={batch_no}, " + f"sample_no={sample_no}. Available batch_no values: {available_batches}" + ) + + return campaign_doc, set_doc + + +def display_value(value): + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + +def build_recipe_params(batch_no: int, sample_no: int, set_doc: dict) -> Dict[str, object]: + return { + "campaign_name": display_value(set_doc.get("campaign_name", "")), + "batch_no": batch_no, + "sample_no": sample_no, + "polymer_name": display_value(set_doc["polymer_name"]), + "temperature": display_value(set_doc["temperature"]), + "motor_speed": float(set_doc["motor_speed"]), + "printing_gap": display_value(set_doc["printing_gap"]), + "solvent": display_value(set_doc["solvent"]), + "concentration": display_value(set_doc["concentration"]), + "precursor_volume": display_value(set_doc["precursor_volume"]), + } + + +def sanitize_path_component(value: object) -> str: + sanitized = re.sub(r'[<>:"/\\|?*]+', "_", str(value).strip()) + return sanitized or "unnamed" + + +def build_sample_name(params: Dict[str, object]) -> str: + speed = f"{params['motor_speed']:.4f}".rstrip("0").rstrip(".") + parts = [ + f"R{params['batch_no']}", + f"S{params['sample_no']}", + params["polymer_name"], + params["solvent"], + f"{params['concentration']}mgml", + f"{speed}mms", + f"{params['temperature']}C", + f"{params['printing_gap']}um", + f"{params['precursor_volume']}ul", + ] + return sanitize_path_component("_".join(str(part) for part in parts)) + + +def build_sequence(params: Dict[str, object]) -> CommandSequence: + seq = CommandSequence() + + # Add devices and commands here using values from params. + # Example: + # volume_ul = float(params["precursor_volume"]) + # speed_mm_s = float(params["motor_speed"]) + # gap_um = float(params["printing_gap"]) + # + # seq.add_device(...) + # seq.add_command(...) + + return seq + + +def prompt_with_default(prompt: str, default: object) -> str: + raw_value = input(f"{prompt} [{default}]: ").strip() + return raw_value if raw_value else str(default) + + +def main() -> None: + load_env() + mongo = get_mongo_helper() + + campaign_name = prompt_with_default("Campaign name", DEFAULT_CAMPAIGN_NAME) + if not campaign_name: + raise ValueError("Campaign name is required.") + + campaign_doc = fetch_campaign(mongo, campaign_name) + batch_no = prompt_choice("batch_no values", list_batch_numbers(mongo, campaign_doc)) + sample_no = prompt_choice("sample_no values", list_sample_numbers(mongo, campaign_doc, batch_no)) + + campaign_doc, set_doc = fetch_parameter_set(mongo, campaign_doc, batch_no, sample_no) + params = build_recipe_params(batch_no, sample_no, set_doc) + params["campaign_name"] = campaign_doc["campaign_name"] + + print("\nLoaded MongoDB parameter set:") + for key, value in params.items(): + print(f" {key}: {value}") + + print(f"\nSuggested sample name: {build_sample_name(params)}") + + seq = build_sequence(params) + print("\nRecipe command sequence preview:") + if seq.command_list: + seq.print_command_names() + else: + print(" No commands yet. Add devices/commands in build_sequence().") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8dbf765 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,35 @@ +certifi +colorama +dash +dash-ace +dash-bootstrap-components +dash-table +dash-ag-grid +mfc +pandas +pdoc +Pillow +pymongo +pyserial +PyVISA +pyyaml +questionary +scipy +slackclient +scikit-learn +umap-learn +plotly +numpy +matplotlib +openpyxl +torch +opencv-python +scikit-image +botorch +gpytorch + +# Kinova Kortex SDK note for Python 3.10 environments: +# - This repo does not vendor the Kinova `kortex_api` package. +# - Install the Kinova SDK wheel separately in the active venv. +# - After installing the SDK, upgrade protobuf for Python 3.10 compatibility: +# python -m pip install --upgrade protobuf==3.20.3 diff --git a/to_implement/festo_solenoid_valve_multiple/Festo_Multiple.ino b/to_implement/festo_solenoid_valve_multiple/Festo_Multiple.ino new file mode 100644 index 0000000..e228b0d --- /dev/null +++ b/to_implement/festo_solenoid_valve_multiple/Festo_Multiple.ino @@ -0,0 +1,45 @@ +int incomingByte; + +void setup() { + // put your setup code here, to run once: + Serial.begin(9600); + pinMode(12, OUTPUT); + pinMode(8, OUTPUT); + pinMode(4, OUTPUT); + delay(500); + digitalWrite(12, LOW); + digitalWrite(8, LOW); + digitalWrite(4, LOW); + +} + +void loop() { + // put your main code here, to run repeatedly: + if (Serial.available() > 0){ + incomingByte = Serial.read(); + if (incomingByte == 'A'){ + digitalWrite(12, HIGH); + delay(250); + } + if (incomingByte == 'B'){ + digitalWrite(8, HIGH); + delay(250); + } + if (incomingByte == 'C'){ + digitalWrite(4, HIGH); + delay(250); + } + if (incomingByte == 'D'){ + digitalWrite(12, LOW); + delay(250); + } + if (incomingByte == 'E'){ + digitalWrite(8, LOW); + delay(250); + } + if (incomingByte == 'F'){ + digitalWrite(4, LOW); + delay(250); + } + } +}