diff --git a/Project.toml b/Project.toml index da06bd7..fb5d97f 100644 --- a/Project.toml +++ b/Project.toml @@ -29,6 +29,7 @@ SynchJulia = "a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6" SynchToolkit = "f500ffa8-9682-42ac-8d34-0ca7926c2e94" ThermalComponents = "084a29d9-4f48-4925-95b4-b7d4c79725f4" TranslationalComponents = "42acc370-819b-4877-bdd6-eb3294461f5d" +UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [compat] ControlSystemsBase = "1" diff --git a/dyad/partial_swingup_juliac.dyad b/dyad/partial_swingup_juliac.dyad new file mode 100644 index 0000000..5dcc5e5 --- /dev/null +++ b/dyad/partial_swingup_juliac.dyad @@ -0,0 +1,33 @@ +# The partial base lives in its own file, apart from the concrete analysis that extends it, +# for the reason spelled out in partial_swingup_experiment.dyad: a GUI edit to a concrete +# analysis rewrites its file and drops `partial` from any partial analysis sharing it +# (JuliaComputing/dyad-lang#1762). +""" +Deploy the swing-up controller as a statically compiled Julia binary. + +Same program and same designed gains as `FurutaSwingupExperiment`, compiled by a different +route: instead of writing C to be built with `make`, this writes a small Julia application +package -- the code-generated node as source, plus a timing loop around it -- and compiles it +with JuliaC's `--trim=safe` into a standalone executable with no Julia installation behind it. +See `QuanserComponents.export_program_juliac`. + +`build = false` stops after writing the application, which is how the generated sources are +inspected without a toolchain; `run = true` then drives the pendulum with the built binary. + +The application's libraries are named by absolute path, so it belongs to the directory it was +written into -- deploying elsewhere means running this analysis there. The parameters +`QubeHardwareRunBase` contributes for the C and deploy targets (`export_c`, `backend`, +`deploy_host`, `live_plot`, ...) have no effect here. +""" +partial analysis FurutaSwingupJuliaCBase + extends FurutaSwingupBase(Tf = 10.0, output_dir = "furuta_juliac") + "Name of the generated application package and of the compiled executable" + parameter app_name::String = "FurutaSwingupApp" + "juliaup channel used for the build; JuliaC's --trim needs Julia 1.13 or newer" + parameter julia_channel::String = "1.13" + "JuliaC trimming mode: safe, unsafe, unsafe-warn or no" + parameter trim::String = "safe" + "Compile the generated application into a standalone binary; false only writes it" + parameter build::Boolean = true +relations +end diff --git a/dyad/swingup_juliac.dyad b/dyad/swingup_juliac.dyad new file mode 100644 index 0000000..650dec6 --- /dev/null +++ b/dyad/swingup_juliac.dyad @@ -0,0 +1,5 @@ +analysis FurutaSwingupJuliaC + extends FurutaSwingupJuliaCBase() + model = FurutaHardware() +relations +end diff --git a/generated/FurutaSwingupJuliaC_definition.jl b/generated/FurutaSwingupJuliaC_definition.jl new file mode 100644 index 0000000..df302ac --- /dev/null +++ b/generated/FurutaSwingupJuliaC_definition.jl @@ -0,0 +1,96 @@ +### DO NOT EDIT THIS FILE +### This file is auto-generated by the Dyad command-line compiler. +### If you edit this code it is likely to get overwritten. +### Instead, update the Dyad source code and regenerate this file + + +using DyadInterface +using DyadInterface: ODEAlg, DEVerbosity, OptimizationLevel +using ModelingToolkit: SymbolicT, toggle_namespacing +using QuanserComponents: AbstractQubeHardwareRunBaseSpec, QubeHardwareRunBaseSpec +@kwdef mutable struct FurutaSwingupJuliaCSpec <: AbstractQubeHardwareRunBaseSpec + name::Symbol = :FurutaSwingupJuliaC + # Sample time [s] + var"Ts"::Float64 = 0.005 + # Run the program on the hardware; false only builds it + var"run"::Bool = false + # Duration [s] of the run; 0 means the program's own natural length + var"Tf"::Float64 = 10.0 + # Motor saturation [V] + var"umax"::Float64 = 10.0 + # Arm angle [deg] at start-up, added to every shoulder reading, so the arm need not be at + # its home position first + var"arm_deg"::Float64 = 0.0 + # Card-specific options applied after opening the board, e.g. + # \"deadband_compensation=0.65\". Empty leaves qube_hw.c on its own default, which is + # what every run should normally use: the command-to-torque path has to be the same one + # the identified parameters were fitted against + var"card_options"::String = "" + # Backend to build the program on when it runs in-process, \"julia\" or \"c\". Ignored when + # `export_c` is true, which always builds C + var"backend"::String = "julia" + # Export the program as standalone C into `output_dir`, and run that instead of in-process + var"export_c"::Bool = true + # Directory the generated C sources and the log are written to + var"output_dir"::String = "furuta_juliac" + # File the program writes its log to. Passed to the `DataLogger` inside the model *and* used + # to open the file, so the two cannot disagree. Empty keeps the program's own default name. + # Exported and deployed runs use the bare file name inside their own directory, since an + # absolute path from this machine means nothing on the target + var"log_file"::String = "" + # Host to build and run on, e.g. \"username@hostname\"; empty runs on this machine. Implies + # `export_c`, since C sources are what gets copied over + var"deploy_host"::String = "" + # Directory on `deploy_host` to copy the sources into + var"deploy_dir"::String = "furuta_c" + # Launch a live plotter alongside the run to watch it as it happens (needs `run = true`) + var"live_plot"::Bool = false + # Live-plot viewer executable + var"live_plot_cmd"::String = "kst2" + # Viewer session file; a relative path resolves against `output_dir` + var"live_plot_config"::String = "kst2config.kst" + # LQR state-penalty diagonal in the order [shoulder_angle, elbow_angle, shoulder_velocity, elbow_velocity] + var"Q1"::Array{Float64, 1} = [1000.0, 10.0, 1.0, 1.0] + # LQR control-penalty weight + var"Q2"::Float64 = 100.0 + # Name of the generated application package and of the compiled executable + var"app_name"::String = "FurutaSwingupApp" + # juliaup channel used for the build; JuliaC's --trim needs Julia 1.13 or newer + var"julia_channel"::String = "1.13" + # JuliaC trimming mode: safe, unsafe, unsafe-warn or no + var"trim"::String = "safe" + # Compile the generated application into a standalone binary; false only writes it + var"build"::Bool = true + # The swing-up controller closed around the physical QUBE, with the hardware I/O + # inside the synchronous program. + # + # `FurutaSwingup` is the same controller closed around the simulated + # `QubePendulum`; here the plant is replaced by `HardwareMeasurement` and + # `HardwareCommand`, which perform the encoder read and the amplifier write + # themselves. Compiling this model therefore yields a synchronous program that + # needs nothing from its caller but a clock tick -- see + # `generate_swingup_controller` and `SwingupController` in `src/codegen.jl`. + # + # The run log is written from inside the program too, by the `DataLogger`, in the + # `SWINGUP_LOG_COLUMNS` order: what was measured and applied, plus the loop + # diagnostics `HardwareDiagnostics` reports. So every target logs the same file + # through the same code — the C harness around the exported program is timing and + # nothing else, and a Julia driver adds no logging of its own either. The driver + # opens the file, since a filename cannot cross a synchronous node's interface; + # `log_file` is the name it must use, and `open_log!` is called with it + # automatically by the runners in src/program.jl. + var"model"::Union{Nothing, System} = QuanserComponents.FurutaHardware(; name=:FurutaHardware) +end + +function DyadInterface.run_analysis(spec::FurutaSwingupJuliaCSpec) + overrides = Dict{SymbolicT, SymbolicT}() + no_namespace_model = toggle_namespacing(spec.model, false) + + base_spec = QubeHardwareRunBaseSpec(; + name=:QubeHardwareRunBase, overrides, Ts=spec.Ts, run=spec.run, Tf=spec.Tf, umax=spec.umax, arm_deg=spec.arm_deg, card_options=spec.card_options, backend=spec.backend, export_c=spec.export_c, output_dir=spec.output_dir, log_file=spec.log_file, deploy_host=spec.deploy_host, deploy_dir=spec.deploy_dir, live_plot=spec.live_plot, live_plot_cmd=spec.live_plot_cmd, live_plot_config=spec.live_plot_config, Q1=spec.Q1, Q2=spec.Q2, app_name=spec.app_name, julia_channel=spec.julia_channel, trim=spec.trim, build=spec.build, model=spec.model + ) + run_analysis(base_spec) +end + +FurutaSwingupJuliaC(;kwargs...) = run_analysis(FurutaSwingupJuliaCSpec(;kwargs...)) +export FurutaSwingupJuliaC, FurutaSwingupJuliaCSpec diff --git a/generated/definitions.jl b/generated/definitions.jl index 4eb6936..a3ac48d 100644 --- a/generated/definitions.jl +++ b/generated/definitions.jl @@ -18,14 +18,10 @@ end import BlockComponents import DiscreteComponents -import DyadData import DyadInterface import ElectricalComponents -import HydraulicComponents import MultibodyComponents import RotationalComponents -import ThermalComponents -import TranslationalComponents @doc Markdown.doc""" This connector represents an electrical pin with voltage and current as the potential and flow variables, respectively. """ @@ -219,6 +215,7 @@ include("FurutaHardware_definition.jl") include("FurutaIdentificationExperiment_definition.jl") include("FurutaIdentification_definition.jl") include("FurutaSwingupExperiment_definition.jl") +include("FurutaSwingupJuliaC_definition.jl") include("FurutaSwingup_definition.jl") include("GoHome_definition.jl") include("HardwareCommand_definition.jl") diff --git a/src/QuanserComponents.jl b/src/QuanserComponents.jl index 0ddb43e..3adfc13 100644 --- a/src/QuanserComponents.jl +++ b/src/QuanserComponents.jl @@ -20,17 +20,20 @@ include("traj_source.jl") include("../generated/module.jl") # Building a synchronous program for the rig and running it, in three layers: what every -# program shares (program.jl), how one gets onto hardware (harness.jl), and the programs -# themselves (codegen.jl for the swing-up controller, friction.jl for the friction experiment, -# identification.jl for the open-loop replay). +# program shares (program.jl), how one gets onto hardware (harness.jl for the C target, +# juliac.jl for a statically compiled Julia binary), and the programs themselves (codegen.jl +# for the swing-up controller, friction.jl for the friction experiment, identification.jl for +# the open-loop replay). include("program.jl") include("harness.jl") +include("juliac.jl") include("codegen.jl") include("friction.jl") include("identification.jl") # The analyses on top of them. include("swingup_analysis.jl") +include("juliac_analysis.jl") include("friction_analysis.jl") include("identification_analysis.jl") diff --git a/src/analysis_base.jl b/src/analysis_base.jl index 575729f..dbd8969 100644 --- a/src/analysis_base.jl +++ b/src/analysis_base.jl @@ -80,6 +80,21 @@ end Q2::Float64 = 100.0 end +# The JuliaC target's own analysis. It extends the swing-up base in Dyad, so it inherits the +# LQR weights and builds the same program; what is its own is where the application goes and +# how it is compiled. The C/deploy parameters come along with the shared root and are unused +# here, which is the price of one root for the whole rig. +@qube_run_spec FurutaSwingupJuliaCBaseSpec :FurutaSwingupJuliaCBase begin + Tf::Float64 = 10.0 + output_dir::String = "furuta_juliac" + Q1::Vector{Float64} = [1000.0, 10.0, 1.0, 1.0] + Q2::Float64 = 100.0 + app_name::String = "FurutaSwingupApp" + julia_channel::String = "1.13" + trim::String = "safe" + build::Bool = true +end + @qube_run_spec FurutaFrictionBaseSpec :FurutaFrictionBase begin run::Bool = true output_dir::String = "friction_c" @@ -116,8 +131,8 @@ end end # Base specs an analysis' generated entry point may be asking for, most specific first. -const QUBE_RUN_SPECS = (FurutaSwingupBaseSpec, FurutaFrictionBaseSpec, - FurutaIdentificationBaseSpec) +const QUBE_RUN_SPECS = (FurutaSwingupBaseSpec, FurutaSwingupJuliaCBaseSpec, + FurutaFrictionBaseSpec, FurutaIdentificationBaseSpec) """ QubeHardwareRunBaseSpec(; parameters...) diff --git a/src/codegen.jl b/src/codegen.jl index f39a435..f4cbc0b 100644 --- a/src/codegen.jl +++ b/src/codegen.jl @@ -26,8 +26,8 @@ using ControlSystemsMTK: named_ss using ControlSystemsBase: c2d, ss, lqr using LinearAlgebra: Diagonal, I, pinv -export generate_swingup_controller, export_swingup_c, SwingupController, design_lqr, - swingup_log +export generate_swingup_controller, export_swingup_c, export_swingup_juliac, + SwingupController, design_lqr, swingup_log """ design_lqr(; Ts=0.005, Q1=[1000.0, 10.0, 1.0, 1.0], Q2=100.0) -> L::Vector{Float64} @@ -187,3 +187,30 @@ function export_swingup_c(dir; Ts = 0.005, log_file = SWINGUP_LOG_FILE, L = noth r = export_program_c(gen, dir; Tf, arm_deg, card_options, gains = gen.gains) return (; r..., gen.compiled) end + +""" + export_swingup_juliac(dir; Ts=0.005, log_file=SWINGUP_LOG_FILE, L=nothing, umax=nothing, + Tf=10.0, arm_deg=0.0, card_options=nothing, app_name="FurutaSwingupApp", + julia_channel="1.13", trim="safe", build=true, overrides...) + +Code-generate the swing-up controller as Julia source and export it as a standalone +application under `dir`, compiled into a trimmed binary unless `build = false` — see +[`export_program_juliac`](@ref), which does the work and documents what lands there. The +JuliaC counterpart of [`export_swingup_c`](@ref), built from the same model, the same designed +gains and the same log. + +Returns what `export_program_juliac` returns. +""" +function export_swingup_juliac(dir; Ts = 0.005, log_file = SWINGUP_LOG_FILE, L = nothing, + umax = nothing, Tf = 10.0, arm_deg = 0.0, + card_options = nothing, + app_name::AbstractString = "FurutaSwingupApp", + julia_channel::AbstractString = APP_COMPAT.julia, + trim::AbstractString = "safe", build::Bool = true, + param_overrides = nothing, overrides...) + gen = compile_program_source(FurutaHardware; name = :controller, Ts, + tunables = SWINGUP_TUNABLES, outputs = _swingup_outputs, + log = swingup_log(log_file), param_overrides, overrides...) + return export_program_juliac(gen, dir; app_name, Tf, arm_deg, card_options, + gains = (; L, umax), julia_channel, trim, build) +end diff --git a/src/juliac.jl b/src/juliac.jl new file mode 100644 index 0000000..0b873c3 --- /dev/null +++ b/src/juliac.jl @@ -0,0 +1,612 @@ +# Deploying a program as a statically compiled Julia binary. +# +# The third target, next to running in this process and exporting C: JuliaC compiles the +# program into a standalone executable with no Julia installation behind it. The layout +# mirrors harness.jl's C export closely, because the deployed thing is the same -- the node +# does its own I/O and its own logging, so what surrounds it is only timing: +# +# export_program_c -> top.c + csrc + run_hardware.c -> `make` -> ./run_hardware +# export_program_juliac -> an app package (node as source) -> JuliaC -> bin/ +# +# What makes trimming work is SynchJulia's static-compilation pattern +# (JuliaComputing/SynchJulia.jl#203): the emitted package defines the node and constructs its +# `SynchExecutable` at package top level, i.e. during *precompilation*, so both are serialized +# into its package image; `@main` then only ticks that executable. Two further conditions, +# both settled upstream, are what let `step!`/`reset!` themselves be statically resolved: +# SynchJulia >= 0.4.3 (JuliaComputing/SynchJulia.jl#226) and its `dynamic_execution = false` +# preference, which the emitted Project.toml sets. +# +# The emitted package depends on nothing from this one. It carries its own copies of the +# `csrc/` implementations the node calls into, builds them next to itself, and defines the +# operator wrappers over them -- see `emit_program_ffi` and `compile_program_source`. + +using UUIDs: UUID, uuid5 + +export export_program_juliac, build_program_juliac, run_program_juliac, juliac_available + +# Versions the emitted app is pinned against. SynchJulia 0.4 is the first release whose +# executables survive serialization into a package image, and 0.4.3 the first whose `step!` +# converts a heterogeneous input tuple without dynamic dispatch, which `--trim=safe` requires. +# JuliaC's `--trim` needs Julia 1.13. +const APP_COMPAT = (SynchJulia = "0.4.3", SynchCompiler = "0.4.3", julia = "1.13") + +# Namespace for the emitted package's deterministic UUID, so re-exporting the same app name +# keeps the same package identity, and with it its precompile cache. +const APP_UUID_NAMESPACE = UUID("6f4b3a3e-2f2d-4a1a-9b4c-4d5a5b6c7d8e") + +# `@v#.#` (the target Julia's own default environment, where JuliaC is installed) spelled as a +# constant because `#` starts a comment inside a command literal. +const DEFAULT_ENV = "@v#.#" + +# --------------------------------------------------------------------------- +## Exporting +# --------------------------------------------------------------------------- +""" + export_program_juliac(gen, dir; app_name="QubeProgramApp", Tf, arm_deg=0.0, + card_options=nothing, gains=(;), julia_channel="1.13", + trim="safe", build=true) -> (; app_dir, files, exe, buildlog, ...) + +Export a source-generated program (from [`compile_program_source`](@ref)) as a standalone +Julia application under `dir` and, unless `build = false`, compile it into a trimmed binary. + +| file | contents | +|:--|:--| +| `/Project.toml` | the app package, including SynchJulia's `dynamic_execution = false` | +| `/src/controller.jl` | the code-generated node and its parameter structs | +| `/src/hardware_ffi.jl` | the operator wrappers the node calls, over the copied `csrc/` | +| `/src/.jl` | the application: baked-in parameters, the timing loop, `@main` | +| `/csrc/`, `/deps/` | the C the node calls into, and the libraries built from it | +| `/README.md` | how to build and run it | + +`gains` overrides the runtime-settable parameters field by field, exactly as when running +in-process; the values are written into the app as literals. `Tf`, `arm_deg`, `card_options` +and the log's identity are baked in the same way the C harness bakes them. + +The built binary is tied to this directory: the operator wrappers `ccall` the libraries in +`/deps` by absolute path, as this package does for its own. Deploying to another host +therefore means exporting and building there, which is what the C target's `make` amounts to +as well. +""" +function export_program_juliac(gen, dir; app_name::AbstractString = "QubeProgramApp", + Tf, arm_deg = 0.0, card_options = nothing, gains = (;), + julia_channel::AbstractString = APP_COMPAT.julia, + trim::AbstractString = "safe", build::Bool = true) + Base.isidentifier(app_name) || + throw(ArgumentError("app_name `$app_name` is not a valid Julia identifier")) + dir = abspath(dir) + app_dir = joinpath(dir, app_name) + for sub in ("src", "csrc", "deps") + mkpath(joinpath(app_dir, sub)) + end + + # The C the node calls into travels with the app and is built next to it, so the binary + # does not reach back into this package's `deps`. + csrc = String[] + for f in ("qube_hw.c", "qube_hw.h", "qube_log.c", "qube_log.h") + cp(joinpath(dirname(QUBE_HW_SRC), f), joinpath(app_dir, "csrc", f); force = true) + push!(csrc, f) + end + traj = gen.traj + if traj !== nothing + for f in ("qube_traj.c", "qube_traj.h") + cp(joinpath(dirname(QUBE_HW_SRC), f), joinpath(app_dir, "csrc", f); force = true) + push!(csrc, f) + end + isfile(traj.file) || + error("export_program_juliac: the trajectory $(traj.file) does not exist") + cp(traj.file, joinpath(app_dir, basename(traj.file)); force = true) + traj = ProgramTrajectory(basename(traj.file), traj.column) + end + libs = build_app_libs(app_dir; traj = traj !== nothing) + + write(joinpath(app_dir, "Project.toml"), app_project(app_name)) + write(joinpath(app_dir, "src", "controller.jl"), controller_source(gen.decls)) + write(joinpath(app_dir, "src", "hardware_ffi.jl"), + emit_program_ffi(gen.operators, libs; traj = traj !== nothing)) + write(joinpath(app_dir, "src", "$app_name.jl"), + app_source(app_name; gen.Ts, Tf, arm_deg, card_options, gen.log, traj, + tuning = tuning_values(gen; gains))) + write(joinpath(app_dir, "README.md"), app_readme(app_name, gen.log)) + + exe = nothing + buildlog = nothing + if build + buildlog = joinpath(dir, "build.log") + exe = build_program_juliac(app_dir; app_name, julia_channel, trim, + bundle_dir = joinpath(dir, "bundle"), logpath = buildlog) + end + files = sort!([relpath(p, app_dir) + for p in _walk_files(app_dir) if !startswith(relpath(p, app_dir), "deps")]) + return (; dir, app_dir, files, libs, exe, buildlog, log = gen.log) +end + +_walk_files(root) = [joinpath(r, f) for (r, _, fs) in walkdir(root) for f in fs] + +""" + build_app_libs(app_dir; traj=false) -> NamedTuple + +Compile the app's own copies of `csrc/qube_hw.c`, `csrc/qube_log.c` (and `qube_traj.c`) into +`/deps`, and return the resulting library paths. Same flags as this package's own +build, so `hil` follows whether the Quanser SDK is installed: without it the binary still +builds and runs against a simulator through the callback backend, but cannot open the board. +""" +function build_app_libs(app_dir; traj::Bool = false) + cc = _c_compiler() + cc === nothing && error(""" + No C compiler found (tried cc, gcc, clang and SynchCompiler's Clang_unified_jll). + One is needed to build the app's copy of the hardware I/O it calls into.""") + src(f) = joinpath(app_dir, "csrc", f) + out(name) = joinpath(app_dir, "deps", name * "." * (Sys.iswindows() ? "dll" : + Sys.isapple() ? "dylib" : "so")) + base = ["-O2", "-Wall", "-fPIC", "-shared"] + sdk = quanser_sdk_flags() + hw = out("libqube_hw") + hwflags = sdk.found ? vcat(base, ["-DQUBE_HW_HAVE_HIL"], sdk.cflags) : base + hwlibs = sdk.found ? vcat(sdk.ldflags, QUANSER_LIBS) : String[] + run(`$cc $hwflags -o $hw $(src("qube_hw.c")) $hwlibs`) + log = out("libqube_log") + run(`$cc $base -o $log $(src("qube_log.c"))`) + tr = nothing + if traj + tr = out("libqube_traj") + run(`$cc $base -o $tr $(src("qube_traj.c"))`) + end + return (; hw, log, traj = tr, hil = sdk.found) +end + +# --------------------------------------------------------------------------- +## The emitted sources +# --------------------------------------------------------------------------- +function app_project(app_name) + uuid = uuid5(APP_UUID_NAMESPACE, app_name) + return """ + # Auto-generated by QuanserComponents.export_program_juliac — do not edit by hand. + name = "$app_name" + uuid = "$uuid" + version = "0.1.0" + + [deps] + FunctionWrappers = "069b7b12-0de2-55c6-9aab-29f3d0a68a2e" + StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" + SynchCompiler = "5d9dccf6-a926-4748-b7e2-6521ccc431d1" + SynchJulia = "a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6" + + [compat] + SynchCompiler = "$(APP_COMPAT.SynchCompiler)" + SynchJulia = "$(APP_COMPAT.SynchJulia)" + julia = "$(APP_COMPAT.julia)" + + # The executable is built during precompilation and no node definition can change + # afterwards, so `step!`/`reset!` need no world-age handling. Switching it off is what + # makes them statically resolvable: the pinned-world fallback, whose + # `Base.invoke_in_world` the trim verifier rejects, folds away. SynchJulia reads this at + # its own precompilation, hence a preference rather than a runtime switch. + [preferences.SynchJulia] + dynamic_execution = false + """ +end + +# Print the code-generated declarations as Julia source. They are emitted unexpanded and +# de-qualified by `compile_program_source` precisely so that they round-trip through `string`; +# the tests parse the result back to check that. +function controller_source(decls) + io = IOBuffer() + print(io, """ + # Auto-generated by QuanserComponents.export_program_juliac — do not edit by hand. + # + # The program as a SynchJulia synchronous node, code-generated from its Dyad model by + # SynchToolkit: + # + # (outputs...) = top(tick, gains::TuningGains, auto::AutoPars) + # + # The node does its own hardware I/O and its own logging, through the operators in + # hardware_ffi.jl; the outputs are for inspection. `TuningGains` holds the parameters that + # stay settable at runtime, `AutoPars` every other model parameter. + + """) + for ex in decls + println(io, string(_strip_linenums(ex))) + println(io) + end + return String(take!(io)) +end + +# Drop source positions so the emitted file neither carries SynchToolkit's own file paths nor +# changes when they move. `Base.remove_linenums!` leaves a macro call's mandatory position +# argument alone (it would print as a `#= … =#` comment), so nil those out too. +function _strip_linenums(ex) + ex isa Expr || return ex + ex = Base.remove_linenums!(copy(ex)) + args = Any[_strip_linenums(a) for a in ex.args] + ex.head === :macrocall && length(args) >= 2 && (args[2] = nothing) + return Expr(ex.head, args...) +end + +# The `csrc` entry points the node calls, by operator name: the Julia name the generated code +# uses, the C symbol behind it, which library it lives in, and its argument count. Mirrors +# src/hardware_io.jl, src/data_log.jl and src/traj_source.jl, which declare the same +# `ccall`s for the in-process backend. +const OPERATOR_FFI = Dict{Symbol, Tuple{Symbol, Symbol, Int}}( + :hw_measure => (:qube_hw_measure, :hw, 1), + :hw_shoulder => (:qube_hw_shoulder, :hw, 1), + :hw_elbow => (:qube_hw_elbow, :hw, 1), + :hw_write => (:qube_hw_write, :hw, 2), + :hw_time => (:qube_hw_time, :hw, 1), + :hw_dt => (:qube_hw_dt, :hw, 1), + :hw_exec => (:qube_hw_exec, :hw, 1), + :hw_count_shoulder => (:qube_hw_count_shoulder, :hw, 1), + :hw_count_elbow => (:qube_hw_count_elbow, :hw, 1), + :log_row => (:qube_log_row, :log, 8), + :traj_value => (:qube_traj_value, :traj, 1), +) + +""" + emit_program_ffi(operators, libs; traj=false) -> String + +The app's `src/hardware_ffi.jl`: one `ccall` wrapper per operator the node calls, plus the +driver-side calls its `@main` needs to open and close the device and the log. + +The generated node refers to these by bare name (see [`compile_program_source`](@ref)), so +defining them here is what keeps the emitted package independent of QuanserComponents while +still calling the very same C implementations. Library paths are absolute, as they are in +this package: a `ccall` library has to be a compile-time constant. +""" +function emit_program_ffi(operators, libs; traj::Bool = false) + io = IOBuffer() + print(io, """ + # Auto-generated by QuanserComponents.export_program_juliac — do not edit by hand. + # + # The hardware I/O and logging the generated node calls into: the same csrc/ code this + # rig's other two targets use, reached by `ccall` exactly as QuanserComponents does it. + # Paths are absolute because a `ccall` library must be a compile-time constant, which is + # also why the built binary belongs to the directory it was exported into. + + const QUBE_HW_LIB = $(repr(libs.hw)) + const QUBE_LOG_LIB = $(repr(libs.log)) + """) + traj && println(io, "const QUBE_TRAJ_LIB = ", repr(libs.traj)) + println(io) + println(io, "# ---- called by the node, once per tick ----") + for op in operators + haskey(OPERATOR_FFI, op) || + error("export_program_juliac: the generated node calls `$op`, which has no \ + known C entry point. Add it to `QuanserComponents.OPERATOR_FFI`.") + sym, lib, nargs = OPERATOR_FFI[op] + args = join(("a$i" for i in 1:nargs), ", ") + types = "(" * repeat("Cdouble, ", nargs) * ")" + libname = lib === :hw ? "QUBE_HW_LIB" : lib === :log ? "QUBE_LOG_LIB" : "QUBE_TRAJ_LIB" + println(io, "$op($args) = ccall((:$sym, $libname), Cdouble, $types, $args)") + end + print(io, """ + + # ---- called by the driver, around the loop ---- + qube_hw_open(mode::Cint, arm_home_rad::Float64) = + ccall((:qube_hw_open, QUBE_HW_LIB), Cint, (Cint, Cdouble), mode, arm_home_rad) + qube_hw_close() = ccall((:qube_hw_close, QUBE_HW_LIB), Cvoid, ()) + qube_hw_set_card_options(opts::String) = + ccall((:qube_hw_set_card_options, QUBE_HW_LIB), Cvoid, (Cstring,), opts) + qube_log_open(file::String, header::String, ncols::Cint) = + ccall((:qube_log_open, QUBE_LOG_LIB), Cint, (Cstring, Cstring, Cint), + file, header, ncols) + qube_log_close() = ccall((:qube_log_close, QUBE_LOG_LIB), Cvoid, ()) + qube_log_rows() = ccall((:qube_log_rows, QUBE_LOG_LIB), Clong, ()) + qube_log_error() = ccall((:qube_log_error, QUBE_LOG_LIB), Cint, ()) + """) + if traj + print(io, """ + qube_traj_open(file::String, column::Cint) = + ccall((:qube_traj_open, QUBE_TRAJ_LIB), Cint, (Cstring, Cint), file, column) + qube_traj_close() = ccall((:qube_traj_close, QUBE_TRAJ_LIB), Cvoid, ()) + qube_traj_length() = ccall((:qube_traj_length, QUBE_TRAJ_LIB), Clong, ()) + qube_traj_error() = ccall((:qube_traj_error, QUBE_TRAJ_LIB), Cint, ()) + """) + end + return String(take!(io)) +end + +# The application module: a Julia transcription of csrc/run_hardware.c, and for the same +# reason as that file it reads none of the node's outputs -- the program logs what it wants +# logged. Everything here must stay statically resolvable from `@main`: no logging macros, no +# `Base.stdout` (a non-constant global), and the C reached through the wrappers above. +function app_source(app_name; Ts, Tf, arm_deg, card_options, log::ProgramLog, traj, tuning) + tunelines = join((" $(_kwname(k)) = $(_literal(v))," for (k, v) in tuning), "\n") + # Empty means "do not call `qube_hw_set_card_options`", which leaves qube_hw.c on its own + # default — the same thing the C harness does when nothing is passed. + opts = card_options === nothing ? "" : String(card_options) + trajblock = traj === nothing ? "" : """ + + if qube_traj_open($(repr(traj.file)), Cint($(traj.column))) != 0 + print(Core.stderr, "could not read the trajectory\\n") + qube_log_close(); qube_hw_close() + return 1 + end + """ + trajclose = traj === nothing ? "" : """ + qube_traj_error() != 0 && + print(Core.stderr, "the run outlived the trajectory (0 V was commanded)\\n") + qube_traj_close() + """ + return """ + # Auto-generated by QuanserComponents.export_program_juliac — do not edit by hand. + module $app_name + + using SynchJulia + using SynchCompiler # build-time only: compiles the node during precompilation + + include("hardware_ffi.jl") + include("controller.jl") + + "Sample time [s]." + const TS = $(repr(float(Ts))) + "Run duration [s]." + const TF = $(repr(float(Tf))) + "Where the arm physically is at start-up [rad], added to every shoulder reading." + const ARM0 = $(repr(deg2rad(float(arm_deg)))) + "Card options pinning the command-to-torque path; empty leaves qube_hw.c's own default." + const CARD_OPTIONS = $(repr(opts)) + "The log the program writes its rows into, and what its columns are called." + const LOG_FILE = $(repr(log.file)) + const LOG_HEADER = $(repr(join(log.columns, "\\t"))) + const LOG_NCOLS = Cint($(length(log.columns))) + const HW_MODE_HIL = Cint(1) + + # The program's parameters, resolved at export time. + const GAINS = TuningGains(; +$tunelines + ) + # Every other model parameter: the generated constructor computes the ones whose defaults + # are relations of the tunables. + const AUTO = AutoPars(GAINS) + + # Constructed at precompilation time and serialized into the package image. This is the + # whole point of the build-time/run-time split, and it needs Julia >= 1.13. + const EXE = SynchExecutable(top, (Bool, TuningGains, AutoPars)) + + \"\"\" + run() -> Int + + Open the device and the log, then tick the program every `TS` seconds for `TF` seconds. + + Opening in HIL mode enables the amplifier, zeroes the motor and records the current + encoder counts as homing offsets. Let the pendulum hang straight down (0 = down, pi = up); + the arm may be anywhere, as long as `ARM0` says where. A swing-up program's `GoHome` then + drives the arm to centre. + + One `step!` per tick reads both encoders, computes, writes the motor voltage and appends a + row to the log — all inside the node. Nothing here reads its outputs. + \"\"\" + function run() + isempty(CARD_OPTIONS) || qube_hw_set_card_options(CARD_OPTIONS) + if qube_hw_open(HW_MODE_HIL, ARM0) != 0 + print(Core.stderr, "could not open the device\\n") + return 1 + end + if qube_log_open(LOG_FILE, LOG_HEADER, LOG_NCOLS) != 0 + print(Core.stderr, "could not open the log for writing\\n") + qube_hw_close() + return 1 + end$trajblock + reset!(EXE) + + # Timing mirrors the C loop and the in-process one: run the body, then sleep the + # remainder of TS (relative sleep, no absolute-schedule catch-up, so one slow step + # stretches that period instead of compressing the next ones). The dt/exec measured + # here are for the summary line only; the program logs its own, from inside the tick. + n = round(Int, TF / TS) + t0 = time() + prev = t0 + sum_dt = 0.0 + max_dt = 0.0 + max_exec = 0.0 + periods = 0 + try + for i in 1:n + start = time() + dt = start - prev + prev = start + step!(EXE, true, GAINS, AUTO) + finish = time() + exec = finish - start + if i > 1 + sum_dt += dt + dt > max_dt && (max_dt = dt) + periods += 1 + end + exec > max_exec && (max_exec = exec) + remain = TS - exec + remain > 0.0 && Libc.systemsleep(remain) + end + finally + # Zeroes the motor and releases the board, whatever happened. + qube_hw_close() + rows = qube_log_rows() + qube_log_close()$trajclose + print(Core.stderr, "$app_name: Ts=") + print(Core.stderr, TS) + print(Core.stderr, " s | mean dt=") + print(Core.stderr, periods > 0 ? sum_dt / periods : 0.0) + print(Core.stderr, " s, max dt=") + print(Core.stderr, max_dt) + print(Core.stderr, " s, max exec=") + print(Core.stderr, max_exec) + print(Core.stderr, " s over ") + print(Core.stderr, periods) + print(Core.stderr, " periods | ") + print(Core.stderr, rows) + print(Core.stderr, " rows in ") + print(Core.stderr, LOG_FILE) + print(Core.stderr, '\\n') + qube_log_error() != 0 && + print(Core.stderr, "the log was closed by a write error\\n") + end + return 0 + end + + function (@main)(ARGS::Vector{String}) + # The node was compiled at build time; never silently fall back to the in-session + # compiler, which a trimmed binary does not carry. + SynchJulia.compilation_enabled!(false) + return run() + end + + end # module $app_name + """ +end + +function app_readme(app_name, log::ProgramLog) + return """ + # $app_name + + Auto-generated by `QuanserComponents.export_program_juliac` — a QUBE-Servo program + (code-generated from its Dyad model by SynchToolkit) packaged as a standalone Julia + application. + + The node is compiled and its `SynchExecutable` constructed during *precompilation*, and so + serialized into the package image; `@main` only ticks it. That is what makes the runtime + path compatible with JuliaC's `--trim=safe`. + + ## Build + + Julia $(APP_COMPAT.julia) or newer and JuliaC 0.3.8 or newer are required, the latter + installed into that Julia's default environment: + + ```sh + julia +$(APP_COMPAT.julia) -m JuliaC \\ + --output-exe $app_name \\ + --bundle ../bundle \\ + --trim=safe \\ + . + ``` + + ## Run + + ```sh + ../bundle/bin/$app_name + ``` + + It drives the pendulum for the duration baked in at export time and writes + `$(basename(log.file))` (columns: $(join(log.columns, ", "))) into its working directory, + the same log the C target produces. + + Note that `src/hardware_ffi.jl` names the libraries in `deps/` by absolute path, so this + package and its binary belong to this directory. To deploy elsewhere, export and build + there. + """ +end + +# Emit a value as a Julia literal. `repr` round-trips Float64 exactly, so the deployed +# controller is bit-identical to the in-process one. +_literal(v::AbstractArray) = "[" * join((repr(x) for x in v), ", ") * "]" +_literal(v) = repr(v) + +# Parameter names are namespaced with `₊`, a valid identifier character; quote anything else +# so it stays a legal keyword argument. +_kwname(name::Symbol) = Base.isidentifier(name) ? string(name) : "var\"$name\"" + +# --------------------------------------------------------------------------- +## Building and running +# --------------------------------------------------------------------------- +""" + build_program_juliac(app_dir; app_name=basename(app_dir), julia_channel="1.13", + trim="safe", bundle_dir=..., logpath=nothing) -> exe_path + +Compile an emitted application package into a standalone binary: resolve its environment, +then run + + julia + -m JuliaC --output-exe --bundle --trim= + +Returns the bundled executable's path. The combined output goes to `logpath` (default +`/build.log`) and to stderr; a failing build throws. +""" +function build_program_juliac(app_dir; app_name::AbstractString = basename(abspath(app_dir)), + julia_channel::AbstractString = APP_COMPAT.julia, + trim::AbstractString = "safe", + bundle_dir = joinpath(dirname(abspath(app_dir)), "bundle"), + logpath = nothing) + app_dir = abspath(app_dir) + bundle_dir = abspath(bundle_dir) + logpath = something(logpath, joinpath(dirname(app_dir), "build.log")) + jl = _juliaup_julia(julia_channel) + open(logpath, "w") do log + _run_logged(log, _clean_env(`$jl --startup-file=no --project=$app_dir + -e "using Pkg; Pkg.instantiate()"`)) + # JuliaC resolves `--output-exe` against the working directory, so run it in a scratch + # one: linking fails outright if the caller's cwd holds a directory of that name (the + # app package itself, say), and nothing is left behind either way. + _run_logged(log, _clean_env(`$jl --startup-file=no --project=$DEFAULT_ENV -m JuliaC + --output-exe $app_name --bundle $bundle_dir + --trim=$trim $app_dir`); dir = mktempdir()) + end + exe = joinpath(bundle_dir, "bin", Sys.iswindows() ? "$app_name.exe" : app_name) + isfile(exe) || error("build_program_juliac: JuliaC produced no executable at $exe \ + (build log: $logpath)") + return exe +end + +""" + run_program_juliac(exe) -> log_path + +Run a built binary, which drives the pendulum for the duration baked into it and writes its +log into the executable's directory. Returns that log's path. +""" +function run_program_juliac(exe; log_name = SWINGUP_LOG_FILE) + exe = abspath(exe) + dir = dirname(exe) + # The child's working directory is where the program's log lands, so put it next to the + # binary rather than wherever this process happens to be — as `run_hardware_harness` does. + run(Cmd(`$exe`; dir)) + return joinpath(dir, basename(log_name)) +end + +""" + juliac_available(; julia_channel="1.13") -> Bool + +Whether [`build_program_juliac`](@ref) can run here: a `julia +` of at least +1.13 exists and JuliaC is loadable from that Julia's default environment. For gating tests and +scripts on the toolchain being installed. +""" +function juliac_available(; julia_channel::AbstractString = APP_COMPAT.julia) + julia = Sys.which("julia") + julia === nothing && return false + cmd = _clean_env(`$julia +$julia_channel --startup-file=no --project=$DEFAULT_ENV + -e $JULIAC_PROBE`) + return success(pipeline(cmd; stdout = devnull, stderr = devnull)) +end + +const JULIAC_PROBE = "VERSION >= v\"1.13-\" || exit(1); using JuliaC" + +# `julia +` is a juliaup shim and `Cmd` does not go through a shell, so resolve the +# channel up front and fail with an actionable message if it is unusable. +function _juliaup_julia(channel) + juliac_available(; julia_channel = channel) || + error("build_program_juliac: no usable `julia +$channel` with JuliaC. Install the \ + channel with `juliaup add $channel` (JuliaC's --trim needs Julia >= 1.13) and \ + JuliaC into its default environment with \ + `julia +$channel -e 'using Pkg; Pkg.add(\"JuliaC\")'`.") + return `$(Sys.which("julia")) +$channel` +end + +# `-m JuliaC` resolves JuliaC from the *active* project, and both `--project=@v#.#` and this +# environment scrub are needed: a caller inside another project — a Dyad analysis run from the +# Builder, or any session started with `--project` — exports `JULIA_PROJECT`, which the child +# would otherwise inherit and where JuliaC is not a dependency. +_clean_env(cmd::Cmd) = addenv(cmd, "JULIA_PROJECT" => nothing, "JULIA_LOAD_PATH" => nothing) + +# Run `cmd`, teeing its output into `log` (kept for the analysis' build-log artifact) and onto +# stderr so a long JuliaC build is not silent. The output is written in the `finally` block, so +# a failing build still leaves its diagnostics in the log. +function _run_logged(log, cmd; dir = nothing) + println(log, "\$ ", cmd) + flush(log) + dir === nothing || (cmd = Cmd(cmd; dir)) + out = IOBuffer() + try + run(pipeline(cmd; stdout = out, stderr = out)) + finally + s = String(take!(out)) + write(log, s) + write(stderr, s) + flush(log) + end + return nothing +end diff --git a/src/juliac_analysis.jl b/src/juliac_analysis.jl new file mode 100644 index 0000000..b11d7cf --- /dev/null +++ b/src/juliac_analysis.jl @@ -0,0 +1,133 @@ +# Implementation of the FurutaSwingupJuliaC analysis: the swing-up controller deployed as a +# statically compiled Julia binary. +# +# `FurutaSwingupJuliaC` (dyad/swingup_juliac.dyad) extends `FurutaSwingupJuliaCBase` +# (dyad/partial_swingup_juliac.dyad), so it shares the parameter set of every hardware run on +# this rig; the Dyad compiler resolves its spec to the root of that chain and the fan-out in +# analysis_base.jl brings it back here, to `FurutaSwingupJuliaCBaseSpec` and this +# `run_analysis`. What belongs to this file is the target: emitting the application, compiling +# it and running it — see juliac.jl, which does the work. +# +# The sibling of swingup_analysis.jl, which does the same for the in-process and C targets. + +export FurutaSwingupJuliaCSolution + +""" + FurutaSwingupJuliaCSolution + +Result of the `FurutaSwingupJuliaC` analysis: the designed LQR gain `L`, the emitted +application (`app_dir` and its `files`), the compiled `exe` and the JuliaC `buildlog` +(`nothing` when the analysis ran with `build = false`), and, when it ran the binary, the `log` +the program wrote. + +`L` is the gain the program was built with, whether designed here or taken from the model's +tuned default. +""" +struct FurutaSwingupJuliaCSolution{SP <: AbstractQubeHardwareRunBaseSpec} <: AbstractAnalysisSolution + spec::SP + app_dir::String + files::Vector{String} + L::Vector{Float64} + exe::Union{Nothing, String} + buildlog::Union{Nothing, String} + ran::Bool + log::Union{Nothing, String} +end + +function DyadInterface.run_analysis(spec::FurutaSwingupJuliaCBaseSpec) + mkpath(spec.output_dir) + # As in the C analysis: `design_lqr` costs a couple of minutes, so it stays switched off + # until asked for and the controller keeps the tuned gain baked into the model. + L = nothing # design_lqr(; Ts = spec.Ts, Q1 = spec.Q1, Q2 = spec.Q2) + # An exported application runs in its own directory, so the log is a bare file name there; + # an absolute path from this machine would be meaningless once the binary is moved. + log_file = basename(program_log_path(spec, SWINGUP_LOG_FILE)) + gen = compile_program_source(FurutaHardware; name = :controller, spec.Ts, + tunables = SWINGUP_TUNABLES, outputs = _swingup_outputs, + log = swingup_log(log_file), + param_overrides = spec.overrides) + Tf = spec.Tf > 0 ? spec.Tf : 10.0 + r = export_program_juliac(gen, spec.output_dir; spec.app_name, Tf, spec.arm_deg, + card_options = isempty(spec.card_options) ? nothing : + spec.card_options, + gains = (; L, umax = spec.umax), + spec.julia_channel, spec.trim, + build = spec.build || spec.run) + # `run = true` drives the physical pendulum with the compiled binary, which writes the same + # log any other target would; it implies a build, since there is otherwise nothing to run. + log = spec.run ? run_program_juliac(r.exe; log_name = log_file) : nothing + return FurutaSwingupJuliaCSolution(spec, r.app_dir, collect(r.files), + collect(float.(something(L, gen.tuning_defaults[:L]))), + r.exe, r.buildlog, spec.run, log) +end + +function DyadInterface.AnalysisSolutionMetadata(sol::FurutaSwingupJuliaCSolution) + arts = [ArtifactMetadata(:GeneratedFiles, ArtifactType.DataFrame, + "Generated application", + "The emitted Julia application package for the swing-up controller — the \ + code-generated node, the operator wrappers it calls into, the application module \ + with the timing loop, and (when built) the compiled standalone binary.")] + if sol.buildlog !== nothing + push!(arts, ArtifactMetadata(:BuildLog, ArtifactType.Native, + "JuliaC build log", + "Combined output of the environment instantiation and the JuliaC \ + --output-exe --trim build of the application package.")) + end + if sol.ran + push!(arts, ArtifactMetadata(:RunLog, ArtifactType.DataFrame, + "Hardware run log", + "Time series the program logged while controlling the hardware: time [s], \ + shoulder/elbow angles [rad], the commanded control voltage [V], the timing \ + diagnostics dt (achieved period [s]) and exec (read-to-write duration [s]), and \ + the raw encoder counts.")) + end + AnalysisSolutionMetadata(arts, Symbol[]) +end + +# `:GeneratedFiles` returns a column table (Tables.jl-compatible NamedTuple of vectors) of the +# emitted files — path relative to the application, size in bytes, and what each is for — with +# the compiled binary appended when there is one. `:BuildLog` returns JuliaC's output as a +# string, `:RunLog` the hardware trace, read with the same reader every other log goes through. +function DyadInterface.artifacts(sol::FurutaSwingupJuliaCSolution, name::Symbol) + if name === :GeneratedFiles + paths = copy(sol.files) + roles = map(sol.files) do f + b = basename(f) + b == "Project.toml" ? "application package" : + b == "controller.jl" ? "code-generated synchronous node" : + b == "hardware_ffi.jl" ? "hardware I/O and logging the node calls" : + b == "README.md" ? "build instructions" : + endswith(b, ".c") || endswith(b, ".h") ? "C the node calls into" : + "application (timing loop, @main)" + end + bytes = [filesize(joinpath(sol.app_dir, p)) for p in paths] + if sol.exe !== nothing && isfile(sol.exe) + push!(paths, relpath(sol.exe, sol.app_dir)) + push!(roles, "compiled standalone binary") + push!(bytes, filesize(sol.exe)) + end + return (; file = paths, bytes = bytes, role = roles) + elseif name === :BuildLog + (sol.buildlog !== nothing && isfile(sol.buildlog)) || + throw(ArgumentError("No build log available (run the analysis with `build = true`)")) + return read(sol.buildlog, String) + elseif name === :RunLog + (sol.ran && sol.log !== nothing && isfile(sol.log)) || + throw(ArgumentError("No run log available (run the analysis with `run = true`)")) + return read_log(sol.log) + else + throw(ArgumentError("Unknown artifact `$name`")) + end +end + +function Base.show(io::IO, ::MIME"text/plain", sol::FurutaSwingupJuliaCSolution) + print(io, "FurutaSwingupJuliaC solution for ") + printstyled(io, "$(nameof(sol.spec))\n", color = :green, bold = true) + println(io, "application: ", sol.app_dir) + println(io, "files: ", join(sol.files, ", ")) + println(io, "gain L: ", sol.L) + println(io, "executable: ", sol.exe === nothing ? "(not built)" : sol.exe) + if sol.ran + println(io, "run log: ", sol.log === nothing ? "(none)" : sol.log) + end +end diff --git a/src/program.jl b/src/program.jl index b8b9b16..f695dc9 100644 --- a/src/program.jl +++ b/src/program.jl @@ -149,10 +149,22 @@ Returns `(; compiled, tuning_struct, auto_struct, tuning_defaults, log, Ts)`, wh they double as the constructors for the structs they were compiled into. The node's argument order is `(tick::Bool, gains::TuningGains, auto::AutoPars)`. """ -function compile_program(ctor; name::Symbol, Ts, tunables::AbstractDict, - outputs, log::ProgramLog, - traj::Union{Nothing, ProgramTrajectory} = nothing, - param_overrides = nothing, overrides...) +function compile_program(ctor; kwargs...) + sig = program_signature(ctor; kwargs...) + @info "Running stkcompile" + compiled = SynchToolkit.stkcompile(sig.sys; sig.inputs, outputs = sig.outs) + return (; compiled, sig.tuning_struct, sig.auto_struct, sig.tuning_defaults, sig.log, + sig.traj, sig.Ts) +end + +# Build the model and the node signature that both compilation targets share: `stkcompile` +# for everything that runs here or exports C ([`compile_program`](@ref)), and the source +# emitter for a statically compiled binary ([`compile_program_source`](@ref)). Returns the +# system, the `inputs`/`outs` the node is compiled with, and everything the callers pass on. +function program_signature(ctor; name::Symbol, Ts, tunables::AbstractDict, + outputs, log::ProgramLog, + traj::Union{Nothing, ProgramTrajectory} = nothing, + param_overrides = nothing, overrides...) # Every C library the program calls into has to exist before the `:c` backend links them # (the Julia backend only needs them at call time). ensure_qube_hw() @@ -176,12 +188,83 @@ function compile_program(ctor; name::Symbol, Ts, tunables::AbstractDict, # declared and assigned Lustre names, and `clock` hits a missing branch in # SynchToolkit's `build_output`. So the outputs are indexed positionally. outs = [ClockedOutput(o) for o in outputs(nsys)] - @info "Running stkcompile" - compiled = SynchToolkit.stkcompile(sys; inputs, outputs = outs) - return (; compiled, tuning_struct, auto_struct, tuning_defaults, log, traj, + return (; sys, inputs, outs, tuning_struct, auto_struct, tuning_defaults, log, traj, Ts = Float64(Ts)) end +""" + compile_program_source(ctor; name, Ts, tunables, outputs, log, ...) -> (; decls, operators, ...) + +Code-generate a program as *Julia source* rather than evaluating it: `decls` are the top-level +expressions of the module SynchToolkit would have `eval`'d -- the `SynchJulia.@node`, the +`TuningGains`/`AutoPars` definitions with their constructors, and the `using` line they need -- +ready to be written into a package. Takes and returns what [`compile_program`](@ref) does, +minus `compiled` and the two `ParametersStruct`s, plus `operators`: the names of this library's +I/O operators the node calls. + +This is what makes a statically compiled deployment possible: JuliaC's `--trim` needs the node +*defined in a package*, so that precompiling that package compiles the node and serializes the +executable into its image (see [`export_program_juliac`](@ref)). A module `eval`'d into +SynchToolkit at runtime has no image to be serialized into. + +Two things have to be done by hand here, because SynchToolkit has no entry point that returns +the generated code unevaluated (JuliaComputing/SynchToolkit.jl#160): + + - `stkcompile`'s prologue is reproduced, stopping after `do_codegen`, before the node is + macroexpanded and the module `eval`'d. The node is pushed as a plain declaration holding + the *unexpanded* `SynchJulia.@node `: `NodeDeclaration` macroexpands it in + SynchToolkit's own scope, which neither round-trips through `string` nor resolves in the + consumer's module. Assembly is left to `codegen_module` so declaration ordering stays + SynchToolkit's business (JuliaComputing/SynchToolkit.jl#157). + - References to this library's operators come out fully qualified + (`QuanserComponents.hw_measure`), which would make the emitted package depend on + QuanserComponents and pull the whole modelling stack into the binary. They are rewritten to + bare names, which the emitted package then defines itself as `ccall`s into the same + `csrc/` implementations. +""" +function compile_program_source(ctor; kwargs...) + sig = program_signature(ctor; kwargs...) + STK = SynchToolkit + state = STK.TearingState(STK.expand_connections(sig.sys)) + ci = STK.MTKTearing.infer_clocks!(STK.MTKTearing.ClockInference(state)) + tss, _, continuous_id, id_to_clock = STK.MTKTearing.split_system(ci) + continuous_id == 0 || + error("compile_program_source: only purely discrete systems can be code-generated") + clocked = STK.collect_clocked(tss, ci, id_to_clock) + SynchJulia.backend!(:julia) + _, result, _, rtmod = STK.do_codegen( + sig.sys, tss, ci, clocked.original_eqs, clocked.eq_clocks, clocked.original_ieqs, + clocked.var_clocks, sig.inputs, sig.outs) + push!(rtmod.declarations, + STK.ExprDeclaration(:(SynchJulia.@node $(STK.to_sj(result.sj_node))))) + body = STK.codegen_module(rtmod).args[end]::Expr + operators = Set{Symbol}() + decls = Expr[_localize_operators(ex, operators) for ex in body.args if ex isa Expr] + return (; decls, operators = sort!(collect(operators)), sig.tuning_defaults, sig.log, + sig.traj, sig.Ts) +end + +# Rewrite this library's operators into bare names, collecting the names rewritten. The emitted +# package defines them itself (see `emit_program_ffi`), so the generated node calls into the +# same `csrc/` implementations without depending on this package. +# +# Codegen splices the operator *functions themselves* into the expression tree, and printing +# one qualifies it (`QuanserComponents.hw_measure`), so a bare `string` of the tree would only +# be loadable where this package is. Base functions the node also calls (`sin`, `clamp`, ...) +# print unqualified and resolve anywhere, hence the parent-module test rather than a name list. +# `GlobalRef`s are handled too, in case a later SynchToolkit emits those instead. +function _localize_operators(ex, operators::Set{Symbol}) + if ex isa Function && parentmodule(ex) === @__MODULE__ + push!(operators, nameof(ex)) + return nameof(ex) + elseif ex isa GlobalRef && ex.mod === @__MODULE__ + push!(operators, ex.name) + return ex.name + end + ex isa Expr || return ex + return Expr(ex.head, Any[_localize_operators(a, operators) for a in ex.args]...) +end + # --------------------------------------------------------------------------- ## The runtime # --------------------------------------------------------------------------- @@ -237,8 +320,10 @@ log_file(c::ProgramRuntime) = c.log.file # `SynchExecutable`/`node` take one, and each does its own `invoke_in_world` inside # (SynchToolkit#159). Hence no `@invokelatest` and no single-crossing contract to honour -- # that used to be this function's whole shape. -function instantiate(gen; gains = (;), backend::Union{Nothing, Symbol} = nothing, - export_dir = nothing) +# The program's runtime-settable values: the model's own, with `gains` overriding field by +# field. Shared by the in-process/C path (`instantiate`, which hands them to the generated +# constructor) and the JuliaC path (`export_program_juliac`, which writes them out as literals). +function tuning_values(gen; gains = (;)) vals = OrderedDict{Symbol, Any}(gen.tuning_defaults) for (field, v) in pairs(gains) v === nothing && continue @@ -247,6 +332,12 @@ function instantiate(gen; gains = (;), backend::Union{Nothing, Symbol} = nothing ($(join(keys(vals), ", ")))")) vals[field] = vals[field] isa AbstractVector ? collect(float.(v)) : float(v) end + return vals +end + +function instantiate(gen; gains = (;), backend::Union{Nothing, Symbol} = nothing, + export_dir = nothing) + vals = tuning_values(gen; gains) cn = gen.compiled g = gen.tuning_struct(cn; vals...) # Pass the static struct: AutoPars defaults may be expressions of its fields. diff --git a/test/runtests.jl b/test/runtests.jl index 6ac3ed2..1ee687e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,6 +8,7 @@ using MultibodyComponents # using DiscreteComponents using SynchToolkit using LinearAlgebra +using Libdl: dlext using Statistics: cor using DelimitedFiles: readdlm using Printf: @sprintf @@ -268,8 +269,10 @@ import DyadCompilerPasses # struct, colliding with the dispatching function of that name in analysis_base.jl. @test length(methods(DI.run_analysis, (QuanserComponents.FurutaIdentificationBaseSpec,))) == 1 - for base in ("FurutaSwingupBase", "FurutaFrictionBase", "FurutaIdentificationBase", - "QubeHardwareRunBase") + @test length(methods(DI.run_analysis, + (QuanserComponents.FurutaSwingupJuliaCBaseSpec,))) == 1 + for base in ("FurutaSwingupBase", "FurutaSwingupJuliaCBase", "FurutaFrictionBase", + "FurutaIdentificationBase", "QubeHardwareRunBase") @test !isfile(joinpath(pkgdir(QuanserComponents), "generated", "$(base)_definition.jl")) end @@ -357,6 +360,93 @@ import DyadCompilerPasses end end + # ---- FurutaSwingupJuliaC: the same program as a trimmed binary ----------- + # Emits a Julia application package instead of C and compiles it with JuliaC. The build + # is gated on the toolchain; what runs unconditionally is the emission, which is where + # everything specific to this target lives. + @testset "FurutaSwingupJuliaC analysis" begin + DI = QuanserComponents.DyadInterface + dir = mktempdir() + # build=false: the analysis defaults to build=true, which shells out to JuliaC and + # takes minutes. + @time sol = QuanserComponents.FurutaSwingupJuliaC(; output_dir = dir, Ts, + build = false, run = false) + app = joinpath(dir, "FurutaSwingupApp") + @test sol.app_dir == app && sol.exe === nothing && sol.buildlog === nothing + @test length(sol.L) == 4 && all(isfinite, sol.L) + for f in ("Project.toml", "README.md", joinpath("src", "FurutaSwingupApp.jl"), + joinpath("src", "controller.jl"), joinpath("src", "hardware_ffi.jl"), + joinpath("csrc", "qube_hw.c"), joinpath("csrc", "qube_log.c")) + @test isfile(joinpath(app, f)) + end + # The C the node calls into is built next to the application, not borrowed from this + # package, so the binary does not reach back into the depot. + @test isfile(joinpath(app, "deps", "libqube_hw." * dlext)) + @test isfile(joinpath(app, "deps", "libqube_log." * dlext)) + + # The node is emitted as source, so it has to round-trip through the parser. Syntax + # errors come back as `:error`/`:incomplete` nodes rather than throwing. + function parses_cleanly(src) + bad = false + walk(x) = x isa Expr && + (x.head in (:error, :incomplete) ? (bad = true) : foreach(walk, x.args)) + walk(Meta.parseall(src)) + return !bad + end + ctrl_src = read(joinpath(app, "src", "controller.jl"), String) + app_src = read(joinpath(app, "src", "FurutaSwingupApp.jl"), String) + ffi_src = read(joinpath(app, "src", "hardware_ffi.jl"), String) + @test all(parses_cleanly, (ctrl_src, app_src, ffi_src)) + @test occursin("SynchJulia.@node function top(", ctrl_src) + @test occursin("mutable struct TuningGains", ctrl_src) + @test occursin("mutable struct AutoPars", ctrl_src) + # The operators the node calls must be local to the application: a qualified + # reference would make it depend on this package, and so drag the modelling stack + # into the binary. Only the header comment may name QuanserComponents. + @test !occursin("QuanserComponents.hw_", ctrl_src) + @test !occursin("QuanserComponents.log_", ctrl_src) + for op in ("hw_measure", "hw_write", "log_row") + @test occursin("$op(", ctrl_src) # called by the node + @test occursin("$op(", ffi_src) # and defined by the application + end + # The tuned gain and the trim-critical settings are baked in. + @test occursin(string(sol.L[1]), app_src) + @test occursin("const EXE = SynchExecutable(", app_src) + @test occursin("compilation_enabled!(false)", app_src) + @test occursin("dynamic_execution = false", read(joinpath(app, "Project.toml"), String)) + + md = DI.AnalysisSolutionMetadata(sol) + @test any(a -> a.name === :GeneratedFiles, md.artifacts) + @test !any(a -> a.name === :BuildLog, md.artifacts) + tbl = DI.artifacts(sol, :GeneratedFiles) + @test Set(tbl.file) == Set(sol.files) && all(>(0), tbl.bytes) + @test_throws ArgumentError DI.artifacts(sol, :BuildLog) + @test_throws ArgumentError DI.artifacts(sol, :RunLog) + @test_throws ArgumentError DI.artifacts(sol, :Nonexistent) + + # Where Julia >= 1.13 and JuliaC are installed, the application must actually build + # with `--trim=safe` — the property the whole shape of it exists to satisfy. + if QuanserComponents.juliac_available() + exe = QuanserComponents.build_program_juliac(app) + @test isfile(exe) + log = read(joinpath(dirname(app), "build.log"), String) + @test !occursin("Verifier error", log) + # The C toolchain is only a SynchCompiler weak dependency and must not be dragged + # into the bundle (as SynchJulia's own test/caching/trim.jl checks). + leftovers = [joinpath(r, n) for (r, ds, fs) in walkdir(dirname(dirname(exe))) + for n in Iterators.flatten((ds, fs)) if occursin("clang", lowercase(n))] + @test isempty(leftovers) + # It starts, resolves its own libraries and reports the device it cannot open + # (this host has no HIL SDK, so `qube_hw_open` fails by design). + out = IOBuffer() + ok = success(pipeline(Cmd(`$exe`; dir = dirname(exe)); stdout = out, stderr = out)) + @test ok || occursin("could not open the device", String(take!(out))) + else + @info "skipping the JuliaC build (needs `juliaup add 1.13` and JuliaC in that \ + Julia's default environment)" + end + end + # ---- the swing-up program logs itself too -------------------------------- # The whole run log is written by the `DataLogger` inside the program, in the # `SWINGUP_LOG_COLUMNS` order: what was measured and applied, plus the loop diagnostics