diff --git a/.gitignore b/.gitignore index b7d89513..63c0421d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,14 @@ write_inner.py .ravenStatus romMeta.xml +# Ignore built executables +build +force_install +*.app + +# Ignore certain GUI files +.forceuirc + # Allow these files !FORCE_logo-bw.png !FORCE_logo-color.png @@ -19,4 +27,4 @@ romMeta.xml # python stuff __pycache__ -*.pyc \ No newline at end of file +*.pyc diff --git a/package/README.md b/package/README.md new file mode 100644 index 00000000..0ba04124 --- /dev/null +++ b/package/README.md @@ -0,0 +1,72 @@ +# FORCE One-Step Installer Creation +FORCE one-step installers are created using the `cx_Freeze` python package after creating a python environment using either `venv` or `conda`. +A computer with the same operating system and architecture as the target operating system must be used to generate an installer, i.e. use a Windows machine to generate a Windows installer, a Mac (Intel) to generate a Mac installer, etc. +Note that installers generated with Apple computers with M-series chips will not be backwards compatible with Intel-based Apple computers. + +Windows and macOS are the only operating systems currently supported. +Linux users are encouraged to use pip-installed or source built versions of the RAVEN, HERON, and TEAL software packages. + +## 1. Build FORCE executables +Create a conda environment `force_build_310`, install the RAVEN, HERON, and TEAL pip packages, and build the FORCE executables using the script `build_force.sh`. +The path to the conda executable must be provided using the `--conda-defs` argument. +```console +./build_force.sh --conda-defs /etc/profile.d/conda.sh +``` + +## 2. Add IPOPT to build directory (Windows only) +Download the IPOPT Windows binary: +https://github.com/coin-or/Ipopt/releases + +Extract the downloaded zip directory and copy its contents to the raven_install directory, ensuring to replace the version numbers of IPOPT as needed. +```console +cd force_install +unzip ~/Downloads/Ipopt-3.14.12-win64-msvs2019-md.zip +mv Ipopt-3.14.12-win64-msvs2019-md local +cd .. +``` + +## 3. Copy examples and build/copy the RAVEN, HERON, and TEAL documentation +Adding examples and documentation to the one-step installer requires having the source installation present on the build machine, with the `raven_libraries` conda environment already created. +```console +conda activate raven_libraries +./copy_examples.sh --raven-dir /path/to/raven --heron-dir /path/to/HERON +cp -R examples force_install/examples +./make_docs.sh --raven-dir /path/to/raven --heron-dir /path/to/HERON --teal-dir /path/to/TEAL +cp -R docs force_install/docs +``` +When running the `make_docs.sh` script, the optional `--no-build` flag may be added if the desired documentation PDFs have already been built, and you do not wish to rebuild the documents. +If using the `--no-build` option, there is no need to have the `raven_libraries` active. +```console +./make_docs.sh --no-build --raven-dir /path/to/raven --heron-dir /path/to/HERON --teal-dir /path/to/TEAL +cp -R docs force_install/docs +``` + +## 4. Get NEAMS Workbench installer +The installers for the NEAMS Workbench software can be found here: +https://code.ornl.gov/neams-workbench/downloads/-/tree/5.4.1?ref_type=heads + +Download `Workbench-5.4.1.exe` for Windows and `Workbench-5.4.1.dmg` for macOS. +Place this file in the `force_install` directory on Windows or the current directory on macOS. + +Windows: +```console +cp ~/Downloads/Workbench-5.4.1.exe force_install +``` + +macOS: +```console +cp ~/Downloads/Workbench-5.4.1.dmg . +``` + +## 5. Create the installer +### Windows +The Windows installer is created using Inno Setup. +Run the `inno_package.iss` script from the Inno Setup application. +The resulting .exe installer file can be found in the `inno_output` directory. + +### macOS +Run the macOS build script +```console +./build_mac_app.sh +``` +The disk image `FORCE.dmg` contains applications for both FORCE and Workbench. diff --git a/package/build_force.sh b/package/build_force.sh new file mode 100755 index 00000000..edb325de --- /dev/null +++ b/package/build_force.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Have users point to the location of their conda installation so we can properly activate the +# conda environment that is being made. Use the "--conda-defs" option to specify this path. +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + --conda-defs) + CONDA_DEFS="$2" + shift + shift + source $CONDA_DEFS + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Establish conda environment +conda create -n force_build_310 python=3.10 -y +conda activate force_build_310 + +# Check that the conda environment is active. If not, exit. +if [[ $CONDA_DEFAULT_ENV != "force_build_310" ]]; then + echo "Conda environment not activated. Maybe the path to the conda installation is incorrect?" + echo "Provided conda path: $CONDA_DEFS" + exit 1 +fi + +pip install cx_Freeze +pip install raven-framework heron-ravenframework teal-ravenframework +# If on macOS, use conda to install ipopt +if [[ "$OSTYPE" == "darwin"* ]]; then + # Note: The PyPI version of ipopt is not maintained and is severl major version + # behind the conda-forge distribution. + conda install -c conda-forge ipopt -y +fi + +# Build the FORCE executables +python setup.py install_exe --install-dir force_install diff --git a/package/build_mac_app.sh b/package/build_mac_app.sh new file mode 100755 index 00000000..b7349192 --- /dev/null +++ b/package/build_mac_app.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Set up the FORCE application bundle +# We'll set up the app so that some FORCE launcher script is the main executable, and the RAVEN, +# HERON, and TEAL executables are in the Resources directory. +# Build the initial app from the force_launcher.scpt AppleScript +osacompile -o FORCE.app force_launcher.scpt +# Now copy over the force_install directory contents to the application's Resources directory +cp -Rp force_install/* FORCE.app/Contents/Resources/ +# Overwrite the app's icon with the FORCE icon +cp icons/FORCE.icns FORCE.app/Contents/Resources/applet.icns + +# Create a new disk image +hdiutil create -size 5g -fs HFS+ -volname "FORCE" -o force_build.dmg + +# Mount the new disk image +hdiutil attach force_build.dmg -mountpoint /Volumes/FORCE + +# Mount the existing .dmg file file Workbench +hdiutil attach Workbench-5.4.1.dmg -mountpoint /Volumes/Workbench + +# Add the workshop tests and data directories to FORCE so that Workbench's autocomplete works for workshop examples +mkdir FORCE.app/Contents/Resources/tests +mkdir FORCE.app/Contents/Resources/examples +cp -Rp examples/workshop FORCE.app/Contents/Resources/tests/ +cp -Rp examples/data FORCE.app/Contents/Resources/examples/ + +# Move the FORCE app to the disk image +cp -Rp FORCE.app /Volumes/FORCE/ +cp -Rp /Volumes/Workbench/Workbench-5.4.1.app /Volumes/FORCE/ + +# Move the "examples" and "docs" directories from the FORCE app bundle to the top level of the disk +# image to make them more accessible. +cp -Rp examples /Volumes/FORCE/ +cp -Rp docs /Volumes/FORCE/ + +# Move the "examples" and "docs" directories from the FORCE app bundle to the top level of the disk +# image to make them more accessible. +if [ -d FORCE.app/Contents/Resources/examples ]; then + mv /Volumes/FORCE/FORCE.app/Contents/Resources/examples /Volumes/FORCE/ +else + echo "WARNING: No examples directory found in FORCE.app bundle" +fi +if [ -d FORCE.app/Contents/Resources/docs ]; then + mv FORCE.app/Contents/Resources/docs /Volumes/FORCE/ +else + echo "WARNING: No docs directory found in FORCE.app bundle" +fi + +# Add .son file to Workbench app to provide a default HERON configuration +cp default.apps.son /Volumes/FORCE/Workbench-5.4.1.app/Contents/ + +# Create a symlink to the Applications directory in the app's build directory +ln -s /Applications /Volumes/FORCE/Applications + +# Unmount all the disk images +hdiutil detach /Volumes/Workbench +hdiutil detach /Volumes/FORCE + +# Convert to read-only compressed image +if [ -f FORCE.dmg ]; then + rm FORCE.dmg +fi +hdiutil convert force_build.dmg -format UDZO -o FORCE.dmg + +# Remove the temporary disk image +rm force_build.dmg diff --git a/package/copy_examples.sh b/package/copy_examples.sh new file mode 100755 index 00000000..562bf08d --- /dev/null +++ b/package/copy_examples.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Copies examples from the test directories of RAVEN and HERON to provide examples for users using +# the standalone install version of FORCE. + +# Get the RAVEN and HERON locations as arguments "--raven-dir" and "--heron-dir" +# The destination directory is "examples" in the current directory but may be changed with the +# "--dest" argument. +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +EXAMPLES_DIR="$SCRIPT_DIR/examples" + +while [[ $# -gt 0 ]] +do + key="$1" + case $key in + --raven-dir) + RAVEN_DIR="$2" + shift + shift + ;; + --heron-dir) + HERON_DIR="$2" + shift + shift + ;; + --dest) + EXAMPLES_DIR="$2" + shift + shift + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# The examples we want to copy are the RAVEN user_guide tests, the HERON workshop tests, and the +# HERON data directory which contains time series models for those workshop tests. +EXAMPLES=($RAVEN_DIR/tests/framework/user_guide $HERON_DIR/data $HERON_DIR/tests/workshop) +mkdir -p $EXAMPLES_DIR + +for ex in ${EXAMPLES[@]}; do + cp -R "$ex" "$EXAMPLES_DIR" +done + +# Clean up the copied examples, removing files and directories created when running the tests. +DIRS_TO_REMOVE=("__pycache__" "gold" "*_o") +for dirname in ${DIRS_TO_REMOVE[@]}; do + find $EXAMPLES_DIR -type d -name $dirname -exec rm -r {} \; 2>/dev/null +done +FILES_TO_REMOVE=("tests" "moped_input.xml" "outer.xml" "inner.xml" "cash.xml" "*.lib" "write_inner.py" "*.heron" "*.heron.xml") +for filename in ${FILES_TO_REMOVE[@]}; do + find $EXAMPLES_DIR -name $filename -exec rm {} \; 2>/dev/null +done + +# If building on Mac, replace the %HERON_DATA% magic string with a relative path to the data +# directory. This is a little hacky but the %HERON_DATA% magic string doesn't look everywhere for +# the data directory. This is only an issue for the Mac standalone install. HERON will find the +# data directory correctly on Windows. +# DATA_DIR=$EXAMPLES_DIR/data +# if [[ "$OSTYPE" == "darwin"* ]]; then +# # Find all XML files recursively from the current directory +# find $EXAMPLES_DIR/workshop -type f -name "*.xml" | while read -r file; do +# # Check if the file contains the %HERON_DATA% magic string. If not, skip this file. +# grep -q "%HERON_DATA%" "$file" || continue + +# # Get the directory of the current XML file +# FILE_DIR=$(dirname "$file") + +# # Calculate the relative path from the XML file directory to the data directory +# echo "FILE_DIR: $FILE_DIR DATA_DIR: $DATA_DIR" +# RELATIVE_PATH=$(python -c "import os.path; print(os.path.relpath('$DATA_DIR', '$FILE_DIR'))") +# # RELATIVE_PATH=$(realpath -s --relative-to="$FILE_DIR" "$DATA_DIR") +# echo $RELATIVE_PATH + +# # Use sed to replace %HERON_DATA% with the relative path to the data directory +# sed -i '' "s|%HERON_DATA%|$RELATIVE_PATH|g" "$file" +# done +# fi diff --git a/package/default.apps.son b/package/default.apps.son new file mode 100644 index 00000000..68e86634 --- /dev/null +++ b/package/default.apps.son @@ -0,0 +1,13 @@ +applications { + HERON { + configurations { + default { + options { + shared { + Executable="/Applications/FORCE.app/Contents/Resources/heron" + } + } + } + } + } +} diff --git a/package/force_launcher.scpt b/package/force_launcher.scpt new file mode 100644 index 00000000..c5fe7d36 --- /dev/null +++ b/package/force_launcher.scpt @@ -0,0 +1,28 @@ +set options to {"HERON", "RAVEN", "TEAL", "Quit"} + +set selectedOption to choose from list options with title "FORCE Launcher" with prompt "Which FORCE application would you like to use?" default items {"HERON"} + +if selectedOption is false then + display dialog "No option selected. Exiting..." buttons {"OK"} default button "OK" +else + set selectedOption to item 1 of selectedOption + if selectedOption is "HERON" then + set filePathName to quoted form of "/Applications/FORCE.app/Contents/Resources/heron" + else if selectedOption is "RAVEN" then + set filePathName to quoted form of "/Applications/FORCE.app/Contents/Resources/raven_framework" + else if selectedOption is "TEAL" then + set filePathName to quoted form of "/Applications/FORCE.app/Contents/Resources/teal" + else if selectedOption is "Quit" then + display dialog "Exiting..." buttons {"OK"} default button "OK" + return + end if + -- do shell script filePathName + try + do shell script "test -e " & filePathName + -- If the test passes, the file exists and we proceed with the script + do shell script filePathName + on error + -- If the file doesn't exist, display an error message + display dialog "The file at " & filePathName & " does not exist." buttons {"OK"} default button "OK" + end try +end if \ No newline at end of file diff --git a/package/heron.py b/package/heron.py new file mode 100755 index 00000000..e8481e62 --- /dev/null +++ b/package/heron.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# Copyright 2017 Battelle Energy Alliance, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import re +import sys +from HERON.src.main import main +from ui import run_from_gui +from utils import add_local_bin_to_path + + +if __name__ == '__main__': + # Adds the "local/bin" directory to the system path in order to find ipopt and other executables + add_local_bin_to_path() + + # Parse the command line arguments + import argparse + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + parser = argparse.ArgumentParser(description='HERON') + parser.add_argument('-w', action='store_true', default=False, required=False, help='Run in the GUI') + parser.add_argument('--definition', action="store_true", dest="definition", help='HERON input file definition compatible with the NEAMS Workbench') + parser.add_argument('input', nargs='?', help='HERON input file') + args, unknown = parser.parse_known_args() + + # if the input file is not an xml file, assume it's an unknown argument + if args.input and not args.input.endswith('.xml'): + unknown.insert(0, args.input) + args.input = None + # remove the -w argument from sys.argv so it doesn't interfere with HERON's argument parsing + if args.w: + sys.argv.remove('-w') + + if (args.w or not args.input) and not args.definition: # if asked to or if no file is passed, run the GUI + run_from_gui(main) + else: + main() diff --git a/package/inno_package.iss b/package/inno_package.iss new file mode 100644 index 00000000..cb2081ae --- /dev/null +++ b/package/inno_package.iss @@ -0,0 +1,137 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "FORCE" +#define MyAppVersion "0.8" +#define MyAppPublisher "Idaho National Laboratory" +#define MyAppURL "https://github.com/idaholab/FORCE" +#define MyAppExeName "MyProg.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{D0EBD58D-0C2A-4451-8E20-C3C9C1AA5BE0} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +; Remove the following line to run in administrative install mode (install for all users.) +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir=inno_output +OutputBaseFilename=force_setup +Compression=lzma +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" +Name: "workbenchinstall"; Description: "Install NEAMS Workbench-5.4.1"; GroupDescription: "Optional Components" + +[Files] +Source: "force_install\heron.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "force_install\raven_framework.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "force_install\Workbench-5.4.1.exe"; DestDir: "{app}"; Flags: ignoreversion deleteafterinstall +Source: "force_install\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\FORCE\HERON"; Filename: "{app}\heron.exe" +Name: "{autoprograms}\FORCE\RAVEN"; Filename: "{app}\raven_framework.exe" +Name: "{autoprograms}\FORCE\TEAL"; Filename: "{app}\teal.exe" +Name: "{autoprograms}\FORCE\docs"; Filename: "{app}\docs" +Name: "{autoprograms}\FORCE\examples"; Filename: "{app}\examples" +Name: "{autodesktop}\HERON"; Filename: "{app}\heron.exe"; Tasks: desktopicon +Name: "{autodesktop}\RAVEN"; Filename: "{app}\raven_framework.exe"; Tasks: desktopicon +Name: "{autodesktop}\TEAL"; Filename: "{app}\teal.exe"; Tasks: desktopicon +; Add desktop icons for the documentation and examples directories +Name: "{autodesktop}\FORCE Documentation"; Filename: "{app}\docs"; Tasks: desktopicon +Name: "{autodesktop}\FORCE Examples"; Filename: "{app}\examples"; Tasks: desktopicon + +[Registry] +; File association for .heron files +Root: HKCU; Subkey: "Software\Classes\.heron"; ValueType: string; ValueName: ""; ValueData: "FORCE.heron"; Flags: uninsdeletevalue; Check: WizardIsTaskSelected('workbenchinstall') +Root: HKCU; Subkey: "Software\Classes\FORCE.heron"; ValueType: string; ValueName: ""; ValueData: "HERON File"; Flags: uninsdeletekey; Check: WizardIsTaskSelected('workbenchinstall') +Root: HKCU; Subkey: "Software\Classes\FORCE.heron\DefaultIcon"; ValueType: string; ValueData: "{app}\heron.exe,0"; Check: WizardIsTaskSelected('workbenchinstall') +; The open command will be set dynamically in the [Code] section + +[Code] +procedure InitializeProgressBar(); +begin + WizardForm.ProgressGauge.Style := npbstMarquee; + WizardForm.ProgressGauge.Visible := True; +end; + +procedure RunWorkbenchInstaller(); +var + ResultCode: Integer; +begin + InitializeProgressBar(); + WizardForm.StatusLabel.Caption := 'Installing NEAMS Workbench-5.4.1...'; + + try + Exec(ExpandConstant('{app}\Workbench-5.4.1.exe'), ExpandConstant('/S /D={autopf}\Workbench-5.4.1\'), '', SW_SHOW, ewWaitUntilTerminated, ResultCode); + finally + WizardForm.ProgressGauge.Visible := False; + WizardForm.ProgressGauge.Style := npbstNormal; + WizardForm.StatusLabel.Caption := ''; + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + WorkbenchPath: string; + DefaultAppsFilePath: string; + DefaultAppsContent: string; + ResultCode: Integer; +begin + // Install Workbench if the user selected the option and associate .heron files with the Workbench executable + if (CurStep = ssPostInstall) and WizardIsTaskSelected('workbenchinstall') then + begin + // Initialize the marquee progress bar + RunWorkbenchInstaller(); + + // Find the path to the Workbench executable + WorkbenchPath := ExpandConstant('{autopf}\Workbench-5.4.1\'); + + // Associate .heron files with the Workbench executable + RegWriteStringValue(HKEY_CURRENT_USER, 'Software\Classes\FORCE.heron\shell\open\command', '', '"' + WorkbenchPath + 'bin\Workbench.exe' + '" "%1"'); + + // default.apps.son file tells Workbench where to find HERON + DefaultAppsFilePath := WorkbenchPath + 'default.apps.son'; + DefaultAppsContent := + 'applications {' + #13#10 + + ' HERON {' + #13#10 + + ' configurations {' + #13#10 + + ' default {' + #13#10 + + ' options {' + #13#10 + + ' shared {' + #13#10 + + ' "Executable"="' + ExpandConstant('{app}') + '\heron.exe"' + #13#10 + + ' }' + #13#10 + + ' }' + #13#10 + + ' }' + #13#10 + + ' }' + #13#10 + + ' }' + #13#10 + + '}'; + + // Save the default.apps.son file in the Workbench base directory + if not SaveStringToFile(DefaultAppsFilePath, DefaultAppsContent, False) then + begin + MsgBox('Failed to create default.apps.son in the Workbench directory. Attempted to write to ' + DefaultAppsFilePath, mbError, MB_OK); + end; + + // Save the path to the Workbench executable in a file at {app}/.workbench. + if not SaveStringToFile(ExpandConstant('{app}\.workbench'), 'WORKBENCHDIR=' + WorkbenchPath, False) then + begin + MsgBox('Failed to save the path to the Workbench executable. Attempted to write to ' + ExpandConstant('{app}\.workbench'), mbError, MB_OK); + end; + end; +end; diff --git a/package/make_docs.sh b/package/make_docs.sh new file mode 100755 index 00000000..ae482412 --- /dev/null +++ b/package/make_docs.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +# This script builds and gathers documentation from the FORCE tools to be included together as +# documentation for FORCE. +# Command line arguments: +# --raven-dir : Path to the RAVEN repository. If no other paths are provided, the script will +# look for HERON and TEAL as RAVEN plugins. +# --heron-dir : (Optional) Path to the HERON repository. +# --teal-dir : (Optional) Path to the HERON repository. +# --dest : (Optional) Path to the directory where the documentation will be copied to. Default +# is to create a "docs" directory the directory this script is in. +# --no-build: (Optional) Skip building the documentation and only gather the existing documentation +# PDFs. Default is to rebuild the documentation. + +# Parse command line arguments +NO_BUILD=0 +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + --raven-dir) + RAVEN_DIR="$2" + shift + shift + ;; + --heron-dir) + HERON_DIR="$2" + shift + shift + ;; + --teal-dir) + TEAL_DIR="$2" + shift + shift + ;; + --no-build) + NO_BUILD=1 + shift + ;; + --dest) + DOC_DIR="$2" + shift + shift + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Check that the RAVEN directory is provided +if [ -z "$RAVEN_DIR" ]; then + echo "ERROR: The RAVEN directory must be provided with --raven-dir." + exit 1 +fi + +# If the HERON and TEAL directories are not provided, look for them as plugins. +if [ -z "$HERON_DIR" ]; then + HERON_DIR="$RAVEN_DIR/plugins/HERON" +fi +if [ -z "$TEAL_DIR" ]; then + TEAL_DIR="$RAVEN_DIR/plugins/TEAL" +fi + +# Default destination directory is a "docs" directory in the directory this script is in. +if [ -z "$DOC_DIR" ]; then + DOC_DIR="$SCRIPT_DIR/docs" +fi + +# Create a directory to store the documentation. We'll do that in the directory this script is in. +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +DOC_DIR="$SCRIPT_DIR/docs" +echo "FORCE documentation directory: $DOC_DIR" +mkdir -p "$DOC_DIR" + +# Build the documentation for the FORCE tools +for loc in RAVEN_DIR HERON_DIR TEAL_DIR; do + pushd "${!loc}/doc" > /dev/null + echo $(pwd) + + # If the build flag is set, build the documentation. + if [ $NO_BUILD -eq 0 ]; then + echo "Building documentation for $(basename ${!loc})" + if [[ -f "Makefile" ]] && command -v "make" >/dev/null 2>&1; then + make + elif [[ -f "make_docs.bat" ]] && [[ $OSTYPE == "msys" ]]; then + ./make_docs.bat + elif [[ -f "make_docs.sh" ]]; then + bash make_docs.sh + else + echo "ERROR: No Makefile or make_docs.sh script found in $(basename ${!loc}) doc directory." + exit 1 + fi + fi + + # The PDFs that are generated are located in either a "pdfs" or "pdf" directory + if [ -d pdfs ]; then + cp pdfs/*.pdf $DOC_DIR + elif [ -d pdf ]; then + cp pdf/*.pdf $DOC_DIR + else + echo "ERROR: No PDFs found in $(basename ${!loc}) doc directory." + exit 1 + fi + + popd > /dev/null +done diff --git a/package/raven_framework.py b/package/raven_framework.py new file mode 100755 index 00000000..825e273d --- /dev/null +++ b/package/raven_framework.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# Copyright 2017 Battelle Energy Alliance, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Created on Feb 14, 2022 + +@author: cogljj + +This is a package that properly imports Driver and runs it. +""" +import sys +from ravenframework.Driver import main +from ui import run_from_gui +from utils import add_local_bin_to_path +import multiprocessing + + +if __name__ == '__main__': + # For Windows, this is required to avoid an infinite loop when running a multiprocessing script from a frozen executable. + # cx_Freeze provides a hook for this that is supposed to be called automatically to fix this issue on all platforms, + # but for now, it doesn't seem to resolve the issue on macOS. + multiprocessing.freeze_support() + + # Adds the "local/bin" directory to the system path in order to find ipopt and other executables + add_local_bin_to_path() + + # Parse the command line arguments + import argparse + parser = argparse.ArgumentParser(description='RAVEN') + parser.add_argument('-w', action='store_true', default=False, required=False,help='Run in the GUI') + parser.add_argument('input', nargs='*', help='RAVEN input file') + args, unknown = parser.parse_known_args() + + # More than one argument may be parsed for "input". Move any arguments that aren't an XML file to + # the unknown arguments list. + args_to_move = [] + for arg in args.input: + if not arg.endswith('.xml'): + args_to_move.append(arg) + for arg in args_to_move: + args.input.remove(arg) + unknown.insert(0, arg) + + # sys.argv is used by the main function, so we need to remove the -w argument + if args.w: + sys.argv.remove('-w') + + if args.w or not args.input: # run the GUI if asked to (-w) or if no input file is given + run_from_gui(main, checkLibraries=True) + else: + sys.exit(main(True)) diff --git a/package/setup.cfg b/package/setup.cfg new file mode 100644 index 00000000..f8e34dac --- /dev/null +++ b/package/setup.cfg @@ -0,0 +1,6 @@ +[options] +install_requires = + raven-framework + teal-ravenframework + heron-ravenframework + ipopt diff --git a/package/setup.py b/package/setup.py new file mode 100644 index 00000000..f39f554d --- /dev/null +++ b/package/setup.py @@ -0,0 +1,48 @@ +import sys +import platform +import os +import pathlib +from cx_Freeze import setup, Executable, build_exe + +import HERON.templates.write_inner + + +build_exe_options = { + "packages": ["ravenframework","msgpack","ray","crow_modules","AMSC","sklearn","pyomo","HERON","TEAL","pyarrow","netCDF4","cftime","distributed","dask","tensorflow"], + "includes": ["ray.thirdparty_files.colorama","ray.autoscaler._private","pyomo.common.plugins","HERON.templates.template_driver","dask.distributed","imageio.plugins.pillow","imageio.plugins.pillowmulti","imageio.plugins.pillow_info"], + "include_files": [(HERON.templates.write_inner.__file__,"lib/HERON/templates/write_inner.py")], +} + +# Add all of the HERON template XML files to the build +write_inner_path = pathlib.Path(HERON.templates.write_inner.__file__) +for xml_file in os.listdir(write_inner_path.parent): + if xml_file.endswith(".xml"): + build_exe_options["include_files"].append((write_inner_path.parent / xml_file, f"lib/HERON/templates/{xml_file}")) + +# Some files must be included manually for the Windows build +if platform.system().lower() == "windows": + # netCDF4 .dll files get missed by cx_Freeze + # ipopt executable must be included manually + netCDF4_libs_path = os.path.join(os.path.dirname(sys.executable), "lib", "site-packages", "netCDF4.libs") + build_exe_options["include_files"] += [ + #("Ipopt-3.14.13-win64-msvs2019-md/","local/bin/Ipopt-3.14.13-win64-msvs2019-md"), # FIXME: Point to the correct location for ipopt executable + (netCDF4_libs_path,"lib/netCDF4") + ] + # Include the Microsoft Visual C++ Runtime + build_exe_options["include_msvcr"] = True +else: + ipopt_path = os.path.join(os.path.dirname(sys.executable), "ipopt") + build_exe_options["include_files"] += [ + # (ipopt_path, "local/bin/ipopt") + (ipopt_path, "ipopt") # put it in main directory + ] + +setup( + name="force", + version="0.1", + description="FORCE package", + executables=[Executable(script="raven_framework.py",icon="icons/raven_64.ico"), + Executable(script="heron.py",icon="icons/heron_64.ico"), + Executable(script="teal.py",icon="icons/teal_64.ico")], + options={"build_exe": build_exe_options}, +) diff --git a/package/teal.py b/package/teal.py new file mode 100644 index 00000000..402b89c5 --- /dev/null +++ b/package/teal.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# Copyright 2017 Battelle Energy Alliance, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Created on Feb 8, 2024 + +@author: j-bryan (Jacob Bryan) + +Runs the TEAL package as a standalone application. +""" +import sys +from TEAL.src.CashFlow_ExtMod import TEALmain +from ui import run_from_gui + + +if __name__ == '__main__': + import argparse + parser = argparse.ArgumentParser(description='RAVEN') + parser.add_argument('-w', action='store_true', default=False, required=False,help='Run in the GUI') + parser.add_argument('-iXML', nargs=1, required=False, help='XML CashFlow input file name', metavar='inp_file') + parser.add_argument('-iINP', nargs=1, required=False, help='CashFlow input file name with the input variable list', metavar='inp_file') + parser.add_argument('-o', nargs=1, required=False, help='Output file name', metavar='out_file') + args = parser.parse_args() + + # Remove the -w argument from sys.argv so it doesn't interfere with TEAL's argument parsing + if args.w: + sys.argv.remove('-w') + + # If the -w argument is present or any of the other arguments are missing, run the GUI + if args.w or not args.iXML or not args.iINP or not args.o: + print('Running TEAL in GUI mode') + run_from_gui(TEALmain) + else: + print('Running TEAL in command line mode') + sys.exit(TEALmain()) diff --git a/package/ui/__init__.py b/package/ui/__init__.py new file mode 100644 index 00000000..18e7264b --- /dev/null +++ b/package/ui/__init__.py @@ -0,0 +1 @@ +from .main import run_from_gui diff --git a/package/ui/controllers/__init__.py b/package/ui/controllers/__init__.py new file mode 100644 index 00000000..eb5881c7 --- /dev/null +++ b/package/ui/controllers/__init__.py @@ -0,0 +1 @@ +from .main import Controller diff --git a/package/ui/controllers/file_dialog.py b/package/ui/controllers/file_dialog.py new file mode 100644 index 00000000..b52d414b --- /dev/null +++ b/package/ui/controllers/file_dialog.py @@ -0,0 +1,74 @@ +from typing import Optional +import os + +from tkinter import filedialog + + +class FileDialogController: + def __init__(self, view, file_type=None, is_output=False, persistence=None): + """ + Constructor + @In, view, tk.Frame, the view + @In, file_type, str, optional, the file type + @In, is_output, bool, optional, whether the file is an output file + @In, persistence, FileLocationPersistence, optional, the file location persistence + @Out, None + """ + self.view = view + + self.filename = None + self.file_type = file_type + self.persistence = persistence + + if is_output: + if not self.file_type: + self.file_type = 'out' + self.view.browse_button.config(command=self.open_save_dialog) + else: + self.view.browse_button.config(command=self.open_selection_dialog) + + def get_filename(self): + """ + filename getter + @In, None + @Out, filename, str, the filename + """ + if not self.filename: # None or empty string + return None + return self.filename + + def set_filename(self, value): + """ + filename setter + @In, value, str, the filename + @Out, None + """ + if not os.path.exists(value): + raise FileNotFoundError(f'File {value} does not exist') + self.filename = os.path.abspath(value) + self.view.filename.set(os.path.basename(value)) + if self.persistence: + self.persistence.set_location(value) + + def open_selection_dialog(self): + """ + Open a file dialog to select an existing file + @In, None + @Out, None + """ + initial_dir = self.persistence.get_location() if self.persistence else os.getcwd() + filetypes = [(self.file_type.upper(), f'*.{self.file_type.strip().lower()}') if self.file_type else ('All Files', '*.*')] + filename = filedialog.askopenfilename(initialdir=initial_dir, filetypes=filetypes) + if filename: + self.set_filename(filename) + + def open_save_dialog(self): + """ + Open a file dialog to save a new file + @In, None + @Out, None + """ + initial_dir = self.persistence.get_location() if self.persistence else os.getcwd() + filename = filedialog.asksaveasfilename(initialdir=initial_dir, defaultextension=f'.{self.file_type}') + if filename: + self.set_filename(filename) diff --git a/package/ui/controllers/file_location_persistence.py b/package/ui/controllers/file_location_persistence.py new file mode 100644 index 00000000..8c4840cf --- /dev/null +++ b/package/ui/controllers/file_location_persistence.py @@ -0,0 +1,73 @@ +import os + + +class RCFile(dict): + """ Class for handling reading and writing to a dotfile for remembering config data between runs. """ + def __init__(self, path: str): + """ + Constructor + @In, path, str, the path to the dotfile + @Out, None + """ + super().__init__() + self.path = path + if os.path.exists(self.path): + self.read() + + def close(self): + """ + Save and close the dotfile + @In, None + @Out, None + """ + # Write the entries to the file when the object is deleted + with open(self.path, 'w') as f: + for key, value in self.items(): + f.write(f"{key}={value}\n") + + def read(self): + """ + Reads the dotfile + @In, None + @Out, None + """ + with open(self.path, 'r') as f: + for line in f.readlines(): + key, value = line.strip().split('=') + self |= {key: value} + + +class FileLocationPersistence: + """ A class for remembering where the case file that was last selected is located. """ + def __init__(self): + """ + Constructor + @In, None + @Out, None + """ + # A file with the location of the last selected case file + self.rcfile = RCFile(os.path.join(os.path.dirname(__file__), '..', '.forceuirc')) + + def get_location(self): + """ + Getter for the last file location + @In, None + @Out, last_location, str, the last file location + """ + return self.rcfile.get('DEFAULT_DIR', os.path.expanduser('~')) + + def set_location(self, value): + """ + Setter for the last file location + @In, value, str, the last file location (file path or directory) + @Out, None + """ + self.rcfile['DEFAULT_DIR'] = os.path.abspath(os.path.dirname(value)) + + def close(self): + """ + Closes the file location persistence + @In, None + @Out, None + """ + self.rcfile.close() diff --git a/package/ui/controllers/file_selection.py b/package/ui/controllers/file_selection.py new file mode 100644 index 00000000..e088b19d --- /dev/null +++ b/package/ui/controllers/file_selection.py @@ -0,0 +1,154 @@ +from typing import Optional +import os +import argparse + +from .file_location_persistence import FileLocationPersistence +from .file_dialog import FileDialogController +from ui.utils import run_in_workbench + +class FileSpec: + """ Input/output file specification for a package. """ + def __init__(self, + arg_name: str, + description: str, + is_output: bool = False, + file_type: Optional[str] = None): + """ + Constructor + @In, arg_name, str, optional, the argument flag for the file + @In, description, str, the description of the file + @In, is_output, bool, optional, whether the file is an output file + @In, file_type, str, optional, the type of file + @Out, None + """ + self.arg_name = arg_name + self.description = description + self.is_output = is_output + self.file_type = file_type + + def add_to_parser(self, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """ + Adds the file specification to the parser. + @In, parser, argparse.ArgumentParser, the parser + @Out, parser, argparse.ArgumentParser, the parser with the file specification added + """ + # if self.arg_name.startswith('-'): + # parser.add_argument(self.arg_name, nargs=1, required=False, help=self.description) + # else: # positional argument + # parser.add_argument(self.arg_name, nargs='?', help=self.description) + parser.add_argument(self.arg_name, nargs='?', help=self.description) + return parser + + +class FileSelectionController: + """ Controller for the file selection widget. """ + # Specification for the files that are required for each package. These are used to tie any files + # passed from the command line to the file selection widget and helps define what file extensions + # are allowed for each file. + _file_selection_specs = { + 'teal': [FileSpec('-iXML', 'TEAL XML File', file_type='xml'), + FileSpec('-iINP','Variable Inputs File', file_type='txt'), + FileSpec('-o', 'Output File', is_output=True)], + 'ravenframework': [FileSpec('filename', 'RAVEN XML File', file_type='xml')], + 'heron': [FileSpec('filename', 'HERON Input File', file_type='xml')] + } + + def __init__(self, model, view): + """ + Constructor + @In, model, Model, the model + @In, view, FileSelection, the view + @Out, None + """ + self.file_selection = view + self.file_dialog_controllers = {} + + # Remember the file locations for the user + self.persistence = FileLocationPersistence() + + # Create the file selectors, adding any files specified from the command line + model_package_name = model.get_package_name().strip().lower() + self._file_specs = self._file_selection_specs[model_package_name] + args, self.unknown_args = self._parse_cli_args() + for spec in self._file_specs: + # Create a new file selector view based on the file spec + self.file_selection.new_file_selector(label=spec.description) + # Create a new file dialog controller for the file selector + file_dialog_controller = FileDialogController( + view=self.file_selection.file_selectors[spec.description], + file_type=spec.file_type, + is_output=spec.is_output, + persistence=self.persistence + ) + if filename := args.get(spec.arg_name, None): + file_dialog_controller.set_filename(filename) + self.persistence.set_location(filename) + self.file_dialog_controllers[spec.description] = file_dialog_controller + + # Set the action for the "Open in Workbench" button + if model_package_name == "heron": + workbench_func = lambda: run_in_workbench(self.file_dialog_controllers['HERON Input File'].get_filename()) + self.file_selection.add_open_in_workbench_button(workbench_func) + + def get_sys_args_from_file_selection(self): + """ + Gets the files selected by the user and returns them as a list along with their + corresponding argument flags, if any. + @In, None + @Out, args, list, a list of files and their corresponding argument flags, if any + """ + args = [] + for spec in self._file_specs: + # Get the filename from the file selector + filename = self.file_dialog_controllers[spec.description].get_filename() + # Add the filename with its corresponding argument flag to the list + if not os.path.exists(filename) and spec.arg_name != '-o': + raise FileNotFoundError(f"File {filename} not found") + if spec.arg_name.startswith('-'): # flag argument, include the flag and the argument + args.extend([spec.arg_name, filename]) + else: # positional argument, include the argument only + args.append(filename) + # Add any unknown arguments to pass along to the model + args.extend(self.unknown_args) + return args + + def close_persistence(self): + """ + Closes the file location persistence + @In, None + @Out, None + """ + self.persistence.close() + + def _parse_cli_args(self): + """ + Parse arguments provided from the command line + @In, None + @Out, args, dict, the parsed arguments + @Out, unknown, list, the unknown arguments + """ + parser = argparse.ArgumentParser() + for spec in self._file_specs: + parser = spec.add_to_parser(parser) + # Handling unknown arguments lets use pass additional arguments to the model while only directly + # handling the file selection arguments. + args, unknown = parser.parse_known_args() + args = vars(args) + # Positional arguments requiring a specific file type may be missing, and an unknown argument + # may have been interpreted as being that file argument. We'll check if the argument has the + # correct file extension and if it does, we'll assume it's the file argument. Otherwise, we'll + # remove it and add it to the list of unknown arguments. Finally, we'll check the unknown arguments + # and make sure the file argument didn't end up in there. + for spec in self._file_specs: + if spec.arg_name not in args: + for arg in unknown: + if arg.endswith(f'.{spec.file_type}'): + args[spec.arg_name] = arg + unknown.remove(arg) + break + # Any arguments with flags will have had the '-' stripped off. It'll be helpful to know which + # arguments were specified with flags, so we'll add the '-' back to the keys. + for key in args.keys(): + if f'-{key}' in [spec.arg_name for spec in self._file_specs]: + args[f'-{key}'] = args.pop(key) + return args, unknown diff --git a/package/ui/controllers/main.py b/package/ui/controllers/main.py new file mode 100644 index 00000000..68853d4b --- /dev/null +++ b/package/ui/controllers/main.py @@ -0,0 +1,39 @@ +import sys + +from .file_selection import FileSelectionController +from .text_output import TextOutputController +from .model_status import ModelStatusController + + +class Controller: + def __init__(self, model, view): + self.model = model + self.view = view + + # Initialize controllers + self.file_selection_controller = FileSelectionController(self.model, self.view.frames["file_selection"]) + self.text_output_controller = TextOutputController(self.model, self.view.frames["text_output"]) + self.model_status_controller = ModelStatusController(self.model, self.view.frames["text_output"].model_status) + + # Bind the run button to the model + self.view.frames["run_abort"].run_button.config(command=self.run_model) + # Bind the abort button to closing the window + self.view.frames["run_abort"].abort_button.config(command=self.quit) + + def run_model(self): + # Construct sys.argv from the file selectors + sys.argv = [sys.argv[0]] + self.file_selection_controller.get_sys_args_from_file_selection() + # Start the model + self.model.start() + + def start(self): + self.view.mainloop() + + def quit(self, showdialog: bool = True): + """ + Quit the application + @In, showdialog, bool, optional, whether to show a dialog before quitting, default is True + @Out, None + """ + self.file_selection_controller.close_persistence() + self.view.quit(showdialog) diff --git a/package/ui/controllers/model_status.py b/package/ui/controllers/model_status.py new file mode 100644 index 00000000..e9567a2c --- /dev/null +++ b/package/ui/controllers/model_status.py @@ -0,0 +1,41 @@ +import threading +import time +from ..models.main import ModelStatus + + +class ModelStatusController: + """ Tracks if the model is running and updates the view to reflect the status. """ + def __init__(self, model, view): + """ + Constructor + @In, model, Model, the model + @In, view, View, the view + """ + self.model = model + self.view = view + self._model_has_run = False # Flag to indicate the model has already been run + self.check_model_status() + + def check_model_status(self): + """ + Check the status of the model and update the view + @In, None + @Out, None + """ + def _check_status(): + """ + Local helper function for checking the status of the model in a separate thread + @In, None + @Out, None + """ + current_status = self.model.status + while True: + if current_status != self.model.status: + current_status = self.model.status + self.view.set_status(self.model.status) + time.sleep(0.5) + + thread = threading.Thread(target=_check_status) + thread.daemon = True + thread.name = "ModelStatusChecker" + thread.start() diff --git a/package/ui/controllers/text_output.py b/package/ui/controllers/text_output.py new file mode 100644 index 00000000..4cab648d --- /dev/null +++ b/package/ui/controllers/text_output.py @@ -0,0 +1,81 @@ +import sys +import io +import threading +import tkinter as tk +import time + + +class TextOutputController: + def __init__(self, model, view): + """ + Constructor + @In, model, Model, the model to control + @In, view, TextOutput, the view to control + """ + self.view = view + self.redirector = StdoutRedirector(self.view.text) + self.redirector.start() + # Define show/hide button behavior + self.view.show_hide_button.config(command=self.toggle_show_text) + + def toggle_show_text(self): + """ + Toggle the visibility of the output text widget and resize the window to fit + @In, None + @Out, None + """ + if self.view.is_showing: # Hide output + # self.view.text.grid_forget() + self.view.hide_text_output() + self.view.show_hide_button.config(text='Show Output') + else: # Show output + # self.view.text.grid(row=1, column=0, sticky='nsew') + self.view.show_text_output() + self.view.show_hide_button.config(text='Hide Output') + # self.view.is_showing = not self.view.is_showing + + +class StdoutRedirector: + """ Redirects stdout to a tkinter widget """ + def __init__(self, widget: tk.Widget): + """ + Constructor + @In, widget, tk.Widget, the widget to redirect stdout to + @Out, None + """ + self.widget = widget + self.redirect_output = io.StringIO() + sys.stdout = self.redirect_output + sys.stderr = self.redirect_output + + def start(self): + """ + Start the redirector. Uses a daemon thread to monitor the output. + @In, None + @Out, None + """ + self.monitor_thread = threading.Thread(target=self.monitor_output) + self.monitor_thread.daemon = True + self.monitor_thread.start() + + def monitor_output(self): + """ + Monitors the output and updates the widget + @In, None + @Out, None + """ + while True: + # Get the buffer's contents, then clear it + text = self.redirect_output.getvalue() + self.redirect_output.seek(0) + self.redirect_output.truncate(0) + + # Update the widget's text + if text: + self.widget.config(state=tk.NORMAL) + self.widget.insert(tk.END, text) + self.widget.config(state=tk.DISABLED) + self.widget.see(tk.END) + + # Sleep to prevent busy-waiting + time.sleep(0.1) # FIXME: expose this as a parameter diff --git a/package/ui/main.py b/package/ui/main.py new file mode 100644 index 00000000..4fbdde4f --- /dev/null +++ b/package/ui/main.py @@ -0,0 +1,20 @@ +from typing import Callable +from ui.models import Model +from ui.controllers import Controller +from ui.views import View + + +def run_from_gui(func: Callable, **kwargs): + """ + Runs the given function from the GUI. + @In, func, Callable, the function to run + @In, args, argparse.Namespace, optional, the parsed command-line arguments + @In, kwargs, dict, optional, the keyword arguments for the model + @Out, None + """ + model = Model(func, **kwargs) + view = View() + controller = Controller(model, view) + controller.start() + # Let the controller know to clean up when the view is closed + controller.quit(showdialog=False) diff --git a/package/ui/models/__init__.py b/package/ui/models/__init__.py new file mode 100644 index 00000000..745978f4 --- /dev/null +++ b/package/ui/models/__init__.py @@ -0,0 +1 @@ +from .main import Model diff --git a/package/ui/models/main.py b/package/ui/models/main.py new file mode 100644 index 00000000..e52a359b --- /dev/null +++ b/package/ui/models/main.py @@ -0,0 +1,60 @@ +import threading +from typing import Callable +import time +from enum import Enum + + +class ModelStatus(Enum): + """ Enum for model status """ + NOT_STARTED = "Not yet run" + RUNNING = "Running" + FINISHED = "Finished" + ERROR = "Error" + + +class Model: + """ Runs a function in a separate thread """ + def __init__(self, func: Callable, **kwargs): + """ + Constructor + @In, func, Callable, the function to run + @In, kwargs, dict, keyword arguments to pass to the function + @Out, None + """ + self.func = func + self.thread = None + self.kwargs = kwargs + self.status = ModelStatus.NOT_STARTED + + def start(self): + """ + Start the thread + @In, None + @Out, None + """ + def func_wrapper(): + """ + Wrapper for the function to run and set the status to FINISHED when done + @In, None + @Out, None + """ + self.status = ModelStatus.RUNNING + try: + self.func(**self.kwargs) + except: + self.status = ModelStatus.ERROR + else: + self.status = ModelStatus.FINISHED + + self.thread = threading.Thread(target=func_wrapper) + self.thread.daemon = True + self.thread.name = self.get_package_name() + self.thread.start() + + def get_package_name(self): + """ + Get the top-level package name of the model + @In, None + @Out, package_name, str, the package name + """ + return self.func.__module__.split('.')[0] diff --git a/package/ui/utils.py b/package/ui/utils.py new file mode 100644 index 00000000..e3eb5b37 --- /dev/null +++ b/package/ui/utils.py @@ -0,0 +1,346 @@ +import sys +import os +import pathlib +import subprocess +import platform +import shutil +import tkinter as tk +from tkinter import messagebox, filedialog + + +def is_frozen_app() -> bool: + """ + Infers whether the application being run is a frozen executable or not. This is done by checking the + location and file extension of the HERON executable. + + @return, bool, True if the application is a frozen executable, False otherwise + """ + current_file_dir = pathlib.Path(__file__).parent + # If there's a "heron.py" file one directory up from this file, then we're likely running from the + # source code. Frozen executables will have "heron" or "heron.exe" as the executable name. + # FIXME: This is likely to be a bit fragile! Is there a better way to determine if we're running from + # a frozen executable? + if (current_file_dir.parent / "heron.py").exists(): + return False + else: + return True + + +def get_workbench_exe_path(workbench_dir: pathlib.Path) -> pathlib.Path: + """ + Returns the path to the Workbench executable, dependent on the operating system. + + @ In, workbench_dir, pathlib.Path, the path to the Workbench installation directory + @ Out, workbench_exe_path, pathlib.Path, the path to the Workbench executable + """ + if platform.system() == "Windows": + workbench_exe_path = workbench_dir / "bin" / "Workbench.exe" + elif platform.system() == "Darwin": + workbench_exe_path = workbench_dir / "Contents" / "MacOS" / "Workbench" + elif platform.system() == "Linux": + workbench_exe_path = workbench_dir / "bin" / "Workbench" + else: + raise ValueError(f"Platform {platform.system()} is not supported.") + + return workbench_exe_path + + +def get_workbench_dir_from_exe_path(workbench_exe_path: pathlib.Path) -> pathlib.Path: + """ + Returns the path to the Workbench installation directory from the path to the Workbench executable. + + @ In, workbench_exe_path, pathlib.Path, the path to the Workbench executable + @ Out, workbench_dir, pathlib.Path, the path to the Workbench installation directory + """ + # NOTE: for macOS, this returns the path to the "Contents" directory in the app bundle, not the + # app bundle's root directory. However, this keeps pathing more consistent with other platforms. + workbench_dir = workbench_exe_path.parent.parent + return workbench_dir + + +def verify_workbench_dir(workbench_dir: pathlib.Path) -> bool: + """ + Verifies that the given path is a valid NEAMS Workbench installation directory. This is done by checking for the Workbench executable, + dependent on the operating system. + + @ In, workbench_dir, pathlib.Path, the path to the Workbench installation directory + @ Out, valid, bool, True if the directory is a valid Workbench installation, False otherwise + """ + workbench_exe_path = get_workbench_exe_path(workbench_dir) + valid = workbench_exe_path.exists() + return valid + + +def get_dirs(dirname: pathlib.Path, pattern: str = "*") -> list[pathlib.Path]: + """ + Finds all directories in dirname that match the given pattern. + + @ In, dirname, pathlib.Path, the directory to search + @ In, pattern, str, optional, the pattern to match directories against + @ Out, dirs, list[pathlib.Path], the list of directories that match the pattern + """ + dirs = [p for p in dirname.iterdir() if p.is_dir() and p.match(pattern)] + return dirs + + +def check_workbench_file_for_dir(workbench_file: pathlib.Path) -> pathlib.Path | None: + """ + Checks the given .workbench file for the installation directory of Workbench. If file does not exist, None is returned. If + the file does exist but if the Workbench executable cannot be found there, the WORKBENCHDIR key is deleted and None is returned. + Finally, if the file exists and the Workbench executable is found, the path to the Workbench installation directory is returned. + + @ In, workbench_file, pathlib.Path, the path to the .workbench file + @ Out, workbench_dir, pathlib.Path | None, the path to the Workbench installation directory, or None if the file does not exist or the + Workbench executable cannot be found + """ + if not workbench_file.exists(): # .workbench file not found at given path + return None + + # Parse the .workbench file to get the installation directory of Workbench. + # Info is stored in the format "KEY=VALUE" on each line. + workbench_config = {} + with open(workbench_file, 'r') as f: + for line in f: + key, value = line.strip().split("=") + workbench_config[key] = value + + workbench_dir = workbench_config.get("WORKBENCHDIR", None) + + if workbench_dir is not None and not verify_workbench_dir(pathlib.Path(workbench_dir)): + workbench_dir = None + + if workbench_dir is None: # either wasn't provided or was invalid + # If the path in the .workbench file is invalid, delete the WORKBENCHDIR key so we don't keep trying + # to use it. If no other keys are present, delete the file. + workbench_config.pop("WORKBENCHDIR", None) # Remove the key if it exists + if len(workbench_config) == 0: # If no other keys are present, delete the file + workbench_file.unlink() + else: # Otherwise, write the updated config back to the file + with open(workbench_file, 'w') as f: + for key, value in workbench_config.items(): + f.write(f"{key}={value}\n") + + return workbench_dir + + +def find_workbench() -> pathlib.Path | None: + """ + Finds the NEAMS Workbench executable. A ".workbench" file in the FORCE app's main directory tracks + the location of the Workbench executable. If that file doesn't exist, we look in common directories + to find Workbench ourselves. If we still can't find it, we ask the user to locate it manually, if + desired. + + @ In, None + @ Out, workbench_exe_path, pathlib.Path | None, the path to the NEAMS Workbench executable, or None if it could not be found + """ + workbench_path = None + + # Check if a ".workbench" file exists in the main build directory (same directory as the heron and + # raven_framework executables). That should be 2 directories up from the directory of this file. + current_file_dir = pathlib.Path(__file__).parent + # Is this being run from a frozen executable or via the source code? Changes where the package's + # base directory is located, changing where to look for the .workbench file. + if is_frozen_app(): # Frozen executable + force_base_dir = current_file_dir.parent.parent + else: # Source code + force_base_dir = current_file_dir.parent + workbench_file = force_base_dir / ".workbench" + workbench_path = check_workbench_file_for_dir(workbench_file) # Returns None if file doesn't exist or is invalid + + # If that .workbench file doesn't exist, we can look around for the Workbench executable + if platform.system() == "Windows": + if wb_path := os.environ.get('WORKBENCH_PATH', None): + workbench_path = wb_path + elif wb_path := shutil.which('Workbench'): + workbench_path = wb_path + else: + # Manually search through a few common directories for the Workbench installation + for path in [force_base_dir.parent, force_base_dir, "$HOMEDRIVE", "$PROGRAMFILES", + "$HOME", "$APPDATA", "$LOCALAPPDATA"]: + path = pathlib.Path(os.path.expandvars(path)) + if not path.exists(): + continue + for loc in get_dirs(path, "Workbench*"): + if verify_workbench_dir(loc): + workbench_path = loc + break + elif platform.system() == "Darwin": + # The only place Workbench should be installed on Mac is in the Applications directory. + for app in get_dirs(pathlib.Path("/Applications"), "Workbench*"): + if verify_workbench_dir(app): + workbench_path = app + break + # NOTE: Workbench install on Linux is only with a source install which has no standard location. We'll rely on the user to connect + # the two tools. + + # If we still haven't found Workbench, let the user help us out. Throw up a tkinter warning dialog to + # ask the user to locate the Workbench executable. + if workbench_path is None: + root = tk.Tk() + root.withdraw() + + response = messagebox.askyesno( + title="NEAMS Workbench could not be found!", + message="The NEAMS Workbench executable could not be found. Would you like to manually find the Workbench installation directory?" + ) + if response: + dialog_title = "Select NEAMS Workbench Application" if platform.system() == "Darwin" else "Select NEAMS Workbench Directory" + workbench_path = filedialog.askdirectory(title=dialog_title) + if workbench_path: + is_valid = verify_workbench_dir(pathlib.Path(workbench_path)) + if not is_valid: + messagebox.showerror( + title="Invalid Workbench Directory", + message="The NEAMS Workbench executable was not found in the selected directory!" + ) + workbench_path = None + else: + with open(workbench_file, 'w') as f: + f.write("WORKBENCHDIR=" + workbench_path) + + if workbench_path is None: # If we still don't have a valid path, just give up I guess + return None + + if isinstance(workbench_path, str): + workbench_path = pathlib.Path(workbench_path) + + workbench_exe_path = get_workbench_exe_path(workbench_path) + + return workbench_exe_path + + +def create_workbench_heron_default(workbench_dir: pathlib.Path): + """ + Creates a configuration file for Workbench so it knows where HERON is located. + + @ In, workbench_dir, pathlib.Path, the path to the NEAMS Workbench installation directory + @ Out, NOne + """ + # First, we need to find the HERON executable. This will be "heron.exe" on Windows + # and just "heron" on MacOS and Linux. It should be located 2 directories up from the + # directory of this file. + current_file_dir = pathlib.Path(__file__).parent + + # Is this a frozen executable or source code? Changes where the package's base directory is located. + if (current_file_dir.parent / "heron.py").exists(): # Source code + heron_path = current_file_dir.parent / "heron.py" + else: # Frozen executable + heron_path = current_file_dir.parent.parent / "heron" + # Windows executables have a ".exe" extension + if platform.system() == "Windows": + heron_path = heron_path.with_suffix(".exe") + + # If the HERON executable doesn't exist, we can't create the Workbench configuration file + if not heron_path.exists(): + return + + # Create the configuration file for Workbench + workbench_config_file = workbench_dir / "default.apps.son" + if workbench_config_file.exists(): + # If the default app config file already exists, don't overwrite it. + return + + # If the file doesn't exist, create it and add a configuration for HERON + print("Adding HERON configuration to NEAMS Workbench", workbench_config_file, + "with HERON executable", heron_path) + with open(workbench_config_file, "w") as f: + f.write("applications {\n" + " HERON {\n" + " configurations {\n" + " default {\n" + " options {\n" + " shared {\n" + f" Executable=\"{heron_path}\"\n" + " }\n" + " }\n" + " }\n" + " }\n" + " }\n" + "}\n") + + +def convert_xml_to_heron(xml_file: pathlib.Path, workbench_path: pathlib.Path) -> pathlib.Path: + """ + Converts an .xml file to a .heron file using Workbench's xml2eddi.py conversion script. + + @ In, xml_file, pathlib.Path, the path to the .xml file to convert + @ In, workbench_path, pathlib.Path, the path to the Workbench installation directory + @ Out, heron_file, pathlib.Path, the path to the converted .heron file + """ + # Find the xml2eddi.py script in the Workbench installation directory + xml2eddi_script = workbench_path / "rte" / "util" / "xml2eddi.py" + if not xml2eddi_script.exists(): + print(f"ERROR: Could not find the xml2eddi.py script in the Workbench installation directory ({str(workbench_path)}). " + f"Checked {str(xml2eddi_script)}.") + return None + + # Convert the .xml file to a .heron file by running the xml2eddi.py script with the .xml file as + # an argument and redirecting the output to a .heron file with the same name. + heron_file = xml_file.with_suffix(".heron") + print(f"Converting {xml_file} to {heron_file} using {xml2eddi_script}...") + # Use Workbench's entry.bat script (or entry.sh) to access the app's internal Python environment + # to run the script + entry_script = workbench_path / "rte" / "entry.bat" + if platform.system() != "Windows": + entry_script = entry_script.with_suffix(".sh") + if not entry_script.exists(): + print("ERROR: Could not find the entry script in the Workbench installation directory.") + return None + + with open(heron_file, "w") as f: + subprocess.run([str(entry_script), str(xml2eddi_script), str(xml_file)], stdout=f) + + return heron_file + + +def run_in_workbench(file: str | None = None): + """ + Opens the given file in the NEAMS Workbench + @ In, file, str, optional, the file to open in NEAMS Workbench + """ + # Find the Workbench executable + workbench_path = find_workbench() + if workbench_path is None: + print("ERROR: Could not find the NEAMS Workbench executable. Please set the " + "WORKBENCH_PATH environment variable, add it to the system path, or specify it manually " + "with the WORKBENCHDIR key in the \".workbench\" file in the main FORCE directory.") + return + + # Create Workbench default configuration for HERON if a default configurations file does not exist + workbench_install_dir = get_workbench_dir_from_exe_path(workbench_path) + create_workbench_heron_default(workbench_install_dir) + + # Convert the .xml file to a .heron file if one was provided + if file is not None: + file = pathlib.Path(file) + if not file.exists(): + print(f"ERROR: The file {file} does not exist.") + return + + if file.suffix == ".xml": + heron_file = convert_xml_to_heron(file, workbench_install_dir) + if heron_file is None: + return + file = heron_file + file = str(file) + + # Open the file in Workbench + # Currently, we're only able to open the MacOS version of Workbench by opening the app itself. + # This does not accept a file as an argument, so users will need to open Workbench, then open + # the desired file manually from within the app. + # if file is not None and platform.system() == "Windows": + # command += ' ' + file + + print("Opening Workbench...", file=sys.__stdout__) + print("***If this is the first time you are running Workbench, this may take a few minutes!***\n", + file=sys.__stdout__) + if platform.system() == "Darwin": # macOS + subprocess.run(["/usr/bin/open", "-n", "-a", workbench_path]) + else: # Windows, Linux + # NOTE: untested on Linux as of 2024-10-11 + if file is None: + command = str(workbench_path) + else: + command = [str(workbench_path), file] + print("using command", command) + subprocess.run(command) diff --git a/package/ui/views/__init__.py b/package/ui/views/__init__.py new file mode 100644 index 00000000..95d22041 --- /dev/null +++ b/package/ui/views/__init__.py @@ -0,0 +1 @@ +from .main import View diff --git a/package/ui/views/file_selection.py b/package/ui/views/file_selection.py new file mode 100644 index 00000000..324ef019 --- /dev/null +++ b/package/ui/views/file_selection.py @@ -0,0 +1,60 @@ +from typing import Optional, Callable +import tkinter as tk + + +class FileSelection(tk.Frame): + """ A widget for selecting files and displaying the path after selection.""" + def __init__(self, master: tk.Widget, **kwargs): + """ + Constructor + @In, master, tk.Widget, the parent widget + @In, kwargs, dict, keyword arguments + @Out, None + """ + super().__init__(master, **kwargs) + self.file_selectors = {} + self.open_in_workbench_button = None + + def new_file_selector(self, label: str): + """ + Add a file selector to the widget + @In, label, str, the title of the file selector + @Out, None + """ + frame = SelectAFile(self, label) + frame.grid(row=len(self.file_selectors), column=0, sticky='w') + self.file_selectors[label] = frame + + def add_open_in_workbench_button(self, command: Callable): + """ + Create a button to open the file in Workbench. This button is only created once the first + file selector is added to the widget. Not every application will need this button, so its + creation is deferred until it is needed and is called by the controller. + """ + self.open_in_workbench_button = tk.Button(self, text='Open in Workbench') + self.open_in_workbench_button.grid(row=0, column=1, sticky='se') + self.grid_columnconfigure(1, weight=1) + self.open_in_workbench_button.config(command=command) + + +class SelectAFile(tk.Frame): + """ A widget for selecting one file and displaying the path after selection. """ + def __init__(self, + master: tk.Widget, + label: Optional[str] = None): + """ + Constructor + @In, master, tk.Widget, the parent widget + @In, label, Optional[str], the title of the file selector + @Out, None + """ + super().__init__(master) + self.file_title = tk.Label(self, text=label) + self.file_title.grid(row=0, column=0, columnspan=2, sticky='w') + self.browse_button = tk.Button(self, text='Browse', width=10, padx=5) + self.browse_button.grid(row=1, column=0, sticky='w') + self.filename = tk.StringVar() + self.filename.set("Select a file") # Default filename is "Select a file", i.e. no file selected + self.filename_label = tk.Label(self, textvariable=self.filename, bg="white", anchor='w', padx=10, pady=3) + self.filename_label.grid(row=1, column=1, sticky='w', padx=5) + self.grid_columnconfigure(1, weight=1) diff --git a/package/ui/views/main.py b/package/ui/views/main.py new file mode 100644 index 00000000..07baec60 --- /dev/null +++ b/package/ui/views/main.py @@ -0,0 +1,59 @@ +from .root import Root +from .file_selection import FileSelection +from .text_output import TextOutput +from .run_abort import RunAbort + +from tkinter.messagebox import askokcancel + + +class View: + """ Main view class. """ + def __init__(self): + """ + Main view constructor. Sets up main window by adding essential frames. + @In, None + @Out, None + """ + self.root = Root() + self.frames = {} + + # add frames to the view in a grid layout + self.add_frame('file_selection', FileSelection(self.root), + row=0, column=0, sticky='nsew', padx=10, pady=5) + self.add_frame('text_output', TextOutput(self.root), + row=1, column=0, sticky='nsew', padx=10, pady=10) + self.add_frame('run_abort', RunAbort(self.root), + row=2, column=0, sticky='se', padx=10, pady=5) + + # Let the text output frame expand to fill the available space + self.root.grid_rowconfigure(1, weight=1) + self.root.grid_columnconfigure(0, weight=1) + + def add_frame(self, name, frame, **kwargs): + """ + Add a frame to the view + @In, name, str, the name of the frame + @In, frame, tk.Frame, the frame to add + @In, kwargs, dict, keyword arguments for grid + @Out, None + """ + self.frames[name] = frame + frame.grid(**kwargs) + + def mainloop(self): + """ + Run the application main loop + @In, None + @Out, None + """ + self.root.mainloop() + + def quit(self, showdialog: bool = True): + """ + Quit the application + @In, showdialog, bool, optional, whether to show a dialog before quitting, default is True + @Out, None + """ + if not showdialog or askokcancel('Abort run', 'Are you sure you want to abort? ' + 'This will close the window and any text output will be lost.'): + self.root.quit() diff --git a/package/ui/views/model_status.py b/package/ui/views/model_status.py new file mode 100644 index 00000000..f6d60105 --- /dev/null +++ b/package/ui/views/model_status.py @@ -0,0 +1,34 @@ +import tkinter as tk +from ..models.main import ModelStatus as ModelStatusEnum + + +class ModelStatus(tk.Frame): + """ A widget for displaying the status of the model. """ + def __init__(self, master: tk.Widget, **kwargs): + """ + Constructor + @In, master, tk.Widget, the parent widget + @In, kwargs, dict, keyword arguments + @Out, None + """ + super().__init__(master, **kwargs) + self.status = tk.StringVar() + self.status.set("Model not yet run") + self.status_label = tk.Label(self, textvariable=self.status, bg="white", anchor='w', padx=10, pady=3) + self.status_label.pack(side='left') + self.grid_columnconfigure(0, weight=1) + + def set_status(self, new_status: ModelStatusEnum): + """ + Set the status label + @In, new_status, ModelStatusEnum, the new status + @Out, None + """ + self.status.set(new_status.value) + # Change the color of the label based on the status + if new_status == ModelStatusEnum.FINISHED: + self.status_label.config(fg='green') + elif new_status == ModelStatusEnum.ERROR: + self.status_label.config(fg='red') + else: + self.status_label.config(fg='black') diff --git a/package/ui/views/root.py b/package/ui/views/root.py new file mode 100644 index 00000000..537e1201 --- /dev/null +++ b/package/ui/views/root.py @@ -0,0 +1,15 @@ +import tkinter as tk + + +class Root(tk.Tk): + """ The main window. """ + def __init__(self, **kwargs): + """ + Constructor + @In, kwargs, dict, keyword arguments for tkinter.Tk + @Out, None + """ + super().__init__(**kwargs) + self.title('FORCE') + self.geometry('800x600') + self.grid() diff --git a/package/ui/views/run_abort.py b/package/ui/views/run_abort.py new file mode 100644 index 00000000..08255748 --- /dev/null +++ b/package/ui/views/run_abort.py @@ -0,0 +1,22 @@ +import tkinter as tk + + +class RunAbort(tk.Frame): + """ Buttons for starting and stopping the application. """ + def __init__(self, master, **kwargs): + """ + Constructor + @In, master, tk.Widget, the parent widget + @In, kwargs, dict, keyword arguments + @Out, None + """ + super().__init__(master, **kwargs) + button_width = 10 + self.abort_button = tk.Button(self, text='Abort', width=button_width) + self.abort_button.grid(row=0, column=0, sticky='w', padx=5) + + self.run_button = tk.Button(self, text='Run', width=button_width) + self.run_button.grid(row=0, column=1, sticky='w', padx=5) + + self.grid_columnconfigure(0, minsize=50) + self.grid_columnconfigure(1, weight=1, minsize=50) diff --git a/package/ui/views/text_output.py b/package/ui/views/text_output.py new file mode 100644 index 00000000..7339a7a1 --- /dev/null +++ b/package/ui/views/text_output.py @@ -0,0 +1,51 @@ +import tkinter as tk +from tkinter.scrolledtext import ScrolledText +from .model_status import ModelStatus + + +class TextOutput(tk.Frame): + """ A widget for displaying text output. """ + def __init__(self, master, **kwargs): + """ + Constructor + @In, master, tk.Widget, the parent widget + @In, kwargs, dict, keyword arguments + @Out, None + """ + super().__init__(master, **kwargs) + self.show_hide_button = tk.Button(self, text='Hide Ouptut', pady=5, width=15) + self.show_hide_button.grid(row=0, column=0, sticky='w') + self.model_status = ModelStatus(self) + self.model_status.grid(row=0, column=1, sticky='e') + self.text = ScrolledText(self, state=tk.DISABLED) + self.is_showing = True # To use with show/hide button + self.text.grid(row=1, column=0, sticky='nsew', columnspan=2) + self.grid_rowconfigure(0, minsize=50) + self.grid_rowconfigure(1, weight=1) + self.grid_columnconfigure(0, weight=1) + + def show_text_output(self): + """ + Show the text output widget + @In, None + @Out, None + """ + self.text.grid(row=1, column=0, sticky='nsew', columnspan=2) + self.show_hide_button.config(text='Hide Output') + self.is_showing = True + # Set window to default size + self.master.update() + self.master.geometry("800x600") + + def hide_text_output(self): + """ + Hide the text output widget + @In, None + @Out, None + """ + self.text.grid_forget() + self.show_hide_button.config(text='Show Output') + self.is_showing = False + # Reduce window size + self.master.update() + self.master.geometry("350x175") diff --git a/package/utils.py b/package/utils.py new file mode 100644 index 00000000..bbeee178 --- /dev/null +++ b/package/utils.py @@ -0,0 +1,19 @@ +import sys +import os + + +def add_local_bin_to_path(): + """ + Adds the local/bin directory to the system path in order to find ipopt and other executables + """ + script_path = os.path.dirname(sys.argv[0]) + local_path = os.path.join(script_path,"local","bin") + # Add script path (to get raven_framework in the path, and local/bin + # to get things like ipopt in the path. + os.environ['PATH'] += (os.pathsep+local_path+os.pathsep+script_path) + # Recursively add all additional "bin" directories in "local/bin" to the system path + if os.path.exists(local_path): + os.environ['PATH'] += (os.pathsep+local_path) + for root, dirs, files in os.walk(local_path): + if 'bin' in dirs: + os.environ['PATH'] += (os.pathsep+os.path.join(root,'bin'))