diff --git a/.github/workflows/Eval.yml b/.github/workflows/Eval.yml index dd0fecb..7cdaf1d 100644 --- a/.github/workflows/Eval.yml +++ b/.github/workflows/Eval.yml @@ -18,6 +18,10 @@ jobs: julia-arch: [x64] steps: + # Required by PkgEval.jl as xvfb runs into issues with ubuntu 24. See ci.yml workflow of PkgEval.jl repo + - name: "Allow unprivileged user namespaces" + run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Check out repository uses: actions/checkout@v4 diff --git a/.github/workflows/Example.yml b/.github/workflows/Example.yml index b03eacf..fc19e79 100644 --- a/.github/workflows/Example.yml +++ b/.github/workflows/Example.yml @@ -142,11 +142,17 @@ jobs: needs: [jupyter, pluto] if: github.event_name != 'pull_request' && github.ref_name == 'main' runs-on: ubuntu-latest + env: + HAS_TRIGGER_TOKEN: ${{ secrets.FMI_DOC_TRIGGER_PAT != '' }} steps: # Trigger an repoisitory dispath event - name: Repository Dispatch + if: ${{ env.HAS_TRIGGER_TOKEN == 'true' }} uses: peter-evans/repository-dispatch@v3 with: token: ${{ secrets.FMI_DOC_TRIGGER_PAT }} repository: 'ThummeTo/FMI.jl' event-type: trigger-docu + - name: no-token-warning + if: ${{ env.HAS_TRIGGER_TOKEN != 'true' }} + run: echo "::warning title=no_token_for_FMI-Repo::Please trigger FMI-Docs manually!!! automatic building of documentation requires an accesstoken for FMI.jl github repository; it has to be added as secrets.FMI_DOC_TRIGGER_PAT to the FMIExport.jl repo " diff --git a/.github/workflows/TestLTS.yml b/.github/workflows/TestLTS.yml index dd1a30a..7d8c2a4 100644 --- a/.github/workflows/TestLTS.yml +++ b/.github/workflows/TestLTS.yml @@ -35,6 +35,10 @@ jobs: version: ${{ matrix.julia-version }} arch: ${{ matrix.julia-arch }} + # Set up julia-cache + - name: Set up julia-cache + uses: julia-actions/cache@v2 + # Set up cache - name: "Set up cache" uses: actions/cache@v4 diff --git a/.github/workflows/TestLatest.yml b/.github/workflows/TestLatest.yml index e64f126..bb5101c 100644 --- a/.github/workflows/TestLatest.yml +++ b/.github/workflows/TestLatest.yml @@ -16,11 +16,11 @@ jobs: test: runs-on: ${{ matrix.os }} strategy: - fail-fast: true + fail-fast: false # keep other os running if one fails matrix: julia-version: ['1'] julia-arch: [x64] - os: [windows-latest] # ubuntu-latest, + os: [windows-latest, ubuntu-latest] experimental: [false] steps: @@ -35,6 +35,10 @@ jobs: version: ${{ matrix.julia-version }} arch: ${{ matrix.julia-arch }} + # Set up julia-cache + - name: Set up julia-cache + uses: julia-actions/cache@v2 + # Set up cache - name: "Set up cache" uses: actions/cache@v4 @@ -66,4 +70,4 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: - file: lcov.info + files: lcov.info diff --git a/test/Project.toml b/test/Project.toml index 56391c0..caa758a 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,8 +1,12 @@ [deps] Conda = "8f4d0f93-b110-5947-807f-2305c1781a2d" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +DifferentialEquations = "0c46a032-eb83-5123-abaf-570d42b7fbaa" +FMI = "14a09403-18e3-468f-ad8a-74f8dda2d9ac" FMIBuild = "226f0e26-6dd6-4589-ada7-1d32f6e1d800" FMIImport = "9fcbc62e-52a0-44e9-a616-1359a0008194" FMIZoo = "724179cf-c260-40a9-bd27-cccc6fe2f195" +Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/test/bouncing_ball.jl b/test/bouncing_ball.jl deleted file mode 100644 index af2c475..0000000 --- a/test/bouncing_ball.jl +++ /dev/null @@ -1,55 +0,0 @@ -# -# Copyright (c) 2021 Tobias Thummerer, Lars Mikelsons -# Licensed under the MIT license. See LICENSE file in the project root for details. -# - -# export FMU script -include(joinpath(@__DIR__, "..", "examples", "FMI2", "BouncingBall", "src", "BouncingBall.jl")) - -# demo! -#using FMIZoo, Test, Plots -#fmu_save_path = FMIZoo.get_model_filename("BouncingBall1D", "Dymola", "2022x") -#fmu_save_path = "C:/Users/thummeto/Documents/BouncingBall.fmu" # "C:/Users/thummeto/Documents/FMIZoo.jl/models/bin/Dymola/2023x/2.0/BouncingBallGravitySwitch1D.fmu" - -# check if FMU exists now -@test isfile(fmu_save_path) -fsize = filesize(fmu_save_path)/1024/1024 -@test fsize > 300 - -# Simulate FMU in Python / FMPy -# @info "Installing `fmpy`..." -# using Conda -# Conda.add("fmpy"; channel="conda-forge") - -# @info "Simulating with `fmpy`..." -# using PyCall -# @pyimport fmpy -# fmpy.dump(fmu_save_path) - -# t_start = 0.0 -# t_stop = 5.0 - -# solution_FMPy = fmpy.simulate_fmu(filename=fmu_save_path, -# validate=false, -# start_time=t_start, -# stop_time=t_stop, record_events=true, solver="CVode") # fmi_call_logger=lambda s: print('[FMI] ' + s) , - -# ts = collect(solution_FMPy[i][1] for i in 1:length(solution_FMPy)) -# ss = collect(solution_FMPy[i][2] for i in 1:length(solution_FMPy)) -# vs = collect(solution_FMPy[i][3] for i in 1:length(solution_FMPy)) - -# @test length(solution_FMPy) == 1001 - -# @test isapprox(ts[1], t_start; atol=1e-6) -# @test isapprox(ss[1], 1.0; atol=1e-6) -# @test isapprox(vs[1], 0.0; atol=1e-6) - -# @test isapprox(ts[end], t_stop; atol=1e-6) -# @test isapprox(ss[end], 0.23272552; atol=1e-6) -# @test isapprox(vs[end], -0.17606235; atol=1e-6) - -# plot(ts, ss) -# plot(ts, vs) - -# ToDo: enable the following line -rm(fmu_save_path) \ No newline at end of file diff --git a/test/bouncing_ball/bouncing_ball.jl b/test/bouncing_ball/bouncing_ball.jl new file mode 100644 index 0000000..8fa6467 --- /dev/null +++ b/test/bouncing_ball/bouncing_ball.jl @@ -0,0 +1,208 @@ +# +# Copyright (c) 2021 Tobias Thummerer, Lars Mikelsons +# Licensed under the MIT license. See LICENSE file in the project root for details. +# + +# export FMU script, currently only available on Windows +if Sys.iswindows() + include( + joinpath( + @__DIR__, + "..", + "..", + "examples", + "FMI2", + "BouncingBall", + "src", + "BouncingBall.jl", + ), + ) + # check if FMU exists now + @test isfile(fmu_save_path) + fsize = filesize(fmu_save_path) / 1024 / 1024 + @test fsize > 300 + + # TODO: as Exported FMUs are currently not able to be simulated with FMPy, use BouncingBall from FMIZoo instead to test Pipeline + println( + "::warning title=Test-Warning::using FMIZoo BouncingBallFMU instead of exported FMU. \r\n", + ) + using FMIZoo + fmu_save_path = FMIZoo.get_model_filename("BouncingBall1D", "Dymola", "2023x") +else + # if not on windows, use BouncingBall from FMIZoo + using FMIZoo + fmu_save_path = FMIZoo.get_model_filename("BouncingBall1D", "Dymola", "2023x") + + # check if FMU exists + @test isfile(fmu_save_path) + fsize = filesize(fmu_save_path) / 1024 # / 1024 # check for 300KB instead of 300 MB as FMIZoo FMU is smaller + @test fsize > 300 +end + +# mutex implementation: indicates running state of fmpy script. File must only be created and cleared afterwards by fmpy script +lockfile = joinpath(pwd(), "bouncing_ball", "lockfile.txt") +# fmpy script puts its logs here +logfile = joinpath(pwd(), "bouncing_ball", "FMPy-log.txt") +# output for scheduled command starting the fmpy script. meight be useful for debugging if logfile does not contain any helpful information on error +outlog = joinpath(pwd(), "bouncing_ball", "outlog.txt") +# fmu-experiment setup +t_start = "0.0" +t_stop = "5.0" +# flag (in logfile), that gets replaced by "@test " by this jl script and evaluated after fmpys completion +juliatestflag = "JULIA_@test:" + +# as commandline interface for task sheduling in windows does only allow 261 characters for \TR option, we need an external config file +config_file = joinpath(pwd(), "bouncing_ball", "fmpy-bouncing_ball.config") +open(config_file, "w+") do io + #line 1: lockfile + write(io, lockfile) + write(io, "\n") + #line 2: logfile + write(io, logfile) + write(io, "\n") + #line 3: fmu_save_path + write(io, fmu_save_path) + write(io, "\n") + #line 4: juliatestflag + write(io, juliatestflag) + write(io, "\n") + #line 5: t_start + write(io, t_start) + write(io, "\n") + #line 5: t_stop + write(io, t_stop) + write(io, "\n") +end +script_file = joinpath(pwd(), "bouncing_ball", "fmpy-bouncing_ball.py") + +# should not exist but cleanup anyway +if isfile(lockfile) + rm(lockfile) +end +if isfile(logfile) + rm(logfile) +end + +# install fmpy +println(readchomp(`python -m pip install FMPy`)) + +using Dates +# task can only be sheduled at full minutes, schedule with at least one full minute until start to avoid cornercases. 120s achives this optimally (seconds get truncated in minute-based-scheduling) +tasktime = now() + Second(120) +# cleanup github-actions logs +flush(stdout) +flush(stderr) + +# the fmpy task that we want to schedule (its stdout and stderr get redirected for debugging, remains empty/non existent if no error occurs) +task_string = "python $script_file $config_file > $outlog 2>&1" + +if Sys.iswindows() + # in windows only 261 chars are allowed as command with args + @test length(task_string) < 261 + time = Dates.format(tasktime, "HH:MM") + println( + readchomp( + `SCHTASKS /CREATE /SC ONCE /TN "ExternalFMIExportTesting\\BouncingBall-FMPy" /TR "$task_string" /ST $time`, + ), + ) +elseif Sys.islinux() + time = Dates.format(tasktime, "M") + open("crontab_fmiexport_fmpy_bouncingball", "w+") do io + # hourly as there were issues when scheduling at fixed hour (not starting, possibly due to timzone issues or am/pm; did not investigate further) + write(io, "$time * * * * $task_string") + write(io, "\n") + end + println(readchomp(`crontab crontab_fmiexport_fmpy_bouncingball`)) +end + +# print schedule status for debugging +if Sys.iswindows() + println( + readchomp( + `SCHTASKS /query /tn "ExternalFMIExportTesting\\BouncingBall-FMPy" /v /fo list`, + ), + ) +elseif Sys.islinux() + println(readchomp(`crontab -l`)) +end + +# wait until task has started for shure +sleep(150) + +# cleanup +rm(config_file) + +# we will wait a maximum time for fmpy. usually it should be done within seconds... (keep in mind maximum runtime on github runner) +time_wait_max = datetime2unix(now()) + 900.0 + +# fmpy still running or generated output in its logfile +if isfile(lockfile) || isfile(logfile) + if isfile(lockfile) + println( + "FMPy-Task still running, will wait for termination or a maximum time of " * + string(round((time_wait_max - datetime2unix(now())) / 60.0, digits = 2)) * + " minutes from now.", + ) + end + while isfile(lockfile) && datetime2unix(now()) < time_wait_max + sleep(10) + end + + # print schedule status for debugging + if Sys.iswindows() + println( + readchomp( + `SCHTASKS /query /tn "ExternalFMIExportTesting\\BouncingBall-FMPy" /v /fo list`, + ), + ) + elseif Sys.islinux() + println(readchomp(`crontab -l`)) + end + + println("wating for FMPy-Task ended; FMPy-Task done: " * string(!isfile(lockfile))) + + # sould not be existing/be empty; if there was no error, fmpy script redirected all its output to its own logfile (see FMPy_log below) + if isfile(outlog) + println("CMD output of FMPy-Task: ") + for line in readlines(outlog) + println(line) + end + println("------------------END_of_CMD_output--------------------") + end + + # FMPy_log + if !isfile(logfile) + println("No log of FMPy-Task found") + @test false # error: no log by fmpy created + else + println("Log of FMPy-Task: ") + for line in readlines(logfile) + println(line) + # if there is a testflag, evaluate the line + if contains(line, juliatestflag) + eval(Meta.parse("@test " * split(line, juliatestflag)[2])) + end + end + println("------------------END_of_FMPy_log--------------------") + + fmpy_log = String(read(logfile)) + # if no testflags occur in log, why are we running the script?! we need testflags in the log to evaluate the result... + @test occursin(juliatestflag, fmpy_log) + end +else + println( + "Error in FMPy-testsetup: Windows task scheduler or cron did not start FMPy successfully or FMPy terminated prematurely before generating lockfile or logfile", + ) + @test false +end + +# cleanup scheduling +if Sys.iswindows() + println( + readchomp(`SCHTASKS /DELETE /TN ExternalFMIExportTesting\\BouncingBall-FMPy /f`), + ) +elseif Sys.islinux() + println(readchomp(`crontab -r`)) +end + +rm(fmu_save_path) diff --git a/test/bouncing_ball/fmpy-bouncing_ball.py b/test/bouncing_ball/fmpy-bouncing_ball.py new file mode 100644 index 0000000..d8dfbbc --- /dev/null +++ b/test/bouncing_ball/fmpy-bouncing_ball.py @@ -0,0 +1,61 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +import sys +import os +import traceback + +with open(sys.argv[1]) as f: + lines = f.read().splitlines() + +lockfile = lines[0] +logfile = lines[1] +fmufile = lines[2] +juliatestflag = lines[3] +t_start = float(lines[4]) +t_stop = float(lines[5]) + +f = open(lockfile, 'w+') +f.write('FMPy_running') +f.close() + +with open(logfile, 'w+') as sys.stdout: + print('fmpy-bouncing_ball.py log:') + print('redirecting output...') + import fmpy + print('imported fmpy') + fmpy.dump(fmufile) + solution_FMPy = fmpy.simulate_fmu( + filename=fmufile, + validate=False, + start_time=t_start, + stop_time=t_stop, + record_events=True, + solver='CVode', + ) + try: + print(juliatestflag + 'isapprox(' + str(solution_FMPy[-1][0]) + ', ' + str(t_stop) + ' ; atol=1e-6) # @test isapprox(ts[end], t_stop; atol=1e-6)') + + # check for height at or just after 0.5s (simulated time), just after first bounce + s_05s = 0 + for elem in solution_FMPy: + if elem[0] >= 0.5: + s_05s = elem[1] + break + print(juliatestflag + 'isapprox(' + str(s_05s) + ', 0.3456658910552819; atol=1e-6) # @test isapprox(ss[<0.5s>], 0.135600687; atol=1e-6)') + + # check for height at or just after 1s (simulated time) + s_1s = 0 + for elem in solution_FMPy: + if elem[0] >= 1.0: + s_1s = elem[1] + break + print(juliatestflag + 'isapprox(' + str(s_1s) + ', 0.6587682981502954; atol=1e-6) # @test isapprox(ss[<1.0s>], 0.236643687; atol=1e-6)') + except Exception: + print(traceback.format_exc()) + print(juliatestflag + 'false # exception occured in python script') + print('fmpy-bouncing_ball.py done') + +f = open(lockfile, 'w') +f.write('FMPy_done') +f.close() +os.remove(lockfile) diff --git a/test/runtests.jl b/test/runtests.jl index 4cda8ca..965c151 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -7,29 +7,35 @@ using FMIExport using Test @testset "FMIExport.jl" begin - if Sys.iswindows() - @info "Automated testing is supported on Windows/Linux/Mac." - + if Sys.iswindows() || Sys.islinux() + @info "Automated testing is supported on Windows/Linux" + @testset "Model Description" begin include("model_description.jl") end @testset "Bouncing Ball" begin - include("bouncing_ball.jl") + include(joinpath("bouncing_ball", "bouncing_ball.jl")) end @testset "FMU Manipulation" begin - #@warn "The test `FMU Manipulation` is currently excluded because of insufficient resources in GitHub-Actions." - include("manipulation.jl") + if Sys.iswindows() + include("manipulation.jl") + else + @warn "The test `FMU Manipulation` is currently only availale for Windows" + end end @testset "NeuralFMU" begin - #@warn "The test `NeuralFMU` is currently excluded because of insufficient resources in GitHub-Actions." - include("neuralFMU.jl") + if Sys.iswindows() + include("neuralFMU.jl") + else + @warn "The test `NeuralFMU` is currently only availale for Windows" + end end - elseif Sys.islinux() || Sys.isapple() - @warn "Tests not supported on Linux and Mac." + elseif Sys.isapple() + @warn "Tests not supported on Mac." else @warn "Tests not supported on `unknown operation system`." end